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,