This is an automated email from the ASF dual-hosted git repository.
sarutak pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new ce072651373e [SPARK-58789][CORE] Add
CredentialProvider.additionalSparkProperties() SPI and auto-config wiring
ce072651373e is described below
commit ce072651373e203efe6e39bf1285fcd3a4c5e3ca
Author: Shrirang Mhalgi <[email protected]>
AuthorDate: Thu Aug 20 05:58:34 2026 +0900
[SPARK-58789][CORE] Add CredentialProvider.additionalSparkProperties() SPI
and auto-config wiring
### What changes were proposed in this pull request?
Adds a default method `additionalSparkProperties()` to the
`CredentialProvider` interface that allows provider modules to declare Spark
configuration properties needed when the provider is active. After successful
credential acquisition, `UserCredentialManager.start()` iterates active
providers (those that successfully resolved credentials) and applies their
declared properties to `SparkConf` -- only if the user has not already set them
explicitly.
`AwsStsCredentialProvider` overrides this to return
`{"spark.hadoop.fs.s3a.aws.credentials.provider":
"org.apache.spark.security.aws.SparkOidcAwsCredentialsProvider"}`, enabling
zero-config executor-side S3A credential resolution when OIDC is enabled.
### Why are the changes needed?
This is a Sub-task of the OIDC Credential Propagation SPIP. PR #57998
(`SparkOidcAwsCredentialsProvider`) originally placed auto-config logic in
`CoarseGrainedSchedulerBackend`, which was a layer violation -- core
referencing an optional module's class name. This PR moves the mechanism to a
generic SPI where each provider module declares its own properties, keeping
vendor-specific knowledge out of core.
### Does this PR introduce _any_ user-facing change?
Yes. When `spark.security.oidc.enabled=true` and `AwsStsCredentialProvider`
is on the classpath, executors automatically use
`SparkOidcAwsCredentialsProvider` for S3A access without requiring manual
Hadoop configuration. User-provided values are never overwritten.
### How was this patch tested?
- `AwsStsCredentialProviderSuite` (56 tests) -- verifies the override
returns the correct S3A mapping
- `UserCredentialManagerSuite` (24 tests) -- verifies properties are
applied to SparkConf, user-set values are not overwritten, null map handling,
and exception isolation (provider throwing from `additionalSparkProperties()`
does not crash `start()` or block other providers)
- `OidcCredentialIntegrationSuite` (13 tests) -- exercises the full
credential acquisition flow
Tested with both Maven and SBT.
### Was this patch authored or co-authored using generative AI tooling?
Yes. Co-Authored using Claude Opus 4.8
Closes #58018 from shrirangmhalgi/SPARK-58789-additional-spark-properties.
Authored-by: Shrirang Mhalgi <[email protected]>
Signed-off-by: Kousuke Saruta <[email protected]>
(cherry picked from commit e424fc652ea41fcc7be981b005899116b80686e0)
Signed-off-by: Kousuke Saruta <[email protected]>
---
.../security/aws/AwsStsCredentialProvider.java | 7 ++
.../aws/AwsStsCredentialProviderSuite.java | 29 +++++++
.../apache/spark/security/CredentialProvider.java | 29 +++++++
.../deploy/security/UserCredentialManager.scala | 41 +++++++++-
.../security/AnotherFakeCredentialProvider.java | 14 ++++
.../spark/security/FakeCredentialProvider.java | 6 ++
.../security/UserCredentialManagerSuite.scala | 89 ++++++++++++++++++++++
7 files changed, 211 insertions(+), 4 deletions(-)
diff --git
a/connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java
b/connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java
index f8b967d1fd83..db537fee6639 100644
---
a/connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java
+++
b/connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java
@@ -414,6 +414,13 @@ public class AwsStsCredentialProvider implements
CredentialProvider {
return Duration.ofMinutes(15);
}
+ @Override
+ public Map<String, String> additionalSparkProperties() {
+ return Map.of(
+ "spark.hadoop.fs.s3a.aws.credentials.provider",
+ "org.apache.spark.security.aws.SparkOidcAwsCredentialsProvider");
+ }
+
/**
* Walks the cause chain of the given throwable and checks whether the raw
token
* appears in any layer's message. Used to decide whether the original
exception
diff --git
a/connector/credential-aws/src/test/java/org/apache/spark/security/aws/AwsStsCredentialProviderSuite.java
b/connector/credential-aws/src/test/java/org/apache/spark/security/aws/AwsStsCredentialProviderSuite.java
index 5f6daa102c8a..54e54d989f4d 100644
---
a/connector/credential-aws/src/test/java/org/apache/spark/security/aws/AwsStsCredentialProviderSuite.java
+++
b/connector/credential-aws/src/test/java/org/apache/spark/security/aws/AwsStsCredentialProviderSuite.java
@@ -1002,4 +1002,33 @@ public class AwsStsCredentialProviderSuite {
.thenReturn(response);
return mockSts;
}
+
+ // ========== additionalSparkProperties() ==========
+
+ @Test
+ public void testAdditionalSparkPropertiesReturnsS3aProviderMapping() {
+ AwsStsCredentialProvider provider = new AwsStsCredentialProvider();
+ Map<String, String> props = provider.additionalSparkProperties();
+ assertEquals(1, props.size());
+ assertEquals(
+ "org.apache.spark.security.aws.SparkOidcAwsCredentialsProvider",
+ props.get("spark.hadoop.fs.s3a.aws.credentials.provider"));
+ }
+
+ @Test
+ public void testAdditionalSparkPropertiesKeyIncludesSparkHadoopPrefix() {
+ AwsStsCredentialProvider provider = new AwsStsCredentialProvider();
+ Map<String, String> props = provider.additionalSparkProperties();
+ props.keySet().forEach(key ->
+ assertTrue(key.startsWith("spark.hadoop."),
+ "Key must include spark.hadoop. prefix: " + key));
+ }
+
+ @Test
+ public void testAdditionalSparkPropertiesIsUnmodifiable() {
+ AwsStsCredentialProvider provider = new AwsStsCredentialProvider();
+ Map<String, String> props = provider.additionalSparkProperties();
+ assertThrows(UnsupportedOperationException.class,
+ () -> props.put("foo", "bar"));
+ }
}
diff --git
a/core/src/main/java/org/apache/spark/security/CredentialProvider.java
b/core/src/main/java/org/apache/spark/security/CredentialProvider.java
index a306b427535b..27015576cbfa 100644
--- a/core/src/main/java/org/apache/spark/security/CredentialProvider.java
+++ b/core/src/main/java/org/apache/spark/security/CredentialProvider.java
@@ -102,6 +102,35 @@ public interface CredentialProvider extends AutoCloseable {
return Duration.ofMinutes(15);
}
+ /**
+ * Returns additional Spark configuration properties that should be set when
this
+ * provider is active.
+ * <p>
+ * This method is called after {@link #init(Map)} and a successful
+ * {@link #resolve(UserContext, URI)} invocation. Implementations may
+ * assume that provider state is fully initialized when this is called.
+ * <p>
+ * The credential management layer applies these entries to {@code
SparkConf} after
+ * successful startup, only if the user has not already set them explicitly.
This
+ * allows provider modules to declare executor-side wiring (e.g., the Hadoop
+ * credentials provider class for a particular filesystem scheme) without
requiring
+ * core to have vendor-specific knowledge.
+ * <p>
+ * Keys must use the {@code spark.} prefix to be effective (SparkConf
convention).
+ * Keys with the {@code spark.hadoop.} prefix are propagated to executor-side
+ * Hadoop {@code Configuration} with the prefix stripped. Other {@code
spark.*}
+ * keys are applied as Spark-internal configuration.
+ * <p>
+ * The default implementation returns an empty map (no additional
properties).
+ *
+ * @return an unmodifiable map of property key-value pairs (never null).
+ * Keys and values within the map must not be {@code null}.
+ * @since 4.4.0
+ */
+ default Map<String, String> additionalSparkProperties() {
+ return Map.of();
+ }
+
/**
* Releases any resources held by this provider (e.g., HTTP clients,
connection pools).
* <p>
diff --git
a/core/src/main/scala/org/apache/spark/deploy/security/UserCredentialManager.scala
b/core/src/main/scala/org/apache/spark/deploy/security/UserCredentialManager.scala
index 5a8d6e46530a..a06bf5f1e33b 100644
---
a/core/src/main/scala/org/apache/spark/deploy/security/UserCredentialManager.scala
+++
b/core/src/main/scala/org/apache/spark/deploy/security/UserCredentialManager.scala
@@ -112,7 +112,7 @@ private[spark] class UserCredentialManager(
log"${MDC(LogKeys.PRINCIPAL, ctx.getPrincipal)} " +
log"(issuer: ${MDC(LogKeys.URI, ctx.getIssuer)})")
- val (credentials, earliestExpiry) = resolveCredentials(ctx)
+ val (credentials, earliestExpiry, activeProviders) =
resolveCredentials(ctx)
val serialized =
UserCredentialManager.serializeUserCredentials(credentials)
val version = credentialVersion.incrementAndGet()
@@ -131,6 +131,37 @@ private[spark] class UserCredentialManager(
logInfo(log"Credential acquisition successful. Next renewal in " +
log"${MDC(LogKeys.TIME_UNITS, UIUtils.formatDuration(renewalDelay))}.")
+
+ // Apply additional Spark properties declared by active providers.
+ // Only providers that successfully resolved credentials contribute
properties.
+ // This allows provider modules to wire executor-side configuration
+ // (e.g., fs.s3a.aws.credentials.provider) without core having
+ // vendor-specific knowledge. Properties are only set if the user
+ // has not already configured them explicitly.
+ for (provider <- activeProviders) {
+ try {
+ val props = provider.additionalSparkProperties()
+ if (props != null) {
+ props.forEach { (key, value) =>
+ if (!sparkConf.contains(key)) {
+ sparkConf.set(key, value)
+ logInfo(log"Auto-configured ${MDC(LogKeys.CONFIG, key)} from " +
+ log"${MDC(LogKeys.CLASS_NAME, provider.getClass.getName)}")
+ } else {
+ logDebug(log"Skipped ${MDC(LogKeys.CONFIG, key)} from " +
+ log"${MDC(LogKeys.CLASS_NAME, provider.getClass.getName)} " +
+ log"(already configured)")
+ }
+ }
+ }
+ } catch {
+ case scala.util.control.NonFatal(e) =>
+ logWarning(log"Failed to apply additionalSparkProperties from " +
+ log"${MDC(LogKeys.CLASS_NAME, provider.getClass.getName)}. " +
+ log"Skipping.", e)
+ }
+ }
+
(version, serialized)
}
@@ -170,7 +201,7 @@ private[spark] class UserCredentialManager(
log"${MDC(LogKeys.PRINCIPAL, ctx.getPrincipal)} " +
log"(issuer: ${MDC(LogKeys.URI, ctx.getIssuer)})")
- val (credentials, earliestExpiry) = resolveCredentials(ctx)
+ val (credentials, earliestExpiry, _) = resolveCredentials(ctx)
val serialized =
UserCredentialManager.serializeUserCredentials(credentials)
val version = credentialVersion.incrementAndGet()
@@ -222,10 +253,11 @@ private[spark] class UserCredentialManager(
* @return Tuple of (UserCredentials, earliest expiry across all service
credentials)
*/
private def resolveCredentials(
- ctx: UserContext): (UserCredentials, Option[Instant]) = {
+ ctx: UserContext): (UserCredentials, Option[Instant],
Seq[CredentialProvider]) = {
val schemes = discoverSchemes()
val credentialMap = new mutable.HashMap[String, ServiceCredential]()
+ val activeProviders = new mutable.ArrayBuffer[CredentialProvider]()
var earliestExpiry: Option[Instant] = None
for (scheme <- schemes) {
@@ -245,6 +277,7 @@ private[spark] class UserCredentialManager(
log"returned null; skipping.")
} else {
credentialMap.put(scheme, credential)
+ activeProviders += provider
val expiry = credential.getExpiresAt
if (expiry != null) {
@@ -271,7 +304,7 @@ private[spark] class UserCredentialManager(
"Check that providers are on the classpath and configured
correctly.")
}
- (new UserCredentials(credentialMap.asJava), earliestExpiry)
+ (new UserCredentials(credentialMap.asJava), earliestExpiry,
activeProviders.toSeq)
}
/**
diff --git
a/core/src/test/java/org/apache/spark/security/AnotherFakeCredentialProvider.java
b/core/src/test/java/org/apache/spark/security/AnotherFakeCredentialProvider.java
index b4f3ae40700b..ccc987e7588c 100644
---
a/core/src/test/java/org/apache/spark/security/AnotherFakeCredentialProvider.java
+++
b/core/src/test/java/org/apache/spark/security/AnotherFakeCredentialProvider.java
@@ -31,6 +31,12 @@ public class AnotherFakeCredentialProvider implements
CredentialProvider {
/** Sentinel URI host that triggers a CredentialResolutionException. */
public static final String ERROR_HOST = "error.example.com";
+ /**
+ * When set to true, {@link #additionalSparkProperties()} throws a
RuntimeException.
+ * Used to test exception isolation in UserCredentialManager.
+ */
+ public static volatile boolean throwOnProperties = false;
+
private Map<String, String> initConf;
@Override
@@ -58,4 +64,12 @@ public class AnotherFakeCredentialProvider implements
CredentialProvider {
public Map<String, String> getInitConf() {
return initConf;
}
+
+ @Override
+ public Map<String, String> additionalSparkProperties() {
+ if (throwOnProperties) {
+ throw new RuntimeException("Simulated failure in
additionalSparkProperties");
+ }
+ return Map.of();
+ }
}
diff --git
a/core/src/test/java/org/apache/spark/security/FakeCredentialProvider.java
b/core/src/test/java/org/apache/spark/security/FakeCredentialProvider.java
index a733803576cf..b9108980a473 100644
--- a/core/src/test/java/org/apache/spark/security/FakeCredentialProvider.java
+++ b/core/src/test/java/org/apache/spark/security/FakeCredentialProvider.java
@@ -76,4 +76,10 @@ public class FakeCredentialProvider implements
CredentialProvider {
public int getCloseCount() {
return closeCount.get();
}
+
+ @Override
+ public Map<String, String> additionalSparkProperties() {
+ return Map.of("spark.hadoop.fs.fake.credentials.provider",
+ "org.apache.spark.security.FakeExecutorCredentialProvider");
+ }
}
diff --git
a/core/src/test/scala/org/apache/spark/deploy/security/UserCredentialManagerSuite.scala
b/core/src/test/scala/org/apache/spark/deploy/security/UserCredentialManagerSuite.scala
index d30d54beb345..14e0f1cf5a11 100644
---
a/core/src/test/scala/org/apache/spark/deploy/security/UserCredentialManagerSuite.scala
+++
b/core/src/test/scala/org/apache/spark/deploy/security/UserCredentialManagerSuite.scala
@@ -520,4 +520,93 @@ class UserCredentialManagerSuite extends SparkFunSuite {
assert(fakeProvider.getCloseCount === 1,
"stop() should close initialized providers exactly once")
}
+
+ // ========== additionalSparkProperties application ==========
+
+ test("start() applies additionalSparkProperties from active providers") {
+ val conf = createSparkConf()
+ conf.set("spark.security.oidc.provider.fake",
+ "org.apache.spark.security.FakeCredentialProvider")
+ val ctx = createUserContext()
+
+ val manager = new UserCredentialManager(
+ conf, createIngestor(ctx), (_, _) => ())
+
+ try {
+ manager.start()
+ assert(conf.get("spark.hadoop.fs.fake.credentials.provider") ===
+ "org.apache.spark.security.FakeExecutorCredentialProvider")
+ } finally {
+ manager.stop()
+ }
+ }
+
+ test("start() does not overwrite user-set properties") {
+ val conf = createSparkConf()
+ conf.set("spark.security.oidc.provider.fake",
+ "org.apache.spark.security.FakeCredentialProvider")
+ // User explicitly sets the property before start()
+ conf.set("spark.hadoop.fs.fake.credentials.provider", "user.Custom")
+ val ctx = createUserContext()
+
+ val manager = new UserCredentialManager(
+ conf, createIngestor(ctx), (_, _) => ())
+
+ try {
+ manager.start()
+ // User-set value must NOT be overwritten
+ assert(conf.get("spark.hadoop.fs.fake.credentials.provider") ===
"user.Custom")
+ } finally {
+ manager.stop()
+ }
+ }
+
+ test("start() handles provider returning null from
additionalSparkProperties") {
+ // AnotherFakeCredentialProvider uses default (empty map), not null.
+ // This test verifies the defensive null check doesn't crash
+ // with a provider that inherits the default empty map.
+ val conf = createSparkConf()
+ conf.set("spark.security.oidc.provider.fake",
+ "org.apache.spark.security.FakeCredentialProvider")
+ val ctx = createUserContext()
+
+ val manager = new UserCredentialManager(
+ conf, createIngestor(ctx), (_, _) => ())
+
+ try {
+ // Should not throw
+ manager.start()
+ assert(conf.contains("spark.hadoop.fs.fake.credentials.provider"))
+ } finally {
+ manager.stop()
+ }
+ }
+
+ test("start() succeeds when a provider throws from
additionalSparkProperties") {
+ // AnotherFakeCredentialProvider is configured to throw;
FakeCredentialProvider should
+ // still have its properties applied (exception isolation via NonFatal
catch).
+ val conf = createSparkConf()
+ conf.set("spark.security.oidc.provider.fake",
+ "org.apache.spark.security.FakeCredentialProvider")
+ conf.set("spark.security.oidc.provider.shared",
+ "org.apache.spark.security.AnotherFakeCredentialProvider")
+ val ctx = createUserContext()
+
+ AnotherFakeCredentialProvider.throwOnProperties = true
+ try {
+ val manager = new UserCredentialManager(
+ conf, createIngestor(ctx), (_, _) => ())
+ try {
+ // start() must not fail even though AnotherFakeCredentialProvider
throws
+ manager.start()
+ // FakeCredentialProvider's property must still be applied
+ assert(conf.get("spark.hadoop.fs.fake.credentials.provider") ===
+ "org.apache.spark.security.FakeExecutorCredentialProvider")
+ } finally {
+ manager.stop()
+ }
+ } finally {
+ AnotherFakeCredentialProvider.throwOnProperties = false
+ }
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]