Repository: kubeflow/spark-operator

Issue:
Make UI ingress's `tls` and `annotations` configurable in the helm config
### What feature you would like to be added?

The `SparkApplication` resource has `ingressAnnotations` and `ingressTLS` parameters for controlling the spark UI ingress that is created https://github.com/kubeflow/spark-operator/blob/master/docs/api-docs.md#sparkuiconfiguration. Meanwhile the ingress URL for the spark UI is configured by `controller.uiIngress.urlFormat` in the helm config https://github.com/kubeflow/spark-operator/tree/master/charts/spark-operator-chart#values.

It would be useful if we could also configure default  `ingressAnnotations` and `ingressTLS` in the helm config. 

### Why is this needed?

`controller.uiIngress.urlFormat` is constant across all `SparkApplication`s and its likely that ingress `tls` and `annotations` will be too. So it could be a nice convenience thing to have it configurable in the helm config. 

I also have a specific use-case where I'm using an integration for the `spark-operator` and I don't have much control over the creation of the `SparkApplication`, so I can't use the `ingressAnnotations` and `ingressTLS` that exist on this. For context the integration I'm using is https://docs.flyte.org/en/latest/flytesnacks/examples/k8s_spark_plugin/index.html

### Describe the solution you would like

New helm config options for default `annotations` and `tls`. 

### Describe alternatives you have considered

My specific problem with Flyte could be solved with a new feature in Flyte. I'm not really confident what the best solution is but I imagine this would be easier to implement and I think it could have some broader value. 

### Additional context
If there is some consensus that this is a reasonable thing to do then I might be able to contribute it. 

### Love this feature?

Give it a 👍 We prioritize the features with most 👍

Discussion:
@thetaiter-swf: Bump@Tom-Newton: I really want this feature, so I'm going to start working on it. @Tom-Newton: I think the PR is ready for review: https://github.com/kubeflow/spark-operator/pull/2513

