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

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


The following commit(s) were added to refs/heads/main by this push:
     new 3e20d9054d7 Log the AWS identity used by AwsDuckDBHook (#75997)
3e20d9054d7 is described below

commit 3e20d9054d70cfbc111aa237a07b232358b961be
Author: Niko Oliveira <[email protected]>
AuthorDate: Mon Nov 2 18:34:44 2026 -0500

    Log the AWS identity used by AwsDuckDBHook (#75997)
    
    DuckDB reports a denied S3 request only as "HTTP 403", without saying
    which credentials it used, so the AWS identity is the first thing to
    check. sts:GetCallerIdentity returns it and needs no IAM permission.
    
    The lookup is only a debugging aid and must never affect the task. It
    has short timeouts and no retries, and a failure is logged at debug
    level and ignored.
---
 .../airflow/providers/amazon/aws/hooks/duckdb.py   | 39 ++++++++++++++-
 .../tests/unit/amazon/aws/hooks/test_duckdb.py     | 57 ++++++++++++++++++++++
 2 files changed, 95 insertions(+), 1 deletion(-)

diff --git a/providers/amazon/src/airflow/providers/amazon/aws/hooks/duckdb.py 
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/duckdb.py
index 3f6e800ae7f..bf91a0d345d 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/duckdb.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/duckdb.py
@@ -20,7 +20,10 @@ from functools import cached_property
 from typing import TYPE_CHECKING, Any, Literal
 from urllib.parse import urlsplit
 
+from botocore.config import Config
+
 from airflow.providers.amazon.aws.hooks.base_aws import AwsBaseHook
+from airflow.providers.amazon.aws.hooks.sts import StsHook
 from airflow.providers.amazon.version_compat import NOTSET, ArgNotSet
 from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
 
@@ -138,6 +141,7 @@ class AwsDuckDBHook(DuckDBHook):
         self._credential_chain = credential_chain
         self._s3_endpoint_url = s3_endpoint_url
         self._secret_name = secret_name
+        self._aws_identity_looked_up = False
 
     @property
     def aws_conn_id(self) -> str | None:
@@ -197,7 +201,7 @@ class AwsDuckDBHook(DuckDBHook):
         return self.connection_extra.get("endpoint_url") or 
self.aws_hook.conn_config.endpoint_url
 
     def configure_secrets(self, conn: DuckDBPyConnection) -> None:
-        """Create the DuckDB S3 secret for this connection."""
+        """Create the DuckDB S3 secret for this connection, and log the AWS 
identity it uses."""
         strategy = self.credential_strategy
         if strategy == "none":
             self.log.debug("credential_strategy is 'none'; not creating a 
DuckDB S3 secret.")
@@ -232,6 +236,39 @@ class AwsDuckDBHook(DuckDBHook):
                 error,
             )
 
+        self._log_aws_identity(strategy, region, endpoint_url)
+
+    def _log_aws_identity(
+        self, strategy: CredentialStrategy, region: str | None, endpoint_url: 
str | None
+    ) -> None:
+        """Log the AWS identity once per hook, to help debug S3 ``HTTP 403`` 
errors."""
+        if self._aws_identity_looked_up:
+            return
+        self._aws_identity_looked_up = True
+        try:
+            if endpoint_url:
+                host = urlsplit(endpoint_url if "//" in endpoint_url else 
f"//{endpoint_url}").hostname or ""
+                if not host.endswith((".amazonaws.com", ".amazonaws.com.cn")):
+                    return  # Not AWS (e.g. MinIO), so no AWS identity applies.
+            aws_config = self.aws_hook.config
+            bounded = Config(
+                # Keeps an unreachable STS endpoint from delaying the task by 
more than a few seconds.
+                connect_timeout=2,
+                read_timeout=2,
+                retries={"total_max_attempts": 1},
+                # Caller names only the operator (_PythonDecoratedOperator for 
@task), so name the hook too.
+                user_agent_extra=f"AwsDuckDBHook {aws_config.user_agent_extra 
or ''}".strip(),
+            )
+            sts_hook = StsHook(
+                # DuckDB's credential chain ignores the connection's 
credentials, so boto3 has to as well.
+                aws_conn_id=self.aws_conn_id if strategy == "config" else None,
+                region_name=region,
+                config=aws_config.merge(bounded),
+            )
+            self.log.info("AWS identity: %s", 
sts_hook.get_conn().get_caller_identity()["Arn"])
+        except Exception as error:  # Not BaseException: a task timeout must 
still stop the task.
+            self.log.debug("Could not look up the AWS identity: %s", error)
+
     def _provider_clauses(self, strategy: CredentialStrategy) -> list[str]:
         if strategy == "credential_chain":
             clauses = ["PROVIDER credential_chain"]
diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_duckdb.py 
b/providers/amazon/tests/unit/amazon/aws/hooks/test_duckdb.py
index ce72e4313fe..4ee6d6a8b6e 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_duckdb.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_duckdb.py
@@ -21,6 +21,8 @@ from unittest import mock
 
 import duckdb
 import pytest
+from botocore.config import Config
+from botocore.exceptions import EndpointConnectionError
 
 from airflow.models.connection import Connection
 from airflow.providers.amazon.aws.hooks.duckdb import AwsDuckDBHook
@@ -29,6 +31,9 @@ from airflow.providers.duckdb.operators.duckdb import 
DuckDBExecuteQueryOperator
 
 GET_CONNECTION = 
"airflow.providers.amazon.aws.hooks.duckdb.AwsDuckDBHook.get_connection"
 AWS_BASE_HOOK = "airflow.providers.amazon.aws.hooks.duckdb.AwsBaseHook"
+STS_HOOK = "airflow.providers.amazon.aws.hooks.duckdb.StsHook"
+ARN = "arn:aws:sts::123456789012:assumed-role/airflow-worker/session"
+S3_VPC_ENDPOINT = 
"https://bucket.vpce-1a2b3c4d-5e6f.s3.us-east-1.vpce.amazonaws.com";
 
 try:
     from airflow.sdk.exceptions import AirflowNotFoundException
@@ -305,3 +310,55 @@ class TestAwsDuckDBHookConnectionOverrides:
         with mock.patch(GET_CONNECTION, 
return_value=self.duckdb_connection(secret_name="from_extra")):
             statement = secret_statement(AwsDuckDBHook(secret_name="explicit"))
         assert statement.startswith("CREATE OR REPLACE SECRET explicit (")
+
+
+class TestAwsDuckDBHookIdentityLookup:
+    @pytest.mark.usefixtures("no_duckdb_connection")
+    @pytest.mark.parametrize(
+        ("hook_kwargs", "sts_conn_id"),
+        [
+            pytest.param({}, None, id="credential-chain"),
+            pytest.param({"credential_strategy": "config"}, "aws_default", 
id="config"),
+            pytest.param({"s3_endpoint_url": S3_VPC_ENDPOINT}, None, 
id="s3-vpc-endpoint"),
+        ],
+    )
+    @mock.patch(STS_HOOK, autospec=True)
+    def test_identity_is_logged(self, mock_sts_hook, aws_base_hook, 
hook_kwargs, sts_conn_id):
+        aws_base_hook.config = Config(user_agent_extra="from-connection")
+        
mock_sts_hook.return_value.get_conn.return_value.get_caller_identity.return_value
 = {"Arn": ARN}
+
+        with mock.patch.object(AwsDuckDBHook, "log") as mock_log:
+            
AwsDuckDBHook(**hook_kwargs).configure_secrets(mock.MagicMock(spec=duckdb.DuckDBPyConnection))
+
+        mock_sts_hook.assert_called_once_with(
+            aws_conn_id=sts_conn_id, region_name="us-west-2", config=mock.ANY
+        )
+        assert mock_sts_hook.call_args.kwargs["config"].user_agent_extra == 
"AwsDuckDBHook from-connection"
+        mock_log.info.assert_any_call("AWS identity: %s", ARN)
+
+    @pytest.mark.usefixtures("aws_base_hook", "no_duckdb_connection")
+    @pytest.mark.parametrize(
+        "hook_kwargs",
+        [
+            pytest.param({"credential_strategy": "none"}, id="no-s3-secret"),
+            pytest.param({"s3_endpoint_url": "http://minio:9000"}, 
id="s3-compatible-endpoint"),
+        ],
+    )
+    @mock.patch(STS_HOOK, autospec=True)
+    def test_lookup_is_skipped_without_aws_s3(self, mock_sts_hook, 
hook_kwargs):
+        
AwsDuckDBHook(**hook_kwargs).configure_secrets(mock.MagicMock(spec=duckdb.DuckDBPyConnection))
+
+        mock_sts_hook.assert_not_called()
+
+    @pytest.mark.usefixtures("aws_base_hook", "no_duckdb_connection")
+    @mock.patch(STS_HOOK, autospec=True)
+    def test_lookup_is_tried_once_and_its_failure_is_ignored(self, 
mock_sts_hook):
+        get_caller_identity = 
mock_sts_hook.return_value.get_conn.return_value.get_caller_identity
+        get_caller_identity.side_effect = 
EndpointConnectionError(endpoint_url="https://sts.amazonaws.com";)
+        hook = AwsDuckDBHook()
+        conn = mock.MagicMock(spec=duckdb.DuckDBPyConnection)
+
+        hook.configure_secrets(conn)
+        hook.configure_secrets(conn)
+
+        get_caller_identity.assert_called_once_with()

Reply via email to