Skip to content

added cleanup k8s service *-ui-svc (#2984)#2985

Open
Arnaud-Nauwynck wants to merge 1 commit into
kubeflow:masterfrom
Arnaud-Nauwynck:master
Open

added cleanup k8s service *-ui-svc (#2984)#2985
Arnaud-Nauwynck wants to merge 1 commit into
kubeflow:masterfrom
Arnaud-Nauwynck:master

Conversation

@Arnaud-Nauwynck

Copy link
Copy Markdown

Purpose of this PR

This PR is for cleaning up the thousands of "*-ui-svc" kubernetes zombi services that still exist after a SparkApplication is COMPLETED or FAILED

Proposed changes:

  • added cleanup for k8s resources

Change Category

  • Bugfix (non-breaking change which fixes an issue)
  • Feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that could affect existing functionality)
  • Documentation update

Rationale

Having clusters in PROD with thousands of SparkApplications launched every day produces blocking ERRORs "argument list too long".
It forced my company to implement strange workarounds: "automatic sparkApp remover", and split into multiple clusters and multiple namespaces ...
A better solution is highly recommended

Checklist

  • I have conducted a self-review of my own code.
  • I have updated documentation accordingly.
  • I have added tests that prove my changes are effective or that my feature works.
  • Existing unit tests pass locally with my changes.

Additional Notes

Code was "pair-progamming" with Claude Code.

Copilot AI review requested due to automatic review settings June 11, 2026 15:19
@google-oss-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign andreyvelich for approval. For more information see the Kubernetes Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR improves SparkApplication termination cleanup to prevent orphaned UI/driver-ingress Services (and their Kubernetes-injected env vars) from lingering, while also preserving operator-required labels when users provide additional labels.

Changes:

  • Merge user-provided driver-ingress Service labels into operator labels instead of replacing them.
  • On termination, delete Spark UI Service/Ingress and driver-ingress Services to avoid resource/env-var leakage (#2984).
  • Add controller tests asserting Services are deleted for Completed/Failed applications.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
internal/controller/sparkapplication/driveringress.go Merge user labels into existing Service labels to preserve operator labels.
internal/controller/sparkapplication/controller.go Add cleanup + validation for UI/ingress + driver-ingress Services and fallback UI Service deletion name.
internal/controller/sparkapplication/controller_test.go Add tests ensuring UI + driver-ingress Services are removed on terminal states.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +361 to +365
// Merge any user-provided labels on top of the operator labels rather than replacing
// them, so that the SparkApplication name label that owner-reference GC, selectors and
// cleanup rely on is always preserved.
for key, value := range serviceLabels {
service.Labels[key] = value
Comment on lines +1234 to +1241
if err := r.client.DeleteAllOf(
ctx,
&corev1.Service{},
client.InNamespace(app.Namespace),
client.MatchingLabels(map[string]string{common.LabelSparkAppName: app.Name}),
client.GracePeriodSeconds(0),
); err != nil && !errors.IsNotFound(err) {
return err

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SparkAppName are generated. Little/No chance to conflict with other services on delete

Comment on lines +1321 to +1332
// Validate whether the driver-ingress services have been deleted. Their names are not
// recorded in the status, so they are matched by the SparkApplication name label.
services := &corev1.ServiceList{}
if err := r.client.List(ctx, services,
client.InNamespace(app.Namespace),
client.MatchingLabels(map[string]string{common.LabelSparkAppName: app.Name}),
); err != nil {
return false
}
if len(services.Items) > 0 {
return false
}
Comment on lines +1203 to 1207
// Fall back to the deterministic default name so the service is not orphaned if the
// status update that recorded WebUIServiceName was lost (e.g. failed right after submission).
if svcName == "" {
return nil
svcName = util.GetDefaultUIServiceName(app)
}
Comment on lines +1465 to +1467
Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: uiServiceKey.Name, Namespace: appNamespace},
}))).To(Succeed())
@Arnaud-Nauwynck

Arnaud-Nauwynck commented Jun 11, 2026

Copy link
Copy Markdown
Author

I did use Claude with the following prompt:

 analyze the creation and deletion of kubernetes "service" objects from this kubernetes operator for Crd of "SparkApplication".   In particular, see issue https://github.com/kubeflow/spark-operator/issues/2984,  and suggest how to implement the code that cleanup correctly resources when the sparkApplication is COMPLETED or FAILED

Then interactively answered serveral times "yes" for security action executions,
Then answered interactively to a multiple choices

add a failing test first then implement A B C

I am NOT a "go" developper, and I do not now enough on the internals of this project "spark-operator"
I wanted to share my findings and the result code provided by Claude, as a patch or a PR.
(I can also provide as comment the full Claude thinking conversation if you whish. It was obtained in ~6mn, costing 3.18 USD, or feel free to redo yourself)

@Arnaud-Nauwynck

Copy link
Copy Markdown
Author

Here is the full conversation thinking result text from Claude Code

