feat(cli): add --image-uri to deploy an already-pushed image (#9222)

## Summary

`--push-to` always builds (or retags a local `--image`) and pushes, even
when the image already exists at the destination. That breaks CI/CD
pipelines where the build/push step and the deploy step run in separate
jobs: the deploy job has no local copy of the image to retag, and
`--image` requires one (it runs `docker image inspect`, which needs the
image present in the local Docker daemon).

This adds `--image-uri`, which takes a full image reference already in a
registry and deploys it directly via `source_revision_config.image_uri`,
with no local Docker involvement at all: no inspect, no build, no tag,
no push. It gets the same placement behavior as `--push-to`
(`--listener-id`/`--k8s-namespace`, or auto-selecting the sole listener)
and the same update-in-place semantics, since both produce an
`external_docker` deployment and are interchangeable on the same
deployment.

`--image-uri` requires a digest (`repo@sha256:...`) and rejects
empty/blank input. `--push-to` always resolves to a digest after
pushing; `--image-uri` has no push step to resolve one from, so a
mutable tag (or an unvalidated empty string) could let Kubernetes
silently keep running a previously cached image while the CLI reports
success.

Implementation: `CustomerRegistrySource`'s image-acquisition step is
split into two variants, `BuildAndPush` (the existing
`--push-to`/`--image` flow, unchanged behavior) and `PublishedImage`
(the new flag), so create/update/placement logic stays shared. Also
reworded `_ensure_customer_registry_source`'s error, which previously
mentioned only `--push-to` even though it's reachable from `--image-uri`
too.

## Test plan

- [x] `make format && make lint` clean in `libs/cli`
- [x] Unit tests in `test_deploy_helpers.py` for `_select_source`
selecting `PublishedImage` (by digest, trimmed), requiring no local
Docker, carrying placement, and the mutual-exclusivity + validation
errors (`--push-to`, `--image`, `--tag`, `--remote`, empty/blank input,
missing digest)
- [x] CLI-level tests in `test_deploy_command.py`: create and update an
external deployment via `--image-uri` with zero Docker calls, listener
auto-placement, rejecting a non-external existing deployment (now
mentioning both flags), and the conflicting-flag cases end to end
- [x] Full `libs/cli` suite: 524 passed, no regressions
This commit is contained in:
gethin-langchain
2026-10-07 13:32:46 +01:00
committed by GitHub
parent ce14d33687
commit ec4f8535f8
3 changed files with 269 additions and 26 deletions
+75 -16
View File
@@ -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}<sha256 hex>. 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:<digest>. 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,
@@ -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:
@@ -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,