From f89bf66d84c3f322d25e6ef56456a8dae80e6c26 Mon Sep 17 00:00:00 2001 From: Hugo Durand Date: Tue, 22 Sep 2026 15:27:09 -0400 Subject: [PATCH] fix(cli): refuse listener flags for an existing deployment before any call --- libs/cli/langgraph_cli/deploy.py | 32 +++++++++---- .../unit_tests/cli/test_deploy_command.py | 48 ++++++++++++++++++- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index d3a27e48b..e561585cf 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -26,6 +26,7 @@ from langgraph_cli.dependency_tracking import find_tracked_packages from langgraph_cli.docker import build_docker_image, can_build_locally from langgraph_cli.exec import CommandRunner, Runner, subp_exec from langgraph_cli.host_backend import ( + MAX_PAGE_SIZE, ControlPlaneEndpoints, HostBackendClient, HostBackendError, @@ -103,6 +104,7 @@ _LOCAL_BUILD_TAG_PREFIX = "langgraph-deploy-tmp" _OPERATOR_DEFAULT_RESOURCE_SPEC: Mapping[str, object] = {} _CUSTOMER_REGISTRY_SOURCE: SourceName = "external_docker" _LISTENER_REQUIRED_MARKER = "listener_id' is required" +_LISTENERS_SHOWN = 10 _TERMINAL_STATUSES = frozenset( @@ -212,6 +214,15 @@ class RequestedPlacement: def requested(self) -> bool: return self.listener_id is not None or self.k8s_namespace is not None + def ensure_not_requested(self, deployment_id: str) -> None: + if self.requested: + raise click.UsageError( + "Listener and namespace are fixed when a deployment is created. " + f"Deployment {deployment_id} already exists, so drop --listener-id " + "and --k8s-namespace, or create a new deployment with a different " + "--name." + ) + def must_place(self, *, required: bool) -> bool: return required or self.requested @@ -264,11 +275,17 @@ class RequestedPlacement: def _describe_listeners(listeners: Sequence[Listener]) -> str: - return "\n".join( + shown = listeners[:_LISTENERS_SHOWN] + lines = [ f" {listener.id} cluster {listener.compute_id} " f"namespaces: {', '.join(listener.namespaces)}" - for listener in listeners - ) + for listener in shown + ] + if len(listeners) > len(shown): + lines.append(f" ... and {len(listeners) - len(shown)} more") + if len(listeners) == MAX_PAGE_SIZE: + lines.append(f" (the first {MAX_PAGE_SIZE} listeners are shown)") + return "\n".join(lines) @dataclass(frozen=True, slots=True) @@ -1497,6 +1514,7 @@ class CustomerRegistrySource: 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( @@ -1513,13 +1531,7 @@ class CustomerRegistrySource: self, ctx: DeployContext, existing: ExistingDeployment, step: int ) -> DeployOutcome: _ensure_customer_registry_source(existing) - if self.placement.requested: - raise click.UsageError( - f"Deployment {existing.id} already exists, and its listener and " - "namespace are fixed when the deployment is created. Drop " - "--listener-id and --k8s-namespace, or use a different --name to " - "create a new deployment." - ) + self.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( 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 29e6c26d7..04e053232 100644 --- a/libs/cli/tests/unit_tests/cli/test_deploy_command.py +++ b/libs/cli/tests/unit_tests/cli/test_deploy_command.py @@ -801,6 +801,24 @@ def test_updating_a_deployment_never_looks_up_listeners( assert LIST_LISTENERS not in deploy_project.timeline +def test_listener_flags_are_refused_for_a_deployment_id_without_any_call( + deploy_project: DeployProject, +) -> None: + result = deploy_project.run( + "--push-to", + PUSH_REPOSITORY, + "--deployment-id", + "dep-ext", + "--k8s-namespace", + "agents", + host_url=CLOUD_CONTROL_PLANE_URL, + ) + + assert result.exit_code != 0 + assert "fixed when a deployment is created" in result.output + assert deploy_project.timeline == [] + + def test_listener_flags_are_refused_on_an_existing_deployment( deploy_project: DeployProject, ) -> None: @@ -818,7 +836,7 @@ def test_listener_flags_are_refused_on_an_existing_deployment( ) assert result.exit_code != 0 - assert "fixed when the deployment is created" in result.output + assert "fixed when a deployment is created" in result.output assert deploy_project.docker.verbs() == [] @@ -868,3 +886,31 @@ def test_a_control_plane_that_demands_a_listener_names_the_flags( assert "--listener-id" in result.output assert "--k8s-namespace" in result.output assert "listener-1" in result.output + + +def test_listener_flags_without_push_to_make_no_call_at_all( + deploy_project: DeployProject, +) -> None: + result = deploy_project.run("--listener-id", "listener-1") + + assert result.exit_code != 0 + assert "--push-to" in result.output + assert deploy_project.timeline == [] + + +def test_a_truncated_listener_page_says_so(deploy_project: DeployProject) -> None: + deploy_project.control_plane.listeners = [ + { + "id": f"listener-{index}", + "compute_id": "cluster", + "compute_config": {"k8s_namespaces": ["agents"]}, + } + for index in range(100) + ] + + result = deploy_project.run( + "--push-to", PUSH_REPOSITORY, host_url=CLOUD_CONTROL_PLANE_URL + ) + + assert result.exit_code != 0 + assert "first 100" in result.output