╭─── Claude Code v2.1.169 ─────────────────────────────────────────────────────────────────────────────────────────────╮
│ │ Tips for getting started │
│ Welcome back Arnaud! │ Run /init to create a CLAUDE.md file with instructions for Cla… │
│ │ ─────────────────────────────────────────────────────────────── │
│ ▐▛███▜▌ │ What's new │
│ ▝▜█████▛▘ │ Fixed Fable 5 model names with a [1m] suffix not being norma… │
│ ▘▘ ▝▝ │ Fixed a spurious "sandbox dependencies missing" startup warnin… │
│ Opus 4.8 with xhigh effort · Claude Pro · arnaud.nauwynck@gmail.com's Organization │ /release-notes for more │
│ C:\arn\devPerso\spark-operator │ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Fable 5 is now available with the latest version of Claude Code! Run "claude update" to update to v2.1.170+

▎ Opus 4.8 is here! Now defaults to high effort · /effort xhigh for your hardest tasks

❯ analyze the creation and deletion of kubernetes "service" objects from this kubernetes operator for Crd of "SparkApplication". In particular, see issue #2984, and suggest how to implement the code that cleanup correctly resources when the sparkApplication is COMPLETED or FAILED

I'll analyze the Service object lifecycle in this Spark operator. Let me start by examining the issue and the relevant code.

