feat: add langgraph deploy command

This commit is contained in:
hari-dhanushkodi
2026-02-24 23:22:19 -05:00
parent 8cb87eaf76
commit fc9c710bae
3 changed files with 394 additions and 2 deletions
+228 -2
View File
@@ -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.
+79
View File
@@ -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}},
)
+87
View File
@@ -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")],
# },
# ),
# ]