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

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git


The following commit(s) were added to refs/heads/main by this push:
     new 1fe9e6f70 test(chaos): add a Kubernetes (kind) backend for the chaos 
harness (#2244)
1fe9e6f70 is described below

commit 1fe9e6f707653ff4e45f64371cf6b9e014f16a5b
Author: Akshay Chitneni <[email protected]>
AuthorDate: Sat Aug 15 08:32:12 2026 -0700

    test(chaos): add a Kubernetes (kind) backend for the chaos harness (#2244)
    
    * test(chaos): add a Kubernetes (kind) backend for the chaos harness
    
    Adds an opt-in Kubernetes backend so the chaos scenarios can run on a real
    kind cluster, alongside the existing multi-process harness. The scheduler 
runs
    as a Deployment behind a ClusterIP Service and executors as a labelled
    Deployment; the harness reaches the scheduler over a kubectl port-forward 
and
    fetches results through the scheduler's embedded flight proxy, so it never
    contacts executor pod IPs.
    
    Gated behind the 'k8s' cargo feature and CHAOS_BACKEND=kind, so the default
    cargo test and the process harness are unchanged.
    
    Includes:
    - src/k8s.rs: K8sCluster backend (kubectl-driven) with scale/kill verbs 
wired
      for future kill scenarios, diagnostics-on-timeout, and CHAOS_KEEP_NS.
    - tests/k8s.rs: baseline scenario asserting the cluster result matches local
      DataFusion.
    - chaos bins: host/bind/flight-proxy env wiring for running in pods.
    - dev/docker/chaos.Dockerfile (+ build script) compiled in-image so the 
binary
      matches the container architecture.
    - dev/chaos-kind.sh runbook script and a gated k8s-chaos CI workflow.
    
    * test(chaos): address PR #2244 review feedback
    
    - CI: install kind+kubectl via pinned curl (drop non-allowlisted actions);
      permissions: contents: read; keep namespaces on failure for log 
collection;
      fix Swatinem casing.
    - Backend: unique namespace per start(); refuse non-kind kube-context; clear
      shared-dir contents on start; scheduler /healthz probes; imagePullPolicy 
Never.
    - Image: strip symbols + no debuginfo (fixes ld OOM at link, shrinks image).
    - Scripts: /usr/bin/env bash shebangs.
    
    ---------
    
    Co-authored-by: Akshay Chitneni <[email protected]>
    Co-authored-by: Martin Grigorov <[email protected]>
---
 .github/workflows/k8s-chaos.yml          | 116 ++++++
 chaos-testing/Cargo.toml                 |   6 +
 chaos-testing/README.md                  |  54 +++
 chaos-testing/k8s/kind-config.yaml       |  39 ++
 chaos-testing/src/bin/chaos-executor.rs  |  42 ++-
 chaos-testing/src/bin/chaos-scheduler.rs |  15 +-
 chaos-testing/src/k8s.rs                 | 629 +++++++++++++++++++++++++++++++
 chaos-testing/src/lib.rs                 |   2 +
 chaos-testing/tests/k8s.rs               | 113 ++++++
 dev/build-chaos-docker.sh                |  31 ++
 dev/chaos-kind.sh                        | 171 +++++++++
 dev/docker/chaos.Dockerfile              |  58 +++
 12 files changed, 1272 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/k8s-chaos.yml b/.github/workflows/k8s-chaos.yml
new file mode 100644
index 000000000..92147aefe
--- /dev/null
+++ b/.github/workflows/k8s-chaos.yml
@@ -0,0 +1,116 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Kubernetes (kind) chaos scenarios. Gated: runs only when the chaos harness,
+# the engine, or the docker artifacts change, plus a nightly cron schedule and
+# on demand — it is deliberately off the per-PR critical path, since the
+# process-based chaos harness already covers HA on every PR.
+name: k8s-chaos
+
+on:
+  pull_request:
+    paths:
+      - "chaos-testing/**"
+      - "ballista/**"
+      - "Cargo.toml"
+      - "Cargo.lock"
+      - "rust-toolchain.toml"
+      - "dev/docker/chaos.Dockerfile"
+      - "dev/build-chaos-docker.sh"
+      - ".github/workflows/k8s-chaos.yml"
+      # docs-only changes cannot affect this job; exclusions are applied last
+      - "!**/*.md"
+  schedule:
+    # Nightly cron (not the Rust nightly toolchain — this job builds with 
stable).
+    - cron: "0 6 * * *"
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
+  cancel-in-progress: true
+
+# Minimal token: this job only reads the repository.
+permissions:
+  contents: read
+
+env:
+  # Pinned tool versions, installed via curl to avoid marketplace actions that
+  # must be on the ASF allowlist.
+  KIND_VERSION: v0.30.0
+  KUBECTL_VERSION: v1.31.4
+  CLUSTER_NAME: ballista-chaos
+  CHAOS_FIXTURE_DIR: /tmp/ballista-chaos-fixtures
+
+jobs:
+  k8s-chaos:
+    name: kind chaos scenarios
+    runs-on: ubuntu-latest
+    timeout-minutes: 45
+    steps:
+      - name: Checkout
+        uses: actions/[email protected]
+        with:
+          fetch-depth: 1
+
+      - name: Install protoc
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y protobuf-compiler
+
+      - name: Setup Rust toolchain
+        run: |
+          rustup update stable
+          rustup toolchain install stable
+          rustup default stable
+
+      - uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 
#2.9.1
+
+      - name: Install kind and kubectl
+        run: |
+          curl -fsSLo ./kind 
"https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64";
+          curl -fsSLo ./kubectl 
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl";
+          chmod +x ./kind ./kubectl
+          sudo mv ./kind ./kubectl /usr/local/bin/
+
+      - name: Create kind cluster
+        run: |
+          mkdir -p "${CHAOS_FIXTURE_DIR}"
+          kind create cluster --name "${CLUSTER_NAME}" --config 
chaos-testing/k8s/kind-config.yaml
+
+      - name: Build the chaos image
+        run: ./dev/build-chaos-docker.sh
+
+      - name: Load the chaos image into kind
+        run: kind load docker-image ballista-chaos:test --name 
"${CLUSTER_NAME}"
+
+      - name: Run the kind chaos scenarios
+        env:
+          CHAOS_BACKEND: kind
+          # Keep namespaces on failure so the diagnostics step below can 
collect
+          # logs — teardown would otherwise delete them. The runner is 
ephemeral.
+          CHAOS_KEEP_NS: "1"
+        run: |
+          cargo test -p ballista-chaos --features k8s --test k8s -- 
--test-threads=1 --nocapture
+
+      - name: Dump cluster state on failure
+        if: failure()
+        run: |
+          kubectl get pods -A -o wide || true
+          for ns in $(kubectl get ns -o name | grep chaos- || true); do
+            kubectl logs -n "${ns#namespace/}" -l app=ballista-scheduler 
--tail=-1 || true
+            kubectl logs -n "${ns#namespace/}" -l app=ballista-executor 
--all-containers --tail=-1 || true
+          done
diff --git a/chaos-testing/Cargo.toml b/chaos-testing/Cargo.toml
index 7b03cb0e2..e1e7e3f06 100644
--- a/chaos-testing/Cargo.toml
+++ b/chaos-testing/Cargo.toml
@@ -24,6 +24,12 @@ edition = { workspace = true }
 rust-version = { workspace = true }
 publish = false
 
+[features]
+# Enables the Kubernetes (kind) backend and its integration test. Off by 
default
+# so the process-based harness and default `cargo test` stay dependency-light 
and
+# require no cluster.
+k8s = []
+
 [dependencies]
 arrow = { workspace = true }
 ballista = { path = "../ballista/client" }
diff --git a/chaos-testing/README.md b/chaos-testing/README.md
index b4afbfc63..020d54b29 100644
--- a/chaos-testing/README.md
+++ b/chaos-testing/README.md
@@ -173,6 +173,60 @@ written under that cluster's own temp directory, in a 
`logs/` subdirectory
 (`TestCluster::log_dir()`). When a scenario fails, those logs are the first
 place to look for what the scheduler and executors were actually doing.
 
+## Running on Kubernetes (kind)
+
+The harness also has an opt-in Kubernetes backend (`K8sCluster`, in
+`src/k8s.rs`) that runs the scheduler and executors as pods in a local
+[kind](https://kind.sigs.k8s.io) cluster rather than as local processes. It is
+gated behind the `k8s` feature _and_ `CHAOS_BACKEND=kind`, so a plain `cargo
+test` never touches a cluster. Today it runs a single baseline scenario — a 
real
+query whose result must match plain local DataFusion — as a walking skeleton 
for
+the executor-kill scenarios the
+[#2029](https://github.com/apache/datafusion-ballista/issues/2029) follow-ups
+will add. It needs [Docker](https://docs.docker.com/get-docker/),
+[kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation), and
+`kubectl`.
+
+`dev/chaos-kind.sh` wraps the whole loop — build the image, create the cluster,
+load the image, run the tests:
+
+```sh
+dev/chaos-kind.sh                       # build image, create cluster, load, 
test
+dev/chaos-kind.sh test -- --nocapture   # re-run against an existing cluster
+dev/chaos-kind.sh down                  # delete the cluster
+```
+
+or run the steps by hand: `dev/build-chaos-docker.sh`, then `kind create 
cluster
+--config chaos-testing/k8s/kind-config.yaml`, `kind load docker-image
+ballista-chaos:test`, and `CHAOS_BACKEND=kind cargo test -p ballista-chaos
+--features k8s --test k8s -- --test-threads=1`. Behavior is tuned through
+`CHAOS_FIXTURE_DIR` (host dir shared into the pods, default
+`$HOME/.ballista-chaos-fixtures`), `CHAOS_KEEP_NS` (keep the namespace for
+`kubectl` inspection), `CLUSTER_NAME`, `KEEP_CLUSTER`, and `KIND_NODE_IMAGE`.
+
+Because the harness runs outside the cluster, a few pieces bridge the gap:
+
+- **Fixture sharing.** The harness writes the deterministic parquet fixture to
+  `CHAOS_FIXTURE_DIR` on the host; kind's `extraMounts` bind that path into the
+  node and both pods `hostPath`-mount it, so the path is identical on host, 
node,
+  and pod and the schema-inferring `CREATE EXTERNAL TABLE ... LOCATION` 
resolves
+  the same everywhere, with no object store. The directory lives under `$HOME`
+  because Docker Desktop shares the home dir into its VM but not `/tmp` (the
+  static `k8s/kind-config.yaml` uses `/tmp` for Linux CI; `dev/chaos-kind.sh`
+  generates a `$HOME` config for local use).
+- **Reaching the cluster.** The client talks to the scheduler's gRPC + REST
+  (both on one port) through a `kubectl port-forward`, and fetches query 
results
+  through the scheduler's embedded Flight proxy, so it never contacts executor
+  pod IPs directly.
+- **Pods.** Both expose `/healthz` + `/readyz` with liveness/readiness probes
+  (the scheduler's readiness uses `/healthz`, not `/readyz`, so its Service
+  routes before executors register), and use `imagePullPolicy: Never` since the
+  image is only ever `kind load`ed. The backend refuses any `kubectl` context
+  without a `kind-` prefix, since it creates and deletes namespaces.
+
+CI runs this on the gated `k8s-chaos` workflow (path-filtered plus nightly), 
not
+per-PR — the process harness above already covers HA on every PR.
+
 ## Scenarios
 
 Every scenario runs under both `ballista.planner.adaptive.enabled=false` (AQE
diff --git a/chaos-testing/k8s/kind-config.yaml 
b/chaos-testing/k8s/kind-config.yaml
new file mode 100644
index 000000000..71e2c7db0
--- /dev/null
+++ b/chaos-testing/k8s/kind-config.yaml
@@ -0,0 +1,39 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# kind cluster for the chaos harness's Kubernetes backend.
+#
+# The single control-plane node bind-mounts the fixture directory from the host
+# so the harness (running outside the cluster) and every pod see the identical
+# path. This static config uses /tmp and is intended for Linux CI, where the
+# kind node binds host paths directly.
+#
+# On macOS, use `dev/chaos-kind.sh`, which generates its own config with the
+# fixture dir under $HOME (`CHAOS_FIXTURE_DIR`): Docker Desktop reliably shares
+# the home directory into its VM, whereas the VM's /tmp is not the host's, so a
+# /tmp extraMount would not propagate host files into pods.
+#
+# The path must match CHAOS_FIXTURE_DIR used by the test (see k8s-chaos.yml).
+# The host directory must exist before `kind create cluster`:
+#   mkdir -p /tmp/ballista-chaos-fixtures
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+nodes:
+  - role: control-plane
+    extraMounts:
+      - hostPath: /tmp/ballista-chaos-fixtures
+        containerPath: /tmp/ballista-chaos-fixtures
diff --git a/chaos-testing/src/bin/chaos-executor.rs 
b/chaos-testing/src/bin/chaos-executor.rs
index 830d295ae..820eb48bc 100644
--- a/chaos-testing/src/bin/chaos-executor.rs
+++ b/chaos-testing/src/bin/chaos-executor.rs
@@ -23,7 +23,9 @@
 use ballista_executor::executor_process::{
     ExecutorProcessConfig, start_executor_process,
 };
+use ballista_executor::health::spawn_health_server;
 use chaos_testing::registry::chaos_function_registry;
+use std::net::SocketAddr;
 use std::sync::Arc;
 
 fn env_u16(key: &str) -> u16 {
@@ -38,10 +40,16 @@ async fn main() -> ballista_core::error::Result<()> {
     env_logger::init();
 
     let config = ExecutorProcessConfig {
-        bind_host: "127.0.0.1".to_string(),
+        // Loopback in the process harness; under Kubernetes the executor binds
+        // all interfaces, finds the scheduler by Service name, and advertises 
its
+        // own pod IP so the scheduler and peers can reach it for Arrow Flight.
+        bind_host: std::env::var("CHAOS_BIND_HOST")
+            .unwrap_or_else(|_| "127.0.0.1".into()),
+        external_host: std::env::var("CHAOS_EXECUTOR_EXTERNAL_HOST").ok(),
         port: env_u16("CHAOS_EXECUTOR_PORT"),
         grpc_port: env_u16("CHAOS_EXECUTOR_GRPC_PORT"),
-        scheduler_host: "127.0.0.1".to_string(),
+        scheduler_host: std::env::var("CHAOS_SCHEDULER_HOST")
+            .unwrap_or_else(|_| "127.0.0.1".into()),
         scheduler_port: env_u16("CHAOS_SCHEDULER_PORT"),
         scheduler_connect_timeout_seconds: 10,
         vcores: std::env::var("CHAOS_CONCURRENT_TASKS")
@@ -59,5 +67,33 @@ async fn main() -> ballista_core::error::Result<()> {
         ..Default::default()
     };
 
-    start_executor_process(Arc::new(config)).await
+    // Kubernetes health probes. `start_executor_process` (the library entry
+    // point) does not serve them — only the standalone binary does — so wire 
the
+    // HTTP probe server here on the config's shared `ExecutorHealth` handle,
+    // exactly as `ballista-executor`'s `bin/main.rs` does. `/healthz` is 
process
+    // liveness; `/readyz` reflects heartbeat state. Only started when
+    // `CHAOS_EXECUTOR_HEALTH_PORT` is set (the k8s manifest sets it), so the
+    // process-based `TestCluster` harness spawns no extra server.
+    let health_server = 
std::env::var("CHAOS_EXECUTOR_HEALTH_PORT").ok().map(|port| {
+        let port: u16 = port
+            .parse()
+            .unwrap_or_else(|e| panic!("CHAOS_EXECUTOR_HEALTH_PORT must be a 
u16: {e}"));
+        let addr: SocketAddr = format!("{}:{}", config.bind_host, port)
+            .parse()
+            .expect("health server address must parse");
+        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
+        let handle = spawn_health_server(addr, config.health.clone(), 
shutdown_rx);
+        (shutdown_tx, handle)
+    });
+
+    let result = start_executor_process(Arc::new(config)).await;
+
+    // Ask the health server to stop so the process can exit cleanly.
+    if let Some((shutdown_tx, handle)) = health_server {
+        let _ = shutdown_tx.send(());
+        if let Err(e) = handle.await {
+            log::warn!("health server task join error: {e}");
+        }
+    }
+    result
 }
diff --git a/chaos-testing/src/bin/chaos-scheduler.rs 
b/chaos-testing/src/bin/chaos-scheduler.rs
index 460582060..6a65f111d 100644
--- a/chaos-testing/src/bin/chaos-scheduler.rs
+++ b/chaos-testing/src/bin/chaos-scheduler.rs
@@ -35,12 +35,19 @@ fn env_parsed<T: std::str::FromStr>(key: &str, default: T) 
-> T {
         .unwrap_or(default)
 }
 
+fn env_or(key: &str, default: &str) -> String {
+    std::env::var(key).unwrap_or_else(|_| default.to_string())
+}
+
 #[tokio::main]
 async fn main() -> ballista_core::error::Result<()> {
     env_logger::init();
 
     let config = SchedulerConfig {
-        bind_host: "127.0.0.1".to_string(),
+        // In the process harness these default to loopback; under Kubernetes 
the
+        // scheduler must bind all interfaces and advertise its Service name.
+        bind_host: env_or("CHAOS_BIND_HOST", "127.0.0.1"),
+        external_host: env_or("CHAOS_EXTERNAL_HOST", "localhost"),
         bind_port: std::env::var("CHAOS_SCHEDULER_PORT")
             .expect("CHAOS_SCHEDULER_PORT must be set")
             .parse()
@@ -60,6 +67,12 @@ async fn main() -> ballista_core::error::Result<()> {
             "CHAOS_NO_EXECUTORS_GRACE_SECONDS",
             1,
         ),
+        // Under Kubernetes the client is outside the cluster and cannot reach
+        // executor pod IPs to fetch results. Setting this (to an empty string 
in
+        // the k8s manifest) starts an embedded flight proxy on the scheduler 
and
+        // makes clients fetch results through the scheduler instead of 
directly
+        // from executors. Unset in the process harness -> None -> unchanged.
+        advertise_flight_sql_endpoint: 
std::env::var("CHAOS_ADVERTISE_FLIGHT_PROXY").ok(),
         override_session_builder: Some(Arc::new(chaos_session_state)),
         ..Default::default()
     };
diff --git a/chaos-testing/src/k8s.rs b/chaos-testing/src/k8s.rs
new file mode 100644
index 000000000..06cd7ba18
--- /dev/null
+++ b/chaos-testing/src/k8s.rs
@@ -0,0 +1,629 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! A Kubernetes (kind) backend for the chaos harness.
+//!
+//! Where [`crate::cluster::TestCluster`] spawns the scheduler and executors as
+//! local OS processes, [`K8sCluster`] runs them as pods in a `kind` cluster: 
the
+//! scheduler as a Deployment behind a `ClusterIP` Service, and the executors 
as
+//! a labelled Deployment (so a later scenario can `scale`/`delete pod` them).
+//!
+//! The fixture is shared through a `hostPath` volume mounted into both pods.
+//! The harness writes the parquet under [`fixture_dir`] on the host; kind's
+//! `extraMounts` bind that path into the node, and each pod `hostPath`-mounts
+//! it, so the path string is identical on host, node, and pod and the
+//! schema-inferring `CREATE EXTERNAL TABLE ... LOCATION` that
+//! `Fixture::register_sql` emits resolves the same everywhere — no object 
store.
+//! The directory lives under `$HOME` rather than `/tmp`: Docker Desktop 
reliably
+//! shares the home directory into its VM (and thus the kind node), whereas the
+//! VM's `/tmp` is not the host's. Because kind is single-node, a rescheduled 
pod
+//! re-mounts the same directory, so the fixture survives executor kills.
+//!
+//! The harness process runs outside the cluster, so it reaches the scheduler's
+//! gRPC + REST (both on one port) through a `kubectl port-forward`. Results 
are
+//! fetched through the scheduler's embedded flight proxy
+//! (`advertise_flight_sql_endpoint`), so the client never contacts executor 
pod
+//! IPs directly.
+//!
+//! This backend shells out to `kubectl`; it assumes a `kind` cluster already
+//! exists, `kubectl` is on `PATH` pointed at it, and the chaos image has been
+//! `kind load`ed. See `chaos-testing/k8s/` and the crate README for the 
runbook.
+
+use std::net::TcpListener;
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Stdio};
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::time::{Duration, Instant};
+
+/// Directory shared between the harness and the pods, holding the fixture.
+///
+/// Defaults to `$HOME/.ballista-chaos-fixtures` and can be overridden with
+/// `CHAOS_FIXTURE_DIR` (the run script sets it so the kind `extraMounts` and
+/// this backend agree). A path under `$HOME` is used rather than `/tmp` 
because
+/// Docker Desktop reliably shares the home directory into its VM (and thus 
into
+/// the kind node), whereas the VM has its own `/tmp` that is not the host's.
+/// The path is identical on host, node, and pod, so the schema-inferring
+/// `CREATE EXTERNAL TABLE ... LOCATION` that `Fixture::register_sql` emits
+/// resolves the same everywhere.
+fn fixture_dir() -> String {
+    std::env::var("CHAOS_FIXTURE_DIR").unwrap_or_else(|_| {
+        let home = std::env::var("HOME").unwrap_or_else(|_| 
"/tmp".to_string());
+        format!("{home}/.ballista-chaos-fixtures")
+    })
+}
+
+const CHAOS_IMAGE: &str = "ballista-chaos:test";
+const SCHEDULER_PORT: u16 = 50050;
+/// Port the executor pods serve `/healthz` + `/readyz` on for the k8s probes.
+const EXECUTOR_HEALTH_PORT: u16 = 50053;
+const EXECUTOR_DEPLOYMENT: &str = "ballista-executor";
+
+/// Per-process counter so each `K8sCluster::start` gets a distinct namespace,
+/// even if several run in one process (`--test-threads=1` serialises them 
today,
+/// but this keeps namespaces unique if that ever changes or a start is 
retried).
+static NS_SEQ: AtomicU32 = AtomicU32::new(0);
+
+/// How an executor pod is removed.
+#[derive(Clone, Copy, Debug)]
+pub enum KillMode {
+    /// `kubectl delete pod` — SIGTERM plus the termination grace period, so 
the
+    /// executor's graceful-shutdown path runs (the path a raw process 
`SIGKILL`
+    /// can never reach).
+    Graceful,
+    /// `kubectl delete pod --grace-period=0 --force` — an abrupt loss, the
+    /// closest k8s analogue of the process harness's `SIGKILL`.
+    Forced,
+}
+
+/// A Ballista cluster running as pods in a kind cluster.
+pub struct K8sCluster {
+    namespace: String,
+    scheduler_local_port: u16,
+    port_forward: Child,
+    shared_dir: PathBuf,
+}
+
+impl K8sCluster {
+    /// Deploy a scheduler + `executors` executor pods, wait until all 
executors
+    /// have registered, and open a port-forward to the scheduler.
+    pub async fn start(executors: usize) -> Result<Self, String> {
+        require_kubectl()?;
+
+        // One cluster per process; --test-threads=1 keeps it to one at a time.
+        // The counter guards against collisions if that ever changes.
+        let namespace = format!(
+            "chaos-{}-{}",
+            std::process::id(),
+            NS_SEQ.fetch_add(1, Ordering::Relaxed)
+        );
+        let shared_dir = PathBuf::from(fixture_dir());
+
+        // Ensure the shared dir exists, then clear its *contents* so a 
previous
+        // run's fixture (e.g. one written by an older build with a different
+        // schema) cannot leak into this run. This matters for local,
+        // non-ephemeral use; on CI the runner is fresh. We clear the contents
+        // rather than the directory itself: it is the bind-mount root, and
+        // removing it can sever the mount so pod writes no longer reach the 
node.
+        std::fs::create_dir_all(&shared_dir)
+            .map_err(|e| format!("create shared dir {}: {e}", 
shared_dir.display()))?;
+        clear_dir_contents(&shared_dir)?;
+
+        let manifests = render_manifests(&namespace, executors, &shared_dir);
+        kubectl_apply(&manifests).await?;
+
+        // Guard so the namespace is torn down even if a later step fails.
+        let guard = NamespaceGuard {
+            namespace: namespace.clone(),
+        };
+
+        kubectl(&[
+            "-n",
+            &namespace,
+            "rollout",
+            "status",
+            "deploy/ballista-scheduler",
+            "--timeout=120s",
+        ])
+        .await?;
+
+        let scheduler_local_port = free_port()?;
+        let port_forward = spawn_port_forward(&namespace, 
scheduler_local_port)?;
+
+        let cluster = Self {
+            namespace,
+            scheduler_local_port,
+            port_forward,
+            shared_dir,
+        };
+
+        cluster.await_executors(executors).await?;
+
+        // Everything is up; keep the namespace (transfer ownership to 
`cluster`).
+        std::mem::forget(guard);
+        Ok(cluster)
+    }
+
+    /// `df://…` endpoint for the `ballista` client, via the port-forward.
+    pub fn scheduler_url(&self) -> String {
+        format!("df://127.0.0.1:{}", self.scheduler_local_port)
+    }
+
+    /// `http://…` endpoint for the scheduler REST API, via the port-forward.
+    pub fn rest_url(&self) -> String {
+        format!("http://127.0.0.1:{}";, self.scheduler_local_port)
+    }
+
+    /// The host directory shared into every pod; write the fixture here.
+    pub fn shared_dir(&self) -> &Path {
+        &self.shared_dir
+    }
+
+    /// Block until `n` executors have registered with the scheduler.
+    pub async fn await_executors(&self, n: usize) -> Result<(), String> {
+        let deadline = Instant::now() + Duration::from_secs(120);
+        loop {
+            if let Ok(count) = self.registered_executors().await
+                && count == n
+            {
+                return Ok(());
+            }
+            if Instant::now() > deadline {
+                self.dump_diagnostics().await;
+                return Err(format!(
+                    "timed out waiting for {n} executors to register with the 
scheduler"
+                ));
+            }
+            tokio::time::sleep(Duration::from_millis(500)).await;
+        }
+    }
+
+    /// Print pod status and scheduler/executor logs to stderr — invoked when a
+    /// wait times out, so a failed run is diagnosable even though the 
namespace
+    /// is torn down afterwards. Set `CHAOS_KEEP_NS=1` to keep the namespace 
for
+    /// manual `kubectl` inspection.
+    pub async fn dump_diagnostics(&self) {
+        eprintln!("==> chaos k8s diagnostics for namespace {}", 
self.namespace);
+        for args in [
+            vec!["-n", &self.namespace, "get", "pods", "-o", "wide"],
+            vec![
+                "-n",
+                &self.namespace,
+                "logs",
+                "-l",
+                "app=ballista-scheduler",
+                "--tail=40",
+            ],
+            vec![
+                "-n",
+                &self.namespace,
+                "logs",
+                "-l",
+                "app=ballista-executor",
+                "--tail=40",
+                "--prefix",
+            ],
+        ] {
+            match kubectl(&args).await {
+                Ok(out) => eprintln!("$ kubectl {}\n{out}", args.join(" ")),
+                Err(e) => eprintln!("$ kubectl {} -> {e}", args.join(" ")),
+            }
+        }
+    }
+
+    /// How many executors the scheduler currently considers registered.
+    pub async fn registered_executors(&self) -> Result<usize, String> {
+        // A short timeout so a stalled port-forward surfaces as a retryable
+        // error in the polling loop rather than hanging the whole wait.
+        let client = reqwest::Client::builder()
+            .timeout(Duration::from_secs(5))
+            .build()
+            .map_err(|e| e.to_string())?;
+        let body: serde_json::Value = client
+            .get(format!("{}/api/executors", self.rest_url()))
+            .send()
+            .await
+            .map_err(|e| e.to_string())?
+            .json()
+            .await
+            .map_err(|e| e.to_string())?;
+        Ok(body.as_array().map(|a| a.len()).unwrap_or(0))
+    }
+
+    /// Scale the executor Deployment. `0` is a total loss that stays lost (the
+    /// controller does not recreate the pods); scaling back up recovers.
+    ///
+    /// Not yet exercised by a scenario — this is the k8s primitive the 
executor
+    /// kill/loss scenarios (the #2029 follow-ups) will drive; the baseline 
test
+    /// only needs a healthy cluster. Kept here so the backend is complete.
+    pub async fn scale_executors(&self, replicas: usize) -> Result<(), String> 
{
+        kubectl(&[
+            "-n",
+            &self.namespace,
+            "scale",
+            &format!("deploy/{EXECUTOR_DEPLOYMENT}"),
+            &format!("--replicas={replicas}"),
+        ])
+        .await
+        .map(|_| ())
+    }
+
+    /// Delete one executor pod. The Deployment reschedules a replacement (a 
fresh
+    /// executor with a new id), exercising k8s rescheduling plus Ballista's
+    /// executor-loss recovery.
+    pub async fn kill_one_executor(&self, mode: KillMode) -> Result<(), 
String> {
+        let pods = self.pods_by_label("app=ballista-executor").await?;
+        let name = pods
+            .into_iter()
+            .next()
+            .ok_or_else(|| "no executor pods found".to_string())?;
+        let mut args = vec!["-n", &self.namespace, "delete", "pod", &name];
+        if matches!(mode, KillMode::Forced) {
+            args.extend_from_slice(&["--grace-period=0", "--force"]);
+        }
+        kubectl(&args).await.map(|_| ())
+    }
+
+    async fn pods_by_label(&self, label: &str) -> Result<Vec<String>, String> {
+        let out = kubectl(&[
+            "-n",
+            &self.namespace,
+            "get",
+            "pods",
+            "-l",
+            label,
+            "-o",
+            "jsonpath={.items[*].metadata.name}",
+        ])
+        .await?;
+        Ok(out.split_whitespace().map(|s| s.to_string()).collect())
+    }
+}
+
+impl Drop for K8sCluster {
+    fn drop(&mut self) {
+        let _ = self.port_forward.kill();
+        let _ = self.port_forward.wait();
+        delete_namespace(&self.namespace);
+    }
+}
+
+/// Deletes a namespace on drop; `mem::forget`ten once startup fully succeeds.
+struct NamespaceGuard {
+    namespace: String,
+}
+
+impl Drop for NamespaceGuard {
+    fn drop(&mut self) {
+        delete_namespace(&self.namespace);
+    }
+}
+
+/// Best-effort namespace teardown, skipped when `CHAOS_KEEP_NS` is set so a
+/// failed run can be inspected with `kubectl`.
+fn delete_namespace(namespace: &str) {
+    if std::env::var_os("CHAOS_KEEP_NS").is_some() {
+        eprintln!(
+            "CHAOS_KEEP_NS set: leaving namespace {namespace} in place; \
+             delete it with `kubectl delete namespace {namespace}`"
+        );
+        return;
+    }
+    let _ = Command::new("kubectl")
+        .args([
+            "delete",
+            "namespace",
+            namespace,
+            "--wait=false",
+            "--ignore-not-found",
+        ])
+        .stdout(Stdio::null())
+        .stderr(Stdio::null())
+        .status();
+}
+
+/// Remove everything *inside* `dir` without removing `dir` itself. `dir` is a
+/// bind-mount root, so deleting it can sever the mount; deleting only its
+/// entries is safe and leaves the mount intact.
+fn clear_dir_contents(dir: &Path) -> Result<(), String> {
+    for entry in std::fs::read_dir(dir)
+        .map_err(|e| format!("read shared dir {}: {e}", dir.display()))?
+    {
+        let entry = entry.map_err(|e| format!("read dir entry: {e}"))?;
+        let path = entry.path();
+        let is_dir = entry.file_type().map_err(|e| e.to_string())?.is_dir();
+        let result = if is_dir {
+            std::fs::remove_dir_all(&path)
+        } else {
+            std::fs::remove_file(&path)
+        };
+        result.map_err(|e| format!("remove {}: {e}", path.display()))?;
+    }
+    Ok(())
+}
+
+fn require_kubectl() -> Result<(), String> {
+    Command::new("kubectl")
+        .arg("version")
+        .arg("--client")
+        .stdout(Stdio::null())
+        .stderr(Stdio::null())
+        .status()
+        .map_err(|e| format!("kubectl not found on PATH: {e}"))
+        .and_then(|s| {
+            s.success()
+                .then_some(())
+                .ok_or_else(|| "`kubectl version --client` failed".to_string())
+        })?;
+
+    // This backend creates and deletes namespaces, so refuse to run unless the
+    // current context is a kind cluster — a guard against pointing it at a 
real
+    // cluster by accident. kind names its context `kind-<cluster>`.
+    let output = Command::new("kubectl")
+        .args(["config", "current-context"])
+        .output()
+        .map_err(|e| format!("read kubectl current-context: {e}"))?;
+    let context = String::from_utf8_lossy(&output.stdout);
+    let context = context.trim();
+    if !context.starts_with("kind-") {
+        return Err(format!(
+            "refusing to run: current kubectl context is {context:?}, not a 
kind \
+             cluster (expected a `kind-` prefix). Point kubectl at a kind 
cluster, \
+             e.g. `kubectl config use-context kind-ballista-chaos`."
+        ));
+    }
+    Ok(())
+}
+
+/// Reserve a free local TCP port for the port-forward.
+fn free_port() -> Result<u16, String> {
+    let listener = TcpListener::bind("127.0.0.1:0")
+        .map_err(|e| format!("bind ephemeral port: {e}"))?;
+    listener
+        .local_addr()
+        .map(|a| a.port())
+        .map_err(|e| e.to_string())
+}
+
+fn spawn_port_forward(namespace: &str, local_port: u16) -> Result<Child, 
String> {
+    Command::new("kubectl")
+        .args([
+            "-n",
+            namespace,
+            "port-forward",
+            "svc/ballista-scheduler",
+            &format!("{local_port}:{SCHEDULER_PORT}"),
+        ])
+        .stdout(Stdio::null())
+        .stderr(Stdio::null())
+        .spawn()
+        .map_err(|e| format!("spawn kubectl port-forward: {e}"))
+}
+
+/// Run `kubectl` with the given args, returning stdout on success.
+async fn kubectl(args: &[&str]) -> Result<String, String> {
+    let output = tokio::process::Command::new("kubectl")
+        .args(args)
+        .output()
+        .await
+        .map_err(|e| format!("run kubectl {args:?}: {e}"))?;
+    if output.status.success() {
+        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+    } else {
+        Err(format!(
+            "kubectl {args:?} failed: {}",
+            String::from_utf8_lossy(&output.stderr)
+        ))
+    }
+}
+
+/// `kubectl apply` a rendered manifest by piping it to stdin.
+async fn kubectl_apply(manifests: &str) -> Result<(), String> {
+    use tokio::io::AsyncWriteExt;
+
+    let mut child = tokio::process::Command::new("kubectl")
+        .args(["apply", "-f", "-"])
+        .stdin(Stdio::piped())
+        .stdout(Stdio::null())
+        .stderr(Stdio::piped())
+        .spawn()
+        .map_err(|e| format!("spawn kubectl apply: {e}"))?;
+
+    child
+        .stdin
+        .take()
+        .expect("stdin piped")
+        .write_all(manifests.as_bytes())
+        .await
+        .map_err(|e| format!("write manifests to kubectl: {e}"))?;
+
+    let output = child
+        .wait_with_output()
+        .await
+        .map_err(|e| format!("wait for kubectl apply: {e}"))?;
+    if output.status.success() {
+        Ok(())
+    } else {
+        Err(format!(
+            "kubectl apply failed: {}",
+            String::from_utf8_lossy(&output.stderr)
+        ))
+    }
+}
+
+/// Render the namespace + scheduler (Deployment + Service) + executor 
Deployment.
+/// `mount` is the fixture directory, bind-mounted into both pods (see
+/// [`fixture_dir`]); it must match the kind `extraMounts` path.
+fn render_manifests(
+    namespace: &str,
+    executors: usize,
+    mount: &std::path::Path,
+) -> String {
+    let mount = mount.display();
+    format!(
+        r#"
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: {namespace}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: ballista-scheduler
+  namespace: {namespace}
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: ballista-scheduler
+  template:
+    metadata:
+      labels:
+        app: ballista-scheduler
+    spec:
+      containers:
+        - name: scheduler
+          image: {CHAOS_IMAGE}
+          imagePullPolicy: Never
+          command: ["/root/chaos-scheduler"]
+          env:
+            - name: CHAOS_SCHEDULER_PORT
+              value: "{SCHEDULER_PORT}"
+            - name: CHAOS_BIND_HOST
+              value: "0.0.0.0"
+            - name: CHAOS_EXTERNAL_HOST
+              value: "ballista-scheduler"
+            - name: CHAOS_ADVERTISE_FLIGHT_PROXY
+              value: ""
+            - name: CHAOS_EXECUTOR_TIMEOUT_SECONDS
+              value: "5"
+            - name: CHAOS_EXPIRE_INTERVAL_SECONDS
+              value: "1"
+            - name: RUST_LOG
+              value: "info"
+          ports:
+            - containerPort: {SCHEDULER_PORT}
+          # Both probes use /healthz, not /readyz: executors reach the 
scheduler
+          # through the Service below, and a Service only routes to Ready pods.
+          # The scheduler's /readyz gates on registered executors, so a /readyz
+          # readiness probe would deadlock (no endpoints -> executors can't
+          # register -> never ready). /healthz reports process liveness, which 
is
+          # all the Service needs to start routing.
+          readinessProbe:
+            httpGet:
+              path: /healthz
+              port: {SCHEDULER_PORT}
+            periodSeconds: 2
+            failureThreshold: 3
+          livenessProbe:
+            httpGet:
+              path: /healthz
+              port: {SCHEDULER_PORT}
+            periodSeconds: 10
+            failureThreshold: 3
+          volumeMounts:
+            - name: fixtures
+              mountPath: {mount}
+      volumes:
+        - name: fixtures
+          hostPath:
+            path: {mount}
+            type: DirectoryOrCreate
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: ballista-scheduler
+  namespace: {namespace}
+spec:
+  selector:
+    app: ballista-scheduler
+  ports:
+    - port: {SCHEDULER_PORT}
+      targetPort: {SCHEDULER_PORT}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {EXECUTOR_DEPLOYMENT}
+  namespace: {namespace}
+spec:
+  replicas: {executors}
+  selector:
+    matchLabels:
+      app: ballista-executor
+  template:
+    metadata:
+      labels:
+        app: ballista-executor
+    spec:
+      terminationGracePeriodSeconds: 30
+      containers:
+        - name: executor
+          image: {CHAOS_IMAGE}
+          imagePullPolicy: Never
+          command: ["/root/chaos-executor"]
+          env:
+            - name: CHAOS_SCHEDULER_HOST
+              value: "ballista-scheduler"
+            - name: CHAOS_SCHEDULER_PORT
+              value: "{SCHEDULER_PORT}"
+            - name: CHAOS_EXECUTOR_PORT
+              value: "50051"
+            - name: CHAOS_EXECUTOR_GRPC_PORT
+              value: "50052"
+            - name: CHAOS_EXECUTOR_HEALTH_PORT
+              value: "{EXECUTOR_HEALTH_PORT}"
+            - name: CHAOS_BIND_HOST
+              value: "0.0.0.0"
+            - name: CHAOS_EXECUTOR_EXTERNAL_HOST
+              valueFrom:
+                fieldRef:
+                  fieldPath: status.podIP
+            - name: CHAOS_HEARTBEAT_SECONDS
+              value: "1"
+            - name: RUST_LOG
+              value: "info"
+          ports:
+            - containerPort: {EXECUTOR_HEALTH_PORT}
+          # The executor is reached by pod IP (not a readiness-gated Service), 
so
+          # /readyz here is safe and meaningful: it reports SERVICE_UNAVAILABLE
+          # until the first heartbeat lands, then 200. Liveness stays on 
/healthz
+          # (process-alive) so a slow/again-disconnected executor is not 
killed.
+          readinessProbe:
+            httpGet:
+              path: /readyz
+              port: {EXECUTOR_HEALTH_PORT}
+            periodSeconds: 2
+            failureThreshold: 3
+          livenessProbe:
+            httpGet:
+              path: /healthz
+              port: {EXECUTOR_HEALTH_PORT}
+            periodSeconds: 10
+            failureThreshold: 3
+          volumeMounts:
+            - name: fixtures
+              mountPath: {mount}
+      volumes:
+        - name: fixtures
+          hostPath:
+            path: {mount}
+            type: DirectoryOrCreate
+"#
+    )
+}
diff --git a/chaos-testing/src/lib.rs b/chaos-testing/src/lib.rs
index 5c1c52764..29cb897c5 100644
--- a/chaos-testing/src/lib.rs
+++ b/chaos-testing/src/lib.rs
@@ -23,5 +23,7 @@
 pub mod budget;
 pub mod cluster;
 pub mod fixture;
+#[cfg(feature = "k8s")]
+pub mod k8s;
 pub mod registry;
 pub mod udf;
diff --git a/chaos-testing/tests/k8s.rs b/chaos-testing/tests/k8s.rs
new file mode 100644
index 000000000..af5d9420a
--- /dev/null
+++ b/chaos-testing/tests/k8s.rs
@@ -0,0 +1,113 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Kubernetes (kind) backend scenarios.
+//!
+//! Gated behind the `k8s` feature and `CHAOS_BACKEND=kind`, so the default
+//! `cargo test` never touches a cluster. Run with the kind runbook in the 
crate
+//! README:
+//!
+//! ```sh
+//! dev/build-chaos-docker.sh
+//! kind create cluster --config chaos-testing/k8s/kind-config.yaml
+//! kind load docker-image ballista-chaos:test
+//! CHAOS_BACKEND=kind cargo test -p ballista-chaos --features k8s --test k8s 
-- --test-threads=1
+//! ```
+#![cfg(feature = "k8s")]
+
+use ballista::prelude::{SessionConfigExt, SessionContextExt};
+use chaos_testing::fixture::Fixture;
+use chaos_testing::k8s::K8sCluster;
+use datafusion::arrow::util::pretty::pretty_format_batches;
+use datafusion::execution::session_state::SessionStateBuilder;
+use datafusion::prelude::{SessionConfig, SessionContext};
+
+/// The k8s scenarios need a running kind cluster with the chaos images loaded;
+/// they are opt-in via `CHAOS_BACKEND=kind` so a plain `cargo test` skips 
them.
+fn kind_backend_selected() -> bool {
+    if std::env::var("CHAOS_BACKEND").as_deref() == Ok("kind") {
+        true
+    } else {
+        eprintln!(
+            "skipping k8s scenario: set CHAOS_BACKEND=kind and provide a kind 
cluster \
+             with the chaos images loaded (see the crate README runbook)"
+        );
+        false
+    }
+}
+
+/// The chaos-free baseline query, run on a fresh local DataFusion context. 
This
+/// is the reference the cluster must reproduce exactly.
+async fn local_baseline(fixture: &Fixture) -> String {
+    let ctx = SessionContext::new();
+    for stmt in fixture.register_sql() {
+        ctx.sql(&stmt).await.unwrap().collect().await.unwrap();
+    }
+    let batches = ctx
+        .sql(Fixture::baseline_query())
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    pretty_format_batches(&batches).unwrap().to_string()
+}
+
+/// Smoke test: a real query on a real kind cluster returns the same result as
+/// plain local DataFusion. Exercises the whole path — client → scheduler → 
pods
+/// → shuffle → result — with the fixture shared through the `hostPath` mount.
+#[tokio::test]
+async fn baseline_matches_local_datafusion_on_k8s() {
+    if !kind_backend_selected() {
+        return;
+    }
+
+    let cluster = K8sCluster::start(2).await.expect("kind cluster must start");
+
+    // Written into the shared mount, so the scheduler and executor pods see 
it.
+    let fixture = Fixture::write(cluster.shared_dir())
+        .await
+        .expect("fixture must be written to the shared mount");
+
+    let expected = local_baseline(&fixture).await;
+
+    let config = SessionConfig::new_with_ballista();
+    let state = SessionStateBuilder::new()
+        .with_config(config)
+        .with_default_features()
+        .build();
+    let ctx = SessionContext::remote_with_state(&cluster.scheduler_url(), 
state)
+        .await
+        .expect("client must connect to the scheduler");
+
+    for stmt in fixture.register_sql() {
+        ctx.sql(&stmt).await.unwrap().collect().await.unwrap();
+    }
+    let batches = ctx
+        .sql(Fixture::baseline_query())
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let actual = pretty_format_batches(&batches).unwrap().to_string();
+
+    assert_eq!(
+        actual, expected,
+        "cluster result must match plain local DataFusion"
+    );
+}
diff --git a/dev/build-chaos-docker.sh b/dev/build-chaos-docker.sh
new file mode 100755
index 000000000..9b19b35e1
--- /dev/null
+++ b/dev/build-chaos-docker.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Build the `ballista-chaos:test` image (both chaos binaries) for the kind
+# backend. The binaries are compiled inside the image so it always matches the
+# container architecture — no host cross-toolchain needed. Load it with:
+#
+#   kind load docker-image ballista-chaos:test
+#
+# Requires BuildKit (default in modern Docker).
+
+set -euo pipefail
+
+DOCKER_BUILDKIT=1 docker build \
+  -t "ballista-chaos:test" -f dev/docker/chaos.Dockerfile .
diff --git a/dev/chaos-kind.sh b/dev/chaos-kind.sh
new file mode 100755
index 000000000..5aa0a7357
--- /dev/null
+++ b/dev/chaos-kind.sh
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Run the chaos harness's Kubernetes (kind) scenarios locally.
+#
+#   dev/chaos-kind.sh [command] [-- <extra args passed to cargo test>]
+#
+# Commands:
+#   up     Create the kind cluster (if missing), build + load the chaos images.
+#   test   Run the kind chaos scenarios against the current cluster.
+#   all    up + test           (default)
+#   down   Delete the kind cluster.
+#
+# Env:
+#   CLUSTER_NAME   kind cluster name           (default: ballista-chaos)
+#   KEEP_CLUSTER   with the `all` command: "0" tears the cluster down at the 
end,
+#                  any other value (default "1") leaves it running
+#
+# Examples:
+#   dev/chaos-kind.sh                       # build, (re)create, load, run 
everything
+#   dev/chaos-kind.sh up                    # just stand the cluster up
+#   dev/chaos-kind.sh test -- --nocapture   # re-run tests on an existing 
cluster
+#   dev/chaos-kind.sh down                  # tear it down
+
+set -euo pipefail
+
+CLUSTER_NAME=${CLUSTER_NAME:-ballista-chaos}
+KEEP_CLUSTER=${KEEP_CLUSTER:-1}
+# Fixture dir shared host<->pods. Under $HOME (not /tmp) because Docker Desktop
+# reliably shares the home directory into its VM/kind node, whereas the VM's
+# /tmp is not the host's. Exported so the test's k8s backend uses the same 
path.
+export CHAOS_FIXTURE_DIR=${CHAOS_FIXTURE_DIR:-$HOME/.ballista-chaos-fixtures}
+CHAOS_IMAGE=ballista-chaos:test
+
+# Run from the repository root so the relative paths below resolve.
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$REPO_ROOT"
+
+# kind config is generated so its extraMounts match CHAOS_FIXTURE_DIR exactly.
+KIND_CONFIG="$(mktemp -t kind-config.XXXXXX.yaml)"
+gen_kind_config() {
+  cat > "$KIND_CONFIG" <<EOF
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+nodes:
+  - role: control-plane
+    extraMounts:
+      - hostPath: ${CHAOS_FIXTURE_DIR}
+        containerPath: ${CHAOS_FIXTURE_DIR}
+EOF
+}
+
+need_cmd() {
+  command -v "$1" >/dev/null 2>&1 || {
+    echo "error: '$1' is required but not found on PATH" >&2
+    exit 1
+  }
+}
+
+cluster_exists() {
+  kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"
+}
+
+up() {
+  need_cmd docker
+  need_cmd kind
+  need_cmd kubectl
+
+  mkdir -p "$CHAOS_FIXTURE_DIR"
+
+  if cluster_exists; then
+    echo "==> kind cluster '$CLUSTER_NAME' already exists"
+  else
+    echo "==> creating kind cluster '$CLUSTER_NAME' (fixture mount: 
$CHAOS_FIXTURE_DIR)"
+    gen_kind_config
+    # KIND_NODE_IMAGE lets you pin a node image compatible with your kind
+    # binary (e.g. kindest/node:v1.31.4). A mismatch between the kind version
+    # and the node image is the usual cause of the "could not find a log line
+    # that matches ... Multi-User System" boot failure.
+    local create_args=(--name "$CLUSTER_NAME" --config "$KIND_CONFIG")
+    if [ -n "${KIND_NODE_IMAGE:-}" ]; then
+      create_args+=(--image "$KIND_NODE_IMAGE")
+    fi
+    if ! kind create cluster "${create_args[@]}"; then
+      echo "" >&2
+      echo "error: kind failed to create the cluster. This is an environment 
issue" >&2
+      echo "       (Docker/kind), not the Ballista harness. Diagnostics:" >&2
+      echo "--- versions ---" >&2
+      kind version >&2 || true
+      docker version --format '{{.Server.Version}}' >&2 || true
+      echo "--- node container logs (if retained) ---" >&2
+      docker ps -a --filter "name=${CLUSTER_NAME}-control-plane" >&2 || true
+      docker logs "${CLUSTER_NAME}-control-plane" 2>&1 | tail -30 >&2 || true
+      echo "" >&2
+      echo "Try: upgrade kind (brew upgrade kind), (re)start/enlarge Docker, 
or pin a" >&2
+      echo "     node image:  KIND_NODE_IMAGE=kindest/node:v1.31.4 $0 up" >&2
+      exit 1
+    fi
+  fi
+
+  echo "==> building chaos image (compiled inside Docker)"
+  ./dev/build-chaos-docker.sh
+
+  echo "==> loading image into kind"
+  kind load docker-image "$CHAOS_IMAGE" --name "$CLUSTER_NAME"
+}
+
+run_tests() {
+  need_cmd kubectl
+  cluster_exists || {
+    echo "error: kind cluster '$CLUSTER_NAME' does not exist; run '$0 up' 
first" >&2
+    exit 1
+  }
+  # Point kubectl at this cluster for the duration of the run.
+  kubectl config use-context "kind-${CLUSTER_NAME}" >/dev/null
+
+  echo "==> running kind chaos scenarios"
+  CHAOS_BACKEND=kind cargo test -p ballista-chaos --features k8s --test k8s \
+    -- --test-threads=1 "$@"
+}
+
+down() {
+  need_cmd kind
+  echo "==> deleting kind cluster '$CLUSTER_NAME'"
+  kind delete cluster --name "$CLUSTER_NAME"
+}
+
+command="all"
+if [ $# -gt 0 ] && [[ "$1" != "--" ]]; then
+  command="$1"
+  shift
+fi
+# Drop a leading "--" so callers can write: `test -- --nocapture`.
+if [ $# -gt 0 ] && [[ "$1" == "--" ]]; then
+  shift
+fi
+
+case "$command" in
+  up) up ;;
+  test) run_tests "$@" ;;
+  down) down ;;
+  all)
+    up
+    run_tests "$@"
+    if [ "$KEEP_CLUSTER" = "0" ]; then
+      down
+    else
+      echo "==> leaving cluster '$CLUSTER_NAME' up (set KEEP_CLUSTER=0 to 
delete, or run '$0 down')"
+    fi
+    ;;
+  *)
+    echo "usage: $0 [up|test|down|all] [-- <extra cargo test args>]" >&2
+    exit 1
+    ;;
+esac
diff --git a/dev/docker/chaos.Dockerfile b/dev/docker/chaos.Dockerfile
new file mode 100644
index 000000000..0709f948f
--- /dev/null
+++ b/dev/docker/chaos.Dockerfile
@@ -0,0 +1,58 @@
+# syntax=docker/dockerfile:1
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# One image containing both chaos binaries, used by the Kubernetes backend in
+# `chaos-testing`. The pod manifest selects the role via `command`.
+#
+# The binaries are compiled *inside* this build, so the image is always built
+# for the container's architecture. A host `cargo build` would embed the host's
+# binary (e.g. a macOS Mach-O on Apple Silicon), which fails in a Linux pod 
with
+# "exec format error". BuildKit cache mounts keep the cargo registry and target
+# dir across builds, so an incremental rebuild after a chaos-only change is 
fast.
+#
+# Debug info is disabled (CARGO_PROFILE_DEV_DEBUG=0) and symbols are stripped: 
a
+# full debug binary statically links all of DataFusion + aws-lc/ring and can 
OOM
+# `ld` at link time, and the ~250MB result is slow (and sometimes fails) to
+# `kind load`. Without DWARF the link is light and each binary is tens of MB.
+
+FROM rust:1-bookworm AS builder
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends protobuf-compiler \
+    && rm -rf /var/lib/apt/lists/*
+WORKDIR /src
+COPY . .
+ENV CARGO_PROFILE_DEV_DEBUG=0
+ENV RUSTFLAGS="-C strip=symbols"
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/src/target \
+    cargo build -p ballista-chaos --bin chaos-scheduler --bin chaos-executor \
+    && mkdir -p /out \
+    && cp target/debug/chaos-scheduler target/debug/chaos-executor /out/
+
+FROM debian:bookworm-slim
+ENV RUST_LOG=info
+ENV RUST_BACKTRACE=full
+COPY --from=builder /out/chaos-scheduler /root/chaos-scheduler
+COPY --from=builder /out/chaos-executor /root/chaos-executor
+
+# scheduler gRPC/REST (50050); executor Arrow Flight (50051), gRPC (50052), and
+# HTTP health probes (50053).
+EXPOSE 50050 50051 50052 50053
+
+# No ENTRYPOINT: the pod manifest sets `command` to the desired binary.


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

Reply via email to