From 8d3ad6474e09a30b16d56adccfeab787280a8183 Mon Sep 17 00:00:00 2001 From: Hugo Durand Date: Fri, 18 Sep 2026 13:44:04 -0400 Subject: [PATCH] refactor(cli): make the control plane client source-agnostic --- libs/cli/langgraph_cli/deploy.py | 17 ++- libs/cli/langgraph_cli/host_backend.py | 44 ++---- .../cli/tests/unit_tests/test_host_backend.py | 132 ++++++++++++------ 3 files changed, 107 insertions(+), 86 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 98c973972..690ed25a5 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -642,18 +642,18 @@ def _create_deployment( step: int, *, name: str, - deployment_type: str, source: str, - config_rel: str | None = None, - secrets: list[dict[str, str]] | None = None, + source_config: dict[str, object], + source_revision_config: dict[str, object], + secrets: list[dict[str, str]], ) -> tuple[str, int]: """Create a deployment and return its ID and next step number.""" _log_deploy_step(step, f"Creating deployment '{name}'") created = client.create_deployment( name=name, - deployment_type=deployment_type, source=source, - config_path=config_rel, + source_config=source_config, + source_revision_config=source_revision_config, secrets=secrets, ) created_id = created.get("id") if isinstance(created, dict) else None @@ -1108,6 +1108,7 @@ def _run_local_build( updated = client.update_deployment( deployment_id, resolved_image, + revision_source="internal_docker", secrets=secrets, tracked_packages=tracked_packages, ) @@ -1147,9 +1148,10 @@ def _run_external_deploy( ) _log_deploy_step(step, f"Updating deployment {deployment_id}") - updated = client.update_deployment_external( + updated = client.update_deployment( deployment_id, resolved_image, + revision_source=None, secrets=secrets, tracked_packages=tracked_packages, ) @@ -1730,8 +1732,9 @@ def _deploy_cmd( client, step, name=name, - deployment_type=deployment_type, source=source, + source_config={"deployment_type": deployment_type}, + source_revision_config={}, secrets=secrets, ) diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index af0441c4a..7f11b962d 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -153,21 +153,19 @@ class HostBackendClient: def create_deployment( self, + *, name: str, - deployment_type: str, source: str, - config_path: str | None = None, + source_config: dict[str, Any], + source_revision_config: dict[str, Any], secrets: list[dict[str, str]] | None = None, ) -> dict[str, Any]: - """Create a deployment.""" payload: dict[str, Any] = { "name": name, "source": source, - "source_config": {"deployment_type": deployment_type}, - "source_revision_config": {}, + "source_config": source_config, + "source_revision_config": source_revision_config, } - if source == "internal_source" and config_path: - payload["source_revision_config"]["langgraph_config_path"] = config_path if secrets is not None: payload["secrets"] = secrets return self._request("POST", "/v2/deployments", payload) @@ -202,43 +200,21 @@ class HostBackendClient: self, deployment_id: str, image_uri: str, + *, + revision_source: str | None, secrets: list[dict[str, str]] | None = None, tracked_packages: list[str] | None = None, ) -> dict[str, Any]: payload: dict[str, Any] = { - "revision_source": "internal_docker", "source_revision_config": {"image_uri": image_uri}, } + if revision_source is not None: + payload["revision_source"] = revision_source if tracked_packages: payload["tracked_packages"] = tracked_packages if secrets is not None: payload["secrets"] = secrets - return self._request( - "PATCH", - f"/v2/deployments/{deployment_id}", - payload, - ) - - def update_deployment_external( - self, - deployment_id: str, - image_uri: str, - secrets: list[dict[str, str]] | None = None, - tracked_packages: list[str] | None = None, - ) -> dict[str, Any]: - """Update a deployment with a pre-built external image.""" - payload: dict[str, Any] = { - "source_revision_config": {"image_uri": image_uri}, - } - if tracked_packages: - payload["tracked_packages"] = tracked_packages - if secrets is not None: - payload["secrets"] = secrets - return self._request( - "PATCH", - f"/v2/deployments/{deployment_id}", - payload, - ) + return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload) def update_deployment_internal_source( self, diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index b737ddba3..42d1de6a4 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -94,7 +94,10 @@ def test_request_transport_error_raises(): def test_create_deployment(client): result = client.create_deployment( - name="my-deploy", deployment_type="dev", source="internal_docker" + name="my-deploy", + source="internal_docker", + source_config={"deployment_type": "dev"}, + source_revision_config={}, ) assert result == {"ok": True} @@ -134,50 +137,19 @@ def test_request_push_token(client): def test_update_deployment(client): result = client.update_deployment( - "dep-123", "image:latest", secrets=[{"name": "KEY", "value": "val"}] + "dep-123", + "image:latest", + revision_source="internal_docker", + secrets=[{"name": "KEY", "value": "val"}], ) assert result == {"ok": True} def test_update_deployment_no_secrets(client): - result = client.update_deployment("dep-123", "image:latest") - assert result == {"ok": True} - - -def test_update_deployment_external(): - captured: dict = {} - c = _capturing_client(captured) - result = c.update_deployment_external( - "dep-123", "registry.example.com/app@sha256:abc123" + result = client.update_deployment( + "dep-123", "image:latest", revision_source="internal_docker" ) assert result == {"ok": True} - body = json.loads(captured["body"]) - assert "revision_source" not in body - assert body["source_revision_config"]["image_uri"] == ( - "registry.example.com/app@sha256:abc123" - ) - - -def test_update_deployment_external_forwards_tracked_packages(): - captured: dict = {} - c = _capturing_client(captured) - c.update_deployment_external( - "dep-123", - "registry.example.com/app:latest", - tracked_packages=["google-adk:1.0.0"], - ) - body = json.loads(captured["body"]) - assert body["tracked_packages"] == ["google-adk:1.0.0"] - assert "revision_source" not in body - - -def test_update_deployment_external_omits_tracked_packages_when_absent(): - captured: dict = {} - c = _capturing_client(captured) - c.update_deployment_external("dep-123", "registry.example.com/app:latest") - body = json.loads(captured["body"]) - assert "tracked_packages" not in body - assert "revision_source" not in body def _capturing_client(captured: dict) -> HostBackendClient: @@ -197,6 +169,7 @@ def test_update_deployment_forwards_tracked_packages(): c.update_deployment( "dep-123", "image:latest", + revision_source="internal_docker", tracked_packages=["google-adk:1.0.0"], ) body = json.loads(captured["body"]) @@ -207,7 +180,7 @@ def test_update_deployment_forwards_tracked_packages(): def test_update_deployment_omits_tracked_packages_when_absent(): captured: dict = {} c = _capturing_client(captured) - c.update_deployment("dep-123", "image:latest") + c.update_deployment("dep-123", "image:latest", revision_source="internal_docker") body = json.loads(captured["body"]) assert "tracked_packages" not in body @@ -296,7 +269,10 @@ def _routing_client(seen: dict) -> HostBackendClient: [ pytest.param( lambda c: c.create_deployment( - name="my-deploy", deployment_type="dev", source="internal_docker" + name="my-deploy", + source="internal_docker", + source_config={"deployment_type": "dev"}, + source_revision_config={}, ), { "name": "my-deploy", @@ -309,8 +285,9 @@ def _routing_client(seen: dict) -> HostBackendClient: pytest.param( lambda c: c.create_deployment( name="my-deploy", - deployment_type="prod", source="internal_docker", + source_config={"deployment_type": "prod"}, + source_revision_config={}, secrets=[{"name": "KEY", "value": "val"}], ), { @@ -325,9 +302,11 @@ def _routing_client(seen: dict) -> HostBackendClient: pytest.param( lambda c: c.create_deployment( name="my-deploy", - deployment_type="dev", source="internal_source", - config_path="apps/agent/langgraph.json", + source_config={"deployment_type": "dev"}, + source_revision_config={ + "langgraph_config_path": "apps/agent/langgraph.json" + }, ), { "name": "my-deploy", @@ -343,6 +322,7 @@ def _routing_client(seen: dict) -> HostBackendClient: lambda c: c.update_deployment( "dep-123", "registry.example.com/app@sha256:abc", + revision_source="internal_docker", secrets=[{"name": "KEY", "value": "val"}], ), { @@ -405,7 +385,10 @@ def test_request_body_matches_control_plane_contract(call, expected_body): [ pytest.param( lambda c: c.create_deployment( - name="n", deployment_type="dev", source="internal_docker" + name="n", + source="internal_docker", + source_config={"deployment_type": "dev"}, + source_revision_config={}, ), "POST", "/v2/deployments", @@ -424,7 +407,7 @@ def test_request_body_matches_control_plane_contract(call, expected_body): id="delete_deployment", ), pytest.param( - lambda c: c.update_deployment("dep-1", "img"), + lambda c: c.update_deployment("dep-1", "img", revision_source=None), "PATCH", "/v2/deployments/dep-1", id="patch_deployment", @@ -603,3 +586,62 @@ 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( + ("call", "expected_body"), + [ + pytest.param( + lambda c: c.create_deployment( + name="agent", + source="external_docker", + source_config={"resource_spec": {}}, + source_revision_config={ + "image_uri": "registry.example.com/agent@sha256:1" + }, + secrets=[], + ), + { + "name": "agent", + "source": "external_docker", + "source_config": {"resource_spec": {}}, + "source_revision_config": { + "image_uri": "registry.example.com/agent@sha256:1" + }, + "secrets": [], + }, + id="create_sends_the_source_configs_as_given", + ), + pytest.param( + lambda c: c.update_deployment( + "dep-1", "registry.example.com/agent@sha256:2", revision_source=None + ), + { + "source_revision_config": { + "image_uri": "registry.example.com/agent@sha256:2" + } + }, + id="revision_without_source_override_omits_revision_source", + ), + pytest.param( + lambda c: c.update_deployment( + "dep-1", + "registry.example.com/agent@sha256:2", + revision_source="internal_docker", + tracked_packages=["langgraph:1.0.0"], + ), + { + "revision_source": "internal_docker", + "source_revision_config": { + "image_uri": "registry.example.com/agent@sha256:2" + }, + "tracked_packages": ["langgraph:1.0.0"], + }, + id="revision_with_source_override_names_it", + ), + ], +) +def test_source_agnostic_client_calls_match_control_plane_contract(call, expected_body): + captured: dict = {} + call(_capturing_client(captured)) + assert json.loads(captured["body"]) == expected_body