This is an automated email from the ASF dual-hosted git repository.
o-nikolas 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 8aa1cf26ea3 Let each team's KubernetesExecutor connect to its own
cluster (#75251)
8aa1cf26ea3 is described below
commit 8aa1cf26ea349a9aec6da774286d14ac4f1c06b8
Author: Sean Ghaeli <[email protected]>
AuthorDate: Fri Oct 23 11:51:30 2026 -0700
Let each team's KubernetesExecutor connect to its own cluster (#75251)
In multi-team mode, a team's KubernetesExecutor read its pod settings from
the team's config, but connected to the cluster set in the global
[kubernetes_executor] section, so every team ran its pods on the same
cluster. A client_factory could be set per team, but it was called without
arguments, so it couldn't tell which team it was building a client for.
A KubernetesExecutor subclass that builds its own client, such as the
upcoming AwsEksExecutor, could only install its factory through
[kubernetes_executor] client_factory. A plain KubernetesExecutor in the
same scope then picked it up too and sent its pods to the subclass's
cluster.
airflow kubernetes cleanup-pods also only connected to the global cluster.
It now takes --team, like generate-dag-yaml, and connects the way the
team's executor does, with --executor to pick one when the team has more
than one. It refuses a namespace that another team's KubernetesExecutor, or
a global one, also uses, since it would delete their pods too.
closes: #74844
---
providers/cncf/kubernetes/docs/changelog.rst | 6 +
.../cncf/kubernetes/docs/kubernetes_executor.rst | 44 +++++
providers/cncf/kubernetes/provider.yaml | 16 +-
.../providers/cncf/kubernetes/cli/definition.py | 35 +++-
.../cncf/kubernetes/cli/kubernetes_command.py | 79 +++++++-
.../kubernetes/executors/kubernetes_executor.py | 39 ++--
.../executors/kubernetes_executor_utils.py | 19 +-
.../providers/cncf/kubernetes/get_provider_info.py | 4 +-
.../providers/cncf/kubernetes/kube_client.py | 89 ++++++---
.../cncf/kubernetes/cli/test_kubernetes_command.py | 219 +++++++++++++++++++--
.../executors/test_kubernetes_executor.py | 119 +++++++++--
.../tests/unit/cncf/kubernetes/test_client.py | 79 ++++++++
.../tests/unit/cncf/kubernetes/test_kube_client.py | 124 +++++++++++-
13 files changed, 782 insertions(+), 90 deletions(-)
diff --git a/providers/cncf/kubernetes/docs/changelog.rst
b/providers/cncf/kubernetes/docs/changelog.rst
index 082be601055..829cc47ba9e 100644
--- a/providers/cncf/kubernetes/docs/changelog.rst
+++ b/providers/cncf/kubernetes/docs/changelog.rst
@@ -27,6 +27,12 @@
Changelog
---------
+.. note::
+ In multi-team mode, a team's ``KubernetesExecutor`` now reads
``in_cluster``, ``config_file``,
+ ``cluster_context``, ``verify_ssl``, ``ssl_ca_cert`` and
``api_client_retry_configuration`` from the
+ team's ``[kubernetes_executor]`` config. Like all team config, these don't
fall back to the
+ un-prefixed section. If your teams connected using the un-prefixed values,
set them for each team.
+
10.25.0
.......
diff --git a/providers/cncf/kubernetes/docs/kubernetes_executor.rst
b/providers/cncf/kubernetes/docs/kubernetes_executor.rst
index 06e1c632798..7e5e5a43650 100644
--- a/providers/cncf/kubernetes/docs/kubernetes_executor.rst
+++ b/providers/cncf/kubernetes/docs/kubernetes_executor.rst
@@ -206,6 +206,50 @@ To get task logs out of the workers, you can:
If you don't enable logging persistence, and if you have not enabled
remote logging, logs will be lost after the worker pods shut down.
+Connecting each team to its own cluster
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+In a :doc:`multi-team <apache-airflow:core-concepts/multi-team>` deployment, a
team's KubernetesExecutor
+reads the settings it connects with from the team's ``[kubernetes_executor]``
config, like every other
+setting: ``in_cluster``, ``config_file``, ``cluster_context``, ``verify_ssl``,
``ssl_ca_cert``,
+``api_client_retry_configuration``, ``client_factory`` and
``async_client_factory``. So each team can run
+its pods on a different cluster. Team config doesn't fall back to the
un-prefixed section, so a team that
+connects from outside the cluster has to set ``in_cluster = False`` itself.
``enable_tcp_keepalive`` and
+the ``tcp_keep_*`` settings are only read from the un-prefixed section, and
apply to every team's client.
+
+.. code-block:: ini
+
+ [core]
+ multi_team = True
+ executor =
LocalExecutor;team_a=KubernetesExecutor;team_b=KubernetesExecutor
+
+ [team_a=kubernetes_executor]
+ in_cluster = False
+ config_file = /opt/airflow/kube/clusters.yaml
+ cluster_context = cluster-a
+ namespace = team-a
+
+ [team_b=kubernetes_executor]
+ in_cluster = False
+ config_file = /opt/airflow/kube/clusters.yaml
+ cluster_context = cluster-b
+ namespace = team-b
+
+A ``client_factory`` or ``async_client_factory`` that takes a ``team_name``
argument is called with the
+team it builds the client for (``None`` for an executor with no team). One
that takes no arguments is
+still called without any.
+
+``airflow kubernetes cleanup-pods --team <team>`` connects the way the team's
default executor does, or
+the one named with ``--executor``, and cleans up the pods in the team's
namespace unless ``--namespace`` is
+given. It refuses a namespace that another team's KubernetesExecutor, or a
global one, also uses, since
+it would delete their pods too, unless you pass ``--allow-shared-namespace``
because they run on another
+cluster.
+
+A subclass of KubernetesExecutor that builds its own clients sets the
``client_factory`` and
+``async_client_factory`` class attributes to the import paths of its
factories, which are called with
+``team_name``. They take precedence over the config settings of the same name,
so a plain
+KubernetesExecutor running alongside the subclass keeps its own client.
+
Comparison with CeleryExecutor
------------------------------
diff --git a/providers/cncf/kubernetes/provider.yaml
b/providers/cncf/kubernetes/provider.yaml
index 32af80a1b3b..3d8f4aa3191 100644
--- a/providers/cncf/kubernetes/provider.yaml
+++ b/providers/cncf/kubernetes/provider.yaml
@@ -440,9 +440,11 @@ config:
default: ~
client_factory:
description: |
- Import path of a zero-argument callable returning the
``kubernetes.client.CoreV1Api``
- the executor should use, for deployments that mint their own
credentials. When set it
- replaces the default client construction entirely, so
``in_cluster``, ``cluster_context``,
+ Import path of a callable returning the
``kubernetes.client.CoreV1Api`` the executor
+ should use, for deployments that mint their own credentials. If the
callable takes a
+ ``team_name`` argument, it is called with the executor's team
(``None`` for an executor
+ with no team); otherwise it is called with no arguments. When set it
replaces the
+ default client construction entirely, so ``in_cluster``,
``cluster_context``,
``config_file``, ``verify_ssl``, ``ssl_ca_cert``,
``enable_tcp_keepalive`` and
``api_client_retry_configuration`` no longer apply. It is resolved
in every process that
needs a client, including the pod watcher subprocess, so it must be
importable on the
@@ -454,10 +456,10 @@ config:
default: ~
async_client_factory:
description: |
- Import path of a zero-argument callable returning the
``kubernetes_asyncio.client.CoreV1Api``
- used for concurrent pod creation. Required when ``client_factory``
is set and
- ``async_pod_creation`` is enabled, so that both clients carry the
same credentials;
- the executor refuses to start without it.
+ Import path of a callable returning the
``kubernetes_asyncio.client.CoreV1Api`` used for
+ concurrent pod creation, called the same way as ``client_factory``.
Required when
+ ``client_factory`` is set and ``async_pod_creation`` is enabled, so
that both clients
+ carry the same credentials; the executor refuses to start without it.
version_added: 10.24.0
type: string
example: "my_company.kubernetes.build_async_client"
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/definition.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/definition.py
index cbcbfac9e45..35cb25e49c0 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/definition.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/definition.py
@@ -29,7 +29,6 @@ from airflow.cli.cli_config import (
positive_int,
)
from airflow.providers.cncf.kubernetes.version_compat import AIRFLOW_V_3_0_PLUS
-from airflow.providers.common.compat.sdk import conf
if TYPE_CHECKING:
import argparse
@@ -54,8 +53,10 @@ else:
# CLI Args
ARG_NAMESPACE = Arg(
("--namespace",),
- default=conf.get("kubernetes_executor", "namespace"),
- help="Kubernetes Namespace. Default value is `[kubernetes] namespace` in
configuration.",
+ help=(
+ "Kubernetes Namespace. Default value is `[kubernetes_executor]
namespace` in configuration, "
+ "the team's one with --team."
+ ),
)
ARG_MIN_PENDING_MINUTES = Arg(
@@ -84,6 +85,24 @@ ARG_TEAM = Arg(
help="Team name for multi-team configuration. When set, team-specific
config overrides are applied.",
)
+ARG_EXECUTOR = Arg(
+ ("--executor",),
+ default=None,
+ help=(
+ "With --team, the team's executor whose pods to clean up, by alias,
module path or class name. "
+ "Default is the team's default executor."
+ ),
+)
+
+ARG_ALLOW_SHARED_NAMESPACE = Arg(
+ ("--allow-shared-namespace",),
+ action="store_true",
+ help=(
+ "With --team, clean up even if another team's or a global
KubernetesExecutor uses the same "
+ "namespace, such as on another cluster."
+ ),
+)
+
# CLI Commands
KUBERNETES_COMMANDS = (
ActionCommand(
@@ -94,7 +113,15 @@ KUBERNETES_COMMANDS = (
"in evicted/failed/succeeded/pending states"
),
func=lazy_load_command("airflow.providers.cncf.kubernetes.cli.kubernetes_command.cleanup_pods"),
- args=(ARG_NAMESPACE, ARG_MIN_PENDING_MINUTES,
ARG_MIN_COMPLETED_MINUTES, ARG_VERBOSE),
+ args=(
+ ARG_NAMESPACE,
+ ARG_MIN_PENDING_MINUTES,
+ ARG_MIN_COMPLETED_MINUTES,
+ ARG_TEAM,
+ ARG_EXECUTOR,
+ ARG_ALLOW_SHARED_NAMESPACE,
+ ARG_VERBOSE,
+ ),
),
ActionCommand(
name="generate-dag-yaml",
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/kubernetes_command.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/kubernetes_command.py
index 2d2490134dd..b4e2c1c325b 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/kubernetes_command.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/kubernetes_command.py
@@ -26,10 +26,16 @@ from kubernetes import client
from kubernetes.client.api_client import ApiClient
from kubernetes.client.rest import ApiException
+from airflow.exceptions import UnknownExecutorException
+from airflow.executors.executor_loader import ExecutorLoader
from airflow.models import DagModel, DagRun, TaskInstance
from airflow.providers.cncf.kubernetes import pod_generator
-from airflow.providers.cncf.kubernetes.executors.kubernetes_executor import
KubeConfig
-from airflow.providers.cncf.kubernetes.kube_client import get_kube_client
+from airflow.providers.cncf.kubernetes.executors.kubernetes_executor import
KubeConfig, KubernetesExecutor
+from airflow.providers.cncf.kubernetes.kube_client import (
+ _get_executor_kube_client,
+ _team_kwargs,
+ get_kube_client,
+)
from airflow.providers.cncf.kubernetes.kubernetes_helper_functions import
create_unique_id
from airflow.providers.cncf.kubernetes.pod_generator import PodGenerator,
generate_pod_command_args
from airflow.providers.cncf.kubernetes.version_compat import (
@@ -37,6 +43,7 @@ from airflow.providers.cncf.kubernetes.version_compat import (
AIRFLOW_V_3_1_PLUS,
AIRFLOW_V_3_2_PLUS,
)
+from airflow.providers.common.compat.sdk import conf
from airflow.utils import cli as cli_utils, yaml
from airflow.utils.providers_configuration_loader import
providers_configuration_loaded
from airflow.utils.types import DagRunType
@@ -148,7 +155,16 @@ def _get_pod_completion_time(pod):
@providers_configuration_loaded
def cleanup_pods(args):
"""Clean up k8s pods in evicted/failed/succeeded/pending states."""
- namespace = args.namespace
+ team_name = args.team
+ if team_name and not AIRFLOW_V_3_2_PLUS:
+ raise SystemExit("cleanup-pods --team requires Airflow 3.2+.")
+ if args.executor and not team_name:
+ raise SystemExit("cleanup-pods --executor requires --team.")
+ namespace = args.namespace or conf.get("kubernetes_executor", "namespace",
**_team_kwargs(team_name))
+ if team_name:
+ executor_cls = _get_team_executor_cls(team_name, args.executor)
+ if not args.allow_shared_namespace:
+ _check_namespace_not_shared(team_name, namespace)
min_pending_minutes = args.min_pending_minutes
# protect newly created pods from deletion
@@ -178,7 +194,12 @@ def cleanup_pods(args):
pod_restart_policy_never = "never"
if args.verbose:
print("Loading Kubernetes configuration")
- kube_client = get_kube_client()
+ if team_name:
+ kube_client = _get_executor_kube_client(
+ team_name=team_name,
executor_client_factory=executor_cls.client_factory
+ )
+ else:
+ kube_client = get_kube_client()
if args.verbose:
print(f"Listing pods in namespace {namespace}")
airflow_pod_labels = [
@@ -219,7 +240,7 @@ def cleanup_pods(args):
f'restart policy "{pod_restart_policy}"'
)
try:
- _delete_pod(pod.metadata.name, namespace)
+ _delete_pod(pod.metadata.name, namespace, kube_client)
except ApiException as e:
print(f"Can't remove POD: {e}", file=sys.stderr)
else:
@@ -231,13 +252,57 @@ def cleanup_pods(args):
list_kwargs["_continue"] = continue_token
-def _delete_pod(name, namespace):
+def _get_team_executor_cls(team_name: str, executor: str | None) ->
type[KubernetesExecutor]:
+ """Return the class of the team's executor named ``executor``, or of its
default one."""
+ # Only the team's own executors: a global one doesn't connect with the
team's config.
+ names = [n for n in
ExecutorLoader.get_executor_names(validate_teams=False) if n.team_name ==
team_name]
+ if not names:
+ raise SystemExit(f"Team {team_name!r} has no executors configured.")
+ try:
+ if hasattr(ExecutorLoader, "resolve_executor_name"):
+ name = ExecutorLoader.resolve_executor_name(executor, team_name,
executor_names=names)
+ elif executor: # Airflow < 3.4
+ name = ExecutorLoader.lookup_executor_name_by_str(
+ executor, team_name=team_name, validate_teams=False
+ )
+ else:
+ name = names[0]
+ except UnknownExecutorException as e:
+ raise SystemExit(str(e))
+ executor_cls, _ = ExecutorLoader.import_executor_cls(name)
+ if not issubclass(executor_cls, KubernetesExecutor):
+ raise SystemExit(
+ f"Executor {name.module_path} of team {team_name!r} isn't a
KubernetesExecutor. "
+ "Pass --executor to pick one that is."
+ )
+ return executor_cls
+
+
+def _check_namespace_not_shared(team_name: str, namespace: str) -> None:
+ """Refuse a namespace another scope's KubernetesExecutor also uses, since
their pods can't be told apart."""
+ # Only scopes that run a KubernetesExecutor make pods; the others still
have a default namespace.
+ scopes = {
+ n.team_name
+ for n in ExecutorLoader.get_executor_names(validate_teams=False)
+ if n.team_name != team_name
+ and issubclass(ExecutorLoader.import_executor_cls(n)[0],
KubernetesExecutor)
+ }
+ for other in sorted(scopes, key=lambda t: t or ""):
+ if conf.get("kubernetes_executor", "namespace", **_team_kwargs(other))
== namespace:
+ owner = f"a KubernetesExecutor of team {other!r}" if other else "a
global KubernetesExecutor"
+ raise SystemExit(
+ f"Namespace {namespace!r} of team {team_name!r} is also used
by {owner}, so cleanup-pods "
+ "would delete its pods too. Pass --allow-shared-namespace if
that executor runs on "
+ "another cluster."
+ )
+
+
+def _delete_pod(name, namespace, kube_client):
"""
Delete a namespaced pod.
Helper Function for cleanup_pods.
"""
- kube_client = get_kube_client()
delete_options = client.V1DeleteOptions()
print(f'Deleting POD "{name}" from "{namespace}" namespace')
api_response = kube_client.delete_namespaced_pod(name=name,
namespace=namespace, body=delete_options)
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
index 0534f10799a..68f61b03bfa 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
@@ -115,6 +115,11 @@ class KubernetesExecutor(BaseExecutor):
RUNNING_POD_LOG_LINES = 100
supports_ad_hoc_ti_run: bool = True
+ # Import paths of the factories building a subclass's own sync and async
clients, called with
+ # ``team_name``. They take precedence over [kubernetes_executor]
client_factory and
+ # async_client_factory, which stay free for a KubernetesExecutor running
alongside.
+ client_factory: str | None = None
+ async_client_factory: str | None = None
supports_multi_team: bool = True
supports_task_instance_uuid = hasattr(BaseExecutor, "get_task_key")
@@ -277,16 +282,24 @@ class KubernetesExecutor(BaseExecutor):
)
from airflow.providers.cncf.kubernetes.kube_client import
_get_executor_kube_client
- if (
- self.kube_config.async_pod_creation
- and self.conf.get("kubernetes_executor", "client_factory",
fallback=None)
- and not self.conf.get("kubernetes_executor",
"async_client_factory", fallback=None)
- ):
- raise AirflowConfigException(
- "In the [kubernetes_executor] Airflow config,
async_client_factory is required "
- "when client_factory is set and async_pod_creation is enabled."
- )
- self.kube_client = _get_executor_kube_client(team_name=self.team_name)
+ if self.kube_config.async_pod_creation:
+ if self.client_factory and not self.async_client_factory:
+ raise AirflowConfigException(
+ f"{type(self).__name__} sets client_factory but not
async_client_factory, "
+ "which is required when async_pod_creation is enabled."
+ )
+ if (
+ not self.client_factory
+ and self.conf.get("kubernetes_executor", "client_factory",
fallback=None)
+ and not self.conf.get("kubernetes_executor",
"async_client_factory", fallback=None)
+ ):
+ raise AirflowConfigException(
+ "In the [kubernetes_executor] Airflow config,
async_client_factory is required "
+ "when client_factory is set and async_pod_creation is
enabled."
+ )
+ self.kube_client = _get_executor_kube_client(
+ team_name=self.team_name,
executor_client_factory=self.client_factory
+ )
self.kube_scheduler = AirflowKubernetesScheduler(
kube_config=self.kube_config,
result_queue=self.result_queue,
@@ -294,6 +307,8 @@ class KubernetesExecutor(BaseExecutor):
scheduler_job_id=self.scheduler_job_id,
team_name=self.team_name,
supports_task_instance_uuid=self.supports_task_instance_uuid,
+ client_factory=self.client_factory,
+ async_client_factory=self.async_client_factory,
)
def _coordinator_extra(self, queue: str | None) -> dict[str, Any] | None:
@@ -1045,7 +1060,9 @@ class KubernetesExecutor(BaseExecutor):
from airflow.providers.cncf.kubernetes.kube_client import
_get_executor_kube_client
from airflow.providers.cncf.kubernetes.pod_generator import
PodGenerator
- client = _get_executor_kube_client(team_name=self.team_name)
+ client = _get_executor_kube_client(
+ team_name=self.team_name,
executor_client_factory=self.client_factory
+ )
hostname_desc = f" {ti.hostname}" if ti.hostname else ""
messages.append(f"Attempting to fetch logs from pod{hostname_desc}
through kube API")
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
index 29dede7d10f..39997af0006 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
@@ -95,6 +95,7 @@ class KubernetesJobWatcher(multiprocessing.Process,
LoggingMixin):
kube_config: Configuration,
supports_task_instance_uuid: bool = False,
team_name: str | None = None,
+ client_factory: str | None = None,
):
super().__init__()
self.namespace = namespace
@@ -103,16 +104,19 @@ class KubernetesJobWatcher(multiprocessing.Process,
LoggingMixin):
self.resource_version = resource_version
self.kube_config = kube_config
self.supports_task_instance_uuid = supports_task_instance_uuid
- # A string survives the spawn start method, so the watcher can resolve
its team's factory
- # from configuration in its own process.
+ # Strings survive the spawn start method, so the watcher can build its
team's client in its
+ # own process.
self.team_name = team_name
+ self.client_factory = client_factory
def run(self) -> None:
"""Perform watching."""
if TYPE_CHECKING:
assert self.scheduler_job_id
- kube_client: client.CoreV1Api =
_get_executor_kube_client(team_name=self.team_name)
+ kube_client: client.CoreV1Api = _get_executor_kube_client(
+ team_name=self.team_name,
executor_client_factory=self.client_factory
+ )
while True:
try:
self.resource_version = self._run(
@@ -507,6 +511,8 @@ class AirflowKubernetesScheduler(LoggingMixin):
scheduler_job_id: str,
team_name: str | None = None,
supports_task_instance_uuid: bool = False,
+ client_factory: str | None = None,
+ async_client_factory: str | None = None,
):
super().__init__()
self.log.debug("Creating Kubernetes executor")
@@ -521,6 +527,8 @@ class AirflowKubernetesScheduler(LoggingMixin):
self.scheduler_job_id = scheduler_job_id
# Set before the watchers are built, since they are told which team
they are watching for.
self.team_name = team_name
+ self.client_factory = client_factory
+ self.async_client_factory = async_client_factory
self.kube_watchers = self._make_kube_watchers()
# Async pod-creation state; populated lazily, only used when
async_pod_creation is enabled.
self._async_loop: asyncio.AbstractEventLoop | None = None
@@ -573,6 +581,7 @@ class AirflowKubernetesScheduler(LoggingMixin):
kube_config=self.kube_config,
supports_task_instance_uuid=self.supports_task_instance_uuid,
team_name=self.team_name,
+ client_factory=self.client_factory,
)
watcher.start()
return watcher
@@ -720,7 +729,9 @@ class AirflowKubernetesScheduler(LoggingMixin):
) -> list[Exception | None]:
"""Issue create_namespaced_pod calls concurrently, bounded by a
semaphore; one result per pod, in order."""
if self._async_pod_client is None:
- self._async_pod_client = await
_get_executor_async_kube_client(team_name=self.team_name)
+ self._async_pod_client = await _get_executor_async_kube_client(
+ team_name=self.team_name,
executor_client_factory=self.async_client_factory
+ )
api = self._async_pod_client
semaphore = asyncio.Semaphore(self.pod_creation_max_concurrency)
request_kwargs: dict[str, Any] =
self.kube_config.kube_client_request_args or {}
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 7e1c8d2cd9e..47e5e5cf536 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
@@ -273,14 +273,14 @@ def get_provider_info():
"default": None,
},
"client_factory": {
- "description": "Import path of a zero-argument
callable returning the ``kubernetes.client.CoreV1Api``\nthe executor should
use, for deployments that mint their own credentials. When set it\nreplaces the
default client construction entirely, so ``in_cluster``,
``cluster_context``,\n``config_file``, ``verify_ssl``, ``ssl_ca_cert``,
``enable_tcp_keepalive`` and\n``api_client_retry_configuration`` no longer
apply. It is resolved in every process that\nneeds a client, [...]
+ "description": "Import path of a callable returning
the ``kubernetes.client.CoreV1Api`` the executor\nshould use, for deployments
that mint their own credentials. If the callable takes a\n``team_name``
argument, it is called with the executor's team (``None`` for an executor\nwith
no team); otherwise it is called with no arguments. When set it replaces
the\ndefault client construction entirely, so ``in_cluster``,
``cluster_context``,\n``config_file``, ``verify_ssl [...]
"version_added": "10.24.0",
"type": "string",
"example": "my_company.kubernetes.build_client",
"default": None,
},
"async_client_factory": {
- "description": "Import path of a zero-argument
callable returning the ``kubernetes_asyncio.client.CoreV1Api``\nused for
concurrent pod creation. Required when ``client_factory`` is set
and\n``async_pod_creation`` is enabled, so that both clients carry the same
credentials;\nthe executor refuses to start without it.\n",
+ "description": "Import path of a callable returning
the ``kubernetes_asyncio.client.CoreV1Api`` used for\nconcurrent pod creation,
called the same way as ``client_factory``. Required when\n``client_factory`` is
set and ``async_pod_creation`` is enabled, so that both clients\ncarry the same
credentials; the executor refuses to start without it.\n",
"version_added": "10.24.0",
"type": "string",
"example": "my_company.kubernetes.build_async_client",
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
index 9b6acbfdb75..1c14a8b2247 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
@@ -18,15 +18,18 @@
from __future__ import annotations
+import inspect
import logging
import socket
import warnings
+from collections.abc import Callable
from typing import Any
import urllib3.util
from urllib3.connection import HTTPConnection
from airflow.exceptions import AirflowProviderDeprecationWarning
+from airflow.providers.common.compat.module_loading import import_string
from airflow.providers.common.compat.sdk import conf
log = logging.getLogger(__name__)
@@ -188,6 +191,7 @@ def get_kube_client(
in_cluster: bool | None = None,
cluster_context: str | None = None,
config_file: str | None = None,
+ team_name: str | None = None,
) -> client.CoreV1Api:
"""
Retrieve Kubernetes client.
@@ -195,22 +199,31 @@ def get_kube_client(
:param in_cluster: whether we are in cluster
:param cluster_context: context of the cluster
:param config_file: configuration file
+ :param team_name: team whose ``[kubernetes_executor]`` connection settings
to use. Like all team
+ config, they don't fall back to the un-prefixed section.
:return: kubernetes client
"""
+ team_kwargs = _team_kwargs(team_name)
if in_cluster is None:
- in_cluster = conf.getboolean("kubernetes_executor", "in_cluster")
+ in_cluster = conf.getboolean("kubernetes_executor", "in_cluster",
**team_kwargs)
if not has_kubernetes:
raise _import_err
configuration = _get_default_configuration()
+ # Keepalive stays a global setting, so it is not read from the team's
config.
if conf.getboolean("kubernetes_executor", "enable_tcp_keepalive"):
configure_tcp_keepalive(configuration)
api_client_retry_configuration = conf.getjson(
- "kubernetes_executor", "api_client_retry_configuration", fallback={}
+ "kubernetes_executor", "api_client_retry_configuration", fallback={},
**team_kwargs
)
- if not conf.getboolean("kubernetes_executor", "verify_ssl"):
+ verify_ssl = conf.getboolean("kubernetes_executor", "verify_ssl",
**team_kwargs)
+ if team_name:
+ # The default configuration is shared by the whole process, so a
team's client sets its
+ # own value rather than changing the default or inheriting another
executor's change.
+ configuration.verify_ssl = verify_ssl
+ elif not verify_ssl:
_disable_verify_ssl()
if isinstance(api_client_retry_configuration, dict):
@@ -222,17 +235,17 @@ def get_kube_client(
config.load_incluster_config(client_configuration=configuration)
else:
if cluster_context is None:
- cluster_context = conf.get("kubernetes_executor",
"cluster_context", fallback=None)
+ cluster_context = conf.get("kubernetes_executor",
"cluster_context", fallback=None, **team_kwargs)
if config_file is None:
- config_file = conf.get("kubernetes_executor", "config_file",
fallback=None)
+ config_file = conf.get("kubernetes_executor", "config_file",
fallback=None, **team_kwargs)
config.load_kube_config(
config_file=config_file, context=cluster_context,
client_configuration=configuration
)
- if not conf.getboolean("kubernetes_executor", "verify_ssl"):
+ if not verify_ssl:
configuration.verify_ssl = False
- ssl_ca_cert = conf.get("kubernetes_executor", "ssl_ca_cert")
+ ssl_ca_cert = conf.get("kubernetes_executor", "ssl_ca_cert", **team_kwargs)
if ssl_ca_cert:
configuration.ssl_ca_cert = ssl_ca_cert
@@ -244,6 +257,7 @@ async def get_async_kube_client(
in_cluster: bool | None = None,
cluster_context: str | None = None,
config_file: str | None = None,
+ team_name: str | None = None,
) -> async_client.CoreV1Api:
"""
Retrieve an asynchronous Kubernetes client.
@@ -256,29 +270,31 @@ async def get_async_kube_client(
:param in_cluster: whether we are in cluster
:param cluster_context: context of the cluster
:param config_file: configuration file
+ :param team_name: team whose connection settings to use, as in
:func:`get_kube_client`
:return: asynchronous kubernetes client
"""
if not has_kubernetes:
raise _import_err
+ team_kwargs = _team_kwargs(team_name)
if in_cluster is None:
- in_cluster = conf.getboolean("kubernetes_executor", "in_cluster")
+ in_cluster = conf.getboolean("kubernetes_executor", "in_cluster",
**team_kwargs)
configuration = async_client.Configuration()
- if not conf.getboolean("kubernetes_executor", "verify_ssl"):
+ if not conf.getboolean("kubernetes_executor", "verify_ssl", **team_kwargs):
configuration.verify_ssl = False
if in_cluster:
async_config.load_incluster_config(client_configuration=configuration)
else:
if cluster_context is None:
- cluster_context = conf.get("kubernetes_executor",
"cluster_context", fallback=None)
+ cluster_context = conf.get("kubernetes_executor",
"cluster_context", fallback=None, **team_kwargs)
if config_file is None:
- config_file = conf.get("kubernetes_executor", "config_file",
fallback=None)
+ config_file = conf.get("kubernetes_executor", "config_file",
fallback=None, **team_kwargs)
await async_config.load_kube_config(
config_file=config_file, context=cluster_context,
client_configuration=configuration
)
- ssl_ca_cert = conf.get("kubernetes_executor", "ssl_ca_cert")
+ ssl_ca_cert = conf.get("kubernetes_executor", "ssl_ca_cert", **team_kwargs)
if ssl_ca_cert:
configuration.ssl_ca_cert = ssl_ca_cert
@@ -290,39 +306,60 @@ def _team_kwargs(team_name: str | None) -> dict[str, str]:
return {"team_name": team_name} if team_name else {}
-def _get_executor_kube_client(team_name: str | None = None) ->
client.CoreV1Api:
+def _call_client_factory(factory: Callable[..., Any], team_name: str | None)
-> Any:
+ """Pass ``team_name`` only to a factory that takes it, since factories
used to take no arguments."""
+ parameters = inspect.signature(factory).parameters.values()
+ if any(p.name == "team_name" or p.kind is p.VAR_KEYWORD for p in
parameters):
+ return factory(team_name=team_name)
+ return factory()
+
+
+def _get_executor_kube_client(
+ team_name: str | None = None, executor_client_factory: str | None = None
+) -> client.CoreV1Api:
"""
Retrieve the Kubernetes client for the KubernetesExecutor.
- Uses the ``client_factory`` setting when it is set, and
:func:`get_kube_client` otherwise.
+ Uses the executor's own factory when it has one, then the
``client_factory`` setting, and
+ :func:`get_kube_client` otherwise.
:param team_name: team the executor is running for, so a team can point
its executor at its
- own cluster; a team that sets no factory gets the default client
rather than the one
- configured in the un-prefixed section, matching how team config
resolves everywhere else
+ own cluster. Like all team config, the team's settings don't fall back
to the un-prefixed
+ section. A factory that takes ``team_name`` is told the team.
+ :param executor_client_factory: import path of the executor's own factory,
see
+ ``KubernetesExecutor.client_factory``
:return: kubernetes client
"""
- # Config only stores strings, so the factory is an import path; the pod
watcher runs in its
- # own process and resolves it there itself.
+ # Factories are import paths, since the pod watcher runs in its own
process and resolves its
+ # factory there itself.
+ if executor_client_factory:
+ return _call_client_factory(import_string(executor_client_factory),
team_name)
if client_factory := conf.getimport(
"kubernetes_executor", "client_factory", fallback=None,
**_team_kwargs(team_name)
):
- return client_factory()
- return get_kube_client()
+ return _call_client_factory(client_factory, team_name)
+ return get_kube_client(team_name=team_name)
-async def _get_executor_async_kube_client(team_name: str | None = None) ->
async_client.CoreV1Api:
+async def _get_executor_async_kube_client(
+ team_name: str | None = None, executor_client_factory: str | None = None
+) -> async_client.CoreV1Api:
"""
Retrieve the asynchronous Kubernetes client for the KubernetesExecutor.
- Uses the ``async_client_factory`` setting when it is set, and
:func:`get_async_kube_client`
- otherwise.
+ Uses the executor's own factory when it has one, then the
``async_client_factory`` setting,
+ and :func:`get_async_kube_client` otherwise.
- :param team_name: team the executor is running for, resolved the same way
as in
+ :param team_name: team the executor is running for, used the same way as in
:func:`_get_executor_kube_client`
+ :param executor_client_factory: import path of the executor's own factory,
see
+ ``KubernetesExecutor.async_client_factory``
:return: asynchronous kubernetes client
"""
+ if executor_client_factory:
+ return _call_client_factory(import_string(executor_client_factory),
team_name)
if async_client_factory := conf.getimport(
"kubernetes_executor", "async_client_factory", fallback=None,
**_team_kwargs(team_name)
):
- return async_client_factory()
- return await get_async_kube_client()
+ return _call_client_factory(async_client_factory, team_name)
+ return await get_async_kube_client(team_name=team_name)
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/cli/test_kubernetes_command.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/cli/test_kubernetes_command.py
index 29eadcc55a2..ce2291a7a46 100644
---
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/cli/test_kubernetes_command.py
+++
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/cli/test_kubernetes_command.py
@@ -31,9 +31,10 @@ from kubernetes.client import models as k8s
from airflow.cli import cli_parser
from airflow.executors import executor_loader
from airflow.providers.cncf.kubernetes.cli import kubernetes_command
+from airflow.providers.cncf.kubernetes.executors.kubernetes_executor import
KubernetesExecutor
from tests_common.test_utils.config import conf_vars
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS,
AIRFLOW_V_3_2_PLUS
pytestmark = pytest.mark.db_test
@@ -118,6 +119,24 @@ class TestGenerateDagYamlCommand:
assert (output_path / path.name).stat().st_size > 0
+class _ExecutorWithOwnClient(KubernetesExecutor):
+ client_factory = "my_company.build_client"
+
+
+TEAM_CONF = {
+ ("core", "multi_team"): "True",
+ ("core", "executor"): (
+
f"KubernetesExecutor;team_a={__name__}._ExecutorWithOwnClient,KubernetesExecutor;"
+ "team_b=LocalExecutor,KubernetesExecutor"
+ ),
+ ("kubernetes_executor", "namespace"): "global-namespace",
+}
+TEAM_NAMESPACES = {
+ "AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__NAMESPACE": "team-a-namespace",
+ "AIRFLOW__TEAM_B___KUBERNETES_EXECUTOR__NAMESPACE": "team-b-namespace",
+}
+
+
class TestCleanUpPodsCommand:
label_selector = "dag_id,task_id,try_number,airflow_version"
@@ -128,12 +147,12 @@ class TestCleanUpPodsCommand:
importlib.reload(cli_parser)
cls.parser = cli_parser.get_parser()
- @mock.patch("kubernetes.client.CoreV1Api.delete_namespaced_pod")
-
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.config.load_incluster_config")
- def test_delete_pod(self, load_incluster_config, delete_namespaced_pod):
- kubernetes_command._delete_pod("dummy", "awesome-namespace")
- delete_namespaced_pod.assert_called_with(body=mock.ANY, name="dummy",
namespace="awesome-namespace")
- load_incluster_config.assert_called_once()
+ def test_delete_pod(self):
+ kube_client = MagicMock()
+ kubernetes_command._delete_pod("dummy", "awesome-namespace",
kube_client)
+ kube_client.delete_namespaced_pod.assert_called_with(
+ body=mock.ANY, name="dummy", namespace="awesome-namespace"
+ )
@mock.patch("airflow.providers.cncf.kubernetes.cli.kubernetes_command._delete_pod")
@mock.patch("kubernetes.client.CoreV1Api.list_namespaced_pod")
@@ -176,7 +195,7 @@ class TestCleanUpPodsCommand:
list_namespaced_pod.assert_called_once_with(
namespace="awesome-namespace", limit=500,
label_selector=self.label_selector
)
- delete_pod.assert_called_with("dummy", "awesome-namespace")
+ delete_pod.assert_called_with("dummy", "awesome-namespace", mock.ANY)
load_incluster_config.assert_called_once()
@mock.patch("airflow.providers.cncf.kubernetes.cli.kubernetes_command._delete_pod")
@@ -226,7 +245,7 @@ class TestCleanUpPodsCommand:
list_namespaced_pod.assert_called_once_with(
namespace="awesome-namespace", limit=500,
label_selector=self.label_selector
)
- delete_pod.assert_called_with("dummy3", "awesome-namespace")
+ delete_pod.assert_called_with("dummy3", "awesome-namespace", mock.ANY)
load_incluster_config.assert_called_once()
@mock.patch("airflow.providers.cncf.kubernetes.cli.kubernetes_command._delete_pod")
@@ -249,7 +268,7 @@ class TestCleanUpPodsCommand:
list_namespaced_pod.assert_called_once_with(
namespace="awesome-namespace", limit=500,
label_selector=self.label_selector
)
- delete_pod.assert_called_with("dummy4", "awesome-namespace")
+ delete_pod.assert_called_with("dummy4", "awesome-namespace", mock.ANY)
load_incluster_config.assert_called_once()
@mock.patch("airflow.providers.cncf.kubernetes.cli.kubernetes_command._delete_pod")
@@ -271,7 +290,7 @@ class TestCleanUpPodsCommand:
list_namespaced_pod.assert_called_once_with(
namespace="awesome-namespace", limit=500,
label_selector=self.label_selector
)
- delete_pod.assert_called_with("dummy5", "awesome-namespace")
+ delete_pod.assert_called_with("dummy5", "awesome-namespace", mock.ANY)
load_incluster_config.assert_called_once()
@mock.patch("airflow.providers.cncf.kubernetes.cli.kubernetes_command._delete_pod")
@@ -325,9 +344,183 @@ class TestCleanUpPodsCommand:
),
]
list_namespaced_pod.assert_has_calls(calls)
- delete_pod.assert_called_with("dummy", "awesome-namespace")
+ delete_pod.assert_called_with("dummy", "awesome-namespace", mock.ANY)
load_incluster_config.assert_called_once()
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @pytest.mark.usefixtures("clean_executor_loader")
+ @pytest.mark.parametrize("has_resolve_executor_name", [True, False],
ids=["resolve", "pre-3.4"])
+ @pytest.mark.parametrize(
+ ("cli_args", "team_client_kwargs", "namespace"),
+ [
+ pytest.param([], None, "global-namespace", id="global"),
+ pytest.param(
+ ["--team", "team_a"],
+ {"team_name": "team_a", "executor_client_factory":
"my_company.build_client"},
+ "team-a-namespace",
+ id="team-default-executor",
+ ),
+ pytest.param(
+ ["--team", "team_a", "--executor", "KubernetesExecutor"],
+ {"team_name": "team_a", "executor_client_factory": None},
+ "team-a-namespace",
+ id="team-named-executor",
+ ),
+ pytest.param(
+ ["--team", "team_a", "--namespace", "other-namespace"],
+ {"team_name": "team_a", "executor_client_factory":
"my_company.build_client"},
+ "other-namespace",
+ id="namespace-flag",
+ ),
+ pytest.param(
+ ["--team", "team_a", "--namespace", "global-namespace",
"--allow-shared-namespace"],
+ {"team_name": "team_a", "executor_client_factory":
"my_company.build_client"},
+ "global-namespace",
+ id="allow-shared-namespace",
+ ),
+ ],
+ )
+ @conf_vars(TEAM_CONF)
+ @mock.patch.dict(os.environ, TEAM_NAMESPACES)
+
@mock.patch("airflow.providers.cncf.kubernetes.cli.kubernetes_command._delete_pod",
autospec=True)
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.cli.kubernetes_command._get_executor_kube_client",
autospec=True
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.cli.kubernetes_command.get_kube_client",
autospec=True)
+ def test_cleanup_pods_uses_the_teams_executor_client(
+ self,
+ get_kube_client,
+ get_executor_kube_client,
+ delete_pod,
+ monkeypatch,
+ cli_args,
+ team_client_kwargs,
+ namespace,
+ has_resolve_executor_name,
+ ):
+ if not has_resolve_executor_name:
+ monkeypatch.delattr(kubernetes_command.ExecutorLoader,
"resolve_executor_name", raising=False)
+ kube_client = (get_executor_kube_client if team_client_kwargs else
get_kube_client).return_value
+ pod = MagicMock()
+ pod.metadata.name = "dummy"
+ pod.metadata.creation_timestamp = parse("2021-12-20T08:01:07Z")
+ pod.status.phase = "Succeeded"
+ pod.status.reason = None
+ kube_client.list_namespaced_pod.return_value = MagicMock(
+ items=[pod], metadata=MagicMock(_continue=None)
+ )
+ kubernetes_command.cleanup_pods(self.parser.parse_args(["kubernetes",
"cleanup-pods", *cli_args]))
+ if team_client_kwargs:
+
get_executor_kube_client.assert_called_once_with(**team_client_kwargs)
+ get_kube_client.assert_not_called()
+ else:
+ get_kube_client.assert_called_once_with()
+ get_executor_kube_client.assert_not_called()
+ kube_client.list_namespaced_pod.assert_called_once_with(
+ namespace=namespace, limit=500, label_selector=self.label_selector
+ )
+ delete_pod.assert_called_once_with("dummy", namespace, kube_client)
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @pytest.mark.usefixtures("clean_executor_loader")
+ @pytest.mark.parametrize("has_resolve_executor_name", [True, False],
ids=["resolve", "pre-3.4"])
+ @pytest.mark.parametrize(
+ ("cli_args", "error"),
+ [
+ pytest.param(["--executor", "KubernetesExecutor"], "--executor
requires --team", id="no-team"),
+ pytest.param(["--team", "team_c"], "Team 'team_c' has no
executors", id="unknown-team"),
+ pytest.param(
+ ["--team", "team_a", "--executor", "CeleryExecutor"],
+ "Unknown executor",
+ id="unknown-executor",
+ ),
+ pytest.param(
+ ["--team", "team_b"],
+ "LocalExecutor of team 'team_b' isn't a KubernetesExecutor",
+ id="not-k8s",
+ ),
+ pytest.param(
+ ["--team", "team_a", "--namespace", "global-namespace"],
+ "also used by a global KubernetesExecutor",
+ id="shared-with-global",
+ ),
+ pytest.param(
+ ["--team", "team_a", "--namespace", "team-b-namespace"],
+ "also used by a KubernetesExecutor of team 'team_b'",
+ id="shared-with-team",
+ ),
+ ],
+ )
+ @conf_vars(TEAM_CONF)
+ @mock.patch.dict(os.environ, TEAM_NAMESPACES)
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.cli.kubernetes_command._get_executor_kube_client",
+ autospec=True,
+ side_effect=AssertionError("connected"),
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.cli.kubernetes_command.get_kube_client",
+ autospec=True,
+ side_effect=AssertionError("connected"),
+ )
+ def test_cleanup_pods_team_refuses(
+ self,
+ get_kube_client,
+ get_executor_kube_client,
+ monkeypatch,
+ cli_args,
+ error,
+ has_resolve_executor_name,
+ ):
+ if not has_resolve_executor_name:
+ monkeypatch.delattr(kubernetes_command.ExecutorLoader,
"resolve_executor_name", raising=False)
+ with pytest.raises(SystemExit, match=error):
+
kubernetes_command.cleanup_pods(self.parser.parse_args(["kubernetes",
"cleanup-pods", *cli_args]))
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @pytest.mark.usefixtures("clean_executor_loader")
+ @pytest.mark.parametrize(
+ "extra_conf",
+ [
+ pytest.param({("core", "executor"):
"LocalExecutor;team_a=KubernetesExecutor"}, id="global"),
+ pytest.param(
+ {
+ ("core", "executor"):
"KubernetesExecutor;team_a=KubernetesExecutor;team_b=LocalExecutor",
+ ("kubernetes_executor", "namespace"): "global-namespace",
+ },
+ id="team",
+ ),
+ ],
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.cli.kubernetes_command._get_executor_kube_client",
autospec=True
+ )
+ def test_cleanup_pods_team_ignores_scopes_without_kubernetes_executor(
+ self, get_executor_kube_client, extra_conf
+ ):
+ # The other scope has no namespace set, so it has team_a's built-in
default, but makes no pods.
+ kube_client = get_executor_kube_client.return_value
+ kube_client.list_namespaced_pod.return_value = MagicMock(items=[],
metadata=MagicMock(_continue=None))
+ with conf_vars({("core", "multi_team"): "True", **extra_conf}):
+ kubernetes_command.cleanup_pods(
+ self.parser.parse_args(["kubernetes", "cleanup-pods",
"--team", "team_a"])
+ )
+ kube_client.list_namespaced_pod.assert_called_once_with(
+ namespace="default", limit=500, label_selector=self.label_selector
+ )
+
+ @mock.patch.object(kubernetes_command, "AIRFLOW_V_3_2_PLUS", False)
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.cli.kubernetes_command.get_kube_client",
+ autospec=True,
+ side_effect=AssertionError("connected"),
+ )
+ def test_cleanup_pods_team_requires_airflow_3_2(self, get_kube_client):
+ with pytest.raises(SystemExit, match=r"--team requires Airflow
3\.2\+"):
+ kubernetes_command.cleanup_pods(
+ self.parser.parse_args(["kubernetes", "cleanup-pods",
"--team", "team_a"])
+ )
+
@pytest.mark.parametrize(
("pod_kwargs", "min_completed_minutes", "expect_deleted"),
[
@@ -405,7 +598,7 @@ class TestCleanUpPodsCommand:
)
)
if expect_deleted:
- delete_pod.assert_called_once_with("run-o1sxc2on",
"awesome-namespace")
+ delete_pod.assert_called_once_with("run-o1sxc2on",
"awesome-namespace", mock.ANY)
else:
delete_pod.assert_not_called()
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
index 26d0aa254d6..568747c038e 100644
---
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
+++
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
@@ -4108,35 +4108,71 @@ class TestKubernetesExecutorMultiTeam:
executor.end()
+class _ExecutorWithOwnClients(KubernetesExecutor):
+ client_factory = "my_company.build_client"
+ async_client_factory = "my_company.build_async_client"
+
+
+EXECUTOR_CLIENT_FACTORIES = pytest.mark.parametrize(
+ ("executor_class", "client_factory", "async_client_factory"),
+ [
+ pytest.param(KubernetesExecutor, None, None, id="kubernetes-executor"),
+ pytest.param(
+ _ExecutorWithOwnClients,
+ "my_company.build_client",
+ "my_company.build_async_client",
+ id="subclass-with-own-clients",
+ ),
+ ],
+)
+
+
@pytest.mark.skipif(AirflowKubernetesScheduler is None, reason="kubernetes
python package is not installed")
class TestClientFactoryCallSites:
"""
Pin the call sites that must go through the executor's client helpers.
If any of them calls ``get_kube_client`` directly instead, the factory
silently stops applying
- there while every other test stays green. Each site also has to pass its
team, or a team's
- executor would build clients from the global factory instead of its own.
+ there while every other test stays green. Each site also has to pass its
team and the
+ executor's own factory, or the client would be built for the wrong team or
by the wrong
+ factory.
"""
+ @EXECUTOR_CLIENT_FACTORIES
@mock.patch("airflow.providers.cncf.kubernetes.kube_client._get_executor_kube_client",
autospec=True)
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.client")
@mock.patch(
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher",
autospec=True,
)
- def test_start_uses_executor_client(self, mock_watcher, mock_client,
mock_get_executor_kube_client):
- executor = KubernetesExecutor()
+ def test_start_uses_executor_client(
+ self,
+ mock_watcher,
+ mock_client,
+ mock_get_executor_kube_client,
+ executor_class,
+ client_factory,
+ async_client_factory,
+ ):
+ executor = executor_class()
executor.team_name = "team_a"
executor.job_id = 1
try:
executor.start()
+ assert executor.kube_scheduler.async_client_factory ==
async_client_factory
finally:
executor.end()
-
mock_get_executor_kube_client.assert_called_once_with(team_name="team_a")
+ mock_get_executor_kube_client.assert_called_once_with(
+ team_name="team_a", executor_client_factory=client_factory
+ )
+ assert mock_watcher.call_args.kwargs["client_factory"] ==
client_factory
+ @EXECUTOR_CLIENT_FACTORIES
@mock.patch("airflow.providers.cncf.kubernetes.kube_client._get_executor_kube_client",
autospec=True)
- def test_get_streaming_task_log_uses_executor_client(self,
mock_get_executor_kube_client):
+ def test_get_streaming_task_log_uses_executor_client(
+ self, mock_get_executor_kube_client, executor_class, client_factory,
async_client_factory
+ ):
ti = mock.MagicMock(
dag_id="dag",
task_id="task",
@@ -4146,12 +4182,14 @@ class TestClientFactoryCallSites:
hostname="",
executor_config={},
)
- executor = KubernetesExecutor()
+ executor = executor_class()
executor.team_name = "team_a"
executor.get_streaming_task_log(ti=ti, try_number=1)
-
mock_get_executor_kube_client.assert_called_once_with(team_name="team_a")
+ mock_get_executor_kube_client.assert_called_once_with(
+ team_name="team_a", executor_client_factory=client_factory
+ )
@mock.patch.object(KubernetesJobWatcher, "_run",
side_effect=RuntimeError("stop"), autospec=True)
@mock.patch(
@@ -4166,24 +4204,30 @@ class TestClientFactoryCallSites:
scheduler_job_id="1",
kube_config=mock.MagicMock(),
team_name="team_a",
+ client_factory="my_company.build_client",
)
with pytest.raises(RuntimeError, match="stop"):
watcher.run()
-
mock_get_executor_kube_client.assert_called_once_with(team_name="team_a")
+ mock_get_executor_kube_client.assert_called_once_with(
+ team_name="team_a",
executor_client_factory="my_company.build_client"
+ )
@mock.patch(
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher",
autospec=True,
)
def test_make_kube_watcher_passes_the_team_to_the_watcher(self,
mock_watcher):
- """The watcher runs in its own process, so it only learns its team by
being told."""
- scheduler = mock.Mock(team_name="team_a", scheduler_job_id="1")
+ """The watcher runs in its own process, so it only learns its team and
factory by being told."""
+ scheduler = mock.Mock(
+ team_name="team_a", scheduler_job_id="1",
client_factory="my_company.build_client"
+ )
AirflowKubernetesScheduler._make_kube_watcher(scheduler, "ns")
assert mock_watcher.call_args.kwargs["team_name"] == "team_a"
+ assert mock_watcher.call_args.kwargs["client_factory"] ==
"my_company.build_client"
@pytest.mark.asyncio
@mock.patch(
@@ -4191,12 +4235,19 @@ class TestClientFactoryCallSites:
autospec=True,
)
async def test_create_pods_async_uses_executor_client(self,
mock_get_executor_async_kube_client):
- scheduler = mock.Mock(pod_creation_max_concurrency=1,
_async_pod_client=None, team_name="team_a")
+ scheduler = mock.Mock(
+ pod_creation_max_concurrency=1,
+ _async_pod_client=None,
+ team_name="team_a",
+ async_client_factory="my_company.build_async_client",
+ )
scheduler.kube_config.kube_client_request_args = {}
await AirflowKubernetesScheduler._create_pods_async(scheduler, [])
-
mock_get_executor_async_kube_client.assert_awaited_once_with(team_name="team_a")
+ mock_get_executor_async_kube_client.assert_awaited_once_with(
+ team_name="team_a",
executor_client_factory="my_company.build_async_client"
+ )
@pytest.mark.skipif(AirflowKubernetesScheduler is None, reason="kubernetes
python package is not installed")
@@ -4265,6 +4316,48 @@ class TestAsyncClientFactoryValidation:
executor.start()
mock_get_executor_kube_client.assert_not_called()
+ @pytest.mark.parametrize(
+ ("async_client_factory", "expect_raise"),
+ [
+ pytest.param(None, True, id="subclass-without-async-factory"),
+ pytest.param("my_company.build_async_client", False,
id="subclass-with-both-factories"),
+ ],
+ )
+ @conf_vars(
+ {
+ ("kubernetes_executor", "async_pod_creation"): "True",
+ ("kubernetes_executor", "client_factory"):
"my_company.build_config_client",
+ }
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client._get_executor_kube_client",
autospec=True)
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.AirflowKubernetesScheduler",
+ autospec=True,
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor.multiprocessing.Manager",
+ autospec=True,
+ )
+ def test_start_checks_the_subclass_factories_not_the_config(
+ self, mock_manager, mock_scheduler, mock_get_executor_kube_client,
async_client_factory, expect_raise
+ ):
+ """A subclass's own factories replace the config ones, so its pair is
what has to be complete."""
+ executor_class = type(
+ "ExecutorWithOwnClients",
+ (KubernetesExecutor,),
+ {"client_factory": "my_company.build_client",
"async_client_factory": async_client_factory},
+ )
+ executor = executor_class()
+ executor.job_id = 1
+ if expect_raise:
+ with pytest.raises(
+ AirflowConfigException, match="sets client_factory but not
async_client_factory"
+ ):
+ executor.start()
+ else:
+ executor.start()
+ mock_get_executor_kube_client.assert_called_once()
+
@pytest.fixture
def kubernetes_scheduler_factory(mocker):
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_client.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_client.py
index 19909341135..c8c1aab827d 100644
--- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_client.py
+++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_client.py
@@ -33,6 +33,30 @@ from airflow.providers.cncf.kubernetes.kube_client import (
)
from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_2_PLUS
+
+GLOBAL_CONNECTION_SETTINGS = {
+ ("kubernetes_executor", "in_cluster"): "False",
+ ("kubernetes_executor", "config_file"): "/global/kubeconfig",
+ ("kubernetes_executor", "cluster_context"): "global-context",
+ ("kubernetes_executor", "ssl_ca_cert"): "/global/ca.crt",
+ ("kubernetes_executor", "api_client_retry_configuration"): '{"total": 1}',
+}
+TEAM_A_CONNECTION_SETTINGS = {
+ "AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__IN_CLUSTER": "False",
+ "AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__CONFIG_FILE": "/team-a/kubeconfig",
+ "AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__CLUSTER_CONTEXT": "team-a-context",
+ "AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__SSL_CA_CERT": "/team-a/ca.crt",
+ "AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__API_CLIENT_RETRY_CONFIGURATION":
'{"total": 7}',
+}
+
+
[email protected]
+def fresh_default_configuration():
+ default = Configuration.get_default_copy()
+ Configuration.set_default(Configuration())
+ yield
+ Configuration.set_default(default)
@pytest.fixture
@@ -209,3 +233,58 @@ class TestClient:
client_configuration =
mock_in_cluster_loader().load_and_set.call_args.args[0]
assert client_configuration.retries.total == 3
assert client_configuration.retries.backoff_factor == 0.5
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @conf_vars(GLOBAL_CONNECTION_SETTINGS)
+ @mock.patch("airflow.providers.cncf.kubernetes.kube_client.config")
+ def test_team_uses_its_own_connection_settings(self, config, monkeypatch):
+ for key, value in TEAM_A_CONNECTION_SETTINGS.items():
+ monkeypatch.setenv(key, value)
+
+ client = get_kube_client(team_name="team_a")
+
+ config.load_incluster_config.assert_not_called()
+ config.load_kube_config.assert_called_once_with(
+ config_file="/team-a/kubeconfig", context="team-a-context",
client_configuration=mock.ANY
+ )
+ assert client.api_client.configuration.ssl_ca_cert == "/team-a/ca.crt"
+ assert client.api_client.configuration.retries.total == 7
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @conf_vars(GLOBAL_CONNECTION_SETTINGS)
+ @mock.patch("airflow.providers.cncf.kubernetes.kube_client.config")
+ def test_team_does_not_fall_back_to_the_global_connection_settings(self,
config):
+ client = get_kube_client(team_name="team_b")
+
+ config.load_incluster_config.assert_called_once()
+ config.load_kube_config.assert_not_called()
+ assert client.api_client.configuration.ssl_ca_cert is None
+ assert client.api_client.configuration.retries.total != 1
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @conf_vars({("kubernetes_executor", "verify_ssl"): "False"})
+ @mock.patch("airflow.providers.cncf.kubernetes.kube_client.config")
+ def test_team_verify_ssl_does_not_leak_between_clients(
+ self, config, monkeypatch, fresh_default_configuration
+ ):
+ """The process-wide default configuration must not carry one
executor's verify_ssl to another."""
+
monkeypatch.setenv("AIRFLOW__TEAM_B___KUBERNETES_EXECUTOR__VERIFY_SSL", "False")
+
+ team_b_client = get_kube_client(in_cluster=True, team_name="team_b")
+ assert Configuration.get_default_copy().verify_ssl
+ global_client = get_kube_client(in_cluster=True)
+ team_a_client = get_kube_client(in_cluster=True, team_name="team_a")
+
+ assert not team_b_client.api_client.configuration.verify_ssl
+ assert not global_client.api_client.configuration.verify_ssl
+ assert team_a_client.api_client.configuration.verify_ssl
+
+ @pytest.mark.platform("linux")
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @conf_vars({("kubernetes_executor", "enable_tcp_keepalive"): "True"})
+ @mock.patch("airflow.providers.cncf.kubernetes.kube_client.config",
autospec=True)
+ def test_team_client_gets_tcp_keepalive(self, mock_config):
+ client = get_kube_client(in_cluster=False, team_name="team_a")
+
+ pool_options =
client.api_client.rest_client.pool_manager.connection_pool_kw["socket_options"]
+ assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in pool_options
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_kube_client.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_kube_client.py
index de2d61718bc..c195c943b58 100644
--- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_kube_client.py
+++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_kube_client.py
@@ -43,13 +43,21 @@ def build_other_client():
return OTHER_MARKER
+def build_team_client(team_name):
+ return FACTORY_MARKER, team_name
+
+
+def build_client_from_kwargs(**kwargs):
+ return FACTORY_MARKER, kwargs
+
+
class TestGetExecutorKubeClient:
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client",
autospec=True)
def test_without_factory_uses_default_client(self, mock_get_kube_client):
client = _get_executor_kube_client()
assert client is mock_get_kube_client.return_value
- mock_get_kube_client.assert_called_once_with()
+ mock_get_kube_client.assert_called_once_with(team_name=None)
@conf_vars({("kubernetes_executor", "client_factory"):
f"{__name__}.build_fake_client"})
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client",
autospec=True)
@@ -80,7 +88,35 @@ class TestGetExecutorKubeClient:
client = _get_executor_kube_client(team_name="team_b")
assert client is mock_get_kube_client.return_value
- mock_get_kube_client.assert_called_once_with()
+ mock_get_kube_client.assert_called_once_with(team_name="team_b")
+
+ @pytest.mark.parametrize(
+ ("factory", "team_name", "expected"),
+ [
+ pytest.param("build_team_client", None, (FACTORY_MARKER, None),
id="no-team"),
+ pytest.param("build_team_client", "team_a", (FACTORY_MARKER,
"team_a"), id="team"),
+ pytest.param(
+ "build_client_from_kwargs", "team_a", (FACTORY_MARKER,
{"team_name": "team_a"}), id="kwargs"
+ ),
+ ],
+ )
+ def test_factory_is_told_the_team(self, factory, team_name, expected,
monkeypatch):
+ if team_name and not AIRFLOW_V_3_2_PLUS:
+ pytest.skip("team config requires Airflow 3.2+")
+ team_prefix = f"{team_name.upper()}___" if team_name else ""
+ monkeypatch.setenv(
+ f"AIRFLOW__{team_prefix}KUBERNETES_EXECUTOR__CLIENT_FACTORY",
f"{__name__}.{factory}"
+ )
+
+ assert _get_executor_kube_client(team_name=team_name) == expected
+
+ @conf_vars({("kubernetes_executor", "client_factory"):
f"{__name__}.build_other_client"})
+ def test_executor_factory_takes_precedence_over_the_config(self):
+ client = _get_executor_kube_client(
+ team_name="team_a",
executor_client_factory=f"{__name__}.build_team_client"
+ )
+
+ assert client == (FACTORY_MARKER, "team_a")
@conf_vars({("kubernetes_executor", "client_factory"):
"no.such.module.build_client"})
def test_unimportable_factory_raises(self):
@@ -95,7 +131,15 @@ class TestGetExecutorAsyncKubeClient:
client = await _get_executor_async_kube_client()
assert client is mock_get_async_kube_client.return_value
- mock_get_async_kube_client.assert_awaited_once_with()
+ mock_get_async_kube_client.assert_awaited_once_with(team_name=None)
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @pytest.mark.asyncio
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_async_kube_client",
autospec=True)
+ async def test_team_without_factory_gets_its_own_default_client(self,
mock_get_async_kube_client):
+ await _get_executor_async_kube_client(team_name="team_b")
+
+ mock_get_async_kube_client.assert_awaited_once_with(team_name="team_b")
@pytest.mark.asyncio
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_async_kube_client",
autospec=True)
@@ -118,6 +162,24 @@ class TestGetExecutorAsyncKubeClient:
assert client == FACTORY_MARKER
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @pytest.mark.asyncio
+ async def test_async_factory_is_told_the_team(self, monkeypatch):
+ monkeypatch.setenv(
+ "AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__ASYNC_CLIENT_FACTORY",
f"{__name__}.build_team_client"
+ )
+
+ assert await _get_executor_async_kube_client(team_name="team_a") ==
(FACTORY_MARKER, "team_a")
+
+ @pytest.mark.asyncio
+ async def
test_executor_async_factory_takes_precedence_over_the_config(self):
+ with conf_vars({("kubernetes_executor", "async_client_factory"):
f"{__name__}.build_other_client"}):
+ client = await _get_executor_async_kube_client(
+ team_name="team_a",
executor_client_factory=f"{__name__}.build_team_client"
+ )
+
+ assert client == (FACTORY_MARKER, "team_a")
+
class TestGetAsyncKubeClient:
@pytest.mark.asyncio
@@ -131,3 +193,59 @@ class TestGetAsyncKubeClient:
assert isinstance(api.api_client, _TimeoutAsyncK8sApiClient)
mock_load_incluster.assert_called_once()
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team config requires
Airflow 3.2+")
+ @pytest.mark.parametrize(
+ ("team_name", "kube_config_kwargs", "ssl_ca_cert", "verify_ssl"),
+ [
+ pytest.param(
+ "team_a",
+ {"config_file": "/team-a/kubeconfig", "context":
"team-a-context"},
+ "/team-a/ca.crt",
+ False,
+ id="team-settings",
+ ),
+ pytest.param("team_b", None, None, True,
id="no-fallback-to-global"),
+ ],
+ )
+ @pytest.mark.asyncio
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client._TimeoutAsyncK8sApiClient",
autospec=True)
+ @mock.patch("airflow.providers.cncf.kubernetes.kube_client.async_config")
+ async def test_team_uses_its_own_connection_settings(
+ self,
+ mock_async_config,
+ mock_api_client,
+ team_name,
+ kube_config_kwargs,
+ ssl_ca_cert,
+ verify_ssl,
+ monkeypatch,
+ ):
+ mock_async_config.load_kube_config = mock.AsyncMock()
+
monkeypatch.setenv("AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__IN_CLUSTER", "False")
+
monkeypatch.setenv("AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__CONFIG_FILE",
"/team-a/kubeconfig")
+
monkeypatch.setenv("AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__CLUSTER_CONTEXT",
"team-a-context")
+
monkeypatch.setenv("AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__SSL_CA_CERT",
"/team-a/ca.crt")
+
monkeypatch.setenv("AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__VERIFY_SSL", "False")
+
+ global_settings = {
+ ("kubernetes_executor", "in_cluster"): "False",
+ ("kubernetes_executor", "config_file"): "/global/kubeconfig",
+ ("kubernetes_executor", "cluster_context"): "global-context",
+ ("kubernetes_executor", "ssl_ca_cert"): "/global/ca.crt",
+ ("kubernetes_executor", "verify_ssl"): "False",
+ }
+ with conf_vars(global_settings):
+ await get_async_kube_client(team_name=team_name)
+
+ if kube_config_kwargs:
+ mock_async_config.load_incluster_config.assert_not_called()
+ mock_async_config.load_kube_config.assert_awaited_once_with(
+ **kube_config_kwargs, client_configuration=mock.ANY
+ )
+ else:
+ mock_async_config.load_incluster_config.assert_called_once()
+ mock_async_config.load_kube_config.assert_not_awaited()
+ configuration = mock_api_client.call_args.args[0]
+ assert configuration.ssl_ca_cert == ssl_ca_cert
+ assert configuration.verify_ssl is verify_ssl