This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 17f41463 [SPARK-59931] Simplify the K8s event messages of errors and 
`SchedulingFailure`
17f41463 is described below

commit 17f41463095a4de3820104c7ddb6915bf93d75d0
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Fri Oct 2 01:35:49 2026 -0700

    [SPARK-59931] Simplify the K8s event messages of errors and 
`SchedulingFailure`
    
    ### What changes were proposed in this pull request?
    
    This PR aims to simplify the K8s event messages of errors and 
`SchedulingFailure`.
    
    - `EventUtils.describe` uses the message of the API server for a rejected 
request, like the K8s built-in controllers, instead of the fabric8 message with 
the request URL and the `Status` dump. It also no longer repeats a cause which 
reads the same.
    - The `SchedulingFailure` event describes the failure instead of carrying 
the stack trace. The resource status keeps the stack trace.
    - `docs/configuration.md` documents the event messages.
    
    ### Why are the changes needed?
    
    K8s events reach more readers than the resource status, e.g. via the 
built-in `view` ClusterRole and event exporters. Stack traces and request URLs 
are not actionable for them, and they push the actual cause out of the 
1024-character event message.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No, because K8s events have not been released yet. The resource status and 
the operator log are unchanged.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added and updated test cases.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    Closes #920 from dongjoon-hyun/SPARK-59931.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 docs/configuration.md                              | 27 +++++++++-
 .../reconciler/reconcilesteps/AppInitStep.java     | 12 ++++-
 .../reconciler/reconcilesteps/ClusterInitStep.java |  5 +-
 .../spark/k8s/operator/utils/EventUtils.java       | 42 ++++++++++++----
 .../spark/k8s/operator/utils/StatusRecorder.java   | 41 +++++++++++++---
 .../reconciler/reconcilesteps/AppInitStepTest.java | 19 ++++++--
 .../reconcilesteps/ClusterInitStepTest.java        | 14 ++++--
 .../spark/k8s/operator/utils/EventUtilsTest.java   | 57 ++++++++++++++++++++++
 .../k8s/operator/utils/StatusRecorderTest.java     | 28 +++++++++++
 9 files changed, 217 insertions(+), 28 deletions(-)

