mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-18 05:57:37 +02:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user