diff --git a/docs/running-on-kubernetes.md b/docs/running-on-kubernetes.md
index 23bc44c46751f..b5f0858fa9835 100644
--- a/docs/running-on-kubernetes.md
+++ b/docs/running-on-kubernetes.md
@@ -1571,6 +1571,7 @@ See the [configuration page](configuration.html) for information on Spark config
K8s IP Family Policy for Driver Service. Valid values are
SingleStack, PreferDualStack, and RequireDualStack.
+ The driver UI Service reuses this setting to keep the same IP family.
|
3.4.0 |
@@ -1580,6 +1581,7 @@ See the [configuration page](configuration.html) for information on Spark config
A list of IP families for K8s Driver Service. Valid values are
IPv4 and IPv6.
+ The driver UI Service reuses this setting to keep the same IP family.
|
3.4.0 |
@@ -1594,6 +1596,38 @@ See the [configuration page](configuration.html) for information on Spark config
4.3.0 |
+
+ spark.kubernetes.driver.ui.service.enabled |
+ false |
+
+ If true, Spark will create a dedicated Kubernetes Service for the Spark driver Web UI.
+ When enabled, after the driver Web UI starts, Spark will patch the Service's
+ targetPort to match the actual bound UI port, which allows using
+ spark.ui.port=0 (random port). Requires the driver's ServiceAccount to have
+ get and patch verbs on services.
+ |
+ 4.3.0 |
+
+
+ spark.kubernetes.driver.ui.service.type |
+ ClusterIP |
+
+ K8s Service type for the dedicated Spark driver Web UI Service (only applies when
+ spark.kubernetes.driver.ui.service.enabled=true). Supported values are
+ ClusterIP, NodePort, and LoadBalancer.
+ |
+ 4.3.0 |
+
+
+ spark.kubernetes.driver.ui.service.name |
+ (none) |
+
+ Optional override for the dedicated Spark driver Web UI Service name (only applies when
+ spark.kubernetes.driver.ui.service.enabled=true). If unset, Spark derives the
+ name as <resourceNamePrefix>-ui-svc.
+ |
+ 4.3.0 |
+
spark.kubernetes.securityContext.allowPrivilegeEscalation |
false |
diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala
index 00a02f3cba6f6..b190ca4a3de79 100644
--- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala
+++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala
@@ -74,7 +74,8 @@ private[spark] object Config extends Logging {
val KUBERNETES_DRIVER_SERVICE_IP_FAMILY_POLICY =
ConfigBuilder("spark.kubernetes.driver.service.ipFamilyPolicy")
- .doc("K8s IP Family Policy for Driver Service")
+ .doc("K8s IP Family Policy for Driver Service. The driver UI Service reuses this " +
+ "setting to keep the same IP family.")
.version("3.4.0")
.stringConf
.checkValues(Set("SingleStack", "PreferDualStack", "RequireDualStack"))
@@ -82,7 +83,8 @@ private[spark] object Config extends Logging {
val KUBERNETES_DRIVER_SERVICE_IP_FAMILIES =
ConfigBuilder("spark.kubernetes.driver.service.ipFamilies")
- .doc("A list of IP families for K8s Driver Service")
+ .doc("A list of IP families for K8s Driver Service. The driver UI Service reuses this " +
+ "setting to keep the same IP family.")
.version("3.4.0")
.stringConf
.checkValues(Set("IPv4", "IPv6", "IPv4,IPv6", "IPv6,IPv4"))
@@ -100,6 +102,36 @@ private[spark] object Config extends Logging {
.booleanConf
.createWithDefault(false)
+ val KUBERNETES_DRIVER_UI_SERVICE_ENABLED =
+ ConfigBuilder("spark.kubernetes.driver.ui.service.enabled")
+ .doc("If true, Spark will create a dedicated Kubernetes Service for the Spark driver " +
+ "Web UI (separate from the headless driver service). When enabled, after the driver " +
+ "Web UI starts, Spark will patch the Service's targetPort to match the actual bound " +
+ "UI port, which allows using `spark.ui.port=0` (random port). Requires the driver's " +
+ "ServiceAccount to have `get` and `patch` verbs on `services`.")
+ .version("4.3.0")
+ .booleanConf
+ .createWithDefault(false)
+
+ val KUBERNETES_DRIVER_UI_SERVICE_TYPE =
+ ConfigBuilder("spark.kubernetes.driver.ui.service.type")
+ .doc("K8s Service type for the dedicated Spark driver Web UI Service " +
+ s"(only applies when ${KUBERNETES_DRIVER_UI_SERVICE_ENABLED.key}=true). " +
+ "Supported values: ClusterIP, NodePort, LoadBalancer.")
+ .version("4.3.0")
+ .stringConf
+ .checkValues(Set("ClusterIP", "NodePort", "LoadBalancer"))
+ .createWithDefault("ClusterIP")
+
+ val KUBERNETES_DRIVER_UI_SERVICE_NAME =
+ ConfigBuilder("spark.kubernetes.driver.ui.service.name")
+ .doc("Optional override for the dedicated Spark driver Web UI Service name " +
+ s"(only applies when ${KUBERNETES_DRIVER_UI_SERVICE_ENABLED.key}=true). " +
+ "If unset, Spark derives the name as `-ui-svc`.")
+ .version("4.3.0")
+ .stringConf
+ .createOptional
+
val KUBERNETES_DRIVER_OWN_PVC =
ConfigBuilder("spark.kubernetes.driver.ownPersistentVolumeClaim")
.doc("If true, driver pod becomes the owner of on-demand persistent volume claims " +
diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Constants.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Constants.scala
index 4ffd617c4e4ce..29b5fecbecdd5 100644
--- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Constants.scala
+++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Constants.scala
@@ -108,6 +108,10 @@ object Constants {
val DEFAULT_PVC_ACCESS_MODE = "ReadWriteOncePod"
val NON_JVM_MEMORY_OVERHEAD_FACTOR = 0.4d
val CONNECT_GRPC_BINDING_PORT = "spark.connect.grpc.binding.port"
+
+ // Suffix for the dedicated Spark Web UI Service.
+ val DRIVER_UI_SVC_POSTFIX = "-ui-svc"
+
val EXIT_EXCEPTION_ANNOTATION = "spark.exit-exception"
val POD_DELETION_COST = "controller.kubernetes.io/pod-deletion-cost"
diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/KubernetesConf.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/KubernetesConf.scala
index 9070d6ce35ff6..228172bfd6b50 100644
--- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/KubernetesConf.scala
+++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/KubernetesConf.scala
@@ -117,6 +117,19 @@ class KubernetesDriverConf(
}
}
+ lazy val driverUIServiceName: String = {
+ val preferredServiceName = s"$resourceNamePrefix$DRIVER_UI_SVC_POSTFIX"
+ if (preferredServiceName.length <= MAX_SERVICE_NAME_LENGTH) {
+ preferredServiceName
+ } else {
+ val randomServiceId = KubernetesUtils.uniqueID(clock)
+ val shorterServiceName = s"spark-$randomServiceId$DRIVER_UI_SVC_POSTFIX"
+ logWarning(s"Preferred UI service name '$preferredServiceName' exceeds Kubernetes DNS " +
+ s"label limit ($MAX_SERVICE_NAME_LENGTH); using '$shorterServiceName' instead.")
+ shorterServiceName
+ }
+ }
+
override val resourceNamePrefix: String = {
val custom = if (Utils.isTesting) get(KUBERNETES_DRIVER_POD_NAME_PREFIX) else None
custom.getOrElse(KubernetesConf.getResourceNamePrefix(appName))
diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala
new file mode 100644
index 0000000000000..212103356b419
--- /dev/null
+++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.deploy.k8s.features
+
+import scala.jdk.CollectionConverters._
+
+import io.fabric8.kubernetes.api.model.{HasMetadata, ServiceBuilder}
+
+import org.apache.spark.deploy.k8s.{KubernetesDriverConf, SparkPod}
+import org.apache.spark.deploy.k8s.Config.{
+ KUBERNETES_DRIVER_SERVICE_IP_FAMILIES,
+ KUBERNETES_DRIVER_SERVICE_IP_FAMILY_POLICY,
+ KUBERNETES_DRIVER_UI_SERVICE_ENABLED,
+ KUBERNETES_DRIVER_UI_SERVICE_NAME,
+ KUBERNETES_DRIVER_UI_SERVICE_TYPE
+}
+import org.apache.spark.deploy.k8s.Constants._
+import org.apache.spark.internal.{config, Logging}
+
+/**
+ * Optionally provisions a dedicated Kubernetes Service exposing only the Spark driver's Web UI
+ * port.
+ *
+ * At creation time, the Service's `targetPort` is set to the configured `spark.ui.port`
+ * (a placeholder when the user has requested a random port). Once the driver's Jetty server
+ * has bound, `K8sDriverUIServicePatcher` updates the Service's `targetPort` to reflect the
+ * actual bound port.
+ */
+private[spark] class DriverUIServiceFeatureStep(kubernetesConf: KubernetesDriverConf)
+ extends KubernetesFeatureConfigStep with Logging {
+ import DriverUIServiceFeatureStep._
+
+ private val enabled = kubernetesConf.get(KUBERNETES_DRIVER_UI_SERVICE_ENABLED)
+ private lazy val serviceType = kubernetesConf.get(KUBERNETES_DRIVER_UI_SERVICE_TYPE)
+ private lazy val configuredUIPort = kubernetesConf.get(config.UI.UI_PORT)
+
+ /**
+ * Port value used when building the Service. When the user has requested a random UI port
+ * (`spark.ui.port=0`), the actual port is only known after the driver's Jetty server binds,
+ * so we substitute the default UI port (typically 4040) purely as a placeholder to satisfy
+ * Kubernetes' Service port validation (must be > 0). After the driver JVM starts,
+ * [[org.apache.spark.scheduler.cluster.k8s.K8sDriverUIServicePatcher]] updates the Service's
+ * `targetPort` to the real bound port.
+ */
+ private lazy val servicePort: Int = if (configuredUIPort == 0) {
+ config.UI.UI_PORT.defaultValue.get
+ } else {
+ configuredUIPort
+ }
+
+ private lazy val serviceName: String = kubernetesConf.get(KUBERNETES_DRIVER_UI_SERVICE_NAME)
+ .getOrElse(kubernetesConf.driverUIServiceName)
+
+ // The UI Service reuses the driver Service IP family settings to keep the same IP family.
+ private lazy val ipFamilyPolicy = kubernetesConf.get(KUBERNETES_DRIVER_SERVICE_IP_FAMILY_POLICY)
+ private lazy val ipFamilies =
+ kubernetesConf.get(KUBERNETES_DRIVER_SERVICE_IP_FAMILIES).split(",").toList.asJava
+
+ override def configurePod(pod: SparkPod): SparkPod = pod
+
+ override def getAdditionalPodSystemProperties(): Map[String, String] = {
+ if (enabled) {
+ Map(KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL -> serviceName)
+ } else {
+ Map.empty
+ }
+ }
+
+ override def getAdditionalKubernetesResources(): Seq[HasMetadata] = {
+ if (!enabled) return Seq.empty
+
+ val uiService = new ServiceBuilder()
+ .withNewMetadata()
+ .withName(serviceName)
+ .addToAnnotations(kubernetesConf.serviceAnnotations.asJava)
+ .addToLabels(SPARK_APP_ID_LABEL, kubernetesConf.appId)
+ .addToLabels(kubernetesConf.serviceLabels.asJava)
+ .endMetadata()
+ .withNewSpec()
+ .withType(serviceType)
+ .withIpFamilyPolicy(ipFamilyPolicy)
+ .withIpFamilies(ipFamilies)
+ .withSelector(kubernetesConf.labels.asJava)
+ .addNewPort()
+ .withName(UI_PORT_NAME)
+ .withPort(servicePort)
+ .withNewTargetPort(servicePort)
+ .endPort()
+ .endSpec()
+ .build()
+ Seq(uiService)
+ }
+}
+
+private[spark] object DriverUIServiceFeatureStep {
+ /**
+ * Internal spark conf key used to pass the UI service name from this feature step to the
+ * driver runtime (SparkContext) so `K8sDriverUIServicePatcher` can look up the Service to
+ * patch.
+ */
+ val KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL =
+ "spark.kubernetes.driver.ui.service.name.internal"
+}
diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesDriverBuilder.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesDriverBuilder.scala
index 8af7d243a5a40..3e285d1bcd08a 100644
--- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesDriverBuilder.scala
+++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesDriverBuilder.scala
@@ -77,6 +77,7 @@ class KubernetesDriverBuilder {
new BasicDriverFeatureStep(conf),
new DriverKubernetesCredentialsFeatureStep(conf),
new DriverServiceFeatureStep(conf),
+ new DriverUIServiceFeatureStep(conf),
new NetworkPolicyFeatureStep(conf),
new MountSecretsFeatureStep(conf),
new EnvSecretsFeatureStep(conf),
diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/K8sDriverUIServicePatcher.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/K8sDriverUIServicePatcher.scala
new file mode 100644
index 0000000000000..c2c78e3ad46b0
--- /dev/null
+++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/K8sDriverUIServicePatcher.scala
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.scheduler.cluster.k8s
+
+import scala.jdk.CollectionConverters._
+
+import io.fabric8.kubernetes.api.model.{IntOrString, ServiceBuilder}
+import io.fabric8.kubernetes.client.KubernetesClient
+
+import org.apache.spark.deploy.k8s.Constants.UI_PORT_NAME
+import org.apache.spark.internal.Logging
+
+/**
+ * Driver-side utility that updates the `targetPort` of the dedicated Spark UI Kubernetes Service
+ * (created by [[org.apache.spark.deploy.k8s.features.DriverUIServiceFeatureStep]]) to the actual
+ * port the driver's Jetty server bound to.
+ */
+private[k8s] object K8sDriverUIServicePatcher extends Logging {
+
+ /**
+ * Patch the targetPort of the given Service's `spark-ui` port entry to `actualPort`, if the
+ * current value differs.
+ *
+ * @param client Kubernetes client to use (typically the one already held by the backend).
+ * @param namespace Namespace where the Service lives.
+ * @param serviceName Name of the UI Service (from
+ * `spark.kubernetes.driver.ui.service.name.internal`).
+ * @param actualPort The actual port the driver's Jetty server bound to
+ * (typically `SparkUI.boundPort`).
+ */
+ def patchTargetPort(
+ client: KubernetesClient,
+ namespace: String,
+ serviceName: String,
+ actualPort: Int): Unit = {
+ try {
+ val service = client.services()
+ .inNamespace(namespace)
+ .withName(serviceName)
+ .get()
+
+ if (service == null) {
+ logWarning(s"UI service '$serviceName' not found in namespace '$namespace'; " +
+ "skipping targetPort patch.")
+ return
+ }
+
+ val currentTargetPort = service.getSpec.getPorts.asScala
+ .find(_.getName == UI_PORT_NAME)
+ .map(_.getTargetPort.getIntVal.toInt)
+
+ currentTargetPort match {
+ case Some(existing) if existing == actualPort =>
+ logInfo(s"UI service '$serviceName' targetPort already matches actual UI port " +
+ s"($actualPort); no patch needed.")
+
+ case _ =>
+ val updated = new ServiceBuilder(service)
+ .editSpec()
+ .editMatchingPort(portBuilder => portBuilder.build().getName == UI_PORT_NAME)
+ .withTargetPort(new IntOrString(actualPort))
+ .endPort()
+ .endSpec()
+ .build()
+
+ client.services()
+ .inNamespace(namespace)
+ .withName(serviceName)
+ .patch(updated)
+
+ logInfo(s"Patched UI service '$serviceName' targetPort " +
+ s"${currentTargetPort.map(_.toString).getOrElse("")} -> $actualPort")
+ }
+ } catch {
+ case e: Exception =>
+ logError(s"Failed to patch UI service '$serviceName' targetPort to $actualPort", e)
+ throw e
+ }
+ }
+}
diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala
index 0784b82a85de2..5c079f3e8d43b 100644
--- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala
+++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala
@@ -31,6 +31,7 @@ import org.apache.spark.SparkContext
import org.apache.spark.deploy.k8s.{KubernetesConf, KubernetesUtils}
import org.apache.spark.deploy.k8s.Config._
import org.apache.spark.deploy.k8s.Constants._
+import org.apache.spark.deploy.k8s.features.DriverUIServiceFeatureStep
import org.apache.spark.deploy.k8s.submit.KubernetesClientUtils
import org.apache.spark.deploy.security.HadoopDelegationTokenManager
import org.apache.spark.internal.LogKeys.{COUNT, TOTAL}
@@ -120,6 +121,27 @@ private[spark] class KubernetesClusterSchedulerBackend(
if (!conf.get(KUBERNETES_EXECUTOR_DISABLE_CONFIGMAP)) {
setUpExecutorConfigMap(podAllocator.driverPod)
}
+ maybePatchDriverUIServiceTargetPort()
+ }
+
+ /**
+ * Patch the dedicated UI Service's `targetPort` to match the actual bound port of the driver's
+ * Jetty server. Only applies when the UI service feature is enabled. Requires `patch services`
+ * RBAC on the driver's ServiceAccount.
+ */
+ private def maybePatchDriverUIServiceTargetPort(): Unit = {
+ if (!conf.get(KUBERNETES_DRIVER_UI_SERVICE_ENABLED)) return
+ for {
+ svcName <- conf.getOption(
+ DriverUIServiceFeatureStep.KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL)
+ actualPort <- sc.ui.map(_.boundPort)
+ } {
+ K8sDriverUIServicePatcher.patchTargetPort(
+ kubernetesClient,
+ conf.get(KUBERNETES_NAMESPACE),
+ svcName,
+ actualPort)
+ }
}
override def stop(): Unit = {
diff --git a/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStepSuite.scala b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStepSuite.scala
new file mode 100644
index 0000000000000..bf85653abaa0c
--- /dev/null
+++ b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStepSuite.scala
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.deploy.k8s.features
+
+import scala.jdk.CollectionConverters._
+
+import io.fabric8.kubernetes.api.model.Service
+
+import org.apache.spark.{SparkConf, SparkFunSuite}
+import org.apache.spark.deploy.k8s.Config._
+import org.apache.spark.deploy.k8s.Constants._
+import org.apache.spark.deploy.k8s.KubernetesTestConf
+import org.apache.spark.internal.config.UI._
+
+class DriverUIServiceFeatureStepSuite extends SparkFunSuite {
+
+ test("SPARK-58203: no additional resource when the feature is disabled") {
+ val sparkConf = new SparkConf(false).set(UI_PORT, 4080)
+ val kconf = KubernetesTestConf.createDriverConf(sparkConf = sparkConf)
+ val step = new DriverUIServiceFeatureStep(kconf)
+
+ assert(step.getAdditionalKubernetesResources().isEmpty)
+ assert(!step.getAdditionalPodSystemProperties()
+ .contains(DriverUIServiceFeatureStep.KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL))
+ }
+
+ test("SPARK-58203: dedicated UI service is created when enabled") {
+ val sparkConf = new SparkConf(false)
+ .set(UI_PORT, 4080)
+ .set(KUBERNETES_DRIVER_UI_SERVICE_ENABLED, true)
+ val kconf = KubernetesTestConf.createDriverConf(sparkConf = sparkConf)
+ val step = new DriverUIServiceFeatureStep(kconf)
+
+ val resources = step.getAdditionalKubernetesResources()
+ assert(resources.size === 1)
+
+ val uiSvc = resources.head.asInstanceOf[Service]
+ assert(uiSvc.getMetadata.getName ===
+ s"${kconf.resourceNamePrefix}${DRIVER_UI_SVC_POSTFIX}")
+ assert(uiSvc.getSpec.getType === "ClusterIP")
+ assert(uiSvc.getSpec.getSelector.asScala === kconf.labels)
+ assert(uiSvc.getSpec.getPorts.size === 1)
+ assert(uiSvc.getSpec.getPorts.get(0).getName === UI_PORT_NAME)
+ assert(uiSvc.getSpec.getPorts.get(0).getPort.intValue() === 4080)
+ assert(uiSvc.getSpec.getPorts.get(0).getTargetPort.getIntVal === 4080)
+
+ assert(step.getAdditionalPodSystemProperties()
+ .get(DriverUIServiceFeatureStep.KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL)
+ === Some(s"${kconf.resourceNamePrefix}${DRIVER_UI_SVC_POSTFIX}"))
+ }
+
+ test("SPARK-58203: UI service honors explicit name and type overrides") {
+ val sparkConf = new SparkConf(false)
+ .set(UI_PORT, 4080)
+ .set(KUBERNETES_DRIVER_UI_SERVICE_ENABLED, true)
+ .set(KUBERNETES_DRIVER_UI_SERVICE_TYPE, "NodePort")
+ .set(KUBERNETES_DRIVER_UI_SERVICE_NAME, "my-app-ui-svc")
+ val kconf = KubernetesTestConf.createDriverConf(sparkConf = sparkConf)
+ val step = new DriverUIServiceFeatureStep(kconf)
+
+ val uiSvc = step.getAdditionalKubernetesResources().head.asInstanceOf[Service]
+ assert(uiSvc.getMetadata.getName === "my-app-ui-svc")
+ assert(uiSvc.getSpec.getType === "NodePort")
+
+ assert(step.getAdditionalPodSystemProperties()
+ .get(DriverUIServiceFeatureStep.KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL)
+ === Some("my-app-ui-svc"))
+ }
+
+ test("SPARK-58203: spark.ui.port=0 substitutes the default UI port as Service placeholder") {
+ val sparkConf = new SparkConf(false)
+ .set(UI_PORT, 0)
+ .set(KUBERNETES_DRIVER_UI_SERVICE_ENABLED, true)
+ val kconf = KubernetesTestConf.createDriverConf(sparkConf = sparkConf)
+ val step = new DriverUIServiceFeatureStep(kconf)
+
+ val uiSvc = step.getAdditionalKubernetesResources().head.asInstanceOf[Service]
+ val expectedPlaceholder = UI_PORT.defaultValue.get
+ assert(uiSvc.getSpec.getPorts.size === 1)
+ assert(uiSvc.getSpec.getPorts.get(0).getPort.intValue() === expectedPlaceholder)
+ assert(uiSvc.getSpec.getPorts.get(0).getTargetPort.getIntVal === expectedPlaceholder)
+ }
+}