refactor(cli): build and push through one spec and one command object

This commit is contained in:
Hugo Durand
2026-09-18 13:41:35 -04:00
parent ac4198d25b
commit 6063a65bbe
3 changed files with 180 additions and 178 deletions
+137 -176
View File
@@ -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,
)
+8 -2
View File
@@ -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():
@@ -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)``