diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index f487070ca..b884212ce 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -1511,6 +1511,15 @@ def prepare( """Prepare the arguments and stdin for running the LangGraph API server.""" config_json = langgraph_cli.config.validate_config_file(config_path) warn_non_wolfi_distro(config_json) + + if engine_runtime_mode == "distributed" and not api_version and not image: + click.secho( + "Resolving latest published version for distributed runtime...", + fg="cyan", + ) + api_version = langgraph_cli.config.fetch_latest_api_version() + click.secho(f"Using version {api_version} for all distributed images.", fg="cyan") + # pull latest images if pull: runner.run( @@ -1535,6 +1544,14 @@ def prepare( verbose=verbose, ) ) + runner.run( + subp_exec( + "docker", + "pull", + f"langchain/langgraph-orchestrator-licensed:{api_version}", + verbose=verbose, + ) + ) args, stdin = prepare_args_and_stdin( capabilities=capabilities, diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 106daccc7..8ea7dedbf 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -4,6 +4,7 @@ import os import pathlib import re import textwrap +import urllib.request from collections import Counter from typing import Literal, NamedTuple @@ -1233,6 +1234,37 @@ def node_config_to_docker( return os.linesep.join(docker_file_contents), {} +VERSION_MARKER_REPO = "langchain/langgraph-published-version-marker" +_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+") + + +def fetch_latest_api_version() -> str: + """Fetch the latest published API version from the Docker Hub version marker. + + The marker repo is tagged with ````, ````, and ``latest``. + We pick the first tag that looks like a semver version. + """ + url = f"https://hub.docker.com/v2/repositories/{VERSION_MARKER_REPO}/tags/?page_size=10" + try: + with urllib.request.urlopen(url, timeout=10) as resp: + data = json.loads(resp.read()) + except Exception as exc: + raise click.ClickException( + f"Failed to fetch latest API version from {VERSION_MARKER_REPO}: {exc}\n" + "You can specify the version explicitly with --api-version." + ) from exc + + for tag in data.get("results", []): + name = tag.get("name", "") + if _VERSION_RE.match(name): + return name + + raise click.ClickException( + f"Could not find a semver tag in {VERSION_MARKER_REPO}.\n" + "You can specify the version explicitly with --api-version." + ) + + def default_base_image( config: Config, engine_runtime_mode: str = "combined_queue_worker" ) -> str: @@ -1422,9 +1454,16 @@ def config_to_compose( additional_contexts: {executor_additional_contexts_str}""" + if not api_version: + raise click.ClickException( + "Distributed runtime requires a pinned API version for all images.\n" + "Either pass --api-version explicitly or let the CLI resolve it " + "from the Docker Hub version marker." + ) + postgres_uri = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable" result += f""" langgraph-orchestrator: - image: langchain/langgraph-orchestrator-licensed:latest + image: langchain/langgraph-orchestrator-licensed:{api_version} depends_on: langgraph-api: condition: service_healthy diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 801b83ab0..76b6b1ae3 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -958,3 +958,25 @@ def test_prepare_args_and_stdin_distributed_mode() -> None: assert "langgraph-executor:" in actual_stdin assert "FROM langchain/langgraph-executor:" in actual_stdin assert "executor_entrypoint.sh" in actual_stdin + + +def test_prepare_args_and_stdin_distributed_with_api_version() -> None: + """All 3 images should use the same api_version in distributed mode.""" + config_path = pathlib.Path(__file__).parent / "langgraph.json" + config = validate_config( + Config(dependencies=["."], graphs={"agent": "agent.py:graph"}) + ) + actual_args, actual_stdin = prepare_args_and_stdin( + capabilities=DEFAULT_DOCKER_CAPABILITIES, + config_path=config_path, + config=config, + docker_compose=None, + port=8000, + watch=False, + engine_runtime_mode="distributed", + api_version="0.7.67", + ) + + assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_stdin + assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_stdin + assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_stdin diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index b71dd76c8..241fb1c26 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -15,6 +15,7 @@ from langgraph_cli.config import ( config_to_docker, default_base_image, docker_tag, + fetch_latest_api_version, has_disallowed_build_command_content, validate_config, validate_config_file, @@ -1772,18 +1773,20 @@ def test_config_to_compose_distributed_mode(): validate_config({"dependencies": ["."], "graphs": graphs}), "langchain/langgraph-api", engine_runtime_mode="distributed", + api_version="0.7.67", ) # API service uses langchain/langgraph-api base image - assert "FROM langchain/langgraph-api:3.11" in actual_compose_stdin + assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_compose_stdin - # Orchestrator service is present + # Orchestrator service is present with pinned version assert "langgraph-orchestrator:" in actual_compose_stdin + assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_compose_stdin assert "EXECUTOR_TARGET: langgraph-executor:8188" in actual_compose_stdin # Executor service is present with correct base image assert "langgraph-executor:" in actual_compose_stdin - assert "FROM langchain/langgraph-executor:3.11" in actual_compose_stdin + assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_compose_stdin assert 'entrypoint: ["sh", "/storage/executor_entrypoint.sh"]' in actual_compose_stdin # Executor has required environment variables @@ -1802,6 +1805,7 @@ def test_config_to_compose_distributed_mode_with_env_file(): validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}), "langchain/langgraph-api", engine_runtime_mode="distributed", + api_version="0.7.67", ) # env_file should appear multiple times: API, orchestrator, executor @@ -1820,6 +1824,7 @@ def test_config_to_compose_distributed_mode_generates_two_dockerfiles(): validate_config({"dependencies": ["."], "graphs": graphs}), "langchain/langgraph-api", engine_runtime_mode="distributed", + api_version="0.7.67", ) # Should contain two different FROM lines @@ -1829,8 +1834,8 @@ def test_config_to_compose_distributed_mode_generates_two_dockerfiles(): if line.strip().startswith("FROM ") ] assert len(from_lines) == 2 - assert "FROM langchain/langgraph-api:3.11" in from_lines[0] - assert "FROM langchain/langgraph-executor:3.11" in from_lines[1] + assert "FROM langchain/langgraph-api:0.7.67-py3.11" in from_lines[0] + assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in from_lines[1] def test_config_to_compose_combined_mode_no_orchestrator(): @@ -1884,6 +1889,91 @@ def test_config_to_compose_distributed_executor_gets_correct_paths(): ) +def test_fetch_latest_api_version_parses_semver(monkeypatch): + """fetch_latest_api_version should return the first semver-like tag.""" + import io + import urllib.request + + fake_body = json.dumps( + {"results": [{"name": "latest"}, {"name": "abc1234"}, {"name": "0.7.67"}]} + ).encode() + + def mock_urlopen(url, *, timeout=None): + return io.BytesIO(fake_body) + + monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen) + assert fetch_latest_api_version() == "0.7.67" + + +def test_fetch_latest_api_version_no_semver_raises(monkeypatch): + """Should raise ClickException when no semver tag is found.""" + import io + import urllib.request + + fake_body = json.dumps( + {"results": [{"name": "latest"}, {"name": "abc1234"}]} + ).encode() + + def mock_urlopen(url, *, timeout=None): + return io.BytesIO(fake_body) + + monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen) + with pytest.raises(click.ClickException, match="Could not find a semver tag"): + fetch_latest_api_version() + + +def test_fetch_latest_api_version_network_error_raises(monkeypatch): + """Should raise ClickException on network failure.""" + import urllib.request + + def mock_urlopen(url, *, timeout=None): + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen) + with pytest.raises(click.ClickException, match="Failed to fetch"): + fetch_latest_api_version() + + +def test_config_to_compose_distributed_orchestrator_uses_api_version(): + """Orchestrator image tag should use the api_version when provided.""" + graphs = {"agent": "./agent.py:graph"} + actual = config_to_compose( + PATH_TO_CONFIG, + validate_config({"dependencies": ["."], "graphs": graphs}), + "langchain/langgraph-api", + engine_runtime_mode="distributed", + api_version="0.7.67", + ) + assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual + + +def test_config_to_compose_distributed_orchestrator_defaults_to_latest(): + """Without api_version, orchestrator image should use 'latest'.""" + graphs = {"agent": "./agent.py:graph"} + actual = config_to_compose( + PATH_TO_CONFIG, + validate_config({"dependencies": ["."], "graphs": graphs}), + "langchain/langgraph-api", + engine_runtime_mode="distributed", + ) + assert "langchain/langgraph-orchestrator-licensed:latest" in actual + + +def test_config_to_compose_distributed_all_images_same_version(): + """All 3 images (api, executor, orchestrator) should use the same api_version.""" + graphs = {"agent": "./agent.py:graph"} + actual = config_to_compose( + PATH_TO_CONFIG, + validate_config({"dependencies": ["."], "graphs": graphs}), + "langchain/langgraph-api", + engine_runtime_mode="distributed", + api_version="0.7.67", + ) + assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual + assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual + assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual + + class TestHasDisallowedBuildCommandContent: """Tests for has_disallowed_build_command_content."""