add support for image_distro in config file (#4871)

This commit is contained in:
Asamu David
2025-06-03 20:59:06 +01:00
committed by GitHub
8 changed files with 453 additions and 15 deletions
+14
View File
@@ -165,6 +165,13 @@ def generate_schema():
if "python_version" in python_schema["properties"]:
python_schema["properties"]["python_version"]["enum"] = ["3.11", "3.12"]
# Add enum constraint for image_distro
if "image_distro" in python_schema["properties"]:
python_schema["properties"]["image_distro"]["anyOf"] = [
{"type": "string", "enum": ["debian", "wolfi"]},
{"type": "null"},
]
# Create Node.js schema with node_version
node_schema = {
"type": "object",
@@ -184,6 +191,13 @@ def generate_schema():
{"type": "null"},
]
# Add enum constraint for image_distro
if "image_distro" in node_schema["properties"]:
node_schema["properties"]["image_distro"]["anyOf"] = [
{"type": "string", "enum": ["debian", "wolfi"]},
{"type": "null"},
]
# Replace the Config schema with a oneOf constraint
config_schema["oneOf"] = [python_schema, node_schema]
+4
View File
@@ -20,6 +20,7 @@ from langgraph_cli.docker import DockerCapabilities
from langgraph_cli.exec import Runner, subp_exec
from langgraph_cli.progress import Progress
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
from langgraph_cli.util import warn_non_wolfi_distro
from langgraph_cli.version import __version__
OPT_DOCKER_COMPOSE = click.option(
@@ -375,6 +376,7 @@ def build(
if shutil.which("docker") is None:
raise click.UsageError("Docker not installed") from None
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
_build(
runner, set, config, config_json, base_image, pull, tag, docker_build_args
)
@@ -466,6 +468,7 @@ def dockerfile(
save_path = pathlib.Path(save_path).absolute()
secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
secho("✅ Configuration validated!", fg="green")
secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow")
@@ -791,6 +794,7 @@ def prepare(
) -> tuple[list[str], str]:
"""Prepare the arguments and stdin for running the LangGraph API server."""
config_json = langgraph_cli.config.validate_config_file(config_path)
warn_non_wolfi_distro(config_json)
# pull latest images
if pull:
runner.run(
+32 -11
View File
@@ -13,6 +13,8 @@ DEFAULT_NODE_VERSION = "20"
MIN_PYTHON_VERSION = "3.11"
DEFAULT_PYTHON_VERSION = "3.11"
DEFAULT_IMAGE_DISTRO = "debian"
class TTLConfig(TypedDict, total=False):
"""Configuration for TTL (time-to-live) behavior in the store."""
@@ -367,6 +369,12 @@ class Config(TypedDict, total=False):
Defaults to langchain/langgraph-api or langchain/langgraphjs-api."""
image_distro: Optional[str]
"""Optional. Linux distribution for the base image.
Must be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.
"""
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.).
@@ -458,7 +466,10 @@ RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir --no-deps -e /api
# -- Removing pip from the final image ~<:===~~~ --
RUN pip uninstall -y pip setuptools wheel && \
rm -rf /usr/local/lib/python*/site-packages/pip* /usr/local/lib/python*/site-packages/setuptools* /usr/local/lib/python*/site-packages/wheel* && \
find /usr/local/bin -name "pip*" -delete
find /usr/local/bin -name "pip*" -delete || true
# pip removal for wolfi
RUN rm -rf /usr/lib/python*/site-packages/pip* /usr/lib/python*/site-packages/setuptools* /usr/lib/python*/site-packages/wheel* && \
find /usr/bin -name "pip*" -delete || true
# -- End of pip removal --"""
@@ -517,12 +528,15 @@ def validate_config(config: Config) -> Config:
"python_version", DEFAULT_PYTHON_VERSION if some_python else None
)
image_distro = config.get("image_distro", DEFAULT_IMAGE_DISTRO)
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"),
"base_image": config.get("base_image"),
"image_distro": image_distro,
"dependencies": config.get("dependencies", []),
"dockerfile_lines": config.get("dockerfile_lines", []),
"graphs": config.get("graphs", {}),
@@ -576,6 +590,14 @@ def validate_config(config: Config) -> Config:
"Add at least one graph to 'graphs' dictionary."
)
# Validate image_distro config
if image_distro := config.get("image_distro"):
if image_distro not in ["debian", "wolfi"]:
raise click.UsageError(
f"Invalid image_distro: '{image_distro}'. "
"Must be either 'debian' or 'wolfi'."
)
# Validate auth config
if auth_conf := config.get("auth"):
if "path" in auth_conf:
@@ -1085,8 +1107,6 @@ 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)
@@ -1205,10 +1225,7 @@ ADD {relpath} /deps/{name}
"# -- End of JS dependencies install --",
]
)
if "/langgraph-server" in base_image:
image_str = f"{base_image}-py{docker_tag}"
else:
image_str = f"{base_image}:{docker_tag}"
image_str = docker_tag(config, base_image)
docker_file_contents = [
f"FROM {image_str}",
"",
@@ -1248,7 +1265,7 @@ def node_config_to_docker(
) -> 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"]
image_str = docker_tag(config, base_image)
env_vars: list[str] = []
@@ -1275,7 +1292,7 @@ def node_config_to_docker(
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
docker_file_contents = [
f"FROM {base_image}:{docker_tag}",
f"FROM {image_str}",
"",
os.linesep.join(config["dockerfile_lines"]),
"",
@@ -1306,6 +1323,10 @@ def docker_tag(
base_image: Optional[str] = None,
) -> str:
base_image = base_image or default_base_image(config)
image_distro = config.get("image_distro")
distro_tag = "" if image_distro == DEFAULT_IMAGE_DISTRO else f"-{image_distro}"
if config.get("_INTERNAL_docker_tag"):
return f"{base_image}:{config['_INTERNAL_docker_tag']}"
@@ -1313,8 +1334,8 @@ def docker_tag(
return f"{base_image}-py{config['python_version']}"
if config.get("node_version") and not config.get("python_version"):
return f"{base_image}:{config['node_version']}"
return f"{base_image}:{config['python_version']}"
return f"{base_image}:{config['node_version']}{distro_tag}"
return f"{base_image}:{config['python_version']}{distro_tag}"
def config_to_docker(
+23
View File
@@ -1,2 +1,25 @@
import click
def clean_empty_lines(input_str: str):
return "\n".join(filter(None, input_str.splitlines()))
def warn_non_wolfi_distro(config_json: dict) -> None:
"""Show warning if image_distro is not set to 'wolfi'."""
image_distro = config_json.get("image_distro", "debian") # Default is debian
if image_distro != "wolfi":
click.secho(
"⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security.",
fg="yellow",
bold=True,
)
click.secho(
" Wolfi is a security-oriented, minimal Linux distribution designed for containers.",
fg="yellow",
)
click.secho(
' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.',
fg="yellow",
)
click.secho("") # Empty line for better readability
+30
View File
@@ -119,6 +119,21 @@
],
"description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
},
"image_distro": {
"anyOf": [
{
"type": "string",
"enum": [
"debian",
"wolfi"
]
},
{
"type": "null"
}
],
"description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n"
},
"store": {
"anyOf": [
{
@@ -257,6 +272,21 @@
],
"description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
},
"image_distro": {
"anyOf": [
{
"type": "string",
"enum": [
"debian",
"wolfi"
]
},
{
"type": "null"
}
],
"description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n"
},
"store": {
"anyOf": [
{
+30
View File
@@ -119,6 +119,21 @@
],
"description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
},
"image_distro": {
"anyOf": [
{
"type": "string",
"enum": [
"debian",
"wolfi"
]
},
{
"type": "null"
}
],
"description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n"
},
"store": {
"anyOf": [
{
@@ -257,6 +272,21 @@
],
"description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
},
"image_distro": {
"anyOf": [
{
"type": "string",
"enum": [
"debian",
"wolfi"
]
},
{
"type": "null"
}
],
"description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n"
},
"store": {
"anyOf": [
{
+94
View File
@@ -438,3 +438,97 @@ def test_dockerfile_command_with_bad_config() -> None:
# Assert command was successful
assert result.exit_code == 2
assert "conf.json' does not exist" in result.output
def test_dockerfile_command_shows_wolfi_warning() -> None:
"""Test the 'dockerfile' command shows warning when image_distro is not wolfi."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
# No image_distro specified - should default to debian and show warning
}
with temporary_config_folder(config_content) as temp_dir:
save_path = temp_dir / "Dockerfile"
agent_path = temp_dir / "agent.py"
agent_path.touch()
result = runner.invoke(
cli,
["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
)
# Assert command was successful
assert result.exit_code == 0, result.output
# Check that warning is shown
assert "Security Recommendation" in result.output
assert "Wolfi Linux" in result.output
assert "image_distro" in result.output
assert "wolfi" in result.output
def test_dockerfile_command_no_wolfi_warning_when_wolfi_set() -> None:
"""Test the 'dockerfile' command does NOT show warning when image_distro is wolfi."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
"image_distro": "wolfi", # Explicitly set to wolfi - should not show warning
}
with temporary_config_folder(config_content) as temp_dir:
save_path = temp_dir / "Dockerfile"
agent_path = temp_dir / "agent.py"
agent_path.touch()
result = runner.invoke(
cli,
["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
)
# Assert command was successful
assert result.exit_code == 0, result.output
# Check that warning is NOT shown
assert "Security Recommendation" not in result.output
assert "Wolfi Linux" not in result.output
def test_build_command_shows_wolfi_warning() -> None:
"""Test the 'build' command shows warning when image_distro is not wolfi."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
# No image_distro specified - should default to debian and show warning
}
with temporary_config_folder(config_content) as temp_dir:
agent_path = temp_dir / "agent.py"
agent_path.touch()
# Mock docker command since we don't want to actually build
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"build",
"--tag",
"test-image",
"--config",
str(temp_dir / "config.json"),
],
catch_exceptions=True,
)
# The command will fail because docker isn't available or we're mocking,
# but we should still see the warning before it fails
assert "Security Recommendation" in result.output
assert "Wolfi Linux" in result.output
assert "image_distro" in result.output
assert "wolfi" in result.output
+226 -4
View File
@@ -11,6 +11,7 @@ from langgraph_cli.config import (
PIP_CLEANUP_LINES,
config_to_compose,
config_to_docker,
docker_tag,
validate_config,
validate_config_file,
)
@@ -34,6 +35,7 @@ def test_validate_config():
"python_version": "3.11",
"node_version": None,
"pip_config_file": None,
"image_distro": "debian",
"dockerfile_lines": [],
"env": {},
"store": None,
@@ -54,6 +56,7 @@ def test_validate_config():
"python_version": "3.12",
"node_version": None,
"pip_config_file": "pipconfig.txt",
"image_distro": "debian",
"dockerfile_lines": ["ARG meow"],
"dependencies": [".", "langchain"],
"graphs": {
@@ -120,10 +123,7 @@ def test_validate_config():
}
)
assert config["python_version"] == "3.12-slim"
with pytest.raises(
ValueError,
match="Invalid http.app format",
):
with pytest.raises(ValueError, match="Invalid http.app format"):
validate_config(
{
"python_version": "3.12",
@@ -134,6 +134,83 @@ def test_validate_config():
)
def test_validate_config_image_distro():
"""Test validation of image_distro field."""
# Valid image_distro values should work
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "debian",
}
)
assert config["image_distro"] == "debian"
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi",
}
)
assert config["image_distro"] == "wolfi"
# Missing image_distro should default to 'debian'
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
assert config["image_distro"] == "debian"
# Invalid image_distro values should raise error
with pytest.raises(click.UsageError) as exc_info:
validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "ubuntu",
}
)
assert "Invalid image_distro: 'ubuntu'" in str(exc_info.value)
assert "Must be either 'debian' or 'wolfi'" in str(exc_info.value)
with pytest.raises(click.UsageError) as exc_info:
validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "alpine",
}
)
assert "Invalid image_distro: 'alpine'" in str(exc_info.value)
# Test base Node.js config with image distro
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
"image_distro": "wolfi",
}
)
assert config["image_distro"] == "wolfi"
# Test Node.js config with no distro specified
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
assert config["image_distro"] == "debian"
def test_validate_config_file():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
@@ -891,3 +968,148 @@ def test_config_to_compose_end_to_end():
watch=True,
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_docker_tag_image_distro():
"""Test docker_tag function with different image_distro configurations."""
# Test 1: Default distro (debian) - no suffix
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
tag = docker_tag(config)
assert tag == "langchain/langgraph-api:3.11"
# Test 2: Explicit debian distro - no suffix (same as default)
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "debian",
}
)
tag = docker_tag(config)
assert tag == "langchain/langgraph-api:3.11"
# Test 3: Wolfi distro - should add suffix
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi",
}
)
tag = docker_tag(config)
assert tag == "langchain/langgraph-api:3.11-wolfi"
# Test 4: Node.js with default distro
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
tag = docker_tag(config)
assert tag == "langchain/langgraphjs-api:20"
# Test 5: Node.js with wolfi distro
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
"image_distro": "wolfi",
}
)
tag = docker_tag(config)
assert tag == "langchain/langgraphjs-api:20-wolfi"
# Test 6: Custom base image with wolfi
config = validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi",
"base_image": "my-registry/custom-image",
}
)
tag = docker_tag(config, base_image="my-registry/custom-image")
assert tag == "my-registry/custom-image:3.12-wolfi"
def test_docker_tag_multiplatform_with_distro():
"""Test docker_tag with multiplatform configs and image_distro."""
# Test 1: Multiplatform (Python + Node) with wolfi
config = validate_config(
{
"python_version": "3.11",
"node_version": "20",
"dependencies": ["."],
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
"image_distro": "wolfi",
}
)
tag = docker_tag(config)
# Should default to Python when both are present
assert tag == "langchain/langgraph-api:3.11-wolfi"
# Test 2: Node-only multiplatform with wolfi
config = validate_config(
{
"node_version": "20",
"graphs": {"js": "./agent.js:graph"},
"image_distro": "wolfi",
}
)
tag = docker_tag(config)
assert tag == "langchain/langgraphjs-api:20-wolfi"
def test_docker_tag_different_python_versions_with_distro():
"""Test docker_tag with different Python versions and distros."""
versions_and_expected = [
("3.11", "langchain/langgraph-api:3.11-wolfi"),
("3.12", "langchain/langgraph-api:3.12-wolfi"),
("3.13", "langchain/langgraph-api:3.13-wolfi"),
]
for python_version, expected_tag in versions_and_expected:
config = validate_config(
{
"python_version": python_version,
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi",
}
)
tag = docker_tag(config)
assert tag == expected_tag, f"Failed for Python {python_version}"
def test_docker_tag_different_node_versions_with_distro():
"""Test docker_tag with different Node.js versions and distros."""
versions_and_expected = [
("20", "langchain/langgraphjs-api:20-wolfi"),
("21", "langchain/langgraphjs-api:21-wolfi"),
("22", "langchain/langgraphjs-api:22-wolfi"),
]
for node_version, expected_tag in versions_and_expected:
config = validate_config(
{
"node_version": node_version,
"graphs": {"agent": "./agent.js:graph"},
"image_distro": "wolfi",
}
)
tag = docker_tag(config)
assert tag == expected_tag, f"Failed for Node.js {node_version}"