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"),