Compare commits

...
Author SHA1 Message Date
William Fu-Hinthorn 46e524d96c bazel ugh 2026-02-25 20:24:25 -08:00
3 changed files with 400 additions and 61 deletions
+160
View File
@@ -1,5 +1,7 @@
"""CLI entrypoint for LangGraph API server.""" """CLI entrypoint for LangGraph API server."""
import difflib
import json
import os import os
import pathlib import pathlib
import shutil import shutil
@@ -165,6 +167,10 @@ def cli():
pass pass
def _format_json(value: object) -> str:
return json.dumps(value, indent=2, sort_keys=True) + os.linesep
@OPT_RECREATE @OPT_RECREATE
@OPT_PULL @OPT_PULL
@OPT_PORT @OPT_PORT
@@ -606,6 +612,160 @@ def dockerfile(
) )
@cli.group(
"build-spec",
help="🧾 Export or verify a machine-readable build specification for hermetic builds.",
)
def build_spec() -> None:
pass
@OPT_CONFIG
@click.option(
"--base-image",
help="Base image to use for the LangGraph API server. Defaults to langchain/langgraph-api or langchain/langgraphjs-api.",
)
@OPT_API_VERSION
@click.option(
"--install-command",
help="Custom install command (Node projects only). If omitted, auto-detects based on package manager files.",
)
@click.option(
"--build-command",
help="Custom build command to run from the langgraph.json directory (Node projects only).",
)
@click.option(
"--output",
"-o",
type=click.Path(
exists=False,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
help="Path to write the build spec JSON. If omitted, prints to stdout.",
)
@build_spec.command("export")
@log_command
def build_spec_export(
config: pathlib.Path,
base_image: str | None,
api_version: str | None,
install_command: str | None,
build_command: str | None,
output: pathlib.Path | None,
) -> None:
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
is_js_project = config_json.get("node_version") and not config_json.get(
"python_version"
)
if is_js_project and (build_command or install_command):
build_context = str(pathlib.Path.cwd())
else:
build_context = str(config.parent)
spec = langgraph_cli.config.config_to_build_spec(
config_path=config,
config=config_json,
base_image=base_image,
api_version=api_version,
install_command=install_command,
build_command=build_command,
build_context=build_context,
)
rendered = _format_json(spec)
if output is None:
click.echo(rendered, nl=False)
return
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="utf-8") as f:
f.write(rendered)
secho(f"✅ Created build spec: {output}", fg="green")
@OPT_CONFIG
@click.argument(
"spec_path",
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
)
@click.option(
"--base-image",
help="Base image to use for the LangGraph API server. Defaults to langchain/langgraph-api or langchain/langgraphjs-api.",
)
@OPT_API_VERSION
@click.option(
"--install-command",
help="Custom install command (Node projects only). If omitted, auto-detects based on package manager files.",
)
@click.option(
"--build-command",
help="Custom build command to run from the langgraph.json directory (Node projects only).",
)
@build_spec.command("verify")
@log_command
def build_spec_verify(
config: pathlib.Path,
spec_path: pathlib.Path,
base_image: str | None,
api_version: str | None,
install_command: str | None,
build_command: str | None,
) -> None:
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
is_js_project = config_json.get("node_version") and not config_json.get(
"python_version"
)
if is_js_project and (build_command or install_command):
build_context = str(pathlib.Path.cwd())
else:
build_context = str(config.parent)
expected = langgraph_cli.config.config_to_build_spec(
config_path=config,
config=config_json,
base_image=base_image,
api_version=api_version,
install_command=install_command,
build_command=build_command,
build_context=build_context,
)
expected_str = _format_json(expected)
with open(spec_path, encoding="utf-8") as f:
actual = json.load(f)
actual_str = _format_json(actual)
if actual_str == expected_str:
secho(f"✅ Build spec is in sync: {spec_path}", fg="green")
return
diff = "\n".join(
difflib.unified_diff(
actual_str.splitlines(),
expected_str.splitlines(),
fromfile=str(spec_path),
tofile="generated",
lineterm="",
)
)
raise click.ClickException(
f"Build spec is out of sync: {spec_path}\n\n{diff}"
) from None
@click.option( @click.option(
"--host", "--host",
default="127.0.0.1", default="127.0.0.1",
+98 -61
View File
@@ -4,7 +4,7 @@ import pathlib
import re import re
import textwrap import textwrap
from collections import Counter from collections import Counter
from typing import Literal, NamedTuple from typing import Any, Literal, NamedTuple
import click import click
@@ -874,6 +874,55 @@ def get_build_tools_to_uninstall(config: Config) -> tuple[str]:
) )
def _build_langgraph_env(
config: Config, *, runtime: Literal["python", "node"]
) -> dict[str, str]:
"""Build runtime environment variables that are serialized from config."""
env_map: dict[str, str] = {}
if (store_config := config.get("store")) is not None:
env_map["LANGGRAPH_STORE"] = json.dumps(store_config)
if (auth_config := config.get("auth")) is not None:
env_map["LANGGRAPH_AUTH"] = json.dumps(auth_config)
if (encryption_config := config.get("encryption")) is not None:
env_map["LANGGRAPH_ENCRYPTION"] = json.dumps(encryption_config)
if (http_config := config.get("http")) is not None:
env_map["LANGGRAPH_HTTP"] = json.dumps(http_config)
if (webhooks_config := config.get("webhooks")) is not None:
env_map["LANGGRAPH_WEBHOOKS"] = json.dumps(webhooks_config)
if (checkpointer_config := config.get("checkpointer")) is not None:
env_map["LANGGRAPH_CHECKPOINTER"] = json.dumps(checkpointer_config)
# Keep Python/Node behavior consistent with current Dockerfile generation:
# Python emits UI vars when explicitly set (including empty dict), while
# Node currently emits them only when truthy.
if runtime == "python":
if (ui := config.get("ui")) is not None:
env_map["LANGGRAPH_UI"] = json.dumps(ui)
if (ui_config := config.get("ui_config")) is not None:
env_map["LANGGRAPH_UI_CONFIG"] = json.dumps(ui_config)
else:
if ui := config.get("ui"):
env_map["LANGGRAPH_UI"] = json.dumps(ui)
if ui_config := config.get("ui_config"):
env_map["LANGGRAPH_UI_CONFIG"] = json.dumps(ui_config)
env_map["LANGSERVE_GRAPHS"] = json.dumps(config["graphs"])
return env_map
def _extract_workdir(dockerfile: str) -> str | None:
for line in dockerfile.splitlines():
if line.startswith("WORKDIR "):
return line.removeprefix("WORKDIR ").strip() or None
return None
def python_config_to_docker( def python_config_to_docker(
config_path: pathlib.Path, config_path: pathlib.Path,
config: Config, config: Config,
@@ -1006,36 +1055,8 @@ ADD {relpath} /deps/{name}
) )
) )
env_vars = [] env_map = _build_langgraph_env(config, runtime="python")
env_vars = [f"ENV {key}='{value}'" for key, value in env_map.items()]
if (store_config := config.get("store")) is not None:
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
if (auth_config := config.get("auth")) is not None:
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
if (encryption_config := config.get("encryption")) is not None:
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
# Inject webhooks configuration if provided
if (webhooks_config := config.get("webhooks")) is not None:
env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
if (checkpointer_config := config.get("checkpointer")) is not None:
env_vars.append(
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
)
if (ui := config.get("ui")) is not None:
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
if (ui_config := config.get("ui_config")) is not None:
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
js_inst_str: str = "" js_inst_str: str = ""
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir: if (config.get("ui") or config.get("node_version")) and local_deps.working_dir:
@@ -1137,36 +1158,8 @@ def node_config_to_docker(
image_str = docker_tag(config, base_image, api_version) image_str = docker_tag(config, base_image, api_version)
env_vars: list[str] = [] env_map = _build_langgraph_env(config, runtime="node")
env_vars = [f"ENV {key}='{value}'" for key, value in env_map.items()]
if (store_config := config.get("store")) is not None:
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
if (auth_config := config.get("auth")) is not None:
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
if (encryption_config := config.get("encryption")) is not None:
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
# Inject webhooks configuration if provided
if (webhooks_config := config.get("webhooks")) is not None:
env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
if (checkpointer_config := config.get("checkpointer")) is not None:
env_vars.append(
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
)
if ui := config.get("ui"):
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
if ui_config := config.get("ui_config"):
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
# For monorepo support, we need to handle install and build commands differently # For monorepo support, we need to handle install and build commands differently
if build_context: if build_context:
@@ -1292,6 +1285,50 @@ def config_to_docker(
) )
def config_to_build_spec(
config_path: pathlib.Path,
config: Config,
*,
base_image: str | None = None,
api_version: str | None = None,
install_command: str | None = None,
build_command: str | None = None,
build_context: str | None = None,
) -> dict[str, Any]:
"""Generate a machine-readable, versioned build specification."""
# Normalize via JSON round-trip to avoid mutating the caller's dictionary.
normalized: Config = json.loads(json.dumps(config))
is_node_runtime = bool(
normalized.get("node_version") and not normalized.get("python_version")
)
runtime: Literal["python", "node"] = "node" if is_node_runtime else "python"
resolved_base_image = docker_tag(normalized, base_image, api_version)
dockerfile, additional_contexts = config_to_docker(
config_path=config_path,
config=normalized,
base_image=base_image,
api_version=api_version,
install_command=install_command,
build_command=build_command,
build_context=build_context,
)
env_map = _build_langgraph_env(normalized, runtime=runtime)
return {
"schema_version": 1,
"kind": "langgraph.build_spec",
"runtime": runtime,
"resolved_base_image": resolved_base_image,
"build_context": build_context,
"additional_contexts": additional_contexts,
"env": env_map,
"graphs": normalized["graphs"],
"working_dir": _extract_workdir(dockerfile),
"dockerfile": dockerfile,
}
def config_to_compose( def config_to_compose(
config_path: pathlib.Path, config_path: pathlib.Path,
config: Config, config: Config,
+142
View File
@@ -287,6 +287,148 @@ def test_version_option() -> None:
) )
def test_build_spec_export_command_python_to_stdout() -> None:
runner = CliRunner()
config_content = {
"python_version": "3.11",
"image_distro": "wolfi",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
(temp_dir / "agent.py").touch()
result = runner.invoke(
cli,
["build-spec", "export", "--config", str(temp_dir / "config.json")],
)
assert result.exit_code == 0, result.output
spec = json.loads(result.output)
assert spec["schema_version"] == 1
assert spec["kind"] == "langgraph.build_spec"
assert spec["runtime"] == "python"
assert spec["resolved_base_image"] == "langchain/langgraph-api:3.11-wolfi"
assert spec["env"]["LANGSERVE_GRAPHS"] == '{"agent": "agent.py:graph"}'
assert spec["working_dir"] is not None
assert spec["working_dir"].startswith("/deps/")
def test_build_spec_export_command_node_to_file() -> None:
runner = CliRunner()
config_content = {
"node_version": "20",
"image_distro": "wolfi",
"graphs": {"agent": "src/agent.ts:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
spec_path = temp_dir / "buildspec.json"
(temp_dir / "src").mkdir(parents=True, exist_ok=True)
(temp_dir / "src" / "agent.ts").touch()
(temp_dir / "package.json").write_text("{}", encoding="utf-8")
result = runner.invoke(
cli,
[
"build-spec",
"export",
"--config",
str(temp_dir / "config.json"),
"--output",
str(spec_path),
],
)
assert result.exit_code == 0, result.output
assert spec_path.exists()
with open(spec_path, encoding="utf-8") as f:
spec = json.load(f)
assert spec["runtime"] == "node"
assert spec["resolved_base_image"] == "langchain/langgraphjs-api:20-wolfi"
assert spec["working_dir"] is not None
assert spec["working_dir"].startswith("/deps/")
def test_build_spec_verify_command_in_sync() -> None:
runner = CliRunner()
config_content = {
"python_version": "3.11",
"image_distro": "wolfi",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
spec_path = temp_dir / "buildspec.json"
(temp_dir / "agent.py").touch()
export_result = runner.invoke(
cli,
[
"build-spec",
"export",
"--config",
str(temp_dir / "config.json"),
"--output",
str(spec_path),
],
)
assert export_result.exit_code == 0, export_result.output
verify_result = runner.invoke(
cli,
[
"build-spec",
"verify",
str(spec_path),
"--config",
str(temp_dir / "config.json"),
],
)
assert verify_result.exit_code == 0, verify_result.output
assert "Build spec is in sync" in verify_result.output
def test_build_spec_verify_command_out_of_sync() -> None:
runner = CliRunner()
config_content = {
"python_version": "3.11",
"image_distro": "wolfi",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
spec_path = temp_dir / "buildspec.json"
(temp_dir / "agent.py").touch()
export_result = runner.invoke(
cli,
[
"build-spec",
"export",
"--config",
str(temp_dir / "config.json"),
"--output",
str(spec_path),
],
)
assert export_result.exit_code == 0, export_result.output
with open(spec_path, encoding="utf-8") as f:
spec = json.load(f)
spec["runtime"] = "node"
with open(spec_path, "w", encoding="utf-8") as f:
json.dump(spec, f, indent=2, sort_keys=True)
verify_result = runner.invoke(
cli,
[
"build-spec",
"verify",
str(spec_path),
"--config",
str(temp_dir / "config.json"),
],
)
assert verify_result.exit_code != 0
assert "Build spec is out of sync" in verify_result.output
def test_dockerfile_command_basic() -> None: def test_dockerfile_command_basic() -> None:
"""Test the 'dockerfile' command with basic configuration.""" """Test the 'dockerfile' command with basic configuration."""
runner = CliRunner() runner = CliRunner()