diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 3a3b8b6b0..4bcacc932 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -408,6 +408,11 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE "--build-command", help="Custom build command to run from the langgraph.json directory. If not provided, uses default build process.", ) +@click.option( + "--single-user-layer", + is_flag=True, + help="Combine generated Python build steps into one image layer when supported.", +) @click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED) @cli.command( help="📦 Build LangGraph API server Docker image.", @@ -426,6 +431,7 @@ def build( tag: str, install_command: str | None, build_command: str | None, + single_user_layer: bool, ): if install_command and langgraph_cli.config.has_disallowed_build_command_content( install_command @@ -461,6 +467,7 @@ def build( docker_build_args, install_command, build_command, + single_user_layer=single_user_layer, ) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 57b225187..517ca18bf 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1267,6 +1267,7 @@ def python_config_to_docker( api_version: str | None = None, *, escape_variables: bool = False, + single_user_layer: bool = False, ) -> tuple[str, dict[str, str]]: """Generate a Dockerfile from the configuration.""" source_kind = _get_source_kind(config) @@ -1428,6 +1429,91 @@ ADD {relpath} /deps/{name} ] ) image_str = docker_tag(config, base_image, api_version) + dep_vname = "$$dep" if escape_variables else "$dep" + local_deps_install_str = f"""RUN for dep in /deps/*; do \ + echo "Installing {dep_vname}"; \ + if [ -d "{dep_vname}" ]; then \ + echo "Installing {dep_vname}"; \ + (cd "{dep_vname}" && {global_reqs_pip_install} -e .); \ + fi; \ + done""" + + if ( + single_user_layer + and not config["dockerfile_lines"] + and not local_deps.additional_contexts + and not config.get("ui") + and not config.get("node_version") + and not escape_variables + ): + commands = ["set -eu"] + context = pathlib.PurePosixPath("/__build_context") + if pip_config_file := config.get("pip_config_file"): + commands.append( + f"cp {shlex.quote(str(context / pip_config_file))} /pipconfig.txt" + ) + if pypi_deps: + commands.append(f"{local_reqs_pip_install} {' '.join(pypi_deps)}") + for reqpath, destpath in local_deps.pip_reqs: + source = context / reqpath.relative_to(config_path.parent) + commands.extend( + [ + f"mkdir -p {shlex.quote(str(pathlib.PurePosixPath(destpath).parent))}", + f"cp {shlex.quote(str(source))} {shlex.quote(destpath)}", + ] + ) + if local_deps.pip_reqs: + commands.append( + f"{local_reqs_pip_install} " + + " ".join(f"-r {path}" for _, path in local_deps.pip_reqs) + ) + for _, (relpath, name) in local_deps.real_pkgs.items(): + destination = f"/deps/{name}" + source = f"{context / relpath}/." + commands.extend( + [ + f"mkdir -p {shlex.quote(destination)}", + f"cp -a {shlex.quote(source)} {shlex.quote(destination)}/", + ] + ) + for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items(): + source = f"{context / relpath}/." + pyproject = shlex.quote(f"/deps/outer-{fullpath.name}/pyproject.toml") + commands.extend( + [ + f"mkdir -p {shlex.quote(destpath)}", + f"cp -a {shlex.quote(source)} {shlex.quote(destpath)}/", + "for line in '[project]' " + f"'name = \"{fullpath.name}\"' " + "'version = \"0.1\"' " + "'[tool.setuptools.package-data]' " + '\'"*" = ["**/*"]\' ' + "'[build-system]' " + "'requires = [\"setuptools>=61\"]' " + "'build-backend = \"setuptools.build_meta\"'; do " + f'echo "$line" >> {pyproject}; done', + ] + ) + commands.append(local_deps_install_str.removeprefix("RUN ")) + commands.extend( + line.removeprefix("RUN ") + for line in _get_pip_cleanup_lines( + install_cmd=install_cmd, + to_uninstall=build_tools_to_uninstall, + pip_installer=pip_installer, + ).splitlines() + if line and not line.startswith("#") + ) + docker_file_contents = [ + "# syntax=docker/dockerfile:1.7", + f"FROM {image_str}", + "RUN --mount=type=bind,target=/__build_context,readonly <<'USER_LAYER'", + *commands, + "USER_LAYER", + *env_vars, + f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "", + ] + return os.linesep.join(docker_file_contents), additional_contexts # Prepare docker file contents docker_file_contents = [] @@ -1442,14 +1528,6 @@ ADD {relpath} /deps/{name} ) # Add main dockerfile content - dep_vname = "$$dep" if escape_variables else "$dep" - local_deps_install_str = f"""RUN for dep in /deps/*; do \ - echo "Installing {dep_vname}"; \ - if [ -d "{dep_vname}" ]; then \ - echo "Installing {dep_vname}"; \ - (cd "{dep_vname}" && {global_reqs_pip_install} -e .); \ - fi; \ - done""" docker_file_contents.extend( [ f"FROM {image_str}", @@ -1625,6 +1703,7 @@ def config_to_docker( build_command: str | None = None, build_context: str | None = None, escape_variables: bool = False, + single_user_layer: bool = False, ) -> tuple[str, dict[str, str]]: base_image = base_image or default_base_image(config) @@ -1645,6 +1724,7 @@ def config_to_docker( base_image=base_image, api_version=api_version, escape_variables=escape_variables, + single_user_layer=single_user_layer, ) diff --git a/libs/cli/langgraph_cli/docker.py b/libs/cli/langgraph_cli/docker.py index a376b5698..f9376398a 100644 --- a/libs/cli/langgraph_cli/docker.py +++ b/libs/cli/langgraph_cli/docker.py @@ -345,6 +345,7 @@ def build_docker_image( docker_command: Sequence[str] | None = None, extra_flags: Sequence[str] = (), verbose: bool = True, + single_user_layer: bool = False, ): """Build a Docker image from a LangGraph config.""" # pull latest images @@ -387,6 +388,7 @@ def build_docker_image( install_command=install_command, build_command=build_command, build_context=build_context, + single_user_layer=single_user_layer, ) # add additional_contexts if additional_contexts: diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index fb9a36ccf..05f708161 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -723,6 +723,23 @@ WORKDIR /deps/outer-unit_tests/unit_tests\ } +def test_config_to_docker_single_user_layer(): + dockerfile, additional_contexts = config_to_docker( + PATH_TO_CONFIG, + validate_config( + {"dependencies": ["."], "graphs": {"agent": "./agent.py:graph"}} + ), + base_image="langchain/langgraph-api", + single_user_layer=True, + ) + + assert additional_contexts == {} + assert dockerfile.count("\nRUN ") == 1 + assert "--mount=type=bind" in dockerfile + assert "cp -a /__build_context/. /deps/outer-unit_tests/unit_tests/" in dockerfile + assert dockerfile.endswith("WORKDIR /deps/outer-unit_tests/unit_tests") + + def test_config_to_docker_outside_path(): graphs = {"agent": "./agent.py:graph"} actual_docker_stdin, additional_contexts = config_to_docker(