Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
155 changes: 120 additions & 35 deletions e2e-tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime
from pathlib import Path
from random import choices
Expand All @@ -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
Expand Down Expand Up @@ -52,13 +53,60 @@ 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):
if p.status.phase != "Pending":
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()

Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lightkube"
version = "0.20.0"
version = "0.21.0"
description = "Lightweight kubernetes client library"
readme = "README.md"
authors = [
Expand Down
38 changes: 20 additions & 18 deletions src/lightkube/core/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/lightkube/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
Loading
Loading