From 2bc994f4ce37ee0d5d2b28d59295cbe4ae801510 Mon Sep 17 00:00:00 2001 From: Ashok Kumar Date: Fri, 19 Jun 2026 16:29:11 +0530 Subject: [PATCH] [LIVY-461] Support multi-tenant (multi-namespace) Kubernetes clusters Livy on Kubernetes previously assumed a single namespace and listed/killed applications across all namespaces (inAnyNamespace). In a multi-tenant cluster the Livy service account is typically scoped to a subset of namespaces, so cluster-wide calls fail. This change makes the namespace a first-class, per-session property: * SparkApp.getNamespace resolves the target namespace from the session Spark conf (spark.kubernetes.namespace), falling back to $SPARK_HOME/conf/spark-defaults.conf and finally the 'default' namespace. It only touches the filesystem on Kubernetes and returns an empty namespace for YARN/local, closing the input stream and tolerating a missing file, an unset SPARK_HOME, or a malformed spark-defaults.conf (an invalid property escape no longer aborts session creation). * The namespace is threaded through SparkApp.create and persisted in the batch and interactive recovery metadata, so a recovered session keeps the namespace it was created with. Recovery metadata written before this change has no namespace field and deserializes to null; such sessions are treated as "unknown" and fall back to scanning all namespaces until the driver pod is found, whereupon its real namespace is adopted. This keeps in-flight sessions recoverable across the upgrade. * SparkKubernetesApp scopes every client call with .inNamespace(...) and tracks the set of namespaces it has seen in a thread-safe set (ConcurrentHashMap-backed) so leaked-application cleanup iterates only over namespaces Livy actually has access to. Leaked-app GC isolates per-namespace failures and guards the whole cycle, so losing access to a single namespace can neither stop the sweep of the others nor terminate the GC thread. * Bumps kubernetes-client to 6.8.1 and netty to 4.1.108.Final for security fixes. * Adds SparkAppSpec covering the namespace-resolution precedence chain (including malformed-conf fallback), a BatchSession recovery test asserting the namespace round-trips through recovery metadata, and updates existing recovery-metadata fixtures for the new field. Co-Authored-By: Claude Opus 4.8 (1M context) --- pom.xml | 4 +- .../livy/server/batch/BatchSession.scala | 16 ++- .../interactive/InteractiveSession.scala | 16 ++- .../org/apache/livy/utils/SparkApp.scala | 72 ++++++++++- .../livy/utils/SparkKubernetesApp.scala | 117 +++++++++++++----- .../livy/server/batch/BatchSessionSpec.scala | 11 +- .../interactive/InteractiveSessionSpec.scala | 6 +- .../livy/sessions/SessionManagerSpec.scala | 2 +- .../org/apache/livy/utils/SparkAppSpec.scala | 95 ++++++++++++++ .../livy/utils/SparkKubernetesAppSpec.scala | 19 ++- 10 files changed, 308 insertions(+), 50 deletions(-) diff --git a/pom.xml b/pom.xml index 0bb42545a..b54b8e69f 100644 --- a/pom.xml +++ b/pom.xml @@ -82,7 +82,7 @@ compile 1.7.36 4.1.2 - 5.6.0 + 6.8.1 3.0.0 1.15 3.17.0 @@ -1461,7 +1461,7 @@ 1.8 0.10.9.7 3.7.0-M11 - 4.1.96.Final + 4.1.108.Final 2.15.2 2.15.2 spark-${spark.version}-bin-hadoop${hadoop.major-minor.version} diff --git a/server/src/main/scala/org/apache/livy/server/batch/BatchSession.scala b/server/src/main/scala/org/apache/livy/server/batch/BatchSession.scala index 0d9a0c644..1f8a49d6c 100644 --- a/server/src/main/scala/org/apache/livy/server/batch/BatchSession.scala +++ b/server/src/main/scala/org/apache/livy/server/batch/BatchSession.scala @@ -40,6 +40,9 @@ case class BatchRecoveryMetadata( appTag: String, owner: String, proxyUser: Option[String], + // Empty means "unknown": metadata persisted before multi-namespace support has no + // namespace and deserializes to null, which SparkKubernetesApp resolves at recovery. + namespace: String = "", version: Int = 1) extends RecoveryMetadata @@ -64,7 +67,7 @@ object BatchSession extends Logging { mockApp: Option[SparkApp] = None): BatchSession = { val appTag = s"livy-batch-$id-${Random.alphanumeric.take(8).mkString}".toLowerCase() val impersonatedUser = accessManager.checkImpersonation(proxyUser, owner) - + val namespace = SparkApp.getNamespace(request.conf, livyConf) def createSparkApp(s: BatchSession): SparkApp = { val conf = SparkApp.prepareSparkConf( appTag, @@ -111,7 +114,8 @@ object BatchSession extends Logging { childProcesses.decrementAndGet() } } - SparkApp.create(appTag, None, Option(sparkSubmit), livyConf, Option(s)) + val extrasMap: Map[String, String] = Map(SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY -> namespace) + SparkApp.create(appTag, None, Option(sparkSubmit), livyConf, Option(s), extrasMap) } info(s"Creating batch session $id: [owner: $owner, request: $request]") @@ -125,6 +129,7 @@ object BatchSession extends Logging { owner, impersonatedUser, sessionStore, + namespace, mockApp.map { m => (_: BatchSession) => m }.getOrElse(createSparkApp)) } @@ -142,8 +147,10 @@ object BatchSession extends Logging { m.owner, m.proxyUser, sessionStore, + m.namespace, mockApp.map { m => (_: BatchSession) => m }.getOrElse { s => - SparkApp.create(m.appTag, m.appId, None, livyConf, Option(s)) + val extrasMap = Map(SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY -> m.namespace) + SparkApp.create(m.appTag, m.appId, None, livyConf, Option(s), extrasMap) }) } } @@ -157,6 +164,7 @@ class BatchSession( owner: String, override val proxyUser: Option[String], sessionStore: SessionStore, + namespace: String, sparkApp: BatchSession => SparkApp) extends Session(id, name, owner, livyConf) with SparkAppListener { import BatchSession._ @@ -209,5 +217,5 @@ class BatchSession( override def infoChanged(appInfo: AppInfo): Unit = { this.appInfo = appInfo } override def recoveryMetadata: RecoveryMetadata = - BatchRecoveryMetadata(id, name, appId, appTag, owner, proxyUser) + BatchRecoveryMetadata(id, name, appId, appTag, owner, proxyUser, namespace) } diff --git a/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala b/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala index b193dac80..276f63362 100644 --- a/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala +++ b/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala @@ -70,6 +70,9 @@ case class InteractiveRecoveryMetadata( // proxyUser is deprecated. It is available here only for backward compatibility proxyUser: Option[String], rscDriverUri: Option[URI], + // Empty means "unknown": metadata persisted before multi-namespace support has no + // namespace and deserializes to null, which SparkKubernetesApp resolves at recovery. + namespace: String = "", version: Int = 1) extends RecoveryMetadata @@ -99,6 +102,7 @@ object InteractiveSession extends Logging { mockClient: Option[RSCClient] = None): InteractiveSession = { val appTag = s"livy-session-$id-${Random.alphanumeric.take(8).mkString}".toLowerCase() val impersonatedUser = accessManager.checkImpersonation(proxyUser, owner) + val namespace = SparkApp.getNamespace(request.conf, livyConf) val client = mockClient.orElse { val conf = SparkApp.prepareSparkConf(appTag, livyConf, prepareConf( @@ -159,6 +163,7 @@ object InteractiveSession extends Logging { request.numExecutors, request.pyFiles, request.queue.filterNot(_.isEmpty).orElse(livyConf.sparkYarnQueue()), + namespace, mockApp) } @@ -199,6 +204,7 @@ object InteractiveSession extends Logging { metadata.numExecutors, metadata.pyFiles, metadata.queue, + metadata.namespace, mockApp) } @@ -443,6 +449,7 @@ class InteractiveSession( val numExecutors: Option[Int], val pyFiles: List[String], val queue: Option[String], + val namespace: String, mockApp: Option[SparkApp]) // For unit test. extends Session(id, name, owner, ttl, idleTimeout, livyConf) with SessionHeartbeat @@ -472,11 +479,14 @@ class InteractiveSession( app = mockApp.orElse { val driverProcess = client.flatMap { c => Option(c.getDriverProcess) } .map(new LineBufferedProcess(_, livyConf.getInt(LivyConf.SPARK_LOGS_SIZE))) + val extrasMap = Map(SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY -> namespace) if (!livyConf.isRunningOnKubernetes()) { - driverProcess.map(_ => SparkApp.create(appTag, appId, driverProcess, livyConf, Some(this))) + driverProcess.map { _ => + SparkApp.create(appTag, appId, driverProcess, livyConf, Some(this), extrasMap) + } } else { // Create SparkApp for Kubernetes anyway - Some(SparkApp.create(appTag, appId, driverProcess, livyConf, Some(this))) + Some(SparkApp.create(appTag, appId, driverProcess, livyConf, Some(this), extrasMap)) } } @@ -545,7 +555,7 @@ class InteractiveSession( heartbeatTimeout.toSeconds.toInt, owner, ttl, idleTimeout, driverMemory, driverCores, executorMemory, executorCores, conf, archives, files, jars, numExecutors, pyFiles, queue, - proxyUser, rscDriverUri) + proxyUser, rscDriverUri, namespace) override def state: SessionState = { if (serverSideState == SessionState.Running) { diff --git a/server/src/main/scala/org/apache/livy/utils/SparkApp.scala b/server/src/main/scala/org/apache/livy/utils/SparkApp.scala index acb2b0e1e..52cda895f 100644 --- a/server/src/main/scala/org/apache/livy/utils/SparkApp.scala +++ b/server/src/main/scala/org/apache/livy/utils/SparkApp.scala @@ -17,9 +17,11 @@ package org.apache.livy.utils -import java.io.IOException +import java.io.{File, FileInputStream, IOException} +import java.util.Properties import scala.collection.JavaConverters._ +import scala.util.control.NonFatal import org.apache.hadoop.conf.Configuration @@ -60,12 +62,73 @@ trait SparkAppListener { */ object SparkApp extends Logging { private val SPARK_YARN_TAG_KEY = "spark.yarn.tags" - + val SPARK_KUBERNETES_NAMESPACE_KEY = "spark.kubernetes.namespace" object State extends Enumeration { val STARTING, RUNNING, FINISHED, FAILED, KILLED = Value } type State = State.Value + val DEFAULT_KUBERNETES_NAMESPACE = "default" + + /** + * Resolve the Kubernetes namespace a Spark application should run in. + * + * The namespace is looked up, in order of precedence, from: + * 1. the session's Spark configuration ([[SPARK_KUBERNETES_NAMESPACE_KEY]]), + * 2. `spark-defaults.conf` in the Spark config directory (`$SPARK_CONF_DIR` if set, + * otherwise `$SPARK_HOME/conf`), if present, + * 3. the [[DEFAULT_KUBERNETES_NAMESPACE]] fallback. + * + * The namespace is only meaningful on Kubernetes, so for any other cluster + * manager (YARN, local) an empty string is returned without touching the + * filesystem. + */ + def getNamespace(conf: Map[String, String], livyConf: LivyConf): String = { + if (!livyConf.isRunningOnKubernetes()) { + return "" + } + conf.get(SPARK_KUBERNETES_NAMESPACE_KEY).filter(_.nonEmpty).getOrElse { + namespaceFromSparkDefaults(livyConf).getOrElse(DEFAULT_KUBERNETES_NAMESPACE) + } + } + + /** + * Resolve the Spark configuration directory the same way Spark's launch scripts do: + * honor `$SPARK_CONF_DIR` if set, otherwise fall back to `$SPARK_HOME/conf`. Livy runs + * spark-submit as a child process, which reads spark-defaults.conf from this directory; + * resolving it identically here keeps the monitored namespace consistent with where the + * driver pod is actually created. + */ + private def sparkConfDir(livyConf: LivyConf): Option[String] = { + sys.env.get("SPARK_CONF_DIR").filter(_.nonEmpty) + .orElse(livyConf.sparkHome().map(home => s"$home${File.separator}conf")) + } + + private def namespaceFromSparkDefaults(livyConf: LivyConf): Option[String] = { + sparkConfDir(livyConf).flatMap { confDir => + val sparkDefaults = new File(confDir, "spark-defaults.conf") + if (!sparkDefaults.isFile) { + None + } else { + val in = new FileInputStream(sparkDefaults) + try { + val properties = new Properties() + properties.load(in) + Option(properties.getProperty(SPARK_KUBERNETES_NAMESPACE_KEY)).filter(_.nonEmpty) + } catch { + case NonFatal(e) => + // A malformed spark-defaults.conf (e.g. an invalid unicode escape, which + // java.util.Properties rejects) must not abort session creation; fall + // back to the default namespace instead. + warn(s"Could not read $sparkDefaults for the Kubernetes namespace; " + + s"falling back to the default namespace: ${e.getMessage}") + None + } finally { + in.close() + } + } + } + } /** * Return cluster manager dependent SparkConf. * @@ -152,11 +215,12 @@ object SparkApp extends Logging { appId: Option[String], process: Option[LineBufferedProcess], livyConf: LivyConf, - listener: Option[SparkAppListener]): SparkApp = { + listener: Option[SparkAppListener], + extrasMap: Map[String, String]): SparkApp = { if (livyConf.isRunningOnYarn()) { new SparkYarnApp(uniqueAppTag, appId, process, listener, livyConf) } else if (livyConf.isRunningOnKubernetes()) { - new SparkKubernetesApp(uniqueAppTag, appId, process, listener, livyConf) + new SparkKubernetesApp(uniqueAppTag, appId, process, listener, livyConf, extrasMap) } else { require(process.isDefined, "process must not be None when Livy master is not YARN or" + "Kubernetes.") diff --git a/server/src/main/scala/org/apache/livy/utils/SparkKubernetesApp.scala b/server/src/main/scala/org/apache/livy/utils/SparkKubernetesApp.scala index bb8eb291b..5773ff93d 100644 --- a/server/src/main/scala/org/apache/livy/utils/SparkKubernetesApp.scala +++ b/server/src/main/scala/org/apache/livy/utils/SparkKubernetesApp.scala @@ -21,6 +21,8 @@ import java.util.Collections import java.util.concurrent._ import scala.annotation.tailrec +import scala.collection.JavaConverters.asScalaSetConverter +import scala.collection.mutable import scala.collection.mutable.ArrayBuffer import scala.concurrent._ import scala.concurrent.duration._ @@ -47,29 +49,47 @@ object SparkKubernetesApp extends Logging { override def run(): Unit = { import KubernetesExtensions._ while (true) { - if (!leakedAppTags.isEmpty) { - // kill the app if found it and remove it if exceeding a threshold - val iter = leakedAppTags.entrySet().iterator() - var isRemoved = false - val now = System.currentTimeMillis() - val apps = withRetry(kubernetesClient.getApplications()) - while (iter.hasNext) { - val entry = iter.next() - apps.find(_.getApplicationTag.contains(entry.getKey)) - .foreach({ - app => - info(s"Kill leaked app ${app.getApplicationId}") - withRetry(kubernetesClient.killApplication(app)) + // Guard the whole cycle: an unexpected error must never terminate this daemon + // thread, otherwise leaked-app GC would silently stop for the whole server. + try { + if (!leakedAppTags.isEmpty) { + // kill the app if found it and remove it if exceeding a threshold + val iter = leakedAppTags.entrySet().iterator() + var isRemoved = false + val now = System.currentTimeMillis() + val apps = appNamespaces.flatMap { namespace => + // Isolate per-namespace failures: losing access to (or deletion of) one + // namespace must not stop GC from sweeping the others this cycle. + try { + withRetry(kubernetesClient.getApplications(namespace)) + } catch { + case NonFatal(e) => + warn(s"Failed to list Spark applications in namespace '$namespace' during " + + s"leaked-app GC; skipping it this cycle: ${e.getMessage}") + Seq.empty[KubernetesApplication] + } + } + while (iter.hasNext) { + val entry = iter.next() + apps.find(_.getApplicationTag.contains(entry.getKey)) + .foreach({ + app => + info(s"Kill leaked app ${app.getApplicationId}") + withRetry(kubernetesClient.killApplication(app)) + iter.remove() + isRemoved = true + }) + if (!isRemoved) { + if ((entry.getValue - now) > sessionLeakageCheckTimeout) { iter.remove() - isRemoved = true - }) - if (!isRemoved) { - if ((entry.getValue - now) > sessionLeakageCheckTimeout) { - iter.remove() - info(s"Remove leaked Kubernetes app tag ${entry.getKey}") + info(s"Remove leaked Kubernetes app tag ${entry.getKey}") + } } } } + } catch { + case NonFatal(e) => + error("Unexpected error during leaked-application GC; retrying next cycle.", e) } Thread.sleep(sessionLeakageCheckInterval) } @@ -156,6 +176,8 @@ object SparkKubernetesApp extends Logging { private var sessionLeakageCheckInterval: Long = _ var kubernetesClient: DefaultKubernetesClient = _ + var appNamespaces: mutable.Set[String] = + ConcurrentHashMap.newKeySet[String]().asScala private var appLookupThreadPoolSize: Long = _ private var appLookupMaxFailedTimes: Long = _ @@ -164,8 +186,7 @@ object SparkKubernetesApp extends Logging { this.livyConf = livyConf // KubernetesClient is thread safe. Create once, share it across threads. - kubernetesClient = - KubernetesClientFactory.createKubernetesClient(livyConf) + kubernetesClient = KubernetesClientFactory.createKubernetesClient(livyConf) cacheLogSize = livyConf.getInt(LivyConf.SPARK_LOGS_SIZE) appLookupTimeout = livyConf.getTimeAsMs(LivyConf.KUBERNETES_APP_LOOKUP_TIMEOUT).milliseconds @@ -268,7 +289,9 @@ class SparkKubernetesApp private[utils] ( process: Option[LineBufferedProcess], listener: Option[SparkAppListener], livyConf: LivyConf, - kubernetesClient: => KubernetesClient = SparkKubernetesApp.kubernetesClient) // For unit test. + extrasMap: Map[String, String], + // For unit test. + kubernetesClient: => DefaultKubernetesClient = SparkKubernetesApp.kubernetesClient) extends SparkApp with Logging { @@ -285,6 +308,20 @@ class SparkKubernetesApp private[utils] ( private var kubernetesTagToAppIdFailedTimes: Int = _ private var kubernetesAppMonitorFailedTimes: Int = _ + // Recovery metadata written before multi-namespace support has no namespace and + // deserializes to null (jackson-module-scala fills absent reference params with null, + // ignoring Scala default values). Treat null/missing as "" ("unknown") and resolve the + // real namespace once the driver pod is discovered (see monitorSparkKubernetesApp). + private var namespace: String = + Option(extrasMap.getOrElse(SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY, "")).getOrElse("") + // Only track a concrete namespace for the leaked-app GC sweep. An empty namespace means + // "unknown" (e.g. a session recovered from pre-multi-namespace metadata); adding it would + // make every GC cycle fall back to a cluster-wide inAnyNamespace scan, which a + // namespace-scoped service account is not permitted to do. The real namespace is adopted + // and tracked once the driver pod is discovered (see monitorSparkKubernetesApp). + if (namespace.nonEmpty) { + appNamespaces.add(namespace) + } private def failToMonitor(): Unit = { changeState(SparkApp.State.FAILED) process.foreach(_.destroy()) @@ -315,10 +352,11 @@ class SparkKubernetesApp private[utils] ( } // Get KubernetesApplication by appTag. val appOption: Option[KubernetesApplication] = try { - getAppFromTag(appTag, pollInterval, appLookupTimeout.fromNow) + getAppFromTag(appTag, pollInterval, appLookupTimeout.fromNow, namespace) } catch { case e: Exception => failToGetAppId() + error(s"Exception getting app from tag $appTag in namespace $namespace with message: ", e) appPromise.failure(e) return } @@ -327,6 +365,13 @@ class SparkKubernetesApp private[utils] ( return } val app: KubernetesApplication = appOption.get + // For a session recovered from pre-multi-namespace metadata the namespace is unknown + // until the driver pod is found. Adopt the pod's real namespace so the report, ingress + // and leaked-app GC operations below target the correct namespace. + if (namespace == null || namespace.isEmpty) { + namespace = app.getApplicationNamespace + appNamespaces.add(namespace) + } appPromise.trySuccess(app) val appId = app.getApplicationId @@ -463,10 +508,11 @@ class SparkKubernetesApp private[utils] ( private def getAppFromTag( appTag: String, pollInterval: duration.Duration, - deadline: Deadline): Option[KubernetesApplication] = { + deadline: Deadline, + namespace: String): Option[KubernetesApplication] = { import KubernetesExtensions._ - - withRetry(kubernetesClient.getApplications().find(_.getApplicationTag.contains(appTag))) + withRetry(kubernetesClient.getApplications(namespace) + .find(_.getApplicationTag.contains(appTag))) match { case Some(app) => Some(app) case None => @@ -705,19 +751,28 @@ private[utils] object KubernetesExtensions { """.stripMargin def getApplications( + namespace: String = "", labels: Map[String, String] = Map(SPARK_ROLE_LABEL -> SPARK_ROLE_DRIVER), appTagLabel: String = SPARK_APP_TAG_LABEL, appIdLabel: String = SPARK_APP_ID_LABEL ): Seq[KubernetesApplication] = { - client.pods.inAnyNamespace - .withLabels(labels.asJava) - .withLabel(appTagLabel) + // An empty namespace means "unknown" (e.g. a session recovered from pre-multi-namespace + // metadata); scan all namespaces so the driver pod can still be located, matching the + // pre-multi-namespace behavior. A concrete namespace scopes the LIST to that tenant. + val pods = if (namespace == null || namespace.isEmpty) { + client.pods.inAnyNamespace.withLabels(labels.asJava) + } else { + client.pods.inNamespace(namespace).withLabels(labels.asJava) + } + pods.withLabel(appTagLabel) .withLabel(appIdLabel) .list.getItems.asScala.map(new KubernetesApplication(_)).toSeq } def killApplication(app: KubernetesApplication): Boolean = { - client.pods.inAnyNamespace.delete(app.getApplicationPod) + // Scope the delete to the driver pod's own namespace so teardown works under a + // namespaced RBAC role (a cluster-wide inAnyNamespace delete would be forbidden). + client.pods.inNamespace(app.getApplicationNamespace).delete(app.getApplicationPod) } def getApplicationReport( @@ -742,7 +797,7 @@ private[utils] object KubernetesExtensions { appTagLabel -> app.getApplicationTag, SPARK_ROLE_LABEL -> SPARK_ROLE_EXECUTOR ).asJava) - .list.getItems.asScala + .list.getItems.asScala.toSeq } else { Seq.empty } diff --git a/server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala b/server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala index 2477eac73..57bfed3a1 100644 --- a/server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala @@ -199,7 +199,7 @@ class BatchSessionSpec val req = new CreateBatchRequest() val name = Some("Test Batch Session") val mockApp = mock[SparkApp] - val m = BatchRecoveryMetadata(99, name, None, "appTag", null, None) + val m = BatchRecoveryMetadata(99, name, None, "appTag", null, None, "") val batch = BatchSession.recover(m, conf, sessionStore, Some(mockApp)) batch.state shouldBe (SessionState.Recovering) @@ -216,5 +216,14 @@ class BatchSessionSpec testRecoverSession(name) } } + + it("should propagate the stored namespace through recovery metadata") { + val conf = new LivyConf() + val mockApp = mock[SparkApp] + val m = BatchRecoveryMetadata( + 101, Some("ns-session"), None, "appTag", "owner", None, "team-a") + val batch = BatchSession.recover(m, conf, sessionStore, Some(mockApp)) + batch.recoveryMetadata.asInstanceOf[BatchRecoveryMetadata].namespace shouldBe "team-a" + } } } diff --git a/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala b/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala index 1efa266ac..bb7eb921f 100644 --- a/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala @@ -286,7 +286,7 @@ class InteractiveSessionSpec extends AnyFunSpec val m = InteractiveRecoveryMetadata( 78, Some("Test session"), None, "appTag", Spark, 0, null, None, None, None, None, None, None, Map.empty[String, String], List.empty[String], List.empty[String], - List.empty[String], None, List.empty[String], None, None, Some(URI.create(""))) + List.empty[String], None, List.empty[String], None, None, Some(URI.create("")), "") val s = InteractiveSession.recover(m, conf, sessionStore, None, Some(mockClient)) s.start() @@ -305,7 +305,7 @@ class InteractiveSessionSpec extends AnyFunSpec val m = InteractiveRecoveryMetadata( 78, None, None, "appTag", Spark, 0, null, None, None, None, None, None, None, Map.empty[String, String], List.empty[String], List.empty[String], - List.empty[String], None, List.empty[String], None, None, Some(URI.create(""))) + List.empty[String], None, List.empty[String], None, None, Some(URI.create("")), "") val s = InteractiveSession.recover(m, conf, sessionStore, None, Some(mockClient)) s.start() @@ -322,7 +322,7 @@ class InteractiveSessionSpec extends AnyFunSpec val m = InteractiveRecoveryMetadata( 78, None, Some("appId"), "appTag", Spark, 0, null, None, None, None, None, None, None, Map.empty[String, String], List.empty[String], List.empty[String], - List.empty[String], None, List.empty[String], None, None, None) + List.empty[String], None, List.empty[String], None, None, None, "") val s = InteractiveSession.recover(m, conf, sessionStore, None) s.start() s.state shouldBe a[SessionState.Dead] diff --git a/server/src/test/scala/org/apache/livy/sessions/SessionManagerSpec.scala b/server/src/test/scala/org/apache/livy/sessions/SessionManagerSpec.scala index 8e5557018..7ab557621 100644 --- a/server/src/test/scala/org/apache/livy/sessions/SessionManagerSpec.scala +++ b/server/src/test/scala/org/apache/livy/sessions/SessionManagerSpec.scala @@ -216,7 +216,7 @@ class SessionManagerSpec extends AnyFunSpec with Matchers with LivyBaseUnitTestS implicit def executor: ExecutionContext = ExecutionContext.global def makeMetadata(id: Int, appTag: String): BatchRecoveryMetadata = { - BatchRecoveryMetadata(id, Some(s"test-session-$id"), None, appTag, null, None) + BatchRecoveryMetadata(id, Some(s"test-session-$id"), None, appTag, null, None, "") } def mockSession(id: Int): BatchSession = { diff --git a/server/src/test/scala/org/apache/livy/utils/SparkAppSpec.scala b/server/src/test/scala/org/apache/livy/utils/SparkAppSpec.scala index eb2ba48e3..4a002dea9 100644 --- a/server/src/test/scala/org/apache/livy/utils/SparkAppSpec.scala +++ b/server/src/test/scala/org/apache/livy/utils/SparkAppSpec.scala @@ -17,6 +17,7 @@ package org.apache.livy.utils +import java.io.{File, PrintWriter} import java.nio.file.{Files, Path} import scala.collection.JavaConverters._ @@ -32,6 +33,39 @@ class SparkAppSpec extends AnyFunSpec with LivyBaseUnitTestSuite { private val providerPathKey = "spark.hadoop.hadoop.security.credential.provider.path" private val truststorePasswordKey = "spark.hadoop.hive.metastore.truststore.password" + private def k8sConf(sparkHome: Option[String] = None): LivyConf = { + val conf = new LivyConf(false) + conf.set(LivyConf.LIVY_SPARK_MASTER, "k8s://https://kubernetes.default.svc:443") + sparkHome.foreach(conf.set(LivyConf.SPARK_HOME, _)) + conf + } + + /** Create a throwaway SPARK_HOME with the given spark-defaults.conf contents (or none). */ + private def withSparkHome(defaultsContent: Option[String])(f: String => Unit): Unit = { + // These tests assert resolution from $SPARK_HOME/conf; an ambient SPARK_CONF_DIR takes + // precedence and would shadow the throwaway SPARK_HOME, so skip when it is set. + assume(sys.env.get("SPARK_CONF_DIR").forall(_.isEmpty)) + val sparkHome = Files.createTempDirectory("livy-spark-home").toFile + try { + val confDir = new File(sparkHome, "conf") + assert(confDir.mkdirs()) + defaultsContent.foreach { content => + val writer = new PrintWriter(new File(confDir, "spark-defaults.conf")) + try writer.write(content) finally writer.close() + } + f(sparkHome.getAbsolutePath) + } finally { + deleteRecursively(sparkHome) + } + } + + private def deleteRecursively(file: File): Unit = { + if (file.isDirectory) { + Option(file.listFiles()).foreach(_.foreach(deleteRecursively)) + } + file.delete() + } + private def deleteRecursively(path: Path): Unit = { if (Files.exists(path)) { Files.walk(path).iterator().asScala.toSeq.reverse.foreach(Files.deleteIfExists) @@ -61,6 +95,67 @@ class SparkAppSpec extends AnyFunSpec with LivyBaseUnitTestSuite { } } + describe("SparkApp.getNamespace") { + + it("should return an empty namespace when not running on Kubernetes") { + val conf = new LivyConf(false) + conf.set(LivyConf.LIVY_SPARK_MASTER, "yarn") + // A namespace in the conf must be ignored for non-Kubernetes masters. + val sparkConf = Map(SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY -> "ignored") + assert(SparkApp.getNamespace(sparkConf, conf) === "") + } + + it("should prefer the namespace from the session Spark conf") { + val sparkConf = Map(SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY -> "team-a") + assert(SparkApp.getNamespace(sparkConf, k8sConf()) === "team-a") + } + + it("should fall back to spark-defaults.conf when the conf has no namespace") { + withSparkHome(Some(s"${SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY} team-b\n")) { sparkHome => + assert(SparkApp.getNamespace(Map.empty, k8sConf(Some(sparkHome))) === "team-b") + } + } + + it("should fall back to the default namespace when spark-defaults.conf is absent") { + withSparkHome(None) { sparkHome => + assert(SparkApp.getNamespace(Map.empty, k8sConf(Some(sparkHome))) === + SparkApp.DEFAULT_KUBERNETES_NAMESPACE) + } + } + + it("should fall back to the default namespace when spark-defaults.conf lacks the key") { + withSparkHome(Some("spark.executor.memory 1g\n")) { sparkHome => + assert(SparkApp.getNamespace(Map.empty, k8sConf(Some(sparkHome))) === + SparkApp.DEFAULT_KUBERNETES_NAMESPACE) + } + } + + it("should fall back to the default namespace when SPARK_HOME is not set") { + // Namespace resolution reads SPARK_CONF_DIR then falls back to the SPARK_HOME env var, + // so only assert the env-independent default when the ambient environment sets neither. + assume(sys.env.get("SPARK_HOME").forall(_.isEmpty)) + assume(sys.env.get("SPARK_CONF_DIR").forall(_.isEmpty)) + assert(SparkApp.getNamespace(Map.empty, k8sConf()) === + SparkApp.DEFAULT_KUBERNETES_NAMESPACE) + } + + it("should fall back to the default namespace when spark-defaults.conf is malformed") { + // An invalid unicode escape makes java.util.Properties.load throw; getNamespace must + // swallow it and fall back rather than aborting session creation. + withSparkHome(Some("spark.driver.extraJavaOptions=-Dp=\\uZZZZ\n")) { sparkHome => + assert(SparkApp.getNamespace(Map.empty, k8sConf(Some(sparkHome))) === + SparkApp.DEFAULT_KUBERNETES_NAMESPACE) + } + } + + it("should treat an empty namespace in the conf as unset") { + withSparkHome(Some(s"${SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY} team-c\n")) { sparkHome => + val sparkConf = Map(SparkApp.SPARK_KUBERNETES_NAMESPACE_KEY -> "") + assert(SparkApp.getNamespace(sparkConf, k8sConf(Some(sparkHome))) === "team-c") + } + } + } + describe("SparkApp.prepareSparkConf") { it("should leave conf unchanged when no credential provider path is configured") { val livyConf = new LivyConf(false) diff --git a/server/src/test/scala/org/apache/livy/utils/SparkKubernetesAppSpec.scala b/server/src/test/scala/org/apache/livy/utils/SparkKubernetesAppSpec.scala index 759cbed10..7066588d9 100644 --- a/server/src/test/scala/org/apache/livy/utils/SparkKubernetesAppSpec.scala +++ b/server/src/test/scala/org/apache/livy/utils/SparkKubernetesAppSpec.scala @@ -27,7 +27,7 @@ import org.scalatest.funspec.AnyFunSpec import org.scalatestplus.mockito.MockitoSugar._ import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} -import org.apache.livy.utils.KubernetesConstants.SPARK_APP_TAG_LABEL +import org.apache.livy.utils.KubernetesConstants.{SPARK_APP_ID_LABEL, SPARK_APP_TAG_LABEL} class SparkKubernetesAppSpec extends AnyFunSpec with LivyBaseUnitTestSuite with BeforeAndAfterAll { @@ -318,4 +318,21 @@ class SparkKubernetesAppSpec extends AnyFunSpec with LivyBaseUnitTestSuite with } } + describe("KubernetesApplication") { + // The namespace/tag/id read here drive namespace adoption on recovery, the leaked-app + // GC sweep and the application report; verify they are surfaced from the driver pod. + it("should expose the driver pod's namespace, tag and id") { + val pod = new PodBuilder().withNewMetadata().withName("driver") + .withNamespace("team-b") + .addToLabels(SPARK_APP_TAG_LABEL, "tag-x") + .addToLabels(SPARK_APP_ID_LABEL, "app-x") + .endMetadata().withNewSpec().endSpec().build() + val app = new KubernetesApplication(pod) + assert(app.getApplicationNamespace === "team-b") + assert(app.getApplicationTag === "tag-x") + assert(app.getApplicationId === "app-x") + assert(app.getApplicationPod === pod) + } + } + }