diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index d052af0a5..f73180419 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -1414,11 +1414,11 @@ def _call_host_backend_with_optional_tenant( "Find your workspace ID in LangSmith under Settings > Workspaces.", fg="yellow", ) - client._client.headers["X-Tenant-ID"] = click.prompt("Workspace ID") + client.set_tenant(click.prompt("Workspace ID")) prompted_for_tenant = True continue if err.status_code == 403 and "not enabled" in err.message.lower(): - smith_base = _smith_dashboard_base_url(client._base_url) + smith_base = _smith_dashboard_base_url(client.base_url) raise HostBackendError( "LangSmith Deployment is not enabled for this organization. " f"Enable it at {smith_base}/host/deployments" diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index 77e4e89aa..17e3a2822 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -24,10 +24,11 @@ class HostBackendClient: base_url: str, api_key: str, tenant_id: str | None = None, + *, + transport: httpx.BaseTransport | None = None, ): if not base_url: raise click.UsageError("Host backend URL is required") - transport = httpx.HTTPTransport(retries=3) headers: dict[str, str] = { "X-Api-Key": api_key, "Accept": "application/json", @@ -36,8 +37,9 @@ class HostBackendClient: headers["X-Tenant-ID"] = tenant_id self._base_url = base_url.rstrip("/") self._client = httpx.Client( + base_url=self._base_url, headers=headers, - transport=transport, + transport=transport or httpx.HTTPTransport(retries=3), timeout=30, ) @@ -45,6 +47,9 @@ class HostBackendClient: def base_url(self) -> str: return self._base_url + def set_tenant(self, tenant_id: str) -> None: + self._client.headers["X-Tenant-ID"] = tenant_id + def _request( self, method: str, @@ -53,8 +58,7 @@ class HostBackendClient: params: dict[str, Any] | None = None, ) -> Any: try: - full_url = self._base_url + path - resp = self._client.request(method, full_url, json=payload, params=params) + resp = self._client.request(method, path, json=payload, params=params) resp.raise_for_status() except httpx.HTTPStatusError as err: detail = err.response.text or str(err.response.status_code) 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 76995ddb8..5f9f6ee1c 100644 --- a/libs/cli/tests/unit_tests/cli/test_deploy_command.py +++ b/libs/cli/tests/unit_tests/cli/test_deploy_command.py @@ -6,94 +6,96 @@ from dataclasses import dataclass, field from pathlib import Path import click.exceptions +import httpx import pytest from click.testing import CliRunner, Result import langgraph_cli.archive as archive_module import langgraph_cli.deploy as deploy_module from langgraph_cli.cli import cli -from langgraph_cli.host_backend import HostBackendError +from langgraph_cli.host_backend import HostBackendClient CONTROL_PLANE_URL = "https://control-plane.example.com" REGISTRY_URL = "https://registry.example.com/team" PUSH_TOKEN = "push-token" PUSHED_IMAGE = "registry.example.com/team/my-app:latest" PUSHED_DIGEST = "registry.example.com/team/my-app@sha256:abc123" -CREATED_DEPLOYMENT_ID = "dep-created" +CREATED_ID = "dep-created" TRACKED_PACKAGES = ["langgraph:1.0.0"] SIGNED_UPLOAD_URL = "https://storage.example.com/signed" ARCHIVE = ("/tmp/src.tgz", 2048, "langgraph.json") OBJECT_PATH = "tarballs/src.tgz" PLATFORM_FORMAT = "{{.Os}}/{{.Architecture}}" DIGESTS_FORMAT = "{{json .RepoDigests}}" +NOT_A_CLI_DEPLOYMENT = ( + "push token is only available for 'internal_docker' source deployments" +) +LIST_DEPLOYMENTS = "GET /v2/deployments" +CREATE_DEPLOYMENT = "POST /v2/deployments" + + +def _push_token(deployment_id: str) -> str: + return f"POST /v2/deployments/{deployment_id}/push-token" + + +def _upload_url(deployment_id: str) -> str: + return f"POST /v2/deployments/{deployment_id}/upload-url" + + +def _patch(deployment_id: str) -> str: + return f"PATCH /v2/deployments/{deployment_id}" @dataclass class ControlPlaneDouble: timeline: list[str] existing_deployments: list[dict] = field(default_factory=list) - push_token_error: HostBackendError | None = None - payloads: dict[str, dict] = field(default_factory=dict) + push_token_status: int = 200 + bodies: dict[str, dict] = field(default_factory=dict) - def record(self, method: str, **payload: object) -> None: - self.timeline.append(method) - self.payloads[method] = payload + def handle(self, request: httpx.Request) -> httpx.Response: + route = f"{request.method} {request.url.path}" + self.timeline.append(route) + if request.content: + self.bodies[route] = json.loads(request.content) + return self._respond(request.method, request.url.path) - def client_class(self) -> type: - double = self + def _respond(self, method: str, path: str) -> httpx.Response: + if (method, path) == ("GET", "/v2/deployments"): + return httpx.Response(200, json={"resources": self.existing_deployments}) + if (method, path) == ("POST", "/v2/deployments"): + return httpx.Response(201, json={"id": CREATED_ID}) + if path.endswith("/push-token"): + if self.push_token_status != 200: + return httpx.Response(self.push_token_status, text=NOT_A_CLI_DEPLOYMENT) + return httpx.Response( + 200, json={"token": PUSH_TOKEN, "registry_url": REGISTRY_URL} + ) + if path.endswith("/upload-url"): + return httpx.Response( + 200, json={"upload_url": SIGNED_UPLOAD_URL, "object_path": OBJECT_PATH} + ) + if method == "PATCH": + return httpx.Response(200, json={"tenant_id": "tenant-1"}) + if method == "GET": + deployment_id = path.rsplit("/", 1)[-1] + return httpx.Response( + 200, + json=next( + d for d in self.existing_deployments if d["id"] == deployment_id + ), + ) + raise AssertionError(f"unexpected control plane call: {method} {path}") - class FakeHostBackendClient: - def __init__( - self, host_url: str, api_key: str, tenant_id: str | None = None - ) -> None: - self.base_url = host_url + def client_factory(self) -> Callable[..., HostBackendClient]: + transport = httpx.MockTransport(self.handle) - def list_deployments(self, name_contains: str = "") -> dict: - double.record("list_deployments", name_contains=name_contains) - return {"resources": double.existing_deployments} + def make( + host_url: str, api_key: str, tenant_id: str | None = None + ) -> HostBackendClient: + return HostBackendClient(host_url, api_key, tenant_id, transport=transport) - def get_deployment(self, deployment_id: str) -> dict: - double.record("get_deployment", deployment_id=deployment_id) - return next( - d for d in double.existing_deployments if d["id"] == deployment_id - ) - - def create_deployment(self, **payload: object) -> dict: - double.record("create_deployment", **payload) - return {"id": CREATED_DEPLOYMENT_ID} - - def request_push_token(self, deployment_id: str) -> dict: - double.record("request_push_token", deployment_id=deployment_id) - if double.push_token_error is not None: - raise double.push_token_error - return {"token": PUSH_TOKEN, "registry_url": REGISTRY_URL} - - def update_deployment( - self, deployment_id: str, image_uri: str, **payload: object - ) -> dict: - double.record( - "update_deployment", - deployment_id=deployment_id, - image_uri=image_uri, - **payload, - ) - return {"tenant_id": "tenant-1"} - - def request_upload_url(self, deployment_id: str) -> dict: - double.record("request_upload_url", deployment_id=deployment_id) - return {"upload_url": SIGNED_UPLOAD_URL, "object_path": OBJECT_PATH} - - def update_deployment_internal_source( - self, deployment_id: str, **payload: object - ) -> dict: - double.record( - "update_deployment_internal_source", - deployment_id=deployment_id, - **payload, - ) - return {"tenant_id": "tenant-1"} - - return FakeHostBackendClient + return make @dataclass @@ -136,7 +138,7 @@ class DockerDouble: self.builds.append( { "tag": tag, - "docker_command": docker_command, + "docker_command": tuple(docker_command or ("docker", "build")), "extra_flags": tuple(extra_flags), } ) @@ -225,7 +227,7 @@ def deploy_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DeployPro monkeypatch.setattr(deploy_module, "_no_input", False) monkeypatch.setattr(deploy_module, "_emitter", None) monkeypatch.setattr( - deploy_module, "HostBackendClient", control_plane.client_class() + deploy_module, "HostBackendClient", control_plane.client_factory() ) monkeypatch.setattr(deploy_module, "build_docker_image", docker.build_docker_image) monkeypatch.setattr(deploy_module, "subp_exec", docker.subp_exec) @@ -249,15 +251,15 @@ def test_first_local_deploy_creates_then_builds_pushes_and_updates_in_order( assert result.exit_code == 0, result.output assert deploy_project.timeline == [ - "list_deployments", - "create_deployment", + LIST_DEPLOYMENTS, + CREATE_DEPLOYMENT, "docker build", - "request_push_token", + _push_token(CREATED_ID), "docker login", "docker tag", "docker push", "docker inspect-digest", - "update_deployment", + _patch(CREATED_ID), ] assert "Deployment updated" in result.output @@ -267,11 +269,11 @@ def test_first_local_deploy_creates_an_internal_docker_deployment( ) -> None: deploy_project.run("--no-remote") - assert deploy_project.control_plane.payloads["create_deployment"] == { + assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT] == { "name": "my-app", - "deployment_type": "dev", "source": "internal_docker", - "config_path": None, + "source_config": {"deployment_type": "dev"}, + "source_revision_config": {}, "secrets": [], } @@ -285,14 +287,19 @@ def test_first_local_deploy_creates_an_internal_docker_deployment( ("--platform", "linux/amd64", "--load", "--progress=quiet"), id="apple_silicon_cross_builds_for_linux_amd64", ), - pytest.param("x86_64", None, (), id="amd64_host_uses_plain_docker_build"), + pytest.param( + "x86_64", + ("docker", "build"), + (), + id="amd64_host_uses_plain_docker_build", + ), ], ) def test_local_build_targets_linux_amd64( deploy_project: DeployProject, monkeypatch: pytest.MonkeyPatch, machine: str, - expected_command: tuple[str, ...] | None, + expected_command: tuple[str, ...], expected_flags: tuple[str, ...], ) -> None: monkeypatch.setattr(deploy_module.platform, "machine", lambda: machine) @@ -344,9 +351,9 @@ def test_local_deploy_records_the_pushed_digest_and_tracked_packages( ) -> None: deploy_project.run("--no-remote") - assert deploy_project.control_plane.payloads["update_deployment"] == { - "deployment_id": CREATED_DEPLOYMENT_ID, - "image_uri": PUSHED_DIGEST, + assert deploy_project.control_plane.bodies[_patch(CREATED_ID)] == { + "revision_source": "internal_docker", + "source_revision_config": {"image_uri": PUSHED_DIGEST}, "secrets": [], "tracked_packages": TRACKED_PACKAGES, } @@ -402,7 +409,7 @@ def test_three_failed_pushes_abort_before_the_deployment_is_updated( result = deploy_project.run("--no-remote") assert result.exit_code != 0 - assert "update_deployment" not in deploy_project.timeline + assert _patch(CREATED_ID) not in deploy_project.timeline def test_existing_deployment_matched_by_exact_name_is_updated_not_created( @@ -415,11 +422,8 @@ def test_existing_deployment_matched_by_exact_name_is_updated_not_created( deploy_project.run("--no-remote") - assert "create_deployment" not in deploy_project.timeline - assert ( - deploy_project.control_plane.payloads["update_deployment"]["deployment_id"] - == "dep-existing" - ) + assert CREATE_DEPLOYMENT not in deploy_project.timeline + assert _patch("dep-existing") in deploy_project.timeline def test_deployment_not_created_by_the_cli_gets_an_actionable_error( @@ -428,10 +432,7 @@ def test_deployment_not_created_by_the_cli_gets_an_actionable_error( deploy_project.control_plane.existing_deployments = [ {"id": "dep-ui", "name": "my-app"} ] - deploy_project.control_plane.push_token_error = HostBackendError( - "push token is only available for 'internal_docker' source deployments", - status_code=400, - ) + deploy_project.control_plane.push_token_status = 400 result = deploy_project.run("--no-remote") @@ -447,26 +448,25 @@ def test_remote_build_creates_an_internal_source_deployment_and_uploads_the_arch assert result.exit_code == 0, result.output assert deploy_project.timeline == [ - "list_deployments", - "create_deployment", + LIST_DEPLOYMENTS, + CREATE_DEPLOYMENT, "create_archive", - "request_upload_url", + _upload_url(CREATED_ID), "upload_archive", - "update_deployment_internal_source", + _patch(CREATED_ID), ] - assert deploy_project.control_plane.payloads["create_deployment"]["source"] == ( + assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source"] == ( "internal_source" ) assert deploy_project.uploads == [(SIGNED_UPLOAD_URL, ARCHIVE[0], ARCHIVE[1])] - assert deploy_project.control_plane.payloads[ - "update_deployment_internal_source" - ] == { - "deployment_id": CREATED_DEPLOYMENT_ID, - "source_tarball_path": OBJECT_PATH, - "config_path": ARCHIVE[2], + assert deploy_project.control_plane.bodies[_patch(CREATED_ID)] == { + "revision_source": "internal_source", + "source_revision_config": { + "source_tarball_path": OBJECT_PATH, + "langgraph_config_path": ARCHIVE[2], + }, + "source_config": {"install_command": "yarn install"}, "secrets": [], - "install_command": "yarn install", - "build_command": None, "tracked_packages": TRACKED_PACKAGES, } assert "Build triggered" in result.output diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index d93eb0d25..f88ed1731 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -259,22 +259,18 @@ class TestEnvWithoutDeploymentName: class TestCallHostBackendWithOptionalTenant: def _make_client(self, handler): - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", + c = HostBackendClient( + "https://api.example.com", + "test-key", transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, ) return c def _make_eu_client(self, handler): - c = HostBackendClient("https://eu.api.host.langchain.com", "test-key") - c._client = httpx.Client( - base_url="https://eu.api.host.langchain.com", + c = HostBackendClient( + "https://eu.api.host.langchain.com", + "test-key", transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, ) return c @@ -334,7 +330,6 @@ class TestCallHostBackendWithOptionalTenant: assert exc_info.value.status_code == 403 assert "smith.langchain.com" in exc_info.value.message assert seen_tenant_ids == [None, "workspace-123"] - assert client._client.headers["X-Tenant-ID"] == "workspace-123" def test_other_403_re_raises_original(self): client = self._make_client( diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index 3994f17ac..c373fbb1b 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -13,12 +13,8 @@ def mock_transport(): @pytest.fixture def client(mock_transport): - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=mock_transport, - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com", "test-key", transport=mock_transport ) return c @@ -39,12 +35,8 @@ def test_request_sends_headers(): assert req.headers["accept"] == "application/json" return httpx.Response(200, json={"ok": True}) - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com", "test-key", transport=httpx.MockTransport(handler) ) result = c._request("GET", "/test") assert result == {"ok": True} @@ -56,12 +48,8 @@ def test_request_sends_json_payload(): assert req.content == b'{"key":"value"}' return httpx.Response(200, json={"created": True}) - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com", "test-key", transport=httpx.MockTransport(handler) ) result = c._request("POST", "/test", {"key": "value"}) assert result == {"created": True} @@ -69,25 +57,13 @@ def test_request_sends_json_payload(): def test_request_empty_body_returns_none(): transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b"")) - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=transport, - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, - ) + c = HostBackendClient("https://api.example.com", "test-key", transport=transport) assert c._request("DELETE", "/test") is None def test_request_http_error_raises(): transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found")) - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=transport, - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, - ) + c = HostBackendClient("https://api.example.com", "test-key", transport=transport) with pytest.raises(HostBackendError, match="404"): c._request("GET", "/missing") @@ -96,13 +72,7 @@ def test_request_invalid_json_raises(): transport = httpx.MockTransport( lambda req: httpx.Response(200, content=b"not json") ) - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=transport, - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, - ) + c = HostBackendClient("https://api.example.com", "test-key", transport=transport) with pytest.raises(HostBackendError, match="Failed to decode"): c._request("GET", "/bad-json") @@ -111,12 +81,8 @@ def test_request_transport_error_raises(): def handler(req: httpx.Request) -> httpx.Response: raise httpx.ConnectError("connection refused") - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com", "test-key", transport=httpx.MockTransport(handler) ) with pytest.raises(HostBackendError, match="connection refused"): c._request("GET", "/test") @@ -145,12 +111,8 @@ def test_list_deployments_sends_query_params(): assert req.url.params["name_contains"] == "my app" return httpx.Response(200, json={"ok": True}) - c = HostBackendClient("https://api.example.com", "test-key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "test-key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com", "test-key", transport=httpx.MockTransport(handler) ) result = c.list_deployments("my app") assert result == {"ok": True} @@ -214,34 +176,13 @@ def test_update_deployment_external_omits_tracked_packages_when_absent(): assert "revision_source" not in body -def test_request_builds_full_url_with_path_prefix(): - """base_url with a path prefix (/api-host) must not be dropped when paths start with /.""" - - def handler(req: httpx.Request) -> httpx.Response: - assert str(req.url) == "https://smith.example.com/api-host/v2/deployments" - return httpx.Response(200, json={"ok": True}) - - c = HostBackendClient("https://smith.example.com/api-host", "key") - c._client = httpx.Client( - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "key", "Accept": "application/json"}, - timeout=30, - ) - result = c._request("GET", "/v2/deployments") - assert result == {"ok": True} - - def _capturing_client(captured: dict) -> HostBackendClient: def handler(req: httpx.Request) -> httpx.Response: captured["body"] = req.read() return httpx.Response(200, json={"ok": True}) - c = HostBackendClient("https://api.example.com", "key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com", "key", transport=httpx.MockTransport(handler) ) return c @@ -315,12 +256,8 @@ def test_get_deploy_logs_all_revisions(): assert "/revisions/" not in str(req.url) return httpx.Response(200, json={"logs": [{"message": "running"}]}) - c = HostBackendClient("https://api.example.com", "key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com", "key", transport=httpx.MockTransport(handler) ) result = c.get_deploy_logs("proj-1", {"limit": 10}) assert result == {"logs": [{"message": "running"}]} @@ -331,12 +268,8 @@ def test_get_deploy_logs_specific_revision(): assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url) return httpx.Response(200, json={"logs": []}) - c = HostBackendClient("https://api.example.com", "key") - c._client = httpx.Client( - base_url="https://api.example.com", - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com", "key", transport=httpx.MockTransport(handler) ) result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2") assert result == {"logs": []} @@ -348,12 +281,8 @@ def _routing_client(seen: dict) -> HostBackendClient: seen["url"] = str(req.url) return httpx.Response(200, json={"ok": True}) - c = HostBackendClient("https://api.example.com/prefix", "key") - c._client = httpx.Client( - base_url="https://api.example.com/prefix", - transport=httpx.MockTransport(handler), - headers={"X-Api-Key": "key", "Accept": "application/json"}, - timeout=30, + c = HostBackendClient( + "https://api.example.com/prefix", "key", transport=httpx.MockTransport(handler) ) return c @@ -535,3 +464,24 @@ def test_request_targets_control_plane_route_under_base_url(call, method, route) method, f"https://api.example.com/prefix{route}", ) + + +def test_injected_transport_receives_requests_under_the_prefixed_base_url(): + seen: dict = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen["url"] = str(req.url) + seen["api_key"] = req.headers["x-api-key"] + return httpx.Response(200, json={"ok": True}) + + c = HostBackendClient( + "https://smith.example.com/api-host", + "key", + transport=httpx.MockTransport(handler), + ) + + assert c.list_revisions("dep-1", limit=2) == {"ok": True} + assert seen == { + "url": "https://smith.example.com/api-host/v2/deployments/dep-1/revisions?limit=2", + "api_key": "key", + }