From e908119536a1b779a108724b1805ea630b78b031 Mon Sep 17 00:00:00 2001 From: Hugo Durand Date: Fri, 18 Sep 2026 13:46:25 -0400 Subject: [PATCH] refactor(cli): dispatch deploy through deployment source strategies --- libs/cli/langgraph_cli/deploy.py | 322 ++++++++++++------ .../tests/unit_tests/test_deploy_helpers.py | 100 ++++++ 2 files changed, 309 insertions(+), 113 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 690ed25a5..f554e7966 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -12,6 +12,7 @@ from collections.abc import Callable, Sequence from contextlib import contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone +from typing import Protocol import click import click.exceptions @@ -1264,6 +1265,190 @@ def _run_remote_build( ) +# --------------------------------------------------------------------------- +# Deployment sources +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class DeployContext: + client: HostBackendClient + spec: BuildSpec + verbose: bool + name: str | None + deployment_id: str | None + deployment_type: str + secrets: list[dict[str, str]] + tracked_packages: list[str] | None + + +@dataclass(frozen=True, slots=True) +class DeployOutcome: + deployment_id: str + build_result: BuildResult + + +class DeploymentSource(Protocol): + def run(self, ctx: DeployContext) -> DeployOutcome: ... + + +def _resolve_or_create( + ctx: DeployContext, *, source: str, not_found_message: str +) -> tuple[str, int]: + deployment_id, needs_creation, step = _resolve_deployment( + ctx.client, + 1, + ctx.deployment_id, + ctx.name, + not_found_message=not_found_message, + ) + if needs_creation: + deployment_id, step = _create_deployment( + ctx.client, + step, + name=ctx.name, + source=source, + source_config={"deployment_type": ctx.deployment_type}, + source_revision_config={}, + secrets=ctx.secrets, + ) + if not deployment_id: + raise click.ClickException("Failed to determine deployment ID") + return deployment_id, step + + +def _ensure_external_docker(client: HostBackendClient, deployment_id: str) -> None: + existing = _call_host_backend_with_optional_tenant( + client, lambda c: c.get_deployment(deployment_id) + ) + existing_source = existing.get("source") if isinstance(existing, dict) else None + if existing_source and existing_source != "external_docker": + raise click.UsageError( + f"Deployment {deployment_id} uses a different build mode and " + f"cannot be updated with --image-uri. To use --image-uri, omit " + f"--deployment-id to create a new deployment, or remove " + f"--image-uri to continue using the current build mode." + ) + + +@dataclass(frozen=True, slots=True) +class InternalDockerSource: + prebuilt_image: str | None + image_name: str | None + tag: str + + def run(self, ctx: DeployContext) -> DeployOutcome: + deployment_id, step = _resolve_or_create( + ctx, + source="internal_docker", + not_found_message="No deployment found. Will create after build.", + ) + build_result = _run_local_build( + client=ctx.client, + deployment_id=deployment_id, + step=step, + spec=ctx.spec, + verbose=ctx.verbose, + image_name=self.image_name, + prebuilt_image=self.prebuilt_image, + name=ctx.name, + tag=self.tag, + secrets=ctx.secrets, + tracked_packages=ctx.tracked_packages, + ) + return DeployOutcome(deployment_id, build_result) + + +@dataclass(frozen=True, slots=True) +class InternalSourceUpload: + def run(self, ctx: DeployContext) -> DeployOutcome: + deployment_id, step = _resolve_or_create( + ctx, + source="internal_source", + not_found_message="No deployment found. Will create.", + ) + build_result = _run_remote_build( + client=ctx.client, + deployment_id=deployment_id, + step=step, + spec=ctx.spec, + verbose=ctx.verbose, + secrets=ctx.secrets, + tracked_packages=ctx.tracked_packages, + ) + return DeployOutcome(deployment_id, build_result) + + +@dataclass(frozen=True, slots=True) +class ExternalDockerSource: + image_uri: str + + def run(self, ctx: DeployContext) -> DeployOutcome: + deployment_id, needs_creation, step = _resolve_deployment( + ctx.client, + 1, + ctx.deployment_id, + ctx.name, + not_found_message="No deployment found. Will create.", + ) + if needs_creation: + deployment_id, step = _create_deployment( + ctx.client, + step, + name=ctx.name, + source="external_docker", + source_config={"deployment_type": ctx.deployment_type}, + source_revision_config={}, + secrets=ctx.secrets, + ) + if not deployment_id: + raise click.ClickException("Failed to determine deployment ID") + if not needs_creation: + _ensure_external_docker(ctx.client, deployment_id) + build_result = _run_external_deploy( + client=ctx.client, + deployment_id=deployment_id, + step=step, + spec=ctx.spec, + image_uri=self.image_uri, + verbose=ctx.verbose, + secrets=ctx.secrets, + tracked_packages=ctx.tracked_packages, + ) + return DeployOutcome(deployment_id, build_result) + + +def _select_source( + *, + image_uri: str | None, + image: str | None, + image_name: str | None, + tag: str, + remote_build_flag: bool | None, +) -> DeploymentSource: + if image_uri is not None: + if remote_build_flag is True: + raise click.UsageError("--image-uri cannot be combined with --remote.") + if image: + raise click.UsageError("--image-uri cannot be combined with --image.") + return ExternalDockerSource(image_uri=image_uri) + if image and remote_build_flag is True: + raise click.UsageError("--image cannot be combined with --remote builds.") + use_remote_build, local_build_error = _resolve_build_mode( + remote_build_flag, force_local=image is not None + ) + if not use_remote_build: + return InternalDockerSource( + prebuilt_image=image, image_name=image_name, tag=tag + ) + if remote_build_flag is None and local_build_error: + em = _get_emitter() + em.note(f"{local_build_error}\nUsing remote build instead.") + if not em.json_mode: + click.echo() + return InternalSourceUpload() + + # --------------------------------------------------------------------------- # Host backend client factory # --------------------------------------------------------------------------- @@ -1685,133 +1870,44 @@ def _deploy_cmd( secrets = _secrets_from_env(_env_without_deployment_name(env_vars)) - if image_uri and remote_build_flag is True: - raise click.UsageError("--image-uri cannot be combined with --remote.") - if image_uri and image: - raise click.UsageError("--image-uri cannot be combined with --image.") - - use_external_docker = image_uri is not None - - if use_external_docker: - use_remote_build = False - else: - if image and remote_build_flag is True: - raise click.UsageError("--image cannot be combined with --remote builds.") - use_remote_build, local_build_error = _resolve_build_mode( - remote_build_flag, force_local=image is not None - ) - if use_remote_build and remote_build_flag is None and local_build_error: - em.note(f"{local_build_error}\nUsing remote build instead.") - if not json_output: - click.echo() - - # -- 2. Resolve / create deployment -- - client = _create_host_backend_client(host_url, api_key, env_vars=env_vars) - step = 1 - - deployment_id, needs_creation, step = _resolve_deployment( - client, - step, - deployment_id, - name, - not_found_message=( - "No deployment found. Will create." - if (use_remote_build or use_external_docker) - else "No deployment found. Will create after build." - ), + source = _select_source( + image_uri=image_uri, + image=image, + image_name=image_name, + tag=tag, + remote_build_flag=remote_build_flag, ) - if needs_creation: - if use_external_docker: - source = "external_docker" - elif use_remote_build: - source = "internal_source" - else: - source = "internal_docker" - deployment_id, step = _create_deployment( - client, - step, - name=name, - source=source, - source_config={"deployment_type": deployment_type}, - source_revision_config={}, - secrets=secrets, - ) - - if not deployment_id: - raise click.ClickException("Failed to determine deployment ID") - - # Validate that an existing deployment is compatible with --image-uri. - # update_deployment_external sends an external_docker revision; applying - # it to a deployment created with a different source mode may be rejected - # by the backend or silently produce an inconsistent revision. - if use_external_docker and not needs_creation: - existing = _call_host_backend_with_optional_tenant( - client, lambda c: c.get_deployment(deployment_id) - ) - existing_source = existing.get("source") if isinstance(existing, dict) else None - if existing_source and existing_source != "external_docker": - raise click.UsageError( - f"Deployment {deployment_id} uses a different build mode and " - f"cannot be updated with --image-uri. To use --image-uri, omit " - f"--deployment-id to create a new deployment, or remove " - f"--image-uri to continue using the current build mode." - ) - - # Scan local sources for tracked packages so the new revision carries - # the same metadata GitHub-backed deploys produce. Failures must never - # block a deploy. + client = _create_host_backend_client(host_url, api_key, env_vars=env_vars) try: tracked_packages = find_tracked_packages(config, config_json) or None except Exception as exc: em.warn(f"Skipped tracked-package scan: {exc}") tracked_packages = None - spec = BuildSpec( - config=config, - config_json=config_json, - base_image=base_image, - api_version=api_version, - pull=pull, - docker_build_args=docker_build_args, - install_command=install_command, - build_command=build_command, - ) - if use_external_docker: - build_result = _run_external_deploy( + outcome = source.run( + DeployContext( client=client, - deployment_id=deployment_id, - step=step, - spec=spec, - image_uri=image_uri, + spec=BuildSpec( + config=config, + config_json=config_json, + base_image=base_image, + api_version=api_version, + pull=pull, + docker_build_args=docker_build_args, + install_command=install_command, + build_command=build_command, + ), verbose=verbose, - secrets=secrets, - tracked_packages=tracked_packages, - ) - elif use_remote_build: - build_result = _run_remote_build( - client=client, - deployment_id=deployment_id, - step=step, - spec=spec, - verbose=verbose, - secrets=secrets, - tracked_packages=tracked_packages, - ) - else: - build_result = _run_local_build( - client=client, - deployment_id=deployment_id, - step=step, - spec=spec, - verbose=verbose, - image_name=image_name, - prebuilt_image=image, name=name, - tag=tag, + deployment_id=deployment_id, + deployment_type=deployment_type, secrets=secrets, tracked_packages=tracked_packages, ) + ) + deployment_id = outcome.deployment_id + build_result = outcome.build_result # -- 4. Shared wait + result -- dep_status_url = _emit_deployment_status_url( diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index b30900273..cee9a9aef 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -14,6 +14,9 @@ import pytest import langgraph_cli.deploy as deploy_mod from langgraph_cli.deploy import ( DockerBuildCommand, + ExternalDockerSource, + InternalDockerSource, + InternalSourceUpload, _call_host_backend_with_optional_tenant, _create_host_backend_client, _docker_config_for_token, @@ -22,6 +25,7 @@ from langgraph_cli.deploy import ( _parse_env_from_config, _resolve_env_path, _resolve_pushed_image_digest, + _select_source, _validate_prebuilt_image, normalize_image_tag, normalize_name, @@ -598,6 +602,102 @@ class TestDockerBuildCommand: assert DockerBuildCommand.for_host(machine, verbose=verbose) == expected +class TestSelectSource: + @pytest.mark.parametrize( + ("flags", "docker_available", "expected"), + [ + pytest.param( + {"image_uri": "registry.example.com/app"}, + True, + ExternalDockerSource(image_uri="registry.example.com/app"), + id="image_uri_selects_the_external_source", + ), + pytest.param( + {"remote_build_flag": True}, + True, + InternalSourceUpload(), + id="remote_flag_selects_the_source_upload", + ), + pytest.param( + {}, + False, + InternalSourceUpload(), + id="no_local_docker_falls_back_to_the_source_upload", + ), + pytest.param( + {}, + True, + InternalDockerSource( + prebuilt_image=None, image_name=None, tag="latest" + ), + id="local_docker_selects_the_internal_docker_source", + ), + pytest.param( + {"image": "app:dev", "tag": "v1"}, + False, + InternalDockerSource( + prebuilt_image="app:dev", image_name=None, tag="v1" + ), + id="prebuilt_image_forces_the_internal_docker_source", + ), + ], + ) + def test_flags_select_one_source( + self, monkeypatch, mocker, flags, docker_available, expected + ): + mocker.patch( + "langgraph_cli.deploy._get_emitter", return_value=mocker.MagicMock() + ) + monkeypatch.setattr( + deploy_mod, + "can_build_locally", + lambda: (True, None) if docker_available else (False, "Docker is required"), + ) + options = { + "image_uri": None, + "image": None, + "image_name": None, + "tag": "latest", + "remote_build_flag": None, + **flags, + } + + assert _select_source(**options) == expected + + @pytest.mark.parametrize( + ("flags", "message"), + [ + pytest.param( + {"image_uri": "registry.example.com/app", "remote_build_flag": True}, + "--image-uri cannot be combined with --remote.", + id="image_uri_with_remote", + ), + pytest.param( + {"image_uri": "registry.example.com/app", "image": "app:dev"}, + "--image-uri cannot be combined with --image.", + id="image_uri_with_image", + ), + pytest.param( + {"image": "app:dev", "remote_build_flag": True}, + "--image cannot be combined with --remote builds.", + id="image_with_remote", + ), + ], + ) + def test_conflicting_flags_are_rejected(self, flags, message): + options = { + "image_uri": None, + "image": None, + "image_name": None, + "tag": "latest", + "remote_build_flag": None, + **flags, + } + + with pytest.raises(click.UsageError, match=message): + _select_source(**options) + + class TestResolvePushedImageDigest: """Tests for ``_resolve_pushed_image_digest`` — runner is mocked to return the ``(stdout, stderr)`` tuple that ``subp_exec(collect=True)``