diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index c834800ad..f04e9b31a 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -32,7 +32,7 @@ from langgraph_cli.host_backend import ( HostBackendError, SourceName, ) -from langgraph_cli.image_reference import ImageReference +from langgraph_cli.image_reference import DIGEST_SEPARATOR, ImageReference from langgraph_cli.progress import Progress from langgraph_cli.util import warn_non_wolfi_distro @@ -1579,9 +1579,9 @@ def _ensure_customer_registry_source(existing: ExistingDeployment) -> None: if existing.source != _CUSTOMER_REGISTRY_SOURCE: raise click.UsageError( f"Deployment {existing.id} was not created from an external image " - "and cannot be updated with --push-to. Run without --push-to to keep " - "its current build mode, or use a different --name to create a new " - "deployment." + "and cannot be updated with --push-to or --image-uri. Run without " + "either flag to keep its current build mode, or use a different " + "--name to create a new deployment." ) @@ -1634,9 +1634,22 @@ class RemoteBuildSource: @dataclass(frozen=True, slots=True) -class CustomerRegistrySource: +class BuildAndPush: reference: ImageReference prebuilt_image: str | None + + +@dataclass(frozen=True, slots=True) +class PublishedImage: + image_uri: str + + +ImageSource = BuildAndPush | PublishedImage + + +@dataclass(frozen=True, slots=True) +class CustomerRegistrySource: + image: ImageSource requested_placement: RequestedPlacement def run(self, ctx: DeployContext) -> DeployOutcome: @@ -1721,16 +1734,22 @@ class CustomerRegistrySource: ) def _publish(self, ctx: DeployContext, step: int) -> tuple[str, int]: - image = str(self.reference) + if isinstance(self.image, PublishedImage): + return self.image.image_uri, step + image = str(self.image.reference) with Runner() as runner: - if self.prebuilt_image: - _log_deploy_step(step, f"Validating image {self.prebuilt_image}") + if self.image.prebuilt_image: + _log_deploy_step(step, f"Validating image {self.image.prebuilt_image}") _validate_prebuilt_image( - runner, self.prebuilt_image, verbose=ctx.verbose + runner, self.image.prebuilt_image, verbose=ctx.verbose ) runner.run( subp_exec( - "docker", "tag", self.prebuilt_image, image, verbose=ctx.verbose + "docker", + "tag", + self.image.prebuilt_image, + image, + verbose=ctx.verbose, ) ) else: @@ -1768,20 +1787,35 @@ def _push_reference(push_to: str, tag: str | None) -> ImageReference: return reference.with_tag(normalize_image_tag(tag or _DEFAULT_IMAGE_TAG)) +def _validate_image_uri(image_uri: str) -> str: + value = image_uri.strip() + if not value: + raise click.UsageError("--image-uri must not be empty.") + if DIGEST_SEPARATOR not in value: + raise click.UsageError( + "--image-uri must pin a digest, e.g. " + f"repository{DIGEST_SEPARATOR}. Kubernetes can cache " + "images by tag, so redeploying a mutable tag may silently keep " + "running the previous image." + ) + return value + + def _select_source( *, push_to: str | None, image: str | None, + image_uri: str | None, image_name: str | None, tag: str | None, remote_build_flag: bool | None, placement: RequestedPlacement, selector: DeploymentSelector, ) -> DeploymentSource: - if push_to is None and placement.requested: + if push_to is None and image_uri is None and placement.requested: raise click.UsageError( "--listener-id and --k8s-namespace only apply when creating a " - "deployment with --push-to." + "deployment with --push-to or --image-uri." ) if placement.requested and isinstance(selector, ById): raise click.UsageError( @@ -1789,6 +1823,19 @@ def _select_source( "they cannot be set for an existing --deployment-id. Drop them, or " "create a new deployment with --name." ) + if image_uri is not None: + if push_to is not None: + raise click.UsageError("--image-uri cannot be combined with --push-to.") + if image is not None: + raise click.UsageError("--image-uri cannot be combined with --image.") + if tag is not None: + raise click.UsageError("--image-uri cannot be combined with --tag.") + if remote_build_flag is not None: + raise click.UsageError("--image-uri cannot be combined with --remote.") + return CustomerRegistrySource( + image=PublishedImage(_validate_image_uri(image_uri)), + requested_placement=placement, + ) if push_to is not None: if remote_build_flag is True: raise click.UsageError("--push-to cannot be combined with --remote.") @@ -1796,8 +1843,7 @@ def _select_source( if image is None: _require_local_docker() return CustomerRegistrySource( - reference=reference, - prebuilt_image=image, + image=BuildAndPush(reference=reference, prebuilt_image=image), requested_placement=placement, ) if image and remote_build_flag is True: @@ -2110,19 +2156,30 @@ def _deploy_base_options( "Give the tag here or with --tag (default: latest)." ), ), + click.option( + "--image-uri", + help=( + "Deploy an image that's already in a registry you manage, " + "without building, retagging, or pushing anything. For " + "self-hosted and hybrid LangSmith. Give the full reference, " + "e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com/agents/" + "my-agent:v1.2.3 or ...@sha256:. Cannot be combined " + "with --push-to, --image, --tag, or --remote." + ), + ), click.option( "--listener-id", help=( "Listener that will run the deployment, for workspaces that " "deploy through a listener in your own cluster. Only used when " - "creating a deployment with --push-to." + "creating a deployment with --push-to or --image-uri." ), ), click.option( "--k8s-namespace", help=( "Kubernetes namespace the listener deploys into. Only used when " - "creating a deployment with --push-to." + "creating a deployment with --push-to or --image-uri." ), ), click.option( @@ -2235,6 +2292,7 @@ def _deploy_cmd( image_name: str | None, image: str | None, push_to: str | None, + image_uri: str | None, listener_id: str | None, k8s_namespace: str | None, tag: str | None, @@ -2308,6 +2366,7 @@ def _deploy_cmd( source = _select_source( push_to=push_to, image=image, + image_uri=image_uri, image_name=image_name, tag=tag, remote_build_flag=remote_build_flag, diff --git a/libs/cli/tests/unit_tests/cli/test_deploy_command.py b/libs/cli/tests/unit_tests/cli/test_deploy_command.py index 457725e37..c37cd7624 100644 --- a/libs/cli/tests/unit_tests/cli/test_deploy_command.py +++ b/libs/cli/tests/unit_tests/cli/test_deploy_command.py @@ -695,6 +695,106 @@ def test_push_to_with_deployment_id_fetches_the_deployment_once( ] +def test_image_uri_creates_an_external_deployment_without_any_docker_work( + deploy_project: DeployProject, +) -> None: + result = deploy_project.run("--image-uri", EXTERNAL_DIGEST) + + assert result.exit_code == 0, result.output + assert deploy_project.docker.verbs() == [] + assert deploy_project.timeline == [LIST_DEPLOYMENTS, CREATE_DEPLOYMENT] + assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT][ + "source_revision_config" + ] == {"image_uri": EXTERNAL_DIGEST} + + +def test_image_uri_updates_an_existing_external_deployment_without_any_docker_work( + deploy_project: DeployProject, +) -> None: + deploy_project.control_plane.existing_deployments = [ + {"id": "dep-ext", "name": "my-app", "source": "external_docker"} + ] + + result = deploy_project.run("--image-uri", EXTERNAL_DIGEST) + + assert result.exit_code == 0, result.output + assert deploy_project.docker.verbs() == [] + assert deploy_project.timeline == [LIST_DEPLOYMENTS, _patch("dep-ext")] + assert deploy_project.control_plane.bodies[_patch("dep-ext")] == { + "source_revision_config": {"image_uri": EXTERNAL_DIGEST}, + "secrets": [], + "tracked_packages": TRACKED_PACKAGES, + } + + +def test_image_uri_places_a_new_deployment_on_the_only_listener( + deploy_project: DeployProject, +) -> None: + deploy_project.control_plane.listeners = [LISTENER] + + result = deploy_project.run( + "--image-uri", EXTERNAL_DIGEST, host_url=CLOUD_CONTROL_PLANE_URL + ) + + assert result.exit_code == 0, result.output + assert deploy_project.docker.verbs() == [] + assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == { + "resource_spec": {}, + "listener_id": LISTENER_ID, + "listener_config": {"k8s_namespace": "agents"}, + } + + +def test_image_uri_rejects_a_non_external_deployment_before_any_docker_work( + deploy_project: DeployProject, +) -> None: + deploy_project.control_plane.existing_deployments = [ + {"id": "dep-cli", "name": "my-app", "source": "internal_docker"} + ] + + result = deploy_project.run("--image-uri", EXTERNAL_DIGEST) + + assert result.exit_code != 0 + assert "cannot be updated with --push-to or --image-uri" in result.output + assert deploy_project.docker.verbs() == [] + + +@pytest.mark.parametrize( + ("args", "message"), + [ + pytest.param( + ("--image-uri", EXTERNAL_DIGEST, "--push-to", PUSH_REPOSITORY), + "--image-uri cannot be combined with --push-to.", + id="with_push_to", + ), + pytest.param( + ("--image-uri", EXTERNAL_DIGEST, "--image", "local/app:dev"), + "--image-uri cannot be combined with --image.", + id="with_image", + ), + pytest.param( + ("--image-uri", EXTERNAL_DIGEST, "--tag", "v2"), + "--image-uri cannot be combined with --tag.", + id="with_tag", + ), + pytest.param( + ("--image-uri", EXTERNAL_DIGEST, "--remote"), + "--image-uri cannot be combined with --remote.", + id="with_remote", + ), + ], +) +def test_image_uri_conflicting_flags_are_rejected_before_any_docker_work( + deploy_project: DeployProject, args: tuple[str, ...], message: str +) -> None: + result = deploy_project.run(*args) + + assert result.exit_code != 0 + assert message in result.output + assert deploy_project.docker.verbs() == [] + assert deploy_project.timeline == [] + + def test_invalid_tag_fails_before_any_control_plane_call( deploy_project: DeployProject, ) -> None: diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index 02dce8679..5318fdc73 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -13,6 +13,7 @@ import pytest import langgraph_cli.deploy as deploy_mod from langgraph_cli.deploy import ( + BuildAndPush, ById, ByName, CustomerRegistrySource, @@ -21,6 +22,7 @@ from langgraph_cli.deploy import ( Listener, ManagedRegistrySource, OnListener, + PublishedImage, RemoteBuildSource, RequestedPlacement, Unplaced, @@ -614,6 +616,7 @@ class TestSelectSource: OPTIONS = { "push_to": None, "image": None, + "image_uri": None, "image_name": None, "tag": None, "remote_build_flag": None, @@ -629,8 +632,10 @@ class TestSelectSource: {"push_to": REPOSITORY}, True, CustomerRegistrySource( - reference=ImageReference(REPOSITORY, "latest"), - prebuilt_image=None, + image=BuildAndPush( + reference=ImageReference(REPOSITORY, "latest"), + prebuilt_image=None, + ), requested_placement=RequestedPlacement(), ), id="push_to_selects_the_external_source_with_the_default_tag", @@ -639,8 +644,10 @@ class TestSelectSource: {"push_to": f"{REPOSITORY}:v2"}, True, CustomerRegistrySource( - reference=ImageReference(REPOSITORY, "v2"), - prebuilt_image=None, + image=BuildAndPush( + reference=ImageReference(REPOSITORY, "v2"), + prebuilt_image=None, + ), requested_placement=RequestedPlacement(), ), id="push_to_keeps_a_tag_given_in_the_reference", @@ -649,8 +656,10 @@ class TestSelectSource: {"push_to": REPOSITORY, "tag": "v3"}, True, CustomerRegistrySource( - reference=ImageReference(REPOSITORY, "v3"), - prebuilt_image=None, + image=BuildAndPush( + reference=ImageReference(REPOSITORY, "v3"), + prebuilt_image=None, + ), requested_placement=RequestedPlacement(), ), id="tag_flag_composes_with_push_to", @@ -659,8 +668,10 @@ class TestSelectSource: {"push_to": REPOSITORY, "image": "app:dev"}, False, CustomerRegistrySource( - reference=ImageReference(REPOSITORY, "latest"), - prebuilt_image="app:dev", + image=BuildAndPush( + reference=ImageReference(REPOSITORY, "latest"), + prebuilt_image="app:dev", + ), requested_placement=RequestedPlacement(), ), id="prebuilt_image_is_retagged_for_push_to_without_docker_checks", @@ -672,12 +683,44 @@ class TestSelectSource: }, True, CustomerRegistrySource( - reference=ImageReference(REPOSITORY, "latest"), - prebuilt_image=None, + image=BuildAndPush( + reference=ImageReference(REPOSITORY, "latest"), + prebuilt_image=None, + ), requested_placement=RequestedPlacement("listener-1", "agents"), ), id="push_to_carries_the_requested_placement", ), + pytest.param( + {"image_uri": f"{REPOSITORY}@sha256:abc123"}, + True, + CustomerRegistrySource( + image=PublishedImage(f"{REPOSITORY}@sha256:abc123"), + requested_placement=RequestedPlacement(), + ), + id="image_uri_selects_the_published_image_source_by_digest", + ), + pytest.param( + {"image_uri": f" {REPOSITORY}@sha256:abc123 "}, + False, + CustomerRegistrySource( + image=PublishedImage(f"{REPOSITORY}@sha256:abc123"), + requested_placement=RequestedPlacement(), + ), + id="image_uri_needs_no_local_docker_and_is_trimmed", + ), + pytest.param( + { + "image_uri": f"{REPOSITORY}@sha256:abc123", + "placement": RequestedPlacement("listener-1", "agents"), + }, + False, + CustomerRegistrySource( + image=PublishedImage(f"{REPOSITORY}@sha256:abc123"), + requested_placement=RequestedPlacement("listener-1", "agents"), + ), + id="image_uri_carries_the_requested_placement", + ), pytest.param( {"remote_build_flag": True}, True, @@ -763,6 +806,46 @@ class TestSelectSource: "only apply when creating a deployment with --push-to", id="namespace_without_push_to", ), + pytest.param( + {"image_uri": REPOSITORY, "push_to": REPOSITORY}, + "--image-uri cannot be combined with --push-to.", + id="image_uri_with_push_to", + ), + pytest.param( + {"image_uri": REPOSITORY, "image": "app:dev"}, + "--image-uri cannot be combined with --image.", + id="image_uri_with_image", + ), + pytest.param( + {"image_uri": REPOSITORY, "tag": "v2"}, + "--image-uri cannot be combined with --tag.", + id="image_uri_with_tag", + ), + pytest.param( + {"image_uri": REPOSITORY, "remote_build_flag": True}, + "--image-uri cannot be combined with --remote.", + id="image_uri_with_remote", + ), + pytest.param( + {"image_uri": ""}, + "--image-uri must not be empty.", + id="image_uri_empty", + ), + pytest.param( + {"image_uri": " "}, + "--image-uri must not be empty.", + id="image_uri_blank", + ), + pytest.param( + {"image_uri": f"{REPOSITORY}:v1.2.3"}, + "--image-uri must pin a digest", + id="image_uri_with_a_mutable_tag", + ), + pytest.param( + {"image_uri": REPOSITORY}, + "--image-uri must pin a digest", + id="image_uri_without_any_tag_or_digest", + ), ], ) def test_conflicting_flags_are_rejected(self, monkeypatch, flags, message): @@ -1164,6 +1247,7 @@ def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker( _select_source( push_to="registry.example.com/app", image=None, + image_uri=None, image_name=None, tag=None, remote_build_flag=None,