Searched for 1 pattern
Let me fetch the issue and examine the key f
Fetch(https://github.com/kubeflow/spark-oper
Received 247.5KB (200 OK)
● Read(C:\arn\devPerso\spark-operator\internal\controller\sparkapplication\web_ui.go) Read 128 lines
Read(C:\arn\devPerso\spark-operator\internaldriveringress.go)
Read 482 lines
Now I understand the resource creation side.h OwnerReferences pointing to theSparkApplication. Let me examine how the controller handles state transitions and resource cleanup. Let me look at the main controller and how it deletes resources

Searched for 2 patterns

Let me read the key functions: deleteSparkReand theGetDefaultUIServiceName/GetOwnerReference util functions.

Read 2 files

Now I have the core picture. The key issue: deleteWebUIService relies on app.Status.DriverInfo.WebUIServiceName being
populated. Let me find where WebUIServiceNamrace when deleteSparkResources is calledrelative to COMPLETED/FAILED states.

Searched for 1 pattern, read 1 file

Now let me look at the critical section — where the UI service is created (around line 340-375) and how the status is
persisted. This is the heart of the bug. LetD/FAILED handling around lines 540-640 and760.

Read 1 file

I found a key part of the bug. Notice at line 377-394: driver ingress services created from DriverIngressConfiguration
are NOT recorded in the status (unlike the wIServiceName). Now let me look at theCOMPLETED/FAILED handling and the deleteDriverIngressResources (line ~1273).

Read 1 file

This confirms the core bug. Look at lines 569-605 (reconcileSucceedingSparkApplication) and 607-649
(reconcileFailingSparkApplication):

  • When the app completes (util.ShouldRetry fateCompleted), deleteSparkResources is NOTcalled — services are left in place.
  • Same for failed (line 642). Resources are ing to retry.

So on the terminal COMPLETED/FAILED states, y happens later — let me confirm by checkingthe COMPLETED/FAILED reconcile handlers (line ~760) and the dispatcher. Let me also look at
deleteDriverIngressResources around 1273.

Read 1 file

Now the key function: cleanUpOnTermination (ead it and deleteDriverIngressResources (line ~1273-1310).

Read 1 file

Now let me find cleanUpOnTermination:

Searched for 1 pattern

This is the smoking gun. cleanUpOnTermination only does batch-scheduler cleanup — it does NOT delete the web UI
service or ingress. So when a SparkApplicatiETED/FAILED state, the UI service and ingress are never explicitly deleted by the operator.

The only mechanism keeping them from leaking is the Kubernetes OwnerReference garbage collection (set in
createDriverIngressService, GetOwnerReferencSparkApplication CR itself is deleted (viaTTL or manually). If users keep completed SparkApplications around (the common case — for history/inspection), the UI
services persist indefinitely → the zombie s

Let me verify there's no MatchingLabels-basehe driver pod IS being deleted on termination (to understand why the -svc gets cleaned but -ui-svc does not, per the reporter). Let me look at what deletes the
main driver service and check the reconcile

Searched for 1 pattern, read 1 file

My analysis is confirmed. deleteSparkResourcrvice + deleteWebUIIngress) is invoked atlines 296 (CR deletion), 437 (failed-submission retry), 550 (invalidating), 585 (succeeding-with-retry), 631
(failing-with-retry), 765 (suspending). It il COMPLETED / FAILED path (lines 657-715),which only calls cleanUpOnTermination (batch scheduler only).

I now have a complete and precise understanding. Let me give the analysis and the concrete implementation
recommendation.

Analysis: Service lifecycle in the SparkAppl

How Services are created

When a SparkApplication transitions to SUBMIApplication (controller.go:332) creates up to three kinds of Service objects:

┌───────────────────────────┬─────────────────────────────────────────────┬────────────────────────────────────────┐
│ Service │ │ Status field that records it │
├───────────────────────────┼─────────────────────────────────────────────┼────────────────────────────────────────┤
│ │ createWebUISer │ app.Status.DriverInfo.WebUIServiceName │
│ *-ui-svc (Spark Web UI) │ createDriverIngressService (web_ui.go:53, │ (controller.go:357) │
│ │ driveringress. │ │
├───────────────────────────┼─────────────────────────────────────────────┼────────────────────────────────────────┤
│ *-driver- │ createDriverIn │ │
│ (per-DriverIngressOptions │ (controller.go:378) │ none — not recorded anywhere │
│ entry) │ │ │
└───────────────────────────┴─────────────────────────────────────────────┴────────────────────────────────────────┘

Every Service is created with an OwnerReference to the SparkApplication (GetOwnerReference, sparkapplication.go:279,
with Controller: true, BlockOwnerDeletion: t

How Services are supposed to be deleted

deleteSparkResources (controller.go:1159) de UI service (deleteWebUIService:1200), andthe web UI ingress (deleteWebUIIngress:1224). Crucially, deleteWebUIService deletes by name from status:

svcName := app.Status.DriverInfo.WebUIServiceName
if svcName == "" { return nil }

The bug (issue #2984)

  1. deleteSparkResources is never called on tD path. It is only invoked on CR deletion,invalidating, suspending, and the retry branches of succeeding/failing/failed-submission. The terminal handler
    reconcileTerminatedSparkApplication (controlpOnTermination (:1483), which doesbatch-scheduler cleanup only — no service/ingress deletion:

func (r *Reconciler) cleanUpOnTermination(ctx context.Context, _, newApp *v1beta2.SparkApplication) error {
if needScheduling, scheduler, _ := r.sewApp); needScheduling {
if err := scheduler.Cleanup(newApp); err != nil { return err }
}
return nil
}

So for a completed/failed app that is kept afires), the only thing that ever removes the-ui-svc is Kubernetes owner-reference garbage collection — which fires only when the SparkApplication CR itself is
deleted. Users who retain completed apps fori-svc services → the env-var explosion →"argument list too long".

  1. The *-driver- services from DriverIngressOptions are never recorded in status and never explicitly deleted at
    all (no deleteDriverIngressResources exists) except CR garbage collection.

▎ Note on the reporter's observation that -snot: the driver headless service *-svc iscreated by the Spark driver itself (spark-submit / Kubernetes scheduler backend) and is owned by the driver pod, so
▎ it dies when the driver pod is deleted. ThrkApplication CR, so it survives until the CR is deleted — exactly the leak.

Recommended implementation

The cleanest fix is to make terminal cleanup actually delete the network resources, and make deletion robust to a
missing/empty status (label-based, not name-ed changes:

A. Delete UI service + ingress + driver-ingrExtend cleanUpOnTermination (the only placethe terminal path cleans up):

func (r *Reconciler) cleanUpOnTermination(ctx context.Context, _, newApp *v1beta2.SparkApplication) error {
if needScheduling, scheduler, _ := r.sewApp); needScheduling {
if err := scheduler.Cleanup(newApp); err != nil {
return err
}
}
// Delete network resources (UI service/ingress, driver-ingress services) that
// would otherwise persist for the lifApplication CR
// and leak per-service environment variables into every new pod (issue #2984).
if err := r.deleteWebUIService(ctx, ne
return err
}
if err := r.deleteWebUIIngress(ctx, newApp); err != nil {
return err
}
if err := r.deleteDriverIngressService
return err
}
return nil
}

▎ I deliberately do not add the driver-pod dmpleted/failed driver pod is intentional(logs/inspection, and it's governed separately). Only the network objects that cause the leak are removed.

B. Make deletion label-based so it works even when status is empty. The current name-from-status approach silently
no-ops if WebUIServiceName == "" (which happr submission was lost). Add a robust bulkdelete in deleteWebUIService that also catches orphans. All these services already carry GetResourceLabels(app) (which
includes common.LabelSparkAppName: app.Name)abel selector, restricted to the driver-role services:

func (r *Reconciler) deleteWebUIService(ctx context.Context, app *v1beta2.SparkApplication) error {
logger := log.FromContext(ctx)
// Prefer name from status; fall back to deterministic default name so a lost
// status update does not orphan the s
svcName := app.Status.DriverInfo.WebUIServiceName
if svcName == "" {
svcName = util.GetDefaultUIServiceName(app)
}
logger.Info("Deleting Spark web UI service", "service", svcName)
if err := r.client.Delete(ctx, &corev1
ObjectMeta: metav1.ObjectMeta{Name: svcName, Namespace: app.Namespace},
}, &client.DeleteOptions{GracePeriodSer != nil && !errors.IsNotFound(err) {
return err
}
return nil
}

And add the missing cleanup for DriverIngres no recorded name, so they must be deleted by label):

func (r *Reconciler) deleteDriverIngressServices(ctx context.Context, app *v1beta2.SparkApplication) error {
logger := log.FromContext(ctx)
logger.Info("Deleting driver ingress services", "app", app.Name)
return r.client.DeleteAllOf(ctx, &core
client.InNamespace(app.Namespace),
client.MatchingLabels(map[stripName: app.Name}),
client.GracePeriodSeconds(0),
)
}

(Driver-ingress services are created with GetResourceLabels → LabelSparkAppName, so this matches them; the -ui-svc
also matches, making the bulk delete a belt-ans. If you want to keep the UI servicehandled only by deleteWebUIService, scope deleteDriverIngressServices further by the LabelSubmissionID label.)

C. Record the driver-ingress service names in status (optional but recommended) so validateSparkResourceDeletion
(controller.go:1271) can verify them too — ocheck is blind to them.

Things to verify / decide

  • Idempotency / requeue: cleanUpOnTerminatioueue the terminal reconcile — fine, deletesare idempotent (IsNotFound ignored).
  • Scope of DeleteAllOf: confirm no non-sparkrry LabelSparkAppName for that app name. They shouldn't, but scoping by LabelSubmissionID (set in GetResourceLabels when SubmissionID != "") is safer if you want a
    tighter selector.

Would you like me to:

  1. Implement these changes (A + B + C) with controller_test.go ginkgo suite), or
  2. First add a focused failing test that reproduces the leak (UI service still present after COMPLETED) and then make
    it pass?

I'd recommend option 2 — it pins the regressceed.

✻ Brewed for 1m 55s

※ recap: Goal: fix issue #2984, where Spark UIpplication completes or fails. I've finishedanalyzing the code and identified the cause. Next action: implement the cleanup fix, ideally starting with a failing
test that reproduces the leak. (disable reca

❯ add a failing test first then implement A B

Let me examine the existing test suite to ma failing test and implement A/B/C.

Read 1 file

Let me look at the test that handles the COMhe helpers (createDriverPod, etc.), plus anexisting test that reconciles a completed app, to match the pattern exactly.

Read 1 file

Let me see the helper functions (createDriverPod, getDriverNamespacedName, createExecutorPod) at the end of the test
file, and check how the WebUIServiceName staTED state. Let me also look for any existingtest of the terminated/completed path.

Read 1 file

Now let me check how the reconcile dispatches by state — to confirm a COMPLETED app routes to
reconcileTerminatedSparkApplication. Let me h.

Searched for 1 pattern

Confirmed: COMPLETED → reconcileCompletedSpainatedSparkApplication → cleanUpOnTermination (no service deletion).

Now let me write the failing test. I'll add a new Context for reconciling a Completed SparkApplication that has a UI
service. The test reconciles and asserts thee check IsExpired/IsTerminated to make surethe app stays (not deleted via TTL) so we test the cleanup path, not CR-deletion GC.

Searched for 1 pattern

Good. With no TimeToLiveSeconds, the completed app won't be deleted/expired, so we exercise exactly the cleanup path.
Now I'll add the failing test. Let me insertfore the closing of the top-level Describe(before line 1377 })).

Read 1 file

Now I'll add the failing test. I'll insert a new Context block before the final }) of the top-level Describe (line
1377). The test creates a completed SparkAppnd a driver-ingress service) and a UIingress, all owned by the app, then reconciles and asserts they are deleted.

Read 1 file

Now let me look at the common label constants to construct the services with proper labels, and confirm the UI service
name helper.

Searched for 1 pattern

Now I'll write the failing test. I'll add a and a driver-ingress service with ownerreference, then a Context that reconciles a completed app and expects both services + ingress to be gone.

● Update(internal\controller\sparkapplication\controller_test.go)
Added 137 lines
})
})
})

