Skip to content

Commit bcea2b2

Browse files
sarutakdongjoon-hyun
authored andcommitted
[SPARK-57900][K8S][TEST] Add OIDC credential propagation E2E tests on Minikube with moto
### What changes were proposed in this pull request? Add a new optional integration-test module, `connector/credential-aws-integration-tests`, that validates the end-to-end OIDC credential propagation pipeline on a real Kubernetes cluster (Minikube). This is Sub-task 11 of the OIDC Credential Propagation SPIP ([SPARK-57703](https://issues.apache.org/jira/browse/SPARK-57703)), and it exercises the whole feature together: projected ServiceAccount token -> `FileTokenIngestor` -> `AwsStsCredentialProvider` -> STS -> S3A read/write, plus mid-job token rotation and late-registering executors. The tests use [moto](https://github.com/getmoto/moto) (Apache 2.0-licensed) as a lightweight S3 + STS backend. The original SPIP mentioned LocalStack, but both LocalStack and MinIO have moved away from freely usable OSS distributions and are incompatible with the ASF license policy. moto runs as a plain HTTP server (no extra container) and does not verify the OIDC JWT, keeping the test focused on Spark's credential propagation logic. **Three scenarios are implemented:** 1. **Basic flow** (`OidcS3ReadWriteJob`): a Spark job on Minikube exchanges the identity token for STS credentials and reads/writes S3 via S3A. 2. **Mid-job token rotation** (`OidcTokenRotationJob`): a long-running job writes to S3 repeatedly while the test rewrites the identity token file in the driver pod. The initial token is supplied by an init container into an emptyDir (an externally-provided, rotatable token file, as the SPIP assumes). The rotated token carries a *different* principal; with a short renewal interval, `UserCredentialManager` re-reads it, re-exchanges it via STS, and propagates fresh credentials. The test asserts the driver logged the rotated principal (proving the new token was actually read, not a no-op) and that S3 output for all iterations spanning the rotation is present. 3. **Late-registering executor** (`OidcLateExecutorJob`): with dynamic allocation and a short idle timeout, a job warms up, idles until executors scale down, then runs a wider stage that forces new executors to register *after* credentials were acquired. The test asserts more than one distinct executor registered over the run (evidence of a genuinely late-registering executor) and that the wide stage produced all outputs — an executor that did not receive credentials via the `SparkAppConfig` registration response would have failed its task. **Structure and design:** - The module is gated behind the `-Poidc-e2e` Maven profile (and requires `-Pkubernetes`), so it is skipped by default. - Jobs that run on the cluster live in `src/main` so they are packaged into the module jar and baked into the Spark image; test classes are not packaged. - S3A support (hadoop-aws + AWS SDK) is provided by building the image with `-Phadoop-cloud`. - Image building is handled by an explicit step (`docker-image-tool.sh`) in CI and by `dev-run-integration-tests.sh` locally, rather than being bound to the sbt test task. - The spark-submit helpers (`SparkAppLauncher`, `SparkAppConf`, `SparkAppArguments`, `ProcessUtils`) are implemented locally instead of depending on the `spark-kubernetes-integration-tests` test-jar, which sbt could not resolve as an inter-project reference. They mirror the equivalents there. - moto is reached from two vantage points: pods use the host gateway IP (`spark.oidc.test.s3Endpoint` / `stsEndpoint`), while the test process uses loopback (`spark.oidc.test.s3ClientEndpoint`). In CI, moto is installed into an isolated virtualenv (to avoid the OS-provided urllib3/pyOpenSSL that crashes moto on startup) and started inside the same workflow step that runs the tests. **New files:** - `connector/credential-aws-integration-tests/` — module with the test suite (`OidcCredentialE2ESuite`), the three Spark jobs, spark-submit helpers, `pom.xml`, `log4j2.properties`, a local runner script (`dev-run-integration-tests.sh`), and `README.md`. **Modified files:** - `pom.xml` (root) — add the `oidc-e2e` profile / module. - `project/SparkBuild.scala` — register `credentialAwsIntegrationTests`. - `.github/workflows/build_and_test.yml` — add the `oidc-e2e` job (moto + Minikube). ### Why are the changes needed? The SPIP calls for an end-to-end test that validates the full credential propagation pipeline in a realistic Kubernetes environment. The prior sub-tasks each cover a slice with unit/integration tests, but nothing exercised the entire flow — token ingestion, STS exchange, RPC + SparkAppConfig propagation, S3A read/write, and mid-job refresh — against a real cluster. This module provides that coverage and guards against regressions in how the pieces fit together. ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? This *is* the test. The suite was run on a local Minikube (with moto) under both build tools and all three scenarios passed: - sbt: `build/sbt -Phadoop-3 -Pkubernetes -Pcredential-aws -Poidc-e2e ... credential-aws-integration-tests/test` - Maven: `build/mvn integration-test -pl connector/credential-aws-integration-tests -Phadoop-3 -Pkubernetes -Pcredential-aws -Poidc-e2e ...` The new `oidc-e2e` GitHub Actions job (Minikube + moto) is green. `dev-run-integration-tests.sh` was also verified to build the image, start/stop moto, and run the suite end-to-end. ### Was this patch authored or co-authored using generative AI tooling? Kiro CLI / Claude Closes #58426 from sarutak/oidc-propagation/e2e-tests. Authored-by: Kousuke Saruta <sarutak@apache.org> Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
1 parent 77f1e05 commit bcea2b2

