From a751d09a5214412985cac4f78e9a22ac2450f09a Mon Sep 17 00:00:00 2001 From: gdibben Date: Wed, 14 Oct 2026 08:41:43 +0100 Subject: [PATCH] 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). --- libs/cli/langgraph_cli/deploy.py | 47 +++++++++++--- .../unit_tests/cli/test_deploy_command.py | 63 +++++++++++++++++++ .../tests/unit_tests/test_deploy_helpers.py | 59 +++++++++++++++++ 3 files changed, 161 insertions(+), 8 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index c4169a407..e992587b5 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -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, ) diff --git a/libs/cli/tests/unit_tests/cli/test_deploy_command.py b/libs/cli/tests/unit_tests/cli/test_deploy_command.py index c37cd7624..3aa5e8414 100644 --- a/libs/cli/tests/unit_tests/cli/test_deploy_command.py +++ b/libs/cli/tests/unit_tests/cli/test_deploy_command.py @@ -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: diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index 5318fdc73..35d0c2e14 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -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"),