This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new dbaf9c63199 Add KubernetesPodExecOperator for existing Kubernetes Pods 
(#71244)
dbaf9c63199 is described below

commit dbaf9c631998ccb6487bf0102d2c63fae9684a83
Author: Alejandro Morgante <[email protected]>
AuthorDate: Wed Sep 2 11:04:20 2026 -0300

    Add KubernetesPodExecOperator for existing Kubernetes Pods (#71244)
---
 providers/cncf/kubernetes/docs/kubernetes_rbac.rst |  21 ++
 providers/cncf/kubernetes/docs/operators.rst       |  32 ++
 providers/cncf/kubernetes/provider.yaml            |   1 +
 .../providers/cncf/kubernetes/get_provider_info.py |   1 +
 .../cncf/kubernetes/operators/pod_exec.py          | 297 ++++++++++++++++
 .../cncf/kubernetes/example_kubernetes_pod_exec.py | 119 +++++++
 .../cncf/kubernetes/operators/test_pod_exec.py     | 381 +++++++++++++++++++++
 7 files changed, 852 insertions(+)

diff --git a/providers/cncf/kubernetes/docs/kubernetes_rbac.rst 
b/providers/cncf/kubernetes/docs/kubernetes_rbac.rst
index fbe64cf418a..971c7c06770 100644
--- a/providers/cncf/kubernetes/docs/kubernetes_rbac.rst
+++ b/providers/cncf/kubernetes/docs/kubernetes_rbac.rst
@@ -89,6 +89,27 @@ deployment commonly needs these permissions:
 retrieving XCom from the sidecar container. ``events`` access is used to read
 Kubernetes events for diagnostics.
 
+Existing Pod exec permissions
+-----------------------------
+
+``KubernetesPodExecOperator`` executes a command in an existing Pod without 
managing its lifecycle.
+When the Pod name and namespace are provided directly, it needs only these 
permissions:
+
+.. code-block:: yaml
+
+   apiVersion: rbac.authorization.k8s.io/v1
+   kind: Role
+   metadata:
+     name: airflow-pod-exec
+     namespace: airflow
+   rules:
+     - apiGroups: [""]
+       resources: ["pods"]
+       verbs: ["get"]
+     - apiGroups: [""]
+       resources: ["pods/exec"]
+       verbs: ["get"]
+
 Job launch permissions
 ----------------------
 
diff --git a/providers/cncf/kubernetes/docs/operators.rst 
b/providers/cncf/kubernetes/docs/operators.rst
index a7d1c242b12..f5e19c27a7a 100644
--- a/providers/cncf/kubernetes/docs/operators.rst
+++ b/providers/cncf/kubernetes/docs/operators.rst
@@ -416,6 +416,38 @@ For further information, look at:
 * `Kubernetes Documentation <https://kubernetes.io/docs/home/>`__
 * `Pull an Image from a Private Registry 
<https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/>`__
 
+.. _howto/operator:KubernetesPodExecOperator:
+
+KubernetesPodExecOperator
+=========================
+
+The 
:class:`~airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesPodExecOperator`
+executes a command in a running container of an existing Kubernetes Pod. It 
does not create,
+restart, or delete the target Pod.
+
+.. exampleinclude:: 
/../tests/system/cncf/kubernetes/example_kubernetes_pod_exec.py
+    :language: python
+    :dedent: 4
+    :start-after: [START howto_operator_k8s_pod_exec]
+    :end-before: [END howto_operator_k8s_pod_exec]
+
+Commands are executed directly rather than through a shell. Include a shell 
explicitly when using
+pipes, redirects, variable expansion, or other shell features.
+Standard output and standard error are streamed to the task log. Set 
``do_xcom_push=True`` to also
+return standard output through XCom. Captured output is limited to 49,344 
UTF-8 bytes by default;
+use ``max_xcom_output_size`` to configure a different finite limit. The task 
fails instead of
+returning truncated output when the limit is exceeded.
+
+The target Pod and container must already be running. When ``container_name`` 
is omitted, the
+operator uses the ``kubectl.kubernetes.io/default-container`` annotation when 
present, or the
+first container otherwise. API-visible static Pods are supported through their 
mirror Pod name;
+components that are not exposed by the Kubernetes API cannot be targeted. The 
Kubernetes connection
+requires ``get`` access to ``pods`` and ``pods/exec``; see 
:doc:`kubernetes_rbac`.
+
+If the task or its worker stops while the command is running, Airflow closes 
the exec connection
+but does not modify the target Pod. Kubernetes cannot always determine whether 
a command completed
+before a connection failure, so configure task retries only when the command 
is safe to repeat.
+
 SparkKubernetesOperator
 ==========================
 The 
:class:`~airflow.providers.cncf.kubernetes.operators.spark_kubernetes.SparkKubernetesOperator`
 allows
diff --git a/providers/cncf/kubernetes/provider.yaml 
b/providers/cncf/kubernetes/provider.yaml
index 04346ad7e06..24940c63ba1 100644
--- a/providers/cncf/kubernetes/provider.yaml
+++ b/providers/cncf/kubernetes/provider.yaml
@@ -152,6 +152,7 @@ operators:
       - airflow.providers.cncf.kubernetes.operators.custom_object_launcher
       - airflow.providers.cncf.kubernetes.operators.kueue
       - airflow.providers.cncf.kubernetes.operators.pod
+      - airflow.providers.cncf.kubernetes.operators.pod_exec
       - airflow.providers.cncf.kubernetes.operators.spark_kubernetes
       - airflow.providers.cncf.kubernetes.operators.resource
       - airflow.providers.cncf.kubernetes.operators.job
diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py
index 50861688550..6291fdb2bc4 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py
@@ -48,6 +48,7 @@ def get_provider_info():
                     
"airflow.providers.cncf.kubernetes.operators.custom_object_launcher",
                     "airflow.providers.cncf.kubernetes.operators.kueue",
                     "airflow.providers.cncf.kubernetes.operators.pod",
+                    "airflow.providers.cncf.kubernetes.operators.pod_exec",
                     
"airflow.providers.cncf.kubernetes.operators.spark_kubernetes",
                     "airflow.providers.cncf.kubernetes.operators.resource",
                     "airflow.providers.cncf.kubernetes.operators.job",
diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod_exec.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod_exec.py
new file mode 100644
index 00000000000..510f2d56190
--- /dev/null
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod_exec.py
@@ -0,0 +1,297 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Execute commands in existing Kubernetes pods."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from functools import cached_property
+from typing import TYPE_CHECKING
+
+from kubernetes.client.rest import ApiException
+from kubernetes.stream import stream as kubernetes_stream
+
+from airflow.providers.cncf.kubernetes.hooks.kubernetes import KubernetesHook
+from airflow.providers.cncf.kubernetes.utils.pod_manager import PodPhase
+from airflow.providers.cncf.kubernetes.version_compat import AIRFLOW_V_3_1_PLUS
+
+if AIRFLOW_V_3_1_PLUS:
+    from airflow.sdk import BaseOperator
+else:
+    from airflow.models import BaseOperator
+
+if TYPE_CHECKING:
+    from kubernetes.client import CoreV1Api, V1Pod
+    from kubernetes.stream.ws_client import WSClient
+
+    from airflow.sdk import Context
+
+__all__ = ["KubernetesPodExecOperator"]
+
+_DEFAULT_MAX_XCOM_OUTPUT_SIZE = 49_344
+
+
+def _extract_complete_lines(buffer: str, chunk: str) -> tuple[list[str], str]:
+    """Extract newline-delimited lines while retaining the incomplete 
remainder."""
+    *lines, remainder = f"{buffer}{chunk}".split("\n")
+    return lines, remainder
+
+
+class KubernetesPodExecOperator(BaseOperator):
+    """
+    Execute a command in a running container of an existing Kubernetes pod.
+
+    The operator does not create, restart, or delete the target pod. Commands 
are executed directly,
+    without a shell; include a shell explicitly in ``command`` when shell 
features are required.
+
+    :param pod_name: Name of the existing Kubernetes pod. (templated)
+    :param command: Command and arguments to execute in the container. 
(templated)
+    :param namespace: Namespace containing the pod. Defaults to the namespace 
configured in the
+        Kubernetes connection, then ``default``. (templated)
+    :param container_name: Name of the container in which to execute the 
command. When omitted, the
+        ``kubectl.kubernetes.io/default-container`` annotation or the first 
container is used. (templated)
+    :param kubernetes_conn_id: The :ref:`Kubernetes connection 
<howto/connection:kubernetes>` to use.
+        (templated)
+    :param in_cluster: Use in-cluster Kubernetes configuration.
+    :param cluster_context: Context to use from the kubeconfig. (templated)
+    :param config_file: Path to the kubeconfig file. (templated)
+    :param do_xcom_push: Return standard output through XCom when ``True``. 
Defaults to ``False``.
+    :param max_xcom_output_size: Maximum UTF-8 byte size retained for XCom. 
Defaults to 49,344 bytes.
+    """
+
+    template_fields: Sequence[str] = (
+        "pod_name",
+        "command",
+        "namespace",
+        "container_name",
+        "kubernetes_conn_id",
+        "cluster_context",
+        "config_file",
+    )
+    template_fields_renderers = {"command": "py"}
+
+    def __init__(
+        self,
+        *,
+        pod_name: str,
+        command: Sequence[str],
+        namespace: str | None = None,
+        container_name: str | None = None,
+        kubernetes_conn_id: str | None = KubernetesHook.default_conn_name,
+        in_cluster: bool | None = None,
+        cluster_context: str | None = None,
+        config_file: str | None = None,
+        do_xcom_push: bool = False,
+        max_xcom_output_size: int = _DEFAULT_MAX_XCOM_OUTPUT_SIZE,
+        **kwargs,
+    ) -> None:
+        super().__init__(do_xcom_push=do_xcom_push, **kwargs)
+        if (
+            isinstance(max_xcom_output_size, bool)
+            or not isinstance(max_xcom_output_size, int)
+            or max_xcom_output_size <= 0
+        ):
+            raise ValueError("`max_xcom_output_size` must be a positive 
integer")
+        self.pod_name = pod_name
+        self.command = command
+        self.namespace = namespace
+        self.container_name = container_name
+        self.kubernetes_conn_id = kubernetes_conn_id
+        self.in_cluster = in_cluster
+        self.cluster_context = cluster_context
+        self.config_file = config_file
+        self.max_xcom_output_size = max_xcom_output_size
+        self._exec_client: WSClient | None = None
+        self._exec_target: tuple[str, str, str] | None = None
+
+    @cached_property
+    def hook(self) -> KubernetesHook:
+        return KubernetesHook(
+            conn_id=self.kubernetes_conn_id,
+            in_cluster=self.in_cluster,
+            config_file=self.config_file,
+            cluster_context=self.cluster_context,
+        )
+
+    @cached_property
+    def client(self) -> CoreV1Api:
+        return self.hook.core_v1_client
+
+    def _resolve_namespace(self) -> str:
+        return self.namespace or self.hook.get_namespace() or 
KubernetesHook.DEFAULT_NAMESPACE
+
+    def _validate_command(self) -> list[str]:
+        if isinstance(self.command, str) or not isinstance(self.command, 
Sequence):
+            raise TypeError("`command` must be a sequence of strings, not a 
single string")
+        if not self.command:
+            raise ValueError("`command` must contain at least one element")
+        if not all(isinstance(argument, str) for argument in self.command):
+            raise TypeError("Every element of `command` must be a string")
+        return list(self.command)
+
+    def _resolve_container_name(self, pod: V1Pod) -> str:
+        containers = pod.spec.containers if pod.spec and pod.spec.containers 
else []
+        container_names = [container.name for container in containers]
+        if not container_names:
+            raise RuntimeError(f"Pod {self.pod_name!r} does not define any 
containers")
+
+        if self.container_name:
+            if self.container_name not in container_names:
+                raise ValueError(f"Container {self.container_name!r} does not 
exist in pod {self.pod_name!r}")
+            return self.container_name
+
+        annotations = pod.metadata.annotations if pod.metadata and 
pod.metadata.annotations else {}
+        default_container = 
annotations.get("kubectl.kubernetes.io/default-container")
+        if isinstance(default_container, str) and default_container in 
container_names:
+            return default_container
+        return container_names[0]
+
+    def _validate_container_is_running(self, pod: V1Pod, container_name: str) 
-> None:
+        if not pod.status or pod.status.phase != PodPhase.RUNNING:
+            phase = pod.status.phase if pod.status else None
+            raise RuntimeError(
+                f"Cannot execute a command in pod {self.pod_name!r} while it 
is in phase {phase!r}"
+            )
+
+        statuses = pod.status.container_statuses or []
+        container_status = next((status for status in statuses if status.name 
== container_name), None)
+        if (
+            container_status is None
+            or container_status.state is None
+            or container_status.state.running is None
+        ):
+            raise RuntimeError(
+                f"Container {container_name!r} in pod {self.pod_name!r} is not 
running or "
+                "container status cannot be retrieved"
+            )
+
+    def _log_lines(self, lines: Sequence[str], *, stream_name: str) -> None:
+        for line in lines:
+            self.log.info("[%s] %s", stream_name, line.removesuffix("\r"))
+
+    def _consume_output(self, exec_client: WSClient) -> str:
+        stdout_chunks: list[str] = []
+        stdout_size = 0
+        stdout_buffer = ""
+        stderr_buffer = ""
+        try:
+            while exec_client.is_open():
+                exec_client.update(timeout=1)
+                while exec_client.peek_stdout():
+                    output = exec_client.read_stdout()
+                    stdout_lines, stdout_buffer = 
_extract_complete_lines(stdout_buffer, output)
+                    self._log_lines(stdout_lines, stream_name="stdout")
+                    if self.do_xcom_push:
+                        stdout_size += len(output.encode("utf-8"))
+                        if stdout_size > self.max_xcom_output_size:
+                            raise RuntimeError(
+                                "Standard output exceeded the configured XCom 
limit of "
+                                f"{self.max_xcom_output_size} bytes"
+                            )
+                        stdout_chunks.append(output)
+                while exec_client.peek_stderr():
+                    stderr_lines, stderr_buffer = _extract_complete_lines(
+                        stderr_buffer, exec_client.read_stderr()
+                    )
+                    self._log_lines(stderr_lines, stream_name="stderr")
+        finally:
+            if stdout_buffer:
+                self._log_lines((stdout_buffer,), stream_name="stdout")
+            if stderr_buffer:
+                self._log_lines((stderr_buffer,), stream_name="stderr")
+        return "".join(stdout_chunks)
+
+    def _close_exec_client(self) -> None:
+        exec_client = self._exec_client
+        exec_target = self._exec_target
+        self._exec_client = None
+        self._exec_target = None
+        if exec_client is None:
+            return
+        try:
+            exec_client.close()
+        except Exception:
+            namespace, pod_name, container_name = exec_target or (
+                self.namespace,
+                self.pod_name,
+                self.container_name,
+            )
+            self.log.exception(
+                "Failed to close Kubernetes exec connection for container %s 
in pod %s/%s",
+                container_name,
+                namespace,
+                pod_name,
+            )
+
+    def execute(self, context: Context) -> str | None:
+        command = self._validate_command()
+        namespace = self._resolve_namespace()
+        if not self.pod_name:
+            raise ValueError("`pod_name` must not be empty")
+
+        try:
+            pod = self.hook.get_pod(name=self.pod_name, namespace=namespace)
+        except ApiException as error:
+            raise RuntimeError(
+                f"Unable to read pod {namespace}/{self.pod_name}: 
{error.reason or error}"
+            ) from error
+
+        container_name = self._resolve_container_name(pod)
+        self._validate_container_is_running(pod, container_name)
+        self.log.info(
+            "Executing command in container %s of pod %s/%s", container_name, 
namespace, self.pod_name
+        )
+
+        self._exec_target = (namespace, self.pod_name, container_name)
+        try:
+            exec_client = kubernetes_stream(
+                self.client.connect_get_namespaced_pod_exec,
+                name=self.pod_name,
+                namespace=namespace,
+                container=container_name,
+                command=command,
+                stdin=False,
+                stdout=True,
+                stderr=True,
+                tty=False,
+                _preload_content=False,
+            )
+            self._exec_client = exec_client
+            output = self._consume_output(exec_client)
+            return_code = exec_client.returncode
+        except ApiException as error:
+            raise RuntimeError(
+                f"Unable to execute command in pod 
{namespace}/{self.pod_name}: {error.reason or error}"
+            ) from error
+        finally:
+            self._close_exec_client()
+
+        if return_code is None:
+            raise RuntimeError(
+                f"Command in container {container_name!r} of pod 
{namespace}/{self.pod_name} ended "
+                "without reporting an exit code"
+            )
+        if return_code != 0:
+            raise RuntimeError(
+                f"Command in container {container_name!r} of pod 
{namespace}/{self.pod_name} "
+                f"failed with exit code {return_code}"
+            )
+        return output if self.do_xcom_push else None
+
+    def on_kill(self) -> None:
+        """Close the active Kubernetes exec connection without modifying the 
target pod."""
+        self._close_exec_client()
diff --git 
a/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes_pod_exec.py
 
b/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes_pod_exec.py
new file mode 100644
index 00000000000..aebb2fe838c
--- /dev/null
+++ 
b/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes_pod_exec.py
@@ -0,0 +1,119 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Example Dag for executing a command in an existing Kubernetes Pod."""
+
+from __future__ import annotations
+
+import os
+from datetime import datetime
+
+from kubernetes.client.rest import ApiException
+
+from airflow.providers.cncf.kubernetes.hooks.kubernetes import KubernetesHook
+from airflow.providers.cncf.kubernetes.operators.pod_exec import 
KubernetesPodExecOperator
+from airflow.providers.cncf.kubernetes.operators.resource import (
+    KubernetesCreateResourceOperator,
+    KubernetesDeleteResourceOperator,
+)
+from airflow.providers.cncf.kubernetes.utils.container import 
container_is_running
+from airflow.providers.cncf.kubernetes.utils.pod_manager import PodPhase
+from airflow.sdk import DAG, TriggerRule, task
+
+ENV_ID = os.environ.get("SYSTEM_TESTS_ENV_ID", "default").lower().replace("_", 
"-")
+DAG_ID = "example_kubernetes_pod_exec_operator"
+NAMESPACE = "default"
+POD_NAME = f"airflow-pod-exec-{ENV_ID}"
+CONTAINER_NAME = "worker"
+EXPECTED_OUTPUT = "command executed in existing pod"
+
+pod_conf = f"""
+apiVersion: v1
+kind: Pod
+metadata:
+  name: {POD_NAME}
+  namespace: {NAMESPACE}
+spec:
+  restartPolicy: Never
+  containers:
+    - name: {CONTAINER_NAME}
+      image: busybox:1.38.0
+      command: ["sleep", "3600"]
+"""
+
+
[email protected](poke_interval=2, timeout=120, mode="reschedule")
+def wait_for_running_pod() -> bool:
+    hook = KubernetesHook()
+    try:
+        pod = hook.get_pod(name=POD_NAME, namespace=NAMESPACE)
+    except ApiException as error:
+        if error.status == 404:
+            return False
+        raise
+    return bool(
+        pod.status and pod.status.phase == PodPhase.RUNNING and 
container_is_running(pod, CONTAINER_NAME)
+    )
+
+
+@task
+def verify_output(output: str) -> None:
+    if output != EXPECTED_OUTPUT:
+        raise ValueError(f"Unexpected command output: {output!r}")
+
+
+with DAG(
+    dag_id=DAG_ID,
+    schedule=None,
+    start_date=datetime(2021, 1, 1),
+    catchup=False,
+    tags=["example", "kubernetes"],
+) as dag:
+    create_pod = KubernetesCreateResourceOperator(
+        task_id="create_pod",
+        yaml_conf=pod_conf,
+    )
+
+    pod_is_running = wait_for_running_pod()
+
+    # [START howto_operator_k8s_pod_exec]
+    run_command = KubernetesPodExecOperator(
+        task_id="run_command",
+        pod_name=POD_NAME,
+        namespace=NAMESPACE,
+        container_name=CONTAINER_NAME,
+        command=["sh", "-c", f"printf '{EXPECTED_OUTPUT}'"],
+        do_xcom_push=True,
+    )
+    # [END howto_operator_k8s_pod_exec]
+
+    output_is_valid = verify_output(run_command.output)
+
+    delete_pod = KubernetesDeleteResourceOperator(
+        task_id="delete_pod",
+        yaml_conf=pod_conf,
+        trigger_rule=TriggerRule.ALL_DONE,
+    )
+
+    create_pod >> pod_is_running >> run_command >> output_is_valid >> 
delete_pod
+
+    from tests_common.test_utils.watcher import watcher
+
+    list(dag.tasks) >> watcher()
+
+from tests_common.test_utils.system_tests import get_test_run  # noqa: E402
+
+test_run = get_test_run(dag)
diff --git 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod_exec.py
 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod_exec.py
new file mode 100644
index 00000000000..afe3eda6b6c
--- /dev/null
+++ 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod_exec.py
@@ -0,0 +1,381 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest import mock
+
+import pytest
+from kubernetes.client.rest import ApiException
+from kubernetes.stream.ws_client import WSClient
+
+from airflow.providers.cncf.kubernetes.hooks.kubernetes import KubernetesHook
+from airflow.providers.cncf.kubernetes.operators.pod_exec import 
KubernetesPodExecOperator
+from airflow.providers.cncf.kubernetes.utils.pod_manager import PodPhase
+
+MODULE = "airflow.providers.cncf.kubernetes.operators.pod_exec"
+
+
+def create_pod(
+    *,
+    container_names: tuple[str, ...] = ("main",),
+    annotations: dict[str, str] | None = None,
+    phase: str = PodPhase.RUNNING,
+    container_statuses=None,
+    with_spec: bool = True,
+    with_status: bool = True,
+):
+    if container_statuses is None:
+        container_statuses = [
+            SimpleNamespace(name=name, 
state=SimpleNamespace(running=object())) for name in container_names
+        ]
+    spec = SimpleNamespace(containers=[SimpleNamespace(name=name) for name in 
container_names])
+    status = SimpleNamespace(phase=phase, 
container_statuses=container_statuses)
+    return SimpleNamespace(
+        metadata=SimpleNamespace(annotations=annotations),
+        spec=spec if with_spec else None,
+        status=status if with_status else None,
+    )
+
+
+def create_exec_client(*, stdout=(), stderr=(), returncode=0):
+    exec_client = mock.MagicMock(spec=WSClient)
+    exec_client.is_open.side_effect = [True, False]
+    exec_client.peek_stdout.side_effect = [*stdout, ""]
+    exec_client.read_stdout.side_effect = stdout
+    exec_client.peek_stderr.side_effect = [*stderr, ""]
+    exec_client.read_stderr.side_effect = stderr
+    exec_client.returncode = returncode
+    return exec_client
+
+
+def create_operator(*, pod=None, hook_namespace=None, **kwargs):
+    operator = KubernetesPodExecOperator(
+        task_id="exec",
+        pod_name="existing-pod",
+        command=["echo", "hello"],
+        **kwargs,
+    )
+    hook = mock.MagicMock(spec=KubernetesHook)
+    hook.get_namespace.return_value = hook_namespace
+    hook.get_pod.return_value = pod or create_pod()
+    operator.__dict__["hook"] = hook
+    return operator, hook
+
+
+class TestKubernetesPodExecOperator:
+    def test_template_fields(self):
+        assert set(KubernetesPodExecOperator.template_fields) == {
+            "pod_name",
+            "command",
+            "namespace",
+            "container_name",
+            "kubernetes_conn_id",
+            "cluster_context",
+            "config_file",
+        }
+
+    @mock.patch(f"{MODULE}.KubernetesHook", autospec=True)
+    def test_hook_configuration(self, kubernetes_hook_mock):
+        operator = KubernetesPodExecOperator(
+            task_id="exec",
+            pod_name="existing-pod",
+            command=["date"],
+            kubernetes_conn_id="kubernetes_test",
+            in_cluster=True,
+            config_file="/tmp/kubeconfig",
+            cluster_context="test-context",
+        )
+
+        assert operator.hook is kubernetes_hook_mock.return_value
+        kubernetes_hook_mock.assert_called_once_with(
+            conn_id="kubernetes_test",
+            in_cluster=True,
+            config_file="/tmp/kubeconfig",
+            cluster_context="test-context",
+        )
+
+    @pytest.mark.parametrize(
+        ("do_xcom_push", "expected_result"),
+        [(False, None), (True, "hello\nworld\n")],
+    )
+    @mock.patch(f"{MODULE}.KubernetesPodExecOperator.log", spec=["info", 
"warning"])
+    @mock.patch(f"{MODULE}.kubernetes_stream", autospec=True)
+    def test_execute(self, kubernetes_stream_mock, log_mock, do_xcom_push, 
expected_result):
+        operator, hook = create_operator(
+            namespace="test-namespace",
+            container_name="main",
+            do_xcom_push=do_xcom_push,
+        )
+        exec_client = create_exec_client(
+            stdout=("hello", "\nworld", "\n"), stderr=("warning", "\n"), 
returncode=0
+        )
+        kubernetes_stream_mock.return_value = exec_client
+
+        result = operator.execute(context={})
+
+        assert result == expected_result
+        hook.get_pod.assert_called_once_with(name="existing-pod", 
namespace="test-namespace")
+        kubernetes_stream_mock.assert_called_once_with(
+            hook.core_v1_client.connect_get_namespaced_pod_exec,
+            name="existing-pod",
+            namespace="test-namespace",
+            container="main",
+            command=["echo", "hello"],
+            stdin=False,
+            stdout=True,
+            stderr=True,
+            tty=False,
+            _preload_content=False,
+        )
+        exec_client.update.assert_called_once_with(timeout=1)
+        assert exec_client.read_stdout.call_count == 3
+        assert exec_client.read_stderr.call_count == 2
+        exec_client.close.assert_called_once_with()
+        assert operator._exec_client is None
+        assert operator._exec_target is None
+        log_mock.info.assert_has_calls(
+            [
+                mock.call("[%s] %s", "stdout", "hello"),
+                mock.call("[%s] %s", "stdout", "world"),
+                mock.call("[%s] %s", "stderr", "warning"),
+            ]
+        )
+        log_mock.warning.assert_not_called()
+
+    @pytest.mark.parametrize("max_xcom_output_size", [0, -1, True, 1.5])
+    def test_rejects_invalid_max_xcom_output_size(self, max_xcom_output_size):
+        with pytest.raises(ValueError, match="must be a positive integer"):
+            create_operator(max_xcom_output_size=max_xcom_output_size)
+
+    @mock.patch(f"{MODULE}.kubernetes_stream", autospec=True)
+    def test_rejects_xcom_output_over_limit(self, kubernetes_stream_mock):
+        operator, _ = create_operator(namespace="test-namespace", 
do_xcom_push=True, max_xcom_output_size=3)
+        exec_client = create_exec_client(stdout=("é", "é"))
+        kubernetes_stream_mock.return_value = exec_client
+
+        with pytest.raises(RuntimeError, match="XCom limit of 3 bytes"):
+            operator.execute(context={})
+
+        exec_client.close.assert_called_once_with()
+
+    @mock.patch(f"{MODULE}.KubernetesPodExecOperator.log", spec=["info"])
+    @mock.patch(f"{MODULE}.kubernetes_stream", autospec=True)
+    def test_execute_flushes_incomplete_log_lines(self, 
kubernetes_stream_mock, log_mock):
+        operator, _ = create_operator(namespace="test-namespace", 
container_name="main")
+        kubernetes_stream_mock.return_value = create_exec_client(
+            stdout=("partial ", "stdout"), stderr=("partial ", "stderr")
+        )
+
+        operator.execute(context={})
+
+        log_mock.info.assert_has_calls(
+            [
+                mock.call("[%s] %s", "stdout", "partial stdout"),
+                mock.call("[%s] %s", "stderr", "partial stderr"),
+            ]
+        )
+
+    @pytest.mark.parametrize(
+        ("namespace", "hook_namespace", "expected_namespace"),
+        [
+            ("task-namespace", "connection-namespace", "task-namespace"),
+            (None, "connection-namespace", "connection-namespace"),
+            (None, None, KubernetesHook.DEFAULT_NAMESPACE),
+        ],
+    )
+    def test_resolve_namespace(self, namespace, hook_namespace, 
expected_namespace):
+        operator, _ = create_operator(namespace=namespace, 
hook_namespace=hook_namespace)
+
+        assert operator._resolve_namespace() == expected_namespace
+
+    @pytest.mark.parametrize(
+        ("container_name", "annotations", "expected_container"),
+        [
+            ("secondary", {"kubectl.kubernetes.io/default-container": "main"}, 
"secondary"),
+            (None, {"kubectl.kubernetes.io/default-container": "secondary"}, 
"secondary"),
+            (None, {"kubectl.kubernetes.io/default-container": "missing"}, 
"main"),
+            (None, None, "main"),
+        ],
+    )
+    def test_resolve_container_name(self, container_name, annotations, 
expected_container):
+        pod = create_pod(container_names=("main", "secondary"), 
annotations=annotations)
+        operator, _ = create_operator(pod=pod, container_name=container_name)
+
+        assert operator._resolve_container_name(pod) == expected_container
+
+    @pytest.mark.parametrize("with_spec", [False, True])
+    def test_rejects_pod_without_containers(self, with_spec):
+        pod = create_pod(container_names=(), with_spec=with_spec)
+        operator, _ = create_operator(pod=pod)
+
+        with pytest.raises(RuntimeError, match="does not define any 
containers"):
+            operator._resolve_container_name(pod)
+
+    def test_rejects_unknown_container(self):
+        pod = create_pod()
+        operator, _ = create_operator(pod=pod, container_name="missing")
+
+        with pytest.raises(ValueError, match="does not exist"):
+            operator._resolve_container_name(pod)
+
+    @pytest.mark.parametrize(
+        ("pod", "expected_message"),
+        [
+            (create_pod(with_status=False), "phase None"),
+            (create_pod(phase="Pending"), "phase 'Pending'"),
+            (create_pod(container_statuses=[]), "is not running or container 
status cannot be retrieved"),
+            (
+                create_pod(container_statuses=[SimpleNamespace(name="main", 
state=None)]),
+                "is not running or container status cannot be retrieved",
+            ),
+            (
+                create_pod(
+                    container_statuses=[SimpleNamespace(name="main", 
state=SimpleNamespace(running=None))]
+                ),
+                "is not running or container status cannot be retrieved",
+            ),
+        ],
+    )
+    def test_rejects_unavailable_target(self, pod, expected_message):
+        operator, _ = create_operator(pod=pod)
+
+        with pytest.raises(RuntimeError, match=expected_message):
+            operator._validate_container_is_running(pod, "main")
+
+    @pytest.mark.parametrize(
+        ("command", "error", "expected_message"),
+        [
+            ("echo hello", TypeError, "sequence of strings"),
+            (42, TypeError, "sequence of strings"),
+            ([], ValueError, "at least one element"),
+            (["echo", 42], TypeError, "Every element"),
+        ],
+    )
+    def test_rejects_invalid_command(self, command, error, expected_message):
+        operator, _ = create_operator()
+        operator.command = command
+
+        with pytest.raises(error, match=expected_message):
+            operator.execute(context={})
+
+    def test_rejects_empty_pod_name(self):
+        operator, _ = create_operator()
+        operator.pod_name = ""
+
+        with pytest.raises(ValueError, match="must not be empty"):
+            operator.execute(context={})
+
+    @mock.patch(f"{MODULE}.kubernetes_stream", autospec=True)
+    def test_wraps_pod_read_error(self, kubernetes_stream_mock):
+        operator, hook = create_operator(namespace="test-namespace")
+        hook.get_pod.side_effect = ApiException(status=404, reason="Not Found")
+
+        with pytest.raises(RuntimeError, match="Unable to read pod.*Not 
Found"):
+            operator.execute(context={})
+
+        kubernetes_stream_mock.assert_not_called()
+
+    @mock.patch(f"{MODULE}.kubernetes_stream", autospec=True)
+    def test_wraps_exec_api_error(self, kubernetes_stream_mock):
+        operator, _ = create_operator(namespace="test-namespace")
+        kubernetes_stream_mock.side_effect = ApiException(status=403, 
reason="Forbidden")
+
+        with pytest.raises(RuntimeError, match="Unable to execute 
command.*Forbidden"):
+            operator.execute(context={})
+
+        assert operator._exec_client is None
+
+    @pytest.mark.parametrize(
+        ("returncode", "expected_message"),
+        [(None, "without reporting an exit code"), (17, "failed with exit code 
17")],
+    )
+    @mock.patch(f"{MODULE}.kubernetes_stream", autospec=True)
+    def test_rejects_unsuccessful_command(self, kubernetes_stream_mock, 
returncode, expected_message):
+        operator, _ = create_operator(namespace="test-namespace")
+        exec_client = create_exec_client(returncode=returncode)
+        kubernetes_stream_mock.return_value = exec_client
+
+        with pytest.raises(RuntimeError, match=expected_message):
+            operator.execute(context={})
+
+        exec_client.close.assert_called_once_with()
+
+    def test_on_kill_closes_active_connection(self):
+        operator, _ = create_operator()
+        exec_client = mock.MagicMock(spec=WSClient)
+        operator._exec_client = exec_client
+
+        operator.on_kill()
+
+        exec_client.close.assert_called_once_with()
+        assert operator._exec_client is None
+        assert operator._exec_target is None
+
+    @mock.patch(f"{MODULE}.kubernetes_stream", autospec=True)
+    def test_on_kill_while_consuming_output(self, kubernetes_stream_mock):
+        operator, _ = create_operator(namespace="test-namespace")
+        exec_client = create_exec_client()
+        exec_client.is_open.side_effect = lambda: (operator.on_kill(), 
False)[1]
+        kubernetes_stream_mock.return_value = exec_client
+
+        assert operator.execute(context={}) is None
+
+        exec_client.close.assert_called_once_with()
+        assert operator._exec_client is None
+        assert operator._exec_target is None
+
+    @mock.patch(f"{MODULE}.kubernetes_stream", autospec=True)
+    def test_execute_closes_connection_when_output_consumption_fails(self, 
kubernetes_stream_mock):
+        operator, _ = create_operator(namespace="test-namespace")
+        exec_client = create_exec_client()
+        exec_client.update.side_effect = RuntimeError("WebSocket update 
failed")
+        kubernetes_stream_mock.return_value = exec_client
+
+        with pytest.raises(RuntimeError, match="WebSocket update failed"):
+            operator.execute(context={})
+
+        exec_client.close.assert_called_once_with()
+        assert operator._exec_client is None
+        assert operator._exec_target is None
+
+    def test_on_kill_without_active_connection(self):
+        operator, _ = create_operator()
+
+        operator.on_kill()
+
+        assert operator._exec_client is None
+        assert operator._exec_target is None
+
+    @mock.patch(f"{MODULE}.KubernetesPodExecOperator.log", spec=["exception"])
+    def test_close_error_does_not_mask_task_shutdown(self, log_mock):
+        operator, _ = create_operator()
+        exec_client = mock.MagicMock(spec=WSClient)
+        exec_client.close.side_effect = RuntimeError("connection already 
closed")
+        operator._exec_client = exec_client
+        operator._exec_target = ("test-namespace", "existing-pod", "main")
+
+        operator.on_kill()
+
+        assert operator._exec_client is None
+        assert operator._exec_target is None
+        log_mock.exception.assert_called_once_with(
+            "Failed to close Kubernetes exec connection for container %s in 
pod %s/%s",
+            "main",
+            "test-namespace",
+            "existing-pod",
+        )

Reply via email to