mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-26 03:25:06 +02:00
feat(cli): place self-hosted deployments on a listener (#9056)
Follow-up to #8482. `langgraph deploy --push-to` can now create a deployment in a workspace that deploys through a listener in the customer's own cluster, which is the hybrid case. Before this, creation in such a workspace was impossible from the CLI: the control plane rejected it and the CLI told the user to go and create the deployment in the UI first. ## Changes - Smart Auto-Placement: The CLI now proactively checks your workspace. If you only have one listener and one Kubernetes namespace configured (and are using the managed cloud control plane), it automatically routes your deployment there. No extra flags needed. - New Disambiguation Flags: If your workspace has multiple listeners or namespaces, the CLI will ask you to choose. You can now pass --listener-id and --k8s-namespace to tell it exactly where to deploy. - Failing Fast: The CLI now validates your listener and namespace choices before it starts building and pushing the heavy Docker image. If you provide an invalid ID, it stops immediately instead of wasting your time and bandwidth. - Fixed a Duplication Bug: Previously, if you had many deployments with similar names, a pagination issue could hide your existing deployment from the CLI, causing it to accidentally create a duplicate. The CLI now queries the server for the exact deployment name to guarantee this doesn't happen. - Cleaner Errors: Error messages from the control plane are now stripped of their clunky HTTP envelopes so you get clear, readable sentences when something goes wrong. ## Testing Deployment on 3 paths, hybrid, self-hosted, nominal
This commit is contained in:
@@ -79,19 +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)
|
||||
)
|
||||
result = c.list_deployments("my app")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def _capturing_client(captured: dict) -> HostBackendClient:
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = req.read()
|
||||
@@ -421,7 +408,7 @@ def test_injected_transport_receives_requests_under_the_prefixed_base_url():
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
assert c.list_revisions("dep-1", limit=2) == {"ok": True}
|
||||
assert c.list_revisions("dep-1", limit=2) == []
|
||||
assert seen == {
|
||||
"url": "https://smith.example.com/api-host/v2/deployments/dep-1/revisions?limit=2",
|
||||
"api_key": "key",
|
||||
@@ -546,3 +533,144 @@ def test_control_plane_endpoints_resolve(host_url, langsmith_endpoint, expected)
|
||||
endpoints = ControlPlaneEndpoints.resolve(host_url, langsmith_endpoint)
|
||||
|
||||
assert (endpoints.control_plane_url, endpoints.dashboard_url) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{"resources": [{"id": "a"}, {"id": "b"}]},
|
||||
[{"id": "a"}, {"id": "b"}],
|
||||
id="list_returns_the_resources",
|
||||
),
|
||||
pytest.param({"resources": []}, [], id="empty_list"),
|
||||
pytest.param({}, [], id="missing_key"),
|
||||
pytest.param({"resources": None}, [], id="null_resources"),
|
||||
pytest.param(
|
||||
{"resources": ["nope", {"id": "a"}]}, [{"id": "a"}], id="skips_non_objects"
|
||||
),
|
||||
pytest.param([], [], id="unexpected_envelope"),
|
||||
],
|
||||
)
|
||||
def test_list_endpoints_return_resource_objects(payload, expected):
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
assert c.list_deployments() == expected
|
||||
|
||||
|
||||
def test_list_listeners_asks_for_a_full_page():
|
||||
seen: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(req.url)
|
||||
return httpx.Response(200, json={"resources": [{"id": "listener-1"}]})
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
assert c.list_listeners() == [{"id": "listener-1"}]
|
||||
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("control_plane_url", "expected"),
|
||||
[
|
||||
pytest.param("https://api.host.langchain.com", True, id="cloud"),
|
||||
pytest.param("https://eu.api.host.langchain.com", True, id="cloud_region"),
|
||||
pytest.param("https://dev.api.host.langchain.com", True, id="cloud_dev"),
|
||||
pytest.param("https://smith.example.com/api-host", False, id="self_hosted"),
|
||||
pytest.param(
|
||||
"https://corp.example.com/langsmith/api-host",
|
||||
False,
|
||||
id="self_hosted_prefix",
|
||||
),
|
||||
pytest.param("http://localhost:8080/api-host", False, id="local"),
|
||||
pytest.param(
|
||||
"https://evil-api.host.langchain.com", False, id="lookalike_needs_a_dot"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_cloud_recognises_the_managed_control_plane(control_plane_url, expected):
|
||||
endpoints = ControlPlaneEndpoints.from_control_plane_url(control_plane_url)
|
||||
|
||||
assert endpoints.is_cloud is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected_params"),
|
||||
[
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(name="agent"),
|
||||
{"name": "agent"},
|
||||
id="exact_name_filters_server_side",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(name_contains="age"),
|
||||
{"name_contains": "age"},
|
||||
id="substring_search_keeps_its_own_parameter",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(),
|
||||
{},
|
||||
id="no_filter_sends_no_parameters",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(
|
||||
name="agent", name_contains="agent", limit=100
|
||||
),
|
||||
{"name": "agent", "name_contains": "agent", "limit": "100"},
|
||||
id="both_filters_travel_together_for_older_servers",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_list_deployments_sends_one_name_filter(call, expected_params):
|
||||
seen: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen.update(dict(req.url.params))
|
||||
return httpx.Response(200, json={"resources": []})
|
||||
|
||||
call(
|
||||
HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
)
|
||||
|
||||
assert seen == expected_params
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{"detail": "Source configuration error: bad listener"},
|
||||
"Source configuration error: bad listener",
|
||||
id="fastapi_detail_is_unwrapped",
|
||||
),
|
||||
pytest.param(
|
||||
{"detail": {"loc": ["body"], "msg": "nope"}},
|
||||
None,
|
||||
id="a_structured_detail_is_left_alone",
|
||||
),
|
||||
pytest.param({"other": "shape"}, None, id="an_unknown_shape_is_left_alone"),
|
||||
],
|
||||
)
|
||||
def test_error_detail_is_readable(body, expected):
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"key",
|
||||
transport=httpx.MockTransport(lambda req: httpx.Response(400, json=body)),
|
||||
)
|
||||
|
||||
with pytest.raises(HostBackendError) as error:
|
||||
c.get_deployment("dep-1")
|
||||
|
||||
assert error.value.detail == expected
|
||||
if expected is not None:
|
||||
assert error.value.message.endswith(expected)
|
||||
|
||||
Reference in New Issue
Block a user