diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4733a99..fb0b77f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -72,7 +72,7 @@ jobs: strategy: fail-fast: false matrix: - k8s: [ '1.27', '1.34' ] + k8s: [ '1.28', '1.34' ] name: E2E test in K8s ${{ matrix.k8s }} runs-on: ubuntu-24.04 steps: diff --git a/README.md b/README.md index cac0983..7b7bc63 100644 --- a/README.md +++ b/README.md @@ -219,12 +219,12 @@ from lightkube import Client client = Client() # Capture stdout or raise ApiError if error code is != 0 -res = client.exec('my-pod', namespace='default', command=['ls', '-l', '/'], +res = client.exec('my-pod', namespace='default', container='main', command=['ls', '-l', '/'], stdout=True, raise_on_error=True) print(res.stdout) # Send data to stdin and capture output -res = client.exec('my-pod', namespace='default', command=['cat'], +res = client.exec('my-pod', namespace='default', container='main', command=['cat'], stdin='hello\n', stdout=True) print(res.stdout) print(res.exit_code) diff --git a/docs/index.md b/docs/index.md index 06d6535..5e136c4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -400,12 +400,12 @@ Execute a command inside a pod client = Client() # Capture stdout or raise ApiError if error code is != 0 - res = client.exec('my-pod', namespace='default', command=['ls', '-l', '/'], + res = client.exec('my-pod', namespace='default', container='main', command=['ls', '-l', '/'], stdout=True, raise_on_error=True) print(res.stdout) # Send data to stdin and capture output - res = client.exec('my-pod', namespace='default', command=['cat'], + res = client.exec('my-pod', namespace='default', container='main', command=['cat'], stdin='hello\n', stdout=True) print(res.stdout) print(res.exit_code) @@ -419,12 +419,12 @@ Execute a command inside a pod client = AsyncClient() # List a directory - res = await client.exec('my-pod', namespace='default', command=['ls', '-l', '/'], + res = await client.exec('my-pod', namespace='default', container='main', command=['ls', '-l', '/'], stdout=True, raise_on_error=True) print(res.stdout) # Send data to stdin and capture output - res = await client.exec('my-pod', namespace='default', command=['cat'], + res = await client.exec('my-pod', namespace='default', container='main', command=['cat'], stdin='hello\\n', stdout=True) print(res.stdout) print(res.exit_code) diff --git a/e2e-tests/test_client.py b/e2e-tests/test_client.py index 9b823f1..febe3ad 100644 --- a/e2e-tests/test_client.py +++ b/e2e-tests/test_client.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager, contextmanager from datetime import datetime from pathlib import Path from random import choices @@ -16,7 +17,7 @@ get_generic_resource, load_in_cluster_generic_resources, ) -from lightkube.models.core_v1 import Container, PodSpec, ServicePort, ServiceSpec +from lightkube.models.core_v1 import Container, EnvVar, PodSpec, ServicePort, ServiceSpec from lightkube.models.meta_v1 import ObjectMeta from lightkube.resources.apps_v1 import Deployment from lightkube.resources.core_v1 import ConfigMap, Namespace, Node, Pod, Service @@ -52,6 +53,29 @@ def create_pod(name: str, command: str) -> Pod: ) +def create_exec_pod(name: str) -> Pod: + return Pod( + metadata=ObjectMeta(name=name, labels={"app-name": name}), + spec=PodSpec( + containers=[ + Container( + name="main", + image="busybox", + command=["/bin/sh", "-c", "sleep 300"], + env=[EnvVar(name="TARGET_CONTAINER", value="main")], + ), + Container( + name="sidecar", + image="busybox", + command=["/bin/sh", "-c", "sleep 300"], + env=[EnvVar(name="TARGET_CONTAINER", value="sidecar")], + ), + ], + terminationGracePeriodSeconds=1, + ), + ) + + def wait_pod(client: Client, pod): # watch pods for _, p in client.watch(Pod, labels={"app-name": pod.metadata.name}, resource_version=pod.metadata.resourceVersion): @@ -59,6 +83,30 @@ def wait_pod(client: Client, pod): break +@contextmanager +def pod_context(client: Client, pod: Pod, for_conditions=None): + created = client.create(pod) + try: + if for_conditions: + client.wait(Pod, created.metadata.name, for_conditions=for_conditions) + else: + wait_pod(client, created) + yield created + finally: + client.delete(Pod, created.metadata.name) + + +@asynccontextmanager +async def pod_context_async(client: AsyncClient, pod: Pod, for_conditions=None): + created = await client.create(pod) + try: + if for_conditions: + await client.wait(Pod, created.metadata.name, for_conditions=for_conditions) + yield created + finally: + await client.delete(Pod, created.metadata.name) + + def test_pod_apis(obj_name): client = Client() @@ -67,22 +115,15 @@ def test_pod_apis(obj_name): assert len(pods) > 0 assert any(name.startswith("metrics-server") for name in pods) - # create a pod - pod = client.create(create_pod(obj_name, "while true;do echo 'this is a test';sleep 5; done")) - try: + with pod_context(client, create_pod(obj_name, "while true;do echo 'this is a test';sleep 5; done")) as pod: assert pod.metadata.name == obj_name assert pod.metadata.namespace == client.namespace assert pod.status.phase - wait_pod(client, pod) - # read pod logs - for line in client.log(obj_name, follow=True): + for line in client.log(pod.metadata.name, follow=True): assert line == "this is a test\n" break - finally: - # delete the pod - client.delete(Pod, obj_name) def test_pod_not_exist() -> None: @@ -441,10 +482,7 @@ async def test_load_in_cluster_generic_resources_async(sample_crd): def test_exec_integration_ls_and_cat(): client = Client() - pod = client.create(create_pod("exec-busybox", "sleep 300")) - try: - client.wait(Pod, pod.metadata.name, for_conditions=["Ready"]) - + with pod_context(client, create_pod("exec-busybox", "sleep 300"), for_conditions=["Ready"]) as pod: ls_res = client.exec( pod.metadata.name, namespace=pod.metadata.namespace, @@ -469,40 +507,87 @@ def test_exec_integration_ls_and_cat(): raise assert cat_res.stdout == "hello from stdin\n" assert cat_res.exit_code == 0 - finally: - client.delete(Pod, pod.metadata.name) -@pytest.mark.asyncio -async def test_exec_integration_ls_and_cat_async(): - client = AsyncClient() - pod = await client.create(create_pod("exec-busybox-async", "sleep 300")) - try: - await client.wait(Pod, pod.metadata.name, for_conditions=["Ready"]) +def test_exec_integration_container_selection(): + client = Client() + with pod_context(client, create_exec_pod("exec-multi-container"), for_conditions=["Ready"]) as pod: + main_res = client.exec( + pod.metadata.name, + namespace=pod.metadata.namespace, + container="main", + command=["/bin/sh", "-c", 'printf %s "$TARGET_CONTAINER"'], + stdout=True, + raise_on_error=True, + ) + assert main_res.stdout == "main" - ls_res = await client.exec( + sidecar_res = client.exec( pod.metadata.name, namespace=pod.metadata.namespace, - command=["/bin/sh", "-c", "ls /"], + container="sidecar", + command=["/bin/sh", "-c", 'printf %s "$TARGET_CONTAINER"'], stdout=True, raise_on_error=True, ) - assert "bin" in ls_res.stdout.split() + assert sidecar_res.stdout == "sidecar" - try: - cat_res = await client.exec( + +@pytest.mark.asyncio +async def test_exec_integration_ls_and_cat_async(): + client = AsyncClient() + try: + async with pod_context_async(client, create_pod("exec-busybox-async", "sleep 300"), for_conditions=["Ready"]) as pod: + ls_res = await client.exec( pod.metadata.name, namespace=pod.metadata.namespace, - command=["/bin/cat"], - stdin="hello from stdin\n", + command=["/bin/sh", "-c", "ls /"], stdout=True, raise_on_error=True, ) - except ApiError as exc: - if "Only subprotocol v5.channel.k8s.io" in str(exc): - pytest.skip("stdin not supported without v5.channel.k8s.io protocol") - raise - assert cat_res.stdout == "hello from stdin\n" + assert "bin" in ls_res.stdout.split() + + try: + cat_res = await client.exec( + pod.metadata.name, + namespace=pod.metadata.namespace, + command=["/bin/cat"], + stdin="hello from stdin\n", + stdout=True, + raise_on_error=True, + ) + except ApiError as exc: + if "Only subprotocol v5.channel.k8s.io" in str(exc): + pytest.skip("stdin not supported without v5.channel.k8s.io protocol") + raise + assert cat_res.stdout == "hello from stdin\n" + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_exec_integration_container_selection_async(): + client = AsyncClient() + try: + async with pod_context_async(client, create_exec_pod("exec-multi-container-async"), for_conditions=["Ready"]) as pod: + main_res = await client.exec( + pod.metadata.name, + namespace=pod.metadata.namespace, + container="main", + command=["/bin/sh", "-c", 'printf %s "$TARGET_CONTAINER"'], + stdout=True, + raise_on_error=True, + ) + assert main_res.stdout == "main" + + sidecar_res = await client.exec( + pod.metadata.name, + namespace=pod.metadata.namespace, + container="sidecar", + command=["/bin/sh", "-c", 'printf %s "$TARGET_CONTAINER"'], + stdout=True, + raise_on_error=True, + ) + assert sidecar_res.stdout == "sidecar" finally: - await client.delete(Pod, pod.metadata.name) await client.close() diff --git a/pyproject.toml b/pyproject.toml index d90ad01..958386a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lightkube" -version = "0.20.0" +version = "0.21.0" description = "Lightweight kubernetes client library" readme = "README.md" authors = [ diff --git a/src/lightkube/core/async_client.py b/src/lightkube/core/async_client.py index da974d8..9dd02d0 100644 --- a/src/lightkube/core/async_client.py +++ b/src/lightkube/core/async_client.py @@ -760,6 +760,7 @@ async def exec( name: str, *, namespace: Optional[str] = None, + container: Optional[str] = None, command: Union[str, Iterable[str]], stdin: Union[str, bytes, BinaryIO, None] = None, stdout: Union[BinaryIO, bool] = False, @@ -771,26 +772,27 @@ async def exec( """Execute a command in a Pod and return stdout/stderr. Parameters: - name: Name of the Pod. - namespace: Name of the namespace containing the Pod. - command: Command to execute in the Pod. - stdin: Data to send to stdin. This can be either a string, bytes or a binary stream. - Strings will be encoded as utf-8 before sending. - stdout: If `True`, the command's stdout will be captured and returned in the response. - If a binary stream is passed, the command's stdout will be written to it instead. - stderr: If `True`, the command's stderr will be captured and returned in the response. - If a binary stream is passed, the command's stderr will be written to it instead. - decode: Decode captured stdout/stderr in `ExecResponse` using this encoding as strings. - If you expect a binary output, set `stdout` and/or `stderr` to a binary stream or set this parameter to `None`. - raise_on_error: If `True`, an exception will be raised if the command exits with a non-zero status code. - Note that other exceptions may still be raised for other types of errors, such as connection issues, missing - pod or timeouts. - timeout: If set, the maximum amount of time in seconds to wait for the command to complete before raising a - timeout exception. By default, there is no timeout and the method will wait until the command completes - or an error occurs. + name: Name of the Pod. + namespace: Name of the namespace containing the Pod. + container: Name of the container in the pod to execute the command in. + command: Command to execute in the Pod. + stdin: Data to send to stdin. This can be either a string, bytes or a binary stream. + Strings will be encoded as utf-8 before sending. + stdout: If `True`, the command's stdout will be captured and returned in the response. + If a binary stream is passed, the command's stdout will be written to it instead. + stderr: If `True`, the command's stderr will be captured and returned in the response. + If a binary stream is passed, the command's stderr will be written to it instead. + decode: Decode captured stdout/stderr in `ExecResponse` using this encoding as strings. + If you expect a binary output, set `stdout` and/or `stderr` to a binary stream or set this parameter to `None`. + raise_on_error: If `True`, an exception will be raised if the command exits with a non-zero status code. + Note that other exceptions may still be raised for other types of errors, such as connection issues, missing + pod or timeouts. + timeout: If set, the maximum amount of time in seconds to wait for the command to complete before raising a + timeout exception. By default, there is no timeout and the method will wait until the command completes + or an error occurs. """ commands = [command] if isinstance(command, str) else list(command) - params = {"command": commands, "stdout": stdout, "stderr": stderr, "stdin": stdin} + params = {"command": commands, "stdout": stdout, "stderr": stderr, "stdin": stdin, "container": container} return await self._client.ws_request( "exec", name=name, diff --git a/src/lightkube/core/client.py b/src/lightkube/core/client.py index d74417b..d593ee0 100644 --- a/src/lightkube/core/client.py +++ b/src/lightkube/core/client.py @@ -746,6 +746,7 @@ def exec( name: str, *, namespace: Optional[str] = None, + container: Optional[str] = None, command: Union[str, Iterable[str]], stdin: Union[str, bytes, BinaryIO, None] = None, stdout: Union[BinaryIO, bool] = False, @@ -759,6 +760,7 @@ def exec( Parameters: name: Name of the Pod. namespace: Name of the namespace containing the Pod. + container: Name of the container in the pod to execute the command in. command: Command to execute in the Pod. stdin: Data to send to stdin. This can be either a string, bytes or a binary stream. Strings will be encoded as utf-8 before sending. @@ -776,7 +778,7 @@ def exec( or an error occurs. """ commands = [command] if isinstance(command, str) else list(command) - params = {"command": commands, "stdout": stdout, "stderr": stderr, "stdin": stdin} + params = {"command": commands, "stdout": stdout, "stderr": stderr, "stdin": stdin, "container": container} return self._client.ws_request( "exec", name=name, diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 4127981..f551015 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -493,6 +493,23 @@ async def test_exec_writes_to_provided_streams(client: lightkube.AsyncClient, mo assert err_stream.getvalue() == b"err-stream" +@pytest.mark.asyncio +async def test_exec_supports_container_selection(client: lightkube.AsyncClient, monkeypatch) -> None: + messages = [] + captured = {} + + def connect(url, http_client, subprotocols, params): + captured["url"] = url + captured["params"] = params + return FakeWS(messages, exit_code=0) + + monkeypatch.setattr(websocket, "aconnect_ws", connect) + + await client.exec("pod-container", namespace="default", container="main", command=["/bin/echo"], stdout=True) + + assert captured["params"]["container"] == "main" + + @pytest.mark.asyncio async def test_exec_stdin_variants(client: lightkube.AsyncClient, monkeypatch) -> None: messages = [] diff --git a/tests/test_client.py b/tests/test_client.py index 37cd0c2..27b0aa1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -790,6 +790,22 @@ def test_exec_writes_to_provided_streams(client: lightkube.Client, monkeypatch) assert err_stream.getvalue() == b"err-stream" +def test_exec_supports_container_selection(client: lightkube.Client, monkeypatch) -> None: + messages = [] + captured = {} + + def connect(url, http_client, subprotocols, params): + captured["url"] = url + captured["params"] = params + return FakeWS(messages, exit_code=0) + + monkeypatch.setattr(websocket, "connect_ws", connect) + + client.exec("pod-container", namespace="default", container="main", command=["/bin/echo"], stdout=True) + + assert captured["params"]["container"] == "main" + + def test_exec_stdin_variants(client: lightkube.Client, monkeypatch) -> None: messages = [] diff --git a/uv.lock b/uv.lock index 45fd769..5652d7c 100644 --- a/uv.lock +++ b/uv.lock @@ -1110,7 +1110,7 @@ wheels = [ [[package]] name = "lightkube" -version = "0.20.0" +version = "0.21.0" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["http2"] },