From a84012ca5e6320ed0b7af6985f51b55ab6c4482f Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Mon, 28 Sep 2026 21:29:43 +0000 Subject: [PATCH] ci: add Cassandra CI workflow and test runner --- .github/workflows/integration-cassandra.yml | 209 ++++++++++++++++++++ devtools/run-cassandra-tests | 209 ++++++++++++++++++++ devtools/run-tests | 4 +- 3 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/integration-cassandra.yml create mode 100755 devtools/run-cassandra-tests diff --git a/.github/workflows/integration-cassandra.yml b/.github/workflows/integration-cassandra.yml new file mode 100644 index 000000000..49922b1b2 --- /dev/null +++ b/.github/workflows/integration-cassandra.yml @@ -0,0 +1,209 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +name: Cassandra Integration Tests + +on: + pull_request: + merge_group: + push: + branches: [main, feat/storage-cassandra-in-tree] + +permissions: + contents: read + +# The Cassandra backend's integration tests (crates/storage-cassandra/tests) +# talk to a live node at 127.0.0.1:9042 with cassandra/cassandra credentials +# and are not feature-gated — without this workflow they never run in CI and +# every green result is a local claim. The TTL claim protocol in particular +# fails silently if the stored item encoding drifts, so these tests are the +# only automated signal for a whole class of regressions. +# +# A single-node container is enough: the tests create their own keyspaces +# (RF=1) and run serially because the sweep and reconciliation passes are +# global. +# +# The cassandra:4.1 image does not honour CASSANDRA_AUTHENTICATOR, so each job +# patches cassandra.yaml inside the running container and restarts it before +# running any tests. The health-check on the service container uses the default +# AllowAllAuthenticator credentials (no auth challenge), so the container is +# healthy before the patch step runs; after the restart a second wait loop +# confirms the node is back up with PasswordAuthenticator active. + +jobs: + cassandra-storage: + runs-on: ubuntu-latest + services: + cassandra: + image: cassandra:4.1 + ports: + - 9042:9042 + env: + CASSANDRA_CLUSTER_NAME: extenddb-ci + HEAP_NEWSIZE: 128M + MAX_HEAP_SIZE: 1024M + options: >- + --health-cmd "cqlsh -e 'SELECT release_version FROM system.local'" + --health-interval 15s + --health-timeout 10s + --health-retries 20 + --health-start-period 60s + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Configure Cassandra authentication + run: | + docker exec ${{ job.services.cassandra.id }} sed -i \ + 's/^authenticator: .*/authenticator: PasswordAuthenticator/' \ + /etc/cassandra/cassandra.yaml + docker restart ${{ job.services.cassandra.id }} + for i in $(seq 1 30); do + if docker exec ${{ job.services.cassandra.id }} \ + cqlsh -u cassandra -p cassandra \ + -e 'SELECT release_version FROM system.local' \ + >/dev/null 2>&1; then + echo "Cassandra ready with PasswordAuthenticator after ${i}s" + exit 0 + fi + sleep 2 + done + echo "Cassandra did not become ready" >&2; exit 1 + - name: Unit tests + run: cargo test -p extenddb-storage-cassandra --lib + - name: TTL integration tests + run: cargo test -p extenddb-storage-cassandra --test ttl_integration -- --test-threads=1 + env: + EXTENDDB_TEST_CASSANDRA: required + - name: Direct storage-trait integration tests + # Ported from the plug-in repo's tests/rust suite. Parallel-safe: + # each test provisions its own account and keyspace; the shared + # control-plane setting test restores what it mutates. + run: cargo test -p extenddb-storage-cassandra --test direct_integration + env: + EXTENDDB_TEST_CASSANDRA: required + + cassandra-pytest: + runs-on: ubuntu-latest + services: + cassandra: + image: cassandra:4.1 + ports: + - 9042:9042 + env: + CASSANDRA_CLUSTER_NAME: extenddb-ci + HEAP_NEWSIZE: 128M + MAX_HEAP_SIZE: 1024M + options: >- + --health-cmd "cqlsh -e 'SELECT release_version FROM system.local'" + --health-interval 15s + --health-timeout 10s + --health-retries 20 + --health-start-period 60s + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Configure Cassandra authentication + run: | + docker exec ${{ job.services.cassandra.id }} sed -i \ + 's/^authenticator: .*/authenticator: PasswordAuthenticator/' \ + /etc/cassandra/cassandra.yaml + docker restart ${{ job.services.cassandra.id }} + for i in $(seq 1 30); do + if docker exec ${{ job.services.cassandra.id }} \ + cqlsh -u cassandra -p cassandra \ + -e 'SELECT release_version FROM system.local' \ + >/dev/null 2>&1; then + echo "Cassandra ready with PasswordAuthenticator after ${i}s" + exit 0 + fi + sleep 2 + done + echo "Cassandra did not become ready" >&2; exit 1 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install Python dependencies + run: pip install -r requirements.txt + - name: Build release (cassandra backend) + run: cargo build --release --no-default-features --features cassandra + - name: Run Cassandra pytest suite + run: devtools/run-cassandra-tests -- --pytest --comprehensive --parallel + + cassandra-rust: + runs-on: ubuntu-latest + services: + cassandra: + image: cassandra:4.1 + ports: + - 9042:9042 + env: + CASSANDRA_CLUSTER_NAME: extenddb-ci + HEAP_NEWSIZE: 128M + MAX_HEAP_SIZE: 1024M + options: >- + --health-cmd "cqlsh -e 'SELECT release_version FROM system.local'" + --health-interval 15s + --health-timeout 10s + --health-retries 20 + --health-start-period 60s + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Configure Cassandra authentication + run: | + docker exec ${{ job.services.cassandra.id }} sed -i \ + 's/^authenticator: .*/authenticator: PasswordAuthenticator/' \ + /etc/cassandra/cassandra.yaml + docker restart ${{ job.services.cassandra.id }} + for i in $(seq 1 30); do + if docker exec ${{ job.services.cassandra.id }} \ + cqlsh -u cassandra -p cassandra \ + -e 'SELECT release_version FROM system.local' \ + >/dev/null 2>&1; then + echo "Cassandra ready with PasswordAuthenticator after ${i}s" + exit 0 + fi + sleep 2 + done + echo "Cassandra did not become ready" >&2; exit 1 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install Python dependencies + run: pip install -r requirements.txt + - name: Build release (cassandra backend) + run: cargo build --release --no-default-features --features cassandra + - name: Run Cassandra rust integration suite + run: devtools/run-cassandra-tests -- --rust --rust-integration + + cassandra-production-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Check production Cassandra feature graph + run: cargo check --release --no-default-features --features cassandra + + cassandra-integration: + runs-on: ubuntu-latest + needs: [cassandra-storage, cassandra-pytest, cassandra-rust, cassandra-production-build] + if: always() + steps: + - run: | + if [ "${{ needs.cassandra-storage.result }}" != "success" ] || \ + [ "${{ needs.cassandra-pytest.result }}" != "success" ] || \ + [ "${{ needs.cassandra-rust.result }}" != "success" ] || \ + [ "${{ needs.cassandra-production-build.result }}" != "success" ]; then + exit 1 + fi diff --git a/devtools/run-cassandra-tests b/devtools/run-cassandra-tests new file mode 100755 index 000000000..26cbfd851 --- /dev/null +++ b/devtools/run-cassandra-tests @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Orchestrated Cassandra integration-test runner. +# +# Initializes and serves extenddb against a running Cassandra node, then +# delegates the test workload to `devtools/run-tests --backend cassandra`. +# Tears everything down on exit. +# +# Usage: +# devtools/run-cassandra-tests [OPTIONS] [-- RUN_TESTS_ARGS ...] +# +# Options: +# --contact-points HOST:PORT Cassandra contact point (default: 127.0.0.1:9042) +# --port PORT HTTPS port extenddb should bind (default: 18443) +# --output DIR Directory for logs and generated config +# (default: /tmp/run-cassandra-tests-) +# --keep Do not tear down the extenddb server on exit +# -h, --help Show this help +# +# Arguments after `--` are passed to `devtools/run-tests` verbatim. +# Default suite is `--pytest --comprehensive --parallel`. +# +# Examples: +# devtools/run-cassandra-tests +# devtools/run-cassandra-tests -- --pytest --filter test_put_item +# devtools/run-cassandra-tests --keep -- --rust --rust-integration +# +# Prerequisites: +# - A Cassandra node reachable at the contact point (default 127.0.0.1:9042) +# with PasswordAuthenticator enabled. The official cassandra:4.1 image does +# not honour CASSANDRA_AUTHENTICATOR, so patch it after starting: +# docker run -d --name cassandra -p 9042:9042 \ +# -e CASSANDRA_CLUSTER_NAME=extenddb-ci \ +# -e HEAP_NEWSIZE=128M -e MAX_HEAP_SIZE=1024M \ +# cassandra:4.1 +# sleep 30 +# docker exec cassandra sed -i \ +# 's/^authenticator: .*/authenticator: PasswordAuthenticator/' \ +# /etc/cassandra/cassandra.yaml +# docker restart cassandra && sleep 30 +# - `cargo build --release --no-default-features --features cassandra` done +# - Python venv activated with pytest installed +# - `~/.extenddb/tls/` populated (from a prior `extenddb init`) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_DIR" + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +CONTACT_POINTS="127.0.0.1:9042" +BIND_PORT=18443 +OUTPUT_DIR="" +KEEP=false +RUN_TESTS_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --contact-points) CONTACT_POINTS="$2"; shift 2 ;; + --port) BIND_PORT="$2"; shift 2 ;; + --output) OUTPUT_DIR="$2"; shift 2 ;; + --keep) KEEP=true; shift ;; + --) shift; RUN_TESTS_ARGS=("$@"); break ;; + -h|--help) sed -n '5,35p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "error: unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ ${#RUN_TESTS_ARGS[@]} -eq 0 ]]; then + RUN_TESTS_ARGS=(--pytest --comprehensive --parallel) +fi + +if [[ -z "$OUTPUT_DIR" ]]; then + OUTPUT_DIR="/tmp/run-cassandra-tests-$(date +%Y%m%d-%H%M%S)-$$" +fi +mkdir -p "$OUTPUT_DIR" + +CONFIG="$OUTPUT_DIR/extenddb.toml" +SERVER_LOG="$OUTPUT_DIR/server.log" +BINARY="./target/release/extenddb" + +if [[ ! -x "$BINARY" ]]; then + echo "error: $BINARY not found. Build first:" >&2 + echo " cargo build --release --no-default-features --features cassandra" >&2 + exit 1 +fi + +TMP_CANON=$(realpath /tmp) + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +cleanup() { + if $KEEP; then + echo "" + echo "=== --keep set; leaving extenddb running ===" + echo " server log: $SERVER_LOG" + echo " tear down: $BINARY stop --config $CONFIG" + return + fi + echo "" + echo "=== Cleanup ===" + if [[ -f "$CONFIG" ]] && "$BINARY" stop --config "$CONFIG" >/dev/null 2>&1; then + echo " stopped extenddb server" + fi +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Initialize extenddb +# --------------------------------------------------------------------------- + +echo "=== Initializing extenddb (Cassandra backend) ===" + +ADMIN_PASSWORD=$(openssl rand -base64 16) +EXTENDDB_ADMIN_PASSWORD="$ADMIN_PASSWORD" "$BINARY" init \ + --backend cassandra \ + --config "$CONFIG" \ + --cassandra-contact-points "$CONTACT_POINTS" \ + --cassandra-user cassandra \ + --cassandra-pass cassandra \ + --overwrite 2>&1 | tail -3 + +# Rewrite config with the port and paths the integration suite needs. +cat > "$CONFIG" <"$SERVER_LOG" 2>&1 + +for i in $(seq 1 30); do + if curl -sk "https://127.0.0.1:$BIND_PORT/health" >/dev/null 2>&1; then + echo " server healthy after ${i}s" + break + fi + sleep 1 +done + +if ! curl -sk "https://127.0.0.1:$BIND_PORT/health" >/dev/null 2>&1; then + echo "error: extenddb server did not become healthy" >&2 + tail -30 "$SERVER_LOG" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Delegate to run-tests +# --------------------------------------------------------------------------- + +echo "" +echo "=== Running test suite via devtools/run-tests --backend cassandra ===" +echo " passthrough args: ${RUN_TESTS_ARGS[*]}" +echo "" + +export EXTENDDB_TEST_ENDPOINT="https://127.0.0.1:$BIND_PORT" +export EXTENDDB_ADMIN_USER=admin +export EXTENDDB_ADMIN_PASSWORD="$ADMIN_PASSWORD" +export EXTENDDB_CONFIG="$CONFIG" +export TMPDIR="$TMP_CANON" + +RC=0 +devtools/run-tests --extenddb --backend cassandra --config "$CONFIG" "${RUN_TESTS_ARGS[@]}" || RC=$? + +exit $RC diff --git a/devtools/run-tests b/devtools/run-tests index fd981c874..4e9091e0c 100755 --- a/devtools/run-tests +++ b/devtools/run-tests @@ -135,9 +135,9 @@ done # --- Validate: backend name --- case "$BACKEND" in - postgres|sqlite|mongodb) ;; + postgres|sqlite|mongodb|cassandra) ;; *) - echo "error: --backend must be 'postgres', 'sqlite' or 'mongodb' (got: $BACKEND)" + echo "error: --backend must be 'postgres', 'sqlite', 'mongodb' or 'cassandra' (got: $BACKEND)" exit 1 ;; esac