From 5b9a6a64779d69f54cf9efd7c8b2d7305ba462fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCller?= Date: Wed, 29 Jul 2026 11:12:43 +0200 Subject: [PATCH 1/6] chore(kuttl): add test for failing spark job --- .../kuttl/failure-propagation/00-assert.yaml | 9 +++ .../failure-propagation/00-patch-ns.yaml.j2 | 9 +++ .../00-serviceaccount.yaml.j2 | 29 ++++++++++ .../failure-propagation/01-assert.yaml.j2 | 10 ++++ ...tor-aggregator-discovery-configmap.yaml.j2 | 9 +++ .../10-deploy-failing-spark-app.yaml.j2 | 55 +++++++++++++++++++ .../failure-propagation/11-check-phase.yaml | 42 ++++++++++++++ .../kuttl/failure-propagation/12-assert.yaml | 7 +++ tests/test-definition.yaml | 4 ++ 9 files changed, 174 insertions(+) create mode 100644 tests/templates/kuttl/failure-propagation/00-assert.yaml create mode 100644 tests/templates/kuttl/failure-propagation/00-patch-ns.yaml.j2 create mode 100644 tests/templates/kuttl/failure-propagation/00-serviceaccount.yaml.j2 create mode 100644 tests/templates/kuttl/failure-propagation/01-assert.yaml.j2 create mode 100644 tests/templates/kuttl/failure-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 create mode 100644 tests/templates/kuttl/failure-propagation/10-deploy-failing-spark-app.yaml.j2 create mode 100644 tests/templates/kuttl/failure-propagation/11-check-phase.yaml create mode 100644 tests/templates/kuttl/failure-propagation/12-assert.yaml diff --git a/tests/templates/kuttl/failure-propagation/00-assert.yaml b/tests/templates/kuttl/failure-propagation/00-assert.yaml new file mode 100644 index 00000000..5baf8caa --- /dev/null +++ b/tests/templates/kuttl/failure-propagation/00-assert.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 900 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: integration-tests-sa diff --git a/tests/templates/kuttl/failure-propagation/00-patch-ns.yaml.j2 b/tests/templates/kuttl/failure-propagation/00-patch-ns.yaml.j2 new file mode 100644 index 00000000..67185acf --- /dev/null +++ b/tests/templates/kuttl/failure-propagation/00-patch-ns.yaml.j2 @@ -0,0 +1,9 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +# see https://github.com/stackabletech/issues/issues/566 +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl patch namespace $NAMESPACE -p '{"metadata":{"labels":{"pod-security.kubernetes.io/enforce":"privileged"}}}' + timeout: 120 +{% endif %} diff --git a/tests/templates/kuttl/failure-propagation/00-serviceaccount.yaml.j2 b/tests/templates/kuttl/failure-propagation/00-serviceaccount.yaml.j2 new file mode 100644 index 00000000..9cbf0351 --- /dev/null +++ b/tests/templates/kuttl/failure-propagation/00-serviceaccount.yaml.j2 @@ -0,0 +1,29 @@ +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: use-integration-tests-scc +rules: +{% if test_scenario['values']['openshift'] == "true" %} + - apiGroups: ["security.openshift.io"] + resources: ["securitycontextconstraints"] + resourceNames: ["privileged"] + verbs: ["use"] +{% endif %} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: integration-tests-sa +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: use-integration-tests-scc +subjects: + - kind: ServiceAccount + name: integration-tests-sa +roleRef: + kind: Role + name: use-integration-tests-scc + apiGroup: rbac.authorization.k8s.io diff --git a/tests/templates/kuttl/failure-propagation/01-assert.yaml.j2 b/tests/templates/kuttl/failure-propagation/01-assert.yaml.j2 new file mode 100644 index 00000000..50b1d4c3 --- /dev/null +++ b/tests/templates/kuttl/failure-propagation/01-assert.yaml.j2 @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +{% endif %} diff --git a/tests/templates/kuttl/failure-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/failure-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 new file mode 100644 index 00000000..2d6a0df5 --- /dev/null +++ b/tests/templates/kuttl/failure-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 @@ -0,0 +1,9 @@ +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +data: + ADDRESS: {{ lookup('env', 'VECTOR_AGGREGATOR') }} +{% endif %} diff --git a/tests/templates/kuttl/failure-propagation/10-deploy-failing-spark-app.yaml.j2 b/tests/templates/kuttl/failure-propagation/10-deploy-failing-spark-app.yaml.j2 new file mode 100644 index 00000000..26265b04 --- /dev/null +++ b/tests/templates/kuttl/failure-propagation/10-deploy-failing-spark-app.yaml.j2 @@ -0,0 +1,55 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: failing-job +data: + # A job that starts up correctly, runs a real Spark action, and only then fails. + # This makes sure we are testing the exit code of a genuinely failing application + # and not a startup or submission error. + failing-job.py: | + from pyspark.sql import SparkSession + + spark = SparkSession.builder.appName("failing-job").getOrCreate() + + # forces an actual computation + print(f"count = {spark.range(1000).count()}") + + raise RuntimeError("deliberate failure to test exit code propagation") +--- +apiVersion: spark.stackable.tech/v1alpha1 +kind: SparkApplication +metadata: + name: failure-propagation +spec: +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + sparkImage: +{% if test_scenario['values']['spark'].find(",") > 0 %} + custom: "{{ test_scenario['values']['spark'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['spark'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['spark'] }}" +{% endif %} + pullPolicy: IfNotPresent + mode: cluster + mainApplicationFile: "local:///stackable/spark/jobs/failing-job.py" + driver: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + volumeMounts: + - name: script + mountPath: /stackable/spark/jobs + # The executors only run the tasks of the range/count job, they never read the script, + # so they do not need the volume mounted. + executor: + replicas: 1 + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + volumes: + - name: script + configMap: + name: failing-job diff --git a/tests/templates/kuttl/failure-propagation/11-check-phase.yaml b/tests/templates/kuttl/failure-propagation/11-check-phase.yaml new file mode 100644 index 00000000..d6151536 --- /dev/null +++ b/tests/templates/kuttl/failure-propagation/11-check-phase.yaml @@ -0,0 +1,42 @@ +--- +# This is a TestStep and not a TestAssert on purpose. kuttl retries an assert until it +# times out, so a wrong-but-terminal phase could only ever be reported after the full +# timeout. A TestStep command runs once, which lets us fail as soon as the application +# reaches a terminal phase and say why. +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + # Deliberately above the script's own budget below, so that on a timeout the script + # reports the last phase it saw instead of kuttl killing it with a generic message. + - timeout: 960 + script: | + set -eu + + timeout_seconds=900 + interval_seconds=5 + + phase="" + elapsed=0 + while [ "$elapsed" -lt "$timeout_seconds" ]; do + phase=$(kubectl -n "$NAMESPACE" get sparkapplication failure-propagation \ + -o jsonpath='{.status.phase}' 2>/dev/null || true) + + case "$phase" in + Failed) + echo "OK: the failing application was reported as Failed" + exit 0 + ;; + Succeeded) + echo "FAIL: the application failed at runtime but was reported as Succeeded." + echo "The driver container exit code was masked between the Spark process and" + echo "the container entrypoint (see spark-k8s/stackable/run-spark.sh)." + exit 1 + ;; + esac + + sleep "$interval_seconds" + elapsed=$((elapsed + interval_seconds)) + done + + echo "FAIL: no terminal phase after ${timeout_seconds}s (last seen: '${phase}')" + exit 1 diff --git a/tests/templates/kuttl/failure-propagation/12-assert.yaml b/tests/templates/kuttl/failure-propagation/12-assert.yaml new file mode 100644 index 00000000..8e8c9e11 --- /dev/null +++ b/tests/templates/kuttl/failure-propagation/12-assert.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 300 +commands: + # Test that there is no spark driver pod left hanging + - script: test -z "$(kubectl -n $NAMESPACE get pods -o name | grep -E 'driver$')" diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 5d387b63..3522e1ec 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -93,6 +93,10 @@ tests: dimensions: - spark - openshift + - name: failure-propagation + dimensions: + - spark + - openshift - name: pyspark-ny-public-s3 dimensions: - spark From 6117eb595fba35dcc4aa1f92d0d55e30a8842a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCller?= Date: Wed, 29 Jul 2026 16:13:31 +0200 Subject: [PATCH 2/6] chore(kuttl): add test for graceful shutdown --- .../graceful-shutdown/00-patch-ns.yaml.j2 | 9 +++ .../kuttl/graceful-shutdown/01-assert.yaml.j2 | 10 +++ ...tor-aggregator-discovery-configmap.yaml.j2 | 9 +++ .../kuttl/graceful-shutdown/10-assert.yaml | 19 ++++++ .../10-deploy-long-running-app.yaml.j2 | 55 ++++++++++++++++ .../11-check-executor-shutdown.yaml | 62 ++++++++++++++++++ .../kuttl/graceful-shutdown/12-assert.yaml | 19 ++++++ .../13-check-driver-shutdown-propagation.yaml | 65 +++++++++++++++++++ tests/test-definition.yaml | 4 ++ 9 files changed, 252 insertions(+) create mode 100644 tests/templates/kuttl/graceful-shutdown/00-patch-ns.yaml.j2 create mode 100644 tests/templates/kuttl/graceful-shutdown/01-assert.yaml.j2 create mode 100644 tests/templates/kuttl/graceful-shutdown/01-install-vector-aggregator-discovery-configmap.yaml.j2 create mode 100644 tests/templates/kuttl/graceful-shutdown/10-assert.yaml create mode 100644 tests/templates/kuttl/graceful-shutdown/10-deploy-long-running-app.yaml.j2 create mode 100644 tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml create mode 100644 tests/templates/kuttl/graceful-shutdown/12-assert.yaml create mode 100644 tests/templates/kuttl/graceful-shutdown/13-check-driver-shutdown-propagation.yaml diff --git a/tests/templates/kuttl/graceful-shutdown/00-patch-ns.yaml.j2 b/tests/templates/kuttl/graceful-shutdown/00-patch-ns.yaml.j2 new file mode 100644 index 00000000..67185acf --- /dev/null +++ b/tests/templates/kuttl/graceful-shutdown/00-patch-ns.yaml.j2 @@ -0,0 +1,9 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +# see https://github.com/stackabletech/issues/issues/566 +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl patch namespace $NAMESPACE -p '{"metadata":{"labels":{"pod-security.kubernetes.io/enforce":"privileged"}}}' + timeout: 120 +{% endif %} diff --git a/tests/templates/kuttl/graceful-shutdown/01-assert.yaml.j2 b/tests/templates/kuttl/graceful-shutdown/01-assert.yaml.j2 new file mode 100644 index 00000000..50b1d4c3 --- /dev/null +++ b/tests/templates/kuttl/graceful-shutdown/01-assert.yaml.j2 @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +{% endif %} diff --git a/tests/templates/kuttl/graceful-shutdown/01-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/graceful-shutdown/01-install-vector-aggregator-discovery-configmap.yaml.j2 new file mode 100644 index 00000000..2d6a0df5 --- /dev/null +++ b/tests/templates/kuttl/graceful-shutdown/01-install-vector-aggregator-discovery-configmap.yaml.j2 @@ -0,0 +1,9 @@ +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +data: + ADDRESS: {{ lookup('env', 'VECTOR_AGGREGATOR') }} +{% endif %} diff --git a/tests/templates/kuttl/graceful-shutdown/10-assert.yaml b/tests/templates/kuttl/graceful-shutdown/10-assert.yaml new file mode 100644 index 00000000..f30e7b3f --- /dev/null +++ b/tests/templates/kuttl/graceful-shutdown/10-assert.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 900 +commands: + # Both executors have to be registered and working: an executor that is still registering is + # not in the driver's list yet and would never be asked to shut down. + - script: | + set -eu + + registered=0 + for pod in $(kubectl -n "$NAMESPACE" get pods -o name \ + -l spark-role=executor,app.kubernetes.io/instance=graceful-shutdown | cut -d/ -f2); do + log=$(kubectl -n "$NAMESPACE" logs --tail=-1 "$pod" -c spark) + echo "$log" | grep -q 'Successfully registered with driver' + echo "$log" | grep -q 'Running task' + registered=$(( registered + 1 )) + done + test "$registered" -eq 2 diff --git a/tests/templates/kuttl/graceful-shutdown/10-deploy-long-running-app.yaml.j2 b/tests/templates/kuttl/graceful-shutdown/10-deploy-long-running-app.yaml.j2 new file mode 100644 index 00000000..08f8152f --- /dev/null +++ b/tests/templates/kuttl/graceful-shutdown/10-deploy-long-running-app.yaml.j2 @@ -0,0 +1,55 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: long-running-job +data: + # Keeps the executors busy for longer than the test runs, so that they are stopped mid-task. + long-running-job.py: | + import time + + from pyspark.sql import SparkSession + + spark = SparkSession.builder.appName("long-running-job").getOrCreate() + + def slow(i): + time.sleep(600) + return i + + print(spark.sparkContext.parallelize(range(4), 4).map(slow).collect()) +--- +apiVersion: spark.stackable.tech/v1alpha1 +kind: SparkApplication +metadata: + name: graceful-shutdown +spec: +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + sparkImage: +{% if test_scenario['values']['spark'].find(",") > 0 %} + custom: "{{ test_scenario['values']['spark'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['spark'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['spark'] }}" +{% endif %} + pullPolicy: IfNotPresent + mode: cluster + mainApplicationFile: "local:///stackable/spark/jobs/long-running-job.py" + driver: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + volumeMounts: + - name: script + mountPath: /stackable/spark/jobs + # Only the driver reads the script; the executors receive the closure over the wire. + executor: + replicas: 2 + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + volumes: + - name: script + configMap: + name: long-running-job diff --git a/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml b/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml new file mode 100644 index 00000000..7a0246ad --- /dev/null +++ b/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml @@ -0,0 +1,62 @@ +--- +# An executor whose Pod is deleted (node drain, eviction, downscale) has to be told to stop while +# it is running a task. If SIGTERM is not forwarded to the JVM, the container ignores it and is +# SIGKILLed when the grace period expires, taking the running task with it (docker-images#1564). +# +# TestStep and not TestAssert: kuttl retries asserts until they time out, which would turn every +# failure into a generic timeout. +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - timeout: 600 + script: | + set -eu + + selector="spark-role=executor,app.kubernetes.io/instance=graceful-shutdown" + # A signalled container is gone in 1-2s, an unsignalled one at the grace period (30s). + budget_seconds=20 + + victim=$(kubectl -n "$NAMESPACE" get pods -o name -l "$selector" | head -1 | cut -d/ -f2) + grace=$(kubectl -n "$NAMESPACE" get pod "$victim" -o jsonpath='{.spec.terminationGracePeriodSeconds}') + echo "stopping executor $victim (grace period: ${grace}s)" + + # The log cannot be read once the Pod object is gone, so stream it away first. + log=$(mktemp) + kubectl -n "$NAMESPACE" logs -f "$victim" -c spark >"$log" 2>&1 & + logs_pid=$! + sleep 2 + + kubectl -n "$NAMESPACE" delete pod "$victim" --wait=false + + start=$(date +%s) + stopped=no + while [ $(( $(date +%s) - start )) -lt "$budget_seconds" ]; do + terminated=$(kubectl -n "$NAMESPACE" get pod "$victim" \ + -o jsonpath='{.status.containerStatuses[?(@.name=="spark")].state.terminated.reason}' 2>/dev/null) \ + || { stopped=yes; break; } # Pod already removed + if [ -n "$terminated" ]; then + stopped=yes + break + fi + sleep 1 + done + elapsed=$(( $(date +%s) - start )) + sleep 1 + kill "$logs_pid" 2>/dev/null || true + + if [ "$stopped" != yes ]; then + echo "FAIL: the executor was still running ${elapsed}s after its Pod was deleted, so" + echo "SIGTERM did not reach the JVM and the kubelet will SIGKILL it after ${grace}s." + tail -5 "$log" + exit 1 + fi + + # Only a JVM that ran its shutdown hooks logs from `shutdown-hook-*` threads. + if ! grep -q 'shutdown-hook' "$log"; then + echo "FAIL: the executor stopped after ${elapsed}s but ran no shutdown hook." + tail -5 "$log" + exit 1 + fi + + rm -f "$log" + echo "OK: the executor shut down gracefully after ${elapsed}s" diff --git a/tests/templates/kuttl/graceful-shutdown/12-assert.yaml b/tests/templates/kuttl/graceful-shutdown/12-assert.yaml new file mode 100644 index 00000000..bf511321 --- /dev/null +++ b/tests/templates/kuttl/graceful-shutdown/12-assert.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +commands: + # Same check as in step 10: the replacement for the stopped executor has to be registered and + # working before the driver is stopped, otherwise it is not in the driver's list yet. + - script: | + set -eu + + registered=0 + for pod in $(kubectl -n "$NAMESPACE" get pods -o name \ + -l spark-role=executor,app.kubernetes.io/instance=graceful-shutdown | cut -d/ -f2); do + log=$(kubectl -n "$NAMESPACE" logs --tail=-1 "$pod" -c spark) + echo "$log" | grep -q 'Successfully registered with driver' + echo "$log" | grep -q 'Running task' + registered=$(( registered + 1 )) + done + test "$registered" -eq 2 diff --git a/tests/templates/kuttl/graceful-shutdown/13-check-driver-shutdown-propagation.yaml b/tests/templates/kuttl/graceful-shutdown/13-check-driver-shutdown-propagation.yaml new file mode 100644 index 00000000..7f4c4fb5 --- /dev/null +++ b/tests/templates/kuttl/graceful-shutdown/13-check-driver-shutdown-propagation.yaml @@ -0,0 +1,65 @@ +--- +# Deleting the driver Pod has to let the driver JVM stop the SparkContext, which is what asks the +# executors to shut down. Without SIGTERM reaching the driver it is SIGKILLed at the end of its +# grace period, the executors are never asked, and they keep working until the driver's RPC +# connection dies. +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - timeout: 600 + script: | + set -eu + + app=graceful-shutdown + # The stop command reaches registered executors about a second after the driver is signalled. + budget_seconds=20 + + capture=$(mktemp -d) + driver=$(kubectl -n "$NAMESPACE" get pods -o name \ + -l "spark-role=driver,app.kubernetes.io/instance=$app" | head -1 | cut -d/ -f2) + executors=$(kubectl -n "$NAMESPACE" get pods -o name \ + -l "spark-role=executor,app.kubernetes.io/instance=$app" | cut -d/ -f2) + echo "stopping driver $driver, expecting a shutdown at: $(echo "$executors" | tr '\n' ' ')" + + log_pids="" + for pod in $driver $executors; do + kubectl -n "$NAMESPACE" logs -f "$pod" -c spark >"$capture/$pod.log" 2>&1 & + log_pids="$log_pids $!" + done + sleep 2 + + kubectl -n "$NAMESPACE" delete pod "$driver" --wait=false + + start=$(date +%s) + missing=$executors + while [ $(( $(date +%s) - start )) -lt "$budget_seconds" ]; do + missing="" + for pod in $executors; do + grep -q 'Driver commanded a shutdown' "$capture/$pod.log" || missing="$missing $pod" + done + if [ -z "$missing" ]; then + break + fi + sleep 1 + done + elapsed=$(( $(date +%s) - start )) + sleep 1 + kill $log_pids 2>/dev/null || true + + if [ -n "$missing" ]; then + echo "FAIL: ${elapsed}s after the driver was stopped, these executors had not been asked" + echo "to shut down:$missing" + if grep -q 'Asking each executor to shut down' "$capture/$driver.log"; then + echo "The driver did ask, so the request was lost on the way to the executors:" + for pod in $missing; do + tail -3 "$capture/$pod.log" + done + else + echo "The driver ran no shutdown hook, so SIGTERM did not reach the driver JVM:" + tail -5 "$capture/$driver.log" + fi + exit 1 + fi + + rm -rf "$capture" + echo "OK: every executor was asked to shut down ${elapsed}s after the driver was stopped" diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 3522e1ec..0f498741 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -97,6 +97,10 @@ tests: dimensions: - spark - openshift + - name: graceful-shutdown + dimensions: + - spark + - openshift - name: pyspark-ny-public-s3 dimensions: - spark From bc93d051e295faa809c5f6e08f827d85017018bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCller?= Date: Wed, 29 Jul 2026 17:31:50 +0200 Subject: [PATCH 3/6] chore: cleanups --- .../kuttl/failure-propagation/00-assert.yaml | 9 ------ .../00-serviceaccount.yaml.j2 | 29 ------------------- .../kuttl/failure-propagation/12-assert.yaml | 13 +++++++-- .../11-check-executor-shutdown.yaml | 16 ++++++++++ .../13-check-driver-shutdown-propagation.yaml | 12 ++++++++ 5 files changed, 39 insertions(+), 40 deletions(-) delete mode 100644 tests/templates/kuttl/failure-propagation/00-assert.yaml delete mode 100644 tests/templates/kuttl/failure-propagation/00-serviceaccount.yaml.j2 diff --git a/tests/templates/kuttl/failure-propagation/00-assert.yaml b/tests/templates/kuttl/failure-propagation/00-assert.yaml deleted file mode 100644 index 5baf8caa..00000000 --- a/tests/templates/kuttl/failure-propagation/00-assert.yaml +++ /dev/null @@ -1,9 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -timeout: 900 ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: integration-tests-sa diff --git a/tests/templates/kuttl/failure-propagation/00-serviceaccount.yaml.j2 b/tests/templates/kuttl/failure-propagation/00-serviceaccount.yaml.j2 deleted file mode 100644 index 9cbf0351..00000000 --- a/tests/templates/kuttl/failure-propagation/00-serviceaccount.yaml.j2 +++ /dev/null @@ -1,29 +0,0 @@ ---- -kind: Role -apiVersion: rbac.authorization.k8s.io/v1 -metadata: - name: use-integration-tests-scc -rules: -{% if test_scenario['values']['openshift'] == "true" %} - - apiGroups: ["security.openshift.io"] - resources: ["securitycontextconstraints"] - resourceNames: ["privileged"] - verbs: ["use"] -{% endif %} ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: integration-tests-sa ---- -kind: RoleBinding -apiVersion: rbac.authorization.k8s.io/v1 -metadata: - name: use-integration-tests-scc -subjects: - - kind: ServiceAccount - name: integration-tests-sa -roleRef: - kind: Role - name: use-integration-tests-scc - apiGroup: rbac.authorization.k8s.io diff --git a/tests/templates/kuttl/failure-propagation/12-assert.yaml b/tests/templates/kuttl/failure-propagation/12-assert.yaml index 8e8c9e11..19880980 100644 --- a/tests/templates/kuttl/failure-propagation/12-assert.yaml +++ b/tests/templates/kuttl/failure-propagation/12-assert.yaml @@ -3,5 +3,14 @@ apiVersion: kuttl.dev/v1beta1 kind: TestAssert timeout: 300 commands: - # Test that there is no spark driver pod left hanging - - script: test -z "$(kubectl -n $NAMESPACE get pods -o name | grep -E 'driver$')" + # Test that there is no spark driver pod left hanging. The Pod list is captured in its own step so + # that a failing kubectl cannot be mistaken for "no driver Pod present". + - script: | + set -eu + + pods=$(kubectl -n "$NAMESPACE" get pods -o name) + if echo "$pods" | grep -qE 'driver$'; then + echo "FAIL: a driver Pod is still present after the application reached a terminal phase:" + echo "$pods" | grep -E 'driver$' + exit 1 + fi diff --git a/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml b/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml index 7a0246ad..432dad87 100644 --- a/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml +++ b/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml @@ -17,7 +17,23 @@ commands: budget_seconds=20 victim=$(kubectl -n "$NAMESPACE" get pods -o name -l "$selector" | head -1 | cut -d/ -f2) + if [ -z "$victim" ]; then + echo "FAIL: no executor Pod found to stop" + exit 1 + fi + grace=$(kubectl -n "$NAMESPACE" get pod "$victim" -o jsonpath='{.spec.terminationGracePeriodSeconds}') + # The budget has to stay inside the grace period. Otherwise the kubelet SIGKILLs the container + # before the budget expires, the Pod disappears, and the wait below reads that as a graceful + # stop -- the test would pass for exactly the behaviour it is meant to catch. + if [ -z "$grace" ] || [ "$grace" -le "$budget_seconds" ]; then + if [ -n "$grace" ]; then grace="${grace}s"; else grace="unset"; fi + echo "FAIL: the executor's termination grace period ($grace) is not above this" + echo "test's ${budget_seconds}s budget, so a SIGKILL cannot be told apart from a graceful" + echo "stop. Lower budget_seconds in this file." + exit 1 + fi + echo "stopping executor $victim (grace period: ${grace}s)" # The log cannot be read once the Pod object is gone, so stream it away first. diff --git a/tests/templates/kuttl/graceful-shutdown/13-check-driver-shutdown-propagation.yaml b/tests/templates/kuttl/graceful-shutdown/13-check-driver-shutdown-propagation.yaml index 7f4c4fb5..683d94e5 100644 --- a/tests/templates/kuttl/graceful-shutdown/13-check-driver-shutdown-propagation.yaml +++ b/tests/templates/kuttl/graceful-shutdown/13-check-driver-shutdown-propagation.yaml @@ -19,6 +19,18 @@ commands: -l "spark-role=driver,app.kubernetes.io/instance=$app" | head -1 | cut -d/ -f2) executors=$(kubectl -n "$NAMESPACE" get pods -o name \ -l "spark-role=executor,app.kubernetes.io/instance=$app" | cut -d/ -f2) + + # Without these the loop below has nothing to wait for and would report success while having + # checked nothing at all. + if [ -z "$driver" ]; then + echo "FAIL: no driver Pod found for $app" + exit 1 + fi + if [ -z "$executors" ]; then + echo "FAIL: no executor Pods found for $app, so no shutdown can be propagated to any" + exit 1 + fi + echo "stopping driver $driver, expecting a shutdown at: $(echo "$executors" | tr '\n' ' ')" log_pids="" From b317645e08d9cee3830e4ec06e9160fbd88cbdbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCller?= Date: Wed, 29 Jul 2026 19:18:17 +0200 Subject: [PATCH 4/6] chore: address review comments --- .../11-check-executor-shutdown.yaml | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml b/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml index 432dad87..a67f4682 100644 --- a/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml +++ b/tests/templates/kuttl/graceful-shutdown/11-check-executor-shutdown.yaml @@ -13,7 +13,6 @@ commands: set -eu selector="spark-role=executor,app.kubernetes.io/instance=graceful-shutdown" - # A signalled container is gone in 1-2s, an unsignalled one at the grace period (30s). budget_seconds=20 victim=$(kubectl -n "$NAMESPACE" get pods -o name -l "$selector" | head -1 | cut -d/ -f2) @@ -22,19 +21,7 @@ commands: exit 1 fi - grace=$(kubectl -n "$NAMESPACE" get pod "$victim" -o jsonpath='{.spec.terminationGracePeriodSeconds}') - # The budget has to stay inside the grace period. Otherwise the kubelet SIGKILLs the container - # before the budget expires, the Pod disappears, and the wait below reads that as a graceful - # stop -- the test would pass for exactly the behaviour it is meant to catch. - if [ -z "$grace" ] || [ "$grace" -le "$budget_seconds" ]; then - if [ -n "$grace" ]; then grace="${grace}s"; else grace="unset"; fi - echo "FAIL: the executor's termination grace period ($grace) is not above this" - echo "test's ${budget_seconds}s budget, so a SIGKILL cannot be told apart from a graceful" - echo "stop. Lower budget_seconds in this file." - exit 1 - fi - - echo "stopping executor $victim (grace period: ${grace}s)" + echo "stopping executor $victim" # The log cannot be read once the Pod object is gone, so stream it away first. log=$(mktemp) @@ -42,37 +29,33 @@ commands: logs_pid=$! sleep 2 + if ! grep -q 'Running task' "$log"; then + echo "FAIL: could not stream the log of $victim, so its shutdown cannot be observed" + tail -5 "$log" + exit 1 + fi + kubectl -n "$NAMESPACE" delete pod "$victim" --wait=false start=$(date +%s) stopped=no while [ $(( $(date +%s) - start )) -lt "$budget_seconds" ]; do - terminated=$(kubectl -n "$NAMESPACE" get pod "$victim" \ - -o jsonpath='{.status.containerStatuses[?(@.name=="spark")].state.terminated.reason}' 2>/dev/null) \ - || { stopped=yes; break; } # Pod already removed - if [ -n "$terminated" ]; then + if grep -q 'shutdown-hook' "$log"; then stopped=yes break fi sleep 1 done elapsed=$(( $(date +%s) - start )) - sleep 1 kill "$logs_pid" 2>/dev/null || true if [ "$stopped" != yes ]; then - echo "FAIL: the executor was still running ${elapsed}s after its Pod was deleted, so" - echo "SIGTERM did not reach the JVM and the kubelet will SIGKILL it after ${grace}s." - tail -5 "$log" - exit 1 - fi - - # Only a JVM that ran its shutdown hooks logs from `shutdown-hook-*` threads. - if ! grep -q 'shutdown-hook' "$log"; then - echo "FAIL: the executor stopped after ${elapsed}s but ran no shutdown hook." + echo "FAIL: ${elapsed}s after its Pod was deleted the executor had run no shutdown hook," + echo "so SIGTERM did not reach the JVM and the kubelet will SIGKILL it at the end of the" + echo "grace period, killing the task it is running." tail -5 "$log" exit 1 fi rm -f "$log" - echo "OK: the executor shut down gracefully after ${elapsed}s" + echo "OK: the executor ran its shutdown hooks ${elapsed}s after its Pod was deleted" From cf6402c7d78fd707ca49d4d11fbf9f5b112d7339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCller?= Date: Wed, 29 Jul 2026 19:22:52 +0200 Subject: [PATCH 5/6] chore: rename failure-propagation to signal-propagation --- .../00-patch-ns.yaml.j2 | 0 .../01-assert.yaml.j2 | 0 .../01-install-vector-aggregator-discovery-configmap.yaml.j2 | 0 .../10-deploy-failing-spark-app.yaml.j2 | 0 .../11-check-phase.yaml | 0 .../{failure-propagation => signal-propagation}/12-assert.yaml | 0 tests/test-definition.yaml | 2 +- 7 files changed, 1 insertion(+), 1 deletion(-) rename tests/templates/kuttl/{failure-propagation => signal-propagation}/00-patch-ns.yaml.j2 (100%) rename tests/templates/kuttl/{failure-propagation => signal-propagation}/01-assert.yaml.j2 (100%) rename tests/templates/kuttl/{failure-propagation => signal-propagation}/01-install-vector-aggregator-discovery-configmap.yaml.j2 (100%) rename tests/templates/kuttl/{failure-propagation => signal-propagation}/10-deploy-failing-spark-app.yaml.j2 (100%) rename tests/templates/kuttl/{failure-propagation => signal-propagation}/11-check-phase.yaml (100%) rename tests/templates/kuttl/{failure-propagation => signal-propagation}/12-assert.yaml (100%) diff --git a/tests/templates/kuttl/failure-propagation/00-patch-ns.yaml.j2 b/tests/templates/kuttl/signal-propagation/00-patch-ns.yaml.j2 similarity index 100% rename from tests/templates/kuttl/failure-propagation/00-patch-ns.yaml.j2 rename to tests/templates/kuttl/signal-propagation/00-patch-ns.yaml.j2 diff --git a/tests/templates/kuttl/failure-propagation/01-assert.yaml.j2 b/tests/templates/kuttl/signal-propagation/01-assert.yaml.j2 similarity index 100% rename from tests/templates/kuttl/failure-propagation/01-assert.yaml.j2 rename to tests/templates/kuttl/signal-propagation/01-assert.yaml.j2 diff --git a/tests/templates/kuttl/failure-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/signal-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 similarity index 100% rename from tests/templates/kuttl/failure-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 rename to tests/templates/kuttl/signal-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 diff --git a/tests/templates/kuttl/failure-propagation/10-deploy-failing-spark-app.yaml.j2 b/tests/templates/kuttl/signal-propagation/10-deploy-failing-spark-app.yaml.j2 similarity index 100% rename from tests/templates/kuttl/failure-propagation/10-deploy-failing-spark-app.yaml.j2 rename to tests/templates/kuttl/signal-propagation/10-deploy-failing-spark-app.yaml.j2 diff --git a/tests/templates/kuttl/failure-propagation/11-check-phase.yaml b/tests/templates/kuttl/signal-propagation/11-check-phase.yaml similarity index 100% rename from tests/templates/kuttl/failure-propagation/11-check-phase.yaml rename to tests/templates/kuttl/signal-propagation/11-check-phase.yaml diff --git a/tests/templates/kuttl/failure-propagation/12-assert.yaml b/tests/templates/kuttl/signal-propagation/12-assert.yaml similarity index 100% rename from tests/templates/kuttl/failure-propagation/12-assert.yaml rename to tests/templates/kuttl/signal-propagation/12-assert.yaml diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 0f498741..0a693ff7 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -93,7 +93,7 @@ tests: dimensions: - spark - openshift - - name: failure-propagation + - name: signal-propagation dimensions: - spark - openshift From 5051b27cb833f9e79fbe388d743b8a0ef7f0302e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCller?= Date: Thu, 30 Jul 2026 17:25:55 +0200 Subject: [PATCH 6/6] chore: add vector dimension to make sure post hook is called --- .../signal-propagation/01-assert.yaml.j2 | 2 +- ...tor-aggregator-discovery-configmap.yaml.j2 | 7 ++-- .../10-deploy-failing-spark-app.yaml.j2 | 6 ++-- .../signal-propagation/11-check-phase.yaml | 32 +++++++++++++++++++ tests/test-definition.yaml | 8 +++++ 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/tests/templates/kuttl/signal-propagation/01-assert.yaml.j2 b/tests/templates/kuttl/signal-propagation/01-assert.yaml.j2 index 50b1d4c3..aa13050d 100644 --- a/tests/templates/kuttl/signal-propagation/01-assert.yaml.j2 +++ b/tests/templates/kuttl/signal-propagation/01-assert.yaml.j2 @@ -1,7 +1,7 @@ --- apiVersion: kuttl.dev/v1beta1 kind: TestAssert -{% if lookup('env', 'VECTOR_AGGREGATOR') %} +{% if test_scenario['values']['vector'] == 'true' %} --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/signal-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/signal-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 index 2d6a0df5..ff3b36d0 100644 --- a/tests/templates/kuttl/signal-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 +++ b/tests/templates/kuttl/signal-propagation/01-install-vector-aggregator-discovery-configmap.yaml.j2 @@ -1,9 +1,12 @@ -{% if lookup('env', 'VECTOR_AGGREGATOR') %} +{% if test_scenario['values']['vector'] == 'true' %} --- apiVersion: v1 kind: ConfigMap metadata: name: vector-aggregator-discovery data: - ADDRESS: {{ lookup('env', 'VECTOR_AGGREGATOR') }} + # This test only asserts that the Vector agent is shut down again, not that the log events + # arrive anywhere, so it does not need a reachable aggregator. If one is provided it is used, + # otherwise the agent runs and logs connection errors, which does not affect the assertions. + ADDRESS: {{ lookup('env', 'VECTOR_AGGREGATOR') or 'localhost:6123' }} {% endif %} diff --git a/tests/templates/kuttl/signal-propagation/10-deploy-failing-spark-app.yaml.j2 b/tests/templates/kuttl/signal-propagation/10-deploy-failing-spark-app.yaml.j2 index 26265b04..01781890 100644 --- a/tests/templates/kuttl/signal-propagation/10-deploy-failing-spark-app.yaml.j2 +++ b/tests/templates/kuttl/signal-propagation/10-deploy-failing-spark-app.yaml.j2 @@ -22,7 +22,7 @@ kind: SparkApplication metadata: name: failure-propagation spec: -{% if lookup('env', 'VECTOR_AGGREGATOR') %} +{% if test_scenario['values']['vector'] == 'true' %} vectorAggregatorConfigMapName: vector-aggregator-discovery {% endif %} sparkImage: @@ -38,7 +38,7 @@ spec: driver: config: logging: - enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + enableVectorAgent: {{ test_scenario['values']['vector'] }} volumeMounts: - name: script mountPath: /stackable/spark/jobs @@ -48,7 +48,7 @@ spec: replicas: 1 config: logging: - enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + enableVectorAgent: {{ test_scenario['values']['vector'] }} volumes: - name: script configMap: diff --git a/tests/templates/kuttl/signal-propagation/11-check-phase.yaml b/tests/templates/kuttl/signal-propagation/11-check-phase.yaml index d6151536..3f8ce2f0 100644 --- a/tests/templates/kuttl/signal-propagation/11-check-phase.yaml +++ b/tests/templates/kuttl/signal-propagation/11-check-phase.yaml @@ -14,9 +14,20 @@ commands: timeout_seconds=900 interval_seconds=5 + # How long the application may still take to reach a terminal phase after the Spark + # container itself has terminated. The post hook sleeps 10 seconds before telling Vector + # to shut down, so this only has to be comfortably above that. + grace_seconds=120 + + driver_containers() { + kubectl -n "$NAMESPACE" get pods -l spark-role=driver \ + -o jsonpath='{range .items[*].status.containerStatuses[*]}{.name}={.state}{"\n"}{end}' \ + 2>/dev/null || true + } phase="" elapsed=0 + spark_done_at=-1 while [ "$elapsed" -lt "$timeout_seconds" ]; do phase=$(kubectl -n "$NAMESPACE" get sparkapplication failure-propagation \ -o jsonpath='{.status.phase}' 2>/dev/null || true) @@ -34,9 +45,30 @@ commands: ;; esac + # Once the Spark container has terminated, the Pod has to follow shortly after. + # If it does not, the entrypoint skipped its post hook after the Spark process + # exited, so a sidecar was never told to shut down and the Pod can never reach + # a terminal phase at all. + containers=$(driver_containers) + if echo "$containers" | grep -q '^spark=.*terminated'; then + if [ "$spark_done_at" -lt 0 ]; then + spark_done_at="$elapsed" + elif [ "$((elapsed - spark_done_at))" -ge "$grace_seconds" ]; then + echo "FAIL: the Spark container terminated ${grace_seconds}s ago, but the application" + echo "is still in phase '${phase}'. The entrypoint did not run its post hook after the" + echo "Spark process exited, so a sidecar was never told to shut down and the Pod cannot" + echo "terminate (see spark-k8s/stackable/run-spark.sh)." + echo "Driver container states:" + echo "$containers" | sed 's/^/ /' + exit 1 + fi + fi + sleep "$interval_seconds" elapsed=$((elapsed + interval_seconds)) done echo "FAIL: no terminal phase after ${timeout_seconds}s (last seen: '${phase}')" + echo "Driver container states:" + driver_containers | sed 's/^/ /' exit 1 diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 0a693ff7..3b373261 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -11,6 +11,13 @@ dimensions: # Alternatively, if you want to use a custom image, append a comma and the full image name to the product version # as in the example below. # - 3.5.6,oci.stackable.tech/sandbox/spark-k8s:3.5.6-stackable0.0.0-dev + # Whether the Vector agent is enabled. With it enabled the operator sets a post hook on the + # product container, which the entrypoint has to run even when the application failed. If it + # does not, the agent is never shut down and the Pod cannot reach a terminal phase. + - name: vector + values: + - "false" + - "true" - name: spark-logging values: - 3.5.8 @@ -96,6 +103,7 @@ tests: - name: signal-propagation dimensions: - spark + - vector - openshift - name: graceful-shutdown dimensions: