This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 42e885f96783 feat(kinesis): Cross-account Kinesis source support via
STS assume-role in JsonKinesisSource (#19383)
42e885f96783 is described below
commit 42e885f9678321711e0d1563b1f651efac5794f5
Author: Surya Dhaneshwar <[email protected]>
AuthorDate: Tue Aug 18 05:30:38 2026 -0700
feat(kinesis): Cross-account Kinesis source support via STS assume-role in
JsonKinesisSource (#19383)
* Cross-account Kinesis: assume-role support in JsonKinesisSource
Kinesis has no resource-based policy, so reading a stream in a different AWS
account requires IAM role assumption (sts:AssumeRole). Add an optional
role-ARN
config (hoodie.streamer.source.kinesis.role.arn) that, when set, builds the
KinesisClient with an STS assume-role credentials provider (AWS SDK v2). The
base STS client uses the default credential chain; static access/secret keys
still take precedence. Empty/absent preserves the existing same-account
behavior. No external ID is used.
The assume-role provider is cached per region|roleArn so a long-lived
streaming
executor reuses one StsClient per distinct role instead of leaking one per
mapPartitions task (AWS SDK v2 does not close a user-supplied credentials
provider when the KinesisClient closes).
Adds software.amazon.awssdk:sts to hudi-utilities and unit coverage for the
config-key contract, ARN serialization to executors, and the credential-
provider branch selection.
* review(19383): add external id/session name, non-tautological tests,
javadoc fixes
Addresses the review findings on the cross-account Kinesis assume-role
support:
- KinesisSourceConfig: add hoodie.streamer.source.kinesis.role.external.id
(no default) and
role.session.name (default "hudi-kinesis-source"), mirroring
HoodieAWSConfig; the same gap
HUDI-7699 (#11134) closed for the Glue assume-role provider.
KINESIS_ROLE_ARN moves to
sinceVersion 1.3.0 and its doc now states the static-key precedence and
that endpoint.url
applies to Kinesis only (STS always uses the regional endpoint).
- KinesisOffsetGen: thread external id + session name into the
AssumeRoleRequest and the
provider cache key; rename assumeRoleProvider to
getOrCreateAssumeRoleProvider; correct the
javadoc (AWS SDK v2 does close a user-supplied provider on client close;
the cache is safe
only because StsAssumeRoleCredentialsProvider.close() never closes the
injected StsClient,
so asyncCredentialUpdateEnabled must stay off); ASCII-only comments.
- KinesisReadConfig / JsonKinesisSource: carry the two new fields to
executors; extract the
read-config construction into a package-private buildReadConfig for
testability.
- Tests: TestKinesisOffsetGenClient now asserts the selected credentials
provider via
serviceClientConfiguration().credentialsProvider() (previously
assertNotNull(client), which
passed with the roleArn branch deleted) and pins the per-role/external-id
provider cache;
TestJsonKinesisSource gains testBuildReadConfigCarriesCredentialProps
covering the props to
KinesisReadConfig plumbing; TestKinesisReadConfig (JDK/Lombok/constant
checks) is dropped.
- pom.xml: correct the sts dependency comment (already transitive via
hudi-aws).
* review(19383): make the STS request testable, normalize blank role configs
Round-2 review follow-ups on the cross-account Kinesis assume-role support:
- KinesisOffsetGen: extract the AssumeRoleRequest into a package-private
buildAssumeRoleRequest so
role ARN, external id and session name are asserted directly (the
provider hides its request);
treat blank role.external.id / role.session.name as unset / default
instead of sending "" to STS
and failing lazily on an executor; cache key uses "" for a missing
external id.
- TestKinesisOffsetGenClient: cache test now varies the session name and
closes the first client
before asserting reuse; the props test uses the cache as oracle for the
full role tuple; new
tests for the request fields and the blank-value normalization.
---------
Co-authored-by: voon <[email protected]>
---
hudi-utilities/pom.xml | 7 +
.../hudi/utilities/config/KinesisSourceConfig.java | 30 +++++
.../hudi/utilities/sources/JsonKinesisSource.java | 22 +++-
.../sources/helpers/KinesisOffsetGen.java | 66 +++++++++-
.../sources/helpers/KinesisReadConfig.java | 3 +
.../utilities/sources/TestJsonKinesisSource.java | 40 ++++++
.../helpers/TestKinesisOffsetGenClient.java | 145 +++++++++++++++++++++
7 files changed, 306 insertions(+), 7 deletions(-)
diff --git a/hudi-utilities/pom.xml b/hudi-utilities/pom.xml
index 05906ac8580d..35496f4051f2 100644
--- a/hudi-utilities/pom.xml
+++ b/hudi-utilities/pom.xml
@@ -571,6 +571,13 @@
<artifactId>kinesis</artifactId>
<version>${aws.sdk.version}</version>
</dependency>
+ <!-- STS: assume-role credentials for cross-account Kinesis reads. Already
on the classpath transitively
+ via hudi-aws; declared directly because KinesisOffsetGen references
StsClient itself. -->
+ <dependency>
+ <groupId>software.amazon.awssdk</groupId>
+ <artifactId>sts</artifactId>
+ <version>${aws.sdk.version}</version>
+ </dependency>
<!-- KPL de-aggregation: extracts user records from Kinesis Producer
Library aggregated records -->
<dependency>
<groupId>com.amazonaws</groupId>
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/KinesisSourceConfig.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/KinesisSourceConfig.java
index 1ad17d5efc71..a9f3fd4688f9 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/KinesisSourceConfig.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/KinesisSourceConfig.java
@@ -76,6 +76,36 @@ public class KinesisSourceConfig extends HoodieConfig {
.withDocumentation("AWS secret key for Kinesis. Used when connecting to
custom endpoints (e.g., LocalStack). "
+ "If not set with endpoint, uses the default AWS credential
chain.");
+ public static final ConfigProperty<String> KINESIS_ROLE_ARN = ConfigProperty
+ .key(PREFIX + "role.arn")
+ .noDefaultValue()
+ .sinceVersion("1.3.0")
+ .markAdvanced()
+ .withDocumentation("IAM role ARN to assume via STS when the Kinesis
stream lives in a different AWS "
+ + "account than the application. When set, and no static
access/secret key is configured (static keys "
+ + "take precedence), the Kinesis client uses an auto-refreshing
StsAssumeRoleCredentialsProvider whose "
+ + "base credentials come from the default credential chain, which
must be allowed sts:AssumeRole on this "
+ + "role. When empty/absent, the stream is read with the default
credential chain (same-account behavior). "
+ + "The STS call always goes to the regional STS endpoint of " +
PREFIX + "region; " + PREFIX
+ + "endpoint.url applies to Kinesis only. See also " + PREFIX +
"role.external.id and " + PREFIX
+ + "role.session.name.");
+
+ public static final ConfigProperty<String> KINESIS_ROLE_EXTERNAL_ID =
ConfigProperty
+ .key(PREFIX + "role.external.id")
+ .noDefaultValue()
+ .sinceVersion("1.3.0")
+ .markAdvanced()
+ .withDocumentation("External ID passed to sts:AssumeRole when assuming "
+ PREFIX + "role.arn, for roles "
+ + "whose trust policy carries an sts:ExternalId condition. Ignored
when the role ARN is not set.");
+
+ public static final ConfigProperty<String> KINESIS_ROLE_SESSION_NAME =
ConfigProperty
+ .key(PREFIX + "role.session.name")
+ .defaultValue("hudi-kinesis-source")
+ .sinceVersion("1.3.0")
+ .markAdvanced()
+ .withDocumentation("STS role session name used when assuming " + PREFIX
+ "role.arn. Shows up in the "
+ + "target account's CloudTrail, so set it per pipeline when several
jobs share one role.");
+
public static final ConfigProperty<Long> MAX_EVENTS_FROM_KINESIS_SOURCE =
ConfigProperty
.key(PREFIX + "max.events")
.defaultValue(5000000L)
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/JsonKinesisSource.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/JsonKinesisSource.java
index 3a297467c82e..42e850efc78b 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/JsonKinesisSource.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/JsonKinesisSource.java
@@ -20,6 +20,7 @@ package org.apache.hudi.utilities.sources;
import org.apache.hudi.common.config.TypedProperties;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.VisibleForTesting;
import org.apache.hudi.exception.HoodieException;
import org.apache.hudi.utilities.config.KinesisSourceConfig;
import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics;
@@ -108,15 +109,22 @@ public class JsonKinesisSource extends
KinesisSource<JavaRDD<String>> {
this.offsetGen = new KinesisOffsetGen(props);
}
- @Override
- protected JavaRDD<String> toBatch(KinesisOffsetGen.KinesisShardRange[]
shardRanges, long sourceLimit) {
+ /**
+ * Builds the serializable per-batch read config shipped to executors; the
Kinesis client is rebuilt from
+ * it inside {@code mapPartitions}, so every credential-related property
must be carried here.
+ */
+ @VisibleForTesting
+ KinesisReadConfig buildReadConfig(KinesisOffsetGen.KinesisShardRange[]
shardRanges, long sourceLimit) {
long numEvents = calculateNumEvents(sourceLimit, props);
- KinesisReadConfig readConfig = new KinesisReadConfig(
+ return new KinesisReadConfig(
offsetGen.getStreamName(),
offsetGen.getRegion(),
offsetGen.getEndpointUrl().orElse(null),
getStringWithAltKeys(props, KinesisSourceConfig.KINESIS_ACCESS_KEY,
null),
getStringWithAltKeys(props, KinesisSourceConfig.KINESIS_SECRET_KEY,
null),
+ getStringWithAltKeys(props, KinesisSourceConfig.KINESIS_ROLE_ARN,
null),
+ getStringWithAltKeys(props,
KinesisSourceConfig.KINESIS_ROLE_EXTERNAL_ID, null),
+ getStringWithAltKeys(props,
KinesisSourceConfig.KINESIS_ROLE_SESSION_NAME, true),
offsetGen.getStartingPositionStrategy(),
shouldAddMetaFields,
getBooleanWithAltKeys(props,
KinesisSourceConfig.KINESIS_ENABLE_DEAGGREGATION),
@@ -127,6 +135,11 @@ public class JsonKinesisSource extends
KinesisSource<JavaRDD<String>> {
getLongWithAltKeys(props,
KinesisSourceConfig.KINESIS_RETRY_INITIAL_INTERVAL_MS),
getLongWithAltKeys(props,
KinesisSourceConfig.KINESIS_RETRY_MAX_INTERVAL_MS),
getLongWithAltKeys(props,
KinesisSourceConfig.KINESIS_THROTTLE_TIMEOUT_MS));
+ }
+
+ @Override
+ protected JavaRDD<String> toBatch(KinesisOffsetGen.KinesisShardRange[]
shardRanges, long sourceLimit) {
+ KinesisReadConfig readConfig = buildReadConfig(shardRanges, sourceLimit);
JavaRDD<ShardFetchResult> fetchRdd = sparkContext.parallelize(
java.util.Arrays.asList(shardRanges), shardRanges.length)
@@ -134,7 +147,8 @@ public class JsonKinesisSource extends
KinesisSource<JavaRDD<String>> {
List<ShardFetchResult> results = new ArrayList<>();
try (KinesisClient client = KinesisOffsetGen.createKinesisClient(
readConfig.getRegion(), readConfig.getEndpointUrl(),
- readConfig.getAccessKey(), readConfig.getSecretKey())) {
+ readConfig.getAccessKey(), readConfig.getSecretKey(),
+ readConfig.getRoleArn(), readConfig.getRoleExternalId(),
readConfig.getRoleSessionName())) {
while (shardRangeIt.hasNext()) {
KinesisOffsetGen.KinesisShardRange range = shardRangeIt.next();
// Lazy iterator: fetches one GetRecords page at a time, keeping
only one page in
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KinesisOffsetGen.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KinesisOffsetGen.java
index 27d160c3de0f..9085e2522445 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KinesisOffsetGen.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KinesisOffsetGen.java
@@ -21,6 +21,7 @@ package org.apache.hudi.utilities.sources.helpers;
import org.apache.hudi.common.config.TypedProperties;
import org.apache.hudi.common.table.checkpoint.Checkpoint;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.common.util.VisibleForTesting;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.utilities.config.KinesisSourceConfig;
@@ -41,6 +42,9 @@ import
software.amazon.awssdk.services.kinesis.model.ListShardsResponse;
import
software.amazon.awssdk.services.kinesis.model.ProvisionedThroughputExceededException;
import software.amazon.awssdk.services.kinesis.model.ResourceNotFoundException;
import software.amazon.awssdk.services.kinesis.model.Shard;
+import software.amazon.awssdk.services.sts.StsClient;
+import
software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
+import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
import java.math.BigInteger;
import java.net.URI;
@@ -50,6 +54,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -304,20 +309,71 @@ public class KinesisOffsetGen {
getStringWithAltKeys(props,
KinesisSourceConfig.KINESIS_STARTING_POSITION, true));
}
+ /**
+ * Assume-role credentials providers cached by {@code
region|roleArn|externalId|sessionName}. Kinesis
+ * clients are built per {@code mapPartitions} task, so building a fresh
StsClient (with its own HTTP
+ * connection pool) each time would pile one up per micro-batch on a
long-lived streaming executor. Note
+ * that AWS SDK v2 DOES close a user-supplied credentials provider when the
KinesisClient closes, but
+ * StsAssumeRoleCredentialsProvider.close() only stops background prefetch
(a no-op with the default
+ * synchronous refresh strategy) and never closes the injected StsClient, so
a cached provider stays
+ * usable across clients. For that reason asyncCredentialUpdateEnabled must
never be turned on for this
+ * shared provider: the first client close would permanently disable its
background refresh. Providers
+ * live for the JVM lifetime and are intentionally never closed.
+ */
+ private static final Map<String, StsAssumeRoleCredentialsProvider>
ASSUME_ROLE_PROVIDERS =
+ new ConcurrentHashMap<>();
+
+ private static StsAssumeRoleCredentialsProvider
getOrCreateAssumeRoleProvider(
+ String region, String roleArn, String roleExternalId, String
roleSessionName) {
+ String cacheKey = String.join("|", region, roleArn, roleExternalId == null
? "" : roleExternalId, roleSessionName);
+ return ASSUME_ROLE_PROVIDERS.computeIfAbsent(cacheKey, ignored ->
+ StsAssumeRoleCredentialsProvider.builder()
+ // Regional STS endpoint of the stream's region; the Kinesis
endpoint.url override is
+ // deliberately not applied here (it may be a Kinesis-only VPC
interface endpoint).
+ .stsClient(StsClient.builder().region(Region.of(region)).build())
+ .refreshRequest(buildAssumeRoleRequest(roleArn, roleExternalId,
roleSessionName))
+ .build());
+ }
+
+ /**
+ * The STS request behind the cached provider; a null external id is omitted
from the request.
+ */
+ @VisibleForTesting
+ static AssumeRoleRequest buildAssumeRoleRequest(String roleArn, String
roleExternalId, String roleSessionName) {
+ return AssumeRoleRequest.builder()
+ .roleArn(roleArn)
+ .roleSessionName(roleSessionName)
+ .externalId(roleExternalId)
+ .build();
+ }
+
/**
* Builds a Kinesis client from explicit parameters. Used by both the
instance method
* {@link #createKinesisClient()} and by {@link
org.apache.hudi.utilities.sources.JsonKinesisSource}
- * from serializable {@link KinesisReadConfig} in Spark closures.
+ * from serializable {@link KinesisReadConfig} in Spark closures. Credential
precedence: static
+ * access/secret keys, then STS assume-role when {@code roleArn} is set,
else the default chain.
*/
public static KinesisClient createKinesisClient(String region, String
endpointUrl,
- String accessKey, String secretKey) {
+ String accessKey, String secretKey, String roleArn, String
roleExternalId, String roleSessionName) {
KinesisClientBuilder builder =
KinesisClient.builder().region(Region.of(region));
if (endpointUrl != null && !endpointUrl.isEmpty()) {
builder = builder.endpointOverride(URI.create(endpointUrl));
}
if (accessKey != null && !accessKey.isEmpty() && secretKey != null &&
!secretKey.isEmpty()) {
+ // Static credentials (e.g. LocalStack / custom endpoint) take
precedence.
builder = builder.credentialsProvider(
StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey,
secretKey)));
+ } else if (roleArn != null && !roleArn.isEmpty()) {
+ // Cross-account stream: assume the role via STS with a per-JVM cached
provider (see
+ // ASSUME_ROLE_PROVIDERS). The base STS client uses the default
credential chain, which must be
+ // granted sts:AssumeRole on this ARN. Blank values (e.g.
"role.external.id=" in a templated
+ // properties file) would be sent to STS verbatim and rejected lazily on
the first request, so
+ // treat them as unset.
+ String externalId = StringUtils.isNullOrEmpty(roleExternalId) ? null :
roleExternalId;
+ String sessionName = StringUtils.isNullOrEmpty(roleSessionName)
+ ? KinesisSourceConfig.KINESIS_ROLE_SESSION_NAME.defaultValue() :
roleSessionName;
+ builder = builder.credentialsProvider(
+ getOrCreateAssumeRoleProvider(region, roleArn, externalId,
sessionName));
}
return builder.build();
}
@@ -325,7 +381,11 @@ public class KinesisOffsetGen {
public KinesisClient createKinesisClient() {
String accessKey = getStringWithAltKeys(props,
KinesisSourceConfig.KINESIS_ACCESS_KEY, null);
String secretKey = getStringWithAltKeys(props,
KinesisSourceConfig.KINESIS_SECRET_KEY, null);
- return createKinesisClient(region, endpointUrl.orElse(null), accessKey,
secretKey);
+ String roleArn = getStringWithAltKeys(props,
KinesisSourceConfig.KINESIS_ROLE_ARN, null);
+ String roleExternalId = getStringWithAltKeys(props,
KinesisSourceConfig.KINESIS_ROLE_EXTERNAL_ID, null);
+ String roleSessionName = getStringWithAltKeys(props,
KinesisSourceConfig.KINESIS_ROLE_SESSION_NAME, true);
+ return createKinesisClient(region, endpointUrl.orElse(null), accessKey,
secretKey,
+ roleArn, roleExternalId, roleSessionName);
}
/**
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KinesisReadConfig.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KinesisReadConfig.java
index 7fe53f24d0f9..c1331da46503 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KinesisReadConfig.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KinesisReadConfig.java
@@ -40,6 +40,9 @@ public class KinesisReadConfig implements Serializable {
private final String endpointUrl; // null if not set
private final String accessKey; // null if not set
private final String secretKey; // null if not set
+ private final String roleArn; // null if not set; cross-account stream reads
via STS assume-role
+ private final String roleExternalId; // null if not set; sts:ExternalId for
the assumed role
+ private final String roleSessionName; // STS session name for the assumed
role
private final KinesisSourceConfig.KinesisStartingPositionStrategy
startingPosition;
private final boolean metaFieldsEnabled;
private final boolean deaggregationEnabled;
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKinesisSource.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKinesisSource.java
index 25450dd7c8a1..cce8c3af3aae 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKinesisSource.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKinesisSource.java
@@ -21,9 +21,11 @@ package org.apache.hudi.utilities.sources;
import org.apache.hudi.common.config.TypedProperties;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
+import org.apache.hudi.utilities.config.KinesisSourceConfig;
import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics;
import org.apache.hudi.utilities.schema.SchemaProvider;
import org.apache.hudi.utilities.sources.helpers.KinesisOffsetGen;
+import org.apache.hudi.utilities.sources.helpers.KinesisReadConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -446,6 +448,44 @@ class TestJsonKinesisSource extends
SparkClientFunctionalTestHarness {
"Unread shard should not have arrival time in checkpoint");
}
+ // --- buildReadConfig tests ---
+
+ @Test
+ void testBuildReadConfigCarriesCredentialProps() {
+ TypedProperties props = new TypedProperties();
+ props.setProperty(KINESIS_STREAM_NAME.key(), STREAM_NAME);
+ props.setProperty(KINESIS_REGION.key(), "us-east-1");
+ props.setProperty(KINESIS_STARTING_POSITION.key(), "TRIM_HORIZON");
+ props.setProperty(KinesisSourceConfig.KINESIS_ENDPOINT_URL.key(),
"http://localhost:4566");
+ props.setProperty(KinesisSourceConfig.KINESIS_ACCESS_KEY.key(),
"access-1");
+ props.setProperty(KinesisSourceConfig.KINESIS_SECRET_KEY.key(),
"secret-1");
+ props.setProperty(KinesisSourceConfig.KINESIS_ROLE_ARN.key(),
"arn:aws:iam::123456789012:role/reader");
+ props.setProperty(KinesisSourceConfig.KINESIS_ROLE_EXTERNAL_ID.key(),
"ext-1");
+ props.setProperty(KinesisSourceConfig.KINESIS_ROLE_SESSION_NAME.key(),
"session-1");
+ TestableJsonKinesisSource withCreds = new TestableJsonKinesisSource(
+ props, jsc(), spark(), null, mock(HoodieIngestionMetrics.class));
+
+ KinesisReadConfig readConfig = withCreds.buildReadConfig(new
KinesisOffsetGen.KinesisShardRange[0], 1000L);
+
+ assertEquals(STREAM_NAME, readConfig.getStreamName());
+ assertEquals("us-east-1", readConfig.getRegion());
+ assertEquals("http://localhost:4566", readConfig.getEndpointUrl());
+ assertEquals("access-1", readConfig.getAccessKey());
+ assertEquals("secret-1", readConfig.getSecretKey());
+ assertEquals("arn:aws:iam::123456789012:role/reader",
readConfig.getRoleArn());
+ assertEquals("ext-1", readConfig.getRoleExternalId());
+ assertEquals("session-1", readConfig.getRoleSessionName());
+
+ // Defaults: no credentials configured means nulls, and the session name
falls back to the config default.
+ KinesisReadConfig defaults = source.buildReadConfig(new
KinesisOffsetGen.KinesisShardRange[0], 1000L);
+ assertNull(defaults.getEndpointUrl());
+ assertNull(defaults.getAccessKey());
+ assertNull(defaults.getSecretKey());
+ assertNull(defaults.getRoleArn());
+ assertNull(defaults.getRoleExternalId());
+ assertEquals(KinesisSourceConfig.KINESIS_ROLE_SESSION_NAME.defaultValue(),
defaults.getRoleSessionName());
+ }
+
private JavaRDD<String> emptyRdd() {
return jsc().emptyRDD();
}
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestKinesisOffsetGenClient.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestKinesisOffsetGenClient.java
new file mode 100644
index 000000000000..87ec2d99e648
--- /dev/null
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestKinesisOffsetGenClient.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.utilities.sources.helpers;
+
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.utilities.config.KinesisSourceConfig;
+
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.identity.spi.IdentityProvider;
+import software.amazon.awssdk.services.kinesis.KinesisClient;
+import
software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
+import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+/**
+ * Covers the credential-provider branches of {@link
KinesisOffsetGen#createKinesisClient}, the per-JVM
+ * assume-role provider cache and the STS request it builds. AWS SDK v2
clients resolve credentials
+ * lazily (only on the first request), so a client can be built for each
branch without any AWS
+ * environment or network access, and the chosen provider is observable through
+ * {@code serviceClientConfiguration().credentialsProvider()}.
+ */
+class TestKinesisOffsetGenClient {
+
+ private static final String REGION = "us-west-2";
+ private static final String ROLE_ARN_PREFIX =
"arn:aws:iam::123456789012:role/kinesis-cross-account-";
+ private static final String SESSION_NAME = "hudi-kinesis-source";
+
+ private static IdentityProvider<?> providerOf(KinesisClient client) {
+ return client.serviceClientConfiguration().credentialsProvider();
+ }
+
+ @Test
+ void testAssumeRoleProviderUsedWhenRoleArnPresent() {
+ try (KinesisClient client = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, ROLE_ARN_PREFIX + "arn-only", null,
SESSION_NAME)) {
+ assertInstanceOf(StsAssumeRoleCredentialsProvider.class,
providerOf(client));
+ }
+ }
+
+ @Test
+ void testDefaultChainUsedWhenNoRoleArnOrKeys() {
+ try (KinesisClient client = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, null, null, SESSION_NAME)) {
+ assertInstanceOf(DefaultCredentialsProvider.class, providerOf(client));
+ }
+ }
+
+ @Test
+ void testStaticKeysTakePrecedenceOverAssumeRole() {
+ // Both static keys and a role ARN set: the static-credentials branch wins
(no STS assume-role).
+ try (KinesisClient client = KinesisOffsetGen.createKinesisClient(
+ REGION, null, "access", "secret", ROLE_ARN_PREFIX + "with-keys", null,
SESSION_NAME)) {
+ assertInstanceOf(StaticCredentialsProvider.class, providerOf(client));
+ }
+ }
+
+ @Test
+ void testAssumeRoleProviderCachedPerRoleExternalIdAndSession() {
+ String roleArn = ROLE_ARN_PREFIX + "cached";
+ IdentityProvider<?> firstProvider;
+ try (KinesisClient first = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, roleArn, "ext-1", SESSION_NAME)) {
+ firstProvider = providerOf(first);
+ }
+ // The first client is closed above; the cached provider must still be
handed to later clients with
+ // the same role tuple, and any change to external id, session name or
role must yield a new one.
+ try (KinesisClient second = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, roleArn, "ext-1", SESSION_NAME);
+ KinesisClient otherExternalId = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, roleArn, "ext-2", SESSION_NAME);
+ KinesisClient otherSession = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, roleArn, "ext-1", "other-session");
+ KinesisClient otherRole = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, ROLE_ARN_PREFIX + "cached-other",
"ext-1", SESSION_NAME)) {
+ assertSame(firstProvider, providerOf(second));
+ assertNotSame(firstProvider, providerOf(otherExternalId));
+ assertNotSame(firstProvider, providerOf(otherSession));
+ assertNotSame(firstProvider, providerOf(otherRole));
+ }
+ }
+
+ @Test
+ void testInstanceClientForwardsRoleTupleFromProps() {
+ String roleArn = ROLE_ARN_PREFIX + "from-props";
+ TypedProperties props = new TypedProperties();
+ props.setProperty(KinesisSourceConfig.KINESIS_STREAM_NAME.key(),
"test-stream");
+ props.setProperty(KinesisSourceConfig.KINESIS_REGION.key(), REGION);
+ props.setProperty(KinesisSourceConfig.KINESIS_ROLE_ARN.key(), roleArn);
+ props.setProperty(KinesisSourceConfig.KINESIS_ROLE_EXTERNAL_ID.key(),
"ext-props");
+
+ try (KinesisClient fromProps = new
KinesisOffsetGen(props).createKinesisClient();
+ KinesisClient expected = KinesisOffsetGen.createKinesisClient(REGION,
null, null, null,
+ roleArn, "ext-props",
KinesisSourceConfig.KINESIS_ROLE_SESSION_NAME.defaultValue())) {
+ // The cache is keyed by the full role tuple, so provider identity
proves that role ARN, external
+ // id and the defaulted session name all reached the client in the right
slots.
+ assertSame(providerOf(expected), providerOf(fromProps));
+ }
+ }
+
+ @Test
+ void testAssumeRoleRequestCarriesRoleExternalIdAndSession() {
+ String roleArn = ROLE_ARN_PREFIX + "request";
+ AssumeRoleRequest request =
KinesisOffsetGen.buildAssumeRoleRequest(roleArn, "ext-req", "sess-req");
+ assertEquals(roleArn, request.roleArn());
+ assertEquals("ext-req", request.externalId());
+ assertEquals("sess-req", request.roleSessionName());
+ // No external id: omitted from the request rather than sent as an empty
string.
+ assertNull(KinesisOffsetGen.buildAssumeRoleRequest(roleArn, null,
"sess-req").externalId());
+ }
+
+ @Test
+ void testBlankExternalIdAndSessionNameTreatedAsUnset() {
+ String roleArn = ROLE_ARN_PREFIX + "blank";
+ try (KinesisClient blank = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, roleArn, "", "");
+ KinesisClient unset = KinesisOffsetGen.createKinesisClient(
+ REGION, null, null, null, roleArn, null,
KinesisSourceConfig.KINESIS_ROLE_SESSION_NAME.defaultValue())) {
+ // Blank external id / session name normalize to unset / default, so
both hit the same cache entry.
+ assertSame(providerOf(unset), providerOf(blank));
+ }
+ }
+}