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

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


The following commit(s) were added to refs/heads/main by this push:
     new 7fc063d5964 Reduce SSL setup overhead in async Kubernetes triggers 
(#73399)
7fc063d5964 is described below

commit 7fc063d5964dd47a47efcfaf9b18299369b8194e
Author: Xavi Vega <[email protected]>
AuthorDate: Sun Nov 8 14:48:30 2026 +0100

    Reduce SSL setup overhead in async Kubernetes triggers (#73399)
---
 providers/cncf/kubernetes/docs/changelog.rst       |   7 +
 .../providers/cncf/kubernetes/hooks/kubernetes.py  |  52 ++--
 .../providers/cncf/kubernetes/triggers/job.py      |   5 +
 .../providers/cncf/kubernetes/triggers/pod.py      |  80 +++---
 .../unit/cncf/kubernetes/hooks/test_kubernetes.py  | 308 ++++++++++++---------
 .../unit/cncf/kubernetes/triggers/test_job.py      |   8 +
 .../unit/cncf/kubernetes/triggers/test_pod.py      |  43 ++-
 7 files changed, 321 insertions(+), 182 deletions(-)

diff --git a/providers/cncf/kubernetes/docs/changelog.rst 
b/providers/cncf/kubernetes/docs/changelog.rst
index df8c1612f2c..a3c0c19545a 100644
--- a/providers/cncf/kubernetes/docs/changelog.rst
+++ b/providers/cncf/kubernetes/docs/changelog.rst
@@ -30,6 +30,13 @@ Changelog
 11.0.0
 ......
 
+.. note::
+
+    ``AsyncKubernetesHook`` now reuses its API client for non-exec 
authentication.
+    Direct hook users must call ``await hook.close()`` when finished to 
release the
+    connection pool and SSL context. Pod and job triggers close the hook 
during cleanup.
+    TLS certificate material is retained until the client is closed and 
recreated.
+
 .. note::
   ``@task.kubernetes_cmd`` now renders only the command returned by the 
decorated function inside
   ``execute()``. Its other template fields are rendered once before 
``execute()`` and are no
diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
index 1216fd74429..07f82372cf0 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
@@ -828,7 +828,13 @@ def _split_log_bytes(raw_bytes: bytes) -> list[str]:
 
 
 class AsyncKubernetesHook(KubernetesHook):
-    """Hook to use Kubernetes SDK asynchronously."""
+    """
+    Hook to use Kubernetes SDK asynchronously.
+
+    Non-exec authentication reuses an API client across calls. Call ``await 
hook.close()``
+    when finished to release its connection pool and SSL context. Pod and job 
triggers
+    do this in their cleanup methods.
+    """
 
     def __init__(
         self,
@@ -843,12 +849,13 @@ class AsyncKubernetesHook(KubernetesHook):
         self.config_dict = config_dict
         self._extras: dict | None = connection_extras
         self._event_polling_fallback = False
-        self._config_loaded = False
+        self._client_cacheable = False
         # Override the parent's sync-typed client_configuration with the async 
type.
         self.client_configuration: async_client.Configuration | None = 
client_configuration
         # Cached result of exec-auth detection. None means not yet detected.
         # This is to optimise and not calling _uses_exec_auth repeatedly on 
every _load_config() call.
         self._is_exec_auth: bool | None = None
+        self._cached_kube_client: async_client.ApiClient | None = None
 
     def _uses_exec_auth(self, kubeconfig_data: dict, context: str | None = 
None) -> bool:
         """
@@ -920,16 +927,12 @@ class AsyncKubernetesHook(KubernetesHook):
         """
         Load Kubernetes configuration.
 
-        For static auth (token, certificate), configuration is loaded once per 
hook instance
-        and cached. For exec-based auth (EKS, GKE), the config is reloaded on 
every call so
-        that short-lived tokens are always refreshed.
+        Reload credentials on every access while retaining the client for 
non-exec auth.
+        Exec-based auth continues to use a new client for each access.
         """
         if self.client_configuration is None:
             self.client_configuration = async_client.Configuration()
 
-        if self._config_loaded:
-            return
-
         in_cluster = self._coalesce_param(self.in_cluster, await 
self._get_field("in_cluster"))
         cluster_context = self._coalesce_param(self.cluster_context, await 
self._get_field("cluster_context"))
         kubeconfig_path = await self._get_field("kube_config_path")
@@ -950,7 +953,7 @@ class AsyncKubernetesHook(KubernetesHook):
             self.log.debug(LOADING_KUBE_CONFIG_FILE_RESOURCE.format("within a 
pod"))
             
async_config.load_incluster_config(client_configuration=self.client_configuration)
             self._is_in_cluster = True
-            self._config_loaded = True
+            self._client_cacheable = True
             return
 
         self._is_in_cluster = False
@@ -967,7 +970,7 @@ class AsyncKubernetesHook(KubernetesHook):
                 self._is_exec_auth = self._uses_exec_auth(self.config_dict, 
context=cluster_context)
 
             if not self._is_exec_auth:
-                self._config_loaded = True
+                self._client_cacheable = True
 
             return
         if kubeconfig_path is not None:
@@ -985,7 +988,7 @@ class AsyncKubernetesHook(KubernetesHook):
                 )
 
             if not self._is_exec_auth:
-                self._config_loaded = True
+                self._client_cacheable = True
 
             return
         if kubeconfig is not None:
@@ -1022,7 +1025,7 @@ class AsyncKubernetesHook(KubernetesHook):
                     )
 
                 if not self._is_exec_auth:
-                    self._config_loaded = True
+                    self._client_cacheable = True
 
             return
         self.log.debug(LOADING_KUBE_CONFIG_FILE_RESOURCE.format("default 
configuration file"))
@@ -1036,7 +1039,7 @@ class AsyncKubernetesHook(KubernetesHook):
             self._is_exec_auth = await 
self._default_kubeconfig_uses_exec_auth(cluster_context)
 
         if not self._is_exec_auth:
-            self._config_loaded = True
+            self._client_cacheable = True
 
     async def get_conn_extras(self) -> dict:
         if self._extras is None:
@@ -1067,14 +1070,27 @@ class AsyncKubernetesHook(KubernetesHook):
 
     @contextlib.asynccontextmanager
     async def get_conn(self) -> AsyncGenerator[async_client.ApiClient, None]:
-        kube_client = None
+        await self._load_config()
+        if self._client_cacheable:
+            # Reuse one client per hook: each construction runs 
ssl.create_default_context()
+            # on the event loop and opens a new connection pool. Owners 
release it via
+            # close(); triggers do so in cleanup().
+            if self._cached_kube_client is None:
+                self._cached_kube_client = 
_TimeoutAsyncK8sApiClient(configuration=self.client_configuration)
+            yield self._cached_kube_client
+            return
+        # Exec-based auth is not cacheable: build and close a client per call.
+        kube_client = 
_TimeoutAsyncK8sApiClient(configuration=self.client_configuration)
         try:
-            await self._load_config()
-            kube_client = 
_TimeoutAsyncK8sApiClient(configuration=self.client_configuration)
             yield kube_client
         finally:
-            if kube_client is not None:
-                await kube_client.close()
+            await kube_client.close()
+
+    async def close(self) -> None:
+        """Release the cached API client, if any. Safe to call multiple 
times."""
+        cached, self._cached_kube_client = self._cached_kube_client, None
+        if cached is not None:
+            await cached.close()
 
     @generic_api_retry
     async def get_pod(self, name: str, namespace: str) -> V1Pod:
diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/job.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/job.py
index 22b60be3a78..6339dc3c48f 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/job.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/job.py
@@ -176,3 +176,8 @@ class KubernetesJobTrigger(BaseTrigger):
             cluster_context=self.cluster_context,
         )
         return PodManager(kube_client=sync_hook.core_v1_client)
+
+    async def cleanup(self) -> None:
+        """Release the hook's cached API client when the trigger exits."""
+        await super().cleanup()
+        await self.hook.close()
diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/pod.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/pod.py
index 18a465a0ecf..b27e0ac48fd 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/pod.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/pod.py
@@ -545,46 +545,54 @@ class KubernetesPodTrigger(BaseTrigger):
         On Airflow 3.3.0+ pod deletion on user kill is handled in 
``on_kill()`` only; this avoids
         deleting pods on triggerer restart. On older Airflow versions, 
``cleanup()`` still uses
         ``safe_to_cancel()`` because ``on_kill()`` is not wired for user kills.
-        """
-        # TODO: Remove this Airflow < 3.3 cleanup branch (early return, 
``safe_to_cancel``, and
-        # related tests) once the minimum Airflow version supported by this 
provider is >= 3.3.
-        # In Airflow 3.3+, ``BaseTrigger.on_kill()`` handles user-initiated 
kills; keeping the
-        # legacy path for backward compatibility with older Airflow versions.
-        if AIRFLOW_V_3_3_PLUS:
-            return
-
-        if self._fired_event:
-            self.log.debug("Skipping cleanup since an event has already been 
fired.")
-            return
-
-        if self.on_kill_action == OnKillAction.KEEP_POD:
-            self.log.debug("Skipping cleanup since on_kill_action is set to 
%r.", self.on_kill_action.value)
-            return
 
+        Always releases the hook's cached API client, since the triggerer 
invokes ``cleanup()``
+        after the trigger finishes for any reason (event fired, cancelled, or 
killed).
+        """
         try:
-            safe = await self.safe_to_cancel()
-        except Exception:
-            self.log.warning(
-                "Could not determine task state during cleanup; skipping pod 
deletion to be safe.",
-                exc_info=True,
-            )
-            return
+            # TODO: Remove this Airflow < 3.3 cleanup branch (early return, 
``safe_to_cancel``, and
+            # related tests) once the minimum Airflow version supported by 
this provider is >= 3.3.
+            # In Airflow 3.3+, ``BaseTrigger.on_kill()`` handles 
user-initiated kills; keeping the
+            # legacy path for backward compatibility with older Airflow 
versions.
+            if AIRFLOW_V_3_3_PLUS:
+                return
+
+            if self._fired_event:
+                self.log.debug("Skipping cleanup since an event has already 
been fired.")
+                return
+
+            if self.on_kill_action == OnKillAction.KEEP_POD:
+                self.log.debug(
+                    "Skipping cleanup since on_kill_action is set to %r.", 
self.on_kill_action.value
+                )
+                return
 
-        if not safe:
-            self.log.debug(
-                "Skipping cleanup since the task is still in deferred state 
(likely a triggerer restart)."
-            )
-            return
+            try:
+                safe = await self.safe_to_cancel()
+            except Exception:
+                self.log.warning(
+                    "Could not determine task state during cleanup; skipping 
pod deletion to be safe.",
+                    exc_info=True,
+                )
+                return
 
-        self.log.info("Deleting pod %s in namespace %s.", self.pod_name, 
self.pod_namespace)
-        try:
-            await self.hook.delete_pod(
-                name=self.pod_name,
-                namespace=self.pod_namespace,
-                grace_period_seconds=self.termination_grace_period,
-            )
-        except Exception:
-            self.log.exception("Unexpected error while deleting pod %s", 
self.pod_name)
+            if not safe:
+                self.log.debug(
+                    "Skipping cleanup since the task is still in deferred 
state (likely a triggerer restart)."
+                )
+                return
+
+            self.log.info("Deleting pod %s in namespace %s.", self.pod_name, 
self.pod_namespace)
+            try:
+                await self.hook.delete_pod(
+                    name=self.pod_name,
+                    namespace=self.pod_namespace,
+                    grace_period_seconds=self.termination_grace_period,
+                )
+            except Exception:
+                self.log.exception("Unexpected error while deleting pod %s", 
self.pod_name)
+        finally:
+            await self.hook.close()
 
     def define_container_state(self, pod: V1Pod) -> ContainerState:
         if pod.status is None or pod.status.container_statuses is None:
diff --git 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py
index c8df520d0ee..df74354f83b 100644
--- 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py
+++ 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py
@@ -18,9 +18,11 @@
 from __future__ import annotations
 
 import asyncio
+import inspect
 import json
 import os
 import socket
+import ssl
 import tempfile
 from asyncio import Future
 from unittest import mock
@@ -1060,6 +1062,35 @@ class TestAsyncKubernetesHook:
     KUBE_API = "kubernetes_asyncio.client.api.core_v1_api.CoreV1Api.{}"
     KUBE_BATCH_API = "kubernetes_asyncio.client.api.batch_v1_api.BatchV1Api.{}"
     KUBE_ASYNC_HOOK = HOOK_MODULE + ".AsyncKubernetesHook.{}"
+    SSL_CREATE = "kubernetes_asyncio.client.rest.ssl.create_default_context"
+
+    STATIC_AUTH_CONFIG_DICT = {
+        "apiVersion": "v1",
+        "kind": "Config",
+        "clusters": [{"name": "test", "cluster": {"server": 
"https://k8s.invalid:6443"}}],
+        "users": [{"name": "test", "user": {"token": "static-token"}}],
+        "contexts": [{"name": "test", "context": {"cluster": "test", "user": 
"test"}}],
+        "current-context": "test",
+    }
+
+    EXEC_AUTH_CONFIG_DICT = {
+        "apiVersion": "v1",
+        "kind": "Config",
+        "clusters": [{"name": "test", "cluster": {"server": 
"https://k8s.invalid:6443"}}],
+        "users": [
+            {
+                "name": "test",
+                "user": {
+                    "exec": {
+                        "apiVersion": "client.authentication.k8s.io/v1beta1",
+                        "command": "aws",
+                    }
+                },
+            }
+        ],
+        "contexts": [{"name": "test", "context": {"cluster": "test", "user": 
"test"}}],
+        "current-context": "test",
+    }
 
     @staticmethod
     def mock_await_result(return_value):
@@ -1087,6 +1118,17 @@ class TestAsyncKubernetesHook:
         yield
         clear_test_connections()
 
+    @pytest_asyncio.fixture
+    async def hook(self):
+        hook = AsyncKubernetesHook(
+            conn_id=None,
+            in_cluster=False,
+            config_file=None,
+            cluster_context=None,
+        )
+        yield hook
+        await hook.close()
+
     @pytest.mark.asyncio
     @mock.patch(INCLUSTER_CONFIG_LOADER)
     @mock.patch(KUBE_LOADER_CONFIG)
@@ -1291,7 +1333,7 @@ class TestAsyncKubernetesHook:
         }
         hook = AsyncKubernetesHook(conn_id=None, in_cluster=False, 
config_dict=exec_config)
         await hook._load_config()
-        assert hook._config_loaded is False
+        assert hook._client_cacheable is False
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize(
@@ -1330,7 +1372,7 @@ class TestAsyncKubernetesHook:
             side_effect=lambda field: str(kubeconfig_file) if field == 
"kube_config_path" else None
         )
         await hook._load_config()
-        assert hook._config_loaded is expected_cached
+        assert hook._client_cacheable is expected_cached
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize(
@@ -1380,7 +1422,7 @@ class TestAsyncKubernetesHook:
                 side_effect=lambda field: kubeconfig if field == "kube_config" 
else None
             )
         await hook._load_config()
-        assert hook._config_loaded is expected_cached
+        assert hook._client_cacheable is expected_cached
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize(
@@ -1438,7 +1480,7 @@ class TestAsyncKubernetesHook:
             await hook._load_config()
 
         mock_load_file.assert_awaited_once()
-        assert hook._config_loaded is expected_cached
+        assert hook._client_cacheable is expected_cached
 
     @pytest.mark.asyncio
     
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config")
@@ -1459,7 +1501,7 @@ class TestAsyncKubernetesHook:
         with 
mock.patch(f"{HOOK_MODULE}.async_config.KUBE_CONFIG_DEFAULT_LOCATION", 
default_location):
             await hook._load_config()
 
-        assert hook._config_loaded is True
+        assert hook._client_cacheable is True
 
     @pytest.mark.asyncio
     
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config")
@@ -1481,12 +1523,12 @@ class TestAsyncKubernetesHook:
         with 
mock.patch(f"{HOOK_MODULE}.async_config.KUBE_CONFIG_DEFAULT_LOCATION", 
"~/.kube/config"):
             await hook._load_config()
 
-        assert hook._config_loaded is True
+        assert hook._client_cacheable is True
 
     @pytest.mark.asyncio
     @mock.patch(KUBE_API.format("list_namespaced_event"))
     async def test_async_get_pod_events_with_resource_version(
-        self, mock_list_namespaced_event, kube_config_loader
+        self, mock_list_namespaced_event, kube_config_loader, hook
     ):
         """Test getting pod events with resource_version parameter."""
         mock_event = mock.Mock()
@@ -1495,13 +1537,6 @@ class TestAsyncKubernetesHook:
         mock_events.items = [mock_event]
         mock_list_namespaced_event.return_value = 
self.mock_await_result(mock_events)
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         result = await hook.get_pod_events(name=POD_NAME, namespace=NAMESPACE, 
resource_version="12345")
 
         mock_list_namespaced_event.assert_called_once_with(
@@ -1515,7 +1550,7 @@ class TestAsyncKubernetesHook:
     @pytest.mark.asyncio
     @mock.patch(KUBE_API.format("list_namespaced_event"))
     async def test_async_get_pod_events_without_resource_version(
-        self, mock_list_namespaced_event, kube_config_loader
+        self, mock_list_namespaced_event, kube_config_loader, hook
     ):
         """Test getting pod events without resource_version parameter."""
         mock_event = mock.Mock()
@@ -1524,13 +1559,6 @@ class TestAsyncKubernetesHook:
         mock_events.items = [mock_event]
         mock_list_namespaced_event.return_value = 
self.mock_await_result(mock_events)
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         result = await hook.get_pod_events(name=POD_NAME, namespace=NAMESPACE)
 
         mock_list_namespaced_event.assert_called_once_with(
@@ -1544,7 +1572,7 @@ class TestAsyncKubernetesHook:
     @mock.patch("kubernetes_asyncio.watch.Watch")
     @mock.patch(KUBE_API.format("list_namespaced_event"))
     async def test_async_watch_pod_events(
-        self, mock_list_namespaced_event, mock_watch_class, mock_get_pod, 
kube_config_loader
+        self, mock_list_namespaced_event, mock_watch_class, mock_get_pod, 
kube_config_loader, hook
     ):
         """Test watching pod events using Watch API."""
         mock_event1 = mock.Mock()
@@ -1564,13 +1592,6 @@ class TestAsyncKubernetesHook:
         mock_pod.status.phase = "Running"
         mock_get_pod.return_value = mock_pod
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         events = []
         async_event_generator = hook.watch_pod_events(
             name=POD_NAME, namespace=NAMESPACE, resource_version="12345", 
timeout_seconds=30
@@ -1593,7 +1614,7 @@ class TestAsyncKubernetesHook:
     @mock.patch("kubernetes_asyncio.watch.Watch")
     @mock.patch(KUBE_API.format("list_namespaced_event"))
     async def test_async_watch_pod_events_permission_error_fallback(
-        self, mock_list_namespaced_event, mock_watch_class, mock_get_pod, 
kube_config_loader
+        self, mock_list_namespaced_event, mock_watch_class, mock_get_pod, 
kube_config_loader, hook
     ):
         """Test fallback to polling when watch permission is denied."""
 
@@ -1617,13 +1638,6 @@ class TestAsyncKubernetesHook:
         mock_pod.status.phase = "Running"
         mock_get_pod.return_value = mock_pod
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         events = []
         async for event in hook.watch_pod_events(
             name=POD_NAME, namespace=NAMESPACE, resource_version="12345", 
timeout_seconds=30
@@ -1639,7 +1653,7 @@ class TestAsyncKubernetesHook:
     @mock.patch(KUBE_API.format("list_namespaced_event"))
     @mock.patch("asyncio.sleep", new_callable=mock.AsyncMock)
     async def test_async_watch_pod_events_polling_fallback(
-        self, mock_sleep, mock_list_namespaced_event, kube_config_loader
+        self, mock_sleep, mock_list_namespaced_event, kube_config_loader, hook
     ):
         """Test polling fallback method."""
         mock_event1 = mock.Mock()
@@ -1650,13 +1664,6 @@ class TestAsyncKubernetesHook:
         mock_events.items = [mock_event1, mock_event2]
         mock_list_namespaced_event.return_value = 
self.mock_await_result(mock_events)
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         events = []
         async for event in hook.watch_pod_events_polling_fallback(
             name=POD_NAME, namespace=NAMESPACE, resource_version="12345", 
interval=10
@@ -1678,16 +1685,9 @@ class TestAsyncKubernetesHook:
     @mock.patch("kubernetes_asyncio.watch.Watch")
     @mock.patch(KUBE_API.format("list_namespaced_event"))
     async def test_async_watch_pod_events_uses_fallback_if_already_set(
-        self, mock_list_namespaced_event, mock_watch_class, kube_config_loader
+        self, mock_list_namespaced_event, mock_watch_class, 
kube_config_loader, hook
     ):
         """Test that watch uses polling fallback if flag is already set."""
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         hook._event_polling_fallback = True
 
         mock_event = mock.Mock()
@@ -1715,6 +1715,7 @@ class TestAsyncKubernetesHook:
         mock_get_pod,
         mock_watch_class,
         kube_config_loader,
+        hook,
     ):
         """
         The watch should reconnect when the watch stream ends (e.g. timeout)
@@ -1748,13 +1749,6 @@ class TestAsyncKubernetesHook:
 
         mock_watch_class.side_effect = [watch_instance1, watch_instance2]
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         events = []
 
         async for event in hook.watch_pod_events(
@@ -1775,6 +1769,7 @@ class TestAsyncKubernetesHook:
         mock_get_pod,
         mock_watch_class,
         kube_config_loader,
+        hook,
     ):
         """
         When the Kubernetes API reports resourceVersion too old (410),
@@ -1807,13 +1802,6 @@ class TestAsyncKubernetesHook:
 
         mock_watch_class.side_effect = [watch_instance1, watch_instance2]
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         events = []
         async for event in hook.watch_pod_events(
             name=POD_NAME, namespace=NAMESPACE, resource_version="1", 
timeout_seconds=1
@@ -1832,6 +1820,7 @@ class TestAsyncKubernetesHook:
         mock_get_pod,
         mock_watch_class,
         kube_config_loader,
+        hook,
     ):
         """
         Verify that watch_pod_events stops cleanly when the pod no longer 
exists (404).
@@ -1843,13 +1832,6 @@ class TestAsyncKubernetesHook:
         mock_watch = mock.Mock()
         mock_watch_class.return_value = mock_watch
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         events = []
         async for event in hook.watch_pod_events(
             name=POD_NAME,
@@ -1870,6 +1852,7 @@ class TestAsyncKubernetesHook:
         mock_watch_class,
         pod_status,
         kube_config_loader,
+        hook,
     ):
         """
         Verify that watch_pod_events stops immediately when the pod
@@ -1883,13 +1866,6 @@ class TestAsyncKubernetesHook:
         mock_pod.status.phase = pod_status
         mock_get_pod.return_value = mock_pod
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         events = []
         async for event in hook.watch_pod_events(
             name=POD_NAME,
@@ -1902,15 +1878,9 @@ class TestAsyncKubernetesHook:
 
     @pytest.mark.asyncio
     @mock.patch(KUBE_API.format("read_namespaced_pod"))
-    async def test_get_pod(self, lib_method, kube_config_loader):
+    async def test_get_pod(self, lib_method, kube_config_loader, hook):
         lib_method.return_value = self.mock_await_result(None)
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
         await hook.get_pod(
             name=POD_NAME,
             namespace=NAMESPACE,
@@ -1924,15 +1894,9 @@ class TestAsyncKubernetesHook:
 
     @pytest.mark.asyncio
     @mock.patch(KUBE_API.format("delete_namespaced_pod"))
-    async def test_delete_pod(self, lib_method, kube_config_loader):
+    async def test_delete_pod(self, lib_method, kube_config_loader, hook):
         lib_method.return_value = self.mock_await_result(None)
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
         await hook.delete_pod(
             name=POD_NAME,
             namespace=NAMESPACE,
@@ -1942,18 +1906,11 @@ class TestAsyncKubernetesHook:
 
     @pytest.mark.asyncio
     @mock.patch(KUBE_API.format("read_namespaced_pod_log"))
-    async def test_read_logs(self, lib_method, kube_config_loader):
+    async def test_read_logs(self, lib_method, kube_config_loader, hook):
         mock_raw_resp = mock.AsyncMock()
         mock_raw_resp.read = mock.AsyncMock(return_value=b"2023-01-11 Some 
string logs...")
         lib_method.return_value = self.mock_await_result(mock_raw_resp)
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         logs = await hook.read_logs(
             name=POD_NAME,
             namespace=NAMESPACE,
@@ -1976,7 +1933,7 @@ class TestAsyncKubernetesHook:
 
     @pytest.mark.asyncio
     @mock.patch(KUBE_API.format("read_namespaced_pod_log"))
-    async def test_read_logs_handles_non_utf8_bytes(self, lib_method, 
kube_config_loader):
+    async def test_read_logs_handles_non_utf8_bytes(self, lib_method, 
kube_config_loader, hook):
         """Non-UTF-8 bytes in pod logs are replaced instead of raising 
UnicodeDecodeError."""
         raw_bytes = b"2023-01-11 valid line\n2023-01-11 broken \x80\x81 bytes"
 
@@ -1984,13 +1941,6 @@ class TestAsyncKubernetesHook:
         mock_raw_resp.read = mock.AsyncMock(return_value=raw_bytes)
         lib_method.return_value = self.mock_await_result(mock_raw_resp)
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         logs = await hook.read_logs(
             name=POD_NAME,
             namespace=NAMESPACE,
@@ -2007,7 +1957,9 @@ class TestAsyncKubernetesHook:
     @pytest.mark.asyncio
     @mock.patch("asyncio.to_thread", new_callable=mock.AsyncMock)
     @mock.patch(KUBE_API.format("read_namespaced_pod_log"))
-    async def test_read_logs_decodes_off_the_event_loop(self, lib_method, 
mock_to_thread, kube_config_loader):
+    async def test_read_logs_decodes_off_the_event_loop(
+        self, lib_method, mock_to_thread, kube_config_loader, hook
+    ):
         """The CPU-bound decode/splitlines is offloaded to a worker thread, 
not run on the loop."""
         raw_bytes = b"2023-01-11 Some string logs..."
         mock_raw_resp = mock.AsyncMock()
@@ -2015,13 +1967,6 @@ class TestAsyncKubernetesHook:
         lib_method.return_value = self.mock_await_result(mock_raw_resp)
         mock_to_thread.return_value = ["decoded line"]
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
-
         logs = await hook.read_logs(name=POD_NAME, namespace=NAMESPACE, 
container_name=CONTAINER_NAME)
 
         assert logs == ["decoded line"]
@@ -2085,15 +2030,9 @@ class TestAsyncKubernetesHook:
 
     @pytest.mark.asyncio
     @mock.patch(KUBE_BATCH_API.format("read_namespaced_job_status"))
-    async def test_get_job_status(self, lib_method, kube_config_loader):
+    async def test_get_job_status(self, lib_method, kube_config_loader, hook):
         lib_method.return_value = self.mock_await_result(None)
 
-        hook = AsyncKubernetesHook(
-            conn_id=None,
-            in_cluster=False,
-            config_file=None,
-            cluster_context=None,
-        )
         await hook.get_job_status(
             name=JOB_NAME,
             namespace=NAMESPACE,
@@ -2307,3 +2246,118 @@ class TestAsyncKubernetesHook:
         assert hook_a.client_configuration is not hook_b.client_configuration
         assert hook_a.client_configuration.host == 
"https://cluster-a.example.com";
         assert hook_b.client_configuration.host == 
"https://cluster-b.example.com";
+
+    @pytest.mark.asyncio
+    @mock.patch(SSL_CREATE, autospec=True, 
side_effect=ssl.create_default_context)
+    async def test_static_auth_reuses_client_and_ssl_context(self, 
mock_create_ssl, hook):
+        hook.config_dict = self.STATIC_AUTH_CONFIG_DICT
+        async with hook.get_conn() as first_client:
+            pass
+
+        for _ in range(2):
+            async with hook.get_conn() as client:
+                assert client is first_client
+
+        assert not first_client.rest_client.pool_manager.closed
+        mock_create_ssl.assert_called_once()
+
+    @staticmethod
+    async def _bearer_auth_headers(kube_client) -> dict[str, str]:
+        headers: dict[str, str] = {}
+        # update_params_for_auth is sync in kubernetes_asyncio<33 and async 
from 33 on
+        result = kube_client.update_params_for_auth(headers, [], 
["BearerToken"])
+        if inspect.isawaitable(result):
+            await result
+        return headers
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("source", ["file", "dict"])
+    @mock.patch(SSL_CREATE, autospec=True, 
side_effect=ssl.create_default_context)
+    async def test_cached_client_reloads_rotated_kubeconfig_token(
+        self, mock_create_ssl, hook, tmp_path, source
+    ):
+        kubeconfig = tmp_path / "config"
+        config_data = json.loads(json.dumps(self.STATIC_AUTH_CONFIG_DICT))
+        if source == "file":
+            kubeconfig.write_text(yaml.safe_dump(config_data))
+            hook._get_field = mock.AsyncMock(
+                side_effect=lambda field: str(kubeconfig) if field == 
"kube_config_path" else None
+            )
+        else:
+            hook.config_dict = config_data
+
+        async with hook.get_conn() as first_client:
+            first_headers = await self._bearer_auth_headers(first_client)
+
+        config_data["users"][0]["user"]["token"] = "rotated-token"
+        if source == "file":
+            kubeconfig.write_text(yaml.safe_dump(config_data))
+
+        async with hook.get_conn() as second_client:
+            second_headers = await self._bearer_auth_headers(second_client)
+
+        assert first_client is second_client
+        assert first_headers["authorization"] == "Bearer static-token"
+        assert second_headers["authorization"] == "Bearer rotated-token"
+        mock_create_ssl.assert_called_once()
+
+    @pytest.mark.asyncio
+    @mock.patch(SSL_CREATE, autospec=True, 
side_effect=ssl.create_default_context)
+    
@mock.patch("kubernetes_asyncio.config.kube_config.google_auth_credentials", 
autospec=True)
+    async def test_cached_client_refreshes_expired_auth_provider_token(
+        self, mock_google_credentials, mock_create_ssl, hook, time_machine
+    ):
+        time_machine.move_to("2026-01-01T00:00:00Z")
+        config_data = json.loads(json.dumps(self.STATIC_AUTH_CONFIG_DICT))
+        provider_config = {"access-token": "initial-token", "expiry": 
"2026-01-01T02:00:00Z"}
+        config_data["users"][0]["user"] = {"auth-provider": {"name": "gcp", 
"config": provider_config}}
+        hook.config_dict = config_data
+        mock_google_credentials.return_value = mock.Mock(
+            token="refreshed-token", expiry="2026-01-01T04:00:00Z"
+        )
+
+        async with hook.get_conn() as first_client:
+            first_headers = await self._bearer_auth_headers(first_client)
+
+        time_machine.move_to("2026-01-01T03:00:00Z")
+        async with hook.get_conn() as second_client:
+            second_headers = await self._bearer_auth_headers(second_client)
+
+        assert first_client is second_client
+        assert first_headers["authorization"] == "Bearer initial-token"
+        assert second_headers["authorization"] == "Bearer refreshed-token"
+        mock_google_credentials.assert_awaited_once()
+        mock_create_ssl.assert_called_once()
+
+    @pytest.mark.asyncio
+    @mock.patch(SSL_CREATE, autospec=True, 
side_effect=ssl.create_default_context)
+    @mock.patch(HOOK_MODULE + ".async_config.load_kube_config_from_dict", 
autospec=True)
+    async def test_exec_auth_keeps_per_call_clients(self, mock_load_config, 
mock_create_ssl, hook):
+        hook.config_dict = self.EXEC_AUTH_CONFIG_DICT
+        clients = []
+        for _ in range(3):
+            async with hook.get_conn() as client:
+                clients.append(client)
+            assert client.rest_client.pool_manager.closed
+
+        assert len({id(client) for client in clients}) == 3
+        assert mock_create_ssl.call_count == 3
+        assert mock_load_config.await_count == 3
+        assert hook._cached_kube_client is None
+        assert not hook._client_cacheable
+
+    @pytest.mark.asyncio
+    async def test_close_releases_cached_client_and_is_idempotent(self, hook):
+        hook.config_dict = self.STATIC_AUTH_CONFIG_DICT
+        async with hook.get_conn() as cached:
+            pass
+
+        await hook.close()
+        assert hook._cached_kube_client is None
+        assert cached.rest_client.pool_manager.closed
+
+        await hook.close()
+
+        async with hook.get_conn() as reopened:
+            assert reopened is not cached
+            assert not reopened.rest_client.pool_manager.closed
diff --git 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_job.py 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_job.py
index 37aeb8c1d9f..9b37fc1c5e4 100644
--- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_job.py
+++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_job.py
@@ -27,6 +27,7 @@ from airflow.triggers.base import TriggerEvent
 
 TRIGGER_PATH = "airflow.providers.cncf.kubernetes.triggers.job.{}"
 TRIGGER_CLASS = TRIGGER_PATH.format("KubernetesJobTrigger")
+HOOK_PATH = 
"airflow.providers.cncf.kubernetes.hooks.kubernetes.AsyncKubernetesHook"
 JOB_NAME = "test-job-name"
 POD_NAME = "test-pod-name"
 CONTAINER_NAME = "test-container-name"
@@ -210,3 +211,10 @@ class TestKubernetesJobTrigger:
             cluster_context=CLUSTER_CONTEXT,
         )
         assert hook_actual == hook_expected
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{HOOK_PATH}.close", autospec=True)
+    async def test_cleanup_closes_hook(self, mock_close, trigger):
+        await trigger.cleanup()
+
+        mock_close.assert_awaited_once_with(trigger.hook)
diff --git 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_pod.py 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_pod.py
index 2dcbc426da9..7409587a32e 100644
--- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_pod.py
+++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_pod.py
@@ -32,6 +32,7 @@ from kubernetes.client import models as k8s
 from pendulum import DateTime
 from sqlalchemy.orm.session import Session
 
+from airflow.providers.cncf.kubernetes.hooks.kubernetes import 
AsyncKubernetesHook
 from airflow.providers.cncf.kubernetes.triggers.pod import ContainerState, 
KubernetesPodTrigger
 from airflow.providers.cncf.kubernetes.utils.pod_manager import PodPhase
 from airflow.providers.common.compat.sdk import AirflowException
@@ -890,6 +891,7 @@ class TestKubernetesPodTrigger:
             on_kill_action="delete_pod",
             on_finish_action="delete_pod",
         )
+        mock_hook.close = mock.AsyncMock(spec=AsyncKubernetesHook.close)
         with mock.patch(f"{TRIGGER_PATH}.safe_to_cancel", 
new_callable=mock.AsyncMock, return_value=False):
             await trigger.cleanup()
         mock_hook.delete_pod.assert_not_called()
@@ -911,6 +913,7 @@ class TestKubernetesPodTrigger:
             on_finish_action="delete_pod",
         )
         mock_hook.delete_pod = mock.AsyncMock()
+        mock_hook.close = mock.AsyncMock(spec=AsyncKubernetesHook.close)
         with mock.patch(f"{TRIGGER_PATH}.safe_to_cancel", 
new_callable=mock.AsyncMock, return_value=True):
             await trigger.cleanup()
         mock_hook.delete_pod.assert_called_once_with(
@@ -926,7 +929,8 @@ class TestKubernetesPodTrigger:
     @pytest.mark.asyncio
     @mock.patch(f"{TRIGGER_PATH}.hook")
     async def test_cleanup_noop_on_airflow_3_3_plus(self, mock_hook):
-        """On Airflow 3.3.0+, cleanup does not delete pods (on_kill handles 
user kill)."""
+        """On Airflow 3.3.0+, cleanup releases the client and on_kill handles 
pod deletion."""
+        mock_hook.close = mock.AsyncMock(spec=AsyncKubernetesHook.close)
         trigger = KubernetesPodTrigger(
             pod_name=POD_NAME,
             pod_namespace=NAMESPACE,
@@ -938,6 +942,7 @@ class TestKubernetesPodTrigger:
         )
         await trigger.cleanup()
         mock_hook.delete_pod.assert_not_called()
+        mock_hook.close.assert_awaited_once()
 
     @pytest.mark.asyncio
     @mock.patch(f"{TRIGGER_PATH}.get_task_state", new_callable=mock.AsyncMock)
@@ -1096,6 +1101,7 @@ class TestKubernetesPodTrigger:
             on_kill_action="delete_pod",
             on_finish_action="delete_pod",
         )
+        mock_hook.close = mock.AsyncMock(spec=AsyncKubernetesHook.close)
         with mock.patch(
             f"{TRIGGER_PATH}.safe_to_cancel",
             new_callable=mock.AsyncMock,
@@ -1103,3 +1109,38 @@ class TestKubernetesPodTrigger:
         ):
             await trigger.cleanup()
         mock_hook.delete_pod.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("fired_event", "on_kill_action", "safe_result", "delete_error"),
+        [
+            pytest.param(True, "delete_pod", True, None, id="event-fired"),
+            pytest.param(False, "keep_pod", True, None, id="keep-pod"),
+            pytest.param(False, "delete_pod", False, None, id="restart"),
+            pytest.param(False, "delete_pod", RuntimeError("API down"), None, 
id="state-error"),
+            pytest.param(False, "delete_pod", True, RuntimeError("API down"), 
id="delete-error"),
+            pytest.param(False, "delete_pod", True, None, id="pod-deleted"),
+        ],
+    )
+    
@mock.patch("airflow.providers.cncf.kubernetes.triggers.pod.AIRFLOW_V_3_3_PLUS",
 False)
+    @mock.patch(f"{TRIGGER_PATH}.safe_to_cancel", autospec=True)
+    
@mock.patch("airflow.providers.cncf.kubernetes.triggers.pod.AsyncKubernetesHook",
 autospec=True)
+    async def test_legacy_cleanup_always_closes_hook(
+        self, mock_hook_class, mock_safe_to_cancel, fired_event, 
on_kill_action, safe_result, delete_error
+    ):
+        trigger = KubernetesPodTrigger(
+            pod_name=POD_NAME,
+            pod_namespace=NAMESPACE,
+            base_container_name=BASE_CONTAINER_NAME,
+            trigger_start_time=TRIGGER_START_TIME,
+            schedule_timeout=STARTUP_TIMEOUT_SECS,
+            on_kill_action=on_kill_action,
+        )
+        trigger._fired_event = fired_event
+        mock_safe_to_cancel.side_effect = [safe_result]
+        mock_hook = mock_hook_class.return_value
+        mock_hook.delete_pod.side_effect = delete_error
+
+        await trigger.cleanup()
+
+        mock_hook.close.assert_awaited_once()

Reply via email to