diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index af78f9000..fa054fd0a 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -1,5 +1,7 @@ """CLI entrypoint for LangGraph API server.""" +import difflib +import json import os import pathlib import shutil @@ -165,6 +167,10 @@ def cli(): pass +def _format_json(value: object) -> str: + return json.dumps(value, indent=2, sort_keys=True) + os.linesep + + @OPT_RECREATE @OPT_PULL @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( "--host", default="127.0.0.1", diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 8955ebccd..0d2770a18 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -4,7 +4,7 @@ import pathlib import re import textwrap from collections import Counter -from typing import Literal, NamedTuple +from typing import Any, Literal, NamedTuple 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( config_path: pathlib.Path, config: Config, @@ -1006,36 +1055,8 @@ ADD {relpath} /deps/{name} ) ) - env_vars = [] - - 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'])}'") + env_map = _build_langgraph_env(config, runtime="python") + env_vars = [f"ENV {key}='{value}'" for key, value in env_map.items()] js_inst_str: str = "" 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) - env_vars: list[str] = [] - - 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'])}'") + env_map = _build_langgraph_env(config, runtime="node") + env_vars = [f"ENV {key}='{value}'" for key, value in env_map.items()] # For monorepo support, we need to handle install and build commands differently 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( config_path: pathlib.Path, config: Config, diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 58ef68343..4a69300b2 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -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: """Test the 'dockerfile' command with basic configuration.""" runner = CliRunner()