From 6906e12edb551162f5fede5d9f55b4bb206bb59c Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Wed, 13 Nov 2024 12:42:58 -0500 Subject: [PATCH] cli: add ability to output docker compose file (#2379) * Add ability to output docker compose file `langgraph dockerfile Dockerfile --add-docker-compose` * Add emoji in places --- libs/cli/langgraph_cli/cli.py | 149 +++++++++++++- libs/cli/langgraph_cli/docker.py | 238 +++++++++++++--------- libs/cli/tests/unit_tests/cli/test_cli.py | 115 ++++++++++- 3 files changed, 398 insertions(+), 104 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 66073a8cf..8d95bdc5d 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -6,6 +6,7 @@ from typing import Callable, Optional, Sequence import click import click.exceptions +from click import secho import langgraph_cli.config import langgraph_cli.docker @@ -170,9 +171,7 @@ def cli(): is_flag=True, help="Wait for services to start before returning. Implies --detach", ) -@cli.command( - help="Start langgraph API server. For local testing, requires a LangSmith API key with access to LangGraph Cloud closed beta. Requires a license key for production use." -) +@cli.command(help="🚀 Launch LangGraph API server.") @log_command def up( config: pathlib.Path, @@ -337,7 +336,7 @@ def _build( ) @click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED) @cli.command( - help="Build langgraph API server docker image", + help="📦 Build LangGraph API server Docker image.", context_settings=dict( ignore_unknown_options=True, ), @@ -360,14 +359,86 @@ def build( ) +def _get_docker_ignore_content() -> str: + """Return the content of a .dockerignore file. + + This file is used to exclude files and directories from the Docker build context. + + It may be overly broad, but it's better to be safe than sorry. + + The main goal is to exclude .env files by default. + """ + return """\ +# Ignore node_modules and other dependency directories +node_modules +bower_components +vendor + +# Ignore logs and temporary files +*.log +*.tmp +*.swp + +# Ignore .env files and other environment files +.env +.env.* +*.local + +# Ignore git-related files +.git +.gitignore + +# Ignore Docker-related files and configs +.dockerignore +docker-compose.yml + +# Ignore build and cache directories +dist +build +.cache +__pycache__ + +# Ignore IDE and editor configurations +.vscode +.idea +*.sublime-project +*.sublime-workspace +.DS_Store # macOS-specific + +# Ignore test and coverage files +coverage +*.coverage +*.test.js +*.spec.js +tests +""" + + @OPT_CONFIG @click.argument("save_path", type=click.Path(resolve_path=True)) -@cli.command(help="Generate a Dockerfile for langgraph API server") +@cli.command( + help="🐳 Generate a Dockerfile for the LangGraph API server, with Docker Compose options." +) +@click.option( + # Add a flag for adding a docker-compose.yml file as part of the output + "--add-docker-compose", + help=( + "Add additional files for running the LangGraph API server with " + "docker-compose. These files include a docker-compose.yml, .env file, " + "and a .dockerignore file." + ), + is_flag=True, +) @log_command -def dockerfile(save_path: pathlib.Path, config: pathlib.Path): - with open(config) as f: +def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -> None: + save_path = pathlib.Path(save_path).absolute() + secho(f"🔍 Validating configuration at path: {config}", fg="yellow") + with open(config, encoding="utf-8") as f: config_json = langgraph_cli.config.validate_config(json.load(f)) - with open(save_path, "w") as f: + secho("✅ Configuration validated!", fg="green") + + secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow") + with open(str(save_path), "w", encoding="utf-8") as f: f.write( langgraph_cli.config.config_to_docker( config, @@ -377,6 +448,66 @@ def dockerfile(save_path: pathlib.Path, config: pathlib.Path): else "langchain/langgraph-api", ) ) + secho("✅ Created: Dockerfile", fg="green") + + if add_docker_compose: + # Add docker compose and related files + # Add .dockerignore file in the same directory as the Dockerfile + with open(str(save_path.parent / ".dockerignore"), "w", encoding="utf-8") as f: + f.write(_get_docker_ignore_content()) + secho("✅ Created: .dockerignore", fg="green") + + # Generate a docker-compose.yml file + path = str(save_path.parent / "docker-compose.yml") + with open(path, "w", encoding="utf-8") as f: + with Runner() as runner: + capabilities = langgraph_cli.docker.check_capabilities(runner) + + compose_dict = langgraph_cli.docker.compose_as_dict( + capabilities, + port=8123, + ) + # Add .env file to the docker-compose.yml for the langgraph-api service + compose_dict["services"]["langgraph-api"]["env_file"] = [".env"] + # Add the Dockerfile to the build context + compose_dict["services"]["langgraph-api"]["build"] = { + "context": ".", + "dockerfile": save_path.name, + } + f.write(langgraph_cli.docker.dict_to_yaml(compose_dict)) + secho("✅ Created: docker-compose.yml", fg="green") + + # Check if the .env file exists in the same directory as the Dockerfile + if not (save_path.parent / ".env").exists(): + # Also add an empty .env file + with open(str(save_path.parent / ".env"), "w", encoding="utf-8") as f: + f.writelines( + [ + "# Uncomment the following line to add your LangSmith API key", + "\n", + "# LANGSMITH_API_KEY=your-api-key", + "\n", + "# Or if you have a LangGraph Cloud license key, " + "then uncomment the following line: ", + "\n", + "# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key", + "\n", + "# Add any other environment variables go below...", + ] + ) + + secho("✅ Created: .env", fg="green") + else: + # Do nothing since the .env file already exists. Not a great + # idea to overwrite in case the user has added custom env vars set + # in the .env file already. + secho("➖ Skipped: .env. It already exists!", fg="yellow") + + secho( + f"🎉 Files generated successfully at path {save_path.parent}!", + fg="cyan", + bold=True, + ) @click.argument("path", required=False) @@ -385,7 +516,7 @@ def dockerfile(save_path: pathlib.Path, config: pathlib.Path): type=str, help=TEMPLATE_HELP_STRING, ) -@cli.command("new", help="Create a new LangGraph project from a template.") +@cli.command("new", help="🌱 Create a new LangGraph project from a template.") @log_command def new(path: Optional[str], template: Optional[str]) -> None: """Create a new LangGraph project from a template.""" diff --git a/libs/cli/langgraph_cli/docker.py b/libs/cli/langgraph_cli/docker.py index f28274f4d..5d4c1cddd 100644 --- a/libs/cli/langgraph_cli/docker.py +++ b/libs/cli/langgraph_cli/docker.py @@ -12,34 +12,6 @@ DEFAULT_POSTGRES_URI = ( "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable" ) -REDIS = """ - langgraph-redis: - image: redis:6 - healthcheck: - test: redis-cli ping - interval: 5s - timeout: 1s - retries: 5 -""" - -DB = """ - langgraph-postgres: - image: postgres:16 - ports: - - "5433:5432" - environment: - POSTGRES_DB: postgres - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - volumes: - - langgraph-data:/var/lib/postgresql/data - healthcheck: - test: pg_isready -U postgres - start_period: 10s - timeout: 1s - retries: 5 -""" - class Version(NamedTuple): major: int @@ -116,28 +88,149 @@ def check_capabilities(runner) -> DockerCapabilities: def debugger_compose( *, port: Optional[int] = None, base_url: Optional[str] = None -) -> str: +) -> dict: if port is None: return "" - compose_str = """ - langgraph-debugger: - image: langchain/langgraph-debugger - restart: on-failure - depends_on: - langgraph-postgres: - condition: service_healthy - ports: - - "{port}:3968" -""" + config = { + "langgraph-debugger": { + "image": "langchain/langgraph-debugger", + "restart": "on-failure", + "depends_on": { + "langgraph-postgres": {"condition": "service_healthy"}, + }, + "ports": [f'"{port}:3968"'], + } + } if base_url: - compose_str += """ - environment: - VITE_STUDIO_LOCAL_GRAPH_URL: {base_url} -""" + config["langgraph-debugger"]["environment"] = { + "VITE_STUDIO_LOCAL_GRAPH_URL": base_url + } - return compose_str.format(port=port, base_url=base_url) + return config + + +# Function to convert dictionary to YAML +def dict_to_yaml(d: dict, *, indent: int = 0) -> str: + """Convert a dictionary to a YAML string.""" + yaml_str = "" + + for idx, (key, value) in enumerate(d.items()): + # Format things in a visually appealing way + # Use an extra newline for top-level keys only + if idx >= 1 and indent < 2: + yaml_str += "\n" + space = " " * indent + if isinstance(value, dict): + yaml_str += f"{space}{key}:\n" + dict_to_yaml(value, indent=indent + 1) + elif isinstance(value, list): + yaml_str += f"{space}{key}:\n" + for item in value: + yaml_str += f"{space} - {item}\n" + else: + yaml_str += f"{space}{key}: {value}\n" + return yaml_str + + +def compose_as_dict( + capabilities: DockerCapabilities, + *, + port: int, + debugger_port: Optional[int] = None, + debugger_base_url: Optional[str] = None, + # postgres://user:password@host:port/database?option=value + postgres_uri: Optional[str] = None, +) -> dict: + """Create a docker compose file as a dictionary in YML style.""" + if postgres_uri is None: + include_db = True + postgres_uri = DEFAULT_POSTGRES_URI + else: + include_db = False + + # The services below are defined in a non-intuitive order to match + # the existing unit tests for this function. + # It's fine to re-order just requires updating the unit tests, so it should + # be done with caution. + + # Define the Redis service first as per the test order + services = { + "langgraph-redis": { + "image": "redis:6", + "healthcheck": { + "test": "redis-cli ping", + "interval": "5s", + "timeout": "1s", + "retries": 5, + }, + } + } + + # Add Postgres service before langgraph-api if it is needed + if include_db: + services["langgraph-postgres"] = { + "image": "postgres:16", + "ports": ['"5433:5432"'], + "environment": { + "POSTGRES_DB": "postgres", + "POSTGRES_USER": "postgres", + "POSTGRES_PASSWORD": "postgres", + }, + "volumes": ["langgraph-data:/var/lib/postgresql/data"], + "healthcheck": { + "test": "pg_isready -U postgres", + "start_period": "10s", + "timeout": "1s", + "retries": 5, + }, + } + if capabilities.healthcheck_start_interval: + services["langgraph-postgres"]["healthcheck"]["interval"] = "60s" + services["langgraph-postgres"]["healthcheck"]["start_interval"] = "1s" + else: + services["langgraph-postgres"]["healthcheck"]["interval"] = "5s" + + # Add optional debugger service if debugger_port is specified + if debugger_port: + services["langgraph-debugger"] = debugger_compose( + port=debugger_port, base_url=debugger_base_url + )["langgraph-debugger"] + + # Add langgraph-api service + services["langgraph-api"] = { + "ports": [f'"{port}:8000"'], + "depends_on": { + "langgraph-redis": {"condition": "service_healthy"}, + }, + "environment": { + "REDIS_URI": "redis://langgraph-redis:6379", + "POSTGRES_URI": postgres_uri, + }, + } + + # If Postgres is included, add it to the dependencies of langgraph-api + if include_db: + services["langgraph-api"]["depends_on"]["langgraph-postgres"] = { + "condition": "service_healthy" + } + + # Additional healthcheck for langgraph-api if required + if capabilities.healthcheck_start_interval: + services["langgraph-api"]["healthcheck"] = { + "test": "python /api/healthcheck.py", + "interval": "60s", + "start_interval": "1s", + "start_period": "10s", + } + + # Final compose dictionary with volumes included if needed + compose_dict = {} + if include_db: + compose_dict["volumes"] = {"langgraph-data": {"driver": "local"}} + compose_dict["services"] = services + + return compose_dict def compose( @@ -149,54 +242,13 @@ def compose( # postgres://user:password@host:port/database?option=value postgres_uri: Optional[str] = None, ) -> str: - if postgres_uri is None: - include_db = True - postgres_uri = DEFAULT_POSTGRES_URI - else: - include_db = False - - db = DB.format() if include_db else "" - volumes = ( - """volumes: - langgraph-data: - driver: local -""" - if include_db - else "" + """Create a docker compose file as a string.""" + compose_content = compose_as_dict( + capabilities, + port=port, + debugger_port=debugger_port, + debugger_base_url=debugger_base_url, + postgres_uri=postgres_uri, ) - if db: - if capabilities.healthcheck_start_interval: - db += """ - interval: 60s - start_interval: 1s""" - else: - db += """ - interval: 5s""" - - compose_str = f"""{volumes}services: -{REDIS} -{db} -{debugger_compose(port=debugger_port, base_url=debugger_base_url)} - langgraph-api: - ports: - - "{port}:8000\" - depends_on: - langgraph-redis: - condition: service_healthy""" - if include_db: - compose_str += """ - langgraph-postgres: - condition: service_healthy""" - compose_str += f""" - environment: - REDIS_URI: redis://langgraph-redis:6379 - POSTGRES_URI: {postgres_uri} -""" - if capabilities.healthcheck_start_interval: - compose_str += """ healthcheck: - test: python /api/healthcheck.py - interval: 60s - start_interval: 1s - start_period: 10s""" - + compose_str = dict_to_yaml(compose_content) return compose_str diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 2c7984c72..52e2b682f 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -1,4 +1,9 @@ +import json import pathlib +import shutil +import tempfile +from contextlib import contextmanager +from pathlib import Path from click.testing import CliRunner @@ -14,7 +19,26 @@ DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities( ) -def test_prepare_args_and_stdin(): +@contextmanager +def temporary_config_folder(config_content: dict): + # Create a temporary directory + temp_dir = tempfile.mkdtemp() + try: + # Define the path for the config.json file + config_path = Path(temp_dir) / "config.json" + + # Write the provided dictionary content to config.json + with open(config_path, "w", encoding="utf-8") as config_file: + json.dump(config_content, config_file) + + # Yield the temporary directory path for use within the context + yield config_path.parent + finally: + # Cleanup the temporary directory and its contents + shutil.rmtree(temp_dir) + + +def test_prepare_args_and_stdin() -> None: # this basically serves as an end-to-end test for using config and docker helpers config_path = pathlib.Path("./langgraph.json") config = validate_config( @@ -28,7 +52,7 @@ def test_prepare_args_and_stdin(): capabilities=DEFAULT_DOCKER_CAPABILITIES, config_path=config_path, config=config, - docker_compose="custom-docker-compose.yml", + docker_compose=pathlib.Path("custom-docker-compose.yml"), port=port, debugger_port=debugger_port, debugger_base_url=debugger_graph_url, @@ -131,3 +155,90 @@ def test_version_option() -> None: assert ( "LangGraph CLI, version" in result.output ), "Expected version information in output" + + +def test_dockerfile_command_basic() -> None: + """Test the 'dockerfile' command with basic configuration.""" + runner = CliRunner() + config_content = { + "node_version": "20", # Add any other necessary configuration fields + "graphs": {"agent": "agent.py:graph"}, + } + + with temporary_config_folder(config_content) as temp_dir: + save_path = temp_dir / "Dockerfile" + + 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 + assert "✅ Created: Dockerfile" in result.output + + # Check if Dockerfile was created + assert save_path.exists() + + +def test_dockerfile_command_with_docker_compose() -> None: + """Test the 'dockerfile' command with Docker Compose configuration.""" + runner = CliRunner() + config_content = { + "dependencies": ["./my_agent"], + "graphs": {"agent": "./my_agent/agent.py:graph"}, + "env": ".env", + } + with temporary_config_folder(config_content) as temp_dir: + save_path = temp_dir / "Dockerfile" + # Add agent.py file + agent_path = temp_dir / "my_agent" / "agent.py" + agent_path.parent.mkdir(parents=True, exist_ok=True) + agent_path.touch() + + result = runner.invoke( + cli, + [ + "dockerfile", + str(save_path), + "--config", + str(temp_dir / "config.json"), + "--add-docker-compose", + ], + ) + + # Assert command was successful + assert result.exit_code == 0 + assert "✅ Created: Dockerfile" in result.output + assert "✅ Created: .dockerignore" in result.output + assert "✅ Created: docker-compose.yml" in result.output + assert ( + "✅ Created: .env" in result.output or "➖ Skipped: .env" in result.output + ) + assert "🎉 Files generated successfully" in result.output + + # Check if Dockerfile, .dockerignore, docker-compose.yml, and .env were created + assert save_path.exists() + assert (temp_dir / ".dockerignore").exists() + assert (temp_dir / "docker-compose.yml").exists() + assert (temp_dir / ".env").exists() or "➖ Skipped: .env" in result.output + + +def test_dockerfile_command_with_bad_config() -> None: + """Test the 'dockerfile' command with basic configuration.""" + runner = CliRunner() + config_content = { + "node_version": "20" # Add any other necessary configuration fields + } + + with temporary_config_folder(config_content) as temp_dir: + save_path = temp_dir / "Dockerfile" + + result = runner.invoke( + cli, + ["dockerfile", str(save_path), "--config", str(temp_dir / "conf.json")], + ) + + # Assert command was successful + assert result.exit_code == 2 + assert "conf.json' does not exist" in result.output