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/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/config.py b/libs/cli/langgraph_cli/config.py index 5ef67b673..e9f679247 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.). @@ -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( diff --git a/libs/cli/langgraph_cli/util.py b/libs/cli/langgraph_cli/util.py index 79b67a2c9..683ed44bc 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/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 02eec1560..f458cbcbc 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -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 diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 5a8ce91da..060b4ab0d 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, ) @@ -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}"