This is an automated email from the ASF dual-hosted git repository.
potiuk 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 e2cf316fc36 Fix race condition in AsyncKubernetesHook corrupting
global kubernetes_asyncio config (#65566)
e2cf316fc36 is described below
commit e2cf316fc36c674e5af7bce768b8a2e5a2bb1b6f
Author: Mingjie Zhao <[email protected]>
AuthorDate: Sun May 10 04:27:26 2026 +0200
Fix race condition in AsyncKubernetesHook corrupting global
kubernetes_asyncio config (#65566)
* Fix race condition in AsyncKubernetesHook corrupting global
kubernetes_asyncio config
When multiple deferrable KubernetesPodOperator tasks run concurrently on the
same triggerer, each task's AsyncKubernetesHook calls
load_kube_config_from_dict
(or load_incluster_config), which writes to the module-level global
kubernetes_asyncio Configuration. Because load_kube_config_from_dict is
async
and contains multiple await points, two coroutines for different clusters
can
interleave: one writes its server URL while the other writes its bearer
token,
producing a mixed configuration. _TimeoutAsyncK8sApiClient() was then
instantiated with no configuration= argument, so it silently consumed
whatever
global state happened to be current at that moment.
The fix has four parts:
- Add __init__ to _TimeoutAsyncK8sApiClient accepting configuration=
(matching
the existing sync _TimeoutK8sApiClient pattern).
- Initialise self.client_configuration to a fresh
async_client.Configuration()
at the top of _load_config when it is None, ensuring each hook instance
owns
an isolated Configuration object.
- Pass client_configuration=self.client_configuration to
load_incluster_config
and load_kube_config_from_dict, which previously wrote only to global
state.
- Pass configuration=self.client_configuration when constructing
_TimeoutAsyncK8sApiClient in get_conn, so the client uses the per-instance
configuration rather than the global default.
The kubeconfig_path, kube_config (tempfile), and default-file branches
already
forwarded client_configuration=self.client_configuration; they now benefit
from
the initialisation fix so the value is never None.
* Add tests for AsyncKubernetesHook per-instance Configuration isolation
Covers the race condition fix across five scenarios:
- _TimeoutAsyncK8sApiClient accepts a configuration= argument and forwards
it
to the parent ApiClient, ensuring each instance is isolated.
- No-arg construction still works for backwards compatibility.
- _load_config allocates a fresh async_client.Configuration() per hook
instance
when none was supplied by the caller.
- load_incluster_config and load_kube_config_from_dict are each called with
the
per-instance client_configuration rather than leaving it as None (which
would
cause both functions to fall back to Configuration.set_default() and
corrupt
shared global state).
- get_conn passes configuration= to _TimeoutAsyncK8sApiClient so the API
client
uses the hook's isolated Configuration, not the global default.
- The core regression test runs two hooks for different clusters
concurrently
via asyncio.gather and asserts that each hook's client_configuration is a
distinct object populated only with that hook's own connection data.
* Address review: override client_configuration type in AsyncKubernetesHook
The parent KubernetesHook.client_configuration is typed as
kubernetes.client.Configuration (sync). Rather than using a separate
attribute, AsyncKubernetesHook now explicitly accepts and re-declares
client_configuration as async_client.Configuration | None, making the
override visible to type checkers and callers without adding a new
attribute to track.
Both Configuration classes are generated from the same OpenAPI source and
are structurally compatible when library versions align.
Also add an await asyncio.sleep(0) yield inside the populate_config mock
side effect in the concurrency regression test so asyncio.gather can
actually interleave the two coroutines and exercise the race window.
---------
Co-authored-by: Mingjie Zhao <[email protected]>
---
.../providers/cncf/kubernetes/hooks/kubernetes.py | 23 ++++-
.../unit/cncf/kubernetes/hooks/test_kubernetes.py | 112 +++++++++++++++++++++
2 files changed, 132 insertions(+), 3 deletions(-)
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 3f740981aa7..9dcb6b59bba 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
@@ -111,6 +111,12 @@ class _TimeoutK8sApiClient(client.ApiClient):
class _TimeoutAsyncK8sApiClient(async_client.ApiClient):
"""Wrapper around kubernetes async ApiClient to set default timeout."""
+ def __init__(
+ self,
+ configuration: async_client.Configuration | None = None,
+ ) -> None:
+ super().__init__(configuration=configuration)
+
async def call_api(self, *args, **kwargs):
timeout_seconds = kwargs.get("timeout_seconds") # server-side timeout
kwargs.setdefault("_request_timeout",
_get_request_timeout(timeout_seconds)) # client-side timeout
@@ -847,7 +853,12 @@ class AsyncKubernetesHook(KubernetesHook):
"""Hook to use Kubernetes SDK asynchronously."""
def __init__(
- self, config_dict: dict | None = None, connection_extras: dict | None
= None, *args, **kwargs
+ self,
+ config_dict: dict | None = None,
+ connection_extras: dict | None = None,
+ client_configuration: async_client.Configuration | None = None,
+ *args,
+ **kwargs,
):
super().__init__(*args, **kwargs)
@@ -855,6 +866,8 @@ class AsyncKubernetesHook(KubernetesHook):
self._extras: dict | None = connection_extras
self._event_polling_fallback = False
self._config_loaded = 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
@@ -894,6 +907,9 @@ class AsyncKubernetesHook(KubernetesHook):
and cached. For exec-based auth (EKS, GKE), the config is reloaded on
every call so
that short-lived tokens are always refreshed.
"""
+ if self.client_configuration is None:
+ self.client_configuration = async_client.Configuration()
+
if self._config_loaded:
return
@@ -915,7 +931,7 @@ class AsyncKubernetesHook(KubernetesHook):
if in_cluster:
self.log.debug(LOADING_KUBE_CONFIG_FILE_RESOURCE.format("within a
pod"))
- async_config.load_incluster_config()
+
async_config.load_incluster_config(client_configuration=self.client_configuration)
self._is_in_cluster = True
self._config_loaded = True
return
@@ -927,6 +943,7 @@ class AsyncKubernetesHook(KubernetesHook):
await async_config.load_kube_config_from_dict(
self.config_dict,
context=cluster_context,
+ client_configuration=self.client_configuration,
)
if self._is_exec_auth is None:
@@ -1041,7 +1058,7 @@ class AsyncKubernetesHook(KubernetesHook):
kube_client = None
try:
await self._load_config()
- kube_client = _TimeoutAsyncK8sApiClient()
+ kube_client =
_TimeoutAsyncK8sApiClient(configuration=self.client_configuration)
yield kube_client
finally:
if kube_client is not 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 2d38c697954..de9670f4903 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
@@ -17,6 +17,7 @@
# under the License.
from __future__ import annotations
+import asyncio
import json
import os
import tempfile
@@ -155,6 +156,22 @@ class TestTimeoutAsyncK8sApiClient:
assert call_kwargs["_request_timeout"] == expected_timeout
assert out == "ok"
+ @pytest.mark.asyncio
+ async def test_accepts_configuration_parameter(self):
+ """Constructor forwards configuration= to ApiClient so each instance
is isolated."""
+ config = async_client.Configuration()
+ config.host = "https://test-cluster.example.com"
+ client = _TimeoutAsyncK8sApiClient(configuration=config)
+ assert client.configuration is config
+ await client.close()
+
+ @pytest.mark.asyncio
+ async def test_no_arg_construction_works(self):
+ """No-arg construction must still work for backwards compatibility."""
+ client = _TimeoutAsyncK8sApiClient()
+ assert client is not None
+ await client.close()
+
@pytest.fixture
def remove_default_conn(monkeypatch):
@@ -1935,3 +1952,98 @@ class TestAsyncKubernetesHook:
]
)
mock_sleep.assert_awaited_once_with(10)
+
+ # -------------------------------------------------------------------------
+ # Tests for the per-instance Configuration isolation fix
+ # -------------------------------------------------------------------------
+
+ @pytest.mark.asyncio
+
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config_from_dict")
+ async def test_load_config_initialises_client_configuration(self,
mock_load):
+ """_load_config allocates a fresh per-instance Configuration when none
was supplied."""
+ mock_load.return_value = None
+ hook = AsyncKubernetesHook(conn_id=None, in_cluster=False,
config_dict={"a": "b"})
+ assert hook.client_configuration is None
+
+ await hook._load_config()
+
+ assert isinstance(hook.client_configuration,
async_client.Configuration)
+
+ @pytest.mark.asyncio
+
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_incluster_config")
+ async def test_load_config_incluster_passes_client_configuration(self,
mock_incluster):
+ """load_incluster_config is called with the per-instance
client_configuration."""
+ hook = AsyncKubernetesHook(conn_id=None, in_cluster=True)
+
+ await hook._load_config()
+
+
mock_incluster.assert_called_once_with(client_configuration=hook.client_configuration)
+ assert isinstance(hook.client_configuration,
async_client.Configuration)
+
+ @pytest.mark.asyncio
+
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config_from_dict")
+ async def test_load_config_config_dict_passes_client_configuration(self,
mock_load):
+ """load_kube_config_from_dict is called with the per-instance
client_configuration."""
+ mock_load.return_value = None
+ hook = AsyncKubernetesHook(conn_id=None, in_cluster=False,
config_dict={"a": "b"})
+
+ await hook._load_config()
+
+ mock_load.assert_called_once()
+ _, kwargs = mock_load.call_args
+ assert "client_configuration" in kwargs
+ assert kwargs["client_configuration"] is hook.client_configuration
+ assert isinstance(hook.client_configuration,
async_client.Configuration)
+
+ @pytest.mark.asyncio
+
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config_from_dict")
+ async def test_get_conn_passes_client_configuration_to_async_client(self,
mock_load):
+ """get_conn passes the per-instance configuration= to
_TimeoutAsyncK8sApiClient."""
+ mock_load.return_value = None
+ hook = AsyncKubernetesHook(conn_id=None, in_cluster=False,
config_dict={"a": "b"})
+
+ with mock.patch(HOOK_MODULE + "._TimeoutAsyncK8sApiClient") as
mock_client_cls:
+ mock_client_cls.return_value.close = mock.AsyncMock()
+ async with hook.get_conn():
+ pass
+
+
mock_client_cls.assert_called_once_with(configuration=hook.client_configuration)
+
+ @pytest.mark.asyncio
+
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config_from_dict")
+ async def test_concurrent_hooks_have_isolated_configurations(self,
mock_load):
+ """Two concurrent hooks for different clusters each use an independent
Configuration.
+
+ This is the core regression test for the race condition: before the
fix,
+ load_kube_config_from_dict was called with client_configuration=None,
causing it
+ to overwrite the global kubernetes_asyncio Configuration via
Configuration.set_default().
+ Concurrent triggerer coroutines for different clusters would
interleave at the await
+ inside load_kube_config_from_dict and produce a mixed global state
(e.g. cluster-a's
+ server URL paired with cluster-b's bearer token).
+ """
+
+ async def populate_config(config_dict, context, client_configuration):
+ # Yield to the event loop so concurrent coroutines can interleave,
+ # simulating the await points inside the real
load_kube_config_from_dict.
+ await asyncio.sleep(0)
+ client_configuration.host = config_dict["server"]
+
+ mock_load.side_effect = populate_config
+
+ hook_a = AsyncKubernetesHook(
+ conn_id=None,
+ in_cluster=False,
+ config_dict={"server": "https://cluster-a.example.com"},
+ )
+ hook_b = AsyncKubernetesHook(
+ conn_id=None,
+ in_cluster=False,
+ config_dict={"server": "https://cluster-b.example.com"},
+ )
+
+ await asyncio.gather(hook_a._load_config(), hook_b._load_config())
+
+ # Each hook must own a separate, non-contaminated Configuration object.
+ 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"