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 3b0388815f0 Centralize EKS kubeconfig and credential setup in EksHook 
(#74775)
3b0388815f0 is described below

commit 3b0388815f0d8ec06b5a15e9d61d7b1bd5e54962
Author: Alejandro Morgante <[email protected]>
AuthorDate: Mon Oct 19 02:33:32 2026 -0300

    Centralize EKS kubeconfig and credential setup in EksHook (#74775)
    
    EKS execution, trigger reentry and polling repeat the same credential
    resolution and temporary-file setup. A shared context keeps their file
    lifetimes aligned while preserving access to the credential file for
    refresh during long-running tasks.
    
    ---------
    
    Co-authored-by: Sean Ghaeli <[email protected]>
---
 generated/known_airflow_exceptions.txt             |  2 +-
 .../src/airflow/providers/amazon/aws/hooks/eks.py  | 32 +++++++++
 .../airflow/providers/amazon/aws/operators/eks.py  | 46 +++---------
 .../airflow/providers/amazon/aws/triggers/eks.py   | 44 ++++--------
 .../amazon/tests/unit/amazon/aws/hooks/test_eks.py | 84 ++++++++++++++++++++++
 .../tests/unit/amazon/aws/operators/test_eks.py    | 64 +++++++++++++++++
 .../tests/unit/amazon/aws/triggers/test_eks.py     | 29 ++++++++
 7 files changed, 230 insertions(+), 71 deletions(-)

diff --git a/generated/known_airflow_exceptions.txt 
b/generated/known_airflow_exceptions.txt
index b26359f86f8..47ebd560a73 100644
--- a/generated/known_airflow_exceptions.txt
+++ b/generated/known_airflow_exceptions.txt
@@ -71,7 +71,7 @@ 
providers/amazon/src/airflow/providers/amazon/aws/operators/comprehend.py::2
 providers/amazon/src/airflow/providers/amazon/aws/operators/dms.py::5
 providers/amazon/src/airflow/providers/amazon/aws/operators/ec2.py::1
 providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py::7
-providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py::12
+providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py::10
 providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py::24
 providers/amazon/src/airflow/providers/amazon/aws/operators/eventbridge.py::1
 providers/amazon/src/airflow/providers/amazon/aws/operators/glue.py::6
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/hooks/eks.py 
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/eks.py
index 5a20629ef91..70409e3b62e 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/eks.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/eks.py
@@ -608,6 +608,38 @@ class EksHook(AwsBaseHook):
                 with contextlib.suppress(OSError):
                     os.unlink(temp_path)
 
+    @contextmanager
+    def generate_config_file_with_credentials(
+        self, eks_cluster_name: str, pod_namespace: str | None
+    ) -> Generator[tuple[str, str], None, None]:
+        """
+        Create temporary kubeconfig and credential files using this hook's AWS 
session.
+
+        Both files remain available until the context exits and are then 
removed, including
+        when the caller raises an exception. The credential file can be 
refreshed in place
+        while the context is active without regenerating the kubeconfig.
+
+        :param eks_cluster_name: Name of the EKS cluster.
+        :param pod_namespace: Kubernetes namespace, or ``None`` for the client 
default.
+        :return: Tuple of kubeconfig path and credential file path.
+        """
+        credentials = self.get_session().get_credentials()
+        if credentials is None:
+            raise RuntimeError(
+                "Unable to retrieve AWS credentials. Credentials may have 
expired or not been configured. "
+                "Please check your AWS connection configuration."
+            )
+        frozen_credentials = credentials.get_frozen_credentials()
+        with self._secure_credential_context(
+            frozen_credentials.access_key, frozen_credentials.secret_key, 
frozen_credentials.token
+        ) as credentials_file:
+            with self.generate_config_file(
+                eks_cluster_name=eks_cluster_name,
+                pod_namespace=pod_namespace,
+                credentials_file=credentials_file,
+            ) as config_file:
+                yield config_file, credentials_file
+
     @contextmanager
     def generate_config_file(
         self,
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py
index 3eee3434b91..bd12e2d3075 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/eks.py
@@ -1252,25 +1252,10 @@ class EksPodOperator(KubernetesPodOperator):
             aws_conn_id=self.aws_conn_id,
             region_name=self.region,
         )
-        session = eks_hook.get_session()
-        credentials_obj = session.get_credentials()
-        if credentials_obj is None:
-            raise AirflowException(
-                "Unable to retrieve AWS credentials. Credentials may have 
expired or not been configured. "
-                "Please check your AWS connection configuration."
-            )
-        credentials = credentials_obj.get_frozen_credentials()
-        with eks_hook._secure_credential_context(
-            credentials.access_key, credentials.secret_key, credentials.token
-        ) as credentials_file:
-            # Store credentials file path for potential refresh during 
long-running tasks
-            self._credentials_file_path = credentials_file
-            with eks_hook.generate_config_file(
-                eks_cluster_name=self.cluster_name,
-                pod_namespace=self.namespace,
-                credentials_file=credentials_file,
-            ) as self.config_file:
-                return super().execute(context)
+        with eks_hook.generate_config_file_with_credentials(
+            eks_cluster_name=self.cluster_name, pod_namespace=self.namespace
+        ) as (self.config_file, self._credentials_file_path):
+            return super().execute(context)
 
     def trigger_reentry(self, context: Context, event: dict[str, Any]) -> Any:
         eks_hook = EksHook(
@@ -1279,25 +1264,10 @@ class EksPodOperator(KubernetesPodOperator):
         )
         eks_cluster_name = event["eks_cluster_name"]
         pod_namespace = event["namespace"]
-        session = eks_hook.get_session()
-        credentials_obj = session.get_credentials()
-        if credentials_obj is None:
-            raise AirflowException(
-                "Unable to retrieve AWS credentials. Credentials may have 
expired or not been configured. "
-                "Please check your AWS connection configuration."
-            )
-        credentials = credentials_obj.get_frozen_credentials()
-        with eks_hook._secure_credential_context(
-            credentials.access_key, credentials.secret_key, credentials.token
-        ) as credentials_file:
-            # Store credentials file path for potential refresh during 
long-running tasks
-            self._credentials_file_path = credentials_file
-            with eks_hook.generate_config_file(
-                eks_cluster_name=eks_cluster_name,
-                pod_namespace=pod_namespace,
-                credentials_file=credentials_file,
-            ) as self.config_file:
-                return super().trigger_reentry(context, event)
+        with eks_hook.generate_config_file_with_credentials(
+            eks_cluster_name=eks_cluster_name, pod_namespace=pod_namespace
+        ) as (self.config_file, self._credentials_file_path):
+            return super().trigger_reentry(context, event)
 
     def _write_credentials_to_file(
         self, credentials_file_path: str, access_key: str, secret_key: str, 
session_token: str | None
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/triggers/eks.py 
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/eks.py
index 1d142d9dbb6..2f9cce21ae9 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/triggers/eks.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/triggers/eks.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import datetime
+from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
 import tenacity
@@ -29,6 +30,7 @@ from airflow.providers.amazon.aws.utils.waiter_with_logging 
import async_wait
 from airflow.providers.cncf.kubernetes.triggers.pod import KubernetesPodTrigger
 from airflow.providers.common.compat.sdk import AirflowException
 from airflow.triggers.base import TriggerEvent
+from airflow.utils import yaml
 from airflow.utils.helpers import prune_dict
 
 if TYPE_CHECKING:
@@ -186,45 +188,23 @@ class EksPodTrigger(KubernetesPodTrigger):
 
     async def run(self):
         """Generate fresh kubeconfig, then delegate to the parent trigger."""
-        from airflow.utils import yaml
-
         eks_hook = EksHook(
             aws_conn_id=self._aws_conn_id,
             region_name=self.region,
         )
-        session = eks_hook.get_session()
-        credentials_obj = session.get_credentials()
-        if credentials_obj is None:
-            raise RuntimeError(
-                "Unable to retrieve AWS credentials for EKS trigger. "
-                "Credentials may have expired or not been configured."
+        with eks_hook.generate_config_file_with_credentials(
+            eks_cluster_name=self.eks_cluster_name, 
pod_namespace=self.pod_namespace
+        ) as (config_file_path, _):
+            self.config_dict = yaml.safe_load(
+                Path(config_file_path).read_text()  # noqa: ASYNC240
             )
-        credentials = credentials_obj.get_frozen_credentials()
-
-        # Create fresh credential and kubeconfig files.  The context managers
-        # keep the temp files alive for the entire duration of the trigger.
-        with eks_hook._secure_credential_context(
-            credentials.access_key, credentials.secret_key, credentials.token
-        ) as credentials_file:
-            with eks_hook.generate_config_file(
-                eks_cluster_name=self.eks_cluster_name,
-                pod_namespace=self.pod_namespace,
-                credentials_file=credentials_file,
-            ) as config_file_path:
-                # Reading a small local temp file created by the context 
manager above.
-                # Blocking I/O is acceptable here as the file is tiny and 
local.
-                from pathlib import Path
-
-                self.config_dict = yaml.safe_load(
-                    Path(config_file_path).read_text()  # noqa: ASYNC240
-                )
 
-                # Invalidate any previously cached hook so the new config_dict
-                # is picked up when the parent creates the AsyncKubernetesHook.
-                self.__dict__.pop("hook", None)
+            # Invalidate any previously cached hook so the new config_dict
+            # is picked up when the parent creates the AsyncKubernetesHook.
+            self.__dict__.pop("hook", None)
 
-                async for event in super().run():
-                    yield event
+            async for event in super().run():
+                yield event
 
 
 class EksDeleteClusterTrigger(AwsBaseWaiterTrigger):
diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_eks.py 
b/providers/amazon/tests/unit/amazon/aws/hooks/test_eks.py
index af8b7c0e6ce..26c35383f0a 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_eks.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_eks.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 import json
 import subprocess
 import sys
+from contextlib import AbstractContextManager
 from copy import deepcopy
 from pathlib import Path
 from typing import TYPE_CHECKING
@@ -30,6 +31,8 @@ from urllib.parse import urlsplit
 import pytest
 import time_machine
 import yaml
+from boto3.session import Session
+from botocore.credentials import Credentials
 from botocore.exceptions import ClientError
 from moto import mock_aws
 from moto.core import DEFAULT_ACCOUNT_ID
@@ -1212,6 +1215,87 @@ class TestEksHooks:
 class TestEksHook:
     python_executable = f"python{sys.version_info[0]}.{sys.version_info[1]}"
 
+    @mock.patch.object(EksHook, "generate_config_file", autospec=True)
+    @mock.patch.object(EksHook, "get_session", autospec=True)
+    def test_generate_config_file_with_credentials(self, get_session, 
generate_config):
+        hook = EksHook(aws_conn_id=None)
+        session = mock.create_autospec(Session, instance=True)
+        session.get_credentials.return_value = Credentials("access-key", 
"secret-key", "session-token")
+        get_session.return_value = session
+        config_context = mock.MagicMock(spec=AbstractContextManager)
+        config_context.__enter__.return_value = "/tmp/kubeconfig"
+        generate_config.return_value = config_context
+
+        with hook.generate_config_file_with_credentials("cluster", 
"namespace") as (config, credentials):
+            assert config == "/tmp/kubeconfig"
+            assert Path(credentials).read_text() == (
+                "export AWS_ACCESS_KEY_ID='access-key'\n"
+                "export AWS_SECRET_ACCESS_KEY='secret-key'\n"
+                "export AWS_SESSION_TOKEN='session-token'\n"
+            )
+            config_context.__exit__.assert_not_called()
+
+        credentials = generate_config.call_args.kwargs["credentials_file"]
+        generate_config.assert_called_once_with(
+            hook, eks_cluster_name="cluster", pod_namespace="namespace", 
credentials_file=credentials
+        )
+        assert not Path(credentials).exists()
+        config_context.__exit__.assert_called_once()
+
+    @mock.patch.object(EksHook, "generate_config_file", autospec=True)
+    @mock.patch.object(EksHook, "get_session", autospec=True)
+    def test_auth_context_cleans_up_after_caller_failure(self, get_session, 
generate_config):
+        hook = EksHook(aws_conn_id=None)
+        session = mock.create_autospec(Session, instance=True)
+        session.get_credentials.return_value = Credentials("access-key", 
"secret-key", "session-token")
+        get_session.return_value = session
+        config_context = mock.MagicMock(spec=AbstractContextManager)
+        config_context.__enter__.return_value = "/tmp/kubeconfig"
+        generate_config.return_value = config_context
+        error = ValueError("caller failed")
+
+        with pytest.raises(ValueError, match="caller failed") as raised:
+            with hook.generate_config_file_with_credentials("cluster", 
"namespace") as (_, credentials):
+                raise error
+
+        assert raised.value is error
+        assert not Path(credentials).exists()
+        config_context.__exit__.assert_called_once_with(ValueError, error, 
mock.ANY)
+
+    @mock.patch.object(EksHook, "generate_config_file", autospec=True)
+    @mock.patch.object(EksHook, "get_session", autospec=True)
+    def test_auth_context_cleans_up_after_kubeconfig_failure(self, 
get_session, generate_config):
+        hook = EksHook(aws_conn_id=None)
+        session = mock.create_autospec(Session, instance=True)
+        session.get_credentials.return_value = Credentials("access-key", 
"secret-key", "session-token")
+        get_session.return_value = session
+        config_context = mock.MagicMock(spec=AbstractContextManager)
+        error = ValueError("kubeconfig failed")
+        config_context.__enter__.side_effect = error
+        generate_config.return_value = config_context
+
+        with pytest.raises(ValueError, match="kubeconfig failed") as raised:
+            with hook.generate_config_file_with_credentials("cluster", 
"namespace"):
+                pytest.fail("Kubeconfig failure must prevent entering the auth 
context")
+
+        assert raised.value is error
+        credentials = generate_config.call_args.kwargs["credentials_file"]
+        assert not Path(credentials).exists()
+
+    @mock.patch.object(EksHook, "generate_config_file", autospec=True)
+    @mock.patch.object(EksHook, "get_session", autospec=True)
+    def test_generate_config_file_with_credentials_missing_credentials(self, 
get_session, generate_config):
+        hook = EksHook(aws_conn_id=None)
+        session = mock.create_autospec(Session, instance=True)
+        session.get_credentials.return_value = None
+        get_session.return_value = session
+
+        with pytest.raises(RuntimeError, match="Unable to retrieve AWS 
credentials"):
+            with hook.generate_config_file_with_credentials("cluster", 
"namespace"):
+                pytest.fail("Missing credentials must prevent kubeconfig 
creation")
+
+        generate_config.assert_not_called()
+
     @mock.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook.conn")
     @pytest.mark.parametrize(
         ("aws_conn_id", "region_name", "expected_region_args"),
diff --git a/providers/amazon/tests/unit/amazon/aws/operators/test_eks.py 
b/providers/amazon/tests/unit/amazon/aws/operators/test_eks.py
index ebd756518b9..8d100e6391f 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_eks.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_eks.py
@@ -17,6 +17,7 @@
 # under the License.
 from __future__ import annotations
 
+from contextlib import AbstractContextManager, nullcontext
 from typing import Any, TypedDict
 from unittest import mock
 
@@ -1062,6 +1063,69 @@ class TestEksDeleteFargateProfileOperator:
 
 
 class TestEksPodOperator:
+    @mock.patch(
+        
"airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator.execute",
 autospec=True
+    )
+    @mock.patch.object(EksHook, "generate_config_file_with_credentials", 
autospec=True)
+    @pytest.mark.parametrize("fails", [False, True])
+    def test_execute_uses_shared_auth_context(self, generate_config, execute, 
fails):
+        config_context = mock.MagicMock(spec=AbstractContextManager)
+        config_context.__enter__.return_value = ("/tmp/kubeconfig", 
"/tmp/credentials")
+        generate_config.return_value = config_context
+        operator = EksPodOperator(
+            task_id="run_pod", cluster_name=CLUSTER_NAME, 
namespace="workloads", image="test-image"
+        )
+
+        def run_in_context(op, context):
+            assert op.config_file == "/tmp/kubeconfig"
+            assert op._credentials_file_path == "/tmp/credentials"
+            config_context.__exit__.assert_not_called()
+            if fails:
+                raise RuntimeError("parent failed")
+            return "result"
+
+        execute.side_effect = run_in_context
+        with pytest.raises(RuntimeError, match="parent failed") if fails else 
nullcontext():
+            assert operator.execute({}) == "result"
+
+        generate_config.assert_called_once_with(
+            mock.ANY, eks_cluster_name=CLUSTER_NAME, pod_namespace="workloads"
+        )
+        execute.assert_called_once_with(operator, {})
+        config_context.__exit__.assert_called_once()
+
+    @mock.patch(
+        
"airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator.trigger_reentry",
 autospec=True
+    )
+    @mock.patch.object(EksHook, "generate_config_file_with_credentials", 
autospec=True)
+    @pytest.mark.parametrize("fails", [False, True])
+    def test_trigger_reentry_uses_shared_auth_context(self, generate_config, 
reentry, fails):
+        config_context = mock.MagicMock(spec=AbstractContextManager)
+        config_context.__enter__.return_value = ("/tmp/kubeconfig", 
"/tmp/credentials")
+        generate_config.return_value = config_context
+        operator = EksPodOperator(
+            task_id="run_pod", cluster_name=CLUSTER_NAME, 
namespace="workloads", image="test-image"
+        )
+        event = {"eks_cluster_name": "event-cluster", "namespace": 
"event-namespace"}
+
+        def run_in_context(op, context, event):
+            assert op.config_file == "/tmp/kubeconfig"
+            assert op._credentials_file_path == "/tmp/credentials"
+            config_context.__exit__.assert_not_called()
+            if fails:
+                raise RuntimeError("parent failed")
+            return "result"
+
+        reentry.side_effect = run_in_context
+        with pytest.raises(RuntimeError, match="parent failed") if fails else 
nullcontext():
+            assert operator.trigger_reentry({}, event) == "result"
+
+        generate_config.assert_called_once_with(
+            mock.ANY, eks_cluster_name="event-cluster", 
pod_namespace="event-namespace"
+        )
+        reentry.assert_called_once_with(operator, {}, event)
+        config_context.__exit__.assert_called_once()
+
     
@mock.patch("airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator.execute")
     
@mock.patch("airflow.providers.amazon.aws.hooks.eks.EksHook.generate_config_file")
     
@mock.patch("airflow.providers.amazon.aws.hooks.eks.EksHook._secure_credential_context")
diff --git a/providers/amazon/tests/unit/amazon/aws/triggers/test_eks.py 
b/providers/amazon/tests/unit/amazon/aws/triggers/test_eks.py
index 9adb0859a4e..163aaf3963a 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_eks.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_eks.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import datetime
+from contextlib import AbstractContextManager
 from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
 
 import pytest
@@ -369,6 +370,34 @@ class 
TestEksDeleteClusterTriggerDeleteNodegroupsAndFargateProfiles(TestEksTrigg
 class TestEksPodTrigger:
     """Tests for EksPodTrigger."""
 
+    @pytest.mark.asyncio
+    @patch("pathlib.Path.read_text", autospec=True, return_value="apiVersion: 
v1\nkind: Config\n")
+    @patch(
+        
"airflow.providers.amazon.aws.hooks.eks.EksHook.generate_config_file_with_credentials",
 autospec=True
+    )
+    
@patch("airflow.providers.cncf.kubernetes.triggers.pod.KubernetesPodTrigger.run",
 autospec=True)
+    async def test_keeps_shared_auth_context_while_polling(self, parent_run, 
generate_config, read_text):
+        config_context = MagicMock(spec=AbstractContextManager)
+        config_context.__enter__.return_value = ("/tmp/kubeconfig", 
"/tmp/credentials")
+        generate_config.return_value = config_context
+
+        async def events():
+            config_context.__exit__.assert_not_called()
+            yield TriggerEvent({"status": "success"})
+
+        parent_run.return_value = events()
+        trigger = self._create_trigger()
+        events_received = [event async for event in trigger.run()]
+
+        assert events_received == [TriggerEvent({"status": "success"})]
+        assert trigger.config_dict == {"apiVersion": "v1", "kind": "Config"}
+        generate_config.assert_called_once()
+        assert generate_config.call_args.kwargs == {
+            "eks_cluster_name": CLUSTER_NAME,
+            "pod_namespace": "default",
+        }
+        config_context.__exit__.assert_called_once()
+
     TRIGGER_START_TIME = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
 
     def _create_trigger(self, **overrides):

Reply via email to