Context("When reconciling a terminated SparkApplication with a Spark UI service", func() {
ctx := context.Background()
appName := "test"
appNamespace := "default"
key := types.NamespacedName{
Name: appName,
Namespace: appNamespace,
}

uiServiceKey := types.Namespac
  Namespace: appNamespace,
}
driverIngressServiceName := fmt.Sprintf("%s-driver-8080", appName)
driverIngressServiceKey := typ
  Name:      driverIngressServiceName,
  Namespace: appNamespace,
}

// reconcileTerminatedState creates a Completed/Failed SparkApplication along with
// the Spark UI service, a dripark UI ingress that the
// operator would have created while the application was running, then returns the
// key of the created UI servi
reconcileTerminatedState := func(state v1beta2.ApplicationState) {
  app := &v1beta2.SparkApplica
    ObjectMeta: metav1.ObjectMeta{
      Name:      appName,
      Namespace: appNamespace,
    },
    Spec: v1beta2.SparkApplicationSpec{
      MainApplicationFile: ptr
    },
  }
  v1beta2.SetSparkApplicationDefaults(app)
  Expect(k8sClient.Create(ctx,

  // Re-read to obtain the UID
  Expect(k8sClient.Get(ctx, key, app)).To(Succeed())
  ownerRef := util.GetOwnerRef

  uiServiceName := util.GetDef
  uiServiceKey.Name = uiServiceName
  uiService := &corev1.Service
    ObjectMeta: metav1.ObjectMeta{
      Name:            uiServi
      Namespace:       appNamespace,
      Labels:          util.Ge
      OwnerReferences: []metav1.OwnerReference{ownerRef},
    },
    Spec: corev1.ServiceSpec{
      Ports: []corev1.ServicePort", Port: 4040}},
      Selector: map[string]string{
        common.LabelSparkAppNa
        common.LabelSparkRole:    common.SparkRoleDriver,
      },
    },
  }
  Expect(k8sClient.Create(ctx, uiService)).To(Succeed())

  driverIngressService := &corev1.Service{
    ObjectMeta: metav1.ObjectM
      Name:            driverIngressServiceName,
      Namespace:       appName
      Labels:          util.GetResourceLabels(app),
      OwnerReferences: []metav
    },
    Spec: corev1.ServiceSpec{
      Ports: []corev1.ServicePort{{Name: "driver-ing-8080", Port: 8080}},
      Selector: map[string]str
        common.LabelSparkAppName: appName,
        common.LabelSparkRole:
      },
    },
  }
  Expect(k8sClient.Create(ctx,cceed())

  By("Updating the SparkApplicith the UI service recorded")
  app.Status.AppState.State = state.State
  app.Status.TerminationTime =
  app.Status.DriverInfo.WebUIServiceName = uiServiceName
  Expect(k8sClient.Status().Up
}

AfterEach(func() {
  app := &v1beta2.SparkApplica
  if err := k8sClient.Get(ctx, key, app); err == nil {
    By("Deleting the test Spar
    Expect(k8sClient.Delete(ctx, app)).To(Succeed())
  }
  Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, &corev1.Service{
    ObjectMeta: metav1.ObjectMNamespace: appNamespace},
  }))).To(Succeed())
  Expect(client.IgnoreNotFound1.Service{
    ObjectMeta: metav1.ObjectMeta{Name: driverIngressServiceName, Namespace: appNamespace},
  }))).To(Succeed())
})

