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

amoghdesai 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 26ead210674 Do not cache kubeconfig for exec based auth in 
AsyncKubernetesHook (#65212)
26ead210674 is described below

commit 26ead2106741c5b36a3d22ca894408f4aaa4bfc0
Author: Amogh Desai <[email protected]>
AuthorDate: Thu Apr 16 20:42:51 2026 +0530

    Do not cache kubeconfig for exec based auth in AsyncKubernetesHook (#65212)
---
 .../providers/cncf/kubernetes/hooks/kubernetes.py  | 115 +++++++++++++---
 .../unit/cncf/kubernetes/hooks/test_kubernetes.py  | 152 +++++++++++++++++++++
 2 files changed, 247 insertions(+), 20 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 23b4cc94b1f..3f740981aa7 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
@@ -855,9 +855,45 @@ class AsyncKubernetesHook(KubernetesHook):
         self._extras: dict | None = connection_extras
         self._event_polling_fallback = False
         self._config_loaded = False
+        # 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
+
+    def _uses_exec_auth(self, kubeconfig_data: dict, context: str | None = 
None) -> bool:
+        """
+        Detect if the active kubeconfig context uses exec-based authentication.
+
+        Exec plugins return short-lived tokens (EKS, GKE, etc). Only the user
+        tied to the active context is checked. If the active context or its 
user
+        cannot be resolved, falls back to scanning all users and returns True 
if
+        any use exec auth — erring on the side of not caching.
+        """
+        active_context = context or kubeconfig_data.get("current-context")
+
+        active_user: str | None = None
+        for ctx in kubeconfig_data.get("contexts", []):
+            if ctx.get("name") == active_context:
+                active_user = (ctx.get("context") or {}).get("user")
+                break
+
+        users = kubeconfig_data.get("users", [])
+        if active_user is not None:
+            for user in users:
+                if user.get("name") == active_user:
+                    return "exec" in (user.get("user") or {})
+            return False
+
+        # fallback to check all users if active context user cannot be 
resolved; this is a safe fallback since it errs on the side of not caching
+        return any("exec" in (u.get("user") or {}) for u in users)
 
     async def _load_config(self):
-        """Load Kubernetes configuration once per hook instance."""
+        """
+        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.
+        """
         if self._config_loaded:
             return
 
@@ -884,39 +920,68 @@ class AsyncKubernetesHook(KubernetesHook):
             self._config_loaded = True
             return
 
-        # If above block does not return, we are not in a cluster.
         self._is_in_cluster = False
-
         if self.config_dict:
             self.log.debug(LOADING_KUBE_CONFIG_FILE_RESOURCE.format("config 
dictionary"))
-            await async_config.load_kube_config_from_dict(self.config_dict, 
context=cluster_context)
-            self._config_loaded = True
-            return
 
+            await async_config.load_kube_config_from_dict(
+                self.config_dict,
+                context=cluster_context,
+            )
+
+            if self._is_exec_auth is None:
+                self._is_exec_auth = self._uses_exec_auth(self.config_dict, 
context=cluster_context)
+
+            if not self._is_exec_auth:
+                self._config_loaded = True
+
+            return
         if kubeconfig_path is not None:
             self.log.debug("loading kube_config from: %s", kubeconfig_path)
+
             await async_config.load_kube_config(
                 config_file=kubeconfig_path,
                 client_configuration=self.client_configuration,
                 context=cluster_context,
             )
-            self._config_loaded = True
-            return
 
+            if self._is_exec_auth is None:
+                try:
+                    async with aiofiles.open(kubeconfig_path) as f:
+                        content = await f.read()
+                        data = yaml.safe_load(content)
+                    self._is_exec_auth = self._uses_exec_auth(data, 
context=cluster_context)
+                except Exception as exc:
+                    self.log.warning(
+                        "Error while parsing kube_config from %s to detect 
exec auth; "
+                        "continuing without caching the config: %s",
+                        kubeconfig_path,
+                        exc,
+                    )
+                    self._is_exec_auth = True
+
+            if not self._is_exec_auth:
+                self._config_loaded = True
+
+            return
         if kubeconfig is not None:
             async with aiofiles.tempfile.NamedTemporaryFile() as temp_config:
-                self.log.debug(
-                    "Reading kubernetes configuration file from connection "
-                    "object and writing temporary config file with its 
content",
-                )
                 if isinstance(kubeconfig, dict):
-                    self.log.debug(
-                        LOADING_KUBE_CONFIG_FILE_RESOURCE.format(
-                            "connection kube_config dictionary (serializing)"
+                    kubeconfig_data = kubeconfig
+                    kubeconfig_str = json.dumps(kubeconfig)
+                else:
+                    try:
+                        kubeconfig_data = _load_body_to_dict(kubeconfig)
+                    except AirflowException as exc:
+                        self.log.debug(
+                            "Could not parse kubeconfig string to detect exec 
auth; "
+                            "config will not be cached: %s",
+                            exc,
                         )
-                    )
-                    kubeconfig = json.dumps(kubeconfig)
-                await temp_config.write(kubeconfig.encode())
+                        kubeconfig_data = None
+                    kubeconfig_str = kubeconfig
+
+                await temp_config.write(kubeconfig_str.encode())
                 await temp_config.flush()
 
                 await async_config.load_kube_config(
@@ -924,10 +989,20 @@ class AsyncKubernetesHook(KubernetesHook):
                     client_configuration=self.client_configuration,
                     context=cluster_context,
                 )
-                self._config_loaded = True
-                return
 
+                if self._is_exec_auth is None:
+                    self._is_exec_auth = (
+                        self._uses_exec_auth(kubeconfig_data, 
context=cluster_context)
+                        if kubeconfig_data
+                        else True
+                    )
+
+                if not self._is_exec_auth:
+                    self._config_loaded = True
+
+            return
         self.log.debug(LOADING_KUBE_CONFIG_FILE_RESOURCE.format("default 
configuration file"))
+
         await async_config.load_kube_config(
             client_configuration=self.client_configuration,
             context=cluster_context,
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 850610ace27..2d38c697954 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
@@ -1133,6 +1133,158 @@ class TestAsyncKubernetesHook:
         with pytest.raises(AirflowException):
             await hook._load_config()
 
+    @pytest.mark.parametrize(
+        ("kubeconfig_data", "context", "expected"),
+        [
+            pytest.param(
+                {
+                    "current-context": "ctx1",
+                    "contexts": [{"name": "ctx1", "context": {"user": 
"user1"}}],
+                    "users": [{"name": "user1", "user": {"exec": {"command": 
"aws"}}}],
+                },
+                None,
+                True,
+                id="active_context_user_has_exec",
+            ),
+            pytest.param(
+                {
+                    "current-context": "ctx1",
+                    "contexts": [{"name": "ctx1", "context": {"user": 
"user1"}}],
+                    "users": [{"name": "user1", "user": {"token": "abc123"}}],
+                },
+                None,
+                False,
+                id="active_context_user_has_static_token",
+            ),
+            pytest.param(
+                {
+                    "current-context": "ctx1",
+                    "contexts": [{"name": "ctx1", "context": {"user": 
"user1"}}],
+                    "users": [
+                        {"name": "user1", "user": {"token": "abc123"}},
+                        {"name": "user2", "user": {"exec": {"command": 
"aws"}}},
+                    ],
+                },
+                None,
+                False,
+                id="only_inactive_user_has_exec",
+            ),
+            pytest.param(
+                {
+                    "users": [{"name": "user1", "user": {"exec": {"command": 
"aws"}}}],
+                },
+                None,
+                True,
+                id="no_contexts_fallback_any_user_has_exec",
+            ),
+        ],
+    )
+    def test_uses_exec_auth(self, kubeconfig_data, context, expected):
+        hook = AsyncKubernetesHook(conn_id=None, in_cluster=False)
+        assert hook._uses_exec_auth(kubeconfig_data, context=context) is 
expected
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config_from_dict")
+    async def test_load_config_no_caching_when_exec_auth(self, mock_load):
+        mock_load.return_value = None
+        exec_config = {
+            "current-context": "ctx1",
+            "contexts": [{"name": "ctx1", "context": {"user": "user1"}}],
+            "users": [{"name": "user1", "user": {"exec": {"command": "aws eks 
get-token"}}}],
+        }
+        hook = AsyncKubernetesHook(conn_id=None, in_cluster=False, 
config_dict=exec_config)
+        await hook._load_config()
+        assert hook._config_loaded is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("kubeconfig_content", "expected_cached"),
+        [
+            pytest.param(
+                (
+                    "current-context: ctx1\n"
+                    "contexts:\n- name: ctx1\n  context:\n    user: user1\n"
+                    "users:\n- name: user1\n  user:\n    exec:\n      command: 
aws eks get-token\n"
+                ),
+                False,
+                id="kube_config_path_with_exec",
+            ),
+            pytest.param(
+                (
+                    "current-context: ctx1\n"
+                    "contexts:\n- name: ctx1\n  context:\n    user: user1\n"
+                    "users:\n- name: user1\n  user:\n    token: static-token\n"
+                ),
+                True,
+                id="kube_config_path_no_exec",
+            ),
+        ],
+    )
+    
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config")
+    async def test_load_config_caching_behavior_kube_config_path(
+        self, mock_load_file, tmp_path, kubeconfig_content, expected_cached
+    ):
+        mock_load_file.return_value = None
+        kubeconfig_file = tmp_path / "kubeconfig.yaml"
+        kubeconfig_file.write_text(kubeconfig_content)
+
+        hook = AsyncKubernetesHook(conn_id=None, in_cluster=False)
+        hook._get_field = mock.AsyncMock(
+            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
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("kubeconfig", "expected_cached"),
+        [
+            pytest.param(
+                {
+                    "current-context": "ctx1",
+                    "contexts": [{"name": "ctx1", "context": {"user": 
"user1"}}],
+                    "users": [{"name": "user1", "user": {"token": 
"static-token"}}],
+                },
+                True,
+                id="dict_kubeconfig_no_exec",
+            ),
+            pytest.param(
+                (
+                    "current-context: ctx1\n"
+                    "contexts:\n- name: ctx1\n  context:\n    user: user1\n"
+                    "users:\n- name: user1\n  user:\n    exec:\n      command: 
aws\n"
+                ),
+                False,
+                id="string_kubeconfig_with_exec",
+            ),
+            pytest.param(
+                (
+                    "current-context: ctx1\n"
+                    "contexts:\n- name: ctx1\n  context:\n    user: user1\n"
+                    "users:\n- name: user1\n  user:\n    token: static-token\n"
+                ),
+                True,
+                id="string_kubeconfig_no_exec",
+            ),
+        ],
+    )
+    
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config")
+    
@mock.patch("airflow.providers.cncf.kubernetes.hooks.kubernetes.async_config.load_kube_config_from_dict")
+    async def test_load_config_caching_behavior(
+        self, mock_load_dict, mock_load_file, kubeconfig, expected_cached
+    ):
+        mock_load_dict.return_value = None
+        mock_load_file.return_value = None
+        if isinstance(kubeconfig, dict):
+            hook = AsyncKubernetesHook(conn_id=None, in_cluster=False, 
config_dict=kubeconfig)
+        else:
+            hook = AsyncKubernetesHook(conn_id=None, in_cluster=False)
+            hook._get_field = mock.AsyncMock(
+                side_effect=lambda field: kubeconfig if field == "kube_config" 
else None
+            )
+        await hook._load_config()
+        assert hook._config_loaded is expected_cached
+
     @pytest.mark.asyncio
     @mock.patch(KUBE_API.format("list_namespaced_event"))
     async def test_async_get_pod_events_with_resource_version(

Reply via email to