From fc9c710baeb65b3ff1b7901ec3c802d316c6df5c Mon Sep 17 00:00:00 2001 From: hari-dhanushkodi Date: Wed, 11 Feb 2026 17:41:47 -0500 Subject: [PATCH] feat: add langgraph deploy command --- libs/cli/langgraph_cli/cli.py | 230 ++++++++++++++++++++++++- libs/cli/langgraph_cli/host_backend.py | 79 +++++++++ libs/langgraph/subgraph.py | 87 ++++++++++ 3 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 libs/cli/langgraph_cli/host_backend.py create mode 100644 libs/langgraph/subgraph.py diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index af78f9000..19c0cb0ae 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -2,6 +2,7 @@ import os import pathlib +import re import shutil import sys from collections.abc import Callable, Sequence @@ -17,6 +18,7 @@ from langgraph_cli.config import Config from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT from langgraph_cli.docker import DockerCapabilities from langgraph_cli.exec import Runner, subp_exec +from langgraph_cli.host_backend import HostBackendClient from langgraph_cli.progress import Progress from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new from langgraph_cli.util import warn_non_wolfi_distro @@ -304,6 +306,8 @@ def _build( passthrough: Sequence[str] = (), install_command: str | None = None, build_command: str | None = None, + docker_command: Sequence[str] | None = None, + extra_flags: Sequence[str] = (), ): # pull latest images if pull: @@ -348,11 +352,12 @@ def _build( if additional_contexts: for k, v in additional_contexts.items(): args.extend(["--build-context", f"{k}={v}"]) + cmd = tuple(docker_command) if docker_command else ("docker", "build") runner.run( subp_exec( - "docker", - "build", + *cmd, *args, + *extra_flags, *passthrough, build_context, input=stdin, @@ -429,6 +434,227 @@ def build( ) +@OPT_CONFIG +@OPT_PULL +@OPT_VERBOSE +@OPT_API_VERSION +@click.option( + "--host-url", + envvar="LANGGRAPH_HOST_URL", + help="Base URL for the host backend. Defaults to $LANGGRAPH_HOST_URL.", +) +@click.option( + "--api-key", + envvar="LANGGRAPH_HOST_API_KEY", + help="Host backend API key. If omitted, you will be prompted securely.", +) +@click.option( + "--deployment-id", + help="Existing deployment ID to update. If omitted, a new deployment is created.", +) +@click.option( + "--name", + help="Deployment name used when creating a new deployment.", +) +@click.option( + "--image-name", + help="Repository suffix appended to the registry returned by the host backend.", +) +@click.option( + "--image-tag", + default="latest", + show_default=True, + help="Tag applied to the pushed image.", +) +@click.option( + "--platforms", + default="linux/amd64,linux/arm64", + show_default=True, + help="Comma separated list passed to docker buildx --platform.", +) +@click.option( + "--base-image", + help="Base image to use for building the LangGraph server.", +) +@click.option( + "--install-command", + help="Custom install command to run from the build context root.", +) +@click.option( + "--build-command", + help="Custom build command to run from the langgraph.json directory.", +) +@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED) +@cli.command( + help="🚢 Build and deploy a LangGraph image to the host backend.", + context_settings=dict(ignore_unknown_options=True), +) +@log_command +def deploy( + config: pathlib.Path, + pull: bool, + verbose: bool, + api_version: str | None, + host_url: str | None, + api_key: str | None, + deployment_id: str | None, + name: str | None, + image_name: str | None, + image_tag: str, + platforms: str, + base_image: str | None, + install_command: str | None, + build_command: str | None, + docker_build_args: Sequence[str], +): + host_url = host_url or os.getenv("LANGGRAPH_HOST_URL") + if not host_url: + raise click.UsageError("Provide --host-url or set LANGGRAPH_HOST_URL") + api_key = api_key or os.getenv("LANGGRAPH_HOST_API_KEY") + if not api_key: + api_key = click.prompt("Host API key", hide_input=True) + + if not deployment_id and not name: + raise click.UsageError( + "--name is required when --deployment-id is not provided" + ) + + config_json = langgraph_cli.config.validate_config_file(config) + warn_non_wolfi_distro(config_json) + + client = HostBackendClient(host_url, api_key) + + def log_step(message: str) -> None: + click.secho(message, fg="cyan") + + step = 1 + if deployment_id: + log_step(f"{step}. Using deployment {deployment_id}") + step += 1 + else: + log_step(f"{step}. Creating deployment '{name}'") + payload = { + "name": name, + "source": "internal_docker", + "source_config": {"deployment_type": "dev"}, + "source_revision_config": {}, + "secrets": [], + } + created = client.create_deployment(payload) + deployment_id = created["id"] + click.secho(f" Deployment ID: {deployment_id}", fg="green") + step += 1 + + log_step(f"{step}. Requesting push token") + push_data = client.request_push_token(deployment_id) + deployment_token = push_data.get("token") + registry_url = push_data.get("registry_url") + if not deployment_token or not registry_url: + raise click.ClickException("Push token response missing token or registry_url") + step += 1 + + 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_image_name(repo_seed) + tag_value = _normalize_image_tag(image_tag) + remote_image = f"{normalized_registry}/{repo_name}:{tag_value}" + + registry_host = normalized_registry.split("/")[0] + + with Runner() as runner: + langgraph_cli.docker.check_capabilities(runner) + + log_step(f"{step}. Logging into {registry_host}") + token_input = ( + deployment_token + if deployment_token.endswith("\n") + else f"{deployment_token}\n" + ) + runner.run( + subp_exec( + "docker", + "login", + "-u", + "oauth2accesstoken", + "--password-stdin", + registry_host, + input=token_input, + verbose=verbose, + ) + ) + step += 1 + + log_step(f"{step}. Building and pushing image {remote_image}") + build_platforms = _normalize_platforms(platforms) + extra_flags: list[str] = [] + docker_cmd: Sequence[str] | None + if build_platforms: + extra_flags.extend(["--platform", build_platforms]) + docker_cmd = ("docker", "buildx", "build") + extra_flags.append("--push") + else: + docker_cmd = None + + _build( + runner, + lambda _msg: None, + config, + config_json, + base_image, + api_version, + pull, + remote_image, + docker_build_args, + install_command, + build_command, + docker_command=docker_cmd, + extra_flags=extra_flags, + ) + + if not build_platforms: + runner.run( + subp_exec( + "docker", + "push", + remote_image, + verbose=True, + ) + ) + + step += 1 + log_step(f"{step}. Updating deployment {deployment_id}") + client.update_deployment_image(deployment_id, remote_image) + click.secho(" Deployment updated", fg="green") + + +def _normalize_image_name(value: str | None) -> str: + if not value: + return "app" + slug = re.sub(r"[^a-z0-9._-]+", "-", value.lower()).strip("-.") + return slug or "app" + + +def _normalize_image_tag(value: str) -> str: + if not value: + value = "latest" + if not re.fullmatch(r"[A-Za-z0-9_.-]+", value): + raise click.UsageError( + "Image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'" + ) + 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 new file mode 100644 index 000000000..f7500d90c --- /dev/null +++ b/libs/cli/langgraph_cli/host_backend.py @@ -0,0 +1,79 @@ +"""HTTP client for LangGraph host backend deployments.""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any + +import click + + +class HostBackendError(click.ClickException): + """Raised when the host backend returns an error response.""" + + +class HostBackendClient: + """Minimal JSON HTTP client for the host backend deployment service.""" + + def __init__(self, base_url: str, api_key: str): + if not base_url: + raise click.UsageError("Host backend URL is required") + base_url = base_url.rstrip("/") + self._base_url = base_url + self._api_key = api_key + + def _request( + self, method: str, path: str, payload: dict[str, Any] | None = None + ) -> Any: + url = f"{self._base_url}{path}" + data: bytes | None + if payload is not None: + data = json.dumps(payload).encode("utf-8") + else: + data = None + headers = { + "Content-Type": "application/json", + "X-Api-Key": self._api_key, + "Accept": "application/json", + } + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as resp: # noqa: S310 + body = resp.read() + except urllib.error.HTTPError as err: + detail = err.read().decode("utf-8", errors="ignore") + message = detail or err.reason + raise HostBackendError( + f"{method} {path} failed with status {err.code}: {message}" + ) from None + except urllib.error.URLError as err: + raise HostBackendError(str(err.reason)) from None + + if not body: + return None + try: + return json.loads(body) + except json.JSONDecodeError as err: + raise HostBackendError( + f"Failed to decode response from {path}: {err.msg}" + ) from None + + def create_deployment(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._request("POST", "/v2/deployments", payload) + + def request_push_token(self, deployment_id: str) -> dict[str, Any]: + return self._request( + "POST", + f"/v2/deployments/{deployment_id}/push-token", + ) + + def update_deployment_image( + self, deployment_id: str, image_uri: str + ) -> dict[str, Any]: + return self._request( + "PATCH", + f"/v2/deployments/{deployment_id}", + {"source_revision_config": {"image_uri": image_uri}}, + ) diff --git a/libs/langgraph/subgraph.py b/libs/langgraph/subgraph.py new file mode 100644 index 000000000..c8f5e1095 --- /dev/null +++ b/libs/langgraph/subgraph.py @@ -0,0 +1,87 @@ +import operator +from typing import Annotated, TypedDict +import uuid + +from langchain_core.messages import AIMessage +from pydantic import BaseModel, Field +from langgraph.graph import END, START, StateGraph, add_messages +from langgraph.types import Send, interrupt, Command +from langgraph.checkpoint.postgres import PostgresSaver + + +class State(TypedDict): + parent: str + subgraph: str + messages: Annotated[list, add_messages] + + +def subgraph_node(state: State): + val = interrupt("Interrupting from subgraph_node") + return {"subgraph": "jump", "messages": [AIMessage(content="hop")]} + + +subgraph = StateGraph(State) +subgraph.add_node("subgraph_node", subgraph_node) +subgraph.add_edge(START, "subgraph_node") +subgraph_app = subgraph.compile(name="subgraph", checkpointer=True) + + +def parent_node(state: State): + # val = interrupt("Interrupting from parent_node") + # print(f"KAWHIIII {val}") + return {"parent": "sprint"} + + +graph = StateGraph(State) +graph.add_node("parent_node", parent_node) +graph.add_node("subgraph", subgraph_app) +graph.add_edge(START, "parent_node") +graph.add_edge("parent_node", "subgraph") + +with PostgresSaver.from_conn_string( + "postgresql://postgres:postgres@127.0.0.1:5433/postgres" +) as checkpointer: + checkpointer.setup() + + app = graph.compile(checkpointer=checkpointer) + # import pdb + # + # pdb.set_trace() + + thread_id = str(uuid.uuid4()) + config = {"configurable": {"thread_id": str(thread_id)}} + for chunk in app.stream( + input={}, config=config, subgraphs=True, stream_mode=["debug", "values"] + ): + print("\n") + print(chunk) + + # for chunk in app.stream( + # Command(resume="yolo"), + # config, + # subgraphs=True, + # stream_mode=["debug", "values"], + # ): + # print("\n") + # print(chunk) + + # assert [*app.stream({}, config, subgraphs=True, stream_mode="values")] == [ + # ((), {"parent": "sprint", "messages": []}), + # ((AnyStr("subgraph:"),), {"parent": "sprint", "messages": []}), + # ( + # (AnyStr("subgraph:"),), + # { + # "parent": "sprint", + # "subgraph": "jump", + # "messages": [_AnyIdAIMessage(content="hop")], + # }, + # ), + # ( + # (), + # { + # "parent": "sprint", + # "subgraph": "jump", + # "messages": [_AnyIdAIMessage(content="hop")], + # }, + # ), + # ]