diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 8b3ae47c6..1d9a69c55 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -1354,3 +1354,49 @@ def test_prepare_args_and_stdin_distributed_mode() -> None: assert "langgraph-executor:" in actual_stdin assert "FROM langchain/langgraph-executor:" in actual_stdin assert "executor_entrypoint.sh" in actual_stdin + + +def test_deploy_image_uri_rejects_incompatible_source(monkeypatch, tmp_path) -> None: + """--image-uri raises UsageError when applied to a non-external_docker deployment.""" + # --no-input sets the module-level _no_input global; ensure it's restored. + monkeypatch.setattr(deploy_module, "_no_input", False) + + config = tmp_path / "langgraph.json" + config.write_text('{"graphs": {"agent": "agent.py:graph"}, "dependencies": ["."]}') + + class FakeClient: + def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None): + self.base_url = host_url + + def get_deployment(self, deployment_id: str): + return { + "id": deployment_id, + "name": "test-deploy", + "source": "internal_docker", + "tenant_id": "tenant-1", + } + + monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "deploy", + "--api-key", + "test-key", + "--host-url", + "https://api.example.com", + "--deployment-id", + "dep-123", + "--image-uri", + "registry.example.com/app:latest", + "--config", + str(config), + "--no-input", + ], + ) + + assert result.exit_code != 0 + assert "different build mode" in result.output + assert "cannot be updated with --image-uri" 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 cdeb0d3f5..cf46209e6 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -543,6 +543,55 @@ class TestCreateHostBackendClientNoInput: assert client is not None +class TestCreateHostBackendClientEndpointFallback: + def test_langsmith_endpoint_env_var_used_as_fallback(self, monkeypatch): + monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") + monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1") + monkeypatch.delenv("LANGGRAPH_HOST_URL", raising=False) + client = _create_host_backend_client(host_url=None, api_key=None, env_vars={}) + assert client.base_url == "https://smith.example.com/api-host" + + def test_langsmith_endpoint_from_env_vars_dict(self, monkeypatch): + monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") + monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False) + client = _create_host_backend_client( + host_url=None, + api_key=None, + env_vars={"LANGSMITH_ENDPOINT": "https://smith.example.com/api/v1"}, + ) + assert client.base_url == "https://smith.example.com/api-host" + + def test_cloud_langsmith_endpoint_not_used_as_self_hosted(self, monkeypatch): + monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") + monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com") + client = _create_host_backend_client(host_url=None, api_key=None, env_vars={}) + assert client.base_url == "https://api.host.langchain.com" + + def test_langchain_api_endpoint_not_used_as_self_hosted(self, monkeypatch): + monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") + monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://api.langchain.com") + client = _create_host_backend_client(host_url=None, api_key=None, env_vars={}) + assert client.base_url == "https://api.host.langchain.com" + + def test_explicit_host_url_takes_precedence_over_langsmith_endpoint( + self, monkeypatch + ): + monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") + monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1") + client = _create_host_backend_client( + host_url="https://custom.host.com", + api_key=None, + env_vars={}, + ) + assert client.base_url == "https://custom.host.com" + + def test_no_endpoint_falls_back_to_cloud_default(self, monkeypatch): + monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") + monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False) + client = _create_host_backend_client(host_url=None, api_key=None, env_vars={}) + assert client.base_url == "https://api.host.langchain.com" + + class TestSmithDashboardBaseUrl: def test_none_returns_default(self): assert _smith_dashboard_base_url(None) == "https://smith.langchain.com" @@ -598,6 +647,42 @@ class TestSmithDashboardBaseUrl: == "https://smith.langchain.com" ) + def test_self_hosted_api_host_suffix(self): + assert ( + _smith_dashboard_base_url("https://smith.example.com/api-host") + == "https://smith.example.com" + ) + + def test_self_hosted_api_host_trailing_slash(self): + assert ( + _smith_dashboard_base_url("https://smith.example.com/api-host/") + == "https://smith.example.com" + ) + + def test_self_hosted_localhost_api_host(self): + assert ( + _smith_dashboard_base_url("http://localhost:8080/api-host") + == "http://localhost:8080" + ) + + def test_self_hosted_api_host_suffix(self): + assert ( + _smith_dashboard_base_url("https://langsmith.example.com/api-host") + == "https://langsmith.example.com" + ) + + def test_self_hosted_api_host_trailing_slash(self): + assert ( + _smith_dashboard_base_url("https://langsmith.example.com/api-host/") + == "https://langsmith.example.com" + ) + + def test_self_hosted_localhost_api_host(self): + assert ( + _smith_dashboard_base_url("http://localhost:8080/api-host") + == "http://localhost:8080" + ) + class TestResolvePushedImageDigest: """Tests for ``_resolve_pushed_image_digest`` — runner is mocked to diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index 2be709fdf..d39e503ef 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -178,6 +178,59 @@ def test_update_deployment_no_secrets(client): 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" + ) + 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 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()