Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 103 additions & 45 deletions libs/cli/langgraph_cli/deploy.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Deploy command and subcommands for the LangGraph CLI."""

import base64
Expand Down Expand Up @@ -366,6 +366,37 @@
return value


def _validate_prebuilt_image(runner, image: str, *, verbose: bool) -> None:
"""Ensure a prebuilt image exists locally for linux/amd64."""
try:
stdout, _ = runner.run(
subp_exec(
"docker",
"image",
"inspect",
"--format",
"{{.Os}}/{{.Architecture}}",
image,
verbose=verbose,
collect=True,
)
)
except click.exceptions.Exit:
raise click.ClickException(
f"Docker image '{image}' was not found locally. Build or pull the image "
"before deploying with --image."
) from None
Comment on lines +384 to +388

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Missing Docker crashes image validation

With --image, _resolve_build_mode(..., force_local=True) skips the existing can_build_locally() preflight, so this helper is the first place that invokes Docker. If the Docker binary is not installed, asyncio.create_subprocess_exec("docker", ...) raises FileNotFoundError (and the subprocess helper can surface that instead of click.exceptions.Exit), which is not caught here. That makes langgraph deploy --image ... crash with a traceback on machines without Docker instead of returning the actionable Docker-required error that the normal local-build path provides. Please catch the missing-binary case here or keep the Docker availability preflight for --image.

(Refers to lines 384-388)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.


image_platform = (stdout or "").strip()
if image_platform != "linux/amd64":
detected = image_platform or "unknown"
raise click.ClickException(
f"Docker image '{image}' targets {detected}, but LangSmith Deployment "
"requires linux/amd64. Rebuild or pull the image for linux/amd64 before "
"deploying with --image."
)


def _extract_deployment_url(deployment: dict[str, object]) -> str:
source_config = deployment.get("source_config")
if isinstance(source_config, dict):
Expand Down Expand Up @@ -525,12 +556,18 @@

def _resolve_build_mode(
remote_build_flag: bool | None,
*,
force_local: bool = False,
) -> tuple[bool, str | None]:
"""Determine whether to use a remote build.

