Files
langgraph/libs/cli/tests/unit_tests/cli/test_deploy_command.py
T

645 lines
20 KiB
Python

import asyncio
import json
from collections.abc import Callable, Iterator
from contextlib import contextmanager
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 HostBackendClient
from langgraph_cli.image_reference import ImageReference
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"
PUSH_REPOSITORY = "registry.example.com/team/agent"
EXTERNAL_IMAGE = f"{PUSH_REPOSITORY}:latest"
EXTERNAL_DIGEST = f"{PUSH_REPOSITORY}@sha256:abc123"
LISTENER_REQUIRED = (
"Source configuration error: 'source_config.listener_id' is required for "
"workspace with available listener IDs: ['listener-1']"
)
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}"
def _get(deployment_id: str) -> str:
return f"GET /v2/deployments/{deployment_id}"
@dataclass
class ControlPlaneDouble:
timeline: list[str]
existing_deployments: list[dict] = field(default_factory=list)
push_token_status: int = 200
create_error: str | None = None
bodies: dict[str, dict] = field(default_factory=dict)
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 _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"):
if self.create_error is not None:
return httpx.Response(400, text=self.create_error)
return httpx.Response(201, json={"id": CREATED_ID, "tenant_id": "tenant-1"})
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}")
def client_factory(self) -> Callable[..., HostBackendClient]:
transport = httpx.MockTransport(self.handle)
def make(
host_url: str, api_key: str, tenant_id: str | None = None
) -> HostBackendClient:
return HostBackendClient(host_url, api_key, tenant_id, transport=transport)
return make
@dataclass
class DockerCommand:
args: tuple[str, ...]
kwargs: dict
@dataclass
class DockerDouble:
timeline: list[str]
failing_pushes: int = 0
builds: list[dict] = field(default_factory=list)
commands: list[DockerCommand] = field(default_factory=list)
def verbs(self) -> list[str]:
return [event for event in self.timeline if event.startswith("docker ")]
def command(self, verb: str) -> DockerCommand:
return next(c for c in self.commands if verb in c.args)
def build_docker_image(
self,
runner: object,
set_message: Callable[[str], None],
config: Path,
config_json: dict,
base_image: str | None,
api_version: str | None,
pull: bool,
tag: str,
passthrough: tuple[str, ...] = (),
install_command: str | None = None,
build_command: str | None = None,
docker_command: tuple[str, ...] | None = None,
extra_flags: tuple[str, ...] = (),
verbose: bool = True,
) -> None:
self.timeline.append("docker build")
self.builds.append(
{
"tag": tag,
"docker_command": tuple(docker_command or ("docker", "build")),
"extra_flags": tuple(extra_flags),
}
)
async def subp_exec(
self, *args: str, **kwargs: object
) -> tuple[str | None, str | None]:
self.commands.append(DockerCommand(args=args, kwargs=kwargs))
self.timeline.append(f"docker {self._verb(args)}")
if "push" in args and self.failing_pushes > 0:
self.failing_pushes -= 1
raise click.exceptions.Exit(1)
if PLATFORM_FORMAT in args:
return "linux/amd64\n", None
if DIGESTS_FORMAT in args:
repository = ImageReference.parse(args[-1]).repository
return json.dumps([f"{repository}@sha256:abc123"]), None
return None, None
@staticmethod
def _verb(args: tuple[str, ...]) -> str:
if PLATFORM_FORMAT in args:
return "inspect-platform"
if DIGESTS_FORMAT in args:
return "inspect-digest"
return next(verb for verb in ("login", "tag", "push", "pull") if verb in args)
class _AsyncioRunner:
def run(self, coro):
return asyncio.run(coro)
@contextmanager
def _fake_runner() -> Iterator[_AsyncioRunner]:
yield _AsyncioRunner()
@dataclass
class DeployProject:
control_plane: ControlPlaneDouble
docker: DockerDouble
timeline: list[str]
uploads: list[tuple[str, str, int]]
def run(self, *args: str) -> Result:
return CliRunner().invoke(
cli,
[
"deploy",
"--api-key",
"test-key",
"--host-url",
CONTROL_PLANE_URL,
"--name",
"my-app",
"--no-input",
"--no-wait",
*args,
],
)
@pytest.fixture
def deploy_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DeployProject:
(tmp_path / "langgraph.json").write_text(
json.dumps({"graphs": {"agent": "agent.py:graph"}, "dependencies": ["."]})
)
monkeypatch.chdir(tmp_path)
for name in ("LANGSMITH_TENANT_ID", "LANGSMITH_ENDPOINT", "LANGGRAPH_HOST_URL"):
monkeypatch.delenv(name, raising=False)
timeline: list[str] = []
control_plane = ControlPlaneDouble(timeline)
docker = DockerDouble(timeline)
uploads: list[tuple[str, str, int]] = []
@contextmanager
def fake_create_archive(config_path: Path, config: dict) -> Iterator[tuple]:
timeline.append("create_archive")
yield ARCHIVE
def fake_upload(signed_url: str, file_path: str, file_size: int) -> None:
timeline.append("upload_archive")
uploads.append((signed_url, file_path, file_size))
monkeypatch.setattr(deploy_module, "_no_input", False)
monkeypatch.setattr(deploy_module, "_emitter", None)
monkeypatch.setattr(
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)
monkeypatch.setattr(deploy_module, "Runner", _fake_runner)
monkeypatch.setattr(deploy_module, "can_build_locally", lambda: (True, None))
monkeypatch.setattr(
deploy_module,
"find_tracked_packages",
lambda config, config_json: TRACKED_PACKAGES,
)
monkeypatch.setattr(deploy_module.platform, "machine", lambda: "x86_64")
monkeypatch.setattr(archive_module, "create_archive", fake_create_archive)
monkeypatch.setattr(deploy_module, "_upload_to_gcs", fake_upload)
return DeployProject(control_plane, docker, timeline, uploads)
def test_first_local_deploy_creates_then_builds_pushes_and_updates_in_order(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run("--no-remote")
assert result.exit_code == 0, result.output
assert deploy_project.timeline == [
LIST_DEPLOYMENTS,
CREATE_DEPLOYMENT,
"docker build",
_push_token(CREATED_ID),
"docker login",
"docker tag",
"docker push",
"docker inspect-digest",
_patch(CREATED_ID),
]
assert "Deployment updated" in result.output
def test_first_local_deploy_creates_an_internal_docker_deployment(
deploy_project: DeployProject,
) -> None:
deploy_project.run("--no-remote")
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT] == {
"name": "my-app",
"source": "internal_docker",
"source_config": {"deployment_type": "dev"},
"source_revision_config": {},
"secrets": [],
}
@pytest.mark.parametrize(
("machine", "expected_command", "expected_flags"),
[
pytest.param(
"arm64",
("docker", "buildx", "build"),
("--platform", "linux/amd64", "--load", "--progress=quiet"),
id="apple_silicon_cross_builds_for_linux_amd64",
),
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, ...],
expected_flags: tuple[str, ...],
) -> None:
monkeypatch.setattr(deploy_module.platform, "machine", lambda: machine)
deploy_project.run("--no-remote")
build = deploy_project.docker.builds[0]
assert build["tag"].startswith("langgraph-deploy-tmp:")
assert (build["docker_command"], build["extra_flags"]) == (
expected_command,
expected_flags,
)
def test_local_deploy_logs_in_with_the_control_plane_push_token(
deploy_project: DeployProject,
) -> None:
deploy_project.run("--no-remote")
login = deploy_project.docker.command("login")
assert login.args[:2] == ("docker", "--config")
assert login.args[3:] == (
"login",
"-u",
"oauth2accesstoken",
"--password-stdin",
"registry.example.com",
)
assert login.kwargs["input"] == f"{PUSH_TOKEN}\n"
def test_local_deploy_tags_the_build_into_the_token_registry(
deploy_project: DeployProject,
) -> None:
deploy_project.run("--no-remote")
built_tag = deploy_project.docker.builds[0]["tag"]
assert deploy_project.docker.command("tag").args == (
"docker",
"tag",
built_tag,
PUSHED_IMAGE,
)
assert deploy_project.docker.command("push").args[-1] == PUSHED_IMAGE
def test_local_deploy_records_the_pushed_digest_and_tracked_packages(
deploy_project: DeployProject,
) -> None:
deploy_project.run("--no-remote")
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,
}
def test_status_link_points_at_the_langsmith_dashboard(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run("--no-remote")
assert (
"View status: https://smith.langchain.com/o/tenant-1/host/deployments/dep-created"
in result.output
)
def test_prebuilt_image_is_validated_and_pushed_without_a_build(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run("--image", "local/app:dev")
assert result.exit_code == 0, result.output
assert deploy_project.docker.builds == []
assert deploy_project.docker.verbs() == [
"docker inspect-platform",
"docker login",
"docker tag",
"docker push",
"docker inspect-digest",
]
assert deploy_project.docker.command("tag").args[2:] == (
"local/app:dev",
PUSHED_IMAGE,
)
def test_push_is_retried_until_the_third_attempt(
deploy_project: DeployProject,
) -> None:
deploy_project.docker.failing_pushes = 2
result = deploy_project.run("--no-remote")
assert result.exit_code == 0, result.output
assert deploy_project.docker.verbs().count("docker push") == 3
def test_three_failed_pushes_abort_before_the_deployment_is_updated(
deploy_project: DeployProject,
) -> None:
deploy_project.docker.failing_pushes = 3
result = deploy_project.run("--no-remote")
assert result.exit_code != 0
assert _patch(CREATED_ID) not in deploy_project.timeline
def test_existing_deployment_matched_by_exact_name_is_updated_not_created(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.existing_deployments = [
{"id": "dep-other", "name": "my-app-2"},
{"id": "dep-existing", "name": "my-app"},
]
deploy_project.run("--no-remote")
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(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.existing_deployments = [
{"id": "dep-ui", "name": "my-app"}
]
deploy_project.control_plane.push_token_status = 400
result = deploy_project.run("--no-remote")
assert result.exit_code != 0
assert "was not created by 'langgraph deploy'" in result.output
assert "docker login" not in deploy_project.timeline
def test_remote_build_creates_an_internal_source_deployment_and_uploads_the_archive(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run("--remote", "--install-command", "yarn install")
assert result.exit_code == 0, result.output
assert deploy_project.timeline == [
LIST_DEPLOYMENTS,
CREATE_DEPLOYMENT,
"create_archive",
_upload_url(CREATED_ID),
"upload_archive",
_patch(CREATED_ID),
]
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.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": [],
"tracked_packages": TRACKED_PACKAGES,
}
assert "Build triggered" in result.output
def test_push_to_builds_pushes_then_creates_an_external_deployment(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run("--push-to", PUSH_REPOSITORY)
assert result.exit_code == 0, result.output
assert deploy_project.timeline == [
LIST_DEPLOYMENTS,
"docker build",
"docker push",
"docker inspect-digest",
CREATE_DEPLOYMENT,
]
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT] == {
"name": "my-app",
"source": "external_docker",
"source_config": {"resource_spec": {}},
"source_revision_config": {"image_uri": EXTERNAL_DIGEST},
"secrets": [],
}
assert "Deployment created" in result.output
def test_push_to_builds_directly_with_the_push_reference(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run("--push-to", PUSH_REPOSITORY)
assert result.exit_code == 0, result.output
assert deploy_project.docker.builds[0]["tag"] == EXTERNAL_IMAGE
assert deploy_project.docker.command("push").args == (
"docker",
"push",
EXTERNAL_IMAGE,
)
def test_push_to_composes_with_the_tag_flag(deploy_project: DeployProject) -> None:
result = deploy_project.run("--push-to", PUSH_REPOSITORY, "--tag", "v1")
assert result.exit_code == 0, result.output
assert deploy_project.docker.command("push").args[-1] == f"{PUSH_REPOSITORY}:v1"
def test_push_to_with_a_failing_push_creates_no_deployment(
deploy_project: DeployProject,
) -> None:
deploy_project.docker.failing_pushes = 3
result = deploy_project.run("--push-to", PUSH_REPOSITORY)
assert result.exit_code != 0
assert CREATE_DEPLOYMENT not in deploy_project.timeline
def test_verbose_never_echoes_the_push_token(deploy_project: DeployProject) -> None:
result = deploy_project.run("--no-remote", "--verbose")
assert result.exit_code == 0, result.output
assert deploy_project.docker.command("login").kwargs["verbose"] is False
def test_push_to_retags_a_prebuilt_image_instead_of_building(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(
"--image", "local/app:dev", "--push-to", PUSH_REPOSITORY
)
assert result.exit_code == 0, result.output
assert deploy_project.docker.builds == []
assert deploy_project.docker.verbs() == [
"docker inspect-platform",
"docker tag",
"docker push",
"docker inspect-digest",
]
assert deploy_project.docker.command("tag").args == (
"docker",
"tag",
"local/app:dev",
EXTERNAL_IMAGE,
)
def test_push_to_updates_an_existing_external_deployment_with_the_new_image(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.existing_deployments = [
{"id": "dep-ext", "name": "my-app", "source": "external_docker"}
]
result = deploy_project.run("--push-to", PUSH_REPOSITORY)
assert result.exit_code == 0, result.output
assert deploy_project.timeline == [
LIST_DEPLOYMENTS,
"docker build",
"docker push",
"docker inspect-digest",
_patch("dep-ext"),
]
assert deploy_project.control_plane.bodies[_patch("dep-ext")] == {
"source_revision_config": {"image_uri": EXTERNAL_DIGEST},
"secrets": [],
"tracked_packages": TRACKED_PACKAGES,
}
def test_push_to_rejects_a_non_external_deployment_before_any_docker_work(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.existing_deployments = [
{"id": "dep-cli", "name": "my-app", "source": "internal_docker"}
]
result = deploy_project.run("--push-to", PUSH_REPOSITORY)
assert result.exit_code != 0
assert "cannot be updated with --push-to" in result.output
assert deploy_project.docker.verbs() == []
def test_push_to_explains_the_listener_requirement_of_hybrid_workspaces(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.create_error = LISTENER_REQUIRED
result = deploy_project.run("--push-to", PUSH_REPOSITORY)
assert result.exit_code != 0
assert "listener" in result.output
assert "--deployment-id" in result.output
def test_push_to_with_deployment_id_fetches_the_deployment_once(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.existing_deployments = [
{"id": "dep-ext", "name": "another-name", "source": "external_docker"}
]
result = deploy_project.run(
"--deployment-id", "dep-ext", "--push-to", PUSH_REPOSITORY
)
assert result.exit_code == 0, result.output
assert deploy_project.timeline == [
_get("dep-ext"),
"docker build",
"docker push",
"docker inspect-digest",
_patch("dep-ext"),
]