Solution patch:
diff --git a/charts/spark-operator-chart/README.md b/charts/spark-operator-chart/README.md
index 5af37c1..771b18c 100644
--- a/charts/spark-operator-chart/README.md
+++ b/charts/spark-operator-chart/README.md
@@ -93,6 +93,8 @@ See [helm uninstall](https://helm.sh/docs/helm/helm_uninstall) for command docum
 | controller.uiIngress.enable | bool | `false` | Specifies whether to create ingress for Spark web UI. `controller.uiService.enable` must be `true` to enable ingress. |
 | controller.uiIngress.urlFormat | string | `""` | Ingress URL format. Required if `controller.uiIngress.enable` is true. |
 | controller.uiIngress.ingressClassName | string | `""` | Optionally set the ingressClassName. |
+| controller.uiIngress.tls | list | `[]` | Optionally set default TLS configuration for the Spark UI's ingress. `ingressTLS` in the SparkApplication spec overrides this. |
+| controller.uiIngress.annotations | object | `{}` | Optionally set default ingress annotations for the Spark UI's ingress. `ingressAnnotations` in the SparkApplication spec overrides this. |
 | controller.batchScheduler.enable | bool | `false` | Specifies whether to enable batch scheduler for spark jobs scheduling. If enabled, users can specify batch scheduler name in spark application. |
 | controller.batchScheduler.kubeSchedulerNames | list | `[]` | Specifies a list of kube-scheduler names for scheduling Spark pods. |
 | controller.batchScheduler.default | string | `""` | Default batch scheduler to be used if not specified by the user. If specified, this value must be either "volcano" or "yunikorn". Specifying any other value will cause the controller to error on startup. |
diff --git a/charts/spark-operator-chart/templates/controller/deployment.yaml b/charts/spark-operator-chart/templates/controller/deployment.yaml
index fa5cc15..78bc32e 100644
--- a/charts/spark-operator-chart/templates/controller/deployment.yaml
+++ b/charts/spark-operator-chart/templates/controller/deployment.yaml
@@ -72,6 +72,12 @@ spec:
         {{- with .Values.controller.uiIngress.ingressClassName }}
         - --ingress-class-name={{ . }}
         {{- end }}
+        {{- with .Values.controller.uiIngress.tls }}
+        - --ingress-tls={{ . | toJson }}
+        {{- end }}
+        {{- with .Values.controller.uiIngress.annotations }}
+        - --ingress-annotations={{ . | toJson }}
+        {{- end }}
         {{- end }}
         {{- if .Values.controller.batchScheduler.enable }}
         - --enable-batch-scheduler=true
diff --git a/charts/spark-operator-chart/values.yaml b/charts/spark-operator-chart/values.yaml
index e2df010..9e72319 100644
--- a/charts/spark-operator-chart/values.yaml
+++ b/charts/spark-operator-chart/values.yaml
@@ -74,6 +74,15 @@ controller:
     urlFormat: ""
     # -- Optionally set the ingressClassName.
     ingressClassName: ""
+    # -- Optionally set default TLS configuration for the Spark UI's ingress. `ingressTLS` in the SparkApplication spec overrides this.
+    tls: []
+    # - hosts:
+    #   - "*.example.com"
+    #   secretName: "example-secret"
+    # -- Optionally set default ingress annotations for the Spark UI's ingress. `ingressAnnotations` in the SparkApplication spec overrides this.
+    annotations: {}
+      # key1: value1
+      # key2: value2
 
   batchScheduler:
     # -- Specifies whether to enable batch scheduler for spark jobs scheduling.
diff --git a/cmd/operator/controller/start.go b/cmd/operator/controller/start.go
index 0fed5d0..6306234 100644
--- a/cmd/operator/controller/start.go
+++ b/cmd/operator/controller/start.go
@@ -18,7 +18,9 @@ package controller
 
 import (
 	"crypto/tls"
+	"encoding/json"
 	"flag"
+	"fmt"
 	"os"
 	"slices"
 	"time"
@@ -33,6 +35,7 @@ import (
 	"go.uber.org/zap/zapcore"
 	"golang.org/x/time/rate"
 	corev1 "k8s.io/api/core/v1"
+	networkingv1 "k8s.io/api/networking/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
@@ -87,9 +90,11 @@ var (
 	defaultBatchScheduler string
 
 	// Spark web UI service and ingress
-	enableUIService  bool
-	ingressClassName string
-	ingressURLFormat string
+	enableUIService    bool
+	ingressClassName   string
+	ingressURLFormat   string
+	ingressTLS         []networkingv1.IngressTLS
+	ingressAnnotations map[string]string
 
 	// Leader election
 	enableLeaderElection        bool
@@ -126,11 +131,25 @@ func init() {
 }
 
 func NewStartCommand() *cobra.Command {
+	var ingressTLSstring string
+	var ingressAnnotationsString string
 	var command = &cobra.Command{
 		Use:   "start",
 		Short: "Start controller and webhook",
-		PreRun: func(_ *cobra.Command, args []string) {
+		PreRunE: func(_ *cobra.Command, args []string) error {
 			development = viper.GetBool("development")
+
+			if ingressTLSstring != "" {
+				if err := json.Unmarshal([]byte(ingressTLSstring), &ingressTLS); err != nil {
+					return fmt.Errorf("failed parsing ingress-tls JSON string from CLI: %v", err)
+				}
+			}
+			if ingressAnnotationsString != "" {
+				if err := json.Unmarshal([]byte(ingressAnnotationsString), &ingressAnnotations); err != nil {
+					return fmt.Errorf("failed parsing ingress-annotations JSON string from CLI: %v", err)
+				}
+			}
+			return nil
 		},
 		Run: func(_ *cobra.Command, args []string) {
 			sparkoperator.PrintVersion(false)
@@ -154,6 +173,8 @@ func NewStartCommand() *cobra.Command {
 	command.Flags().BoolVar(&enableUIService, "enable-ui-service", true, "Enable Spark Web UI service.")
 	command.Flags().StringVar(&ingressClassName, "ingress-class-name", "", "Set ingressClassName for ingress resources created.")
 	command.Flags().StringVar(&ingressURLFormat, "ingress-url-format", "", "Ingress URL format.")
+	command.Flags().StringVar(&ingressTLSstring, "ingress-tls", "", "JSON format string for the default TLS config on the Spark UI ingresses. e.g. '[{\"hosts\":[\"*.example.com\"],\"secretName\":\"example-secret\"}]'. `ingressTLS` in the SparkApplication spec will override this value.")
+	command.Flags().StringVar(&ingressAnnotationsString, "ingress-annotations", "", "JSON format string for the default ingress annotations for the Spark UI ingresses. e.g. '[{\"cert-manager.io/cluster-issuer\": \"letsencrypt\"}]'. `ingressAnnotations` in the SparkApplication spec will override this value.")
 
 	command.Flags().BoolVar(&enableLeaderElection, "leader-election", false, "Enable leader election for controller manager. "+
 		"Enabling this will ensure there is only one active controller manager.")
@@ -403,6 +424,8 @@ func newSparkApplicationReconcilerOptions() sparkapplication.Options {
 		EnableUIService:              enableUIService,
 		IngressClassName:             ingressClassName,
 		IngressURLFormat:             ingressURLFormat,
+		IngressTLS:                   ingressTLS,
+		IngressAnnotations:           ingressAnnotations,
 		DefaultBatchScheduler:        defaultBatchScheduler,
 		DriverPodCreationGracePeriod: driverPodCreationGracePeriod,
 		SparkApplicationMetrics:      sparkApplicationMetrics,
diff --git a/internal/controller/sparkapplication/controller.go b/internal/controller/sparkapplication/controller.go
index 9d02616..94168c6 100644
--- a/internal/controller/sparkapplication/controller.go
+++ b/internal/controller/sparkapplication/controller.go
@@ -61,6 +61,8 @@ type Options struct {
 	EnableUIService       bool
 	IngressClassName      string
 	IngressURLFormat      string
+	IngressTLS            []networkingv1.IngressTLS
+	IngressAnnotations    map[string]string
 	DefaultBatchScheduler string
 
 	DriverPodCreationGracePeriod time.Duration
@@ -323,7 +325,7 @@ func (r *Reconciler) reconcileSubmittedSparkApplication(ctx context.Context, req
 						app.Spec.SparkConf[common.SparkUIProxyBase] = ingressURL.Path
 						app.Spec.SparkConf[common.SparkUIProxyRedirectURI] = "/"
 					}
-					ingress, err := r.createWebUIIngress(app, *service, ingressURL, r.options.IngressClassName)
+					ingress, err := r.createWebUIIngress(app, *service, ingressURL, r.options.IngressClassName, r.options.IngressTLS, r.options.IngressAnnotations)
 					if err != nil {
 						return fmt.Errorf("failed to create web UI ingress: %v", err)
 					}
diff --git a/internal/controller/sparkapplication/driveringress.go b/internal/controller/sparkapplication/driveringress.go
index dab7155..6dfa6a2 100644
--- a/internal/controller/sparkapplication/driveringress.go
+++ b/internal/controller/sparkapplication/driveringress.go
@@ -89,14 +89,20 @@ func (r *Reconciler) createDriverIngress(app *v1beta2.SparkApplication, driverIn
 	}
 	ingressName := fmt.Sprintf("%s-ing-%d", app.Name, *driverIngressConfiguration.ServicePort)
 	if util.IngressCapabilities.Has("networking.k8s.io/v1") {
-		return r.createDriverIngressV1(app, service, ingressName, ingressURL, ingressClassName)
+		return r.createDriverIngressV1(app, service, ingressName, ingressURL, ingressClassName, []networkingv1.IngressTLS{}, map[string]string{})
 	}
 	return r.createDriverIngressLegacy(app, service, ingressName, ingressURL)
 }
 
-func (r *Reconciler) createDriverIngressV1(app *v1beta2.SparkApplication, service SparkService, ingressName string, ingressURL *url.URL, ingressClassName string) (*SparkIngress, error) {
+func (r *Reconciler) createDriverIngressV1(app *v1beta2.SparkApplication, service SparkService, ingressName string, ingressURL *url.URL, ingressClassName string, defaultIngressTLS []networkingv1.IngressTLS, defaultIngressAnnotations map[string]string) (*SparkIngress, error) {
 	ingressResourceAnnotations := util.GetWebUIIngressAnnotations(app)
+	if len(ingressResourceAnnotations) == 0 && len(defaultIngressAnnotations) != 0 {
+		ingressResourceAnnotations = defaultIngressAnnotations
+	}
 	ingressTLSHosts := util.GetWebUIIngressTLS(app)
+	if len(ingressTLSHosts) == 0 && len(defaultIngressTLS) != 0 {
+		ingressTLSHosts = defaultIngressTLS
+	}
 
 	ingressURLPath := ingressURL.Path
 	// If we're serving on a subpath, we need to ensure we create capture groups
diff --git a/internal/controller/sparkapplication/web_ui.go b/internal/controller/sparkapplication/web_ui.go
index 0ecd7dd..1203022 100644
--- a/internal/controller/sparkapplication/web_ui.go
+++ b/internal/controller/sparkapplication/web_ui.go
@@ -21,6 +21,8 @@ import (
 	"net/url"
 	"strconv"
 
+	networkingv1 "k8s.io/api/networking/v1"
+
 	"github.com/kubeflow/spark-operator/v2/api/v1beta2"
 	"github.com/kubeflow/spark-operator/v2/pkg/common"
 	"github.com/kubeflow/spark-operator/v2/pkg/util"
@@ -46,10 +48,10 @@ func (r *Reconciler) createWebUIService(app *v1beta2.SparkApplication) (*SparkSe
 	return r.createDriverIngressService(app, portName, port, targetPort, serviceName, serviceType, serviceAnnotations, serviceLabels)
 }
 
-func (r *Reconciler) createWebUIIngress(app *v1beta2.SparkApplication, service SparkService, ingressURL *url.URL, ingressClassName string) (*SparkIngress, error) {
+func (r *Reconciler) createWebUIIngress(app *v1beta2.SparkApplication, service SparkService, ingressURL *url.URL, ingressClassName string, defaultIngressTLS []networkingv1.IngressTLS, defaultIngressAnnotations map[string]string) (*SparkIngress, error) {
 	ingressName := util.GetDefaultUIIngressName(app)
 	if util.IngressCapabilities.Has("networking.k8s.io/v1") {
-		return r.createDriverIngressV1(app, service, ingressName, ingressURL, ingressClassName)
+		return r.createDriverIngressV1(app, service, ingressName, ingressURL, ingressClassName, defaultIngressTLS, defaultIngressAnnotations)
 	}
 	return r.createDriverIngressLegacy(app, service, ingressName, ingressURL)
 }



Test patch:
diff --git a/charts/spark-operator-chart/tests/controller/deployment_test.yaml b/charts/spark-operator-chart/tests/controller/deployment_test.yaml
index 0261471..1f17d51 100644
--- a/charts/spark-operator-chart/tests/controller/deployment_test.yaml
+++ b/charts/spark-operator-chart/tests/controller/deployment_test.yaml
@@ -184,6 +184,37 @@ tests:
           path: spec.template.spec.containers[?(@.name=="spark-operator-controller")].args
           content: --ingress-class-name=nginx
 
+  - it: Should contain `--ingress-tls` arg if `controller.uiIngress.enable` is set to `true` and `controller.uiIngress.tls` is set
+    set:
+      controller:
+        uiService:
+          enable: true
+        uiIngress:
+          enable: true
+          tls:
+            - hosts:
+                - "*.test.com"
+              secretName: test-secret
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[?(@.name=="spark-operator-controller")].args
+          content: '--ingress-tls=[{"hosts":["*.test.com"],"secretName":"test-secret"}]'
+
+  - it: Should contain `--ingress-annotations` arg if `controller.uiIngress.enable` is set to `true` and `controller.uiIngress.annotations` is set
+    set:
+      controller:
+        uiService:
+          enable: true
+        uiIngress:
+          enable: true
+          annotations:
+            cert-manager.io/cluster-issuer: "letsencrypt"
+            kubernetes.io/ingress.class: nginx
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[?(@.name=="spark-operator-controller")].args
+          content: '--ingress-annotations={"cert-manager.io/cluster-issuer":"letsencrypt","kubernetes.io/ingress.class":"nginx"}'
+
   - it: Should contain `--enable-batch-scheduler` arg if `controller.batchScheduler.enable` is `true`
     set:
       controller:
@@ -246,7 +277,7 @@ tests:
       - contains:
           path: spec.template.spec.containers[?(@.name=="spark-operator-controller")].args
           content: --leader-election-lock-namespace=spark-operator
-  
+
   - it: Should disable leader election if `controller.leaderElection.enable` is set to `false`
     set:
       controller:
diff --git a/internal/controller/sparkapplication/controller_test.go b/internal/controller/sparkapplication/controller_test.go
index 1aabedb..483f6bf 100644
--- a/internal/controller/sparkapplication/controller_test.go
+++ b/internal/controller/sparkapplication/controller_test.go
@@ -25,6 +25,7 @@ import (
 	. "github.com/onsi/gomega"
 
 	corev1 "k8s.io/api/core/v1"
+	networkingv1 "k8s.io/api/networking/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
@@ -38,7 +39,7 @@ import (
 )
 
 var _ = Describe("SparkApplication Controller", func() {
-	Context("When reconciling a new SparkApplication", func() {
+	Context("When reconciling a submitted SparkApplication", func() {
 		ctx := context.Background()
 		appName := "test"
 		appNamespace := "default"
@@ -46,21 +47,33 @@ var _ = Describe("SparkApplication Controller", func() {
 			Name:      appName,
 			Namespace: appNamespace,
 		}
+		appConfig := &v1beta2.SparkApplication{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      appName,
+				Namespace: appNamespace,
+			},
+			Spec: v1beta2.SparkApplicationSpec{
+				MainApplicationFile: util.StringPtr("local:///dummy.jar"),
+			},
+		}
+		ingressKey := types.NamespacedName{
+			Name:      util.GetDefaultUIIngressName(appConfig),
+			Namespace: appNamespace,
+		}
 
 		BeforeEach(func() {
 			By("Creating a test SparkApplication")
-			app := &v1beta2.SparkApplication{}
+			app := appConfig.DeepCopy()
 			if err := k8sClient.Get(ctx, key, app); err != nil && errors.IsNotFound(err) {
-				app = &v1beta2.SparkApplication{
-					ObjectMeta: metav1.ObjectMeta{
-						Name:      appName,
-						Namespace: appNamespace,
-					},
-				}
+				util.IngressCapabilities = util.Capabilities{"networking.k8s.io/v1": true}
 				v1beta2.SetSparkApplicationDefaults(app)
 				Expect(k8sClient.Create(ctx, app)).To(Succeed())
 
-				app.Status.AppState.State = v1beta2.ApplicationStateCompleted
+				driverPod := createDriverPod(appName, appNamespace)
+				Expect(k8sClient.Create(ctx, driverPod)).To(Succeed())
+
+				app.Status.DriverInfo.PodName = driverPod.Name
+				app.Status.AppState.State = v1beta2.ApplicationStateSubmitted
 				Expect(k8sClient.Status().Update(ctx, app)).To(Succeed())
 			}
 		})
@@ -68,9 +81,190 @@ var _ = Describe("SparkApplication Controller", func() {
 		AfterEach(func() {
 			app := &v1beta2.SparkApplication{}
 			Expect(k8sClient.Get(ctx, key, app)).To(Succeed())
+			By("Deleting the created test SparkApplication")
+			Expect(k8sClient.Delete(ctx, app)).To(Succeed())
+
+			ingress := &networkingv1.Ingress{}
+			Expect(k8sClient.Get(ctx, ingressKey, ingress)).To(Succeed())
+			By("Deleting the created test ingress")
+			Expect(k8sClient.Delete(ctx, ingress)).To(Succeed())
+
+			driverPod := &corev1.Pod{}
+			By("Deleting the driver pod")
+			Expect(k8sClient.Get(ctx, getDriverNamespacedName(appName, appNamespace), driverPod)).To(Succeed())
+			Expect(k8sClient.Delete(ctx, driverPod)).To(Succeed())
+		})
+
+		It("Should create an ingress for the Spark UI with no TLS or annotations", func() {
+			By("Reconciling the new test SparkApplication")
+			reconciler := sparkapplication.NewReconciler(
+				nil,
+				k8sClient.Scheme(),
+				k8sClient,
+				record.NewFakeRecorder(3),
+				nil,
+				&sparkapplication.SparkSubmitter{},
+				sparkapplication.Options{EnableUIService: true, IngressURLFormat: "{{$appName}}.spark.test.com", Namespaces: []string{appNamespace}},
+			)
+			_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: key})
+			Expect(err).NotTo(HaveOccurred())
 
+			ingress := &networkingv1.Ingress{}
+			Expect(k8sClient.Get(ctx, ingressKey, ingress)).To(Succeed())
+			Expect(ingress.Spec.TLS).To(HaveLen(0))
+			Expect(ingress.ObjectMeta.Annotations).To(HaveLen(0))
+		})
+
+		It("Should create an ingress for the Spark UI with default TLS and annotations", func() {
+			By("Reconciling the new test SparkApplication")
+			defaultIngressTLS := []networkingv1.IngressTLS{
+				{
+					Hosts:      []string{"*.test.com", "*.test2.com"},
+					SecretName: "example-tls-secret",
+				},
+				{
+					Hosts:      []string{"another-test.com"},
+					SecretName: "test-tls-secret",
+				},
+			}
+			defaultIngressAnnotations := map[string]string{
+				"cert-manager.io/cluster-issuer": "letsencrypt",
+				"kubernetes.io/ingress.class":    "nginx",
+			}
+			reconciler := sparkapplication.NewReconciler(
+				nil,
+				k8sClient.Scheme(),
+				k8sClient,
+				record.NewFakeRecorder(3),
+				nil,
+				&sparkapplication.SparkSubmitter{},
+				sparkapplication.Options{EnableUIService: true, IngressURLFormat: "{{$appName}}.spark.test.com", IngressTLS: defaultIngressTLS, IngressAnnotations: defaultIngressAnnotations, Namespaces: []string{appNamespace}},
+			)
+			_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: key})
+			Expect(err).NotTo(HaveOccurred())
+
+			ingress := &networkingv1.Ingress{}
+			Expect(k8sClient.Get(ctx, ingressKey, ingress)).To(Succeed())
+			Expect(ingress.Spec.TLS).To(Equal(defaultIngressTLS))
+			Expect(ingress.ObjectMeta.Annotations).To(Equal(defaultIngressAnnotations))
+		})
+	})
+
+	Context("When reconciling a submitted SparkApplication with spark UI ingress TLS and annotations", func() {
+		ctx := context.Background()
+		appName := "test"
+		appNamespace := "default"
+		key := types.NamespacedName{
+			Name:      appName,
+			Namespace: appNamespace,
+		}
+		ingressTLS := []networkingv1.IngressTLS{
+			{
+				Hosts:      []string{"*.test.com"},
+				SecretName: "test-tls-secret",
+			},
+		}
+		ingressAnnotations := map[string]string{"cert-manager.io/cluster-issuer": "letsencrypt"}
+		appConfig := &v1beta2.SparkApplication{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      appName,
+				Namespace: appNamespace,
+			},
+			Spec: v1beta2.SparkApplicationSpec{
+				MainApplicationFile: util.StringPtr("local:///dummy.jar"),
+				SparkUIOptions: &v1beta2.SparkUIConfiguration{
+					IngressTLS:         ingressTLS,
+					IngressAnnotations: ingressAnnotations,
+				},
+			},
+		}
+		ingressKey := types.NamespacedName{
+			Name:      util.GetDefaultUIIngressName(appConfig),
+			Namespace: appNamespace,
+		}
+
+		BeforeEach(func() {
+			By("Creating a test SparkApplication")
+			app := appConfig.DeepCopy()
+			if err := k8sClient.Get(ctx, key, app); err != nil && errors.IsNotFound(err) {
+				util.IngressCapabilities = util.Capabilities{"networking.k8s.io/v1": true}
+
+				v1beta2.SetSparkApplicationDefaults(app)
+				Expect(k8sClient.Create(ctx, app)).To(Succeed())
+
+				driverPod := createDriverPod(appName, appNamespace)
+				Expect(k8sClient.Create(ctx, driverPod)).To(Succeed())
+
+				app.Status.DriverInfo.PodName = driverPod.Name
+				app.Status.AppState.State = v1beta2.ApplicationStateSubmitted
+				Expect(k8sClient.Status().Update(ctx, app)).To(Succeed())
+			}
+		})
+
+		AfterEach(func() {
+			app := &v1beta2.SparkApplication{}
+			Expect(k8sClient.Get(ctx, key, app)).To(Succeed())
 			By("Deleting the created test SparkApplication")
 			Expect(k8sClient.Delete(ctx, app)).To(Succeed())
+
+			ingress := &networkingv1.Ingress{}
+			Expect(k8sClient.Get(ctx, ingressKey, ingress)).To(Succeed())
+			By("Deleting the created test ingress")
+			Expect(k8sClient.Delete(ctx, ingress)).To(Succeed())
+
+			driverPod := &corev1.Pod{}
+			By("Deleting the driver pod")
+			Expect(k8sClient.Get(ctx, getDriverNamespacedName(appName, appNamespace), driverPod)).To(Succeed())
+			Expect(k8sClient.Delete(ctx, driverPod)).To(Succeed())
+		})
+
+		It("Should create an ingress for the Spark UI with TLS and annotations", func() {
+			By("Reconciling the new test SparkApplication")
+			reconciler := sparkapplication.NewReconciler(
+				nil,
+				k8sClient.Scheme(),
+				k8sClient,
+				record.NewFakeRecorder(3),
+				nil,
+				&sparkapplication.SparkSubmitter{},
+				sparkapplication.Options{EnableUIService: true, IngressURLFormat: "{{$appName}}.spark.test.com", Namespaces: []string{appNamespace}},
+			)
+			_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: key})
+			Expect(err).NotTo(HaveOccurred())
+
+			ingress := &networkingv1.Ingress{}
+			Expect(k8sClient.Get(ctx, ingressKey, ingress)).To(Succeed())
+			Expect(ingress.Spec.TLS).To(Equal(ingressTLS))
+			Expect(ingress.ObjectMeta.Annotations).To(Equal(ingressAnnotations))
+		})
+
+		It("Should create an ingress for the Spark UI with TLS and annotations overriding the defaults", func() {
+			By("Reconciling the new test SparkApplication")
+			defaultIngressTLS := []networkingv1.IngressTLS{
+				{
+					Hosts:      []string{"*.default.com"},
+					SecretName: "default-tls-secret",
+				},
+			}
+			defaultIngressAnnotations := map[string]string{
+				"default-annotation-key": "default-annotation-value",
+			}
+			reconciler := sparkapplication.NewReconciler(
+				nil,
+				k8sClient.Scheme(),
+				k8sClient,
+				record.NewFakeRecorder(3),
+				nil,
+				&sparkapplication.SparkSubmitter{},
+				sparkapplication.Options{EnableUIService: true, IngressURLFormat: "{{$appName}}.spark.test.com", IngressTLS: defaultIngressTLS, IngressAnnotations: defaultIngressAnnotations, Namespaces: []string{appNamespace}},
+			)
+			_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: key})
+			Expect(err).NotTo(HaveOccurred())
+
+			ingress := &networkingv1.Ingress{}
+			Expect(k8sClient.Get(ctx, ingressKey, ingress)).To(Succeed())
+			Expect(ingress.Spec.TLS).To(Equal(ingressTLS))
+			Expect(ingress.ObjectMeta.Annotations).To(Equal(ingressAnnotations))
 		})
 	})