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 95cb4c37147d [SPARK-57894][SPARK-57895][CORE][K8S] Implement
`UpdateUserCredentials` RPC, backend integration, and initial credential
delivery
95cb4c37147d is described below
commit 95cb4c37147d1c1b6032db0a70313779608a4648
Author: Kousuke Saruta <[email protected]>
AuthorDate: Thu Aug 6 23:23:14 2026 +0900
[SPARK-57894][SPARK-57895][CORE][K8S] Implement `UpdateUserCredentials`
RPC, backend integration, and initial credential delivery
### What changes were proposed in this pull request?
This PR adds the credential distribution layer for the OIDC Credential
Propagation SPIP (SPARK-57703). This PR covers Sub-task 5 (SPARK-57894):
UpdateUserCredentials RPC and backend integration) and Sub-task 6
(SPARK-57895): initial credential delivery for newly registered executors).
Sub-task 6 was originally scoped as a separate PR, but during
implementation it became clear that the two are inseparable: the
`AtomicReference` storing current credentials (Sub-task 5) is the same state
that `SparkAppConfig` reads from for initial delivery (Sub-task 6). Splitting
them would produce an artificial boundary with no meaningful independent
reviewability.
**Credential delivery via three complementary paths:**
1. **SparkAppConfig** (Sub-task 6): When a new executor registers,
`RetrieveSparkAppConfig` includes the current credentials in the response. This
ensures late-registering executors (e.g., dynamic allocation, Executor Plugins
that access S3 during `init()`) receive credentials immediately without waiting
for a refresh cycle or task dispatch.
2. **UpdateUserCredentials RPC** (Sub-task 5): On credential renewal,
`UserCredentialManager`'s callback sends `UpdateUserCredentials` to the
`DriverEndpoint`, which broadcasts to all registered executors. This refreshes
credentials during long-running tasks.
3. **TaskDescription** (Sub-task 5): Each task carries the current
credentials at dispatch time. This eliminates the race between RPC broadcast
and task execution, guaranteeing credentials are available before any task code
runs. Unlike Hadoop delegation tokens (which have hours-long TTLs), OIDC
credentials are short-lived (minutes), making this race a practical concern.
**Version guard for stale-credential prevention:**
Each credential renewal increments a monotonic version counter in
`UserCredentialManager`. Credentials are transmitted as `(version: Long, bytes:
Array[Byte])` across all three delivery paths. On the executor side, both
`CoarseGrainedExecutorBackend.receive` and `Executor.TaskRunner` use
`AtomicReference.updateAndGet` to atomically apply credentials only if the
incoming version is strictly greater than the current one. This prevents a
delayed `TaskDescription` (carrying an older versi [...]
**Key implementation details:**
- `UpdateUserCredentials(version: Long, credentials: Array[Byte])`: New RPC
message
- `VersionedCredentials(version: Long, bytes: Array[Byte])`: Case class for
the credential store
- `SparkEnv.userCredentials: AtomicReference[VersionedCredentials]`:
Credential store on both driver and executors
- `CoarseGrainedSchedulerBackend`: Manages credential store, starts/stops
`UserCredentialManager`, handles `UpdateUserCredentials` in
`DriverEndpoint.receive` (thread-safe access to `executorDataMap`)
- `CoarseGrainedExecutorBackend`: Handles `UpdateUserCredentials` with
version guard; applies initial credentials from `SparkAppConfig` on startup
- `TaskDescription`: New `userCredentials: Option[(Long, Array[Byte])]`
field with encode/decode
- `Executor`: Applies credentials with version guard before task execution
**Security:** Raw identity tokens (`UserContext.rawToken`) are never
present in any RPC payload, `TaskDescription`, or `SparkAppConfig`. Only
derived `ServiceCredential` bundles (serialized `UserCredentials`) are
transmitted.
**Modified files:**
| File | Change |
|------|--------|
| `CoarseGrainedClusterMessage.scala` | `UpdateUserCredentials` message
with version, `SparkAppConfig.userCredentials` field |
| `CoarseGrainedSchedulerBackend.scala` | `updateUserCredentials()`,
`setupUserCredentialManager()`, `stopUserCredentialManager()` |
| `CoarseGrainedExecutorBackend.scala` | `UpdateUserCredentials` handler
with version guard, initial credential application |
| `SparkEnv.scala` | `VersionedCredentials` case class, `userCredentials`
credential store |
| `TaskDescription.scala` | `userCredentials` field with version,
encode/decode |
| `TaskSetManager.scala` | Include current versioned credentials in
`TaskDescription` |
| `Executor.scala` | Apply credentials with atomic version guard before
task execution |
| `KubernetesExecutorBackend.scala` | Apply initial credentials from
`SparkAppConfig` |
| `UserCredentialManager.scala` | Add monotonic version counter, update
callback signature |
### Why are the changes needed?
After Sub-task 4 (#57387, `UserCredentialManager`) acquires and renews
credentials on the driver, there is no mechanism to distribute them to
executors. Without this PR, the credential propagation framework has no
delivery layer, and executors would never receive the short-lived service
credentials needed to access external services (e.g., S3 via STS-derived
credentials).
### Does this PR introduce _any_ user-facing change?
No. All new behavior is gated by `spark.security.oidc.enabled=false`
(default). No existing APIs or behaviors are changed.
### How was this patch tested?
- **TaskDescriptionSuite**: Encode/decode round-trip with versioned
`userCredentials` (Some and None)
- **CoarseGrainedSchedulerBackendSuite**:
- `UpdateUserCredentials` broadcast to all registered executors
- `SparkAppConfig` includes versioned credentials for late-registering
executors
- Version guard prevents stale credentials from overwriting newer ones
(v3 -> v1 rejected -> v5 accepted)
- Executor-side credential store version guard rejects stale and accepts
newer (unit test for `updateAndGet` logic)
- **Existing test updates**: All `TaskDescription` and
`UserCredentialManager` call sites updated with versioned signatures
### Was this patch authored or co-authored using generative AI tooling?
Kiro CLI / Claude
Closes #57675 from
sarutak/oidc-propagation/subtask5-update-user-credentials-rpc.
Authored-by: Kousuke Saruta <[email protected]>
Signed-off-by: Kousuke Saruta <[email protected]>
(cherry picked from commit 247405ce1c77ec12e6b0b07c663ba411fcd0f795)
Signed-off-by: Kousuke Saruta <[email protected]>
---
.../java/org/apache/spark/internal/LogKeys.java | 1 +
.../src/main/scala/org/apache/spark/SparkEnv.scala | 44 ++++++
.../deploy/security/UserCredentialManager.scala | 23 +++-
.../executor/CoarseGrainedExecutorBackend.scala | 12 ++
.../scala/org/apache/spark/executor/Executor.scala | 9 ++
.../apache/spark/scheduler/TaskDescription.scala | 34 ++++-
.../apache/spark/scheduler/TaskSetManager.scala | 1 +
.../cluster/CoarseGrainedClusterMessage.scala | 4 +
.../cluster/CoarseGrainedSchedulerBackend.scala | 51 ++++++-
.../security/UserCredentialManagerSuite.scala | 65 +++++----
.../CoarseGrainedExecutorBackendSuite.scala | 6 +-
.../org/apache/spark/executor/ExecutorSuite.scala | 1 +
.../CoarseGrainedSchedulerBackendSuite.scala | 148 ++++++++++++++++++++-
.../spark/scheduler/TaskDescriptionSuite.scala | 65 +++++++++
.../cluster/k8s/KubernetesExecutorBackend.scala | 5 +
15 files changed, 429 insertions(+), 40 deletions(-)
diff --git
a/common/utils-java/src/main/java/org/apache/spark/internal/LogKeys.java
b/common/utils-java/src/main/java/org/apache/spark/internal/LogKeys.java
index 91bff3120f00..bf53e4d2b773 100644
--- a/common/utils-java/src/main/java/org/apache/spark/internal/LogKeys.java
+++ b/common/utils-java/src/main/java/org/apache/spark/internal/LogKeys.java
@@ -140,6 +140,7 @@ public enum LogKeys implements LogKey {
CREATED_POOL_NAME,
CREATION_SITE,
CREDENTIALS_RENEWAL_INTERVAL_RATIO,
+ CREDENTIAL_VERSION,
CROSS_VALIDATION_METRIC,
CROSS_VALIDATION_METRICS,
CSV_HEADER_COLUMN_NAME,
diff --git a/core/src/main/scala/org/apache/spark/SparkEnv.scala
b/core/src/main/scala/org/apache/spark/SparkEnv.scala
index ca48ee473eb0..8c9be740027f 100644
--- a/core/src/main/scala/org/apache/spark/SparkEnv.scala
+++ b/core/src/main/scala/org/apache/spark/SparkEnv.scala
@@ -19,6 +19,7 @@ package org.apache.spark
import java.io.File
import java.util.concurrent.{CountDownLatch, TimeUnit}
+import java.util.concurrent.atomic.AtomicReference
import scala.collection.concurrent
import scala.collection.mutable
@@ -268,6 +269,19 @@ class SparkEnv (
private[spark] var executorBackend: Option[ExecutorBackend] = None
+ /**
+ * Versioned credential store for OIDC-based user credentials on both driver
and executors.
+ * Updated via `UpdateUserCredentials` RPC and `TaskDescription` credential
delivery.
+ * Read by connector-specific credential providers (e.g.,
SparkOidcAwsCredentialsProvider).
+ * Contains serialized `UserCredentials` (no raw identity token).
+ *
+ * The version field is a monotonically increasing counter assigned by
`UserCredentialManager`
+ * on each credential renewal. It is used to guard against stale credentials
from delayed
+ * `TaskDescription` delivery overwriting fresher credentials delivered via
RPC broadcast.
+ */
+ private[spark] val userCredentials: AtomicReference[VersionedCredentials] =
+ new AtomicReference[VersionedCredentials]()
+
private[spark] def stop(): Unit = {
if (!isStopped) {
@@ -825,3 +839,33 @@ object SparkEnv extends Logging {
"Metrics Properties" -> metricsProperties.toSeq.sorted)
}
}
+
+/**
+ * Container for versioned OIDC user credentials.
+ *
+ * @param version Monotonically increasing counter assigned by
`UserCredentialManager` on each
+ * credential renewal. Used to prevent stale credentials from
overwriting fresher
+ * ones on executors.
+ * @param bytes Serialized `UserCredentials` payload (no raw identity token).
+ */
+private[spark] case class VersionedCredentials(version: Long, bytes:
Array[Byte])
+
+private[spark] object VersionedCredentials {
+ /**
+ * Atomically update a credential store only if the given version is
strictly newer
+ * than what is currently stored. This prevents stale credentials (e.g.,
from a delayed
+ * `TaskDescription`) from overwriting fresher credentials delivered via RPC
broadcast.
+ *
+ * Uses `AtomicReference.updateAndGet` to ensure the check-and-set is atomic
even
+ * when called concurrently from multiple task threads and the RPC
dispatcher thread.
+ */
+ def updateIfNewer(
+ store: AtomicReference[VersionedCredentials],
+ version: Long,
+ bytes: Array[Byte]): Unit = {
+ val newValue = VersionedCredentials(version, bytes)
+ store.updateAndGet { current =>
+ if (current == null || version > current.version) newValue else current
+ }
+ }
+}
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 0627a87d9436..5a8d6e46530a 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
@@ -22,6 +22,7 @@ import java.net.URI
import java.nio.file.Paths
import java.time.Instant
import java.util.concurrent.{RejectedExecutionException,
ScheduledExecutorService, TimeUnit}
+import java.util.concurrent.atomic.AtomicLong
import scala.collection.mutable
import scala.jdk.CollectionConverters._
@@ -60,11 +61,17 @@ import org.apache.spark.util.{ThreadUtils, Utils}
private[spark] class UserCredentialManager(
sparkConf: SparkConf,
tokenIngestor: TokenIngestor,
- onCredentialsUpdate: Array[Byte] => Unit) extends Logging {
+ onCredentialsUpdate: (Long, Array[Byte]) => Unit) extends Logging {
private val safetyMargin = sparkConf.get(SECURITY_OIDC_RENEWAL_SAFETY_MARGIN)
private val minInterval = sparkConf.get(SECURITY_OIDC_RENEWAL_MIN_INTERVAL)
+ // Monotonically increasing version counter for credential updates.
+ // Incremented on each successful credential acquisition (initial +
renewals).
+ // Used by executors to guard against stale TaskDescription credentials
overwriting
+ // fresher credentials delivered via RPC broadcast.
+ private val credentialVersion = new AtomicLong(0)
+
// Counter for exponential backoff calculation.
// Only accessed from the single-thread renewal executor.
private var consecutiveFailures: Int = 0
@@ -84,10 +91,10 @@ private[spark] class UserCredentialManager(
* no credentials can be resolved, an exception is thrown. Subsequent
renewal failures
* are handled with exponential backoff.
*
- * @return The serialized initial [[UserCredentials]].
+ * @return The version and serialized initial [[UserCredentials]].
* @throws IllegalStateException if the initial credential acquisition fails.
*/
- def start(): Array[Byte] = {
+ def start(): (Long, Array[Byte]) = {
require(renewalExecutor == null, "start() must not be called more than
once")
// Initial acquisition is fail-fast (no retry/backoff).
@@ -107,9 +114,10 @@ private[spark] class UserCredentialManager(
val (credentials, earliestExpiry) = resolveCredentials(ctx)
val serialized =
UserCredentialManager.serializeUserCredentials(credentials)
+ val version = credentialVersion.incrementAndGet()
// Propagate initial credentials
- onCredentialsUpdate(serialized)
+ onCredentialsUpdate(version, serialized)
// Create the renewal executor only after successful initial acquisition.
// This avoids leaking a daemon thread if the fail-fast path throws, and
@@ -123,7 +131,7 @@ private[spark] class UserCredentialManager(
logInfo(log"Credential acquisition successful. Next renewal in " +
log"${MDC(LogKeys.TIME_UNITS, UIUtils.formatDuration(renewalDelay))}.")
- serialized
+ (version, serialized)
}
def stop(): Unit = {
@@ -164,11 +172,12 @@ private[spark] class UserCredentialManager(
val (credentials, earliestExpiry) = resolveCredentials(ctx)
val serialized =
UserCredentialManager.serializeUserCredentials(credentials)
+ val version = credentialVersion.incrementAndGet()
// Propagate credentials to executors. Errors here are logged separately
// so that credential-fetch success is not conflated with distribution
failure.
try {
- onCredentialsUpdate(serialized)
+ onCredentialsUpdate(version, serialized)
} catch {
case e: Exception =>
logWarning(log"Credentials were resolved successfully but failed to
propagate " +
@@ -417,7 +426,7 @@ private[spark] object UserCredentialManager {
*/
def create(
sparkConf: SparkConf,
- onCredentialsUpdate: Array[Byte] => Unit): Option[UserCredentialManager]
= {
+ onCredentialsUpdate: (Long, Array[Byte]) => Unit):
Option[UserCredentialManager] = {
if (!sparkConf.get(SECURITY_OIDC_ENABLED)) {
None
} else {
diff --git
a/core/src/main/scala/org/apache/spark/executor/CoarseGrainedExecutorBackend.scala
b/core/src/main/scala/org/apache/spark/executor/CoarseGrainedExecutorBackend.scala
index 206a6a0fe385..7429774cec60 100644
---
a/core/src/main/scala/org/apache/spark/executor/CoarseGrainedExecutorBackend.scala
+++
b/core/src/main/scala/org/apache/spark/executor/CoarseGrainedExecutorBackend.scala
@@ -223,6 +223,12 @@ private[spark] class CoarseGrainedExecutorBackend(
logInfo(log"Received tokens of ${MDC(LogKeys.NUM_BYTES,
tokenBytes.length)} bytes")
SparkHadoopUtil.get.addDelegationTokens(tokenBytes, env.conf)
+ case UpdateUserCredentials(version, credentials) =>
+ logInfo(log"Received user credentials of " +
+ log"${MDC(LogKeys.NUM_BYTES, credentials.length)} bytes " +
+ log"(version ${MDC(LogKeys.CREDENTIAL_VERSION, version)})")
+ VersionedCredentials.updateIfNewer(env.userCredentials, version,
credentials)
+
case DecommissionExecutor =>
decommissionSelf()
}
@@ -505,6 +511,12 @@ private[spark] object CoarseGrainedExecutorBackend extends
Logging {
}
val env = SparkEnv.createExecutorEnv(driverConf, arguments.executorId,
arguments.bindAddress,
arguments.hostname, arguments.cores, cfg.ioEncryptionKey, isLocal =
false)
+
+ // Apply initial user credentials to the executor credential store.
+ cfg.userCredentials.foreach { case (version, credentials) =>
+ env.userCredentials.set(VersionedCredentials(version, credentials))
+ }
+
// Set the application attemptId in the BlockStoreClient if available.
val appAttemptId = env.conf.get(APP_ATTEMPT_ID)
appAttemptId.foreach(attemptId =>
diff --git a/core/src/main/scala/org/apache/spark/executor/Executor.scala
b/core/src/main/scala/org/apache/spark/executor/Executor.scala
index f288ceacef7e..a67e700bc372 100644
--- a/core/src/main/scala/org/apache/spark/executor/Executor.scala
+++ b/core/src/main/scala/org/apache/spark/executor/Executor.scala
@@ -847,6 +847,15 @@ private[spark] class Executor(
// requires access to properties contained within (e.g. for access
control).
Executor.taskDeserializationProps.set(taskDescription.properties)
+ // Apply user credentials from TaskDescription to the executor
credential store.
+ // This ensures credentials are available before any task code runs,
avoiding
+ // the race between RPC broadcast and task dispatch.
+ // Only apply if the version is newer than what the store already has,
preventing
+ // a delayed TaskDescription from overwriting fresher credentials
delivered via RPC.
+ taskDescription.userCredentials.foreach { case (version, creds) =>
+ VersionedCredentials.updateIfNewer(env.userCredentials, version,
creds)
+ }
+
updateDependencies(
taskDescription.artifacts.files,
taskDescription.artifacts.jars,
diff --git
a/core/src/main/scala/org/apache/spark/scheduler/TaskDescription.scala
b/core/src/main/scala/org/apache/spark/scheduler/TaskDescription.scala
index 7eba116c5680..42b0d33d724c 100644
--- a/core/src/main/scala/org/apache/spark/scheduler/TaskDescription.scala
+++ b/core/src/main/scala/org/apache/spark/scheduler/TaskDescription.scala
@@ -61,6 +61,16 @@ private[spark] class TaskDescription(
// Eg, Map("gpu" -> Map("0" ->
ResourceAmountUtils.toInternalResource(0.7))):
// assign 0.7 of the gpu address "0" to this task
val resources: immutable.Map[String, immutable.Map[String, Long]],
+ // OIDC credentials with version for stale-update prevention.
+ // The version is a monotonic counter from UserCredentialManager;
executors only apply
+ // credentials if the version is newer than what they already have. This
prevents a
+ // delayed TaskDescription from overwriting fresher credentials delivered
via RPC.
+ // Trade-off: every TaskDescription carries the full serialized
UserCredentials (a few KB).
+ // This is acceptable because OIDC credentials are short-lived (minutes)
unlike Hadoop
+ // delegation tokens (hours/days), making the race between RPC broadcast
and task dispatch
+ // a practical concern. For short-task-heavy workloads, the overhead is
bounded by
+ // credential size x tasks-in-flight (not total task count).
+ val userCredentials: Option[(Long, Array[Byte])],
val serializedTask: ByteBuffer) {
assert(cpus > 0, "CPUs per task should be > 0")
@@ -121,6 +131,17 @@ private[spark] object TaskDescription {
// Write resources.
serializeResources(taskDescription.resources, dataOut)
+ // Write user credentials (OIDC).
+ taskDescription.userCredentials match {
+ case Some((version, creds)) =>
+ dataOut.writeBoolean(true)
+ dataOut.writeLong(version)
+ dataOut.writeInt(creds.length)
+ dataOut.write(creds)
+ case None =>
+ dataOut.writeBoolean(false)
+ }
+
// Write the task. The task is already serialized, so write it directly to
the byte buffer.
Utils.writeByteBuffer(taskDescription.serializedTask, bytesOut)
@@ -228,10 +249,21 @@ private[spark] object TaskDescription {
// Read resources.
val resources = deserializeResources(dataIn)
+ // Read user credentials (OIDC).
+ val userCredentials = if (dataIn.readBoolean()) {
+ val version = dataIn.readLong()
+ val length = dataIn.readInt()
+ val creds = new Array[Byte](length)
+ dataIn.readFully(creds)
+ Some((version, creds))
+ } else {
+ None
+ }
+
// Create a sub-buffer for the serialized task into its own buffer (to be
deserialized later).
val serializedTask = byteBuffer.slice()
new TaskDescription(taskId, attemptNumber, executorId, name, index,
partitionId, artifacts,
- properties, cpus, resources, serializedTask)
+ properties, cpus, resources, userCredentials, serializedTask)
}
}
diff --git
a/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala
b/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala
index 4fbe197cb838..9920d7ad971f 100644
--- a/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala
+++ b/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala
@@ -604,6 +604,7 @@ private[spark] class TaskSetManager(
task.localProperties,
taskCpus,
taskResourceAssignments,
+ Option(env.userCredentials.get()).map(vc => (vc.version, vc.bytes)),
serializedTask)
}
diff --git
a/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedClusterMessage.scala
b/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedClusterMessage.scala
index 84a6f9b3be2a..e1be2af9294b 100644
---
a/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedClusterMessage.scala
+++
b/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedClusterMessage.scala
@@ -35,6 +35,7 @@ private[spark] object CoarseGrainedClusterMessages {
sparkProperties: Seq[(String, String)],
ioEncryptionKey: Option[Array[Byte]],
hadoopDelegationCreds: Option[Array[Byte]],
+ userCredentials: Option[(Long, Array[Byte])],
resourceProfile: ResourceProfile,
logLevel: Option[String])
extends CoarseGrainedClusterMessage
@@ -60,6 +61,9 @@ private[spark] object CoarseGrainedClusterMessages {
case class UpdateDelegationTokens(tokens: Array[Byte])
extends CoarseGrainedClusterMessage
+ case class UpdateUserCredentials(version: Long, credentials: Array[Byte])
+ extends CoarseGrainedClusterMessage
+
// Executors to driver
case class RegisterExecutor(
executorId: String,
diff --git
a/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala
b/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala
index 99fdc534a6fb..8871a4b5b880 100644
---
a/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala
+++
b/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala
@@ -26,8 +26,9 @@ import scala.concurrent.Future
import com.google.common.cache.CacheBuilder
-import org.apache.spark.{ExecutorAllocationClient, SparkEnv, TaskState}
+import org.apache.spark.{ExecutorAllocationClient, SparkEnv, TaskState,
VersionedCredentials}
import org.apache.spark.deploy.SparkHadoopUtil
+import org.apache.spark.deploy.security.UserCredentialManager
import org.apache.spark.errors.SparkCoreErrors
import org.apache.spark.executor.ExecutorLogUrlHandler
import org.apache.spark.internal.{config, Logging}
@@ -132,6 +133,9 @@ class CoarseGrainedSchedulerBackend(scheduler:
TaskSchedulerImpl, val rpcEnv: Rp
// Current set of delegation tokens to send to executors.
private val delegationTokens = new AtomicReference[Array[Byte]]()
+ // UserCredentialManager for OIDC credential propagation (if enabled).
+ private var userCredentialManager: Option[UserCredentialManager] = None
+
private val reviveThread =
ThreadUtils.newDaemonSingleThreadScheduledExecutor("driver-revive-thread")
@@ -220,6 +224,9 @@ class CoarseGrainedSchedulerBackend(scheduler:
TaskSchedulerImpl, val rpcEnv: Rp
case UpdateDelegationTokens(newDelegationTokens) =>
updateDelegationTokens(newDelegationTokens)
+ case UpdateUserCredentials(version, newCredentials) =>
+ updateUserCredentials(version, newCredentials)
+
case RemoveExecutor(executorId, reason) =>
// We will remove the executor's state and cannot restore it. However,
the connection
// between the driver and the executor may be still alive so that the
executor won't exit
@@ -358,6 +365,7 @@ class CoarseGrainedSchedulerBackend(scheduler:
TaskSchedulerImpl, val rpcEnv: Rp
sparkProperties,
SparkEnv.get.securityManager.getIOEncryptionKey(),
Option(delegationTokens.get()),
+ Option(SparkEnv.get.userCredentials.get()).map(vc => (vc.version,
vc.bytes)),
rp,
currentLogLevel)
context.reply(reply)
@@ -618,6 +626,7 @@ class CoarseGrainedSchedulerBackend(scheduler:
TaskSchedulerImpl, val rpcEnv: Rp
override def start(): Unit = {
setupTokenManager()
+ setupUserCredentialManager()
if (conf.get(DIRECT_CREDENTIAL_PROVIDERS_ENABLED) &&
delegationTokenManager.isEmpty) {
logWarning("spark.security.directCredentialProviders.enabled is set but
" +
"this cluster manager does not support credential distribution. " +
@@ -648,6 +657,7 @@ class CoarseGrainedSchedulerBackend(scheduler:
TaskSchedulerImpl, val rpcEnv: Rp
cleanupService.foreach(_.shutdownNow())
stopExecutors()
stopTokenManager()
+ stopUserCredentialManager()
try {
if (driverEndpoint != null) {
driverEndpoint.askSync[Boolean](StopDriver)
@@ -1047,6 +1057,45 @@ class CoarseGrainedSchedulerBackend(scheduler:
TaskSchedulerImpl, val rpcEnv: Rp
protected def currentDelegationTokens: Array[Byte] = delegationTokens.get()
+ /**
+ * Update user credentials and broadcast to all registered executors.
+ * Called from the DriverEndpoint receive loop (thread-safe access to
executorDataMap).
+ */
+ private def updateUserCredentials(version: Long, credentials: Array[Byte]):
Unit = {
+ VersionedCredentials.updateIfNewer(SparkEnv.get.userCredentials, version,
credentials)
+ executorDataMap.values.foreach { ed =>
+ ed.executorEndpoint.send(UpdateUserCredentials(version, credentials))
+ }
+ }
+
+ /**
+ * Start the UserCredentialManager if OIDC credential propagation is enabled.
+ * Called from start(), independently of
Kerberos/HadoopDelegationTokenManager.
+ */
+ private def setupUserCredentialManager(): Unit = {
+ userCredentialManager = UserCredentialManager.create(conf, { (version,
credentials) =>
+ // Send to DriverEndpoint to ensure thread-safe access to
executorDataMap.
+ // This mirrors HadoopDelegationTokenManager's pattern of sending
+ // UpdateDelegationTokens via schedulerRef.
+ driverEndpoint.send(UpdateUserCredentials(version, credentials))
+ })
+ userCredentialManager.foreach { manager =>
+ val (version, initialCredentials) = manager.start()
+ // Store initial credentials synchronously so they are available for
SparkAppConfig
+ // (late-registering executors) and TaskDescription (task dispatch)
immediately.
+ // Note: the onCredentialsUpdate callback above also triggers an async
+ // UpdateUserCredentials message that will redundantly call
updateIfNewer.
+ // The synchronous set here ensures no null window before the async
message
+ // is processed by DriverEndpoint.
+ VersionedCredentials.updateIfNewer(
+ SparkEnv.get.userCredentials, version, initialCredentials)
+ }
+ }
+
+ private def stopUserCredentialManager(): Unit = {
+ userCredentialManager.foreach(_.stop())
+ }
+
/**
* Checks whether the executor is excluded due to failure(s). This is called
when the executor
* tries to register with the scheduler, and will deny registration if this
method returns true.
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 5e881c8d3a3e..d30d54beb345 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
@@ -80,10 +80,10 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val manager = new UserCredentialManager(
conf,
createIngestor(ctx),
- bytes => callbackRef.set(bytes))
+ (_, bytes) => callbackRef.set(bytes))
try {
- val result = manager.start()
+ val (_, result) = manager.start()
assert(result != null, "start() should return serialized credentials")
assert(callbackRef.get() != null, "callback should have been invoked")
@@ -105,7 +105,7 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val manager = new UserCredentialManager(
conf,
createFailingIngestor(),
- _ => ())
+ (_: Long, _: Array[Byte]) => ())
try {
val ex = intercept[IllegalStateException] {
@@ -129,7 +129,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val original = new UserCredentials(credsMap)
val conf = createSparkConf()
- val manager = new UserCredentialManager(conf, createFailingIngestor(), _
=> ())
+ val manager = new UserCredentialManager(
+ conf, createFailingIngestor(), (_: Long, _: Array[Byte]) => ())
try {
val serialized = UserCredentialManager.serializeUserCredentials(original)
@@ -168,7 +169,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
.set(SECURITY_OIDC_RENEWAL_SAFETY_MARGIN, 10000L) // 10s
.set(SECURITY_OIDC_RENEWAL_MIN_INTERVAL, 5000L) // 5s
- val manager = new UserCredentialManager(conf, createFailingIngestor(), _
=> ())
+ val manager = new UserCredentialManager(
+ conf, createFailingIngestor(), (_: Long, _: Array[Byte]) => ())
try {
// Token expires in 60s, credential expires in 30s
// Expected: min(60s, 30s) - 10s = 20s
@@ -189,7 +191,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
.set(SECURITY_OIDC_RENEWAL_SAFETY_MARGIN, 10000L)
.set(SECURITY_OIDC_RENEWAL_MIN_INTERVAL, 5000L)
- val manager = new UserCredentialManager(conf, createFailingIngestor(), _
=> ())
+ val manager = new UserCredentialManager(
+ conf, createFailingIngestor(), (_: Long, _: Array[Byte]) => ())
try {
// Token expires in 5s, safetyMargin is 10s -> computed delay would be
negative
// Should be bounded by minInterval (5s)
@@ -208,7 +211,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
.set(SECURITY_OIDC_RENEWAL_SAFETY_MARGIN, 10000L) // 10s
.set(SECURITY_OIDC_RENEWAL_MIN_INTERVAL, 5000L) // 5s
- val manager = new UserCredentialManager(conf, createFailingIngestor(), _
=> ())
+ val manager = new UserCredentialManager(
+ conf, createFailingIngestor(), (_: Long, _: Array[Byte]) => ())
try {
// Token expires in 60s, no credential expiry
// Expected: 60s - 10s = 50s
@@ -224,7 +228,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
test("computeRenewalDelay returns default when no expiry information") {
val conf = createSparkConf()
- val manager = new UserCredentialManager(conf, createFailingIngestor(), _
=> ())
+ val manager = new UserCredentialManager(
+ conf, createFailingIngestor(), (_: Long, _: Array[Byte]) => ())
try {
// UserContext with null expiresAt
val ctx = new UserContext(
@@ -243,7 +248,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val conf = createSparkConf()
.set(SECURITY_OIDC_RENEWAL_MIN_INTERVAL, 1000L)
- val manager = new UserCredentialManager(conf, createFailingIngestor(), _
=> ())
+ val manager = new UserCredentialManager(
+ conf, createFailingIngestor(), (_: Long, _: Array[Byte]) => ())
try {
val failuresField =
classOf[UserCredentialManager].getDeclaredField("consecutiveFailures")
failuresField.setAccessible(true)
@@ -274,7 +280,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val conf = createSparkConf()
.set(SECURITY_OIDC_RENEWAL_MIN_INTERVAL, 1000L)
- val manager = new UserCredentialManager(conf, createFailingIngestor(), _
=> ())
+ val manager = new UserCredentialManager(
+ conf, createFailingIngestor(), (_: Long, _: Array[Byte]) => ())
try {
val failuresField =
classOf[UserCredentialManager].getDeclaredField("consecutiveFailures")
failuresField.setAccessible(true)
@@ -293,7 +300,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val conf = createSparkConf()
.set(SECURITY_OIDC_RENEWAL_MIN_INTERVAL, 1000L)
- val manager = new UserCredentialManager(conf, createFailingIngestor(), _
=> ())
+ val manager = new UserCredentialManager(
+ conf, createFailingIngestor(), (_: Long, _: Array[Byte]) => ())
try {
val failuresField =
classOf[UserCredentialManager].getDeclaredField("consecutiveFailures")
failuresField.setAccessible(true)
@@ -314,13 +322,13 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val conf = new SparkConf(loadDefaults = false)
.set(SECURITY_OIDC_ENABLED, false)
- val result = UserCredentialManager.create(conf, _ => ())
+ val result = UserCredentialManager.create(conf, (_: Long, _: Array[Byte])
=> ())
assert(result.isEmpty)
}
test("UserCredentialManager.create returns Some when enabled with valid
config") {
val conf = createSparkConf()
- val result = UserCredentialManager.create(conf, _ => ())
+ val result = UserCredentialManager.create(conf, (_: Long, _: Array[Byte])
=> ())
assert(result.isDefined)
}
@@ -330,7 +338,7 @@ class UserCredentialManagerSuite extends SparkFunSuite {
// Deliberately not setting SECURITY_OIDC_IDENTITY_TOKEN_FILE
val ex = intercept[IllegalArgumentException] {
- UserCredentialManager.create(conf, _ => ())
+ UserCredentialManager.create(conf, (_: Long, _: Array[Byte]) => ())
}
assert(ex.getMessage.contains("spark.security.oidc.identityToken.file"))
}
@@ -345,10 +353,10 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val manager = new UserCredentialManager(
conf,
createIngestor(ctx),
- _ => { callbackCount += 1 })
+ (_: Long, _: Array[Byte]) => { callbackCount += 1 })
try {
- val result = manager.start()
+ val (_, result) = manager.start()
assert(result != null)
assert(callbackCount === 1, "callback should be invoked once on start")
} finally {
@@ -362,7 +370,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
"org.apache.spark.security.FakeCredentialProvider")
val ctx = createUserContext()
- val manager = new UserCredentialManager(conf, createIngestor(ctx), _ => ())
+ val manager = new UserCredentialManager(
+ conf, createIngestor(ctx), (_: Long, _: Array[Byte]) => ())
manager.start()
// Should not throw
manager.stop()
@@ -386,10 +395,10 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val manager = new UserCredentialManager(
conf,
createIngestor(ctx),
- bytes => callbackRef.set(bytes))
+ (_, bytes) => callbackRef.set(bytes))
try {
- val result = manager.start()
+ val (_, result) = manager.start()
assert(result != null, "start() should return serialized credentials")
// Verify that "fake" credentials were resolved despite "shared" failing
@@ -412,7 +421,8 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val ctx = createUserContext()
- val manager = new UserCredentialManager(conf, createIngestor(ctx), _ => ())
+ val manager = new UserCredentialManager(
+ conf, createIngestor(ctx), (_: Long, _: Array[Byte]) => ())
try {
val ex = intercept[IllegalStateException] {
@@ -450,14 +460,14 @@ class UserCredentialManagerSuite extends SparkFunSuite {
}
}
- val callbacks = new
java.util.concurrent.CopyOnWriteArrayList[Array[Byte]]()
+ val callbacks = new java.util.concurrent.CopyOnWriteArrayList[(Long,
Array[Byte])]()
val manager = new UserCredentialManager(
conf,
rotatingIngestor,
- bytes => callbacks.add(bytes))
+ (version, bytes) => callbacks.add((version, bytes)))
try {
- val initial = manager.start()
+ val (_, initial) = manager.start()
assert(initial != null)
assert(callbacks.size() === 1, "Should have one callback from start()")
@@ -471,6 +481,13 @@ class UserCredentialManagerSuite extends SparkFunSuite {
// Verify that the ingestor was called more than once (rotation detected)
assert(callCount.get() >= 2,
s"TokenIngestor should have been called at least twice, got
${callCount.get()}")
+
+ // Verify version monotonicity: each callback receives a strictly
increasing version
+ val versions = (0 until callbacks.size()).map(i => callbacks.get(i)._1)
+ assert(versions === versions.sorted,
+ s"Versions should be monotonically increasing: $versions")
+ assert(versions.head === 1L, "First version should be 1")
+ assert(versions(1) === 2L, "Second version should be 2")
} finally {
manager.stop()
}
@@ -487,7 +504,7 @@ class UserCredentialManagerSuite extends SparkFunSuite {
val manager = new UserCredentialManager(
conf,
createIngestor(ctx),
- bytes => callbackRef.set(bytes))
+ (_, bytes) => callbackRef.set(bytes))
manager.start()
diff --git
a/core/src/test/scala/org/apache/spark/executor/CoarseGrainedExecutorBackendSuite.scala
b/core/src/test/scala/org/apache/spark/executor/CoarseGrainedExecutorBackendSuite.scala
index a038d8e8613e..c7307066c78d 100644
---
a/core/src/test/scala/org/apache/spark/executor/CoarseGrainedExecutorBackendSuite.scala
+++
b/core/src/test/scala/org/apache/spark/executor/CoarseGrainedExecutorBackendSuite.scala
@@ -312,7 +312,7 @@ class CoarseGrainedExecutorBackendSuite extends
SparkFunSuite
// We don't really verify the data, just pass it around.
val data = ByteBuffer.wrap(Array[Byte](1, 2, 3, 4))
val taskDescription = new TaskDescription(taskId, 2, "1", "TASK
1000000", 19,
- 1, JobArtifactSet.emptyJobArtifactSet, new Properties, 1,
resourcesAmounts, data)
+ 1, JobArtifactSet.emptyJobArtifactSet, new Properties, 1,
resourcesAmounts, None, data)
val serializedTaskDescription = TaskDescription.encode(taskDescription)
backend.rpcEnv.setupEndpoint("Executor 1", backend)
backend.executor = mock[Executor](CALLS_REAL_METHODS)
@@ -437,7 +437,7 @@ class CoarseGrainedExecutorBackendSuite extends
SparkFunSuite
// Fake tasks with different taskIds.
val taskDescriptions = (1 to numTasks).map {
taskId => new TaskDescription(taskId, 2, "1", s"TASK $taskId", 19,
- 1, JobArtifactSet.emptyJobArtifactSet, new Properties, 1,
resourcesAmounts, data)
+ 1, JobArtifactSet.emptyJobArtifactSet, new Properties, 1,
resourcesAmounts, None, data)
}
assert(taskDescriptions.length == numTasks)
@@ -531,7 +531,7 @@ class CoarseGrainedExecutorBackendSuite extends
SparkFunSuite
// Fake tasks with different taskIds.
val taskDescriptions = (1 to numTasks).map {
taskId => new TaskDescription(taskId, 2, "1", s"TASK $taskId", 19,
- 1, JobArtifactSet.emptyJobArtifactSet, new Properties, 1,
resourcesAmounts, data)
+ 1, JobArtifactSet.emptyJobArtifactSet, new Properties, 1,
resourcesAmounts, None, data)
}
assert(taskDescriptions.length == numTasks)
diff --git a/core/src/test/scala/org/apache/spark/executor/ExecutorSuite.scala
b/core/src/test/scala/org/apache/spark/executor/ExecutorSuite.scala
index 4362eb2f816f..80b0bf69d45c 100644
--- a/core/src/test/scala/org/apache/spark/executor/ExecutorSuite.scala
+++ b/core/src/test/scala/org/apache/spark/executor/ExecutorSuite.scala
@@ -870,6 +870,7 @@ class ExecutorSuite extends SparkFunSuite
properties = new Properties,
cpus = 1,
resources = Map.empty,
+ None,
serializedTask)
}
diff --git
a/core/src/test/scala/org/apache/spark/scheduler/CoarseGrainedSchedulerBackendSuite.scala
b/core/src/test/scala/org/apache/spark/scheduler/CoarseGrainedSchedulerBackendSuite.scala
index 99b757496379..462daa046835 100644
---
a/core/src/test/scala/org/apache/spark/scheduler/CoarseGrainedSchedulerBackendSuite.scala
+++
b/core/src/test/scala/org/apache/spark/scheduler/CoarseGrainedSchedulerBackendSuite.scala
@@ -18,7 +18,7 @@
package org.apache.spark.scheduler
import java.util.Properties
-import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
import scala.collection.mutable
import scala.concurrent.Future
@@ -348,7 +348,7 @@ class CoarseGrainedSchedulerBackendSuite extends
SparkFunSuite with LocalSparkCo
val taskCpus = 1
val taskDescs: Seq[Seq[TaskDescription]] = Seq(Seq(new TaskDescription(1,
0, "1",
"t1", 0, 1, JobArtifactSet.emptyJobArtifactSet, new Properties(),
- taskCpus, taskResources, bytebuffer)))
+ taskCpus, taskResources, None, bytebuffer)))
val ts = backend.getTaskSchedulerImpl()
when(ts.resourceOffers(any[IndexedSeq[WorkerOffer]],
any[Boolean])).thenReturn(taskDescs)
@@ -455,7 +455,7 @@ class CoarseGrainedSchedulerBackendSuite extends
SparkFunSuite with LocalSparkCo
val taskCpus = 1
val taskDescs: Seq[Seq[TaskDescription]] = Seq(Seq(new TaskDescription(1,
0, "1",
"t1", 0, 1, JobArtifactSet.emptyJobArtifactSet, new Properties(),
- taskCpus, taskResources, bytebuffer)))
+ taskCpus, taskResources, None, bytebuffer)))
val ts = backend.getTaskSchedulerImpl()
when(ts.resourceOffers(any[IndexedSeq[WorkerOffer]],
any[Boolean])).thenReturn(taskDescs)
@@ -548,7 +548,7 @@ class CoarseGrainedSchedulerBackendSuite extends
SparkFunSuite with LocalSparkCo
val taskCpus = 2
val taskDescs: Seq[Seq[TaskDescription]] = Seq(Seq(new TaskDescription(1,
0, "1",
"t1", 0, 1, JobArtifactSet.emptyJobArtifactSet, new Properties(),
- taskCpus, Map.empty, bytebuffer)))
+ taskCpus, Map.empty, None, bytebuffer)))
when(ts.resourceOffers(any[IndexedSeq[WorkerOffer]],
any[Boolean])).thenReturn(taskDescs)
backend.driverEndpoint.send(ReviveOffers)
@@ -606,6 +606,143 @@ class CoarseGrainedSchedulerBackendSuite extends
SparkFunSuite with LocalSparkCo
assert(mockEndpointRef.decommissionReceived)
}
+ test("UpdateUserCredentials is broadcast to all registered executors") {
+ val conf = new SparkConf()
+ .setMaster("local-cluster[0, 3, 1024]")
+ .setAppName("test")
+
+ sc = new SparkContext(conf)
+ val backend =
sc.schedulerBackend.asInstanceOf[CoarseGrainedSchedulerBackend]
+
+ // Register two mock executors
+ val mockEndpointRef1 = new MockExecutorRpcEndpointRef(conf)
+ val mockEndpointRef2 = new MockExecutorRpcEndpointRef(conf)
+ val mockAddress = mock[RpcAddress]
+
+ backend.driverEndpoint.askSync[Boolean](
+ RegisterExecutor("1", mockEndpointRef1, mockAddress.host, 1, Map(),
Map(),
+ Map.empty, ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID))
+ backend.driverEndpoint.askSync[Boolean](
+ RegisterExecutor("2", mockEndpointRef2, mockAddress.host, 1, Map(),
Map(),
+ Map.empty, ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID))
+
+ sc.listenerBus.waitUntilEmpty(executorUpTimeout.toMillis)
+
+ // Neither executor should have received credentials yet
+ assert(mockEndpointRef1.receivedUserCredentials.isEmpty)
+ assert(mockEndpointRef2.receivedUserCredentials.isEmpty)
+
+ // Send UpdateUserCredentials via DriverEndpoint
+ val testCredentials = Array[Byte](1, 2, 3, 4, 5)
+ backend.driverEndpoint.send(UpdateUserCredentials(1L, testCredentials))
+
+ // Wait for the message to be processed
+ eventually(timeout(5 seconds)) {
+ assert(mockEndpointRef1.receivedUserCredentials.isDefined)
+ assert(mockEndpointRef2.receivedUserCredentials.isDefined)
+ }
+
+ assert(mockEndpointRef1.receivedUserCredentials.get._2 === testCredentials)
+ assert(mockEndpointRef2.receivedUserCredentials.get._2 === testCredentials)
+
+ // Verify SparkEnv credential store is also updated
+ assert(SparkEnv.get.userCredentials.get().bytes === testCredentials)
+ }
+
+ test("SparkAppConfig includes current user credentials for late-registering
executors") {
+ val conf = new SparkConf()
+ .setMaster("local-cluster[0, 3, 1024]")
+ .setAppName("test")
+
+ sc = new SparkContext(conf)
+ val backend =
sc.schedulerBackend.asInstanceOf[CoarseGrainedSchedulerBackend]
+
+ // Simulate credential acquisition by setting credentials before any
executor registers
+ val testCredentials = Array[Byte](10, 20, 30, 40, 50)
+ backend.driverEndpoint.send(UpdateUserCredentials(1L, testCredentials))
+
+ // Wait for the message to be processed
+ eventually(timeout(5 seconds)) {
+ assert(SparkEnv.get.userCredentials.get() != null)
+ }
+
+ // Now retrieve SparkAppConfig as a late-registering executor would
+ val appConfig = backend.driverEndpoint.askSync[SparkAppConfig](
+ RetrieveSparkAppConfig(ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID))
+
+ // Verify that userCredentials is present in the response
+ assert(appConfig.userCredentials.isDefined,
+ "SparkAppConfig should include user credentials for late-registering
executors")
+ assert(appConfig.userCredentials.get._2 === testCredentials)
+ }
+
+ test("version guard prevents stale credentials from overwriting newer ones")
{
+ val conf = new SparkConf()
+ .setMaster("local-cluster[0, 3, 1024]")
+ .setAppName("test")
+
+ sc = new SparkContext(conf)
+ val backend =
sc.schedulerBackend.asInstanceOf[CoarseGrainedSchedulerBackend]
+
+ // Send version 3 credentials first
+ val credsV3 = Array[Byte](30, 30, 30)
+ backend.driverEndpoint.send(UpdateUserCredentials(3L, credsV3))
+
+ eventually(timeout(5 seconds)) {
+ assert(SparkEnv.get.userCredentials.get() != null)
+ assert(SparkEnv.get.userCredentials.get().version === 3L)
+ }
+
+ // Now send version 1 (stale) -- should be rejected
+ val credsV1 = Array[Byte](10, 10, 10)
+ backend.driverEndpoint.send(UpdateUserCredentials(1L, credsV1))
+
+ // Flush the DriverEndpoint mailbox by sending a synchronous request.
+ // Since DriverEndpoint is single-threaded, when this returns, v1 has been
processed.
+ backend.driverEndpoint.askSync[SparkAppConfig](
+ RetrieveSparkAppConfig(ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID))
+
+ // Store should still hold v3 (stale v1 was rejected by version guard)
+ assert(SparkEnv.get.userCredentials.get().version === 3L,
+ "Stale version 1 should not overwrite newer version 3")
+
+ // Send version 5 (newer) -- should be accepted
+ val credsV5 = Array[Byte](50, 50, 50)
+ backend.driverEndpoint.send(UpdateUserCredentials(5L, credsV5))
+
+ eventually(timeout(5 seconds)) {
+ assert(SparkEnv.get.userCredentials.get().version === 5L)
+ }
+
+ // Verify version 5 credentials are in the store (not v1 or v3)
+ assert(SparkEnv.get.userCredentials.get().bytes === credsV5)
+ }
+
+ test("executor-side credential store version guard rejects stale and accepts
newer") {
+ // This tests VersionedCredentials.updateIfNewer -- the same method used in
+ // CoarseGrainedExecutorBackend.receive and Executor.TaskRunner.
+ val store = new AtomicReference[VersionedCredentials]()
+
+ // Initial write to null store should succeed
+ VersionedCredentials.updateIfNewer(store, 2L, Array[Byte](20, 20))
+ assert(store.get().version === 2L)
+ assert(store.get().bytes === Array[Byte](20, 20))
+
+ // Stale version (1) should be rejected
+ VersionedCredentials.updateIfNewer(store, 1L, Array[Byte](10, 10))
+ assert(store.get().version === 2L, "Stale version should not overwrite
newer")
+
+ // Same version (2) should also be rejected (strict >)
+ VersionedCredentials.updateIfNewer(store, 2L, Array[Byte](22, 22))
+ assert(store.get().version === 2L)
+ assert(store.get().bytes === Array[Byte](20, 20), "Same version should not
overwrite")
+
+ // Newer version (5) should be accepted
+ VersionedCredentials.updateIfNewer(store, 5L, Array[Byte](50, 50))
+ assert(store.get().version === 5L)
+ assert(store.get().bytes === Array[Byte](50, 50))
+ }
+
private def testSubmitJob(sc: SparkContext, rdd: RDD[Int]): Unit = {
sc.submitJob(
rdd,
@@ -667,12 +804,15 @@ private[spark] class MockExecutorRpcEndpointRef(conf:
SparkConf) extends RpcEndp
// scalastyle:on executioncontextglobal
var decommissionReceived = false
+ var receivedUserCredentials: Option[(Long, Array[Byte])] = None
override def address: RpcAddress = null
override def name: String = "executor"
override def send(message: Any): Unit =
message match {
case DecommissionExecutor => decommissionReceived = true
+ case UpdateUserCredentials(version, creds) => receivedUserCredentials =
Some((version, creds))
+ case _ =>
}
override def ask[T: ClassTag](message: Any, timeout: RpcTimeout): Future[T]
= {
Future{true.asInstanceOf[T]}
diff --git
a/core/src/test/scala/org/apache/spark/scheduler/TaskDescriptionSuite.scala
b/core/src/test/scala/org/apache/spark/scheduler/TaskDescriptionSuite.scala
index dcb22f8b0d7d..4744b41809bb 100644
--- a/core/src/test/scala/org/apache/spark/scheduler/TaskDescriptionSuite.scala
+++ b/core/src/test/scala/org/apache/spark/scheduler/TaskDescriptionSuite.scala
@@ -85,6 +85,7 @@ class TaskDescriptionSuite extends SparkFunSuite {
originalProperties,
cpus = 2,
originalResources,
+ None,
taskBuffer
)
@@ -102,6 +103,7 @@ class TaskDescriptionSuite extends SparkFunSuite {
assert(decodedTaskDescription.properties.equals(originalTaskDescription.properties))
assert(decodedTaskDescription.cpus.equals(originalTaskDescription.cpus))
assert(decodedTaskDescription.resources ===
originalTaskDescription.resources)
+ assert(decodedTaskDescription.userCredentials === None)
assert(decodedTaskDescription.serializedTask.equals(taskBuffer))
}
@@ -119,6 +121,7 @@ class TaskDescriptionSuite extends SparkFunSuite {
new Properties(),
cpus,
Map.empty,
+ None,
ByteBuffer.wrap(Array[Byte](1, 2, 3, 4)))
val decoded =
TaskDescription.decode(TaskDescription.encode(taskDescription))
// The decoded amount is what the executor echoes back in its status
update and what the
@@ -129,4 +132,66 @@ class TaskDescriptionSuite extends SparkFunSuite {
}
}
+ test("encoding and decoding TaskDescription with userCredentials preserves
credentials") {
+ val taskBuffer = ByteBuffer.wrap(Array[Byte](1, 2, 3, 4))
+ val properties = new Properties()
+ properties.put("key", "value")
+
+ val credentialBytes = Array[Byte](10, 20, 30, 40, 50)
+
+ val taskDescription = new TaskDescription(
+ taskId = 42,
+ attemptNumber = 0,
+ executorId = "exec-1",
+ name = "task with credentials",
+ index = 0,
+ partitionId = 0,
+ JobArtifactSet.emptyJobArtifactSet,
+ properties,
+ cpus = 1,
+ Map.empty,
+ Some((1L, credentialBytes)),
+ taskBuffer
+ )
+
+ val serialized = TaskDescription.encode(taskDescription)
+ val decoded = TaskDescription.decode(serialized)
+
+ assert(decoded.taskId === 42)
+ assert(decoded.name === "task with credentials")
+ assert(decoded.userCredentials.isDefined)
+ assert(decoded.userCredentials.get._1 === 1L)
+ assert(decoded.userCredentials.get._2 === credentialBytes)
+ assert(decoded.serializedTask.equals(taskBuffer))
+ }
+
+ test("encoding and decoding TaskDescription without userCredentials
round-trips correctly") {
+ val taskBuffer = ByteBuffer.wrap(Array[Byte](5, 6, 7, 8))
+ val properties = new Properties()
+
+ val taskDescription = new TaskDescription(
+ taskId = 99,
+ attemptNumber = 1,
+ executorId = "exec-2",
+ name = "task without credentials",
+ index = 3,
+ partitionId = 2,
+ JobArtifactSet.emptyJobArtifactSet,
+ properties,
+ cpus = 2,
+ Map.empty,
+ None,
+ taskBuffer
+ )
+
+ val serialized = TaskDescription.encode(taskDescription)
+ val decoded = TaskDescription.decode(serialized)
+
+ assert(decoded.taskId === 99)
+ assert(decoded.attemptNumber === 1)
+ assert(decoded.name === "task without credentials")
+ assert(decoded.userCredentials === None)
+ assert(decoded.serializedTask.equals(taskBuffer))
+ }
+
}
diff --git
a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesExecutorBackend.scala
b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesExecutorBackend.scala
index e44d7e29ef60..5b8a30fdab1c 100644
---
a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesExecutorBackend.scala
+++
b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesExecutorBackend.scala
@@ -128,6 +128,11 @@ private[spark] object KubernetesExecutorBackend extends
Logging {
val env = SparkEnv.createExecutorEnv(driverConf, execId,
arguments.bindAddress,
arguments.hostname, arguments.cores, cfg.ioEncryptionKey, isLocal =
false)
+ // Apply initial user credentials to the executor credential store.
+ cfg.userCredentials.foreach { case (version, credentials) =>
+ env.userCredentials.set(VersionedCredentials(version, credentials))
+ }
+
val backend = backendCreateFn(env.rpcEnv, arguments, env,
cfg.resourceProfile, execId)
env.rpcEnv.setupEndpoint("Executor", backend)
arguments.workerUrl.foreach { url =>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]