newReconciler := func() *sparkapplication.Reconciler {
  return sparkapplication.NewR
    nil,
    k8sClient.Scheme(),
    k8sClient,
    record.NewFakeRecorder(3),
    nil,
    &sparkapplication.SparkSub
    sparkapplication.Options{EnableUIService: true, Namespaces: []string{appNamespace}},
  )
}

It("Should delete the Spark UI service when the application is Completed", func() {
  reconcileTerminatedState(v1bv1beta2.ApplicationStateCompleted})

  By("Reconciling the Complete
  _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: key})
  Expect(err).NotTo(HaveOccurr

  By("Checking that the Spark
  Expect(k8sClient.Get(ctx, uiServiceKey, &corev1.Service{})).To(Satisfy(errors.IsNotFound))

  By("Checking that the driver-ingress service has been deleted")
  Expect(k8sClient.Get(ctx, dr1.Service{})).To(Satisfy(errors.IsNotF

ound))
})

It("Should delete the Spark UI is Failed", func() {
  reconcileTerminatedState(v1beta2.ApplicationState{State: v1beta2.ApplicationStateFailed})

  By("Reconciling the Failed SparkApplication")
  _, err := newReconciler().Ret{NamespacedName: key})
  Expect(err).NotTo(HaveOccurred())

  By("Checking that the Spark UI service has been deleted")
  Expect(k8sClient.Get(ctx, ui)).To(Satisfy(errors.IsNotFound))

  By("Checking that the drivereted")
  Expect(k8sClient.Get(ctx, driverIngressServiceKey, &corev1.Service{})).To(Satisfy(errors.IsNotF

ound))
})
})
})