Returns (use_remote_build, local_build_error). Raises UsageError when
--no-remote is set but the machine cannot build locally.
--no-remote is set but the machine cannot build locally. When
`force_local` is set, the function short-circuits and always selects a
local build.
"""
if force_local:
return False, None
local_build_supported, local_build_error = can_build_locally()

if remote_build_flag is True:
Expand Down Expand Up @@ -897,6 +934,7 @@
api_version: str | None,
base_image: str | None,
image_name: str | None,
prebuilt_image: str | None,
name: str | None,
tag: str,
install_command: str | None,
Expand All @@ -910,51 +948,57 @@
# (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient.
needs_buildx = platform.machine() != "x86_64"
local_tag = f"langgraph-deploy-tmp:{int(time.time())}"
image_to_push = prebuilt_image or local_tag

with Runner() as runner:
# -- Step: Build image --
_log_deploy_step(step, "Building image")
if needs_buildx:
build_flags: list[str] = [
"--platform",
"linux/amd64",
"--load",
]
if not verbose:
build_flags.append("--progress=quiet")
with Progress(message="Building...", elapsed=not verbose):
build_docker_image(
runner,
lambda _msg: None,
config,
config_json,
base_image,
api_version,
pull,
local_tag,
docker_build_args,
install_command,
build_command,
docker_command=("docker", "buildx", "build"),
extra_flags=build_flags,
verbose=verbose,
)
if prebuilt_image:
_log_deploy_step(step, f"Validating image {prebuilt_image}")
_validate_prebuilt_image(runner, prebuilt_image, verbose=verbose)
click.secho(" Image is available for linux/amd64", fg="green")
else:
with Progress(message="Building...", elapsed=not verbose):
build_docker_image(
runner,
lambda _msg: None,
config,
config_json,
base_image,
api_version,
pull,
local_tag,
docker_build_args,
install_command,
build_command,
verbose=verbose,
)
# -- Step: Build image --
_log_deploy_step(step, "Building image")
if needs_buildx:
build_flags: list[str] = [
"--platform",
"linux/amd64",
"--load",
]
if not verbose:
build_flags.append("--progress=quiet")
with Progress(message="Building...", elapsed=not verbose):
build_docker_image(
runner,
lambda _msg: None,
config,
config_json,
base_image,
api_version,
pull,
local_tag,
docker_build_args,
install_command,
build_command,
docker_command=("docker", "buildx", "build"),
extra_flags=build_flags,
verbose=verbose,
)
else:
with Progress(message="Building...", elapsed=not verbose):
build_docker_image(
runner,
lambda _msg: None,
config,
config_json,
base_image,
api_version,
pull,
local_tag,
docker_build_args,
install_command,
build_command,
verbose=verbose,
)
step += 1

# -- Step: Get push token and authenticate --
Expand Down Expand Up @@ -1023,7 +1067,7 @@
subp_exec(
"docker",
"tag",
local_tag,
image_to_push,
remote_image,
verbose=verbose,
)
Expand Down Expand Up @@ -1423,6 +1467,13 @@
show_default=True,
help="Tag to use for the pushed deployment image.",
),
click.option(
"--image",
help=(
"Use an existing local image reference (e.g. repo:tag) and "
"skip building. The image must target linux/amd64."
),
),
click.option(
"--config",
"-c",
Expand Down Expand Up @@ -1523,6 +1574,7 @@
deployment_type: str,
name: str | None,
image_name: str | None,
image: str | None,
tag: str,
base_image: str | None,
install_command: str | None,
Expand Down Expand Up @@ -1569,7 +1621,12 @@

secrets = _secrets_from_env(_env_without_deployment_name(env_vars))

use_remote_build, local_build_error = _resolve_build_mode(remote_build_flag)
if image and remote_build_flag is True:
raise click.UsageError("--image cannot be combined with --remote builds.")

use_remote_build, local_build_error = _resolve_build_mode(
remote_build_flag, force_local=image is not None
)
if use_remote_build and remote_build_flag is None and local_build_error:
em.note(f"{local_build_error}\nUsing remote build instead.")
if not json_output:
Expand Down Expand Up @@ -1639,6 +1696,7 @@
api_version=api_version,
base_image=base_image,
image_name=image_name,
prebuilt_image=image,
name=name,
tag=tag,
install_command=install_command,
Expand Down
54 changes: 54 additions & 0 deletions libs/cli/tests/unit_tests/test_deploy_helpers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import base64
import io
import json
Expand All @@ -6,9 +7,11 @@
from unittest.mock import MagicMock

import click
import click.exceptions
import httpx
import pytest

import langgraph_cli.deploy as deploy_mod
from langgraph_cli.deploy import (
_call_host_backend_with_optional_tenant,
_create_host_backend_client,
Expand All @@ -19,6 +22,7 @@
_resolve_env_path,
_resolve_pushed_image_digest,
_smith_dashboard_base_url,
_validate_prebuilt_image,
normalize_image_tag,
normalize_name,
)
Expand Down Expand Up @@ -95,6 +99,56 @@ def test_spaces_raises(self):
normalize_image_tag("has space")


class _FakeRunner:
def run(self, coro):
return asyncio.run(coro)


class TestValidatePrebuiltImage:
def test_accepts_linux_amd64(self, monkeypatch):
calls = []

async def fake_subp_exec(*args, **kwargs):
calls.append((args, kwargs))
return "linux/amd64\n", None

monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)

_validate_prebuilt_image(_FakeRunner(), "repo/app:tag", verbose=False)

assert calls == [
(
(
"docker",
"image",
"inspect",
"--format",
"{{.Os}}/{{.Architecture}}",
"repo/app:tag",
),
{"verbose": False, "collect": True},
)
]

def test_missing_image_raises_actionable_error(self, monkeypatch):
async def fake_subp_exec(*args, **kwargs):
raise click.exceptions.Exit(1)

monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)

with pytest.raises(click.ClickException, match="not found locally"):
_validate_prebuilt_image(_FakeRunner(), "missing:tag", verbose=False)

def test_rejects_non_amd64_platform(self, monkeypatch):
async def fake_subp_exec(*args, **kwargs):
return "linux/arm64\n", None

monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)

with pytest.raises(click.ClickException, match="requires linux/amd64"):
_validate_prebuilt_image(_FakeRunner(), "repo/app:arm", verbose=False)


class TestParseEnvFromConfig:
def test_env_dict(self, tmp_path):
config_path = tmp_path / "langgraph.json"
Expand Down
Loading