17 files changed

Lines changed: 2186 additions & 4 deletions

File tree

.github/workflows/build_and_test.yml

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ jobs:
116116
if [[ "${{ github.repository }}" != 'apache/spark' ]]; then
117117
yarn=`./dev/is-changed.py -m yarn`
118118
kubernetes=`./dev/is-changed.py -m kubernetes`
119+
# The OIDC E2E job covers connector/credential-aws end to end on K8s, so it
120+
# must run when either the Kubernetes support or the credential-aws module
121+
# (including its integration-tests module, mapped to credential-aws) changes.
122+
oidc_e2e=`./dev/is-changed.py -m kubernetes,credential-aws`
119123
sparkr=`./dev/is-changed.py -m sparkr`
120124
tpcds=`./dev/is-changed.py -m sql`
121125
docker=`./dev/is-changed.py -m docker-integration-tests`
@@ -156,6 +160,7 @@ jobs:
156160
pandas=false
157161
yarn=false
158162
kubernetes=false
163+
oidc_e2e=false
159164
sparkr=false
160165
tpcds=false
161166
docker=false
@@ -182,6 +187,7 @@ jobs:
182187
\"docs\" : \"$docs\",
183188
\"yarn\" : \"$yarn\",
184189
\"k8s-integration-tests\" : \"$kubernetes\",
190+
\"oidc-e2e\" : \"$oidc_e2e\",
185191
\"buf\" : \"$buf\",
186192
\"ui\" : \"$ui\",
187193
\"transpile\": \"$transpile\",
@@ -541,6 +547,7 @@ jobs:
541547
fromJson(needs.precondition.outputs.required).sparkr == 'true' ||
542548
fromJson(needs.precondition.outputs.required).docker-integration-tests == 'true' ||
543549
fromJson(needs.precondition.outputs.required).k8s-integration-tests == 'true' ||
550+
fromJson(needs.precondition.outputs.required).oidc-e2e == 'true' ||
544551
fromJson(needs.precondition.outputs.required).tpcds-1g == 'true')
545552
name: "Precompile Spark"
546553
runs-on: ubuntu-latest
@@ -582,7 +589,7 @@ jobs:
582589
run: |
583590
./build/sbt -Phadoop-3 -Pyarn -Pspark-ganglia-lgpl -Phadoop-cloud -Phive \
584591
-Pkubernetes -Pjvm-profiler -Pkinesis-asl -Pcredential-aws -Phive-thriftserver \
585-
-Pdocker-integration-tests -Pkubernetes-integration-tests -Pvolcano \
592+
-Pdocker-integration-tests -Pkubernetes-integration-tests -Pvolcano -Poidc-e2e \
586593
Test/package streaming-kinesis-asl-assembly/assembly connect/assembly assembly/package
587594
- name: Package compile output
588595
run: |
@@ -1447,6 +1454,171 @@ jobs:
14471454
name: spark-on-kubernetes-it-log
14481455
path: "**/target/integration-tests.log"
14491456

