From f4c8adfc69b1e6d75edd12ad2ef02d2dc6705265 Mon Sep 17 00:00:00 2001 From: Parker Rule Date: Fri, 28 Aug 2026 12:22:04 -0400 Subject: [PATCH] Expand flat Docker builds --- libs/cli/langgraph_cli/cli.py | 2 +- libs/cli/langgraph_cli/config.py | 121 +++++++++++++++++------ libs/cli/tests/unit_tests/test_config.py | 87 ++++++++++++++++ 3 files changed, 177 insertions(+), 33 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index f5b114ce4..0467c92b4 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -412,7 +412,7 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE "--flat", is_flag=True, envvar="LANGGRAPH_CLI_FLAT_IMAGES", - help="Flatten generated Python build steps into one image layer when supported.", + help="Flatten generated build steps into one image layer when supported.", ) @click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED) @cli.command( diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index fb6b742db..f7aa694da 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1162,16 +1162,6 @@ def _build_runtime_env_vars(config: Config) -> list[str]: return env_vars -def _can_flatten(config: Config, local_deps: LocalDeps, escape_variables: bool) -> bool: - return not ( - config["dockerfile_lines"] - or local_deps.additional_contexts - or config.get("ui") - or config.get("node_version") - or escape_variables - ) - - def _faux_package_pyproject_command(package_name: str) -> str: pyproject_path = shlex.quote(f"/deps/outer-{package_name}/pyproject.toml") return f"""cat > {pyproject_path} <<'PYPROJECT' @@ -1196,10 +1186,29 @@ def _build_flat_dockerfile( pip_cleanup: str, image: str, env_vars: list[str], + additional_context_names: dict[pathlib.Path, str], ) -> str: context = pathlib.PurePosixPath("/__build_context") + additional_context_root = pathlib.PurePosixPath("/__additional_contexts") + js_working_dir = ( + local_deps.working_dir + if config.get("ui") or config.get("node_version") + else None + ) commands = ["set -eu"] + if js_working_dir: + commands.append("/storage/install-node.sh") + + def source_path( + path: pathlib.Path, + relative_path: str = ".", + additional_relative_path: str = ".", + ) -> str: + if name := additional_context_names.get(path): + return str(additional_context_root / name / additional_relative_path) + return str(context / relative_path) + if pip_config_file := config.get("pip_config_file"): source = shlex.quote(str(context / pip_config_file)) commands.append(f"cp {source} /pipconfig.txt") @@ -1207,44 +1216,69 @@ def _build_flat_dockerfile( commands.append(f"{local_reqs_pip_install} {' '.join(pypi_deps)}") for reqpath, destination in local_deps.pip_reqs: - source = context / reqpath.relative_to(config_path.parent) + source = source_path( + reqpath.parent, + str(reqpath.relative_to(config_path.parent)), + reqpath.name, + ) destination_parent = pathlib.PurePosixPath(destination).parent commands.append(f"""mkdir -p {shlex.quote(str(destination_parent))} -cp {shlex.quote(str(source))} {shlex.quote(destination)}""") +cp {shlex.quote(source)} {shlex.quote(destination)}""") if local_deps.pip_reqs: requirements = " ".join( f"-r {destination}" for _, destination in local_deps.pip_reqs ) commands.append(f"{local_reqs_pip_install} {requirements}") - for _, (relative_path, name) in local_deps.real_pkgs.items(): - source = f"{context / relative_path}/." + for full_path, (relative_path, name) in local_deps.real_pkgs.items(): + source = f"{source_path(full_path, relative_path)}/." destination = f"/deps/{name}" commands.append(f"""mkdir -p {shlex.quote(destination)} cp -a {shlex.quote(source)} {shlex.quote(destination)}/""") for full_path, (relative_path, destination) in local_deps.faux_pkgs.items(): - source = f"{context / relative_path}/." + source = f"{source_path(full_path, relative_path)}/." pyproject = _faux_package_pyproject_command(full_path.name) commands.append(f"""mkdir -p {shlex.quote(destination)} cp -a {shlex.quote(source)} {shlex.quote(destination)}/ {pyproject}""") commands.append(local_deps_install_command) + if js_working_dir: + working_dir = shlex.quote(js_working_dir) + commands.append( + f"cd {working_dir}\n" + f"{_get_node_pm_install_cmd(config_path.parent)} " + "&& tsx /api/langgraph_api/js/build.mts" + ) commands.extend( line.removeprefix("RUN ") for line in pip_cleanup.splitlines() if line and not line.startswith("#") ) + mounts = ["--mount=type=bind,target=/__build_context,readonly"] + mounts.extend( + f"--mount=type=bind,from={name},target={additional_context_root / name},readonly" + for name in additional_context_names.values() + ) + run = f"RUN {' '.join(mounts)} <<'USER_LAYER'" + node_env = ( + f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}" + if js_working_dir + else "" + ) + return os.linesep.join( [ "# syntax=docker/dockerfile:1.7", f"FROM {image}", - "RUN --mount=type=bind,target=/__build_context,readonly <<'USER_LAYER'", + *config["dockerfile_lines"], + node_env, + *env_vars, + run, *commands, "USER_LAYER", - *env_vars, f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "", ] ) @@ -1531,7 +1565,7 @@ ADD {relpath} /deps/{name} pip_installer=pip_installer, ) - if flat and _can_flatten(config, local_deps, escape_variables): + if flat: return ( _build_flat_dockerfile( config_path=config_path, @@ -1543,6 +1577,7 @@ ADD {relpath} /deps/{name} pip_cleanup=pip_cleanup, image=image_str, env_vars=env_vars, + additional_context_names=additional_context_names, ), additional_contexts, ) @@ -1593,6 +1628,7 @@ def node_config_to_docker( install_command: str | None = None, build_command: str | None = None, build_context: str | None = None, + flat: bool = False, ) -> tuple[str, dict[str, str]]: # Calculate paths for monorepo support install_root = ( @@ -1619,22 +1655,42 @@ def node_config_to_docker( # Monorepo case: install from root, build from config directory container_root = f"/deps/{pathlib.Path(build_context).name}" install_workdir = container_root - install_step = f"RUN {install_cmd}" - - if build_command: - build_step = f"RUN {build_command}" - else: - build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts' else: # Original behavior: everything happens in the same directory install_workdir = faux_path - install_step = f"RUN {install_cmd}" - build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts' - if build_context: - build_workdir = faux_path - else: - build_workdir = faux_path + build_workdir = faux_path + build_cmd = ( + build_command + if build_context and build_command + else '(test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts' + ) + + if flat: + context = pathlib.PurePosixPath("/__build_context") + destination = container_root if build_context else faux_path + commands = [ + "set -eu", + f"mkdir -p {shlex.quote(destination)}\n" + f"cp -a {shlex.quote(f'{context}/.')} {shlex.quote(destination)}/", + f"cd {shlex.quote(install_workdir)}\n{install_cmd}", + f"cd {shlex.quote(build_workdir)}\n{build_cmd}", + ] + return ( + os.linesep.join( + [ + "# syntax=docker/dockerfile:1.7", + f"FROM {image_str}", + *config["dockerfile_lines"], + *env_vars, + "RUN --mount=type=bind,target=/__build_context,readonly <<'USER_LAYER'", + *commands, + "USER_LAYER", + f"WORKDIR {build_workdir}", + ] + ), + {}, + ) docker_file_contents = [ f"FROM {image_str}", @@ -1645,13 +1701,13 @@ def node_config_to_docker( "", f"WORKDIR {install_workdir}", "", - install_step, + f"RUN {install_cmd}", "", os.linesep.join(env_vars), "", f"WORKDIR {build_workdir}", "", - build_step, + f"RUN {build_cmd}", ] return os.linesep.join(docker_file_contents), {} @@ -1744,6 +1800,7 @@ def config_to_docker( install_command=install_command, build_command=build_command, build_context=build_context, + flat=flat, ) return python_config_to_docker( diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 8879ce4da..a13bd881d 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -742,6 +742,48 @@ def test_config_to_docker_flat(): assert dockerfile.endswith("WORKDIR /deps/outer-unit_tests/unit_tests") +def test_config_to_docker_flat_with_custom_lines_and_escaped_variables(): + dockerfile, _ = config_to_docker( + PATH_TO_CONFIG, + validate_config( + { + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "dockerfile_lines": ["RUN echo custom"], + } + ), + base_image="langchain/langgraph-api", + escape_variables=True, + flat=True, + ) + + assert dockerfile.index("RUN echo custom") < dockerfile.index("USER_LAYER") + assert 'echo "Installing $$dep"' in dockerfile + + +def test_config_to_docker_flat_with_additional_context(): + dockerfile, additional_contexts = config_to_docker( + PATH_TO_CONFIG, + validate_config( + {"dependencies": [".", ".."], "graphs": {"agent": "./agent.py:graph"}} + ), + base_image="langchain/langgraph-api", + flat=True, + ) + + assert additional_contexts == { + "outer-tests": str(pathlib.Path(__file__).parent.parent.absolute()), + } + assert ( + "--mount=type=bind,from=outer-tests," + "target=/__additional_contexts/outer-tests,readonly" in dockerfile + ) + assert ( + "cp -a /__additional_contexts/outer-tests/. /deps/outer-tests/tests/" + in dockerfile + ) + + def test_config_to_docker_outside_path(): graphs = {"agent": "./agent.py:graph"} actual_docker_stdin, additional_contexts = config_to_docker( @@ -1017,6 +1059,28 @@ RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not foun assert additional_contexts == {} +def test_config_to_docker_nodejs_flat(): + dockerfile, additional_contexts = config_to_docker( + PATH_TO_CONFIG, + validate_config( + { + "node_version": "20", + "graphs": {"agent": "./graphs/agent.js:graph"}, + "dockerfile_lines": ["RUN echo custom"], + } + ), + base_image="langchain/langgraphjs-api", + flat=True, + ) + + assert additional_contexts == {} + assert dockerfile.index("RUN echo custom") < dockerfile.index("USER_LAYER") + assert "cp -a /__build_context/. /deps/unit_tests/" in dockerfile + assert "cd /deps/unit_tests\nnpm i" in dockerfile + assert "tsx /api/langgraph_api/js/build.mts" in dockerfile + assert dockerfile.endswith("WORKDIR /deps/unit_tests") + + def test_config_to_docker_python_encryption(): # Test that encryption config is included in validation graphs = {"agent": "./agent.py:graph"} @@ -1233,6 +1297,29 @@ WORKDIR /deps/outer-unit_tests/unit_tests""" assert additional_contexts == {} +def test_config_to_docker_gen_ui_python_flat(): + dockerfile, additional_contexts = config_to_docker( + PATH_TO_CONFIG, + validate_config( + { + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "ui": {"agent": "./graphs/agent.ui.jsx"}, + } + ), + base_image="langchain/langgraph-api", + flat=True, + ) + + assert additional_contexts == {} + assert dockerfile.count("\nRUN ") == 1 + assert dockerfile.index("ENV NODE_VERSION=20") < dockerfile.index("\nRUN ") + assert dockerfile.index("ENV LANGGRAPH_UI=") < dockerfile.index("\nRUN ") + assert "/storage/install-node.sh" in dockerfile + assert "cd /deps/outer-unit_tests/unit_tests\nnpm i" in dockerfile + assert "tsx /api/langgraph_api/js/build.mts" in dockerfile + + def test_config_to_docker_multiplatform(): graphs = { "python": "./multiplatform/python.py:graph",