mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-15 12:45:25 +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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user