fix(cli): refuse listener flags for an existing deployment before any call

This commit is contained in:
Hugo Durand
2026-09-22 15:27:09 -04:00
parent 904478cbec
commit f89bf66d84
2 changed files with 69 additions and 11 deletions
+22 -10
View File
@@ -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(
@@ -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