func getDriverNamespacedName(appName string, appNamespace string) types.NamespacedName {

Now let me run the new test to confirm it fails (the bug exists). Let me check how tests are run in this repo first.

Searched for 1 pattern

These ginkgo controller tests require an envtest binary (KUBEBUILDER_ASSETS). Let me check whether envtest is already
set up locally on Windows so I can run just

Ran 1 shell command

No envtest binaries and no go on PATH in this installed somewhere on this Windowsmachine.

Ran 1 shell command

Go isn't installed (or not in a standard location) on this machine, and envtest binaries aren't present — so I can't
execute the ginkgo suite here. The test is wsuite's patterns and will fail against thecurrent code because cleanUpOnTermination doesn't delete services. Let me verify the test is at least syntactically
valid using JavaLens-style checks... that's ully re-read my test for correctness, thenimplement A/B/C so the test passes.

Let me confirm NewReconciler returns *Reconciler (exported) so my newReconciler helper compiles.

Searched for 1 pattern, read 1 file

NewReconciler returns *Reconciler — my helper is correct. The test is sound.

Now let me implement A/B/C. First, A + B: modify cleanUpOnTermination and deleteWebUIService, and add
deleteDriverIngressServices. Let me re-read Service and cleanUpOnTermination to editprecisely.

Now implementing B — make deleteWebUIService fall back to the deterministic name, and add deleteDriverIngressServices:

● Update(internal\controller\sparkapplication\controller.go)
Added 23 lines, removed 1 line
func (r *Reconciler) deleteWebUIService(ctx context.Context, app *v1beta2.SparkApplication) error {
logger := log.FromContext(ctx)
svcName := app.Status.DriverInfo.WebUIServiceName
// Fall back to the deterministi is not orphaned if the
// status update that recorded WebUIServiceName was lost (e.g. failed right after submission).
if svcName == "" {
return nil
svcName = util.GetDefaultUISer
}
logger.Info("Deleting Spark web me)
if err := r.client.Delete(

return nil
}

// deleteDriverIngressServices delions services created for the
// application. Their names are not recorded in the status, so they are matched by the
// SparkApplication name label thay resource it creates.
// Leaving them behind causes Kubernetes to inject a pair of environment variables per service
// into every newly created pod, w process argument list (#2984).
func (r *Reconciler) deleteDriverIngressServices(ctx context.Context, app *v1beta2.SparkApplication)
error {
logger := log.FromContext(ctx)
logger.Info("Deleting driver ingame)
if err := r.client.DeleteAllOf(
ctx,
&corev1.Service{},
client.InNamespace(app.Namespa
client.MatchingLabels(map[string]string{common.LabelSparkAppName: app.Name}),
client.GracePeriodSeconds(0),
); err != nil && !errors.IsNotFound(err) {
return err
}
return nil
}

func (r *Reconciler) deleteWebUIIngress(ctx context.Context, app *v1beta2.SparkApplication) error {
logger := log.FromContext(ctx)
ingressName := app.Status.DriverInfo.WebUIIngressName

Now A — wire the deletions into cleanUpOnTermination:

● Update(internal\controller\sparkapplication\controller.go)
Added 15 lines
return err
}
}

// Delete the network resources (Spark UI service/ingress and driver-ingress services) that
// are owned by the SparkApplicarsist for the entire lifetime of
// a retained Completed/Failed SparkApplication and leak per-service environment variables into
// every newly created pod (#298onally left in place so that its
// logs remain available for inspection.
if err := r.deleteWebUIService(c
return err
}
if err := r.deleteWebUIIngress(ctx, newApp); err != nil {
return err
}
if err := r.deleteDriverIngressSnil {
return err
}
return nil
}

Now C — record the driver-ingress service names in status so validateSparkResourceDeletion can verify them, and so
cleanup has a recorded handle. Let me check there's a suitable field, and how status isshaped.

Searched for 1 pattern

For C, adding a new status field would require an API change + make generate + CRD regeneration — heavier than needed,
and the label-based deleteDriverIngressServibustly without recording names. The morevaluable part of C is making validateSparkResourceDeletion aware of the driver-ingress services so the retry-readiness
check isn't blind to them. I'll do that withhange), which is consistent with the newlabel-based deletion.

Let me update validateSparkResourceDeletion to also verify that no driver-ingress services remain.

Read 1 file

● Update(internal\controller\sparkapplication\controller.go)
Added 13 lines
}
}

// Validate whether the driver-ieted. Their names are not
// recorded in the status, so they are matched by the SparkApplication name label.
services := &corev1.ServiceList{
if err := r.client.List(ctx, services,
client.InNamespace(app.Namespa
client.MatchingLabels(map[string]string{common.LabelSparkAppName: app.Name}),
); err != nil {
return false
}
if len(services.Items) > 0 {
return false
}

return true
}

Now let me verify common is already imported in controller.go (it's used extensively, e.g. common.LabelSparkAppName
already appears in the file at line 354). Lelient.GracePeriodSeconds andclient.MatchingLabels exist in the controller-runtime client package used here.

Searched for 1 pattern

common is imported, and client.InNamespace/MatchingLabels/GracePeriodSeconds are all already used (lines 1116, plnew code). Lines 1237-1239 are my deleteDriv326 are the validation List.
Now, one concern: the existing deleteWebUISeeteDriverIngressServices does a DeleteAllOfby LabelSparkAppName — which also matches the UI service (it carries the same label). That's fine and idempotent (double-delete of an already-deleted object ut let me make sure the driver pod doesn'tcarry that label and get caught by the Service DeleteAllOf — no, it's scoped to &corev1.Service{}, so only Services.
Good.

Let me also verify the driver-ingress servickAppName. They're created viacreateDriverIngressService with Labels: util.GetResourceLabels(app) (driveringress.go:339), and GetResourceLabels sets
common.LabelSparkAppName. ✓ But note line 36n-empty, it replaces service.Labelsentirely:

Read 1 file

This is an important edge case: if a user sets custom serviceLabels on a DriverIngressConfiguration (or
SparkUIOptions), line 362 overwrites the opeel entirely. Then my label-based DeleteAllOfwould not match that service → it would leak again.

This is a pre-existing latent issue (the label that owner-GC and selectors rely on gets clobbered), but it directly
undermines fix B. The robust fix is to merger labels rather than replace. Let me fixcreateDriverIngressService to always retain GetResourceLabels:

Read 1 file

● Update(internal\controller\sparkapplication\driveringress.go)
Added 5 lines, removed 2 lines
},
}

