From fd502adb167d0b267bdab1e505334cb641d3e8d4 Mon Sep 17 00:00:00 2001 From: hari-dhanushkodi Date: Tue, 7 Apr 2026 12:09:50 -0400 Subject: [PATCH 1/4] feat: add prebuilt image for langgraph deploy --- libs/cli/langgraph_cli/deploy.py | 115 +++++++++++++++++++------------ 1 file changed, 70 insertions(+), 45 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 25909338ff9..d1a9b0c50bb 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -358,12 +358,18 @@ def _secrets_from_env( 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: @@ -664,6 +670,7 @@ def _run_local_build( 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, @@ -676,51 +683,55 @@ def _run_local_build( # (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"Using image {prebuilt_image}") 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 -- @@ -789,7 +800,7 @@ def _run_local_build( subp_exec( "docker", "tag", - local_tag, + image_to_push, remote_image, verbose=verbose, ) @@ -1162,6 +1173,13 @@ def _apply(target: Callable) -> Callable: show_default=True, help="Tag to use for the pushed deployment image.", ), + click.option( + "--image", + help=( + "Use an existing local image (must include a tag) instead of " + "building a new one." + ), + ), click.option( "--config", "-c", @@ -1249,6 +1267,7 @@ def _deploy_cmd( deployment_type: str, name: str | None, image_name: str | None, + image: str | None, tag: str, base_image: str | None, install_command: str | None, @@ -1282,7 +1301,12 @@ def _deploy_cmd( 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: click.secho(f"{local_build_error}\nUsing remote build instead.", fg="yellow") click.echo() @@ -1341,6 +1365,7 @@ def _deploy_cmd( api_version=api_version, base_image=base_image, image_name=image_name, + prebuilt_image=image, name=name, tag=tag, install_command=install_command, From fd12a2cfafaeda6dd070552d8ef3139df407e026 Mon Sep 17 00:00:00 2001 From: hari-dhanushkodi Date: Tue, 7 Apr 2026 12:53:51 -0400 Subject: [PATCH 2/4] update help text --- libs/cli/langgraph_cli/deploy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index d1a9b0c50bb..df6f4e82d81 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -1176,8 +1176,8 @@ def _apply(target: Callable) -> Callable: click.option( "--image", help=( - "Use an existing local image (must include a tag) instead of " - "building a new one." + "Use an existing local image reference (e.g. repo:tag) and " + "skip building." ), ), click.option( From 796cb49f2d058a78ad311016d1d9002d3d32c931 Mon Sep 17 00:00:00 2001 From: hari-dhanushkodi Date: Tue, 7 Apr 2026 13:01:05 -0400 Subject: [PATCH 3/4] fix help text again --- libs/cli/langgraph_cli/deploy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index df6f4e82d81..25463c37d82 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -1177,7 +1177,7 @@ def _apply(target: Callable) -> Callable: "--image", help=( "Use an existing local image reference (e.g. repo:tag) and " - "skip building." + "skip building. The image must target linux/amd64." ), ), click.option( From 2e0af6dd98a38dc2ba2c9411c035752c4a8e8c70 Mon Sep 17 00:00:00 2001 From: hari-dhanushkodi <203702815+hari-dhanushkodi@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:56:40 +0000 Subject: [PATCH 4/4] fix: validate prebuilt deploy image Co-authored-by: open-swe[bot] --- libs/cli/langgraph_cli/deploy.py | 35 +++++++++++- .../tests/unit_tests/test_deploy_helpers.py | 54 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 25463c37d82..ab44319ae3a 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -198,6 +198,37 @@ def normalize_image_tag(value: str) -> str: 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 + + 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): @@ -687,7 +718,9 @@ def _run_local_build( with Runner() as runner: if prebuilt_image: - _log_deploy_step(step, f"Using image {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: # -- Step: Build image -- _log_deploy_step(step, "Building image") diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index d7a2450746b..2a097cc1cf5 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -1,17 +1,21 @@ +import asyncio import base64 import json import os 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, _docker_config_for_token, _env_without_deployment_name, _parse_env_from_config, _resolve_env_path, + _validate_prebuilt_image, normalize_image_name, normalize_image_tag, ) @@ -85,6 +89,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"