diff --git a/docs/configuration.md b/docs/configuration.md
index 38b4ba69..56bba661 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -80,7 +80,9 @@ kubectl get events -A --field-selector 
reportingComponent=spark-kubernetes-opera
 ```
 
 Whenever a resource transitions into a new state, the operator publishes an 
event whose `reason`
-is the name of the new state and whose `message` is the state message. 
Repeated transitions into
+is the name of the new state and whose `message` is the state message, except 
that the event of
+`SchedulingFailure` leaves out the stack trace of the failure, see
+[Event messages](#event-messages). Repeated transitions into
 the same state are aggregated into a single `Event` object by incrementing its 
`count` and
 replacing its `message`. A repeat carrying the same `message` may be paced by
 [`minIntervalSeconds`](#event-frequency), while one carrying a new `message` 
is always published.
@@ -189,6 +191,29 @@ still holds the interval. An event which is republished, 
such as `SuspendHeld` o
 a failure which lasts, recovers with its next repeat after the interval, while 
one which is not,
 such as a state transition, is lost; the resource status remains the source of 
truth.
 
+### Event messages
+
+The `message` of a `Warning` event about an error, such as `ReconcileError` or
+`ClusterRequestFailed`, describes the exception and its innermost cause, 
without the stack trace,
+which the operator logs. For a request which the API server rejected, it is 
the message of the API
+server, as in the events of the built-in controllers, e.g. an RBAC denial 
which names the
+ServiceAccount of the operator, a rejection by an admission webhook or a 
resource quota, or a field
+value which the API server found invalid. When `SchedulingFailure` is caused 
by an exception, its
+event describes the exception alike, while its state message keeps the stack 
trace.
+
+Events reach more readers than the status of the resources. The built-in 
`view`, `edit` and
+`admin` ClusterRoles grant reading the `events` of a namespace, but not the 
`SparkApplication` and
+`SparkCluster` resources, which the Helm chart does not aggregate into them, 
and event exporters
+often forward warnings out of the cluster, e.g. to chat or logging services. 
So keep credentials
+out of `spec.sparkConf` and pod templates, since an error may quote them, e.g. 
as an invalid value,
+and refer to Kubernetes Secrets instead, e.g. with 
`spark.kubernetes.driver.secretKeyRef.[EnvName]`
+and `spark.kubernetes.executor.secretKeyRef.[EnvName]`. To keep the details of 
failures in the
+resource status and the operator log only, exclude their reasons, e.g. as 
follows.
+
+```properties
+spark.kubernetes.operator.events.excludedReasons=SchedulingFailure,ReconcileError,CleanupError,StatusUpdateFailed,ClusterRequestFailed,SuspendReleaseFailed,Kueue.*Failed
+```
+
 ## Metrics
 
 Spark operator,
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
index 01efce15..b1bf58c8 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
@@ -52,6 +52,7 @@ import 
org.apache.spark.k8s.operator.status.ApplicationAttemptSummary;
 import org.apache.spark.k8s.operator.status.ApplicationState;
 import org.apache.spark.k8s.operator.status.ApplicationStateSummary;
 import org.apache.spark.k8s.operator.status.ApplicationStatus;
+import org.apache.spark.k8s.operator.utils.EventUtils;
 import org.apache.spark.k8s.operator.utils.ReconcilerUtils;
 import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
 
@@ -162,8 +163,15 @@ public final class AppInitStep extends AppReconcileStep {
               .getStatus()
               .appendNewState(
                   new 
ApplicationState(ApplicationStateSummary.SchedulingFailure, errorMessage));
-      return attemptStatusUpdate(
-          context, statusRecorder, updatedStatus, 
completeAndImmediateRequeue());
+      // The status keeps the stack trace, while the event, which reaches more 
readers, only
+      // describes the failure, like the other warning events.
+      if (!statusRecorder.persistStatus(
+          context,
+          updatedStatus,
+          Constants.SCHEDULE_FAILURE_MESSAGE + " " + EventUtils.describe(e))) {
+        log.warn("Failed to persist status, will retry status update in next 
reconcile attempt");
+      }
+      return completeAndImmediateRequeue();
     }
     ApplicationStatus updatedStatus =
         context
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
index 2378c34c..2f5f399b 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
@@ -199,12 +199,15 @@ public final class ClusterInitStep extends 
ClusterReconcileStep {
       log.error("Failed to request master resource.", e);
     }
     String msg = CLUSTER_SCHEDULE_FAILURE_MESSAGE + " StackTrace: " + 
buildGeneralErrorMessage(e);
+    // The status keeps the stack trace, while the event, which reaches more 
readers, only
+    // describes the failure, like the other warning events.
     statusRecorder.persistStatus(
         context,
         context
             .getResource()
             .getStatus()
-            .appendNewState(new ClusterState(SchedulingFailure, msg)));
+            .appendNewState(new ClusterState(SchedulingFailure, msg)),
+        CLUSTER_SCHEDULE_FAILURE_MESSAGE + " " + EventUtils.describe(e));
     return completeAndImmediateRequeue();
   }
 
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
index 6dc72a8f..a33384f3 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
@@ -21,6 +21,7 @@ package org.apache.spark.k8s.operator.utils;
 
 import java.util.Set;
 
+import io.fabric8.kubernetes.client.KubernetesClientException;
 import io.javaoperatorsdk.operator.api.event.EventRecord;
 import io.javaoperatorsdk.operator.api.event.EventType;
 import io.javaoperatorsdk.operator.api.event.ResourceEventRecorder;
@@ -146,8 +147,8 @@ public final class EventUtils {
    * <p>The reason doubles as the event key, so repeated warnings for the same 
reason on the same
    * resource collapse into a count increment on a single Event object instead 
of creating one
    * Event per occurrence. Messages therefore must not be part of the 
identity: they typically vary
-   * between attempts, for instance because a {@code 
KubernetesClientException} message embeds the
-   * request URL.
+   * between attempts, for instance because a rejection by a resource quota 
reports its current
+   * usage.
    *
    * @param recorder The event recorder bound to the resource.
    * @param reason A short CamelCase reason, as expected by Kubernetes.
@@ -193,8 +194,14 @@ public final class EventUtils {
   /**
    * Builds a concise single-line description of an exception, suitable for an 
event message. The
    * innermost cause is appended when the exception has one, since that is 
usually where the
-   * actionable detail is. The full stack trace is intentionally omitted, it 
belongs in the
-   * operator log.
+   * actionable detail is, unless its message is the same. The full stack 
trace is intentionally
+   * omitted, it belongs in the operator log.
+   *
+   * <p>A request which the API server answered with an error is described by 
the message of the
+   * returned {@code Status}, like the events of the built-in controllers, 
rather than by the
+   * message of fabric8, which wraps it with the request URL and a dump of the 
whole {@code
+   * Status}. fabric8 rethrows such a failure as a copy of itself which 
carries the original as its
+   * cause, so that cause is not repeated.
    *
    * @param throwable The exception to describe, may be null.
    * @return A description of the exception.
@@ -203,16 +210,31 @@ public final class EventUtils {
     if (throwable == null) {
       return "";
     }
-    StringBuilder builder = new StringBuilder(describeSingle(throwable));
+    String message = messageOf(throwable);
+    String description = describeSingle(throwable, message);
     Throwable rootCause = rootCauseOf(throwable);
-    if (rootCause != null) {
-      builder.append(", caused by: ").append(describeSingle(rootCause));
+    if (rootCause == null) {
+      return description;
+    }
+    String rootCauseMessage = messageOf(rootCause);
+    // A cause with the same message adds nothing but its type, e.g. the 
original of the copy which
+    // fabric8 rethrows, or the request timeout which it wraps.
+    if (StringUtils.isNotBlank(message) && message.equals(rootCauseMessage)) {
+      return description;
+    }
+    return description + ", caused by: " + describeSingle(rootCause, 
rootCauseMessage);
+  }
+
+  private static String messageOf(Throwable throwable) {
+    if (throwable instanceof KubernetesClientException e
+        && e.getStatus() != null
+        && StringUtils.isNotBlank(e.getStatus().getMessage())) {
+      return e.getStatus().getMessage();
     }
-    return builder.toString();
+    return throwable.getMessage();
   }
 
-  private static String describeSingle(Throwable throwable) {
-    String message = throwable.getMessage();
+  private static String describeSingle(Throwable throwable, String message) {
     String type = throwable.getClass().getSimpleName();
     return StringUtils.isBlank(message) ? type : type + ": " + message;
   }
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/StatusRecorder.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/StatusRecorder.java
index 724fa1ab..dbc43ad5 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/StatusRecorder.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/StatusRecorder.java
@@ -84,8 +84,11 @@ public class StatusRecorder<
    * behavior.
    *
    * @param context Context of the resource for which status update should be 
performed.
+   * @param currentStateEventMessage The message of the event about the 
current state, or null for
+   *     the message of that state.
    */
-  private void patchAndStatusWithVersionLocked(BaseContext<CR> context) {
+  private void patchAndStatusWithVersionLocked(
+      BaseContext<CR> context, String currentStateEventMessage) {
     CR resource = context.getResource();
     KubernetesClient client = context.getClient();
     ObjectNode newStatusNode = objectMapper.convertValue(resource.getStatus(), 
ObjectNode.class);
@@ -139,7 +142,7 @@ public class StatusRecorder<
         listener -> {
           listener.listenStatus(resource, prevStatus, resource.getStatus());
         });
-    recordTransitionEvents(context, prevStatus, resource.getStatus());
+    recordTransitionEvents(context, prevStatus, resource.getStatus(), 
currentStateEventMessage);
   }
 
   /**
@@ -159,13 +162,17 @@ public class StatusRecorder<
    *
    * <p>The event type follows {@link 
EventUtils#eventTypeOf(BaseStateSummary)}. The state name is
    * used as both reason and dedup key, so an app that keeps re-entering the 
same state bumps the
-   * count on one Event instead of accumulating one per attempt.
+   * count on one Event instead of accumulating one per attempt. The message 
is the state message,
+   * unless a message is given for the event about the current state.
    *
    * @param context Context of the resource whose status has just been patched.
    * @param prevStatus The status before the patch, may be null.
    * @param status The status that was persisted.
+   * @param currentStateEventMessage The message of the event about the 
current state, or null for
+   *     the message of that state.
    */
-  private void recordTransitionEvents(BaseContext<CR> context, STATUS 
prevStatus, STATUS status) {
+  private void recordTransitionEvents(
+      BaseContext<CR> context, STATUS prevStatus, STATUS status, String 
currentStateEventMessage) {
     BaseState<?> currentState = status.getCurrentState();
     if (currentState == null
         || (prevStatus != null && 
currentState.equals(prevStatus.getCurrentState()))) {
@@ -175,7 +182,10 @@ public class StatusRecorder<
     for (BaseState<?> state : newStatesSince(prevStatus, status)) {
       Object summary = state.getCurrentStateSummary();
       if (summary instanceof BaseStateSummary stateSummary && 
!summary.equals(lastSummary)) {
-        String message = state.getMessage();
+        String message =
+            currentStateEventMessage != null && state.equals(currentState)
+                ? currentStateEventMessage
+                : state.getMessage();
         EventUtils.record(
             context.getEventRecorder(),
             EventUtils.eventTypeOf(stateSummary),
@@ -219,9 +229,28 @@ public class StatusRecorder<
    * @return true if the status is successfully patched.
    */
   public boolean persistStatus(BaseContext<CR> context, STATUS newStatus) {
+    return persistStatus(context, newStatus, null);
+  }
+
+  /**
+   * Persists the new status of the resource to the Kubernetes cluster, like 
{@link
+   * #persistStatus(BaseContext, BaseStatus)}, but publishes the given message 
in place of the
+   * message of the current state in the event about the transition into it. 
It is meant for a
+   * state message which is too detailed for an event, such as one which 
carries a stack trace,
+   * since events typically reach more readers than the resource status, e.g. 
through the built-in
+   * {@code view} ClusterRole or an event exporter.
+   *
+   * @param context The BaseContext containing the resource and client.
+   * @param newStatus The new status to persist.
+   * @param currentStateEventMessage The message of the event about the 
current state, or null for
+   *     the message of that state.
+   * @return true if the status is successfully patched.
+   */
+  public boolean persistStatus(
+      BaseContext<CR> context, STATUS newStatus, String 
currentStateEventMessage) {
     try {
       context.getResource().setStatus(newStatus);
-      patchAndStatusWithVersionLocked(context);
+      patchAndStatusWithVersionLocked(context, currentStateEventMessage);
       return true;
     } catch (KubernetesClientException e) {
       log.error("Error while persisting status to {}", newStatus, e);
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
index 2c95ff4a..5bcc3db6 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
@@ -1135,7 +1135,7 @@ class AppInitStepTest {
     Assertions.assertEquals(ReconcileProgress.completeAndImmediateRequeue(), 
progress);
     Assertions.assertNull(getWorkload());
     ArgumentCaptor<ApplicationStatus> captor = 
ArgumentCaptor.forClass(ApplicationStatus.class);
-    verify(recorder).persistStatus(any(), captor.capture());
+    verify(recorder).persistStatus(any(), captor.capture(), any());
     Assertions.assertEquals(
         ApplicationStateSummary.SchedulingFailure,
         captor.getValue().getCurrentState().getCurrentStateSummary());
@@ -1638,16 +1638,25 @@ class AppInitStepTest {
               throw new SparkException(
                   "Please specify spark.kubernetes.file.upload.path 
property.");
             });
-    when(recorder.persistStatus(any(), any())).thenReturn(true);
+    when(recorder.persistStatus(any(), any(), any())).thenReturn(true);
 
     ReconcileProgress progress = appInitStep.reconcile(mockContext, recorder);
 
     Assertions.assertEquals(ReconcileProgress.completeAndImmediateRequeue(), 
progress);
     ArgumentCaptor<ApplicationStatus> captor = 
ArgumentCaptor.forClass(ApplicationStatus.class);
-    verify(recorder).persistStatus(any(), captor.capture());
+    ArgumentCaptor<String> eventMessage = 
ArgumentCaptor.forClass(String.class);
+    verify(recorder).persistStatus(any(), captor.capture(), 
eventMessage.capture());
     Assertions.assertEquals(
         ApplicationStateSummary.SchedulingFailure,
         captor.getValue().getCurrentState().getCurrentStateSummary());
+    // The status keeps the stack trace, while the event only describes the 
failure
+    Assertions.assertTrue(
+        captor.getValue().getCurrentState().getMessage().contains("\tat "),
+        captor.getValue().getCurrentState().getMessage());
+    Assertions.assertEquals(
+        Constants.SCHEDULE_FAILURE_MESSAGE
+            + " SparkException: Please specify 
spark.kubernetes.file.upload.path property.",
+        eventMessage.getValue());
     Assertions.assertNotNull(getWorkload());
   }
 
@@ -1804,7 +1813,7 @@ class AppInitStepTest {
     when(mockContext.getClient()).thenReturn(kubernetesClient);
     when(mockContext.getEventRecorder()).thenReturn(eventRecorder);
     createFlavor("spot-flavor", Map.of("pool", "spot"), List.of());
-    when(recorder.persistStatus(any(), any())).thenReturn(true);
+    when(recorder.persistStatus(any(), any(), any())).thenReturn(true);
 
     appInitStep.reconcile(mockContext, recorder);
     admitWorkload(Map.of("driver", Map.of("cpu", "spot-flavor")));
@@ -1815,7 +1824,7 @@ class AppInitStepTest {
     Assertions.assertNull(getWorkload());
     verify(mockContext, never()).setKueuePodSetFlavors(any());
     ArgumentCaptor<ApplicationStatus> captor = 
ArgumentCaptor.forClass(ApplicationStatus.class);
-    verify(recorder).persistStatus(any(), captor.capture());
+    verify(recorder).persistStatus(any(), captor.capture(), any());
     Assertions.assertEquals(
         ApplicationStateSummary.SchedulingFailure,
         captor.getValue().getCurrentState().getCurrentStateSummary());
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
index 02afbd51..3fab2f4f 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
@@ -293,11 +293,19 @@ class ClusterInitStepTest {
         ReconcileProgress.completeAndImmediateRequeue(),
         clusterInitStep.reconcile(mockContext, recorder));
     ArgumentCaptor<ClusterStatus> statusCaptor = 
ArgumentCaptor.forClass(ClusterStatus.class);
-    verify(recorder).persistStatus(any(), statusCaptor.capture());
+    ArgumentCaptor<String> eventMessage = 
ArgumentCaptor.forClass(String.class);
+    verify(recorder).persistStatus(any(), statusCaptor.capture(), 
eventMessage.capture());
     Assertions.assertEquals(
         ClusterStateSummary.SchedulingFailure,
         statusCaptor.getValue().getCurrentState().getCurrentStateSummary());
-    // The status already says why, so no event is needed
+    // The status keeps the stack trace, which the SchedulingFailure event 
leaves out
+    Assertions.assertTrue(
+        statusCaptor.getValue().getCurrentState().getMessage().contains("\tat 
"),
+        statusCaptor.getValue().getCurrentState().getMessage());
+    Assertions.assertEquals(
+        Constants.CLUSTER_SCHEDULE_FAILURE_MESSAGE + " 
KubernetesClientException: Invalid",
+        eventMessage.getValue());
+    // SchedulingFailure already says why, so no other event is needed
     verifyNoInteractions(eventRecorder);
   }
 
@@ -1082,7 +1090,7 @@ class ClusterInitStepTest {
     verify(mockContext, never()).setKueuePodSetFlavors(any());
     verify(mockContext, never()).getMasterServiceSpec();
     ArgumentCaptor<ClusterStatus> captor = 
ArgumentCaptor.forClass(ClusterStatus.class);
-    verify(recorder).persistStatus(any(), captor.capture());
+    verify(recorder).persistStatus(any(), captor.capture(), any());
     Assertions.assertEquals(
         ClusterStateSummary.SchedulingFailure,
         captor.getValue().getCurrentState().getCurrentStateSummary());
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/EventUtilsTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/EventUtilsTest.java
index d53d55bd..422cae7a 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/EventUtilsTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/EventUtilsTest.java
@@ -27,9 +27,13 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 
 import java.util.Set;
+import java.util.concurrent.TimeoutException;
 
 import io.fabric8.kubernetes.api.model.Event;
 import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.Status;
+import io.fabric8.kubernetes.api.model.StatusBuilder;
+import io.fabric8.kubernetes.client.KubernetesClientException;
 import io.javaoperatorsdk.operator.api.config.ConfigurationService;
 import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
 import io.javaoperatorsdk.operator.api.event.DefaultEventRecorder;
@@ -194,6 +198,59 @@ class EventUtilsTest {
                 + "IllegalArgumentException: quota exceeded");
   }
 
+  @Test
+  void describeUsesTheMessageOfTheApiServerForARejectedRequest() {
+    Status status =
+        new StatusBuilder()
+            .withCode(403)
+            .withReason("Forbidden")
+            .withMessage("pods \"app-1-driver\" is forbidden: exceeded quota: 
compute")
+            .build();
+    // Like fabric8 builds the failure of a request which the API server 
rejected, and rethrows it
+    // as a copy which carries the original as its cause.
+    KubernetesClientException rejected =
+        new KubernetesClientException(
+            "Failure executing: POST at: 
https://10.43.0.1:443/api/v1/namespaces/default/pods. "
+                + "Message: "
+                + status.getMessage()
+                + ". Received status: "
+                + status
+                + ".",
+            403,
+            status);
+
+    // Neither the request URL nor the dump of the Status, and the cause is 
not repeated.
+    assertThat(EventUtils.describe(rejected.copyAsCause()))
+        .isEqualTo("KubernetesClientException: " + status.getMessage());
+    assertThat(EventUtils.describe(new IllegalStateException("not requested", 
rejected)))
+        .isEqualTo(
+            "IllegalStateException: not requested, caused by: 
KubernetesClientException: "
+                + status.getMessage());
+  }
+
+  @Test
+  void describeKeepsTheMessageOfAFailureWithoutStatus() {
+    KubernetesClientException notFound =
+        new KubernetesClientException("Kueue ResourceFlavor spot is not 
found.", 404, null);
+
+    assertThat(EventUtils.describe(notFound))
+        .isEqualTo("KubernetesClientException: Kueue ResourceFlavor spot is 
not found.");
+  }
+
+  @Test
+  void describeDoesNotRepeatACauseWithTheSameMessage() {
+    // Like fabric8 wraps a request timeout, which is neither an IOException 
nor a
+    // KubernetesClientException, in a KubernetesClientException with the same 
message.
+    String message =
+        "The timeout period of 1000ms has been exceeded while executing POST "
+            + "/api/v1/namespaces/default/pods for server 10.43.0.1:443";
+
+    assertThat(
+            EventUtils.describe(
+                new KubernetesClientException(message, new 
TimeoutException(message))))
+        .isEqualTo("KubernetesClientException: " + message);
+  }
+
   @Test
   void describeHandlesNullAndCauseless() {
     assertThat(EventUtils.describe(null)).isEmpty();
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/StatusRecorderTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/StatusRecorderTest.java
index 57ba2e3c..52a13a0b 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/StatusRecorderTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/StatusRecorderTest.java
@@ -235,6 +235,34 @@ class StatusRecorderTest {
             ApplicationStateSummary.DriverReady.name());
   }
 
+  @Test
+  void publishesTheGivenMessageInTheEventAboutTheCurrentStateOnly() {
+    var testResource = getSparkApplication("1");
+    var context = contextFor(testResource);
+    expectStatusPatch(testResource, getSparkApplication("2"));
+
+    var requested =
+        new ApplicationStatus()
+            .appendNewState(
+                new ApplicationState(ApplicationStateSummary.DriverRequested, 
"requested"));
+    statusRecorder.persistStatus(context, requested);
+    String stackTrace = "SparkException: boom\n\tat Foo.bar(Foo.java:1)";
+    statusRecorder.persistStatus(
+        context,
+        requested
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.DriverStarted, "started"))
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.Failed, stackTrace)),
+        "SparkException: boom");
+
+    ArgumentCaptor<EventRecord> captor = 
ArgumentCaptor.forClass(EventRecord.class);
+    verify(mockEventRecorder, times(3)).record(captor.capture());
+    assertThat(captor.getAllValues())
+        .extracting(EventRecord::message)
+        .containsExactly("requested", "started", "SparkException: boom");
+    // The status keeps the message of the state
+    
assertThat(testResource.getStatus().getCurrentState().getMessage()).isEqualTo(stackTrace);
+  }
+
   @Test
   void publishesOnceForRepeatedStatesInASinglePatch() {
     var testResource = getSparkApplication("1");


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to