From 5a200cd89e7b603ff938ff90382222c20114bc2e Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Thu, 17 Apr 2025 22:15:10 +0200 Subject: [PATCH 1/6] feat(cli): add internal docker tag support --- libs/cli/langgraph_cli/cli.py | 18 +++++++++++++--- libs/cli/langgraph_cli/config.py | 36 +++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 07a71c8b9..4e56bb8cc 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -273,6 +273,7 @@ def _build( pull: bool, tag: str, passthrough: Sequence[str] = (), + base_docker_tag: Optional[str] = None, ): # pull latest images if pull: @@ -280,10 +281,13 @@ def _build( subp_exec( "docker", "pull", - langgraph_cli.config.docker_tag(config_json, base_image), + langgraph_cli.config.docker_tag( + config_json, base_image, base_docker_tag + ), verbose=True, ) ) + set("Building...") # apply options args = [ @@ -294,7 +298,7 @@ def _build( ] # apply config stdin, additional_contexts = langgraph_cli.config.config_to_docker( - config, config_json, base_image + config, config_json, base_image, base_docker_tag ) # add additional_contexts if additional_contexts: @@ -355,7 +359,15 @@ def build( raise click.UsageError("Docker not installed") from None config_json = langgraph_cli.config.validate_config_file(config) _build( - runner, set, config, config_json, base_image, pull, tag, docker_build_args + runner, + set, + config, + config_json, + base_image, + pull, + tag, + docker_build_args, + config_json.get("_INTERNAL_docker_tag"), ) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index f11850016..7b18b4a4d 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -326,6 +326,10 @@ class Config(TypedDict, total=False): Must be >= 20 if provided. """ + _INTERNAL_docker_tag: Optional[str] + """Optional. Internal use only. + """ + pip_config_file: Optional[str] """Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling package installation (custom indices, credentials, etc.). @@ -480,6 +484,7 @@ def validate_config(config: Config) -> Config: "node_version": node_version, "python_version": python_version, "pip_config_file": config.get("pip_config_file"), + "_INTERNAL_docker_tag": config.get("_INTERNAL_docker_tag"), "dependencies": config.get("dependencies", []), "dockerfile_lines": config.get("dockerfile_lines", []), "graphs": config.get("graphs", {}), @@ -1025,7 +1030,10 @@ def _get_node_pm_install_cmd(config_path: pathlib.Path, config: Config) -> str: def python_config_to_docker( - config_path: pathlib.Path, config: Config, base_image: str + config_path: pathlib.Path, + config: Config, + base_image: str, + base_docker_tag: Optional[str] = None, ) -> tuple[str, dict[str, str]]: """Generate a Dockerfile from the configuration.""" # configure pip @@ -1160,7 +1168,7 @@ ADD {relpath} /deps/{name} ) docker_file_contents = [ - f"FROM {base_image}:{config['python_version']}", + f"FROM {base_image}:{base_docker_tag or config['python_version']}", "", os.linesep.join(config["dockerfile_lines"]), "", @@ -1192,7 +1200,10 @@ ADD {relpath} /deps/{name} def node_config_to_docker( - config_path: pathlib.Path, config: Config, base_image: str + config_path: pathlib.Path, + config: Config, + base_image: str, + base_docker_tag: Optional[str] = None, ) -> tuple[str, dict[str, str]]: faux_path = f"/deps/{config_path.parent.name}" install_cmd = _get_node_pm_install_cmd(config_path, config) @@ -1222,7 +1233,7 @@ def node_config_to_docker( env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'") docker_file_contents = [ - f"FROM {base_image}:{config['node_version']}", + f"FROM {base_image}:{base_docker_tag or config['node_version']}", "", os.linesep.join(config["dockerfile_lines"]), "", @@ -1246,8 +1257,14 @@ def default_base_image(config: Config) -> str: return "langchain/langgraph-api" -def docker_tag(config: Config, base_image: Optional[str] = None) -> str: +def docker_tag( + config: Config, + base_image: Optional[str] = None, + base_docker_tag: Optional[str] = None, +) -> str: base_image = base_image or default_base_image(config) + if base_docker_tag: + return f"{base_image}:{base_docker_tag}" if config.get("node_version") and not config.get("python_version"): return f"{base_image}:{config['node_version']}" @@ -1255,14 +1272,17 @@ def docker_tag(config: Config, base_image: Optional[str] = None) -> str: def config_to_docker( - config_path: pathlib.Path, config: Config, base_image: Optional[str] = None + config_path: pathlib.Path, + config: Config, + base_image: Optional[str] = None, + base_docker_tag: Optional[str] = None, ) -> tuple[str, dict[str, str]]: base_image = base_image or default_base_image(config) if config.get("node_version") and not config.get("python_version"): - return node_config_to_docker(config_path, config, base_image) + return node_config_to_docker(config_path, config, base_image, base_docker_tag) - return python_config_to_docker(config_path, config, base_image) + return python_config_to_docker(config_path, config, base_image, base_docker_tag) def config_to_compose( From d5f73fe37bdade6f2928767266f9bbe6283b98f9 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Thu, 17 Apr 2025 22:21:23 +0200 Subject: [PATCH 2/6] Add tests --- libs/cli/langgraph_cli/config.py | 13 +++++---- libs/cli/tests/unit_tests/test_config.py | 35 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 7b18b4a4d..8170062fe 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1048,6 +1048,8 @@ def python_config_to_docker( else "" ) + docker_tag = config.get("_INTERNAL_docker_tag") or config["python_version"] + # collect dependencies pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")] local_deps = _assemble_local_deps(config_path, config) @@ -1168,7 +1170,7 @@ ADD {relpath} /deps/{name} ) docker_file_contents = [ - f"FROM {base_image}:{base_docker_tag or config['python_version']}", + f"FROM {base_image}:{docker_tag}", "", os.linesep.join(config["dockerfile_lines"]), "", @@ -1203,10 +1205,10 @@ def node_config_to_docker( config_path: pathlib.Path, config: Config, base_image: str, - base_docker_tag: Optional[str] = None, ) -> tuple[str, dict[str, str]]: faux_path = f"/deps/{config_path.parent.name}" install_cmd = _get_node_pm_install_cmd(config_path, config) + docker_tag = config.get("_INTERNAL_docker_tag") or config["node_version"] env_vars: list[str] = [] @@ -1233,7 +1235,7 @@ def node_config_to_docker( env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'") docker_file_contents = [ - f"FROM {base_image}:{base_docker_tag or config['node_version']}", + f"FROM {base_image}:{docker_tag}", "", os.linesep.join(config["dockerfile_lines"]), "", @@ -1275,14 +1277,13 @@ def config_to_docker( config_path: pathlib.Path, config: Config, base_image: Optional[str] = None, - base_docker_tag: Optional[str] = None, ) -> tuple[str, dict[str, str]]: base_image = base_image or default_base_image(config) if config.get("node_version") and not config.get("python_version"): - return node_config_to_docker(config_path, config, base_image, base_docker_tag) + return node_config_to_docker(config_path, config, base_image) - return python_config_to_docker(config_path, config, base_image, base_docker_tag) + return python_config_to_docker(config_path, config, base_image) def config_to_compose( diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 0249510ab..cb25eee30 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -29,6 +29,7 @@ def test_validate_config(): } actual_config = validate_config(expected_config) expected_config = { + "_INTERNAL_docker_tag": None, "python_version": "3.11", "node_version": None, "pip_config_file": None, @@ -47,6 +48,7 @@ def test_validate_config(): # full config env = ".env" expected_config = { + "_INTERNAL_docker_tag": None, "python_version": "3.12", "node_version": None, "pip_config_file": "pipconfig.txt", @@ -567,6 +569,39 @@ RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not foun assert additional_contexts == {} +def test_config_to_docker_nodejs_internal_docker_tag(): + graphs = {"agent": "./graphs/agent.js:graph"} + actual_docker_stdin, additional_contexts = config_to_docker( + PATH_TO_CONFIG, + validate_config( + { + "node_version": "20", + "graphs": graphs, + "dockerfile_lines": ["ARG meow", "ARG foo"], + "auth": {"path": "./graphs/auth.mts:auth"}, + "ui": {"agent": "./graphs/agent.ui.jsx"}, + "ui_config": {"shared": ["nuqs"]}, + "_INTERNAL_docker_tag": "my-tag", + } + ), + "langchain/langgraphjs-api", + ) + expected_docker_stdin = """FROM langchain/langgraphjs-api:my-tag +ARG meow +ARG foo +ADD . /deps/unit_tests +RUN cd /deps/unit_tests && npm i +ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}' +ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}' +ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}' +ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}' +WORKDIR /deps/unit_tests +RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts""" + + assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin + assert additional_contexts == {} + + def test_config_to_docker_gen_ui_python(): graphs = {"agent": "./agent.py:graph"} actual_docker_stdin, additional_contexts = config_to_docker( From 01ce86ad9b1bca1b2053b8dce263d2316502c256 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Thu, 17 Apr 2025 22:21:42 +0200 Subject: [PATCH 3/6] Bump to 0.2.5 --- libs/cli/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index a69df77ed..ccb8822fd 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-cli" -version = "0.2.4" +version = "0.2.5" description = "CLI for interacting with LangGraph API" authors = [] license = "MIT" From db1fbe74cc0920d91c9b10200efb0b86be964c3e Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Thu, 17 Apr 2025 22:22:43 +0200 Subject: [PATCH 4/6] Revert args --- libs/cli/langgraph_cli/cli.py | 17 +++-------------- libs/cli/langgraph_cli/config.py | 5 ++--- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 4e56bb8cc..1f4b73d43 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -273,7 +273,6 @@ def _build( pull: bool, tag: str, passthrough: Sequence[str] = (), - base_docker_tag: Optional[str] = None, ): # pull latest images if pull: @@ -281,9 +280,7 @@ def _build( subp_exec( "docker", "pull", - langgraph_cli.config.docker_tag( - config_json, base_image, base_docker_tag - ), + langgraph_cli.config.docker_tag(config_json, base_image), verbose=True, ) ) @@ -298,7 +295,7 @@ def _build( ] # apply config stdin, additional_contexts = langgraph_cli.config.config_to_docker( - config, config_json, base_image, base_docker_tag + config, config_json, base_image ) # add additional_contexts if additional_contexts: @@ -359,15 +356,7 @@ def build( raise click.UsageError("Docker not installed") from None config_json = langgraph_cli.config.validate_config_file(config) _build( - runner, - set, - config, - config_json, - base_image, - pull, - tag, - docker_build_args, - config_json.get("_INTERNAL_docker_tag"), + runner, set, config, config_json, base_image, pull, tag, docker_build_args ) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 8170062fe..5a363ea42 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1262,11 +1262,10 @@ def default_base_image(config: Config) -> str: def docker_tag( config: Config, base_image: Optional[str] = None, - base_docker_tag: Optional[str] = None, ) -> str: base_image = base_image or default_base_image(config) - if base_docker_tag: - return f"{base_image}:{base_docker_tag}" + if config.get("_INTERNAL_docker_tag"): + return f"{base_image}:{config['_INTERNAL_docker_tag']}" if config.get("node_version") and not config.get("python_version"): return f"{base_image}:{config['node_version']}" From 4bbdfbf3818793c5d213ee33e9b562b84c7c81cb Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Thu, 17 Apr 2025 22:23:44 +0200 Subject: [PATCH 5/6] Cleanup --- libs/cli/langgraph_cli/cli.py | 1 - libs/cli/langgraph_cli/config.py | 1 - 2 files changed, 2 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 1f4b73d43..07a71c8b9 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -284,7 +284,6 @@ def _build( verbose=True, ) ) - set("Building...") # apply options args = [ diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 5a363ea42..826ec3b89 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1033,7 +1033,6 @@ def python_config_to_docker( config_path: pathlib.Path, config: Config, base_image: str, - base_docker_tag: Optional[str] = None, ) -> tuple[str, dict[str, str]]: """Generate a Dockerfile from the configuration.""" # configure pip From e478a8deb915278bbc5104c0cc91c7a8bf1ebdab Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Thu, 17 Apr 2025 22:25:47 +0200 Subject: [PATCH 6/6] Update schema --- libs/cli/schemas/schema.json | 22 ++++++++++++++++++++++ libs/cli/schemas/schema.v0.json | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/libs/cli/schemas/schema.json b/libs/cli/schemas/schema.json index 015f0e209..845e5e0ab 100644 --- a/libs/cli/schemas/schema.json +++ b/libs/cli/schemas/schema.json @@ -29,6 +29,17 @@ ], "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" }, + "_INTERNAL_docker_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Internal use only.\n" + }, "auth": { "anyOf": [ { @@ -145,6 +156,17 @@ ], "description": "Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.\nMust be >= 20 if provided.\n" }, + "_INTERNAL_docker_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Internal use only.\n" + }, "auth": { "anyOf": [ { diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json index 015f0e209..845e5e0ab 100644 --- a/libs/cli/schemas/schema.v0.json +++ b/libs/cli/schemas/schema.v0.json @@ -29,6 +29,17 @@ ], "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" }, + "_INTERNAL_docker_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Internal use only.\n" + }, "auth": { "anyOf": [ { @@ -145,6 +156,17 @@ ], "description": "Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.\nMust be >= 20 if provided.\n" }, + "_INTERNAL_docker_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Internal use only.\n" + }, "auth": { "anyOf": [ {