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

shahar1 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 35e8183358a Add EKS operator for commands in existing Pods (#72542)
35e8183358a is described below

commit 35e8183358a82e9ad53a3b380910bd7465548310
Author: Alejandro Morgante <[email protected]>
AuthorDate: Wed Oct 21 16:23:27 2026 -0300

    Add EKS operator for commands in existing Pods (#72542)
    
    Allow Dags to execute commands in externally managed EKS Pods without 
making Airflow responsible for their lifecycle.
---
 .../tests/unit/always/test_project_structure.py    |   2 +
 providers/amazon/docs/operators/eks.rst            |  30 +++
 .../airflow/providers/amazon/aws/operators/eks.py  | 121 ++++++++++-
 .../amazon/aws/example_eks_with_nodegroups.py      |  89 ++++++++-
 .../tests/unit/amazon/aws/operators/test_eks.py    | 222 ++++++++++++++++++++-
 5 files changed, 458 insertions(+), 6 deletions(-)

diff --git a/airflow-core/tests/unit/always/test_project_structure.py 
b/airflow-core/tests/unit/always/test_project_structure.py
index a9d12c05600..88a17745b89 100644
--- a/airflow-core/tests/unit/always/test_project_structure.py
+++ b/airflow-core/tests/unit/always/test_project_structure.py
@@ -511,6 +511,8 @@ class 
TestAmazonProviderProjectStructure(ExampleCoverageTest):
 
     BASE_CLASSES = {
         "airflow.providers.amazon.aws.operators.base_aws.AwsBaseOperator",
+        # Compatibility fallback for EksPodExecOperator, not a standalone 
Amazon operator.
+        "airflow.providers.amazon.aws.operators.eks.KubernetesPodExecOperator",
         
"airflow.providers.amazon.aws.operators.glue_crawler._GlueCrawlerBaseOperator",
         "airflow.providers.amazon.aws.operators.rds.RdsBaseOperator",
         
"airflow.providers.amazon.aws.operators.sagemaker.SageMakerBaseOperator",
diff --git a/providers/amazon/docs/operators/eks.rst 
b/providers/amazon/docs/operators/eks.rst
index 76c0f5ea4ed..3d8c99fb4d6 100644
--- a/providers/amazon/docs/operators/eks.rst
+++ b/providers/amazon/docs/operators/eks.rst
@@ -205,6 +205,36 @@ Note: An Amazon EKS Cluster with underlying compute 
infrastructure is required.
     :start-after: [START howto_operator_eks_pod_operator]
     :end-before: [END howto_operator_eks_pod_operator]
 
+.. _howto/operator:EksPodExecOperator:
+
+Execute a command in an existing Pod on Amazon EKS
+==================================================
+
+To execute a command in a running container without managing the Pod 
lifecycle, use
+:class:`~airflow.providers.amazon.aws.operators.eks.EksPodExecOperator`.
+
+This operator requires ``apache-airflow-providers-cncf-kubernetes>=10.22.0``.
+Existing EKS operators remain available with older supported versions of the 
Kubernetes provider.
+
+As with ``EksPodOperator``, ``kubernetes_conn_id`` defaults to 
``kubernetes_default`` and can be
+set to another Kubernetes connection. If the default connection contains 
``kube_config`` or
+``cluster_context``, use a separate connection without those settings, since 
EKS generates its own kubeconfig.
+
+The Pod must already exist and be running. The operator streams command 
output, waits for the exit code,
+and does not create, restart, or delete the Pod.
+
+The AWS identity must have permission to call ``eks:DescribeCluster`` and be 
authorized to access the
+EKS cluster. Kubernetes RBAC must allow ``get`` on ``pods`` and both ``get`` 
and ``create`` on ``pods/exec``.
+
+See 
:class:`~airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesPodExecOperator`
+for command, output, XCom, and retry behavior.
+
+.. exampleinclude:: 
/../../amazon/tests/system/amazon/aws/example_eks_with_nodegroups.py
+    :language: python
+    :dedent: 4
+    :start-after: [START howto_operator_eks_pod_exec]
+    :end-before: [END howto_operator_eks_pod_exec]
+
 Sensors
 -------
 
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py
index bd12e2d3075..4840c1a38a0 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py
@@ -47,10 +47,28 @@ from airflow.providers.amazon.aws.utils import (
     build_resource_in_use_retry_args,
     validate_execute_complete_event,
 )
-from airflow.providers.amazon.aws.utils.mixins import aws_template_fields
+from airflow.providers.amazon.aws.utils.mixins import AwsHookParams, 
aws_template_fields
 from airflow.providers.amazon.aws.utils.waiter_with_logging import wait
 from airflow.providers.cncf.kubernetes.utils.pod_manager import OnFinishAction
-from airflow.providers.common.compat.sdk import AirflowException, conf
+from airflow.providers.common.compat.sdk import (
+    AirflowException,
+    AirflowOptionalProviderFeatureException,
+    BaseOperator,
+    conf,
+)
+
+try:
+    from airflow.providers.cncf.kubernetes.operators.pod_exec import 
KubernetesPodExecOperator
+except ImportError:
+
+    class KubernetesPodExecOperator(BaseOperator):  # type: ignore[no-redef]
+        """Keep existing EKS operators importable with older Kubernetes 
providers."""
+
+        def __init__(self, **kwargs):
+            raise AirflowOptionalProviderFeatureException(
+                "EksPodExecOperator requires 
apache-airflow-providers-cncf-kubernetes>=10.22.0."
+            )
+
 
 try:
     from airflow.providers.cncf.kubernetes.operators.pod import 
KubernetesPodOperator
@@ -1348,3 +1366,102 @@ class EksPodOperator(KubernetesPodOperator):
                 self.log.exception("Failed to refresh AWS credentials.")
                 raise
         super()._refresh_cached_properties()
+
+
+class EksPodExecOperator(KubernetesPodExecOperator):
+    """
+    Execute a command in a running container of an existing Pod on Amazon EKS.
+
+    The operator authenticates with Amazon EKS and delegates command execution 
to
+    
:class:`~airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesPodExecOperator`.
+    It does not create, restart, or delete the target Pod.
+
+    .. seealso::
+        For more information on how to use this operator, take a look at the 
guide:
+        :ref:`howto/operator:EksPodExecOperator`
+
+    :param cluster_name: The name of the Amazon EKS Cluster containing the 
Pod. (templated)
+    :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 ``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.
+        Defaults to ``None``. (templated)
+    :param aws_conn_id: The Airflow connection used for AWS credentials. 
(templated)
+        Defaults to ``aws_default``. If this is ``None`` or empty, the default 
boto3 credential
+        strategy is used without an Airflow connection lookup.
+    :param region_name: AWS region containing the Amazon EKS Cluster. 
(templated)
+        Defaults to ``None``, which uses the region from the AWS connection 
when available and
+        otherwise falls back to the default boto3 region strategy.
+    :param verify: Whether to verify SSL certificates, or the path to a CA 
bundle. Defaults to
+        ``None``, which uses the value from the AWS connection when available. 
(templated)
+    :param botocore_config: Configuration dictionary for the botocore client. 
Defaults to ``None``,
+        which uses ``config_kwargs`` from the AWS connection when available.
+    :param kubernetes_conn_id: Kubernetes connection used for additional 
client configuration.
+        Defaults to ``kubernetes_default``, as with ``EksPodOperator``. 
(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] = aws_template_fields(
+        "cluster_name",
+        *(
+            field
+            for field in KubernetesPodExecOperator.template_fields
+            if field not in {"cluster_context", "config_file"}
+        ),
+    )
+
+    def __init__(
+        self,
+        *,
+        cluster_name: str,
+        pod_name: str,
+        command: Sequence[str],
+        namespace: str = DEFAULT_NAMESPACE_NAME,
+        container_name: str | None = None,
+        aws_conn_id: str | None = DEFAULT_CONN_ID,
+        region_name: str | None = None,
+        verify: bool | str | None = None,
+        botocore_config: dict | None = None,
+        **kwargs,
+    ) -> None:
+        for parameter in ("in_cluster", "config_file", "cluster_context"):
+            if parameter in kwargs:
+                raise ValueError(f"The {parameter} is not an allowed parameter 
for the EksPodExecOperator.")
+        hook_params = AwsHookParams.from_constructor(
+            aws_conn_id, region_name, verify, botocore_config, 
additional_params=kwargs
+        )
+        super().__init__(
+            pod_name=pod_name,
+            command=command,
+            namespace=namespace,
+            container_name=container_name,
+            in_cluster=False,
+            cluster_context=None,
+            config_file=None,
+            **kwargs,
+        )
+        self.cluster_name = cluster_name
+        self.aws_conn_id = hook_params.aws_conn_id
+        self.region_name = hook_params.region_name
+        self.verify = hook_params.verify
+        self.botocore_config = hook_params.botocore_config
+
+    def execute(self, context: Context) -> str | None:
+        eks_hook = EksHook(
+            aws_conn_id=self.aws_conn_id,
+            region_name=self.region_name,
+            verify=self.verify,
+            config=self.botocore_config,
+        )
+        with eks_hook.generate_config_file_with_credentials(
+            eks_cluster_name=self.cluster_name, pod_namespace=self.namespace
+        ) as (config_file, _):
+            self.config_file = config_file
+            try:
+                return super().execute(context)
+            finally:
+                self.config_file = None
+                self.__dict__.pop("client", None)
+                self.__dict__.pop("hook", None)
diff --git 
a/providers/amazon/tests/system/amazon/aws/example_eks_with_nodegroups.py 
b/providers/amazon/tests/system/amazon/aws/example_eks_with_nodegroups.py
index 018d49c0831..8ed41186500 100644
--- a/providers/amazon/tests/system/amazon/aws/example_eks_with_nodegroups.py
+++ b/providers/amazon/tests/system/amazon/aws/example_eks_with_nodegroups.py
@@ -16,19 +16,26 @@
 # under the License.
 from __future__ import annotations
 
+import asyncio
+from contextlib import contextmanager
 from datetime import datetime
+from typing import TYPE_CHECKING
 
 import boto3
+from kubernetes.client import V1Container, V1ObjectMeta, V1Pod, V1PodSpec
 
-from airflow.providers.amazon.aws.hooks.eks import ClusterStates, 
NodegroupStates
+from airflow.providers.amazon.aws.hooks.eks import ClusterStates, EksHook, 
NodegroupStates
 from airflow.providers.amazon.aws.operators.eks import (
     EksCreateClusterOperator,
     EksCreateNodegroupOperator,
     EksDeleteClusterOperator,
     EksDeleteNodegroupOperator,
+    EksPodExecOperator,
     EksPodOperator,
 )
 from airflow.providers.amazon.aws.sensors.eks import EksClusterStateSensor, 
EksNodegroupStateSensor
+from airflow.providers.cncf.kubernetes.hooks.kubernetes import KubernetesHook
+from airflow.providers.cncf.kubernetes.utils.pod_manager import PodManager
 
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
 
@@ -49,7 +56,13 @@ except ImportError:
 from system.amazon.aws.utils import ENV_ID_KEY, SystemTestContextBuilder
 from system.amazon.aws.utils.k8s import get_describe_pod_operator
 
+if TYPE_CHECKING:
+    from collections.abc import Generator
+
+    from kubernetes.client import CoreV1Api
+
 DAG_ID = "example_eks_with_nodegroups"
+EXPECTED_EXEC_OUTPUT = "command executed in existing EKS pod"
 
 # Externally fetched variables:
 ROLE_ARN_KEY = "ROLE_ARN"
@@ -76,6 +89,43 @@ def delete_launch_template(template_name: str):
     
boto3.client("ec2").delete_launch_template(LaunchTemplateName=template_name)
 
 
+@contextmanager
+def get_eks_kubernetes_client(cluster_name: str) -> Generator[CoreV1Api, None, 
None]:
+    eks_hook = EksHook()
+    with eks_hook.generate_config_file_with_credentials(
+        eks_cluster_name=cluster_name, pod_namespace="default"
+    ) as (config_file, _):
+        yield KubernetesHook(kubernetes_conn_id=None, 
config_file=config_file).core_v1_client
+
+
+@task
+def create_exec_pod(cluster_name: str, pod_name: str) -> None:
+    pod = V1Pod(
+        metadata=V1ObjectMeta(name=pod_name, namespace="default"),
+        spec=V1PodSpec(
+            containers=[V1Container(name="main", image="busybox:1.38.0", 
command=["sleep", "3600"])],
+            restart_policy="Never",
+        ),
+    )
+    with get_eks_kubernetes_client(cluster_name) as kube_client:
+        pod_manager = PodManager(kube_client=kube_client)
+        created_pod = pod_manager.create_pod(pod)
+        asyncio.run(pod_manager.await_pod_start(created_pod))
+
+
+@task(trigger_rule=TriggerRule.ALL_DONE)
+def delete_exec_pod(cluster_name: str, pod_name: str) -> None:
+    pod = V1Pod(metadata=V1ObjectMeta(name=pod_name, namespace="default"))
+    with get_eks_kubernetes_client(cluster_name) as kube_client:
+        PodManager(kube_client=kube_client).delete_pod(pod)
+
+
+@task
+def verify_exec_output(output: str) -> None:
+    if output != EXPECTED_EXEC_OUTPUT:
+        raise ValueError(f"Unexpected command output: {output!r}")
+
+
 with DAG(
     dag_id=DAG_ID,
     schedule="@once",
@@ -88,6 +138,7 @@ with DAG(
     cluster_name = f"{env_id}-cluster"
     nodegroup_name = f"{env_id}-nodegroup"
     launch_template_name = f"{env_id}-launch-template"
+    exec_pod_name = f"{env_id}-exec-pod"
 
     # [START howto_operator_eks_create_cluster]
     # Create an Amazon EKS Cluster control plane without attaching compute 
service.
@@ -151,6 +202,22 @@ with DAG(
     # it is cleaned anyway with the cluster later on.
     start_pod.is_delete_operator_pod = False
 
+    create_exec_pod_task = create_exec_pod(cluster_name, exec_pod_name)
+
+    # [START howto_operator_eks_pod_exec]
+    run_command = EksPodExecOperator(
+        task_id="run_command_in_existing_pod",
+        cluster_name=cluster_name,
+        pod_name=exec_pod_name,
+        command=["sh", "-c", f"printf '{EXPECTED_EXEC_OUTPUT}'"],
+        do_xcom_push=True,
+    )
+    # [END howto_operator_eks_pod_exec]
+
+    exec_output_is_valid = verify_exec_output(run_command.output)
+
+    delete_exec_pod_task = delete_exec_pod(cluster_name, exec_pod_name)
+
     describe_pod = get_describe_pod_operator(
         cluster_name, pod_name="{{ ti.xcom_pull(key='pod_name', 
task_ids='run_pod') }}"
     )
@@ -211,15 +278,31 @@ with DAG(
         # TEST SETUP
         test_context,
         create_launch_template(launch_template_name),
-        # TEST BODY
         create_cluster,
         await_create_cluster,
         create_nodegroup,
         await_create_nodegroup,
+    )
+    chain(
+        # TEST BODY: EksPodOperator
+        await_create_nodegroup,
         start_pod,
-        # TEST TEARDOWN
         describe_pod,
         await_nodegroup_stable,
+    )
+    chain(
+        # TEST BODY: EksPodExecOperator
+        await_create_nodegroup,
+        create_exec_pod_task,
+        run_command,
+        exec_output_is_valid,
+        # TEST TEARDOWN
+        delete_exec_pod_task,
+        await_nodegroup_stable,
+    )
+    chain(
+        # TEST TEARDOWN
+        await_nodegroup_stable,
         delete_nodegroup,  # part of the test AND teardown
         await_delete_nodegroup,
         await_cluster_stable,
diff --git a/providers/amazon/tests/unit/amazon/aws/operators/test_eks.py 
b/providers/amazon/tests/unit/amazon/aws/operators/test_eks.py
index 8d100e6391f..418878c777c 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_eks.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_eks.py
@@ -17,6 +17,7 @@
 # under the License.
 from __future__ import annotations
 
+import runpy
 from contextlib import AbstractContextManager, nullcontext
 from typing import Any, TypedDict
 from unittest import mock
@@ -27,6 +28,7 @@ from botocore.waiter import Waiter
 
 from airflow.exceptions import AirflowException, 
AirflowProviderDeprecationWarning
 from airflow.providers.amazon.aws.hooks.eks import ClusterStates, EksHook
+from airflow.providers.amazon.aws.operators import eks
 from airflow.providers.amazon.aws.operators.eks import (
     EksCreateClusterOperator,
     EksCreateFargateProfileOperator,
@@ -34,6 +36,7 @@ from airflow.providers.amazon.aws.operators.eks import (
     EksDeleteClusterOperator,
     EksDeleteFargateProfileOperator,
     EksDeleteNodegroupOperator,
+    EksPodExecOperator,
     EksPodOperator,
 )
 from airflow.providers.amazon.aws.triggers.eks import (
@@ -43,7 +46,7 @@ from airflow.providers.amazon.aws.triggers.eks import (
     EksPodTrigger,
 )
 from airflow.providers.cncf.kubernetes.utils.pod_manager import OnFinishAction
-from airflow.providers.common.compat.sdk import TaskDeferred
+from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException, TaskDeferred
 
 from unit.amazon.aws.utils.eks_test_constants import (
     NODEROLE_ARN,
@@ -1463,3 +1466,220 @@ class TestEksPodOperator:
 
         with pytest.raises(RuntimeError, match="Pod must be created with 
metadata before deferring"):
             op.invoke_defer_method()
+
+
[email protected]("sys.modules", 
{"airflow.providers.cncf.kubernetes.operators.pod_exec": None})
+def test_eks_operators_remain_available_without_pod_exec():
+    operators = runpy.run_path(eks.__file__)
+
+    operators["EksPodOperator"](task_id="existing_operator", 
cluster_name=CLUSTER_NAME)
+
+
[email protected]("sys.modules", 
{"airflow.providers.cncf.kubernetes.operators.pod_exec": None})
+def test_eks_pod_exec_requires_available_kubernetes_pod_exec():
+    operators = runpy.run_path(eks.__file__)
+
+    with pytest.raises(
+        AirflowOptionalProviderFeatureException,
+        match=r"EksPodExecOperator requires 
apache-airflow-providers-cncf-kubernetes>=10\.22\.0",
+    ):
+        operators["EksPodExecOperator"](
+            task_id="exec_operator", cluster_name=CLUSTER_NAME, 
pod_name="existing-pod", command=["true"]
+        )
+
+
+class TestEksPodExecOperator:
+    @pytest.mark.parametrize("parameter", ["in_cluster", "config_file", 
"cluster_context"])
+    @pytest.mark.parametrize("value", [None, "custom-value"])
+    def test_rejects_managed_kubernetes_parameters(self, parameter, value):
+        with pytest.raises(
+            ValueError, match=f"The {parameter} is not an allowed parameter 
for the EksPodExecOperator"
+        ):
+            EksPodExecOperator(
+                task_id="run_command",
+                cluster_name=CLUSTER_NAME,
+                pod_name="existing-pod",
+                command=["echo", "hello"],
+                **{parameter: value},
+            )
+
+    @staticmethod
+    def configure_eks_auth(eks_hook_mock):
+        eks_hook = eks_hook_mock.return_value
+        auth_context = mock.MagicMock(spec=AbstractContextManager)
+        auth_context.__enter__.return_value = (
+            "/tmp/eks-kubeconfig",
+            "/tmp/aws-credentials",
+        )
+        eks_hook.generate_config_file_with_credentials.return_value = 
auth_context
+        return eks_hook
+
+    @mock.patch(
+        
"airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesPodExecOperator.execute",
+        autospec=True,
+    )
+    @mock.patch("airflow.providers.amazon.aws.operators.eks.EksHook", 
autospec=True)
+    def test_execute_uses_shared_auth_context(self, eks_hook_mock, 
pod_exec_execute_mock):
+        eks_hook = self.configure_eks_auth(eks_hook_mock)
+
+        def execute_with_generated_config(operator, context):
+            assert operator.config_file == "/tmp/eks-kubeconfig"
+            
eks_hook.generate_config_file_with_credentials.return_value.__exit__.assert_not_called()
+            return "command output"
+
+        pod_exec_execute_mock.side_effect = execute_with_generated_config
+        operator = EksPodExecOperator(
+            task_id="run_command",
+            cluster_name=CLUSTER_NAME,
+            pod_name="existing-pod",
+            namespace="workloads",
+            container_name="worker",
+            command=["dbt", "run"],
+            aws_conn_id="aws_test",
+            region_name="us-east-2",
+            verify=False,
+            botocore_config={"retries": {"max_attempts": 5}},
+            do_xcom_push=True,
+            max_xcom_output_size=1024,
+        )
+
+        result = operator.execute({})
+
+        assert result == "command output"
+        eks_hook_mock.assert_called_once_with(
+            aws_conn_id="aws_test",
+            region_name="us-east-2",
+            verify=False,
+            config={"retries": {"max_attempts": 5}},
+        )
+        eks_hook.generate_config_file_with_credentials.assert_called_once_with(
+            eks_cluster_name=CLUSTER_NAME,
+            pod_namespace="workloads",
+        )
+        
eks_hook.generate_config_file_with_credentials.return_value.__exit__.assert_called_once_with(
+            None, None, None
+        )
+        pod_exec_execute_mock.assert_called_once_with(operator, {})
+        assert operator.config_file is None
+
+    @mock.patch(
+        
"airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesPodExecOperator.execute",
+        autospec=True,
+    )
+    @mock.patch("airflow.providers.amazon.aws.operators.eks.EksHook", 
autospec=True)
+    def test_execute_clears_config_file_on_failure(self, eks_hook_mock, 
pod_exec_execute_mock):
+        eks_hook = self.configure_eks_auth(eks_hook_mock)
+        error = RuntimeError("command failed")
+        pod_exec_execute_mock.side_effect = error
+        operator = EksPodExecOperator(
+            task_id="run_command",
+            cluster_name=CLUSTER_NAME,
+            pod_name="existing-pod",
+            command=["false"],
+        )
+
+        with pytest.raises(RuntimeError, match="command failed") as raised:
+            operator.execute({})
+
+        assert raised.value is error
+        
eks_hook.generate_config_file_with_credentials.return_value.__exit__.assert_called_once_with(
+            RuntimeError, error, mock.ANY
+        )
+        assert operator.config_file is None
+
+    @pytest.mark.parametrize("first_execution_fails", [False, True])
+    
@mock.patch("airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesHook",
 autospec=True)
+    @mock.patch(
+        
"airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesPodExecOperator.execute",
+        autospec=True,
+    )
+    @mock.patch("airflow.providers.amazon.aws.operators.eks.EksHook", 
autospec=True)
+    def test_execute_recreates_client_with_new_config(
+        self, eks_hook_mock, pod_exec_execute_mock, kubernetes_hook_mock, 
first_execution_fails
+    ):
+        eks_hook = self.configure_eks_auth(eks_hook_mock)
+        
eks_hook.generate_config_file_with_credentials.return_value.__enter__.side_effect
 = [
+            ("/tmp/first-kubeconfig", "/tmp/first-credentials"),
+            ("/tmp/second-kubeconfig", "/tmp/second-credentials"),
+        ]
+        clients = []
+
+        def execute_with_client(operator, context):
+            clients.append(operator.client)
+            if first_execution_fails and len(clients) == 1:
+                raise RuntimeError("command failed")
+            return "command output"
+
+        pod_exec_execute_mock.side_effect = execute_with_client
+        operator = EksPodExecOperator(
+            task_id="run_command",
+            cluster_name=CLUSTER_NAME,
+            pod_name="existing-pod",
+            command=["true"],
+        )
+
+        if first_execution_fails:
+            with pytest.raises(RuntimeError, match="command failed"):
+                operator.execute({})
+        else:
+            operator.execute({})
+        assert operator.execute({}) == "command output"
+
+        assert kubernetes_hook_mock.call_args_list == [
+            mock.call(
+                conn_id="kubernetes_default", in_cluster=False, 
config_file=config_file, cluster_context=None
+            )
+            for config_file in ("/tmp/first-kubeconfig", 
"/tmp/second-kubeconfig")
+        ]
+
+    @pytest.mark.parametrize("kubernetes_conn_id", ["eks_kubernetes", None])
+    def test_kubernetes_connection_can_be_configured(self, kubernetes_conn_id):
+        operator = EksPodExecOperator(
+            task_id="run_command",
+            cluster_name=CLUSTER_NAME,
+            pod_name="existing-pod",
+            command=["true"],
+            kubernetes_conn_id=kubernetes_conn_id,
+        )
+
+        assert operator.kubernetes_conn_id == kubernetes_conn_id
+        assert operator.hook.conn_id == (kubernetes_conn_id or 
"kubernetes_default")
+
+    @mock.patch(
+        
"airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesPodExecOperator.execute",
+        autospec=True,
+    )
+    @mock.patch("airflow.providers.amazon.aws.operators.eks.EksHook", 
autospec=True)
+    def test_execute_propagates_auth_context_failure(self, eks_hook_mock, 
pod_exec_execute_mock):
+        eks_hook = self.configure_eks_auth(eks_hook_mock)
+        error = RuntimeError("Unable to retrieve AWS credentials")
+        
eks_hook.generate_config_file_with_credentials.return_value.__enter__.side_effect
 = error
+        operator = EksPodExecOperator(
+            task_id="run_command",
+            cluster_name=CLUSTER_NAME,
+            pod_name="existing-pod",
+            command=["true"],
+        )
+
+        with pytest.raises(RuntimeError, match="Unable to retrieve AWS 
credentials") as raised:
+            operator.execute({})
+
+        assert raised.value is error
+        pod_exec_execute_mock.assert_not_called()
+
+    def test_template_fields(self):
+        operator = EksPodExecOperator(
+            task_id="run_command",
+            cluster_name=CLUSTER_NAME,
+            pod_name="existing-pod",
+            command=["dbt", "run"],
+        )
+
+        validate_template_fields(operator)
+        assert "cluster_name" in operator.template_fields
+        assert "pod_name" in operator.template_fields
+        assert "command" in operator.template_fields
+
+        assert "cluster_context" not in operator.template_fields
+        assert "config_file" not in operator.template_fields
+        assert "kubernetes_conn_id" in operator.template_fields

Reply via email to