1457+
oidc-e2e:
1458+
needs: [precondition, precompile]
1459+
if: fromJson(needs.precondition.outputs.required).oidc-e2e == 'true'
1460+
name: Run OIDC E2E integration tests (Minikube + moto)
1461+
runs-on: ubuntu-latest
1462+
timeout-minutes: 120
1463+
steps:
1464+
- name: Bootstrap composite actions
1465+
uses: actions/checkout@v6
1466+
- name: Checkout and sync Spark repository
1467+
uses: ./.github/actions/checkout-and-sync
1468+
with:
1469+
ref: ${{ needs.precondition.outputs.head_sha }}
1470+
- name: Cache SBT and Maven
1471+
uses: actions/cache@v5
1472+
with:
1473+
path: |
1474+
build/apache-maven-*
1475+
build/*.jar
1476+
~/.sbt
1477+
key: build-${{ runner.os }}-${{ hashFiles('**/pom.xml', 'project/build.properties', 'build/mvn', 'build/sbt', 'build/sbt-launch-lib.bash', 'build/spark-build-info') }}
1478+
restore-keys: |
1479+
build-${{ runner.os }}-
1480+
- name: Restore Coursier local repository
1481+
uses: actions/cache/restore@v5
1482+
with:
1483+
path: ~/.cache/coursier
1484+
key: coursier-${{ runner.os }}-${{ hashFiles('**/pom.xml', '**/plugins.sbt') }}
1485+
restore-keys: |
1486+
coursier-${{ runner.os }}-
1487+
- name: Free up disk space
1488+
timeout-minutes: 10
1489+
continue-on-error: true
1490+
run: |
1491+
if [ -f ./dev/free_disk_space ]; then
1492+
./dev/free_disk_space
1493+
fi
1494+
- name: Install Java ${{ inputs.java }}
1495+
uses: actions/setup-java@v5
1496+
with:
1497+
distribution: zulu
1498+
java-version: ${{ inputs.java }}
1499+
- name: Download precompiled artifact
1500+
uses: actions/download-artifact@v8
1501+
with:
1502+
name: spark-compile-${{ inputs.branch }}-${{ github.run_id }}
1503+
- name: Extract precompiled artifact
1504+
run: |
1505+
zstd -dc compile-artifact.tar.zst | tar -xf -
1506+
rm compile-artifact.tar.zst
1507+
- name: Install Python and moto
1508+
run: |
1509+
sudo apt-get update -qq
1510+
sudo apt-get install -y python3-venv
1511+
# Install moto into an isolated virtualenv. A plain "pip install --user"
1512+
# leaves the OS-provided packages under /usr/lib/python3/dist-packages on
1513+
# the import path, and that copy of pyOpenSSL is incompatible with the
1514+
# cryptography build pulled in transitively, which crashes moto on startup
1515+
# (AttributeError: module 'lib' has no attribute 'GEN_EMAIL'). A venv gives
1516+
# moto a clean, self-consistent dependency set.
1517+
python3 -m venv "${RUNNER_TEMP}/moto-venv"
1518+
"${RUNNER_TEMP}/moto-venv/bin/pip" install --upgrade pip
1519+
"${RUNNER_TEMP}/moto-venv/bin/pip" install "moto[server,s3,sts]>=5.0.0,<6.0.0"
1520+
echo "MOTO_PYTHON=${RUNNER_TEMP}/moto-venv/bin/python" >> $GITHUB_ENV
1521+
- name: Start Minikube
1522+
uses: medyagh/setup-minikube@e9e035a86bbc3caea26a450bd4dbf9d0c453682e # v0.0.21
1523+
with:
1524+
kubernetes-version: "1.36.0"
1525+
cpus: 2
1526+
memory: 6144m
1527+
- name: Print K8S pods and nodes info
1528+
run: |
1529+
kubectl get pods -A
1530+
kubectl describe node
1531+
- name: Resolve moto host IP reachable from Minikube pods
1532+
run: |
1533+
# The host gateway IP as seen from inside Minikube
1534+
HOST_IP=$(minikube ssh "ip route | grep default | awk '{print \$3}'" | tr -d '[:space:]')
1535+
echo "MOTO_HOST_IP=${HOST_IP}" >> $GITHUB_ENV
1536+
echo "Moto reachable from Minikube at: http://${HOST_IP}:5000"
1537+
- name: Build and load Spark image into Minikube
1538+
run: |
1539+
eval $(minikube docker-env)
1540+
# Build the base Spark Docker image using the precompiled artifact. The
1541+
# precompile step builds with -Phadoop-cloud, so hadoop-aws and the AWS SDK
1542+
# (needed for S3A) are included in /opt/spark/jars.
1543+
./bin/docker-image-tool.sh \
1544+
-r docker.io/kubespark \
1545+
-t oidc-e2e-test \
1546+
build
1547+
# The job class (OidcS3ReadWriteJob) lives in this module's jar, which is not
1548+
# part of the assembly and is not copied into the image by docker-image-tool.sh
1549+
# (it only copies examples/jars). SparkSubmit already puts a local:// primary
1550+
# resource on the driver classpath, so this is not a classpath gap -- the jar
1551+
# simply has to be present in the container, so bake it into /opt/spark/jars.
1552+
JOB_JAR=$(ls connector/credential-aws-integration-tests/target/scala-*/spark-credential-aws-integration-tests_*.jar | grep -v -- '-tests.jar' | head -1)
1553+
JOB_JAR_NAME=$(basename "$JOB_JAR")
1554+
BUILD_CTX=$(mktemp -d)
1555+
cp "$JOB_JAR" "$BUILD_CTX/"
1556+
cat > "$BUILD_CTX/Dockerfile" <<EOF
1557+
FROM docker.io/kubespark/spark:oidc-e2e-test
1558+
COPY ${JOB_JAR_NAME} /opt/spark/jars/${JOB_JAR_NAME}
1559+
EOF
1560+
docker build -t docker.io/kubespark/spark:oidc-e2e-test-job "$BUILD_CTX"
1561+
rm -rf "$BUILD_CTX"
1562+
export SPARK_IMAGE="docker.io/kubespark/spark:oidc-e2e-test-job"
1563+
echo "SPARK_IMAGE=${SPARK_IMAGE}" >> $GITHUB_ENV
1564+
- name: Run OIDC E2E integration tests
1565+
run: |
1566+
# moto must be started in the same step as the test run: GitHub Actions
1567+
# terminates background processes when a step's shell exits, so a moto
1568+
# started in a previous step would already be dead here.
1569+
# Use the isolated venv python so moto runs against a clean, self-consistent
1570+
# dependency set (avoids the broken OS-provided pyOpenSSL).
1571+
"${MOTO_PYTHON}" -m moto.server -H 0.0.0.0 -p 5000 > /tmp/moto.log 2>&1 &
1572+
MOTO_PID=$!
1573+
trap "kill ${MOTO_PID} 2>/dev/null || true" EXIT
1574+
# Wait for moto to be ready, and fail loudly (with logs) if it never comes up.
1575+
ready=false
1576+
for i in $(seq 1 30); do
1577+
if curl -sf http://127.0.0.1:5000/ >/dev/null 2>&1; then
1578+
ready=true
1579+
break
1580+
fi
1581+
if ! kill -0 ${MOTO_PID} 2>/dev/null; then
1582+
echo "ERROR: moto server process exited early." >&2
1583+
cat /tmp/moto.log >&2
1584+
exit 1
1585+
fi
1586+
sleep 1
1587+
done
1588+
if [ "${ready}" != "true" ]; then
1589+
echo "ERROR: moto server did not become ready within 30s." >&2
1590+
cat /tmp/moto.log >&2
1591+
exit 1
1592+
fi
1593+
echo "moto server is ready on 127.0.0.1:5000 (PID ${MOTO_PID})."
1594+
# Pods inside Minikube reach moto via the host gateway IP; the test
1595+
# process (on the host) reaches the same moto server on loopback.
1596+
MOTO_ENDPOINT="http://${MOTO_HOST_IP}:5000"
1597+
MOTO_CLIENT_ENDPOINT="http://127.0.0.1:5000"
1598+
# Grant cluster-admin to all service accounts. This is intentionally broad
1599+
# but scoped to this ephemeral, single-run CI Minikube cluster (destroyed
1600+
# with the runner). It mirrors kubernetes-integration-tests and spares the
1601+
# test from provisioning fine-grained RBAC (pod create/exec/log, namespace
1602+
# management) for the many service accounts Spark-on-K8s uses. Do NOT copy
1603+
# this into any shared or long-lived cluster; there, create a minimal Role
1604+
# limited to the test namespace instead.
1605+
kubectl create clusterrolebinding serviceaccounts-cluster-admin \
1606+
--clusterrole=cluster-admin --group=system:serviceaccounts || true
1607+
build/sbt \
1608+
-Phadoop-3 -Pkubernetes -Pcredential-aws -Poidc-e2e \
1609+
-Dspark.kubernetes.test.deployMode=minikube \
1610+
-Dspark.oidc.test.stsEndpoint="${MOTO_ENDPOINT}" \
1611+
-Dspark.oidc.test.s3Endpoint="${MOTO_ENDPOINT}" \
1612+
-Dspark.oidc.test.s3ClientEndpoint="${MOTO_CLIENT_ENDPOINT}" \
1613+
-Dspark.oidc.test.sparkImage="${SPARK_IMAGE}" \
1614+
"credential-aws-integration-tests/test"
1615+
- name: Upload OIDC E2E integration tests log files
1616+
if: ${{ !success() }}
1617+
uses: actions/upload-artifact@v7
1618+
with:
1619+
name: oidc-e2e-it-log
1620+
path: "**/target/integration-tests.log"
1621+
14501622
ui:
14511623
needs: [precondition]
14521624
if: fromJson(needs.precondition.outputs.required).ui == 'true'

.github/workflows/build_java17.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,6 @@ jobs:
4545
"tpcds-1g": "true",
4646
"docker-integration-tests": "true",
4747
"k8s-integration-tests": "true",
48+
"oidc-e2e": "true",
4849
"lint" : "true"
4950
}

.github/workflows/build_java21.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
"docker-integration-tests": "true",
5353
"yarn": "true",
5454
"k8s-integration-tests": "true",
55+
"oidc-e2e": "true",
5556
"buf": "true",
5657
"ui": "true"
5758
}

.github/workflows/build_java25.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
"docker-integration-tests": "true",
5353
"yarn": "true",
5454
"k8s-integration-tests": "true",
55+
"oidc-e2e": "true",
5556
"buf": "true",
5657
"ui": "true"
5758
}

0 commit comments

Comments
 (0)