feat(cli): add --image-pull-secret for deploying through a private registry

Self-hosted and listener-based deployments created with --push-to or
--image-uri had no way to tell the control plane which Kubernetes
secret to use for pulling the image, so they couldn't deploy at all
when the registry requires authentication.

The control plane's resource_spec already supports image_pull_secrets;
the CLI just never exposed a way to set it. Add a repeatable
--image-pull-secret flag, following the same validation pattern as
--listener-id/--k8s-namespace (only valid when creating a deployment
with --push-to or --image-uri, not on an existing --deployment-id).
This commit is contained in:
gdibben
2026-10-14 08:41:43 +01:00
parent a6731f4367
commit a751d09a52
3 changed files with 161 additions and 8 deletions
+39 -8
View File
@@ -1647,10 +1647,19 @@ class PublishedImage:
ImageSource = BuildAndPush | PublishedImage
def _resource_spec(image_pull_secrets: tuple[str, ...]) -> Mapping[str, object]:
if not image_pull_secrets:
return _OPERATOR_DEFAULT_RESOURCE_SPEC
return {
"image_pull_secrets": [{"name": name} for name in image_pull_secrets],
}
@dataclass(frozen=True, slots=True)
class CustomerRegistrySource:
image: ImageSource
requested_placement: RequestedPlacement
image_pull_secrets: tuple[str, ...] = ()
def run(self, ctx: DeployContext) -> DeployOutcome:
if isinstance(ctx.selector, ById):
@@ -1716,7 +1725,7 @@ class CustomerRegistrySource:
else None,
source=_CUSTOMER_REGISTRY_SOURCE,
source_config={
"resource_spec": _OPERATOR_DEFAULT_RESOURCE_SPEC,
"resource_spec": _resource_spec(self.image_pull_secrets),
**placement.source_config(),
},
source_revision_config={"image_uri": image_uri},
@@ -1810,18 +1819,24 @@ def _select_source(
tag: str | None,
remote_build_flag: bool | None,
placement: RequestedPlacement,
image_pull_secrets: tuple[str, ...],
selector: DeploymentSelector,
) -> DeploymentSource:
if push_to is None and image_uri is None and placement.requested:
if (
push_to is None
and image_uri is None
and (placement.requested or image_pull_secrets)
):
raise click.UsageError(
"--listener-id and --k8s-namespace only apply when creating a "
"deployment with --push-to or --image-uri."
"--listener-id, --k8s-namespace, and --image-pull-secret only "
"apply when creating a deployment with --push-to or --image-uri."
)
if placement.requested and isinstance(selector, ById):
if (placement.requested or image_pull_secrets) and isinstance(selector, ById):
raise click.UsageError(
"Listener and namespace are fixed when a deployment is created, so "
"they cannot be set for an existing --deployment-id. Drop them, or "
"create a new deployment with --name."
"Listener, namespace, and image pull secrets are fixed when a "
"deployment is created, so 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:
@@ -1835,6 +1850,7 @@ def _select_source(
return CustomerRegistrySource(
image=PublishedImage(_validate_image_uri(image_uri)),
requested_placement=placement,
image_pull_secrets=image_pull_secrets,
)
if push_to is not None:
if remote_build_flag is True:
@@ -1845,6 +1861,7 @@ def _select_source(
return CustomerRegistrySource(
image=BuildAndPush(reference=reference, prebuilt_image=image),
requested_placement=placement,
image_pull_secrets=image_pull_secrets,
)
if image and remote_build_flag is True:
raise click.UsageError("--image cannot be combined with --remote builds.")
@@ -2188,6 +2205,18 @@ def _deploy_base_options(
"creating a deployment with --push-to or --image-uri."
),
),
click.option(
"--image-pull-secret",
"image_pull_secrets",
multiple=True,
help=(
"Name of a Kubernetes secret (type kubernetes.io/"
"dockerconfigjson) in the deployment's namespace to use for "
"pulling the image from a private registry. Repeat to give "
"more than one. Only used when creating a deployment with "
"--push-to or --image-uri."
),
),
click.option(
"--config",
"-c",
@@ -2301,6 +2330,7 @@ def _deploy_cmd(
image_uri: str | None,
listener_id: str | None,
k8s_namespace: str | None,
image_pull_secrets: tuple[str, ...],
tag: str | None,
base_image: str | None,
install_command: str | None,
@@ -2377,6 +2407,7 @@ def _deploy_cmd(
tag=tag,
remote_build_flag=remote_build_flag,
placement=RequestedPlacement(listener_id, k8s_namespace),
image_pull_secrets=image_pull_secrets,
selector=selector,
)
@@ -918,6 +918,69 @@ def test_self_hosted_control_plane_places_when_asked(
}
def test_image_pull_secret_is_included_in_resource_spec_on_create(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(
"--push-to",
PUSH_REPOSITORY,
"--image-pull-secret",
"regcred",
"--image-pull-secret",
"other-secret",
)
assert result.exit_code == 0, result.output
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == {
"resource_spec": {
"image_pull_secrets": [
{"name": "regcred"},
{"name": "other-secret"},
],
},
}
def test_image_pull_secret_works_with_image_uri_too(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(
"--image-uri", EXTERNAL_DIGEST, "--image-pull-secret", "regcred"
)
assert result.exit_code == 0, result.output
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == {
"resource_spec": {"image_pull_secrets": [{"name": "regcred"}]},
}
def test_image_pull_secret_without_push_to_or_image_uri_is_refused(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run("--image-pull-secret", "regcred")
assert result.exit_code != 0
assert "only apply when creating a deployment with --push-to" in result.output
assert deploy_project.timeline == []
def test_image_pull_secret_cannot_update_an_existing_deployment(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(
"--deployment-id",
"dep-1",
"--push-to",
PUSH_REPOSITORY,
"--image-pull-secret",
"regcred",
)
assert result.exit_code != 0
assert "--deployment-id" in result.output
assert deploy_project.timeline == []
def test_updating_a_deployment_never_looks_up_listeners(
deploy_project: DeployProject,
) -> None:
@@ -34,6 +34,7 @@ from langgraph_cli.deploy import (
_parse_env_from_config,
_resolve_env_path,
_resolve_pushed_image_digest,
_resource_spec,
_select_source,
_validate_prebuilt_image,
find_deployment_by_name,
@@ -621,6 +622,7 @@ class TestSelectSource:
"tag": None,
"remote_build_flag": None,
"placement": RequestedPlacement(),
"image_pull_secrets": (),
"selector": ByName("my-app"),
}
REPOSITORY = "registry.example.com/app"
@@ -691,6 +693,22 @@ class TestSelectSource:
),
id="push_to_carries_the_requested_placement",
),
pytest.param(
{
"push_to": REPOSITORY,
"image_pull_secrets": ("regcred", "other-secret"),
},
True,
CustomerRegistrySource(
image=BuildAndPush(
reference=ImageReference(REPOSITORY, "latest"),
prebuilt_image=None,
),
requested_placement=RequestedPlacement(),
image_pull_secrets=("regcred", "other-secret"),
),
id="push_to_carries_the_image_pull_secrets",
),
pytest.param(
{"image_uri": f"{REPOSITORY}@sha256:abc123"},
True,
@@ -806,6 +824,11 @@ class TestSelectSource:
"only apply when creating a deployment with --push-to",
id="namespace_without_push_to",
),
pytest.param(
{"image_pull_secrets": ("regcred",)},
"only apply when creating a deployment with --push-to",
id="image_pull_secret_without_push_to",
),
pytest.param(
{"image_uri": REPOSITORY, "push_to": REPOSITORY},
"--image-uri cannot be combined with --push-to.",
@@ -1252,10 +1275,46 @@ def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker(
tag=None,
remote_build_flag=None,
placement=RequestedPlacement(listener_id="listener-1"),
image_pull_secrets=(),
selector=ById("dep-1"),
)
def test_a_deployment_id_with_image_pull_secrets_is_refused_without_probing_docker(
monkeypatch,
):
def explode() -> tuple[bool, str | None]:
raise AssertionError("docker must not be probed for an argv-only conflict")
monkeypatch.setattr(deploy_mod, "can_build_locally", explode)
with pytest.raises(click.UsageError, match="--deployment-id"):
_select_source(
push_to="registry.example.com/app",
image=None,
image_uri=None,
image_name=None,
tag=None,
remote_build_flag=None,
placement=RequestedPlacement(),
image_pull_secrets=("regcred",),
selector=ById("dep-1"),
)
class TestResourceSpec:
def test_no_secrets_keeps_the_operator_default(self):
assert _resource_spec(()) == {}
def test_secrets_become_local_object_references_in_order(self):
assert _resource_spec(("regcred", "other-secret")) == {
"image_pull_secrets": [
{"name": "regcred"},
{"name": "other-secret"},
],
}
class TestPlacementOnAKnownListener:
@pytest.mark.parametrize(
("request_", "listener", "expected"),