import copy import json import pathlib import platform import shutil from collections.abc import Callable, Sequence from typing import Literal, NamedTuple import click.exceptions import langgraph_cli.config from langgraph_cli.exec import subp_exec ROOT = pathlib.Path(__file__).parent.resolve() DEFAULT_POSTGRES_URI = ( "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable" ) class Version(NamedTuple): major: int minor: int patch: int DockerComposeType = Literal["plugin", "standalone"] class DockerCapabilities(NamedTuple): version_docker: Version version_compose: Version healthcheck_start_interval: bool compose_type: DockerComposeType = "plugin" def _parse_version(version: str) -> Version: parts = version.split(".", 2) if len(parts) == 1: major = parts[0] minor = "0" patch = "0" elif len(parts) == 2: major, minor = parts patch = "0" else: major, minor, patch = parts return Version( int(major.lstrip("v")), int(minor), int(patch.split("-")[0].split("+")[0]) ) def can_build_locally() -> tuple[bool, str | None]: """Return whether local deployment builds can run on this machine. Checks: - Docker binary is installed - Docker daemon is running - Buildx is available when cross-compilation is required (non-x86_64) """ if shutil.which("docker") is None: return ( False, "Docker is required but not installed.\n" "Install Docker Desktop: https://docs.docker.com/get-docker/", ) try: import subprocess docker_info = subprocess.run( ["docker", "info"], capture_output=True, timeout=10, ) if docker_info.returncode != 0: return ( False, "Docker is installed but not running.\nStart Docker and try again.", ) if platform.machine() != "x86_64": buildx = subprocess.run( ["docker", "buildx", "version"], capture_output=True, timeout=10, ) if buildx.returncode != 0: return ( False, "Docker Buildx is required but not installed.\n" "Your machine architecture (" + platform.machine() + ") requires Buildx to cross-compile images for linux/amd64.\n" "Install Buildx: https://docs.docker.com/build/install-buildx/", ) return True, None except Exception: return False, "Unable to verify local Docker build support." def check_capabilities(runner) -> DockerCapabilities: # check docker available if shutil.which("docker") is None: raise click.UsageError("Docker not installed") from None try: stdout, _ = runner.run( subp_exec("docker", "info", "-f", "{{json .}}", collect=True) ) info = json.loads(stdout) except (click.exceptions.Exit, json.JSONDecodeError): raise click.UsageError("Docker not installed or not running") from None if not info["ServerVersion"]: raise click.UsageError("Docker not running") from None compose_type: DockerComposeType try: compose = next( p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose" ) compose_version_str = compose["Version"] compose_type = "plugin" except (KeyError, StopIteration): if shutil.which("docker-compose") is None: raise click.UsageError("Docker Compose not installed") from None compose_version_str, _ = runner.run( subp_exec("docker-compose", "--version", "--short", collect=True) ) compose_type = "standalone" # parse versions docker_version = _parse_version(info["ServerVersion"]) compose_version = _parse_version(compose_version_str) # check capabilities return DockerCapabilities( version_docker=docker_version, version_compose=compose_version, healthcheck_start_interval=docker_version >= Version(25, 0, 0), compose_type=compose_type, ) def debugger_compose(*, port: int | None = None, base_url: str | None = None) -> dict: if port is None: return "" config = { "langgraph-debugger": { "image": "langchain/langgraph-debugger", "restart": "on-failure", "depends_on": { "langgraph-postgres": {"condition": "service_healthy"}, }, "ports": [f'"{port}:3968"'], } } if base_url: config["langgraph-debugger"]["environment"] = { "VITE_STUDIO_LOCAL_GRAPH_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: int | None = None, debugger_base_url: str | None = None, # postgres://user:password@host:port/database?option=value postgres_uri: str | None = None, # If you are running against an already-built image, you can pass it here image: str | None = None, # Base image to use for the LangGraph API server base_image: str | None = None, # API version of the base image api_version: str | None = None, engine_runtime_mode: str = "combined_queue_worker", ) -> 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": "pgvector/pgvector:pg16", "ports": ['"5433:5432"'], "environment": { "POSTGRES_DB": "postgres", "POSTGRES_USER": "postgres", "POSTGRES_PASSWORD": "postgres", }, "command": ["postgres", "-c", "shared_preload_libraries=vector"], "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 api_environment = { "REDIS_URI": "redis://langgraph-redis:6379", "POSTGRES_URI": postgres_uri, } if engine_runtime_mode == "distributed": api_environment["N_JOBS_PER_WORKER"] = '"0"' services["langgraph-api"] = { "ports": [f'"{port}:8000"'], "depends_on": { "langgraph-redis": {"condition": "service_healthy"}, }, "environment": api_environment, } if image: services["langgraph-api"]["image"] = image # 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( capabilities: DockerCapabilities, *, port: int, debugger_port: int | None = None, debugger_base_url: str | None = None, # postgres://user:password@host:port/database?option=value postgres_uri: str | None = None, image: str | None = None, base_image: str | None = None, api_version: str | None = None, engine_runtime_mode: str = "combined_queue_worker", ) -> str: """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, image=image, base_image=base_image, api_version=api_version, engine_runtime_mode=engine_runtime_mode, ) compose_str = dict_to_yaml(compose_content) return compose_str def build_docker_image( runner, set: Callable[[str], None], config: pathlib.Path, config_json: dict, base_image: str | None, api_version: str | None, pull: bool, tag: str, passthrough: Sequence[str] = (), install_command: str | None = None, build_command: str | None = None, docker_command: Sequence[str] | None = None, extra_flags: Sequence[str] = (), verbose: bool = True, ): """Build a Docker image from a LangGraph config.""" # pull latest images if pull: runner.run( subp_exec( "docker", "pull", langgraph_cli.config.docker_tag(config_json, base_image, api_version), verbose=verbose, ) ) set("Building...") # apply options args = [ "-f", "-", # stdin "-t", tag, ] # determine build context: use current directory for JS projects, config parent for Python is_js_project = config_json.get("node_version") and not config_json.get( "python_version" ) # build/install commands only apply to JS projects for now # without install/build command, JS projects will follow the old behavior if is_js_project and (build_command or install_command): build_context = str(pathlib.Path.cwd()) else: build_context = str(config.parent) # Deep copy to avoid mutating the caller's config (config_to_docker # rewrites graph paths to container-internal paths in place). config_json = copy.deepcopy(config_json) stdin, additional_contexts = langgraph_cli.config.config_to_docker( config_path=config, config=config_json, base_image=base_image, api_version=api_version, install_command=install_command, build_command=build_command, build_context=build_context, ) # add additional_contexts if additional_contexts: for k, v in additional_contexts.items(): args.extend(["--build-context", f"{k}={v}"]) cmd = tuple(docker_command) if docker_command else ("docker", "build") runner.run( subp_exec( *cmd, *args, *extra_flags, *passthrough, build_context, input=stdin, verbose=verbose, ) )