cli: Add back up (#854)

* Revert "cli: Reduce to test and build commands (#838)"

This reverts commit 5697f07163.

* Undo

* Undo

* Undo

* Fix

* Fix

* Add license key admonition

* Quote env vars

* Test
This commit is contained in:
Nuno Campos
2024-06-27 09:10:21 -07:00
committed by GitHub
parent 7973df6216
commit 4768bdb0c8
7 changed files with 703 additions and 20 deletions
+200
View File
@@ -9,10 +9,24 @@ import click.exceptions
import langgraph_cli.config
import langgraph_cli.docker
from langgraph_cli.analytics import log_command
from langgraph_cli.config import Config
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
from langgraph_cli.docker import DockerCapabilities
from langgraph_cli.exec import Runner, subp_exec
from langgraph_cli.progress import Progress
OPT_DOCKER_COMPOSE = click.option(
"--docker-compose",
"-d",
help="Advanced: Path to docker-compose.yml file with additional services to launch.",
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
)
OPT_CONFIG = click.option(
"--config",
"-c",
@@ -89,6 +103,12 @@ OPT_PORT = click.option(
\b
""",
)
OPT_RECREATE = click.option(
"--recreate/--no-recreate",
default=False,
show_default=True,
help="Recreate containers even if their configuration and image haven't changed",
)
OPT_PULL = click.option(
"--pull/--no-pull",
default=True,
@@ -108,6 +128,16 @@ OPT_VERBOSE = click.option(
default=False,
help="Show more output from the server logs",
)
OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
OPT_DEBUGGER_PORT = click.option(
"--debugger-port",
type=int,
help="Pull the debugger image locally and serve the UI on specified port",
)
OPT_POSTGRES_URI = click.option(
"--postgres-uri",
help="Postgres URI to use for the database. Defaults to launching a local database",
)
@click.group()
@@ -115,6 +145,101 @@ def cli():
pass
@OPT_RECREATE
@OPT_PULL
@OPT_PORT
@OPT_DOCKER_COMPOSE
@OPT_CONFIG
@OPT_VERBOSE
@OPT_DEBUGGER_PORT
@OPT_WATCH
@OPT_POSTGRES_URI
@click.option(
"--wait",
is_flag=True,
help="Wait for services to start before returning. Implies --detach",
)
@cli.command(help="Start langgraph API server. Requires a license key.")
@log_command
def up(
config: pathlib.Path,
docker_compose: Optional[pathlib.Path],
port: int,
recreate: bool,
pull: bool,
watch: bool,
wait: bool,
verbose: bool,
debugger_port: Optional[int],
postgres_uri: Optional[str],
):
with Runner() as runner, Progress(message="Pulling...") as set:
capabilities = langgraph_cli.docker.check_capabilities(runner)
args, stdin = prepare(
runner,
capabilities=capabilities,
config_path=config,
docker_compose=docker_compose,
port=port,
pull=pull,
watch=watch,
verbose=verbose,
debugger_port=debugger_port,
postgres_uri=postgres_uri,
)
# add up + options
args.extend(["up", "--remove-orphans"])
if recreate:
args.extend(["--force-recreate", "--renew-anon-volumes"])
try:
runner.run(subp_exec("docker", "volume", "rm", "langgraph-data"))
except click.exceptions.Exit:
pass
if watch:
args.append("--watch")
if wait:
args.append("--wait")
else:
args.append("--abort-on-container-exit")
# run docker compose
set("Building...")
def on_stdout(line: str):
if "unpacking to docker.io" in line:
set("Starting...")
elif "GET /ok" in line:
debugger_origin = (
f"http://localhost:{debugger_port}"
if debugger_port
else "https://smith.langchain.com"
)
set("")
sys.stdout.write(
f"""Ready!
- API: http://localhost:{port}
- Docs: http://localhost:{port}/docs
- Debugger: {debugger_origin}/studio/?baseUrl=http://127.0.0.1:{port}
"""
)
sys.stdout.flush()
return True
if capabilities.compose_type == "plugin":
compose_cmd = ["docker", "compose"]
elif capabilities.compose_type == "standalone":
compose_cmd = ["docker-compose"]
runner.run(
subp_exec(
*compose_cmd,
*args,
input=stdin,
verbose=verbose,
on_stdout=on_stdout,
)
)
@OPT_PULL
@OPT_PORT
@OPT_CONFIG
@@ -304,3 +429,78 @@ def build(
config_json = langgraph_cli.config.validate_config(json.load(f))
# build
_build(runner, set, config, config_json, platform, base_image, pull, tag)
def prepare_args_and_stdin(
*,
capabilities: DockerCapabilities,
config_path: pathlib.Path,
config: Config,
docker_compose: Optional[pathlib.Path],
port: int,
watch: bool,
debugger_port: Optional[int] = None,
postgres_uri: Optional[str] = None,
):
# prepare args
stdin = langgraph_cli.docker.compose(
capabilities,
port=port,
debugger_port=debugger_port,
postgres_uri=postgres_uri,
)
args = [
"--project-directory",
str(config_path.parent),
]
# apply options
if docker_compose:
args.extend(["-f", str(docker_compose)])
args.extend(["-f", "-"]) # stdin
# apply config
stdin += langgraph_cli.config.config_to_compose(
config_path,
config,
watch=watch,
base_image="langchain/langgraph-api",
)
return args, stdin
def prepare(
runner,
*,
capabilities: DockerCapabilities,
config_path: pathlib.Path,
docker_compose: Optional[pathlib.Path],
port: int,
pull: bool,
watch: bool,
verbose: bool,
debugger_port: Optional[int] = None,
postgres_uri: Optional[str] = None,
):
with open(config_path) as f:
config = langgraph_cli.config.validate_config(json.load(f))
# pull latest images
if pull:
runner.run(
subp_exec(
"docker",
"pull",
f"langchain/langgraph-api:{config['python_version']}",
verbose=verbose,
)
)
args, stdin = prepare_args_and_stdin(
capabilities=capabilities,
config_path=config_path,
config=config,
docker_compose=docker_compose,
port=port,
watch=watch,
debugger_port=debugger_port,
postgres_uri=postgres_uri,
)
return args, stdin
+4 -13
View File
@@ -265,30 +265,21 @@ def config_to_compose(
config: Config,
base_image: str,
watch: bool = False,
langgraph_api_path: Optional[pathlib.Path] = None,
):
env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
env_vars_str = "\n".join(f" {k}: {v}" for k, v in env_vars)
env_vars_str = "\n".join(f' {k}: "{v}"' for k, v in env_vars)
env_file_str = (
f"env_file: {config['env']}" if isinstance(config["env"], str) else ""
)
if watch:
watch_paths = [config_path] + [
config_path.parent / dep
for dep in config["dependencies"]
if dep.startswith(".")
watch_paths = [config_path.name] + [
dep for dep in config["dependencies"] if dep.startswith(".")
]
watch_actions = "\n".join(
f"""- path: {path}
action: rebuild
ignore:
- .langgraph-data"""
action: rebuild"""
for path in watch_paths
)
if langgraph_api_path:
watch_actions += f"""\n- path: {langgraph_api_path}
action: sync+restart
target: /api/langgraph_api"""
watch_str = f"""
develop:
watch:
+116 -1
View File
@@ -1,13 +1,47 @@
import json
import pathlib
import shutil
from typing import NamedTuple
from typing import Literal, NamedTuple, Optional
import click.exceptions
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"
)
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
"""
DEBUGGER = """
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
ports:
- "{debugger_port}:3968"
depends_on:
langgraph-postgres:
condition: service_healthy
"""
class Version(NamedTuple):
@@ -16,9 +50,14 @@ class Version(NamedTuple):
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:
@@ -49,11 +88,87 @@ def check_capabilities(runner) -> DockerCapabilities:
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 compose(
capabilities: DockerCapabilities,
*,
port: int,
debugger_port: Optional[int] = None,
# 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 ""
)
if db:
if capabilities.healthcheck_start_interval:
db += """
interval: 60s
start_interval: 1s"""
else:
db += """
interval: 5s"""
compose_str = f"""{volumes}services:
{db}
{DEBUGGER.format(debugger_port=debugger_port) if debugger_port else ""}
langgraph-api:
ports:
- "{port}:8000\""""
if include_db:
compose_str += """
depends_on:
langgraph-postgres:
condition: service_healthy"""
compose_str += f"""
environment:
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"""
return compose_str
+103
View File
@@ -0,0 +1,103 @@
import pathlib
from langgraph_cli.cli import prepare_args_and_stdin
from langgraph_cli.config import Config, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
from langgraph_cli.util import clean_empty_lines
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
version_docker=Version(26, 1, 1),
version_compose=Version(2, 27, 0),
healthcheck_start_interval=True,
)
def test_prepare_args_and_stdin():
# this basically serves as an end-to-end test for using config and docker helpers
config_path = pathlib.Path("./langgraph.json")
config = validate_config(
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
)
port = 8000
debugger_port = 8001
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
config_path=config_path,
config=config,
docker_compose="custom-docker-compose.yml",
port=port,
debugger_port=debugger_port,
watch=True,
)
expected_args = [
"--project-directory",
".",
"-f",
"custom-docker-compose.yml",
"-f",
"-",
]
expected_stdin = f"""volumes:
langgraph-data:
driver: local
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
interval: 60s
start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
ports:
- "{debugger_port}:3968"
depends_on:
langgraph-postgres:
condition: service_healthy
langgraph-api:
ports:
- "8000:8000"
depends_on:
langgraph-postgres:
condition: service_healthy
environment:
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
healthcheck:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
WORKDIR /deps/
develop:
watch:
- path: langgraph.json
action: rebuild
- path: .
action: rebuild\
"""
assert actual_args == expected_args
assert clean_empty_lines(actual_stdin) == expected_stdin
+3 -5
View File
@@ -1,15 +1,13 @@
{
"python_version": "3.12",
"pip_config_file": "pipconfig.txt",
"dockerfile_lines": [
"ARG meow"
],
"dockerfile_lines": ["ARG meow"],
"dependencies": [
"langchain_openai",
"."
],
"graphs": {
"agent": "./agent.py:graph"
"agent": "tests/unit_tests/agent.py:graph"
},
"env": ".env"
}
}
+176 -1
View File
@@ -4,7 +4,7 @@ import pathlib
import click
import pytest
from langgraph_cli.config import config_to_docker, validate_config
from langgraph_cli.config import config_to_compose, config_to_docker, validate_config
from langgraph_cli.util import clean_empty_lines
PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json"
@@ -230,3 +230,178 @@ RUN set -ex && \\
RUN PIP_CONFIG_FILE=/pipconfig.txt pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
# config_to_compose
def test_config_to_compose_simple_config():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\
for line in '[project]' \\
'name = "unit_tests"' \\
'version = "0.1"' \\
'[tool.setuptools.package-data]' \\
'"*" = ["**/*"]'; do \\
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
done
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_env_vars():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """ OPENAI_API_KEY: "key"
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api-custom:3.11
ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\
for line in '[project]' \\
'name = "unit_tests"' \\
'version = "0.1"' \\
'[tool.setuptools.package-data]' \\
'"*" = ["**/*"]'; do \\
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
done
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
openai_api_key = "key"
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": graphs,
"env": {"OPENAI_API_KEY": openai_api_key},
}
),
"langchain/langgraph-api-custom",
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_env_file():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
env_file: .env
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\
for line in '[project]' \\
'name = "unit_tests"' \\
'version = "0.1"' \\
'[tool.setuptools.package-data]' \\
'"*" = ["**/*"]'; do \\
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
done
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
"langchain/langgraph-api",
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_watch():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\
for line in '[project]' \\
'name = "unit_tests"' \\
'version = "0.1"' \\
'[tool.setuptools.package-data]' \\
'"*" = ["**/*"]'; do \\
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
done
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
develop:
watch:
- path: test_config.json
action: rebuild
- path: .
action: rebuild\
"""
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
watch=True,
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_end_to_end():
# test all of the above + langgraph API path
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
env_file: .env
pull_policy: build
build:
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\
for line in '[project]' \\
'name = "unit_tests"' \\
'version = "0.1"' \\
'[tool.setuptools.package-data]' \\
'"*" = ["**/*"]'; do \\
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
done
RUN pip install -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
develop:
watch:
- path: test_config.json
action: rebuild
- path: .
action: rebuild\
"""
actual_compose_stdin = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
"langchain/langgraph-api",
watch=True,
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
+101
View File
@@ -0,0 +1,101 @@
from langgraph_cli.docker import (
DEFAULT_POSTGRES_URI,
DockerCapabilities,
Version,
compose,
)
from langgraph_cli.util import clean_empty_lines
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
version_docker=Version(26, 1, 1),
version_compose=Version(2, 27, 0),
healthcheck_start_interval=False,
)
def test_compose_with_no_debugger_and_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES, port=port, postgres_uri=custom_postgres_uri
)
expected_compose_str = f"""services:
langgraph-api:
ports:
- "{port}:8000"
environment:
POSTGRES_URI: {custom_postgres_uri}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES._replace(healthcheck_start_interval=True),
port=port,
postgres_uri=custom_postgres_uri,
)
expected_compose_str = f"""services:
langgraph-api:
ports:
- "{port}:8000"
environment:
POSTGRES_URI: {custom_postgres_uri}
healthcheck:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
postgres_uri=custom_postgres_uri,
)
expected_compose_str = f"""services:
langgraph-api:
ports:
- "{port}:8000"
environment:
POSTGRES_URI: {custom_postgres_uri}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_default_db():
port = 8123
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
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
interval: 5s
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-postgres:
condition: service_healthy
environment:
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str