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
This commit is contained in:
Eugene Yurtsev
2024-11-13 12:42:58 -05:00
committed by GitHub
parent 7b4c29a20d
commit 6906e12edb
3 changed files with 398 additions and 104 deletions
+140 -9
View File
@@ -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."""
+145 -93
View File
@@ -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
+113 -2
View File
@@ -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