From 3df6f50ad545e1c81fa14bc172784185aabf11ce Mon Sep 17 00:00:00 2001 From: hari-dhanushkodi Date: Wed, 24 Jun 2026 23:32:42 -0400 Subject: [PATCH] feat(cli): allow prebuild images for langgraph deploy (#8100) Fixes # adds a --image flag to `langgraph deploy`, allows a user to `langgraph build` a custom image and then deploy this specific image with `langgraph deploy`. --------- Co-authored-by: hari-dhanushkodi <203702815+hari-dhanushkodi@users.noreply.github.com> Co-authored-by: open-swe[bot] --- .github/workflows/_integration_test.yml | 4 +- libs/cli/langgraph_cli/deploy.py | 153 ++++++++++++------ .../tests/unit_tests/test_deploy_helpers.py | 63 ++++++++ 3 files changed, 173 insertions(+), 47 deletions(-) diff --git a/.github/workflows/_integration_test.yml b/.github/workflows/_integration_test.yml index 5af1f1d0e..554c422ff 100644 --- a/.github/workflows/_integration_test.yml +++ b/.github/workflows/_integration_test.yml @@ -126,8 +126,8 @@ jobs: exit 1 fi LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);") - if [ "$LANGCHAIN_ANTHROPIC_VERSION" != "1.0.0a5" ]; then - echo "LANGCHAIN_ANTHROPIC_VERSION != 1.0.0a5; $LANGCHAIN_ANTHROPIC_VERSION" + if [ "$LANGCHAIN_ANTHROPIC_VERSION" != "1.4.6" ]; then + echo "LANGCHAIN_ANTHROPIC_VERSION != 1.4.6; $LANGCHAIN_ANTHROPIC_VERSION" exit 1 fi diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 54d9ee03e..289ed16ba 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -366,6 +366,42 @@ 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 FileNotFoundError: + raise click.ClickException( + "Docker is required but not installed.\n" + "Install Docker Desktop: https://docs.docker.com/get-docker/" + ) from None + 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): @@ -525,12 +561,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: @@ -897,6 +939,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, @@ -910,51 +953,57 @@ 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"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 -- @@ -1023,7 +1072,7 @@ def _run_local_build( subp_exec( "docker", "tag", - local_tag, + image_to_push, remote_image, verbose=verbose, ) @@ -1423,6 +1472,13 @@ def _deploy_base_options( 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", @@ -1523,6 +1579,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, @@ -1569,7 +1626,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: em.note(f"{local_build_error}\nUsing remote build instead.") if not json_output: @@ -1639,6 +1701,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, diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index ce77a2e18..cdeb0d3f5 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -1,3 +1,4 @@ +import asyncio import base64 import io import json @@ -6,9 +7,11 @@ import sys 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, @@ -19,6 +22,7 @@ from langgraph_cli.deploy import ( _resolve_env_path, _resolve_pushed_image_digest, _smith_dashboard_base_url, + _validate_prebuilt_image, normalize_image_tag, normalize_name, ) @@ -95,6 +99,65 @@ class TestNormalizeImageTag: 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_docker_binary_raises_actionable_error(self, monkeypatch): + async def fake_subp_exec(*args, **kwargs): + raise FileNotFoundError("docker") + + monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec) + + with pytest.raises(click.ClickException, match="Docker is required"): + _validate_prebuilt_image(_FakeRunner(), "repo/app:tag", verbose=False) + + 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"