From c46f7a4c3d4480819c8589bfe6c960e5c97155b1 Mon Sep 17 00:00:00 2001 From: David Asamu Date: Thu, 29 May 2025 21:54:54 +0100 Subject: [PATCH 1/5] add support for image_distro in config file --- libs/cli/generate_schema.py | 14 ++++ libs/cli/langgraph_cli/config.py | 24 ++++++- libs/cli/tests/unit_tests/test_config.py | 86 ++++++++++++++++++++++-- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/libs/cli/generate_schema.py b/libs/cli/generate_schema.py index 7a76ca5fa..041012c61 100644 --- a/libs/cli/generate_schema.py +++ b/libs/cli/generate_schema.py @@ -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] diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 5ef67b673..b48ac7779 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -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.). @@ -517,12 +525,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 +587,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: @@ -1306,6 +1325,7 @@ def docker_tag( base_image: Optional[str] = None, ) -> str: base_image = base_image or default_base_image(config) + wolfi_tag = "-wolfi" if config.get("image_distro") == "wolfi" else "" if config.get("_INTERNAL_docker_tag"): return f"{base_image}:{config['_INTERNAL_docker_tag']}" @@ -1313,8 +1333,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']}{wolfi_tag}" + return f"{base_image}:{config['python_version']}{wolfi_tag}" def config_to_docker( diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 5a8ce91da..9d2fc7745 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -34,6 +34,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 +55,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,18 +122,90 @@ 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", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "http": {"app": "../../examples/my_app.py"}, + }) + + +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.12", + "python_version": "3.11", "dependencies": ["."], "graphs": {"agent": "./agent.py:graph"}, - "http": {"app": "../../examples/my_app.py"}, + "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 with Node.js config too + config = validate_config( + { + "node_version": "20", + "graphs": {"agent": "./agent.js:graph"}, + "image_distro": "wolfi", + } + ) + assert config["image_distro"] == "wolfi" + + # Test Node.js config with default + config = validate_config( + { + "node_version": "20", + "graphs": {"agent": "./agent.js:graph"}, + } + ) + assert config["image_distro"] == "debian" def test_validate_config_file(): From 574a9246a6820a37f03f48f510f7f19bdb7be76d Mon Sep 17 00:00:00 2001 From: David Asamu Date: Thu, 29 May 2025 23:30:27 +0100 Subject: [PATCH 2/5] add unit tests for image_distro config --- libs/cli/langgraph_cli/config.py | 9 +- libs/cli/tests/unit_tests/test_config.py | 131 ++++++++++++++++++++++- 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index b48ac7779..a391db4d4 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1325,7 +1325,10 @@ def docker_tag( base_image: Optional[str] = None, ) -> str: base_image = base_image or default_base_image(config) - wolfi_tag = "-wolfi" if config.get("image_distro") == "wolfi" else "" + + 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']}" @@ -1333,8 +1336,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']}{wolfi_tag}" - return f"{base_image}:{config['python_version']}{wolfi_tag}" + return f"{base_image}:{config['node_version']}{distro_tag}" + return f"{base_image}:{config['python_version']}{distro_tag}" def config_to_docker( diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 9d2fc7745..e3aed1bc2 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -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, ) @@ -188,7 +189,7 @@ def test_validate_config_image_distro(): ) assert "Invalid image_distro: 'alpine'" in str(exc_info.value) - # Test with Node.js config too + # Test base Node.js config with image distro config = validate_config( { "node_version": "20", @@ -198,7 +199,7 @@ def test_validate_config_image_distro(): ) assert config["image_distro"] == "wolfi" - # Test Node.js config with default + # Test Node.js config with no distro specified config = validate_config( { "node_version": "20", @@ -965,3 +966,129 @@ 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}" + From 1e8f5dd2b65188e237e4615c4c8cf3a93584769e Mon Sep 17 00:00:00 2001 From: David Asamu Date: Fri, 30 May 2025 00:25:27 +0100 Subject: [PATCH 3/5] add warning when image distro is not configured as wolfi --- libs/cli/langgraph_cli/cli.py | 4 ++ libs/cli/langgraph_cli/util.py | 23 ++++++ libs/cli/tests/unit_tests/cli/test_cli.py | 88 +++++++++++++++++++++++ libs/cli/tests/unit_tests/test_config.py | 1 - 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index cf066876a..cf2c1429c 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -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( diff --git a/libs/cli/langgraph_cli/util.py b/libs/cli/langgraph_cli/util.py index 79b67a2c9..61c2ae8b8 100644 --- a/libs/cli/langgraph_cli/util.py +++ b/libs/cli/langgraph_cli/util.py @@ -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 diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 02eec1560..b9101fe76 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -438,3 +438,91 @@ 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 diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index e3aed1bc2..6acdb00ca 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -1091,4 +1091,3 @@ def test_docker_tag_different_node_versions_with_distro(): }) tag = docker_tag(config) assert tag == expected_tag, f"Failed for Node.js {node_version}" - From bbbadc3db93645119d2865d5b7b4073276477cbf Mon Sep 17 00:00:00 2001 From: David Asamu Date: Mon, 2 Jun 2025 20:50:48 +0100 Subject: [PATCH 4/5] regenerate schema + lint & format --- libs/cli/langgraph_cli/util.py | 2 +- libs/cli/schemas/schema.json | 30 ++++ libs/cli/schemas/schema.v0.json | 30 ++++ libs/cli/tests/unit_tests/cli/test_cli.py | 14 +- libs/cli/tests/unit_tests/test_config.py | 176 ++++++++++++---------- 5 files changed, 170 insertions(+), 82 deletions(-) diff --git a/libs/cli/langgraph_cli/util.py b/libs/cli/langgraph_cli/util.py index 61c2ae8b8..683ed44bc 100644 --- a/libs/cli/langgraph_cli/util.py +++ b/libs/cli/langgraph_cli/util.py @@ -19,7 +19,7 @@ def warn_non_wolfi_distro(config_json: dict) -> None: fg="yellow", ) click.secho( - " To switch, add '\"image_distro\": \"wolfi\"' to your langgraph.json config file.", + ' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.', fg="yellow", ) click.secho("") # Empty line for better readability diff --git a/libs/cli/schemas/schema.json b/libs/cli/schemas/schema.json index a8972fb0c..b47bf0013 100644 --- a/libs/cli/schemas/schema.json +++ b/libs/cli/schemas/schema.json @@ -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": [ { diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json index a8972fb0c..b47bf0013 100644 --- a/libs/cli/schemas/schema.v0.json +++ b/libs/cli/schemas/schema.v0.json @@ -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": [ { diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index b9101fe76..f458cbcbc 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -462,7 +462,7 @@ def test_dockerfile_command_shows_wolfi_warning() -> None: # 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 @@ -492,7 +492,7 @@ def test_dockerfile_command_no_wolfi_warning_when_wolfi_set() -> None: # 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 @@ -516,11 +516,17 @@ def test_build_command_shows_wolfi_warning() -> None: with runner.isolated_filesystem(): result = runner.invoke( cli, - ["build", "--tag", "test-image", "--config", str(temp_dir / "config.json")], + [ + "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, + # 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 diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 6acdb00ca..060b4ab0d 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -124,12 +124,14 @@ def test_validate_config(): ) assert config["python_version"] == "3.12-slim" with pytest.raises(ValueError, match="Invalid http.app format"): - validate_config({ - "python_version": "3.12", - "dependencies": ["."], - "graphs": {"agent": "./agent.py:graph"}, - "http": {"app": "../../examples/my_app.py"}, - }) + validate_config( + { + "python_version": "3.12", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "http": {"app": "../../examples/my_app.py"}, + } + ) def test_validate_config_image_distro(): @@ -189,7 +191,7 @@ def test_validate_config_image_distro(): ) assert "Invalid image_distro: 'alpine'" in str(exc_info.value) - # Test base Node.js config with image distro + # Test base Node.js config with image distro config = validate_config( { "node_version": "20", @@ -970,124 +972,144 @@ def test_config_to_compose_end_to_end(): 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"}, - }) + 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", - }) + 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", - }) + 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"}, - }) + 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", - }) + 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", - }) + 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", - }) + 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", - }) + 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") + ("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", - }) + 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") + ("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", - }) + 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}" From 1e324b681ac1096707b34e36c2826f087f61ccd4 Mon Sep 17 00:00:00 2001 From: David Asamu Date: Tue, 3 Jun 2025 01:43:59 +0100 Subject: [PATCH 5/5] update dockerfile generation logic, fix pip removal in wolfi --- libs/cli/langgraph_cli/config.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index a391db4d4..e9f679247 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -466,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 --""" @@ -1104,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) @@ -1224,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}", "", @@ -1267,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] = [] @@ -1294,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"]), "",