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 b19bc48f96f Let the EKS executor run per team and next to 
KubernetesExecutor (#75449)
b19bc48f96f is described below

commit b19bc48f96f0ae81b5a1b424be5b6efc8dacf55a
Author: Sean Ghaeli <[email protected]>
AuthorDate: Mon Oct 26 14:12:02 2026 -0700

    Let the EKS executor run per team and next to KubernetesExecutor (#75449)
    
    The executor installed its client through the [kubernetes_executor]
    client_factory setting, so a KubernetesExecutor in the same scope sent its
    pods to the EKS cluster too, and it had to refuse that setup. It also
    read [aws_eks_executor] only from the global section, so every team ran
    on the same cluster, and it refused team values rather than ignore them.
---
 providers/amazon/docs/changelog.rst                |   6 +
 providers/amazon/docs/executors/eks-executor.rst   |  52 ++--
 providers/amazon/pyproject.toml                    |   2 +-
 .../amazon/aws/executors/eks/_client_factory.py    |  30 +-
 .../amazon/aws/executors/eks/eks_executor.py       | 106 +------
 .../providers/amazon/aws/executors/eks/utils.py    |   5 +
 .../src/airflow/providers/amazon/version_compat.py |   2 -
 .../aws/executors/eks/test__client_factory.py      |  29 ++
 .../amazon/aws/executors/eks/test_eks_executor.py  | 330 +++++++++++++--------
 9 files changed, 308 insertions(+), 254 deletions(-)

diff --git a/providers/amazon/docs/changelog.rst 
b/providers/amazon/docs/changelog.rst
index b0999532227..0b4304c6cc1 100644
--- a/providers/amazon/docs/changelog.rst
+++ b/providers/amazon/docs/changelog.rst
@@ -26,6 +26,12 @@
 Changelog
 ---------
 
+.. note::
+
+    In multi-team mode, a team's ``AwsEksExecutor`` now reads 
``[aws_eks_executor]`` from the team's
+    section and no longer falls back to the global one. Set ``cluster_name`` 
for each team, and
+    ``region_name`` and ``conn_id`` too if the team needs them.
+
 9.39.0
 ......
 
diff --git a/providers/amazon/docs/executors/eks-executor.rst 
b/providers/amazon/docs/executors/eks-executor.rst
index 12ca2a30ccb..d996f9e38a1 100644
--- a/providers/amazon/docs/executors/eks-executor.rst
+++ b/providers/amazon/docs/executors/eks-executor.rst
@@ -38,17 +38,14 @@ For a quick start guide please see :ref:`here 
<eks_setup_guide>`.
 Requirements
 ------------
 
-The executor needs ``apache-airflow-providers-cncf-kubernetes`` version 
10.24.0 or
-newer, the release that added the
-:ref:`apache-airflow-providers-cncf-kubernetes:config:kubernetes_executor__client_factory`
-setting the executor is built on. The executor checks this when it is imported 
and raises a
-clear error if an older version is installed. Install the ``cncf.kubernetes`` 
extra of the
-Amazon provider together with that version:
+The executor needs a version of ``apache-airflow-providers-cncf-kubernetes`` 
that lets an
+executor built on the Kubernetes executor bring its own Kubernetes client. The 
executor checks
+this when it is imported and raises a clear error if an older version is 
installed. Install the
+``cncf.kubernetes`` extra of the Amazon provider, which requires that version:
 
 .. code-block:: bash
 
-    pip install 'apache-airflow-providers-amazon[cncf.kubernetes]' \
-        'apache-airflow-providers-cncf-kubernetes>=10.24.0'
+    pip install 'apache-airflow-providers-amazon[cncf.kubernetes]'
 
 The executor also relies on botocore using the regional STS endpoint by 
default (botocore 1.40
 and newer), and the Amazon provider already requires ``botocore>=1.42.79``, so 
no extra install
@@ -121,10 +118,10 @@ See 
:doc:`apache-airflow-providers-cncf-kubernetes:kubernetes_executor` for the
 for how :ref:`pod templates 
<apache-airflow-providers-cncf-kubernetes:concepts:pod_template_file>` and
 :ref:`apache-airflow-providers-cncf-kubernetes:concepts:pod_override` work.
 
-Leave 
:ref:`apache-airflow-providers-cncf-kubernetes:config:kubernetes_executor__client_factory`
 and
-:ref:`apache-airflow-providers-cncf-kubernetes:config:kubernetes_executor__async_client_factory`
-unset. The executor sets both itself, and raises an error at startup if either 
is set to
-something else, so that a conflicting setting cannot silently send tasks to 
the wrong cluster.
+The executor builds its own Kubernetes clients and ignores
+:ref:`apache-airflow-providers-cncf-kubernetes:config:kubernetes_executor__client_factory`
 and
+:ref:`apache-airflow-providers-cncf-kubernetes:config:kubernetes_executor__async_client_factory`,
+which only apply to the Kubernetes executor.
 
 Set :ref:`[kubernetes_executor] pod_template_file
 
<apache-airflow-providers-cncf-kubernetes:config:kubernetes_executor__pod_template_file>`
@@ -203,14 +200,29 @@ Multi-team deployments
 ----------------------
 
 The executor supports :doc:`multi-team 
<apache-airflow:core-concepts/multi-team>` mode
-the same way the Kubernetes executor does. Teams share the cluster set in the 
global
-:ref:`[aws_eks_executor] <eks_config_options>` section, and each team gets its 
own
-team-scoped 
:ref:`apache-airflow-providers-cncf-kubernetes:config:kubernetes_executor` 
settings,
-such as the namespace and pod template.
-
-The executor refuses to start next to a Kubernetes executor in the same team, 
or when both are
-global, because that Kubernetes executor would also send its pods to the EKS 
cluster; configure
-them for different teams instead (see `#74844 
<https://github.com/apache/airflow/issues/74844>`__).
+the same way the other AWS executors do. Each team reads its own team-scoped
+:ref:`[aws_eks_executor] <eks_config_options>` and
+:ref:`apache-airflow-providers-cncf-kubernetes:config:kubernetes_executor` 
settings, so a
+team can use its own cluster, connection, namespace and pod template. 
Team-scoped settings
+don't fall back to the global ones, so set ``cluster_name`` for every team 
that uses the
+executor.
+
+.. code-block:: ini
+
+    [core]
+    multi_team = True
+    executor = 
LocalExecutor;team_a=airflow.providers.amazon.aws.executors.eks.eks_executor.AwsEksExecutor;team_b=airflow.providers.amazon.aws.executors.eks.eks_executor.AwsEksExecutor
+
+    [team_a=aws_eks_executor]
+    cluster_name = team-a-cluster
+
+    [team_b=aws_eks_executor]
+    cluster_name = team-b-cluster
+    conn_id = aws_team_b
+
+The executor can also run alongside the Kubernetes executor, in the same team 
or not, and each
+keeps its own cluster. Keep them on different clusters, though: two executors 
watching the same
+namespace of one cluster each act on the other's worker pods.
 
 Fault tolerance
 ---------------
diff --git a/providers/amazon/pyproject.toml b/providers/amazon/pyproject.toml
index d0149c29d47..9ecac3334fa 100644
--- a/providers/amazon/pyproject.toml
+++ b/providers/amazon/pyproject.toml
@@ -91,7 +91,7 @@ dependencies = [
     "aiobotocore>=3.0.0",
 ]
 "cncf.kubernetes" = [
-    "apache-airflow-providers-cncf-kubernetes>=7.2.0",
+    "apache-airflow-providers-cncf-kubernetes>=7.2.0",  # use next version
 ]
 "s3fs" = [
     "s3fs>=2023.10.0",
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/_client_factory.py
 
b/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/_client_factory.py
index 4b0dfc2f001..eb0acccf8b8 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/_client_factory.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/_client_factory.py
@@ -16,8 +16,8 @@
 # under the License.
 """Kubernetes client factories for the AwsEksExecutor."""
 
-# Internal to AwsEksExecutor, which writes these functions' import paths into
-# [kubernetes_executor] client_factory and async_client_factory.
+# Internal to AwsEksExecutor, which declares these functions' import paths as 
its client_factory
+# and async_client_factory.
 
 from __future__ import annotations
 
@@ -37,6 +37,7 @@ from airflow.providers.amazon.aws.executors.eks.utils import (
     CONFIG_DEFAULTS,
     CONFIG_GROUP_NAME,
     AllEksConfigKeys,
+    get_cluster_name_env_var,
 )
 from airflow.providers.amazon.aws.hooks.eks import EksHook
 from airflow.providers.amazon.aws.hooks.sts import StsHook
@@ -62,39 +63,46 @@ def _get_sync_token_key(kubernetes_version: str) -> str:
 _SYNC_TOKEN_KEY = _get_sync_token_key(kubernetes.__version__)
 
 
-def _get_eks_kube_client() -> client.CoreV1Api:
-    """Build a Kubernetes client for the configured EKS cluster, in memory and 
without a kubeconfig."""
+def _get_eks_kube_client(team_name: str | None = None) -> client.CoreV1Api:
+    """Build a Kubernetes client for the team's EKS cluster, in memory and 
without a kubeconfig."""
     from kubernetes import client
 
     configuration = client.Configuration()
-    _configure_eks_auth(configuration, _SYNC_TOKEN_KEY)
+    _configure_eks_auth(configuration, _SYNC_TOKEN_KEY, team_name)
     return client.CoreV1Api(client.ApiClient(configuration=configuration))
 
 
-def _get_eks_async_kube_client() -> async_client.CoreV1Api:
+def _get_eks_async_kube_client(team_name: str | None = None) -> 
async_client.CoreV1Api:
     """Build the asynchronous Kubernetes client used when 
``async_pod_creation`` is enabled."""
     from kubernetes_asyncio import client as async_client
 
     configuration = async_client.Configuration()
     # kubernetes_asyncio has always used "BearerToken".
-    _configure_eks_auth(configuration, "BearerToken")
+    _configure_eks_auth(configuration, "BearerToken", team_name)
     return 
async_client.CoreV1Api(async_client.ApiClient(configuration=configuration))
 
 
 def _configure_eks_auth(
-    configuration: client.Configuration | async_client.Configuration, 
token_key: str
+    configuration: client.Configuration | async_client.Configuration,
+    token_key: str,
+    team_name: str | None,
 ) -> None:
-    cluster_name = conf.get(CONFIG_GROUP_NAME, AllEksConfigKeys.CLUSTER_NAME, 
fallback=None)
+    # The factories run where there is no executor, such as the pod watcher 
process, so they look
+    # up the team's config themselves, the same way ExecutorConf does. Airflow 
releases without
+    # multi-team don't accept team_name.
+    team_kwargs = {"team_name": team_name} if team_name else {}
+    cluster_name = conf.get(CONFIG_GROUP_NAME, AllEksConfigKeys.CLUSTER_NAME, 
fallback=None, **team_kwargs)
     if not cluster_name:
         raise ValueError(
             f"[{CONFIG_GROUP_NAME}] cluster_name is required to build an EKS 
client. "
-            "Set it in airflow.cfg or with 
AIRFLOW__AWS_EKS_EXECUTOR__CLUSTER_NAME."
+            f"Set it in airflow.cfg or with 
{get_cluster_name_env_var(team_name)}."
         )
-    region_name = conf.get(CONFIG_GROUP_NAME, AllEksConfigKeys.REGION_NAME, 
fallback=None)
+    region_name = conf.get(CONFIG_GROUP_NAME, AllEksConfigKeys.REGION_NAME, 
fallback=None, **team_kwargs)
     conn_id = conf.get(
         CONFIG_GROUP_NAME,
         AllEksConfigKeys.AWS_CONN_ID,
         fallback=CONFIG_DEFAULTS[AllEksConfigKeys.AWS_CONN_ID],
+        **team_kwargs,
     )
 
     eks_hook = EksHook(aws_conn_id=conn_id, region_name=region_name)
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/eks_executor.py
 
b/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/eks_executor.py
index 083b0bb9d8d..281fe405f14 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/eks_executor.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/eks_executor.py
@@ -18,41 +18,34 @@
 
 from __future__ import annotations
 
-import os
 from typing import TYPE_CHECKING
 
-from airflow.executors.executor_loader import ExecutorLoader
 from airflow.providers.amazon.aws.executors.eks.utils import (
     CONFIG_DEFAULTS,
     CONFIG_GROUP_NAME,
     AllEksConfigKeys,
+    get_cluster_name_env_var,
 )
-from airflow.providers.amazon.version_compat import AIRFLOW_V_3_2_PLUS
-from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException, conf
+from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
 
-_CNCF_KUBERNETES_REQUIRED = "AwsEksExecutor requires 
apache-airflow-providers-cncf-kubernetes>=10.24.0"
+_CNCF_KUBERNETES_REQUIRED = (
+    "AwsEksExecutor requires the apache-airflow-providers-cncf-kubernetes 
version that the "
+    "Amazon provider's cncf.kubernetes extra installs: "
+    "pip install 'apache-airflow-providers-amazon[cncf.kubernetes]'"
+)
 
 try:
     from kubernetes.client.rest import ApiException
 
     from airflow.providers.cncf.kubernetes.executors.kubernetes_executor 
import KubernetesExecutor
-    from airflow.providers.cncf.kubernetes.get_provider_info import (
-        get_provider_info as get_cncf_kubernetes_provider_info,
-    )
 except ImportError as e:
     raise AirflowOptionalProviderFeatureException(_CNCF_KUBERNETES_REQUIRED) 
from e
 
-# Check for the option, not the version, since unreleased trees report the 
previous version.
-if "client_factory" not in (
-    get_cncf_kubernetes_provider_info().get("config", 
{}).get("kubernetes_executor", {}).get("options", {})
-):
+# Check for the feature, not the version, since unreleased trees report the 
previous version.
+if not hasattr(KubernetesExecutor, "client_factory"):
     raise AirflowOptionalProviderFeatureException(_CNCF_KUBERNETES_REQUIRED)
 
 _FACTORY_MODULE = "airflow.providers.amazon.aws.executors.eks._client_factory"
-_CLIENT_FACTORY_PATH = f"{_FACTORY_MODULE}._get_eks_kube_client"
-_ASYNC_CLIENT_FACTORY_PATH = f"{_FACTORY_MODULE}._get_eks_async_kube_client"
-# Airflow 2 hybrid executors that wrap a KubernetesExecutor.
-_HYBRID_KUBERNETES_EXECUTORS = ("CeleryKubernetesExecutor", 
"LocalKubernetesExecutor")
 
 
 class AwsEksExecutor(KubernetesExecutor):
@@ -65,19 +58,18 @@ class AwsEksExecutor(KubernetesExecutor):
     """
 
     supports_multi_team: bool = True
+    client_factory = f"{_FACTORY_MODULE}._get_eks_kube_client"
+    async_client_factory = f"{_FACTORY_MODULE}._get_eks_async_kube_client"
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        # These need team_name, which super().__init__ sets.
-        self._check_no_team_eks_config()
+        # Needs team_name, which super().__init__ sets.
         self._validate_eks_config()
-        self._check_no_kubernetes_executor_in_scope()
-        self._ensure_client_factory()
 
     def start(self) -> None:
         """Call this when the Executor is run for the first time by the 
scheduler."""
         super().start()
-        check_health = conf.getboolean(
+        check_health = self.conf.getboolean(
             CONFIG_GROUP_NAME,
             AllEksConfigKeys.CHECK_HEALTH_ON_STARTUP,
             fallback=CONFIG_DEFAULTS[AllEksConfigKeys.CHECK_HEALTH_ON_STARTUP],
@@ -115,75 +107,9 @@ class AwsEksExecutor(KubernetesExecutor):
             ) from e
         self.log.info("EKS Executor health check has succeeded.")
 
-    @staticmethod
-    def _validate_eks_config() -> None:
-        if not conf.get(CONFIG_GROUP_NAME, AllEksConfigKeys.CLUSTER_NAME, 
fallback=None):
+    def _validate_eks_config(self) -> None:
+        if not self.conf.get(CONFIG_GROUP_NAME, AllEksConfigKeys.CLUSTER_NAME, 
fallback=None):
             raise ValueError(
                 f"AwsEksExecutor requires [{CONFIG_GROUP_NAME}] cluster_name 
to be set. "
-                "Set it in airflow.cfg or with 
AIRFLOW__AWS_EKS_EXECUTOR__CLUSTER_NAME."
-            )
-
-    def _check_no_team_eks_config(self) -> None:
-        # [aws_eks_executor] is only read globally for now, so refuse team 
values rather than ignore them.
-        if not self.team_name:
-            return
-        # self.conf looks options up for this team only, falling back to the 
built-in defaults.
-        team_keys = sorted(
-            key
-            for key in AllEksConfigKeys()
-            if self.conf.get(CONFIG_GROUP_NAME, key, fallback=None) not in 
(None, CONFIG_DEFAULTS.get(key))
-        )
-        if team_keys:
-            raise ValueError(
-                f"AwsEksExecutor doesn't support team-specific 
[{CONFIG_GROUP_NAME}] config yet, but team "
-                f"{self.team_name} sets {', '.join(team_keys)}. Set these in 
the global "
-                f"[{CONFIG_GROUP_NAME}] section instead. Per-team clusters are 
tracked at "
-                "https://github.com/apache/airflow/issues/74844";
+                f"Set it in airflow.cfg or with 
{get_cluster_name_env_var(self.team_name)}."
             )
-
-    def _check_no_kubernetes_executor_in_scope(self) -> None:
-        executor_names = (
-            ExecutorLoader.get_executor_names(validate_teams=False)
-            if AIRFLOW_V_3_2_PLUS
-            else ExecutorLoader.get_executor_names()
-        )
-        for executor_name in executor_names:
-            # ExecutorName has no team_name before Airflow 3.1.
-            if getattr(executor_name, "team_name", None) != self.team_name:
-                continue
-            executor_cls, _ = ExecutorLoader.import_executor_cls(executor_name)
-            if issubclass(executor_cls, AwsEksExecutor) or not (
-                issubclass(executor_cls, KubernetesExecutor)
-                or executor_cls.__name__ in _HYBRID_KUBERNETES_EXECUTORS
-            ):
-                continue
-            scope = f"team {self.team_name}" if self.team_name else "the 
global scope"
-            raise ValueError(
-                f"AwsEksExecutor can't run alongside {executor_cls.__name__} 
in {scope}. AwsEksExecutor "
-                f"sets the [kubernetes_executor] client factory for {scope}, 
so {executor_cls.__name__} "
-                "would silently send its pods to the EKS cluster. Configure 
the two executors for "
-                "different teams, or don't use them together. "
-                "See https://github.com/apache/airflow/issues/74844";
-            )
-
-    def _ensure_client_factory(self) -> None:
-        # A team's factory is looked up in team config only, with no global 
fallback.
-        team_name = self.team_name
-        team_kwargs = {"team_name": team_name} if team_name else {}
-        env_var_prefix = f"AIRFLOW__{team_name.upper()}___" if team_name else 
"AIRFLOW__"
-        for key, path in (
-            ("client_factory", _CLIENT_FACTORY_PATH),
-            ("async_client_factory", _ASYNC_CLIENT_FACTORY_PATH),
-        ):
-            configured = conf.get("kubernetes_executor", key, fallback=None, 
**team_kwargs)
-            if configured and configured != path:
-                raise ValueError(
-                    f"AwsEksExecutor sets [kubernetes_executor] {key} itself, 
so leave it unset; "
-                    f"it is set to {configured}"
-                )
-            if not configured:
-                # An env var, not conf.set, so the spawned pod watcher process 
sees it too. A plain
-                # KubernetesExecutor in the same scope reads it as well, which
-                # _check_no_kubernetes_executor_in_scope refuses; replacing 
this env var is tracked at
-                # https://github.com/apache/airflow/issues/74844
-                
os.environ[f"{env_var_prefix}KUBERNETES_EXECUTOR__{key.upper()}"] = path
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/utils.py 
b/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/utils.py
index 52e0064f9d8..d97b8162b38 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/utils.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/executors/eks/utils.py
@@ -35,3 +35,8 @@ class AllEksConfigKeys(BaseConfigKeys):
     CHECK_HEALTH_ON_STARTUP = "check_health_on_startup"
     CLUSTER_NAME = "cluster_name"
     REGION_NAME = "region_name"
+
+
+def get_cluster_name_env_var(team_name: str | None) -> str:
+    team_prefix = f"{team_name.upper()}___" if team_name else ""
+    return 
f"AIRFLOW__{team_prefix}{CONFIG_GROUP_NAME.upper()}__{AllEksConfigKeys.CLUSTER_NAME.upper()}"
diff --git a/providers/amazon/src/airflow/providers/amazon/version_compat.py 
b/providers/amazon/src/airflow/providers/amazon/version_compat.py
index 92e9bc41c2c..fa6faf2ba41 100644
--- a/providers/amazon/src/airflow/providers/amazon/version_compat.py
+++ b/providers/amazon/src/airflow/providers/amazon/version_compat.py
@@ -40,7 +40,6 @@ AIRFLOW_V_3_0_PLUS = get_base_airflow_version_tuple() >= (3, 
0, 0)
 AIRFLOW_V_3_1_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 0)
 AIRFLOW_V_3_1_1_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 1)
 AIRFLOW_V_3_1_8_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 8)
-AIRFLOW_V_3_2_PLUS: bool = get_base_airflow_version_tuple() >= (3, 2, 0)
 AIRFLOW_V_3_3_PLUS: bool = get_base_airflow_version_tuple() >= (3, 3, 0)
 AIRFLOW_V_3_4_PLUS: bool = get_base_airflow_version_tuple() >= (3, 4, 0)
 
@@ -61,7 +60,6 @@ __all__ = [
     "AIRFLOW_V_3_1_PLUS",
     "AIRFLOW_V_3_1_1_PLUS",
     "AIRFLOW_V_3_1_8_PLUS",
-    "AIRFLOW_V_3_2_PLUS",
     "AIRFLOW_V_3_3_PLUS",
     "AIRFLOW_V_3_4_PLUS",
     "NOTSET",
diff --git 
a/providers/amazon/tests/unit/amazon/aws/executors/eks/test__client_factory.py 
b/providers/amazon/tests/unit/amazon/aws/executors/eks/test__client_factory.py
index 45306e27316..f3b10ad6bac 100644
--- 
a/providers/amazon/tests/unit/amazon/aws/executors/eks/test__client_factory.py
+++ 
b/providers/amazon/tests/unit/amazon/aws/executors/eks/test__client_factory.py
@@ -43,6 +43,7 @@ from airflow.providers.amazon.aws.utils.eks_get_token import (
 )
 
 from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_2_PLUS
 
 CLUSTER_NAME = "test-eks-cluster"
 CLUSTER_ENDPOINT = "https://ABCDEF0123456789.gr7.us-east-1.eks.amazonaws.com";
@@ -194,6 +195,34 @@ class TestGetEksKubeClient:
         ):
             _get_eks_kube_client()
 
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="Multi-team requires 
Airflow 3.2+")
+    def test_team_without_cluster_name_raises(self, mock_aws):
+        """The global cluster_name set by mock_aws is not a fallback for the 
team."""
+        with pytest.raises(
+            ValueError,
+            match=r"cluster_name is 
required.*AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__CLUSTER_NAME",
+        ):
+            _get_eks_kube_client(team_name="team_a")
+
+
[email protected](not AIRFLOW_V_3_2_PLUS, reason="Multi-team requires 
Airflow 3.2+")
[email protected]("factory", [_get_eks_kube_client, 
_get_eks_async_kube_client], ids=["sync", "async"])
[email protected]
+# kubernetes_asyncio loads the CA eagerly and the fake PEM is not loadable.
[email protected](_client_factory, "_write_cluster_ca_file", 
return_value=None)
+async def test_builds_client_for_the_teams_cluster(_, mock_aws, monkeypatch, 
factory):
+    monkeypatch.setenv("AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__CLUSTER_NAME", 
"team-a-cluster")
+    monkeypatch.setenv("AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__REGION_NAME", 
"eu-west-1")
+    monkeypatch.setenv("AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__CONN_ID", 
"aws_team_a")
+
+    core_v1 = factory(team_name="team_a")
+
+    mock_aws["eks_hook_cls"].assert_called_once_with(aws_conn_id="aws_team_a", 
region_name="eu-west-1")
+    
mock_aws["eks_hook"].conn.describe_cluster.assert_called_once_with(name="team-a-cluster")
+    assert mock_aws["fetch_token"].call_args.args[0] == "team-a-cluster"
+    if inspect.iscoroutinefunction(core_v1.api_client.close):
+        await core_v1.api_client.close()
+
 
 @pytest.mark.asyncio
 # kubernetes_asyncio loads the CA eagerly and the fake PEM is not loadable.
diff --git 
a/providers/amazon/tests/unit/amazon/aws/executors/eks/test_eks_executor.py 
b/providers/amazon/tests/unit/amazon/aws/executors/eks/test_eks_executor.py
index 096dce5c64b..db78f1bab42 100644
--- a/providers/amazon/tests/unit/amazon/aws/executors/eks/test_eks_executor.py
+++ b/providers/amazon/tests/unit/amazon/aws/executors/eks/test_eks_executor.py
@@ -16,10 +16,11 @@
 # under the License.
 from __future__ import annotations
 
+import argparse
 import importlib
-import os
 import sys
-from contextlib import nullcontext
+from base64 import b64encode
+from datetime import UTC, datetime
 from unittest import mock
 
 import pytest
@@ -27,22 +28,17 @@ from kubernetes.client import CoreV1Api
 from kubernetes.client.rest import ApiException
 
 from airflow.providers.amazon.aws.executors.eks import AwsEksExecutor, 
_client_factory
-from airflow.providers.amazon.aws.executors.eks.eks_executor import (
-    _ASYNC_CLIENT_FACTORY_PATH,
-    _CLIENT_FACTORY_PATH,
-)
+from airflow.providers.cncf.kubernetes.cli import kubernetes_command
 from airflow.providers.cncf.kubernetes.executors.kubernetes_executor import 
KubernetesExecutor
-from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException, conf
+from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
 
 from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_2_PLUS
 
 EKS_EXECUTOR_MODULE = "airflow.providers.amazon.aws.executors.eks.eks_executor"
-EKS_EXECUTOR_PATH = f"{EKS_EXECUTOR_MODULE}.AwsEksExecutor"
-LOCAL_KUBERNETES_EXECUTOR_PATH = (
-    
"airflow.providers.cncf.kubernetes.executors.local_kubernetes_executor.LocalKubernetesExecutor"
-)
-CLIENT_FACTORY_ENV_VAR = "AIRFLOW__KUBERNETES_EXECUTOR__CLIENT_FACTORY"
+CLUSTER_ENDPOINT = "https://ABCDEF0123456789.gr7.us-east-1.eks.amazonaws.com";
+JOB_WATCHER = 
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher"
+USER_CLIENT = "client-from-user-factory"
 REQUIRES_MULTI_TEAM = pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, 
reason="Multi-team requires Airflow 3.2+")
 TEAMS = [
     pytest.param(None, "AIRFLOW__", id="global"),
@@ -59,132 +55,166 @@ def _team_kwargs(team_name: str | None) -> dict:
     return {"team_name": team_name} if team_name else {}
 
 
+def build_user_client():
+    return USER_CLIENT
+
+
 @pytest.fixture(autouse=True)
 def eks_config():
-    with mock.patch.dict(os.environ), conf_vars({("aws_eks_executor", 
"cluster_name"): "test-eks-cluster"}):
-        for prefix in ("AIRFLOW__", "AIRFLOW__TEAM_A___"):
-            for key in ("CLIENT_FACTORY", "ASYNC_CLIENT_FACTORY"):
-                os.environ.pop(f"{prefix}KUBERNETES_EXECUTOR__{key}", None)
+    with conf_vars({("aws_eks_executor", "cluster_name"): "test-eks-cluster"}):
         yield
 
 
-class TestAwsEksExecutor:
-    @conf_vars({("aws_eks_executor", "cluster_name"): None})
-    def test_missing_cluster_name_raises(self):
-        with pytest.raises(
-            ValueError, match=r"\[aws_eks_executor\] 
cluster_name.*AIRFLOW__AWS_EKS_EXECUTOR__CLUSTER_NAME"
-        ):
-            AwsEksExecutor()
-
-    @pytest.mark.parametrize(("team_name", "env_prefix"), TEAMS)
-    def test_sets_client_factories_when_unset(self, team_name, env_prefix):
-        AwsEksExecutor(**_team_kwargs(team_name))
-
-        assert {k: v for k, v in os.environ.items() if 
k.endswith("CLIENT_FACTORY")} == {
-            f"{env_prefix}KUBERNETES_EXECUTOR__CLIENT_FACTORY": 
_CLIENT_FACTORY_PATH,
-            f"{env_prefix}KUBERNETES_EXECUTOR__ASYNC_CLIENT_FACTORY": 
_ASYNC_CLIENT_FACTORY_PATH,
[email protected]
+def mock_aws():
+    with (
+        mock.patch.object(_client_factory, "EksHook") as eks_hook_cls,
+        mock.patch.object(_client_factory, "StsHook") as sts_hook_cls,
+        mock.patch.object(_client_factory, "fetch_access_token_for_cluster", 
autospec=True) as fetch_token,
+    ):
+        eks_hook = eks_hook_cls.return_value
+        eks_hook.conn.describe_cluster.return_value = {
+            "cluster": {
+                "status": "ACTIVE",
+                "endpoint": CLUSTER_ENDPOINT,
+                "certificateAuthority": {"data": 
b64encode(b"fake-ca").decode()},
+            }
         }
-        team_kwargs = _team_kwargs(team_name)
-        assert (
-            conf.getimport("kubernetes_executor", "client_factory", 
**team_kwargs)
-            is _client_factory._get_eks_kube_client
-        )
-        assert (
-            conf.getimport("kubernetes_executor", "async_client_factory", 
**team_kwargs)
-            is _client_factory._get_eks_async_kube_client
-        )
+        eks_hook.get_session.return_value.region_name = "us-east-1"
+        sts_hook_cls.return_value.conn_client_meta.endpoint_url = 
"https://sts.us-east-1.amazonaws.com";
+        fetch_token.return_value = "k8s-aws-v1.token"
+        yield {"eks_hook_cls": eks_hook_cls, "eks_hook": eks_hook, 
"fetch_token": fetch_token}
 
-    @conf_vars({("kubernetes_executor", "client_factory"): 
_CLIENT_FACTORY_PATH})
-    def test_accepts_already_matching_client_factory(self):
-        AwsEksExecutor()
 
-        assert CLIENT_FACTORY_ENV_VAR not in os.environ
+def _start_and_end(executor: KubernetesExecutor) -> None:
+    executor.job_id = 1
+    try:
+        executor.start()
+    finally:
+        executor.end()
 
-    @pytest.mark.parametrize("key", ["client_factory", "async_client_factory"])
-    @pytest.mark.parametrize(("team_name", "env_prefix"), TEAMS)
-    def test_conflicting_client_factory_raises(self, monkeypatch, team_name, 
env_prefix, key):
-        monkeypatch.setenv(f"{env_prefix}KUBERNETES_EXECUTOR__{key.upper()}", 
"my_company.build_client")
 
-        with pytest.raises(ValueError, match=rf"{key} 
itself.*my_company.build_client"):
-            AwsEksExecutor(**_team_kwargs(team_name))
+class TestAwsEksExecutor:
+    @pytest.mark.parametrize(("team_name", "env_prefix"), TEAMS)
+    @conf_vars({("aws_eks_executor", "cluster_name"): None})
+    def test_missing_cluster_name_raises(self, monkeypatch, team_name, 
env_prefix):
+        if team_name:
+            # Team config doesn't fall back to the global section.
+            monkeypatch.setenv("AIRFLOW__AWS_EKS_EXECUTOR__CLUSTER_NAME", 
"global-cluster")
 
-    @REQUIRES_MULTI_TEAM
-    @pytest.mark.parametrize(
-        ("env", "file_config", "key"),
-        [
-            pytest.param(
-                {"AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__CLUSTER_NAME": 
"team-cluster"},
-                {("aws_eks_executor", "cluster_name"): None},
-                "cluster_name",
-                id="env-var-without-global-cluster",
-            ),
-            pytest.param(
-                {"AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__CONN_ID": "team_aws"},
-                {},
-                "conn_id",
-                id="env-var-for-option-with-default",
-            ),
-            pytest.param(
-                {},
-                {("team_a=aws_eks_executor", "region_name"): "eu-west-1"},
-                "region_name",
-                id="config-file-section",
-            ),
-        ],
-    )
-    def test_team_eks_config_raises(self, monkeypatch, env, file_config, key):
-        for name, value in env.items():
-            monkeypatch.setenv(name, value)
-
-        with (
-            conf_vars(file_config),
-            pytest.raises(
-                ValueError, match=rf"team-specific \[aws_eks_executor\].*team 
team_a sets {key}\..*74844"
-            ),
+        with pytest.raises(
+            ValueError,
+            match=rf"\[aws_eks_executor\] 
cluster_name.*{env_prefix}AWS_EKS_EXECUTOR__CLUSTER_NAME",
         ):
-            AwsEksExecutor(team_name="team_a")
+            AwsEksExecutor(**_team_kwargs(team_name))
 
     @pytest.mark.parametrize(
-        ("executors", "team_name", "conflict"),
+        ("team_name", "cluster_name", "conn_id", "region_name", 
"check_health"),
         [
             pytest.param(
-                f"KubernetesExecutor,{EKS_EXECUTOR_PATH}",
-                None,
-                "KubernetesExecutor in the global scope",
-                id="both-global",
-            ),
-            pytest.param(
-                f"{LOCAL_KUBERNETES_EXECUTOR_PATH},{EKS_EXECUTOR_PATH}",
-                None,
-                "LocalKubernetesExecutor in the global scope",
-                id="hybrid-global",
-            ),
-            pytest.param(
-                f"LocalExecutor;team_a=KubernetesExecutor,{EKS_EXECUTOR_PATH}",
                 "team_a",
-                "KubernetesExecutor in team team_a",
-                id="both-same-team",
+                "team-a-cluster",
+                "aws_team_a",
+                "eu-west-1",
+                True,
+                id="team",
                 marks=REQUIRES_MULTI_TEAM,
             ),
-            pytest.param(
-                f"KubernetesExecutor;team_a={EKS_EXECUTOR_PATH}",
-                "team_a",
-                None,
-                id="global-kubernetes-team-eks",
-                marks=REQUIRES_MULTI_TEAM,
-            ),
-            pytest.param(EKS_EXECUTOR_PATH, None, None, id="eks-alone"),
+            pytest.param(None, "global-cluster", "aws_global", "us-east-1", 
False, id="global"),
         ],
     )
-    @pytest.mark.usefixtures("clean_executor_loader")
-    def test_refuses_kubernetes_executor_in_same_scope(self, executors, 
team_name, conflict):
-        expectation = (
-            pytest.raises(ValueError, match=rf"alongside 
{conflict}\..*issues/74844")
-            if conflict
-            else nullcontext()
+    # kubernetes_asyncio loads the CA eagerly and the fake one is not loadable.
+    @mock.patch.object(_client_factory, "_write_cluster_ca_file", 
return_value=None)
+    @mock.patch.object(AwsEksExecutor, "check_health", autospec=True)
+    @mock.patch(JOB_WATCHER, autospec=True)
+    def test_team_config(
+        self,
+        mock_watcher,
+        mock_check_health,
+        _,
+        mock_aws,
+        monkeypatch,
+        team_name,
+        cluster_name,
+        conn_id,
+        region_name,
+        check_health,
+    ):
+        """Each executor builds both its clients from its own 
[aws_eks_executor] values, and so does its watcher."""
+        for key, value in {
+            "AIRFLOW__KUBERNETES_EXECUTOR__ASYNC_POD_CREATION": "True",
+            "AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__ASYNC_POD_CREATION": 
"True",
+            "AIRFLOW__AWS_EKS_EXECUTOR__CLUSTER_NAME": "global-cluster",
+            "AIRFLOW__AWS_EKS_EXECUTOR__REGION_NAME": "us-east-1",
+            "AIRFLOW__AWS_EKS_EXECUTOR__CONN_ID": "aws_global",
+            "AIRFLOW__AWS_EKS_EXECUTOR__CHECK_HEALTH_ON_STARTUP": "False",
+            # The team leaves check_health_on_startup at its default rather 
than the global value.
+            "AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__CLUSTER_NAME": 
"team-a-cluster",
+            "AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__REGION_NAME": "eu-west-1",
+            "AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__CONN_ID": "aws_team_a",
+        }.items():
+            monkeypatch.setenv(key, value)
+        executor = AwsEksExecutor(**_team_kwargs(team_name))
+        executor.job_id = 1
+        executor.start()
+        try:
+            # Builds the async client, as the first batch of pods would.
+            executor.kube_scheduler._run_pods_async([])
+            async_client = executor.kube_scheduler._async_pod_client
+        finally:
+            executor.end()
+
+        assert executor.kube_client.api_client.configuration.host == 
CLUSTER_ENDPOINT
+        assert async_client.api_client.configuration.host == CLUSTER_ENDPOINT
+        assert (
+            mock_aws["eks_hook_cls"].call_args_list
+            == [mock.call(aws_conn_id=conn_id, region_name=region_name)] * 2
         )
-        with conf_vars({("core", "executor"): executors, ("core", 
"multi_team"): "True"}), expectation:
-            AwsEksExecutor(**_team_kwargs(team_name))
+        assert mock_aws["eks_hook"].conn.describe_cluster.call_args_list == 
[mock.call(name=cluster_name)] * 2
+        assert {call.args[0] for call in 
mock_aws["fetch_token"].call_args_list} == {cluster_name}
+        assert mock_check_health.call_count == int(check_health)
+        assert mock_watcher.call_args.kwargs["team_name"] == team_name
+        assert mock_watcher.call_args.kwargs["client_factory"] == 
AwsEksExecutor.client_factory
+
+    @pytest.mark.parametrize("user_factory", [False, True], 
ids=["default-client", "user-factory"])
+    @pytest.mark.parametrize(("team_name", "env_prefix"), TEAMS)
+    @mock.patch.object(AwsEksExecutor, "check_health", autospec=True)
+    @mock.patch(JOB_WATCHER, autospec=True)
+    
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client", 
autospec=True)
+    def test_runs_alongside_kubernetes_executor(
+        self,
+        mock_get_kube_client,
+        mock_watcher,
+        mock_check_health,
+        mock_aws,
+        monkeypatch,
+        team_name,
+        env_prefix,
+        user_factory,
+    ):
+        """A KubernetesExecutor in the same scope keeps its own client, 
including its own factory."""
+        monkeypatch.setenv(f"{env_prefix}AWS_EKS_EXECUTOR__CLUSTER_NAME", 
"test-eks-cluster")
+        if user_factory:
+            monkeypatch.setenv(
+                f"{env_prefix}KUBERNETES_EXECUTOR__CLIENT_FACTORY", 
f"{__name__}.build_user_client"
+            )
+        # The scheduler builds every executor before it starts any of them.
+        eks_executor = AwsEksExecutor(**_team_kwargs(team_name))
+        kubernetes_executor = KubernetesExecutor(**_team_kwargs(team_name))
+
+        _start_and_end(eks_executor)
+        _start_and_end(kubernetes_executor)
+
+        assert eks_executor.kube_client.api_client.configuration.host == 
CLUSTER_ENDPOINT
+        if user_factory:
+            assert kubernetes_executor.kube_client == USER_CLIENT
+        else:
+            assert kubernetes_executor.kube_client is 
mock_get_kube_client.return_value
+            mock_get_kube_client.assert_called_once_with(team_name=team_name)
+        assert [call.kwargs["client_factory"] for call in 
mock_watcher.call_args_list] == [
+            AwsEksExecutor.client_factory,
+            None,
+        ]
 
     @pytest.mark.parametrize("check_health", [True, False])
     @mock.patch.object(KubernetesExecutor, "start", autospec=True)
@@ -217,22 +247,62 @@ class TestCncfKubernetesRequirement:
             sys.modules, 
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor", None
         )
 
-        with pytest.raises(AirflowOptionalProviderFeatureException, 
match=">=10.24.0"):
+        with pytest.raises(
+            AirflowOptionalProviderFeatureException,
+            match=r"apache-airflow-providers-amazon\[cncf.kubernetes\]",
+        ):
             importlib.import_module(EKS_EXECUTOR_MODULE)
 
-    @pytest.mark.parametrize(
-        "provider_info",
-        [
-            pytest.param({"config": {"kubernetes_executor": {"options": {}}}}, 
id="no-client-factory"),
-            pytest.param({}, id="no-config"),
-        ],
-    )
-    
@mock.patch("airflow.providers.cncf.kubernetes.get_provider_info.get_provider_info",
 autospec=True)
-    def test_import_fails_with_cncf_kubernetes_without_client_factory(
-        self, mock_get_provider_info, monkeypatch, provider_info
-    ):
-        mock_get_provider_info.return_value = provider_info
+    def 
test_import_fails_with_cncf_kubernetes_without_executor_client_factory(self, 
monkeypatch):
+        monkeypatch.delattr(KubernetesExecutor, "client_factory")
         monkeypatch.delitem(sys.modules, EKS_EXECUTOR_MODULE)
 
-        with pytest.raises(AirflowOptionalProviderFeatureException, 
match=">=10.24.0"):
+        with pytest.raises(
+            AirflowOptionalProviderFeatureException,
+            match=r"apache-airflow-providers-amazon\[cncf.kubernetes\]",
+        ):
             importlib.import_module(EKS_EXECUTOR_MODULE)
+
+
[email protected]_test
+class TestCleanupPods:
+    @REQUIRES_MULTI_TEAM
+    @pytest.mark.usefixtures("clean_executor_loader")
+    @conf_vars(
+        {
+            ("core", "multi_team"): "True",
+            ("core", "executor"): 
f"LocalExecutor;team_a={EKS_EXECUTOR_MODULE}.AwsEksExecutor",
+        }
+    )
+    @mock.patch.object(kubernetes_command, "_delete_pod", autospec=True)
+    @mock.patch.object(CoreV1Api, "list_namespaced_pod", autospec=True)
+    def test_cleanup_pods_team_uses_the_teams_eks_cluster(
+        self, mock_list_pods, mock_delete_pod, mock_aws, monkeypatch
+    ):
+        """``airflow kubernetes cleanup-pods --team`` connects through the 
team's AwsEksExecutor."""
+        monkeypatch.setenv("AIRFLOW__TEAM_A___AWS_EKS_EXECUTOR__CLUSTER_NAME", 
"team-a-cluster")
+        monkeypatch.setenv("AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__NAMESPACE", 
"team-a-namespace")
+        pod = mock.MagicMock()
+        pod.metadata.name = "finished-pod"
+        pod.metadata.creation_timestamp = datetime(2021, 12, 20, tzinfo=UTC)
+        pod.status.phase = "Succeeded"
+        pod.status.reason = None
+        mock_list_pods.return_value = mock.MagicMock(items=[pod], 
metadata=mock.MagicMock(_continue=None))
+
+        kubernetes_command.cleanup_pods(
+            argparse.Namespace(
+                namespace=None,
+                min_pending_minutes=30,
+                min_completed_minutes=1,
+                team="team_a",
+                executor=None,
+                allow_shared_namespace=False,
+                verbose=False,
+            )
+        )
+
+        
mock_aws["eks_hook"].conn.describe_cluster.assert_called_once_with(name="team-a-cluster")
+        kube_client = mock_list_pods.call_args.args[0]
+        assert kube_client.api_client.configuration.host == CLUSTER_ENDPOINT
+        assert mock_list_pods.call_args.kwargs["namespace"] == 
"team-a-namespace"
+        mock_delete_pod.assert_called_once_with("finished-pod", 
"team-a-namespace", kube_client)

Reply via email to