Expand flat Docker builds

This commit is contained in:
Parker Rule
2026-08-28 12:22:04 -04:00
parent ffd16825a4
commit f4c8adfc69
3 changed files with 177 additions and 33 deletions
+1 -1
View File
@@ -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(
+89 -32
View File
@@ -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(
+87
View File
@@ -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",