diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index ce825491e..e8dd3cad2 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -5,6 +5,7 @@ import copy import json as json_mod import os import pathlib +import platform import re import shutil import sys @@ -97,7 +98,12 @@ def _parse_dotenv_file(path: pathlib.Path) -> dict[str, str]: key, _, value = line.partition("=") key = key.strip() value = value.strip() - if value and len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + if ( + value + and len(value) >= 2 + and value[0] == value[-1] + and value[0] in ("'", '"') + ): value = value[1:-1] if key: result[key] = value @@ -125,9 +131,7 @@ def _secrets_from_env( secrets: list[dict[str, str]] = [] for name, value in env_vars.items(): if name in RESERVED_ENV_VARS: - click.secho( - f" Skipping reserved env var: {name}", fg="yellow" - ) + click.secho(f" Skipping reserved env var: {name}", fg="yellow") continue if not value: continue @@ -148,37 +152,18 @@ _TERMINAL_STATUSES = frozenset( @contextmanager def _docker_config_for_token(registry_host: str, token: str): - """Create a temporary Docker config using the push token. + """Create a temporary Docker config with only the push token. - This avoids interference from system credential helpers (e.g. gcloud) - that would otherwise take precedence over ``docker login`` credentials. + Yields the path to a temporary config directory that can be passed + to ``docker --config `` so that system credential helpers + (e.g. gcloud) don't interfere with the push token. """ - auth_b64 = base64.b64encode( - f"oauth2accesstoken:{token}".encode() - ).decode() + auth_b64 = base64.b64encode(f"oauth2accesstoken:{token}".encode()).decode() config_data = {"auths": {registry_host: {"auth": auth_b64}}} with tempfile.TemporaryDirectory() as tmpdir: - config_path = os.path.join(tmpdir, "config.json") - with open(config_path, "w") as f: + with open(os.path.join(tmpdir, "config.json"), "w") as f: json_mod.dump(config_data, f) - - # Symlink cli-plugins so Docker can still discover buildx etc. - real_docker_dir = os.environ.get( - "DOCKER_CONFIG", os.path.expanduser("~/.docker") - ) - real_plugins = os.path.join(real_docker_dir, "cli-plugins") - if os.path.isdir(real_plugins): - os.symlink(real_plugins, os.path.join(tmpdir, "cli-plugins")) - - old_value = os.environ.get("DOCKER_CONFIG") - os.environ["DOCKER_CONFIG"] = tmpdir - try: - yield - finally: - if old_value is None: - os.environ.pop("DOCKER_CONFIG", None) - else: - os.environ["DOCKER_CONFIG"] = old_value + yield tmpdir OPT_DOCKER_COMPOSE = click.option( @@ -614,12 +599,6 @@ def build( show_default=True, help="Deployment type (used when creating a new deployment).", ) -@click.option( - "--platforms", - default="linux/amd64,linux/arm64", - show_default=True, - help="Comma-separated list of target platforms.", -) @click.option( "--no-wait", is_flag=True, @@ -676,7 +655,6 @@ def deploy( name: str | None, image_name: str | None, image_tag: str, - platforms: str, base_image: str | None, install_command: str | None, build_command: str | None, @@ -702,14 +680,16 @@ def deploy( secrets = _secrets_from_env(env_vars) - build_platforms = _normalize_platforms(platforms) + # Use buildx to cross-compile for amd64 when running on a non-x86_64 host + # (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())}" with Runner() as runner: langgraph_cli.docker.check_capabilities( runner, require_compose=False, - require_buildx=bool(build_platforms), + require_buildx=needs_buildx, ) def log_step(message: str) -> None: @@ -717,11 +697,14 @@ def deploy( step = 1 - # -- Step: Build image locally first (before creating deployment) -- + # -- Step: Build image -- log_step(f"{step}. Building image") - if build_platforms: - # TODO: deduplicate Dockerfile generation between the two _build() calls - build_flags: list[str] = ["--platform", build_platforms] + 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): @@ -790,18 +773,14 @@ def deploy( "secrets": secrets, } created = client.create_deployment(payload) - created_id = ( - created.get("id") if isinstance(created, dict) else None - ) + created_id = created.get("id") if isinstance(created, dict) else None if not isinstance(created_id, str) or not created_id: raise HostBackendError( "POST /v2/deployments succeeded but response " "missing a valid 'id'" ) deployment_id = created_id - click.secho( - f" Deployment ID: {deployment_id}", fg="green" - ) + click.secho(f" Deployment ID: {deployment_id}", fg="green") step += 1 # -- Step: Get push token and authenticate -- @@ -827,7 +806,7 @@ def deploy( # 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): + with _docker_config_for_token(registry_host, deployment_token) as cfg: log_step(f"{step}. Logging into {registry_host}") token_input = ( deployment_token @@ -837,6 +816,8 @@ def deploy( runner.run( subp_exec( "docker", + "--config", + cfg, "login", "-u", "oauth2accesstoken", @@ -850,41 +831,26 @@ def deploy( # -- Step: Tag and push -- log_step(f"{step}. Pushing image {remote_image}") - if build_platforms: - push_flags = ["--platform", build_platforms, "--push"] - if not verbose: - push_flags.append("--progress=quiet") - with Progress(message="Pushing...", elapsed=not verbose): - _build( - runner, - lambda _msg: None, - config, - config_json, - base_image, - api_version, - False, - remote_image, - docker_build_args, - install_command, - build_command, - docker_command=("docker", "buildx", "build"), - extra_flags=push_flags, - verbose=verbose, - ) - else: + runner.run( + subp_exec( + "docker", + "tag", + local_tag, + remote_image, + verbose=verbose, + ) + ) + with Progress(message="Pushing...", elapsed=not verbose): runner.run( subp_exec( - "docker", "tag", local_tag, remote_image, + "docker", + "--config", + cfg, + "push", + remote_image, verbose=verbose, ) ) - with Progress(message="Pushing...", elapsed=not verbose): - runner.run( - subp_exec( - "docker", "push", remote_image, - verbose=verbose, - ) - ) step += 1 # -- Step: Update deployment -- @@ -914,9 +880,7 @@ def deploy( while time.time() < deadline: rev = client.get_revision(deployment_id, revision_id) status = ( - rev.get("status", "UNKNOWN") - if isinstance(rev, dict) - else "UNKNOWN" + rev.get("status", "UNKNOWN") if isinstance(rev, dict) else "UNKNOWN" ) if status != last_status: last_status = status @@ -945,8 +909,7 @@ def deploy( raise click.exceptions.Exit(1) else: click.secho( - " Timed out waiting for deployment " - f"(last status: {last_status}).", + f" Timed out waiting for deployment (last status: {last_status}).", fg="yellow", ) if custom_url: @@ -978,15 +941,6 @@ def _normalize_image_tag(value: str) -> str: return value -def _normalize_platforms(value: str | None) -> str | None: - if not value: - return None - entries = [item.strip() for item in value.split(",") if item.strip()] - if not entries: - return None - return ",".join(entries) - - def _get_docker_ignore_content() -> str: """Return the content of a .dockerignore file. diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index 2fe59f0e3..c4a5cb423 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -95,17 +95,13 @@ class HostBackendClient: payload, ) - def list_revisions( - self, deployment_id: str, limit: int = 1 - ) -> dict[str, Any]: + def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]: return self._request( "GET", f"/v2/deployments/{deployment_id}/revisions?limit={limit}", ) - def get_revision( - self, deployment_id: str, revision_id: str - ) -> dict[str, Any]: + def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]: return self._request( "GET", f"/v2/deployments/{deployment_id}/revisions/{revision_id}", diff --git a/libs/cli/langgraph_cli/progress.py b/libs/cli/langgraph_cli/progress.py index 4a975c11d..78edfb649 100644 --- a/libs/cli/langgraph_cli/progress.py +++ b/libs/cli/langgraph_cli/progress.py @@ -44,9 +44,7 @@ class Progress: time.sleep(self.delay) continue if self._show_elapsed: - self.message = self._format_elapsed( - time.monotonic() - start - ) + self.message = self._format_elapsed(time.monotonic() - start) message = self.message if not message: continue