mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-01 14:05:10 +02:00
Follow-up to #8482. `langgraph deploy --push-to` can now create a deployment in a workspace that deploys through a listener in the customer's own cluster, which is the hybrid case. Before this, creation in such a workspace was impossible from the CLI: the control plane rejected it and the CLI told the user to go and create the deployment in the UI first. ## Changes - Smart Auto-Placement: The CLI now proactively checks your workspace. If you only have one listener and one Kubernetes namespace configured (and are using the managed cloud control plane), it automatically routes your deployment there. No extra flags needed. - New Disambiguation Flags: If your workspace has multiple listeners or namespaces, the CLI will ask you to choose. You can now pass --listener-id and --k8s-namespace to tell it exactly where to deploy. - Failing Fast: The CLI now validates your listener and namespace choices before it starts building and pushing the heavy Docker image. If you provide an invalid ID, it stops immediately instead of wasting your time and bandwidth. - Fixed a Duplication Bug: Previously, if you had many deployments with similar names, a pagination issue could hide your existing deployment from the CLI, causing it to accidentally create a duplicate. The CLI now queries the server for the exact deployment name to guarantee this doesn't happen. - Cleaner Errors: Error messages from the control plane are now stripped of their clunky HTTP envelopes so you get clear, readable sentences when something goes wrong. ## Testing Deployment on 3 paths, hybrid, self-hosted, nominal
1378 lines
43 KiB
Python
1378 lines
43 KiB
Python
import json
|
||
import pathlib
|
||
import re
|
||
import shutil
|
||
import tempfile
|
||
import textwrap
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
|
||
import click
|
||
from click.testing import CliRunner
|
||
|
||
import langgraph_cli.deploy as deploy_module
|
||
from langgraph_cli.cli import cli, prepare_args_and_stdin
|
||
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
|
||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||
from langgraph_cli.util import clean_empty_lines
|
||
|
||
FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines(
|
||
install_cmd="uv pip install --system",
|
||
to_uninstall=("pip", "setuptools", "wheel"),
|
||
pip_installer="uv",
|
||
)
|
||
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
|
||
version_docker=Version(26, 1, 1),
|
||
version_compose=Version(2, 27, 0),
|
||
healthcheck_start_interval=True,
|
||
)
|
||
|
||
|
||
@contextmanager
|
||
def temporary_config_folder(config_content: dict, levels: int = 0):
|
||
# Create a temporary directory
|
||
temp_dir = tempfile.mkdtemp()
|
||
try:
|
||
# Define the path for the config.json file
|
||
config_path = Path(temp_dir) / f"{'a/' * levels}config.json"
|
||
# Ensure the parent directory exists
|
||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 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(__file__).parent / "langgraph.json"
|
||
config = validate_config(
|
||
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
|
||
)
|
||
port = 8000
|
||
debugger_port = 8001
|
||
debugger_graph_url = f"http://127.0.0.1:{port}"
|
||
|
||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||
config_path=config_path,
|
||
config=config,
|
||
docker_compose=pathlib.Path("custom-docker-compose.yml"),
|
||
port=port,
|
||
debugger_port=debugger_port,
|
||
debugger_base_url=debugger_graph_url,
|
||
watch=True,
|
||
)
|
||
|
||
expected_args = [
|
||
"--project-directory",
|
||
str(pathlib.Path(__file__).parent.absolute()),
|
||
"-f",
|
||
"custom-docker-compose.yml",
|
||
"-f",
|
||
"-",
|
||
]
|
||
expected_stdin = f"""volumes:
|
||
langgraph-data:
|
||
driver: local
|
||
services:
|
||
langgraph-redis:
|
||
image: redis:6
|
||
healthcheck:
|
||
test: redis-cli ping
|
||
interval: 5s
|
||
timeout: 1s
|
||
retries: 5
|
||
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
|
||
interval: 60s
|
||
start_interval: 1s
|
||
langgraph-debugger:
|
||
image: langchain/langgraph-debugger
|
||
restart: on-failure
|
||
depends_on:
|
||
langgraph-postgres:
|
||
condition: service_healthy
|
||
ports:
|
||
- "{debugger_port}:3968"
|
||
environment:
|
||
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
|
||
langgraph-api:
|
||
ports:
|
||
- "8000:8000"
|
||
depends_on:
|
||
langgraph-redis:
|
||
condition: service_healthy
|
||
langgraph-postgres:
|
||
condition: service_healthy
|
||
environment:
|
||
REDIS_URI: redis://langgraph-redis:6379
|
||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
|
||
healthcheck:
|
||
test: python /api/healthcheck.py
|
||
interval: 60s
|
||
start_interval: 1s
|
||
start_period: 10s
|
||
|
||
pull_policy: build
|
||
build:
|
||
context: .
|
||
additional_contexts:
|
||
- cli_1: {str(pathlib.Path(__file__).parent.parent.parent.parent.absolute())}
|
||
dockerfile_inline: |
|
||
# syntax=docker/dockerfile:1.4
|
||
FROM langchain/langgraph-api:3.11
|
||
# -- Adding local package . --
|
||
ADD . /deps/cli
|
||
# -- End of local package . --
|
||
# -- Adding local package ../../.. --
|
||
COPY --from=cli_1 . /deps/cli_1
|
||
# -- End of local package ../../.. --
|
||
# -- Installing all local dependencies --
|
||
RUN for dep in /deps/*; do echo "Installing $$dep"; if [ -d "$$dep" ]; then echo "Installing $$dep"; (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .); fi; done
|
||
# -- End of local dependencies install --
|
||
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
|
||
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
|
||
WORKDIR /deps/cli
|
||
|
||
develop:
|
||
watch:
|
||
- path: langgraph.json
|
||
action: rebuild
|
||
- path: .
|
||
action: rebuild
|
||
- path: ../../..
|
||
action: rebuild\
|
||
"""
|
||
assert actual_args == expected_args
|
||
assert clean_empty_lines(actual_stdin) == expected_stdin
|
||
|
||
|
||
def test_prepare_args_and_stdin_with_image() -> None:
|
||
# this basically serves as an end-to-end test for using config and docker helpers
|
||
config_path = pathlib.Path(__file__).parent / "langgraph.json"
|
||
config = validate_config(
|
||
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
|
||
)
|
||
port = 8000
|
||
debugger_port = 8001
|
||
debugger_graph_url = f"http://127.0.0.1:{port}"
|
||
|
||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||
config_path=config_path,
|
||
config=config,
|
||
docker_compose=pathlib.Path("custom-docker-compose.yml"),
|
||
port=port,
|
||
debugger_port=debugger_port,
|
||
debugger_base_url=debugger_graph_url,
|
||
watch=True,
|
||
image="my-cool-image",
|
||
)
|
||
|
||
expected_args = [
|
||
"--project-directory",
|
||
str(pathlib.Path(__file__).parent.absolute()),
|
||
"-f",
|
||
"custom-docker-compose.yml",
|
||
"-f",
|
||
"-",
|
||
]
|
||
expected_stdin = f"""volumes:
|
||
langgraph-data:
|
||
driver: local
|
||
services:
|
||
langgraph-redis:
|
||
image: redis:6
|
||
healthcheck:
|
||
test: redis-cli ping
|
||
interval: 5s
|
||
timeout: 1s
|
||
retries: 5
|
||
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
|
||
interval: 60s
|
||
start_interval: 1s
|
||
langgraph-debugger:
|
||
image: langchain/langgraph-debugger
|
||
restart: on-failure
|
||
depends_on:
|
||
langgraph-postgres:
|
||
condition: service_healthy
|
||
ports:
|
||
- "{debugger_port}:3968"
|
||
environment:
|
||
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
|
||
langgraph-api:
|
||
ports:
|
||
- "8000:8000"
|
||
depends_on:
|
||
langgraph-redis:
|
||
condition: service_healthy
|
||
langgraph-postgres:
|
||
condition: service_healthy
|
||
environment:
|
||
REDIS_URI: redis://langgraph-redis:6379
|
||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
|
||
image: my-cool-image
|
||
healthcheck:
|
||
test: python /api/healthcheck.py
|
||
interval: 60s
|
||
start_interval: 1s
|
||
start_period: 10s
|
||
|
||
|
||
develop:
|
||
watch:
|
||
- path: langgraph.json
|
||
action: rebuild
|
||
- path: .
|
||
action: rebuild
|
||
- path: ../../..
|
||
action: rebuild\
|
||
"""
|
||
assert actual_args == expected_args
|
||
assert clean_empty_lines(actual_stdin) == expected_stdin
|
||
|
||
|
||
def test_version_option() -> None:
|
||
"""Test the --version option of the CLI."""
|
||
runner = CliRunner()
|
||
result = runner.invoke(cli, ["--version"])
|
||
|
||
# Verify that the command executed successfully
|
||
assert result.exit_code == 0, "Expected exit code 0 for --version option"
|
||
|
||
# Check that the output contains the correct version information
|
||
assert "LangGraph CLI, version" in result.output, (
|
||
"Expected version information in output"
|
||
)
|
||
|
||
|
||
def test_top_level_help_shows_deploy_subcommands() -> None:
|
||
runner = CliRunner()
|
||
|
||
result = runner.invoke(cli, ["--help"])
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert "deploy" in result.output
|
||
assert "deploy list" in result.output
|
||
assert "deploy delete" in result.output
|
||
assert "deploy revisions" in result.output
|
||
assert "deploy revisions list" in result.output
|
||
assert "[Beta] List LangSmith Deployments." in result.output
|
||
|
||
|
||
def test_top_level_help_truncates_command_descriptions_to_single_line() -> None:
|
||
runner = CliRunner()
|
||
|
||
result = runner.invoke(cli, ["--help"])
|
||
|
||
assert result.exit_code == 0, result.output
|
||
lines = result.output.splitlines()
|
||
deploy_line = next(line for line in lines if line.strip().startswith("deploy"))
|
||
deploy_list_line = next(
|
||
line for line in lines if line.strip().startswith("deploy list")
|
||
)
|
||
|
||
assert not lines[lines.index(deploy_line) + 1].startswith(" ")
|
||
assert "..." in deploy_line
|
||
assert "[Beta] List LangSmith Deployments." in deploy_list_line
|
||
|
||
|
||
def test_deploy_missing_config_shows_actionable_error(tmp_path, monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
result = runner.invoke(cli, ["deploy"])
|
||
|
||
assert result.exit_code == 1
|
||
assert "We couldn't find a langgraph.json file." in result.output
|
||
assert "Run `langgraph deploy` from the root" in result.output
|
||
assert "https://docs.langchain.com/langsmith/deployment-quickstart" in result.output
|
||
assert "Traceback" not in result.output
|
||
|
||
|
||
def test_deploy_missing_config_emits_json_error(tmp_path, monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
result = runner.invoke(cli, ["deploy", "--json"])
|
||
|
||
assert result.exit_code == 1
|
||
events = [json.loads(line) for line in result.output.splitlines()]
|
||
assert events[-1]["event"] == "error"
|
||
assert "We couldn't find a langgraph.json file." in events[-1]["message"]
|
||
|
||
|
||
def test_dev_command_requires_ssl_certfile_and_keyfile_together(tmp_path) -> None:
|
||
config_path = tmp_path / "langgraph.json"
|
||
config_path.write_text(
|
||
json.dumps({"dependencies": [], "graphs": {"agent": "./agent.py:graph"}}),
|
||
encoding="utf-8",
|
||
)
|
||
certfile = tmp_path / "cert.pem"
|
||
certfile.write_text("cert", encoding="utf-8")
|
||
|
||
runner = CliRunner()
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"dev",
|
||
"--config",
|
||
str(config_path),
|
||
"--ssl-certfile",
|
||
str(certfile),
|
||
],
|
||
)
|
||
|
||
assert result.exit_code != 0
|
||
assert "Both --ssl-certfile and --ssl-keyfile must be provided" in result.output
|
||
|
||
|
||
def test_deploy_list_command(monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
captured: dict[str, str] = {}
|
||
|
||
class FakeClient:
|
||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||
captured["host_url"] = host_url
|
||
captured["api_key"] = api_key
|
||
captured["tenant_id"] = tenant_id or ""
|
||
|
||
def list_deployments(self, name_contains: str = ""):
|
||
captured["name_contains"] = name_contains
|
||
return [
|
||
{
|
||
"id": "dep-123",
|
||
"name": "alpha",
|
||
"source_config": {"custom_url": "https://alpha.example.com"},
|
||
},
|
||
{
|
||
"id": "dep-456",
|
||
"name": "beta",
|
||
"source_config": {"custom_url": "https://beta.example.com"},
|
||
},
|
||
]
|
||
|
||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"deploy",
|
||
"list",
|
||
"--api-key",
|
||
"test-key",
|
||
"--host-url",
|
||
"https://api.example.com",
|
||
"--name-contains",
|
||
"alp",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert captured == {
|
||
"host_url": "https://api.example.com",
|
||
"api_key": "test-key",
|
||
"tenant_id": "",
|
||
"name_contains": "alp",
|
||
}
|
||
assert "Deployment ID" in result.output
|
||
assert "Deployment Name" in result.output
|
||
assert "Deployment URL" in result.output
|
||
assert "dep-123" in result.output
|
||
assert "https://beta.example.com" in result.output
|
||
|
||
|
||
def test_deploy_list_command_no_results(monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
|
||
class FakeClient:
|
||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||
pass
|
||
|
||
def list_deployments(self, name_contains: str = ""):
|
||
return []
|
||
|
||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"deploy",
|
||
"list",
|
||
"--api-key",
|
||
"test-key",
|
||
"--host-url",
|
||
"https://api.example.com",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert result.output.strip() == "No deployments found."
|
||
|
||
|
||
def test_deploy_revisions_list_command(monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
captured: dict[str, str] = {}
|
||
|
||
class FakeClient:
|
||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||
captured["host_url"] = host_url
|
||
captured["api_key"] = api_key
|
||
captured["tenant_id"] = tenant_id or ""
|
||
|
||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||
captured["deployment_id"] = deployment_id
|
||
captured["limit"] = str(limit)
|
||
return [
|
||
{
|
||
"id": "rev-123",
|
||
"status": "CREATING",
|
||
"created_at": "2023-11-07T05:31:56Z",
|
||
},
|
||
{
|
||
"id": "rev-456",
|
||
"status": "DEPLOYED",
|
||
"created_at": "2023-11-08T10:00:00Z",
|
||
},
|
||
]
|
||
|
||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"deploy",
|
||
"revisions",
|
||
"list",
|
||
"--api-key",
|
||
"test-key",
|
||
"--host-url",
|
||
"https://api.example.com",
|
||
"dep-123",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert captured == {
|
||
"host_url": "https://api.example.com",
|
||
"api_key": "test-key",
|
||
"tenant_id": "",
|
||
"deployment_id": "dep-123",
|
||
"limit": "10",
|
||
}
|
||
assert "Revision ID" in result.output
|
||
assert "Status" in result.output
|
||
assert "Created At" in result.output
|
||
assert "rev-123" in result.output
|
||
assert "2023-11-08T10:00:00Z" in result.output
|
||
|
||
|
||
def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
|
||
class FakeClient:
|
||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||
pass
|
||
|
||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||
return []
|
||
|
||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"deploy",
|
||
"revisions",
|
||
"list",
|
||
"--api-key",
|
||
"test-key",
|
||
"--host-url",
|
||
"https://api.example.com",
|
||
"dep-123",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert result.output.strip() == "No revisions found for deployment dep-123."
|
||
|
||
|
||
def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
captured: dict[str, str] = {}
|
||
|
||
class FakeClient:
|
||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||
pass
|
||
|
||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||
captured["deployment_id"] = deployment_id
|
||
captured["limit"] = str(limit)
|
||
return []
|
||
|
||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"deploy",
|
||
"revisions",
|
||
"list",
|
||
"--api-key",
|
||
"test-key",
|
||
"--host-url",
|
||
"https://api.example.com",
|
||
"--limit",
|
||
"25",
|
||
"dep-123",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert captured == {"deployment_id": "dep-123", "limit": "25"}
|
||
assert result.output.strip() == "No revisions found for deployment dep-123."
|
||
|
||
|
||
def test_deploy_revisions_list_missing_deployment_id_shows_usage() -> None:
|
||
runner = CliRunner()
|
||
|
||
result = runner.invoke(cli, ["deploy", "revisions", "list"])
|
||
|
||
assert result.exit_code == 2, result.output
|
||
assert "Missing argument 'DEPLOYMENT_ID'" in result.output
|
||
|
||
|
||
def test_deploy_delete_command(monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
captured: dict[str, str] = {}
|
||
|
||
class FakeClient:
|
||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||
captured["host_url"] = host_url
|
||
captured["api_key"] = api_key
|
||
captured["tenant_id"] = tenant_id or ""
|
||
|
||
def delete_deployment(self, deployment_id: str):
|
||
captured["deployment_id"] = deployment_id
|
||
return None
|
||
|
||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"deploy",
|
||
"delete",
|
||
"--api-key",
|
||
"test-key",
|
||
"--host-url",
|
||
"https://api.example.com",
|
||
"dep-123",
|
||
],
|
||
input="y\n",
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert captured == {
|
||
"host_url": "https://api.example.com",
|
||
"api_key": "test-key",
|
||
"tenant_id": "",
|
||
"deployment_id": "dep-123",
|
||
}
|
||
assert (
|
||
"Are you sure you want to delete deployment ID dep-123? (Y/n):"
|
||
in click.unstyle(result.output)
|
||
)
|
||
assert result.output.strip().endswith("Deleted deployment dep-123.")
|
||
|
||
|
||
def test_deploy_delete_command_cancelled(monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
deleted = False
|
||
|
||
class FakeClient:
|
||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||
pass
|
||
|
||
def delete_deployment(self, deployment_id: str):
|
||
nonlocal deleted
|
||
deleted = True
|
||
return None
|
||
|
||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"deploy",
|
||
"delete",
|
||
"--api-key",
|
||
"test-key",
|
||
"--host-url",
|
||
"https://api.example.com",
|
||
"dep-123",
|
||
],
|
||
input="n\n",
|
||
)
|
||
|
||
assert result.exit_code == 1, result.output
|
||
assert not deleted
|
||
assert "Aborted!" in result.output
|
||
|
||
|
||
def test_deploy_delete_command_force(monkeypatch) -> None:
|
||
runner = CliRunner()
|
||
captured: dict[str, str] = {}
|
||
|
||
class FakeClient:
|
||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||
captured["host_url"] = host_url
|
||
captured["api_key"] = api_key
|
||
captured["tenant_id"] = tenant_id or ""
|
||
|
||
def delete_deployment(self, deployment_id: str):
|
||
captured["deployment_id"] = deployment_id
|
||
return None
|
||
|
||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"deploy",
|
||
"delete",
|
||
"--force",
|
||
"--api-key",
|
||
"test-key",
|
||
"--host-url",
|
||
"https://api.example.com",
|
||
"dep-123",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert "Are you sure you want to delete deployment ID dep-123?" not in result.output
|
||
assert captured == {
|
||
"host_url": "https://api.example.com",
|
||
"api_key": "test-key",
|
||
"tenant_id": "",
|
||
"deployment_id": "dep-123",
|
||
}
|
||
assert result.output.strip() == "Deleted deployment dep-123."
|
||
|
||
|
||
def test_dockerfile_command_basic() -> None:
|
||
"""Test the 'dockerfile' command with basic configuration."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.11",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
}
|
||
|
||
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_new_style_config() -> None:
|
||
"""Test `dockerfile` command with a new style config.
|
||
|
||
This config format allows specifying agent data as a dictionary.
|
||
{
|
||
"graphs": {
|
||
"agent1": {
|
||
"path": ... # path to graph definition,
|
||
... # other fields
|
||
}
|
||
}
|
||
}
|
||
"""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"dependencies": ["./my_agent"],
|
||
"graphs": {
|
||
"agent": {
|
||
"path": "./my_agent/agent.py:graph",
|
||
"description": "This is a test agent",
|
||
}
|
||
},
|
||
"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")],
|
||
)
|
||
|
||
# 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_base_image() -> None:
|
||
"""Test the 'dockerfile' command with a base image."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.11",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
"base_image": "langchain/langgraph-server:0.2",
|
||
}
|
||
with temporary_config_folder(config_content) as temp_dir:
|
||
save_path = temp_dir / "Dockerfile"
|
||
agent_path = temp_dir / "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")],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert "✅ Created: Dockerfile" in result.output
|
||
|
||
assert save_path.exists()
|
||
with open(save_path) as f:
|
||
dockerfile = f.read()
|
||
assert re.match("FROM langchain/langgraph-server:0.2-py3.*", dockerfile), (
|
||
"\n".join(dockerfile.splitlines()[:3])
|
||
)
|
||
|
||
|
||
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
|
||
|
||
|
||
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
|
||
|
||
|
||
def test_build_generate_proper_build_context():
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.11",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": [".", "../../..", "../.."],
|
||
"image_distro": "wolfi",
|
||
}
|
||
|
||
with temporary_config_folder(config_content, levels=3) 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,
|
||
)
|
||
|
||
build_context_pattern = re.compile(r"--build-context\s+([\w-]+)=([^\s]+)")
|
||
|
||
build_contexts = re.findall(build_context_pattern, result.output)
|
||
assert len(build_contexts) == 2, (
|
||
f"Expected 2 build contexts, but found {len(build_contexts)}"
|
||
)
|
||
|
||
|
||
def test_dockerfile_command_with_api_version() -> None:
|
||
"""Test the 'dockerfile' command with --api-version flag."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.11",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
}
|
||
|
||
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"),
|
||
"--api-version",
|
||
"0.2.74",
|
||
],
|
||
)
|
||
|
||
# Assert command was successful
|
||
assert result.exit_code == 0, result.output
|
||
assert "✅ Created: Dockerfile" in result.output
|
||
|
||
# Check if Dockerfile was created and contains correct FROM line
|
||
assert save_path.exists()
|
||
with open(save_path) as f:
|
||
dockerfile = f.read()
|
||
assert "FROM langchain/langgraph-api:0.2.74-py3.11" in dockerfile
|
||
|
||
|
||
def test_dockerfile_command_with_api_version_and_base_image() -> None:
|
||
"""Test the 'dockerfile' command with both --api-version and --base-image flags."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.12",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
"image_distro": "wolfi",
|
||
}
|
||
|
||
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"),
|
||
"--api-version",
|
||
"1.0.0",
|
||
"--base-image",
|
||
"my-registry/custom-api",
|
||
],
|
||
)
|
||
|
||
# Assert command was successful
|
||
assert result.exit_code == 0, result.output
|
||
assert "✅ Created: Dockerfile" in result.output
|
||
|
||
# Check if Dockerfile was created and contains correct FROM line
|
||
assert save_path.exists()
|
||
with open(save_path) as f:
|
||
dockerfile = f.read()
|
||
assert "FROM my-registry/custom-api:1.0.0-py3.12-wolfi" in dockerfile
|
||
|
||
|
||
def test_dockerfile_command_with_api_version_nodejs() -> None:
|
||
"""Test the 'dockerfile' command with --api-version flag for Node.js config."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"node_version": "20",
|
||
"graphs": {"agent": "agent.js:graph"},
|
||
}
|
||
|
||
with temporary_config_folder(config_content) as temp_dir:
|
||
save_path = temp_dir / "Dockerfile"
|
||
agent_path = temp_dir / "agent.js"
|
||
agent_path.touch()
|
||
|
||
result = runner.invoke(
|
||
cli,
|
||
[
|
||
"dockerfile",
|
||
str(save_path),
|
||
"--config",
|
||
str(temp_dir / "config.json"),
|
||
"--api-version",
|
||
"0.2.74",
|
||
],
|
||
)
|
||
|
||
# Assert command was successful
|
||
assert result.exit_code == 0, result.output
|
||
assert "✅ Created: Dockerfile" in result.output
|
||
|
||
# Check if Dockerfile was created and contains correct FROM line
|
||
assert save_path.exists()
|
||
with open(save_path) as f:
|
||
dockerfile = f.read()
|
||
assert "FROM langchain/langgraphjs-api:0.2.74-node20" in dockerfile
|
||
|
||
|
||
def test_build_command_with_api_version() -> None:
|
||
"""Test the 'build' command with --api-version flag."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.11",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
"image_distro": "wolfi", # Use wolfi to avoid warning messages
|
||
}
|
||
|
||
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"),
|
||
"--api-version",
|
||
"0.2.74",
|
||
"--no-pull", # Avoid pulling non-existent images
|
||
],
|
||
catch_exceptions=True,
|
||
)
|
||
|
||
# Check that the build command is called with the correct tag
|
||
# The output should contain the docker build command with the api_version tag
|
||
assert "langchain/langgraph-api:0.2.74-py3.11-wolfi" in result.output
|
||
|
||
|
||
def test_build_command_with_api_version_and_base_image() -> None:
|
||
"""Test the 'build' command with both --api-version and --base-image flags."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.12",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
"image_distro": "wolfi", # Use wolfi to avoid warning messages
|
||
}
|
||
|
||
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"),
|
||
"--api-version",
|
||
"1.0.0",
|
||
"--base-image",
|
||
"my-registry/custom-api",
|
||
"--no-pull", # Avoid pulling non-existent images
|
||
],
|
||
catch_exceptions=True,
|
||
)
|
||
|
||
# Check that the build command includes the api_version
|
||
assert "my-registry/custom-api:1.0.0-py3.12-wolfi" in result.output
|
||
|
||
|
||
def test_prepare_args_and_stdin_with_api_version() -> None:
|
||
"""Test prepare_args_and_stdin function with api_version parameter."""
|
||
config_path = pathlib.Path(__file__).parent / "langgraph.json"
|
||
config = validate_config(
|
||
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
|
||
)
|
||
port = 8000
|
||
api_version = "0.2.74"
|
||
|
||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||
config_path=config_path,
|
||
config=config,
|
||
docker_compose=None,
|
||
port=port,
|
||
watch=False,
|
||
api_version=api_version,
|
||
)
|
||
|
||
expected_args = [
|
||
"--project-directory",
|
||
str(pathlib.Path(__file__).parent.absolute()),
|
||
"-f",
|
||
"-",
|
||
]
|
||
|
||
# Check that the args are correct
|
||
assert actual_args == expected_args
|
||
|
||
# Check that the stdin contains the correct FROM line with api_version
|
||
assert "FROM langchain/langgraph-api:0.2.74-py3.11" in actual_stdin
|
||
|
||
|
||
def test_prepare_args_and_stdin_with_api_version_and_image() -> None:
|
||
"""Test prepare_args_and_stdin function with both api_version and image parameters."""
|
||
config_path = pathlib.Path(__file__).parent / "langgraph.json"
|
||
config = validate_config(
|
||
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
|
||
)
|
||
port = 8000
|
||
api_version = "0.2.74"
|
||
image = "my-custom-image:latest"
|
||
|
||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||
config_path=config_path,
|
||
config=config,
|
||
docker_compose=None,
|
||
port=port,
|
||
watch=False,
|
||
api_version=api_version,
|
||
image=image,
|
||
)
|
||
|
||
# When image is provided, api_version should be ignored for the image
|
||
# but the stdin should not contain a build section (since image is provided)
|
||
assert "pull_policy: build" not in actual_stdin
|
||
|
||
|
||
def test_dockerfile_command_distributed_mode() -> None:
|
||
"""Test the 'dockerfile' command with --engine-runtime-mode distributed."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.11",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
}
|
||
|
||
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"),
|
||
"--engine-runtime-mode",
|
||
"distributed",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert "✅ Created: Dockerfile" in result.output
|
||
|
||
assert save_path.exists()
|
||
with open(save_path) as f:
|
||
dockerfile = f.read()
|
||
assert "FROM langchain/langgraph-executor:3.11" in dockerfile
|
||
|
||
|
||
def test_dockerfile_command_combined_mode() -> None:
|
||
"""Test the 'dockerfile' command with --engine-runtime-mode combined_queue_worker."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.11",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
}
|
||
|
||
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"),
|
||
"--engine-runtime-mode",
|
||
"combined_queue_worker",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert save_path.exists()
|
||
with open(save_path) as f:
|
||
dockerfile = f.read()
|
||
assert "FROM langchain/langgraph-api:3.11" in dockerfile
|
||
|
||
|
||
def test_dockerfile_command_distributed_with_explicit_base_image() -> None:
|
||
"""Test distributed mode with explicit --base-image overrides executor default."""
|
||
runner = CliRunner()
|
||
config_content = {
|
||
"python_version": "3.11",
|
||
"graphs": {"agent": "agent.py:graph"},
|
||
"dependencies": ["."],
|
||
}
|
||
|
||
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"),
|
||
"--engine-runtime-mode",
|
||
"distributed",
|
||
"--base-image",
|
||
"my-custom-executor:latest",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0, result.output
|
||
assert save_path.exists()
|
||
with open(save_path) as f:
|
||
dockerfile = f.read()
|
||
assert "FROM my-custom-executor:latest" in dockerfile
|
||
|
||
|
||
def test_prepare_args_and_stdin_distributed_mode() -> None:
|
||
"""Test prepare_args_and_stdin with distributed mode includes all services."""
|
||
config_path = pathlib.Path(__file__).parent / "langgraph.json"
|
||
config = validate_config(
|
||
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
|
||
)
|
||
port = 8000
|
||
|
||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||
config_path=config_path,
|
||
config=config,
|
||
docker_compose=None,
|
||
port=port,
|
||
watch=False,
|
||
engine_runtime_mode="distributed",
|
||
)
|
||
|
||
# API service should use langgraph-api base image
|
||
assert "FROM langchain/langgraph-api:" in actual_stdin
|
||
|
||
# Distributed mode sets N_JOBS_PER_WORKER=0 on the API service
|
||
assert 'N_JOBS_PER_WORKER: "0"' in actual_stdin
|
||
|
||
# Orchestrator service present
|
||
assert "langgraph-orchestrator:" in actual_stdin
|
||
|
||
# Executor service present with correct base image
|
||
assert "langgraph-executor:" in actual_stdin
|
||
assert "FROM langchain/langgraph-executor:" in actual_stdin
|
||
assert "executor_entrypoint.sh" in actual_stdin
|