mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-20 08:37:59 +02:00
Fix customer-registry deployment creation and image handling
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
co-authored by
open-swe[bot] <open-swe@users.noreply.github.com>
parent
d707ab6650
commit
9779ea5d3a
@@ -6,8 +6,10 @@ import tempfile
|
||||
import textwrap
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import langgraph_cli.deploy as deploy_module
|
||||
@@ -1356,47 +1358,89 @@ def test_prepare_args_and_stdin_distributed_mode() -> None:
|
||||
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.
|
||||
@pytest.mark.parametrize(
|
||||
"source,image",
|
||||
[
|
||||
(None, None),
|
||||
(None, "local:latest"),
|
||||
("external_docker", None),
|
||||
("external_docker", "local:latest"),
|
||||
("internal_docker", None),
|
||||
],
|
||||
)
|
||||
def test_deploy_push_to(monkeypatch, tmp_path, source, image):
|
||||
monkeypatch.setattr(deploy_module, "_no_input", False)
|
||||
|
||||
monkeypatch.setattr(deploy_module, "_emitter", None)
|
||||
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",
|
||||
],
|
||||
events = []
|
||||
client = MagicMock(base_url="https://smith.example.com/api-host")
|
||||
client.get_deployment.return_value = {"id": "dep-123", "source": source}
|
||||
client.list_deployments.return_value = {"deployments": []}
|
||||
client.create_deployment.side_effect = lambda **kw: (
|
||||
events.append(("create", kw)) or {"id": "dep-123"}
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "different build mode" in result.output
|
||||
assert "cannot be updated with --image-uri" in result.output
|
||||
client.update_deployment_external.side_effect = lambda *a, **kw: (
|
||||
events.append(("update", a)) or {}
|
||||
)
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", lambda *a, **kw: client)
|
||||
monkeypatch.setattr(
|
||||
deploy_module,
|
||||
"_build_image_tagged",
|
||||
lambda *a, **kw: events.append(("build", a[6])),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
deploy_module,
|
||||
"_validate_prebuilt_image",
|
||||
lambda *a, **kw: events.append(("validate", a[1])),
|
||||
)
|
||||
monkeypatch.setattr(deploy_module, "subp_exec", lambda *a, **kw: a)
|
||||
runner = MagicMock()
|
||||
runner.__enter__.return_value = runner
|
||||
runner.run.side_effect = lambda args: events.append(args)
|
||||
monkeypatch.setattr(deploy_module, "Runner", lambda: runner)
|
||||
digest = "registry.example.com/app@sha256:abc123"
|
||||
monkeypatch.setattr(
|
||||
deploy_module,
|
||||
"_resolve_pushed_image_digest",
|
||||
lambda *a, **kw: events.append(("digest",)) or digest,
|
||||
)
|
||||
args = [
|
||||
"deploy",
|
||||
"--api-key",
|
||||
"key",
|
||||
"--push-to",
|
||||
"registry.example.com/app:latest",
|
||||
"--config",
|
||||
str(config),
|
||||
"--no-input",
|
||||
"--no-wait",
|
||||
]
|
||||
args += ["--deployment-id", "dep-123"] if source else ["--name", "app"]
|
||||
if image:
|
||||
args += ["--image", image]
|
||||
result = CliRunner().invoke(cli, args)
|
||||
if source == "internal_docker":
|
||||
assert result.exit_code != 0
|
||||
assert "cannot be updated with --push-to" in result.output
|
||||
assert events == []
|
||||
return
|
||||
assert result.exit_code == 0, result.output
|
||||
destination = "registry.example.com/app:latest"
|
||||
expected = (
|
||||
[("validate", image), ("docker", "tag", image, destination)]
|
||||
if image
|
||||
else [("build", destination)]
|
||||
)
|
||||
assert events[: len(expected) + 2] == expected + [
|
||||
("docker", "push", destination),
|
||||
("digest",),
|
||||
]
|
||||
if source:
|
||||
assert events[-1] == ("update", ("dep-123", digest))
|
||||
client.create_deployment.assert_not_called()
|
||||
else:
|
||||
assert events[-1][0] == "create"
|
||||
assert events[-1][1]["image_uri"] == digest
|
||||
assert events[-1][1]["source"] == "external_docker"
|
||||
client.update_deployment_external.assert_not_called()
|
||||
|
||||
@@ -543,53 +543,47 @@ 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"
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint,host,expected",
|
||||
[
|
||||
(None, None, "https://api.host.langchain.com"),
|
||||
("https://api.smith.langchain.com/", None, "https://api.host.langchain.com"),
|
||||
("https://api.langchain.com", None, "https://api.host.langchain.com"),
|
||||
(
|
||||
"https://eu.api.smith.langchain.com",
|
||||
None,
|
||||
"https://eu.api.host.langchain.com",
|
||||
),
|
||||
(
|
||||
"https://smith.example.com/api/v1",
|
||||
None,
|
||||
"https://smith.example.com/api-host",
|
||||
),
|
||||
(
|
||||
"https://smith.example.com",
|
||||
"https://api.host.langchain.com",
|
||||
"https://api.host.langchain.com",
|
||||
),
|
||||
(
|
||||
"https://smith.example.com",
|
||||
"https://custom.host.com",
|
||||
"https://custom.host.com",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("from_env", [True, False])
|
||||
def test_endpoint_fallback(monkeypatch, endpoint, host, expected, from_env):
|
||||
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
|
||||
env_vars = {}
|
||||
if endpoint:
|
||||
if from_env:
|
||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", endpoint)
|
||||
else:
|
||||
env_vars["LANGSMITH_ENDPOINT"] = endpoint
|
||||
client = _create_host_backend_client(
|
||||
host_url=host, api_key="key", env_vars=env_vars
|
||||
)
|
||||
assert client.base_url == expected
|
||||
|
||||
|
||||
class TestSmithDashboardBaseUrl:
|
||||
@@ -647,23 +641,16 @@ 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"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"url,expected",
|
||||
[
|
||||
("https://smith.example.com/api-host", "https://smith.example.com"),
|
||||
("https://smith.example.com/api-host/", "https://smith.example.com"),
|
||||
("http://localhost:8080/api-host", "http://localhost:8080"),
|
||||
],
|
||||
)
|
||||
def test_self_hosted(self, url, expected):
|
||||
assert _smith_dashboard_base_url(url) == expected
|
||||
|
||||
|
||||
class TestResolvePushedImageDigest:
|
||||
|
||||
@@ -178,45 +178,32 @@ def test_update_deployment_no_secrets(client):
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_update_deployment_external():
|
||||
@pytest.mark.parametrize("create", [True, False])
|
||||
def test_external_deployment_payload(create):
|
||||
captured: dict = {}
|
||||
c = _capturing_client(captured)
|
||||
result = c.update_deployment_external(
|
||||
"dep-123", "registry.example.com/app@sha256:abc123"
|
||||
)
|
||||
assert result == {"ok": True}
|
||||
image = "registry.example.com/app@sha256:abc123"
|
||||
kwargs = {
|
||||
"secrets": [{"name": "KEY", "value": "value"}],
|
||||
"tracked_packages": ["google-adk:1.0.0"],
|
||||
}
|
||||
if create:
|
||||
c.create_deployment("app", "dev", "external_docker", image_uri=image, **kwargs)
|
||||
else:
|
||||
c.update_deployment_external("dep-123", image, **kwargs)
|
||||
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
|
||||
assert body == {
|
||||
**(
|
||||
{"name": "app", "source": "external_docker", "source_config": {}}
|
||||
if create
|
||||
else {}
|
||||
),
|
||||
"source_revision_config": {"image_uri": image},
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
|
||||
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})
|
||||
|
||||
Reference in New Issue
Block a user