diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 5f32ab39e..98c973972 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -22,7 +22,7 @@ from langgraph_cli.analytics import log_command from langgraph_cli.constants import DEFAULT_CONFIG from langgraph_cli.dependency_tracking import find_tracked_packages from langgraph_cli.docker import build_docker_image, can_build_locally -from langgraph_cli.exec import Runner, subp_exec +from langgraph_cli.exec import CommandRunner, Runner, subp_exec from langgraph_cli.host_backend import ( ControlPlaneEndpoints, HostBackendClient, @@ -371,7 +371,9 @@ def normalize_image_tag(value: str) -> str: return value -def _validate_prebuilt_image(runner, image: str, *, verbose: bool) -> None: +def _validate_prebuilt_image( + runner: CommandRunner, image: str, *, verbose: bool +) -> None: """Ensure a prebuilt image exists locally for linux/amd64.""" try: stdout, _ = runner.run( @@ -877,7 +879,7 @@ def _upload_to_gcs(signed_url: str, file_path: str, file_size: int) -> None: def _resolve_pushed_image_digest( - runner, + runner: CommandRunner, *, remote_image: str, docker_config_dir: str | None, @@ -891,11 +893,18 @@ def _resolve_pushed_image_digest( found, rather than failing the deploy. """ reference = ImageReference.parse(remote_image) - args: list[str] = ["docker"] - if docker_config_dir: - args += ["--config", docker_config_dir] - args += ["image", "inspect", "--format", "{{json .RepoDigests}}", remote_image] - stdout, _ = runner.run(subp_exec(*args, collect=True, verbose=verbose)) + stdout, _ = runner.run( + subp_exec( + *_docker(docker_config_dir), + "image", + "inspect", + "--format", + "{{json .RepoDigests}}", + remote_image, + collect=True, + verbose=verbose, + ) + ) try: digests = json_mod.loads(stdout or "[]") or [] except json_mod.JSONDecodeError: @@ -910,56 +919,89 @@ def _resolve_pushed_image_digest( return remote_image -def _build_image_tagged( - runner, - config: pathlib.Path, - config_json: dict, - base_image: str | None, - api_version: str | None, - pull: bool, - tag: str, - docker_build_args: Sequence[str], - install_command: str | None, - build_command: str | None, +DEPLOYMENT_PLATFORM = "linux/amd64" +NATIVE_AMD64_MACHINE = "x86_64" +PUSH_ATTEMPTS = 3 +LOCAL_BUILD_TAG_PREFIX = "langgraph-deploy-tmp" + + +@dataclass(frozen=True, slots=True) +class BuildSpec: + config: pathlib.Path + config_json: dict + base_image: str | None + api_version: str | None + pull: bool + docker_build_args: Sequence[str] + install_command: str | None + build_command: str | None + + +@dataclass(frozen=True, slots=True) +class DockerBuildCommand: + command: tuple[str, ...] + flags: tuple[str, ...] + + @classmethod + def for_host(cls, machine: str, *, verbose: bool) -> "DockerBuildCommand": + if machine == NATIVE_AMD64_MACHINE: + return cls(("docker", "build"), ()) + flags: tuple[str, ...] = ("--platform", DEPLOYMENT_PLATFORM, "--load") + if not verbose: + flags += ("--progress=quiet",) + return cls(("docker", "buildx", "build"), flags) + + +def _docker(docker_config_dir: str | None) -> tuple[str, ...]: + if docker_config_dir is None: + return ("docker",) + return ("docker", "--config", docker_config_dir) + + +def _build_image( + runner: CommandRunner, spec: BuildSpec, tag: str, *, verbose: bool +) -> None: + build = DockerBuildCommand.for_host(platform.machine(), verbose=verbose) + with Progress(message="Building...", elapsed=not verbose): + build_docker_image( + runner, + lambda _msg: None, + spec.config, + spec.config_json, + spec.base_image, + spec.api_version, + spec.pull, + tag, + spec.docker_build_args, + spec.install_command, + spec.build_command, + docker_command=build.command, + extra_flags=build.flags, + verbose=verbose, + ) + + +def _push_image( + runner: CommandRunner, + image: str, + *, + docker_config_dir: str | None, verbose: bool, ) -> None: - """Build a Docker image to *tag*, using buildx on non-x86_64 hosts to target linux/amd64.""" - if platform.machine() != "x86_64": - 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, - 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, - tag, - docker_build_args, - install_command, - build_command, - verbose=verbose, + for attempt in range(1, PUSH_ATTEMPTS + 1): + try: + with Progress(message="Pushing...", elapsed=not verbose): + runner.run( + subp_exec( + *_docker(docker_config_dir), "push", image, verbose=verbose + ) + ) + return + except click.exceptions.Exit: + if attempt == PUSH_ATTEMPTS: + raise + _get_emitter().warn( + f" Push failed, retrying (attempt {attempt + 1} of {PUSH_ATTEMPTS})..." ) @@ -968,24 +1010,17 @@ def _run_local_build( client: HostBackendClient, deployment_id: str, step: int, - config: pathlib.Path, - config_json: dict, + spec: BuildSpec, verbose: bool, - pull: bool, - 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, - build_command: str | None, - docker_build_args: Sequence[str], secrets: list[dict[str, str]], tracked_packages: list[str] | None, ) -> BuildResult: """Build locally with Docker, push to registry, update deployment.""" - local_tag = f"langgraph-deploy-tmp:{int(time.time())}" + local_tag = f"{LOCAL_BUILD_TAG_PREFIX}:{int(time.time())}" image_to_push = prebuilt_image or local_tag with Runner() as runner: @@ -995,22 +1030,9 @@ def _run_local_build( click.secho(" Image is available for linux/amd64", fg="green") else: _log_deploy_step(step, "Building image") - _build_image_tagged( - runner, - config, - config_json, - base_image, - api_version, - pull, - local_tag, - docker_build_args, - install_command, - build_command, - verbose=verbose, - ) + _build_image(runner, spec, local_tag, verbose=verbose) step += 1 - # -- Step: Get push token and authenticate -- _log_deploy_step(step, "Requesting push token") try: push_data = client.request_push_token(deployment_id) @@ -1038,15 +1060,15 @@ def _run_local_build( normalized_registry = registry_url.rstrip("/") if "://" in normalized_registry: normalized_registry = normalized_registry.split("//", 1)[1] - repo_seed = image_name or name or config.parent.name - repo_name = normalize_name(repo_seed) - tag_value = normalize_image_tag(tag) - remote_image = f"{normalized_registry}/{repo_name}:{tag_value}" - + repo_seed = image_name or name or spec.config.parent.name + remote_image = str( + ImageReference( + f"{normalized_registry}/{normalize_name(repo_seed)}", + normalize_image_tag(tag), + ) + ) registry_host = normalized_registry.split("/")[0] - # Use a clean Docker config with only the push token so that - # system credential helpers (e.g. gcloud) don't interfere. with _docker_config_for_token(registry_host, deployment_token) as cfg: _log_deploy_step(step, f"Logging into {registry_host}") token_input = ( @@ -1056,9 +1078,7 @@ def _run_local_build( ) runner.run( subp_exec( - "docker", - "--config", - cfg, + *_docker(cfg), "login", "-u", "oauth2accesstoken", @@ -1070,39 +1090,11 @@ def _run_local_build( ) step += 1 - # -- Step: Tag and push -- _log_deploy_step(step, f"Pushing image {remote_image}") runner.run( - subp_exec( - "docker", - "tag", - image_to_push, - remote_image, - verbose=verbose, - ) + subp_exec("docker", "tag", image_to_push, remote_image, verbose=verbose) ) - max_push_retries = 3 - for attempt in range(max_push_retries): - try: - with Progress(message="Pushing...", elapsed=not verbose): - runner.run( - subp_exec( - "docker", - "--config", - cfg, - "push", - remote_image, - verbose=verbose, - ) - ) - break - except click.exceptions.Exit: - if attempt < max_push_retries - 1: - _get_emitter().warn( - f" Push failed, retrying (attempt {attempt + 2} of {max_push_retries})..." - ) - else: - raise + _push_image(runner, remote_image, docker_config_dir=cfg, verbose=verbose) step += 1 resolved_image = _resolve_pushed_image_digest( @@ -1112,7 +1104,6 @@ def _run_local_build( verbose=verbose, ) - # -- Step: Update deployment -- _log_deploy_step(step, f"Updating deployment {deployment_id}") updated = client.update_deployment( deployment_id, @@ -1135,47 +1126,24 @@ def _run_external_deploy( client: HostBackendClient, deployment_id: str, step: int, - config: pathlib.Path, - config_json: dict, + spec: BuildSpec, image_uri: str, verbose: bool, - pull: bool, - api_version: str | None, - base_image: str | None, - install_command: str | None, - build_command: str | None, - docker_build_args: Sequence[str], secrets: list[dict[str, str]], tracked_packages: list[str] | None, -) -> "BuildResult": +) -> BuildResult: """Build image, push using existing Docker credentials, and update the deployment.""" with Runner() as runner: _log_deploy_step(step, f"Building image {image_uri}") - _build_image_tagged( - runner, - config, - config_json, - base_image, - api_version, - pull, - image_uri, - docker_build_args, - install_command, - build_command, - verbose=verbose, - ) + _build_image(runner, spec, image_uri, verbose=verbose) step += 1 _log_deploy_step(step, f"Pushing image {image_uri}") - with Progress(message="Pushing...", elapsed=not verbose): - runner.run(subp_exec("docker", "push", image_uri, verbose=verbose)) + _push_image(runner, image_uri, docker_config_dir=None, verbose=verbose) step += 1 resolved_image = _resolve_pushed_image_digest( - runner, - remote_image=image_uri, - docker_config_dir=None, - verbose=verbose, + runner, remote_image=image_uri, docker_config_dir=None, verbose=verbose ) _log_deploy_step(step, f"Updating deployment {deployment_id}") @@ -1200,11 +1168,8 @@ def _run_remote_build( client: HostBackendClient, deployment_id: str, step: int, - config: pathlib.Path, - config_json: dict, + spec: BuildSpec, verbose: bool, - install_command: str | None, - build_command: str | None, secrets: list[dict[str, str]], tracked_packages: list[str] | None, ) -> BuildResult: @@ -1213,7 +1178,11 @@ def _run_remote_build( em = _get_emitter() _log_deploy_step(step, "Creating source archive") - with create_archive(config, config_json) as (archive_path, file_size, config_rel): + with create_archive(spec.config, spec.config_json) as ( + archive_path, + file_size, + config_rel, + ): em.info(f"Archive created ({file_size / _BYTES_PER_MIB:.1f} MB)") step += 1 @@ -1235,8 +1204,8 @@ def _run_remote_build( source_tarball_path=object_path, config_path=config_rel, secrets=secrets, - install_command=install_command, - build_command=build_command, + install_command=spec.install_command, + build_command=spec.build_command, tracked_packages=tracked_packages, ) @@ -1795,22 +1764,24 @@ def _deploy_cmd( em.warn(f"Skipped tracked-package scan: {exc}") tracked_packages = None - # -- 3. Build (divergent path) -- + spec = BuildSpec( + config=config, + config_json=config_json, + base_image=base_image, + api_version=api_version, + pull=pull, + docker_build_args=docker_build_args, + install_command=install_command, + build_command=build_command, + ) if use_external_docker: build_result = _run_external_deploy( client=client, deployment_id=deployment_id, step=step, - config=config, - config_json=config_json, + spec=spec, image_uri=image_uri, verbose=verbose, - pull=pull, - api_version=api_version, - base_image=base_image, - install_command=install_command, - build_command=build_command, - docker_build_args=docker_build_args, secrets=secrets, tracked_packages=tracked_packages, ) @@ -1819,11 +1790,8 @@ def _deploy_cmd( client=client, deployment_id=deployment_id, step=step, - config=config, - config_json=config_json, + spec=spec, verbose=verbose, - install_command=install_command, - build_command=build_command, secrets=secrets, tracked_packages=tracked_packages, ) @@ -1832,19 +1800,12 @@ def _deploy_cmd( client=client, deployment_id=deployment_id, step=step, - config=config, - config_json=config_json, + spec=spec, verbose=verbose, - pull=pull, - api_version=api_version, - base_image=base_image, image_name=image_name, prebuilt_image=image, name=name, tag=tag, - install_command=install_command, - build_command=build_command, - docker_build_args=docker_build_args, secrets=secrets, tracked_packages=tracked_packages, ) diff --git a/libs/cli/langgraph_cli/exec.py b/libs/cli/langgraph_cli/exec.py index 974fc75f3..32c3d3470 100644 --- a/libs/cli/langgraph_cli/exec.py +++ b/libs/cli/langgraph_cli/exec.py @@ -1,12 +1,18 @@ import asyncio import signal import sys -from collections.abc import Callable +from collections.abc import Callable, Coroutine from contextlib import contextmanager -from typing import cast +from typing import Any, Protocol, TypeVar, cast import click.exceptions +_T = TypeVar("_T") + + +class CommandRunner(Protocol): + def run(self, coro: Coroutine[Any, Any, _T]) -> _T: ... + @contextmanager def Runner(): diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index 3c1128dc4..b30900273 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -13,6 +13,7 @@ import pytest import langgraph_cli.deploy as deploy_mod from langgraph_cli.deploy import ( + DockerBuildCommand, _call_host_backend_with_optional_tenant, _create_host_backend_client, _docker_config_for_token, @@ -563,6 +564,40 @@ class TestCreateHostBackendClientEndpoint: assert client.base_url == "https://custom.host.com" +class TestDockerBuildCommand: + @pytest.mark.parametrize( + ("machine", "verbose", "expected"), + [ + pytest.param( + "x86_64", + False, + DockerBuildCommand(("docker", "build"), ()), + id="amd64_host_builds_natively", + ), + pytest.param( + "arm64", + False, + DockerBuildCommand( + ("docker", "buildx", "build"), + ("--platform", "linux/amd64", "--load", "--progress=quiet"), + ), + id="other_hosts_cross_build_quietly", + ), + pytest.param( + "arm64", + True, + DockerBuildCommand( + ("docker", "buildx", "build"), + ("--platform", "linux/amd64", "--load"), + ), + id="verbose_cross_build_keeps_progress_output", + ), + ], + ) + def test_for_host_targets_the_deployment_platform(self, machine, verbose, expected): + assert DockerBuildCommand.for_host(machine, verbose=verbose) == expected + + class TestResolvePushedImageDigest: """Tests for ``_resolve_pushed_image_digest`` — runner is mocked to return the ``(stdout, stderr)`` tuple that ``subp_exec(collect=True)``