if len(serviceLabels) != 0 {
service.Labels = serviceLabels
// Merge any user-provided labelss rather than replacing
// them, so that the SparkApplication name label that owner-reference GC, selectors and
// cleanup rely on is always pres
for key, value := range serviceLabels {
service.Labels[key] = value
}

if len(serviceAnnotations) != 0 {

Now there's a test in driveringress_test.go that may assert the old replace-behavior. Let me check.

Searched for 1 pattern

Let me look at how this test verifies the actual created Service object's labels — specifically whether it asserts
exact equality on service.Labels (which woulhecks the SparkService struct's serviceLabels field.

Read 1 file

The test reads ingressConfig.serviceLabels — that's the SparkService.serviceLabels field, which is populated from the
serviceLabels argument (driveringress.go:411e.Labels. So my merge change does not breakthis assertion. The test checks each expected label is present (subset check, line 90-93), which still holds.

Now let me re-read my full set of changes to confirm correctness and that the new test will pass. Let me view the
final state of the three edited functions in

Read 1 file

All changes are in place. Now let me try to un. Let me check WSL/bash since theenvironment lists Bash as available.

Ran 1 shell command

Go is not available in this environment (neither PowerShell nor Bash), and there are no envtest binaries, so I cannot
compile or run the suite here. I've kept thewith existing patterns to avoid compileerrors. Let me do a final careful review of the test file's new block and the imports it relies on (all already
imported: corev1, metav1, ptr, client, error, ginkgo/gomega).

Let me verify there are no leftover/unused rriverIngressServiceKey is used in both Itblocks — yes; uiServiceKey populated in reconcileTerminatedState and used in assertions/AfterEach — yes).

One thing to double check: in AfterEach, I delete leftover services by name even on the happy path where they're
already gone — IgnoreNotFound handles that.

Let me also verify the test file compiles co1beta2.ApplicationState is a struct with aState field (used in reconcileTerminatedState(state v1beta2.ApplicationState) then state.State).

Searched for 1 pattern

ApplicationState{State: ...} is correct. Actually, my helper takes a whole ApplicationState just to use .State — let
me simplify it to take the ApplicationStateTr and avoids the awkwardv1beta2.ApplicationState{State: ...} wrapper at call sites.

● Update(internal\controller\sparkapplication\controller_test.go)
Added 1 line, removed 1 line
// the Spark UI service, a driver-ingress service and the Spark UI ingress that the
// operator would have creatednning, then returns the
// key of the created UI service.
reconcileTerminatedState := futate) {
reconcileTerminatedState := func(state v1beta2.ApplicationStateType) {
app := &v1beta2.SparkApplica
ObjectMeta: metav1.ObjectMeta{
Name: appName,

● Update(internal\controller\sparkapplication
Added 1 line, removed 1 line
Expect(k8sClient.Create(ctx,cceed())

  By("Updating the SparkApplicith the UI service recorded")
  app.Status.AppState.State = state.State
  app.Status.AppState.State =
  app.Status.TerminationTime = metav1.Now()
  app.Status.DriverInfo.WebUIS
  Expect(k8sClient.Status().Update(ctx, app)).To(Succeed())

● Update(internal\controller\sparkapplication\controller_test.go)
Added 1 line, removed 1 line
}

It("Should delete the Spark UI service when the application is Completed", func() {
  reconcileTerminatedState(v1bv1beta2.ApplicationStateCompleted})
  reconcileTerminatedState(v1beta2.ApplicationStateCompleted)

  By("Reconciling the Completed SparkApplication")
  _, err := newReconciler().Ret{NamespacedName: key})

● Update(internal\controller\sparkapplication
Added 1 line, removed 1 line
})

It("Should delete the Spark UI is Failed", func() {
  reconcileTerminatedState(v1beta2.ApplicationState{State: v1beta2.ApplicationStateFailed})
  reconcileTerminatedState(v1b

  By("Reconciling the Failed S
  _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: key})

Let me confirm ApplicationStateType is the right type name (used above) and that the helper function name doesn't
collide with anything.

Searched for 1 pattern

All correct. Let me do a final read of the ce sure it's coherent end-to-end.

Read 1 file

The test block is coherent. One small stale-nt says "then returns the key of the createdUI service" but it returns nothing. Let me correct it.

● Update(internal\controller\sparkapplication\controller_test.go)
Added 4 lines, removed 5 lines
Namespace: appNamespace,
}

// reconcileTerminatedState crkApplication along with
// the Spark UI service, a driver-ingress service and the Spark UI ingress that the
// operator would have creatednning, then returns the
// key of the created UI service.                                                                       reconcileTerminatedState := futateType) {
// createTerminatedAppWithServices creates a Completed/Failed SparkApplication along with               // the Spark UI service and a he operator would have created
// while the application was running.                                                                   createTerminatedAppWithServicecationStateType) {
  app := &v1beta2.SparkApplication{                                                                         ObjectMeta: metav1.ObjectM
      Name:      appName,                                                                           

● Update(internal\controller\sparkapplication\controller_test.go) Added 1 line, removed 1 line
}
It("Should delete the Spark UI service when the application is Completed", func() { reconcileTerminatedState(v1bd)
createTerminatedAppWithServices(v1beta2.ApplicationStateCompleted)
By("Reconciling the Completed SparkApplication") _, err := newReconciler().Ret{NamespacedName: key})
Update(internal\controller\sparkapplication
Added 1 line, removed 1 line })
It("Should delete the Spark UI is Failed", func() {
reconcileTerminatedState(v1beta2.ApplicationStateFailed) createTerminatedAppWithServiailed)
By("Reconciling the Failed S
_, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: key})
All changes are complete. Here's a summary of what I did.
What I found (the bug, issue #2984)
When a SparkApplication reaches the terminal COMPLETED / FAILED state, the reconcile path is:
reconcileCompletedSparkApplication/reconcileFailedSparkApplication → reconcileTerminatedSparkApplication → cleanUpOnTermination
…and cleanUpOnTermination only did batch-scheted the Spark UI service (-ui-svc), the UIingress, or the per-DriverIngressOptions services (-driver-). deleteSparkResources (the function that does delete those) is only called on CR deletion,d retry branches — never on the terminalhappy/fail path.
So for completed/failed apps that users keep around, those services survive until the CR itself is GC'd. Each lingering Service makes Kubernetes inject a RT env-var pair into every new pod →eventually "argument list too long".
Two secondary defects: the *-driver- services were never recorded in status or explicitly deleted at all, ancustom serviceLabels replaced the operator'saking any label-based cleanup/GC.
Changes
Test first (controller_test.go) — added a Cominated SparkApplication with a Spark UIservice" with two specs (Completed and Failed). Each seeds a terminated app plus an owner-referenced *-ui-svc and *-driver-8080 service, reconciles, and asserese fail against the old code (services wereleft behind).
A — cleanUpOnTermination now deletes the UI service, UI ingress, and driver-ingress services (driver pod intentionleft for log inspection).
B — robust deletion:

  • deleteWebUIService falls back to util.GetDefaultUIServiceName(app) when Status.DriverInfo.WebUIServiceName is em(lost status update no longer orphans the se
  • New deleteDriverIngressServices uses DeleteAllOf matching LabelSparkAppName to remove the unrecorded *-driver-<pservices.
  • createDriverIngressService now merges user serviceLabels over the operator labels instead of replacing them, so LabelSparkAppName always survives.

C — verification: validateSparkResourceDeletarkAppName and fails if any service remains,so the retry-readiness check covers driver-ingress services too. I deliberately avoided a new status field (would
force an API/CRD regen) since label-based cl

Not yet verified
I could not compile or run the suite — thereowerShell or Bash) and no envtest binaries in this environment. The code follows existing patterns and uses only already-imported symbols, but you'll want to run make unit-test (or go test ./internal/controh KUBEBUILDER_ASSETS set) to confirm the newtests fail on the pre-change code and pass after.

Want me to stage these into a branch/commit, or adjust the DeleteAllOf selector to additionally scope by LabelSubmissionID for a tighter match?

✻ Crunched for 4m 21s

@tariq-hasan tariq-hasan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Arnaud-Nauwynck! I have added a few comments. Thanks!

Comment on lines 1167 to 1172

if err := r.deleteWebUIIngress(ctx, app); err != nil {
return err
}

return nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should delete all the operator-managed services since validateSparkResourceDeletion validates that all of these have been removed. Otherwise we will have a deadlock in the reconciliation loop.

Suggested change
if err := r.deleteWebUIIngress(ctx, app); err != nil {
return err
}
if err := r.deleteDriverIngressServices(ctx, app); err != nil {
return err
}
return nil

return nil
svcName = util.GetDefaultUIServiceName(app)
}
logger.Info("Deleting Spark web UI service", "service", svcName)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably best to put a guard on the logging since this would be redundant for a terminated app that keeps being requeued until TTL.

@tariq-hasan

Copy link
Copy Markdown
Member

@Arnaud-Nauwynck The commits need to be signed as well to pass the DCO check.

Detailed instructions to pass the check: https://github.com/kubeflow/spark-operator/pull/2985/checks?check_run_id=80835640557.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants