Add multi-node cluster regression suite (compose stack + behave e2e) (#7026)

# Description of Changes

- The multi-node compose stack + behave suite (11 features)
- The nightly multinode-e2e job in build-enterprise.yml


cuke features are

cluster_health - both nodes boot healthy and join the Valkey backplane
load_balancing - traffic spreads across nodes; no spurious 401 when
bounced
cross_node_auth - a token from one node validates on all nodes (shared
DB keys)
shared_state - teams/sources/org visible from every node
policy_management - create/rename/delete a policy on any node, reflected
everywhere
source_management - source CRUD cross-node; referenced source can't be
deleted anywhere
connections - S3 connection resolves (secret masked) and deletes
cluster-wide
processor_ledger - files processed exactly once even when both nodes
trigger together
policy_run_coordination - a run on one node is visible from every node
rate_limiting - rate-limit counters shared via Valkey, not per node
failover - LB keeps serving when a node dies; recovered node accepts
existing tokens


can now start a full node system with 
export PREMIUM_KEY=<your licence key> ./start-multinode-test.sh
starts a 40 person org DB install with multi node and database
(--no-seed to have without DB on startup)
4 teams
1 s3 connection
1 policy

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-08-01 17:09:11 +01:00
committed by GitHub
parent aba9275ea7
commit 547dce4cf3
20 changed files with 1481 additions and 7 deletions
+67
View File
@@ -295,3 +295,70 @@ jobs:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
multinode-e2e:
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
always() && needs.pick.outputs.is_fork != 'true'
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 60
env:
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
PREMIUM_ENABLED: "true"
SYSTEM_ENABLEANALYTICS: "false"
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
MN_COMPOSE: docker-compose-multinode.yml
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Install behave test deps
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
- name: Build the multi-node image
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" build
- name: Bring up the cluster and wait for both nodes healthy
working-directory: testing/compose
run: |
docker compose -f "$MN_COMPOSE" up -d
for i in $(seq 1 90); do
h1=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null || echo starting)
h2=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-2 2>/dev/null || echo starting)
if [ "$h1" = healthy ] && [ "$h2" = healthy ]; then echo "both nodes healthy"; exit 0; fi
sleep 5
done
echo "::error::nodes did not become healthy"
docker compose -f "$MN_COMPOSE" logs --tail=200 stirling-1 stirling-2
exit 1
- name: Seed the cluster (teams, users, S3 connection, policy)
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" --profile seed run --rm seed
- name: Run multi-node regression (implemented guarantees)
working-directory: testing/cucumber
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
- name: Run multi-node failover (destructive)
working-directory: testing/cucumber
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
- name: Dump node logs on failure
if: failure()
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" logs --tail=400 stirling-1 stirling-2
- name: Tear down
if: always()
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" --profile seed down -v --remove-orphans
@@ -0,0 +1,225 @@
# Multi-node Stirling-PDF processor test stack: shared Postgres/MinIO/Valkey behind an nginx LB fronting N app nodes, with enterprise features unlocked via a local test licence key.
# Bring up: ./start-multinode-test.sh Validate: ./validate-multinode-test.sh Access: http://localhost:8080 (admin / stirling)
x-stirling-node: &stirling-node
build:
context: ../..
dockerfile: docker/embedded/Dockerfile
image: stirling-pdf-multinode:local
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
minio-init:
condition: service_completed_successfully
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/info/status | grep -q UP"]
interval: 10s
timeout: 10s
retries: 30
start_period: 90s
environment: &stirling-env
# --- Licensing: unlock pro/enterprise (custom DB + cluster mode) ---
# Cluster mode is licence-gated - set PREMIUM_KEY (locally: export PREMIUM_KEY=...; CI: the PREMIUM_KEY_ENTERPRISE secret); the default below is a non-functional placeholder, never a real key.
PREMIUM_ENABLED: "true"
PREMIUM_KEY: "${PREMIUM_KEY:-00000000-0000-0000-0000-000000000000}"
DISABLE_ADDITIONAL_FEATURES: "false"
# --- Login + teams ---
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "admin"
SECURITY_INITIALLOGIN_PASSWORD: "stirling"
# Global API key: lets the seed/validate scripts call admin APIs without a login flow.
SECURITY_CUSTOMGLOBALAPIKEY: "multinode-test-key"
# --- Shared Postgres (the single DB every node coordinates through) ---
# enableCustomDatabase is an enterprise feature, unlocked by PREMIUM_* above.
SYSTEM_DATASOURCE_ENABLECUSTOMDATABASE: "true"
SYSTEM_DATASOURCE_TYPE: "postgresql"
SYSTEM_DATASOURCE_HOSTNAME: "postgres"
SYSTEM_DATASOURCE_PORT: "5432"
SYSTEM_DATASOURCE_NAME: "stirling"
SYSTEM_DATASOURCE_USERNAME: "stirling"
SYSTEM_DATASOURCE_PASSWORD: "stirling"
# User.settings has no @Lob annotation - with one, Postgres treats it as a large object and every login 500s ("Large Objects may not be used in auto-commit mode"); see multinode/README.md.
# --- Shared object storage (persistent user uploads + share/store feature) ---
STORAGE_ENABLED: "true"
STORAGE_PROVIDER: "s3"
STORAGE_S3_ENDPOINT: "http://minio:9000"
STORAGE_S3_BUCKET: "stirling-storage"
STORAGE_S3_REGION: "us-east-1"
STORAGE_S3_ACCESSKEY: "minioadmin"
STORAGE_S3_SECRETKEY: "minioadmin"
STORAGE_S3_PATHSTYLEACCESS: "true"
STORAGE_S3_ALLOWPRIVATEENDPOINTS: "true"
# --- Cluster mode: Valkey backplane + shared S3 job-artifact store ---
# artifactStore=s3 is REQUIRED for multi-node (transient job artifacts must be shared).
CLUSTER_ENABLED: "true"
CLUSTER_BACKPLANE: "valkey"
CLUSTER_ARTIFACTSTORE: "s3"
CLUSTER_VALKEY_URL: "redis://valkey:6379"
# SPRING_DATA_REDIS_REPOSITORIES_ENABLED is not needed: DataRedisRepositoriesAutoConfiguration is excluded (see application.properties, multinode/README.md).
# --- Shared credential-encryption key (REQUIRED in cluster mode) ---
# AES-256 key that encrypts stored integration/S3 secrets - must match on every node or secrets encrypted on one can't decrypt on another; boot fails if unset with cluster.enabled=true (test-only value; JWT keys persist separately in the shared DB).
STIRLING_CREDENTIAL_ENCRYPTION_KEY: "dMobekyUEnEV7WHBah2FkbboP4Coqifd3JRXB00LiIY="
# --- Policy / processor subsystem (the thing under test) ---
POLICIES_ENABLED: "true"
# Let policy S3 sources/webhook-staging connections point at the in-cluster MinIO.
POLICIES_ALLOWPRIVATES3ENDPOINTS: "true"
# --- Misc features on ---
METRICS_ENABLED: "true"
SYSTEM_DEFAULTLOCALE: "en-US"
SYSTEM_MAXFILESIZE: "100"
UI_APPNAME: "Stirling-PDF Multi-Node"
UI_APPNAMENAVBAR: "Stirling Multi-Node"
networks:
- stirling-multinode
services:
# ---- Shared Postgres: the single DB all nodes coordinate through ----------
postgres:
image: postgres:17-alpine
container_name: multinode-postgres
environment:
POSTGRES_USER: stirling
POSTGRES_PASSWORD: stirling
POSTGRES_DB: stirling
ports:
- "5434:5432" # host access for inspection (psql -h localhost -p 5434 -U stirling)
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U stirling -d stirling"]
interval: 3s
timeout: 5s
retries: 30
networks:
- stirling-multinode
# ---- Valkey: cluster backplane (shared job state / rate limiting) ---------
valkey:
image: valkey/valkey:8-alpine
container_name: multinode-valkey
command: ["valkey-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"]
interval: 3s
timeout: 5s
retries: 30
networks:
- stirling-multinode
# ---- MinIO: shared S3 (platform storage + policy connections + webhook staging) ----
minio:
image: minio/minio:latest
container_name: multinode-minio
command: ["server", "/data", "--console-address", ":9001"]
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000" # S3 API
- "9001:9001" # web console (minioadmin / minioadmin)
volumes:
- minio-data:/data
healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"]
interval: 5s
timeout: 5s
retries: 30
networks:
- stirling-multinode
# ---- One-shot: create the buckets the stack needs -------------------------
minio-init:
image: minio/mc:latest
container_name: multinode-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin &&
mc mb --ignore-existing local/stirling-storage &&
mc mb --ignore-existing local/policy-data &&
echo 'buckets ready: stirling-storage, policy-data'
"
networks:
- stirling-multinode
# ---- App node 1 -----------------------------------------------------------
stirling-1:
<<: *stirling-node
container_name: multinode-stirling-1
environment:
<<: *stirling-env
SYSTEM_NODEID: "node-1"
# ---- App node 2 -----------------------------------------------------------
# No ordering vs node-1 needed - each node mints its own signing key at boot and publishes the public half to the shared DB, so both verify each other's tokens.
stirling-2:
<<: *stirling-node
container_name: multinode-stirling-2
environment:
<<: *stirling-env
SYSTEM_NODEID: "node-2"
# ---- One-shot seeder: teams, ~40 users, S3 connection, policies -----------
# Profile-gated so plain `docker compose up` skips it; the start script runs it once, using postgres:alpine for psql with curl+jq added for the HTTP calls.
seed:
image: postgres:17-alpine
container_name: multinode-seed
profiles: ["seed"]
depends_on:
stirling-1:
condition: service_healthy
stirling-2:
condition: service_healthy
environment:
BASE_URL: "http://nginx:8080"
PGHOST: "postgres"
PGUSER: "stirling"
PGPASSWORD: "stirling"
PGDATABASE: "stirling"
USER_COUNT: "40"
volumes:
- ./multinode/seed.sh:/seed.sh:ro
entrypoint: ["/bin/sh", "-c", "apk add --no-cache curl jq >/dev/null && sh /seed.sh"]
networks:
- stirling-multinode
# ---- nginx load balancer: single entrypoint round-robining the nodes ------
nginx:
image: nginx:1.27-alpine
container_name: multinode-nginx
depends_on:
stirling-1:
condition: service_healthy
stirling-2:
condition: service_healthy
ports:
- "8080:8080"
volumes:
- ./multinode/nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- stirling-multinode
networks:
stirling-multinode:
driver: bridge
volumes:
postgres-data:
minio-data:
+49
View File
@@ -0,0 +1,49 @@
worker_processes 1;
events { worker_connections 1024; }
http {
# Round-robin across the app nodes. Add a node here to scale out.
# max_fails=1 marks a node down after a single failure so a dead node drains fast.
upstream stirling_nodes {
server stirling-1:8080 max_fails=1 fail_timeout=10s;
server stirling-2:8080 max_fails=1 fail_timeout=10s;
}
# Large uploads (SYSTEM_MAXFILESIZE=100MB) plus headroom.
client_max_body_size 200m;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 8080;
location / {
proxy_pass http://stirling_nodes;
# Graceful failover: retries the other node only on connection-level failures (unreachable/timeout), never on 5xx, so a POST a node already started is never re-sent.
proxy_next_upstream error timeout;
proxy_next_upstream_tries 2;
proxy_connect_timeout 3s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket / SSE upgrade support (policy run streaming).
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Stream server-sent events straight through, don't buffer.
proxy_buffering off;
proxy_read_timeout 3600s;
# Surface which app node served the request, so the validate script can prove the LB is spreading load.
add_header X-Served-By $upstream_addr always;
}
}
}
+140
View File
@@ -0,0 +1,140 @@
#!/bin/sh
# Seeds a running multi-node stack: 4 teams, ~40 users, an S3 connection, a scheduled S3 policy, and a webhook source if the build supports it.
# Auth uses the Bearer JWT from the login response body (not a cookie) since the global API key can't create teams.
# Idempotent-ish: re-running skips existing teams/users; each step is best-effort and logs failures without aborting.
set -u
BASE_URL="${BASE_URL:-http://localhost:8080}"
ADMIN_USER="${ADMIN_USER:-admin}"
ADMIN_PASS="${ADMIN_PASS:-stirling}"
USER_COUNT="${USER_COUNT:-40}"
USER_PASS="${USER_PASS:-Password123!}"
PGHOST="${PGHOST:-postgres}"
PGUSER="${PGUSER:-stirling}"
PGPASSWORD="${PGPASSWORD:-stirling}"
PGDATABASE="${PGDATABASE:-stirling}"
export PGPASSWORD
TEAMS="Engineering Finance Legal Operations"
log() { echo "[seed] $*"; }
psqlq() { psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -tAc "$1" 2>/dev/null | tr -d '[:space:]'; }
# --- wait for the load balancer to serve a healthy app -----------------------
log "waiting for $BASE_URL ..."
i=0
until curl -fsS "$BASE_URL/api/v1/info/status" 2>/dev/null | grep -q UP; do
i=$((i+1)); [ "$i" -gt 120 ] && { log "timed out waiting for API"; exit 1; }
sleep 3
done
log "API is up"
# --- admin login -> Bearer token ---------------------------------------------
code=$(curl -sS -o /tmp/login.json -w '%{http_code}' \
-X POST "$BASE_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}")
log "admin login: HTTP $code"
[ "$code" = "200" ] || { log "login failed: $(cat /tmp/login.json)"; exit 1; }
TOKEN=$(jq -r '.session.access_token' </tmp/login.json)
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || { log "no access_token in login response"; exit 1; }
auth() { curl -sS -H "Authorization: Bearer $TOKEN" "$@"; }
# --- teams -------------------------------------------------------------------
for t in $TEAMS; do
code=$(auth -o /dev/null -w '%{http_code}' -X POST "$BASE_URL/api/v1/team/create" \
--data-urlencode "name=$t")
log "team '$t': HTTP $code"
done
# Resolve team ids from the DB (no admin list endpoint self-hosted).
seed_team_ids=""
for t in $TEAMS; do
id=$(psqlq "select team_id from teams where name='$t' limit 1")
[ -n "$id" ] && seed_team_ids="$seed_team_ids $id"
done
set -- $seed_team_ids
team_count=$#
log "seedable team ids:$seed_team_ids (count=$team_count)"
# --- users: spread across teams, first two are admins ------------------------
created=0; failed=0
n=1
while [ "$n" -le "$USER_COUNT" ]; do
uname=$(printf "user%02d@stirling.test" "$n")
role="ROLE_USER"; [ "$n" -le 2 ] && role="ROLE_ADMIN"
team_id=""
if [ "$team_count" -gt 0 ]; then
idx=$(( (n % team_count) + 1 )); team_id=$(eval echo "\${$idx}")
fi
code=$(auth -o /tmp/user.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/user/admin/saveUser" \
--data-urlencode "username=$uname" \
--data-urlencode "password=$USER_PASS" \
--data-urlencode "role=$role" \
${team_id:+--data-urlencode "teamId=$team_id"} \
--data-urlencode "authType=WEB" \
--data-urlencode "forceChange=false")
case "$code" in
200|201) created=$((created+1));;
409) log "user $uname already exists";;
*) failed=$((failed+1)); [ "$failed" -le 3 ] && log "user $uname failed HTTP $code: $(cat /tmp/user.json)";;
esac
n=$((n+1))
done
log "users created: $created (failed: $failed, requested: $USER_COUNT)"
# --- S3 connection -> the in-cluster MinIO 'policy-data' bucket ---------------
conn_body=$(cat <<JSON
{"integrationType":"S3","name":"MinIO policy bucket","scope":"SERVER","enabled":true,"locked":false,"defaultAccess":"ORG_ALL",
"config":{"bucket":"policy-data","region":"us-east-1","endpoint":"http://minio:9000","accessKeyId":"minioadmin","secretAccessKey":"minioadmin","pathStyleAccess":true}}
JSON
)
conn_id=$(auth -X POST "$BASE_URL/api/v1/integrations" -H 'Content-Type: application/json' -d "$conn_body" \
| jq -r '.id // empty' 2>/dev/null)
log "S3 connection id: ${conn_id:-<none>}"
# --- a scheduled S3 -> compress -> S3 policy ---------------------------------
if [ -n "${conn_id:-}" ]; then
src_body=$(cat <<JSON
{"name":"Incoming S3","type":"s3","enabled":true,
"options":{"connectionId":$conn_id,"prefix":"incoming/","mode":"consume"}}
JSON
)
src_id=$(auth -X POST "$BASE_URL/api/v1/sources" -H 'Content-Type: application/json' -d "$src_body" \
| jq -r '.id // empty' 2>/dev/null)
log "S3 source id: ${src_id:-<none>}"
if [ -n "${src_id:-}" ]; then
pol_body=$(cat <<JSON
{"name":"Compress incoming PDFs","enabled":true,
"trigger":{"type":"schedule","options":{"schedule":{"type":"every","count":5,"unit":"MINUTES"}}},
"sourceIds":["$src_id"],
"steps":[{"operation":"/api/v1/misc/compress-pdf","parameters":{}}],
"output":{"type":"s3","options":{"connectionId":$conn_id,"prefix":"processed/"}}}
JSON
)
code=$(auth -o /tmp/pol.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/policies" \
-H 'Content-Type: application/json' -d "$pol_body")
log "policy create: HTTP $code $( [ "$code" != 200 ] && head -c 160 /tmp/pol.json )"
fi
# --- webhook source + policy (only if this build has the webhook type) -----
wh_body=$(cat <<JSON
{"name":"Partner webhook","type":"webhook","enabled":true,
"options":{"connectionId":$conn_id,"mode":"consume"}}
JSON
)
wh=$(auth -o /tmp/wh.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/sources" \
-H 'Content-Type: application/json' -d "$wh_body")
if [ "$wh" = "200" ] || [ "$wh" = "201" ]; then
wh_url=$(jq -r '.options.webhookId // empty' </tmp/wh.json 2>/dev/null)
log "webhook source created (deliver to /api/v1/webhooks/$wh_url)"
else
log "webhook source not created (HTTP $wh) - expected on builds without the webhook branch"
fi
fi
log "seed complete."
log " login: $ADMIN_USER / $ADMIN_PASS at $BASE_URL"
log " users: user01..$(printf '%02d' "$USER_COUNT")@stirling.test / $USER_PASS"
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Runs the multi-node regression suite (behave features/multinode) against the clustered stack: brings it up if needed, runs non-destructive scenarios then @destructive failover ones, and restores any killed node.
# Usage: ./run-multinode-regression.sh [--no-failover] [--no-seed]
# @known_gap scenarios are expected to fail - they mark work not yet done, so a non-zero exit is fine while those are open.
set -uo pipefail
cd "$(dirname "$0")"
COMPOSE="docker compose -f docker-compose-multinode.yml"
CUKE_DIR="../cucumber"
RUN_FAILOVER=1
SEED=1
for arg in "$@"; do
case "$arg" in
--no-failover) RUN_FAILOVER=0 ;;
--no-seed) SEED=0 ;;
esac
done
echo "==> Ensuring the multi-node stack is up..."
if ! docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null | grep -q healthy; then
./start-multinode-test.sh $([ "$SEED" = 0 ] && echo --no-seed) || exit 1
elif [ "$SEED" = 1 ]; then
echo " stack already up; seeding (idempotent)..."
$COMPOSE --profile seed run --rm seed >/dev/null 2>&1 || echo " (seed reported issues, continuing)"
fi
echo "==> Checking Python + behave..."
PY="${PYTHON:-python}"
command -v "$PY" >/dev/null || PY=python3
if ! "$PY" -c "import behave" 2>/dev/null; then
echo " installing test deps..."
"$PY" -m pip install -q -r "$CUKE_DIR/requirements.txt" || {
echo " could not install behave; install $CUKE_DIR/requirements.txt manually"; exit 1; }
fi
REPORT_DIR="$(pwd)/multinode/regression-report"
mkdir -p "$REPORT_DIR"
run_behave() { # $1=tags $2=label
echo "==> behave features/multinode --tags='$1' ($2)"
# behave.ini excludes features/multinode by default; -e here overrides that while still excluding the licence-gated enterprise suite.
( cd "$CUKE_DIR" && "$PY" -m behave features/multinode -e "features/enterprise" \
--tags="$1" --no-capture --format plain --format html --outfile "$REPORT_DIR/$2.html" )
return $?
}
rc=0
run_behave "~@destructive" "core" || rc=1
if [ "$RUN_FAILOVER" = 1 ]; then
run_behave "@destructive" "failover" || rc=1
echo "==> Restoring any killed nodes..."
$COMPOSE up -d >/dev/null 2>&1
for n in multinode-stirling-1 multinode-stirling-2; do
for i in $(seq 1 24); do
[ "$(docker inspect -f '{{.State.Health.Status}}' "$n" 2>/dev/null)" = "healthy" ] && break
sleep 5
done
done
fi
echo
echo "============================================================"
echo " Regression run complete. Reports: $REPORT_DIR"
echo " Exit $rc (non-zero = at least one scenario failed;"
echo " @known_gap scenarios are expected to fail - see the report)."
echo " Stack left running: http://localhost:8080 (admin / stirling)"
echo "============================================================"
exit $rc
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Brings up the multi-node stack (Postgres/Valkey/MinIO/2 app nodes/nginx LB), seeds teams/users/an S3 connection/policies, then leaves it running for manual testing.
# Usage: ./start-multinode-test.sh [--no-seed | --down]
set -euo pipefail
cd "$(dirname "$0")"
COMPOSE="docker compose -f docker-compose-multinode.yml"
if [ "${1:-}" = "--down" ]; then
echo "Tearing down multi-node stack + volumes..."
$COMPOSE --profile seed down -v --remove-orphans
exit 0
fi
SEED=1
[ "${1:-}" = "--no-seed" ] && SEED=0
# Cluster mode is licence-gated. Without a valid key the nodes fail the cluster licence gate at boot.
if [ -z "${PREMIUM_KEY:-}" ]; then
echo "WARNING: PREMIUM_KEY is not set - cluster mode needs a valid enterprise/pro licence key."
echo " Run: export PREMIUM_KEY=<your test licence key> before starting."
fi
echo "==> Building the Stirling image (first run compiles the app; be patient)..."
$COMPOSE build
echo "==> Starting Postgres + Valkey + MinIO + 2 app nodes + nginx..."
$COMPOSE up -d
echo "==> Waiting for both app nodes to report healthy..."
for node in multinode-stirling-1 multinode-stirling-2; do
for i in $(seq 1 60); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$node" 2>/dev/null || echo "starting")
[ "$status" = "healthy" ] && { echo " $node: healthy"; break; }
[ "$i" = "60" ] && { echo " $node did not become healthy; see: $COMPOSE logs $node"; exit 1; }
sleep 5
done
done
if [ "$SEED" = "1" ]; then
echo "==> Seeding teams / users / S3 connection / policies..."
$COMPOSE --profile seed run --rm seed || echo " (seed reported issues; check output above)"
fi
cat <<EOF
============================================================================
Multi-node Stirling is UP.
App (via load balancer): http://localhost:8080 (admin / stirling)
MinIO console: http://localhost:9001 (minioadmin / minioadmin)
Postgres: localhost:5434 (stirling / stirling, db 'stirling')
Seeded users: user01..user40@stirling.test / Password123!
Global API key: multinode-test-key (header: X-API-KEY)
Try it:
./validate-multinode-test.sh # multi-node smoke tests (optional)
$COMPOSE logs -f stirling-1 # tail a node
./start-multinode-test.sh --down # stop + wipe
Nodes are reachable directly for cross-node checks:
docker compose -f docker-compose-multinode.yml exec stirling-1 curl -s localhost:8080/api/v1/info/status
============================================================================
EOF
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Multi-node smoke tests against a running stack (start-multinode-test.sh): load-balancer spread, cross-node JWT validation (signing keys persist in the shared DB), and processor state visible from every node.
# Auth: extracts the Bearer JWT from the login body via sed (no jq needed host-side) and hits nodes directly with docker exec.
# Non-destructive - safe to re-run against the stack at http://localhost:8080.
set -uo pipefail
cd "$(dirname "$0")"
LB="http://localhost:8080"
ADMIN_USER="admin"; ADMIN_PASS="stirling"
NODES="multinode-stirling-1 multinode-stirling-2"
# An authed, admin-visible endpoint that returns 200 with a valid token, 401 without.
PROBE="/api/v1/sources"
pass=0; fail=0
ok() { echo " PASS - $*"; pass=$((pass+1)); }
bad() { echo " FAIL - $*"; fail=$((fail+1)); }
login() { # -> prints the bearer token
curl -s -X POST "$LB/api/v1/auth/login" -H 'Content-Type: application/json' \
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}
echo "== 1. Load balancer spreads requests across nodes =="
served=$(for i in $(seq 1 12); do
curl -s -D - -o /dev/null "$LB/api/v1/info/status" | tr -d '\r' | awk -F': ' '/^X-Served-By/{print $2}'
done | sort -u)
distinct=$(printf '%s\n' "$served" | grep -c .)
echo " upstreams seen: $(printf '%s' "$served" | paste -sd, -)"
[ "$distinct" -ge 2 ] && ok "LB round-robined across $distinct nodes" \
|| bad "only $distinct node(s) served (expected >=2; is X-Served-By enabled?)"
echo "== 2. A JWT from the LB is accepted by BOTH nodes directly (shared signing key) =="
jwt=$(login)
if [ -z "$jwt" ]; then
bad "admin login via LB failed - cannot test cross-node JWT"
else
ok "logged in via LB, got a JWT (${#jwt} chars)"
for n in $NODES; do
hc=$(docker exec "$n" curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $jwt" "http://localhost:8080$PROBE" 2>/dev/null)
[ "$hc" = "200" ] && ok "$n accepted the foreign-minted JWT (HTTP 200)" \
|| bad "$n rejected the JWT (HTTP $hc) - keys not shared across nodes"
done
fi
echo "== 3. Processor state is shared: each node sees the same sources =="
count_of() { # $1=node -> number of sources that node reports
docker exec "$1" curl -s -H "Authorization: Bearer $jwt" "http://localhost:8080$PROBE" 2>/dev/null \
| grep -o '"id"' | grep -c .
}
a=$(count_of multinode-stirling-1); b=$(count_of multinode-stirling-2)
echo " stirling-1 sources: $a stirling-2 sources: $b"
if [ "$a" -gt 0 ] && [ "$a" = "$b" ]; then
ok "both nodes report the same $a sources (shared DB)"
else
bad "source counts differ or zero across nodes ($a vs $b)"
fi
echo "== 4. Seeded org is in the shared DB =="
users=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from users" 2>/dev/null | tr -d '[:space:]')
teams=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from teams" 2>/dev/null | tr -d '[:space:]')
conns=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from integration_configs" 2>/dev/null | tr -d '[:space:]')
echo " users=$users teams=$teams integration_configs=$conns"
[ "${users:-0}" -ge 40 ] && ok "$users users present" || bad "only ${users:-0} users (did the seed run?)"
[ "${conns:-0}" -ge 1 ] && ok "$conns S3/integration connection(s) present" || bad "no integration connections"
echo "== 5. Cross-node encrypted-secret read (shared credential key) =="
# The seed's S3 secret was encrypted by whichever node handled it; fetching it via the LB (either node) and getting a masked, non-error view proves the credential key is shared, not per-node.
lc=$(curl -s -o /tmp/mn_conns.json -w '%{http_code}' -H "Authorization: Bearer $jwt" "$LB/api/v1/integrations")
if [ "$lc" = "200" ] && grep -q '"integrationType"' /tmp/mn_conns.json; then
ok "integration list decrypts through the LB (HTTP 200) - credential key is shared"
else
bad "integration list failed (HTTP $lc) - credential key may not be shared across nodes"
fi
echo
echo "============================================================"
echo " Multi-node validation: $pass passed, $fail failed."
echo " Stack left running: $LB (admin / stirling)"
echo "============================================================"
[ "$fail" -eq 0 ]
+7 -7
View File
@@ -1,9 +1,9 @@
[behave]
# Enterprise and premium-licensed features live in features/enterprise/.
# They are excluded from the default CI run because the test environment
# does not have a commercial licence. To run them explicitly:
#
# python -m behave features/enterprise
#
exclude_re = features/enterprise
# features/enterprise (needs a commercial licence) and features/multinode (needs the clustered stack in testing/compose/docker-compose-multinode.yml) are excluded by default.
# Run either directly, e.g. `python -m behave features/enterprise`.
exclude_re = features/(enterprise|multinode)
tags = ~@manual
[behave.formatters]
# Registers the html report formatter (behave-html-formatter) used by run-multinode-regression.sh.
html = behave_html_formatter:HTMLFormatter
@@ -0,0 +1,18 @@
@multinode @health
Feature: Multi-node cluster health
Every node must come up healthy and join the shared Valkey backplane - the precondition for the rest of the suite (stack: testing/compose/docker-compose-multinode.yml).
@smoke
Scenario: Both application nodes are healthy
Given the multi-node stack is running
@smoke
Scenario: Both nodes joined the Valkey backplane in cluster mode
Given the multi-node stack is running
And both nodes are cluster members using the Valkey backplane
Scenario: The load balancer answers the health endpoint
Given the multi-node stack is running
When I request "/api/v1/info/status" 4 times through the load balancer
Then every load-balanced response should be 200
@@ -0,0 +1,12 @@
@multinode @connections
Feature: S3 connections across nodes
Integration connections are team-scoped, secret-encrypted rows in the shared DB - a connection created via the LB must resolve (secret masked) from every node and deletion must remove it cluster-wide.
Scenario: A connection created via the LB resolves everywhere, then deletes cluster-wide
Given the multi-node stack is running
And I am logged in as admin
When I create an S3 connection named "regr_conn_alpha" via the load balancer
Then the connection "regr_conn_alpha" should resolve from every node with its secret masked
When I delete the connection via the load balancer
Then the connection should be gone from every node
@@ -0,0 +1,14 @@
@multinode @auth
Feature: Cross-node authentication
JWTs are signed RS256 and verified by key id, so the signing keys (stored in the shared DB, private half encrypted) must be shared for a token minted on one node to validate on another.
Scenario: A token minted through the LB validates on every node directly
Given the multi-node stack is running
And I am logged in as admin
Then the current token should be accepted by every node
Scenario: Signing keys are persisted in the shared database, encrypted
Given the multi-node stack is running
Then the signing keys should be stored in the shared database
And every stored private key should be encrypted at rest
@@ -0,0 +1,21 @@
@multinode @failover @destructive
Feature: Node failover and recovery
Losing a node must not take the service down: the LB drains it and the survivor keeps serving, then the node rejoins healthy on restart. These scenarios kill/restart containers, so they are @destructive - pass --tags=failover to run them.
@destructive
Scenario: The load balancer keeps serving when a node dies
Given the multi-node stack is running
When I kill node "2"
Then the load balancer should still serve requests
When I restart node "2"
Then node "2" should become healthy again within 120s
@destructive
Scenario: A recovered node validates tokens minted while it was down
Given the multi-node stack is running
And I am logged in as admin
When I kill node "2"
And I restart node "2"
Then node "2" should become healthy again within 120s
And the current token should be accepted by every node
@@ -0,0 +1,15 @@
@multinode @loadbalancing
Feature: Load balancer distributes across nodes
Requests must spread across nodes via round-robin with no session affinity, and a client bounced between nodes must never see a spurious failure since the app is stateless at the HTTP layer.
Scenario: Requests are spread across both nodes
Given the multi-node stack is running
When I request "/api/v1/info/status" 12 times through the load balancer
Then the requests should be served by at least 2 distinct nodes
Scenario: An authenticated client bounced between nodes never gets a spurious 401
Given the multi-node stack is running
And I am logged in as admin
When I request "/api/v1/sources" 12 times through the load balancer
Then every load-balanced response should be 200
@@ -0,0 +1,19 @@
@multinode @policy
Feature: Policy management across nodes
A policy is durable state in the shared DB, so creating, editing, or deleting it on any node must be reflected on every other node, and both nodes must register the same trigger types.
Scenario: A policy created on one node is edited and deleted from the other
Given the multi-node stack is running
And I am logged in as admin
When I create a policy named "regr_pol_alpha" on node "1"
Then the policy "regr_pol_alpha" should be visible from every node
When I rename the policy "regr_pol_alpha" to "regr_pol_beta" via the load balancer
Then the policy "regr_pol_beta" should be visible from every node
When I delete the policy "regr_pol_beta" on node "2"
Then the policy "regr_pol_beta" should be absent from every node
Scenario: Every node registers the same trigger types
Given the multi-node stack is running
And I am logged in as admin
Then the trigger registry should be identical across nodes
@@ -0,0 +1,10 @@
@multinode @processor
Feature: Policy run visibility across nodes
Run state is projected into the shared job store, so the run-view endpoints (/policies/runs, /run/{runId}) show a run from any node, not only the one that executed it - no sticky-session LB required.
Scenario: A run executed on one node is visible from every node
Given the multi-node stack is running
And I am logged in as admin
When I run the policy "Compress incoming PDFs" on node "1"
Then the run should be visible from every node
@@ -0,0 +1,20 @@
@multinode @processor
Feature: Processor pipeline and exactly-once ledger
A shared processed-file ledger with an atomic (identity_hash, policy_id) primary key ensures exactly-once processing when several nodes race the same scheduled policy; depends on the seeded "Compress incoming PDFs" policy.
@slow
Scenario: Files dropped in the source are processed across the cluster when both
nodes trigger the policy at the same instant
Given the multi-node stack is running
And I am logged in as admin
And the processor workspace is clean
When I drop 5 PDF file(s) into the S3 source under "incoming/"
And I trigger the policy "Compress incoming PDFs" on every node simultaneously
Then within 90s every dropped file should be processed across the cluster
Scenario: The ledger's atomic claim enforces exactly-once
Given the multi-node stack is running
Then a duplicate ledger claim for the same file and policy is rejected
# Note: output lands in the source's own bucket so it can be re-ingested; too flaky to assert under consume-mode pruning.
@@ -0,0 +1,9 @@
@multinode @ratelimit
Feature: Shared rate limiting via the backplane
Rate limits must be enforced across all nodes, not per node, or a client's effective limit multiplies by the node count - cluster mode routes counters through Valkey (ValkeyRateLimitStore).
Scenario: Backplane counters are held in Valkey, not per node
Given the multi-node stack is running
And both nodes are cluster members using the Valkey backplane
Then the rate-limit counter should be shared across nodes
@@ -0,0 +1,20 @@
@multinode @state
Feature: Shared state across nodes
All durable state lives in the shared database, so an object created through the LB (landing on whichever node) is immediately visible from every other node - the cluster is a single logical system.
Scenario: A team created through the LB lands in the shared database
Given the multi-node stack is running
And I am logged in as admin
When I create a team named "regr_shared_team" through the load balancer
Then the team "regr_shared_team" should exist in the shared database
Scenario: Every node reports the same sources
Given the multi-node stack is running
And I am logged in as admin
Then every node should report the same number of sources
Scenario: The seeded org is present in the shared database
Given the multi-node stack is running
Then the "users" table should contain at least 40 row(s)
And the "integration_configs" table should contain at least 1 row(s)
@@ -0,0 +1,25 @@
@multinode @sources
Feature: Source management across nodes
Sources are durable state. A source created on one node must be usable from
every node, and the guard that stops a referenced source from being deleted
must hold across nodes - even when the source and the referencing policy were
created on different nodes.
Scenario: A source created on one node is visible and deletable from the other
Given the multi-node stack is running
And I am logged in as admin
When I create an S3 source named "regr_src_alpha" on node "1"
Then the source "regr_src_alpha" should be visible from every node
When I delete the source "regr_src_alpha" on node "2"
Then the source "regr_src_alpha" should be absent from every node
Scenario: A referenced source cannot be deleted from another node
Given the multi-node stack is running
And I am logged in as admin
When I create an S3 source named "regr_src_ref" on node "1"
And I create a policy named "regr_pol_ref" referencing source "regr_src_ref" via the load balancer
Then deleting the source "regr_src_ref" from node "2" is rejected because it is referenced
When I delete the policy "regr_pol_ref" on node "2"
And I delete the source "regr_src_ref" on node "1"
Then the source "regr_src_ref" should be absent from every node
@@ -0,0 +1,595 @@
"""Step definitions for the multi-node regression suite: drive the stack (testing/compose/docker-compose-multinode.yml) via the LB, docker exec curl/psql on individual nodes, and a throwaway minio/mc container."""
import io
import json
import subprocess
import time
import uuid
import requests
from behave import given, then, when
LB_URL = "http://localhost:8080"
NODES = ["multinode-stirling-1", "multinode-stirling-2"]
PG = "multinode-postgres"
MINIO = "multinode-minio"
BUCKET = "policy-data"
SOURCE_PREFIX = "incoming/"
OUTPUT_PREFIX = "processed/"
ADMIN_USER = "admin"
ADMIN_PASS = "stirling"
# --------------------------------------------------------------------------- helpers
def _sh(args, stdin=None, timeout=60):
"""Run a command, return (returncode, stdout, stderr)."""
r = subprocess.run(
args, input=stdin, capture_output=True, timeout=timeout,
text=(stdin is None or isinstance(stdin, str)),
)
out = r.stdout if isinstance(r.stdout, str) else r.stdout.decode("utf-8", "replace")
err = r.stderr if isinstance(r.stderr, str) else r.stderr.decode("utf-8", "replace")
return r.returncode, out, err
def _psql(query):
"""Run a query against the shared Postgres, return the raw tab/newline output (trimmed)."""
rc, out, err = _sh(
["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", "-tAc", query]
)
assert rc == 0, f"psql failed: {err.strip() or out.strip()}"
return out.strip()
def _psql_int(query):
val = _psql(query)
return int(val) if val else 0
def _network():
rc, out, _ = _sh(
["docker", "inspect", "-f",
"{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}", NODES[0]]
)
return out.strip() or "compose_stirling-multinode"
def _token(context):
tok = getattr(context, "jwt_token", None)
assert tok, "No JWT token in context - use 'Given I am logged in as admin' first."
return tok
def _curl_on_node(node, method, path, token=None, data=None, content_type=None, timeout=30):
"""Hit a node's own :8080 from inside the cluster (bypasses the LB). Returns (status, body)."""
cmd = ["docker", "exec", node, "curl", "-s", "-w", "\n%{http_code}", "-X", method]
if token:
cmd += ["-H", f"Authorization: Bearer {token}"]
if content_type:
cmd += ["-H", f"Content-Type: {content_type}"]
if data is not None:
cmd += ["--data", data]
cmd.append(f"http://localhost:8080{path}")
rc, out, err = _sh(cmd, timeout=timeout)
# Body then a newline then the status code; split the status off the end.
text = out.rstrip("\n")
body, _, status = text.rpartition("\n")
return (int(status.strip()) if status.strip().isdigit() else 0), body
def _node(idx):
return NODES[int(idx) - 1]
def _names_on_node(node, path, token):
"""GET a list endpoint on a node and return the set of resource names it reports."""
status, body = _curl_on_node(node, "GET", path, token=token)
assert status == 200, f"{node} GET {path} returned HTTP {status}: {body[:150]}"
try:
data = json.loads(body)
except ValueError:
return set()
items = data if isinstance(data, list) else data.get(path.rsplit("/", 1)[-1], []) \
or data.get("sources", []) or data.get("policies", [])
return {i.get("name") for i in items if isinstance(i, dict)}
def _policy_body(name, source_ids=None, enabled=True):
return json.dumps({
"name": name, "enabled": enabled, "trigger": None,
"sourceIds": source_ids or [],
"steps": [{"operation": "/api/v1/misc/compress-pdf", "parameters": {}}],
"output": {"type": "inline", "options": {}},
})
def _any_connection_id(context):
"""Id of any usable S3 connection (the seed creates one). Cached on the context."""
cid = getattr(context, "_seed_conn_id", None)
if cid:
return cid
r = requests.get(f"{LB_URL}/api/v1/integrations",
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
assert r.status_code == 200, f"list integrations failed: HTTP {r.status_code}"
s3 = next((c for c in r.json() if c.get("integrationType") == "S3"), None)
assert s3, "no S3 connection available (did the seed run?)"
context._seed_conn_id = s3["id"]
return s3["id"]
def _s3_source_body(name, connection_id):
# Folder sources are config-gated; S3 sources against the seeded connection always work.
return json.dumps({
"name": name, "type": "s3",
"options": {"connectionId": connection_id, "prefix": "regr/", "mode": "snapshot"},
"enabled": True,
})
def _source_id_by_name(context, name):
r = requests.get(f"{LB_URL}/api/v1/sources",
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
assert r.status_code == 200, f"list sources failed: HTTP {r.status_code}"
return next((s["id"] for s in r.json().get("sources", []) if s.get("name") == name), None)
def _s3_connection_body(name):
return json.dumps({
"integrationType": "S3", "name": name, "scope": "SERVER", "enabled": True,
"locked": False, "defaultAccess": "ORG_ALL",
"config": {"bucket": BUCKET, "region": "us-east-1", "endpoint": "http://minio:9000",
"accessKeyId": "minioadmin", "secretAccessKey": "minioadmin",
"pathStyleAccess": True},
})
def _lb_login(context):
r = requests.post(f"{LB_URL}/api/v1/auth/login",
json={"username": ADMIN_USER, "password": ADMIN_PASS}, timeout=15)
assert r.status_code == 200, f"admin login via LB failed: HTTP {r.status_code}"
context.jwt_token = r.json()["session"]["access_token"]
def _pdf_bytes(marker):
"""A minimal valid single-page PDF carrying a unique marker (so outputs are identifiable)."""
try:
from reportlab.pdfgen import canvas
buf = io.BytesIO()
c = canvas.Canvas(buf)
c.drawString(100, 750, f"multinode-regression {marker}")
c.showPage()
c.save()
return buf.getvalue()
except Exception:
# Fallback: a hand-rolled minimal PDF if reportlab is unavailable.
return (b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
b"trailer<</Root 1 0 R>>\n%%EOF")
def _mc(context, script, stdin=None):
"""Run an mc script in a throwaway minio/mc container on the cluster network."""
net = getattr(context, "_net", None) or _network()
context._net = net
full = f"mc alias set local http://minio:9000 minioadmin minioadmin >/dev/null 2>&1 && {script}"
args = ["docker", "run", "-i", "--rm", "--network", net, "--entrypoint", "/bin/sh",
"minio/mc", "-c", full]
return _sh(args, stdin=stdin, timeout=90)
def _policy_id_by_name(context, name):
r = requests.get(f"{LB_URL}/api/v1/policies",
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
assert r.status_code == 200, f"list policies failed: HTTP {r.status_code}"
data = r.json()
items = data if isinstance(data, list) else data.get("policies", [])
for p in items:
if p.get("name") == name:
return p.get("id")
return None
# --------------------------------------------------------------------------- preconditions
@given("the multi-node stack is running")
def step_stack_running(context):
for node in NODES:
rc, out, _ = _sh(["docker", "inspect", "-f", "{{.State.Health.Status}}", node])
assert rc == 0 and out.strip() == "healthy", f"{node} is not healthy (got '{out.strip()}')"
context._net = _network()
@given("both nodes are cluster members using the Valkey backplane")
def step_cluster_members(context):
for node in NODES:
rc, out, err = _sh(["docker", "logs", node])
logs = out + err
assert "backplane=valkey" in logs, f"{node} did not join the Valkey backplane"
# --------------------------------------------------------------------------- load balancer
@when('I request "{endpoint}" {count:d} times through the load balancer')
def step_lb_requests(context, endpoint, count):
context._served_by = []
context._lb_statuses = []
headers = {}
if getattr(context, "jwt_token", None):
headers["Authorization"] = f"Bearer {context.jwt_token}"
for _ in range(count):
r = requests.get(f"{LB_URL}{endpoint}", headers=headers, timeout=15)
context._lb_statuses.append(r.status_code)
node = r.headers.get("X-Served-By")
if node:
context._served_by.append(node)
@then("the requests should be served by at least {n:d} distinct nodes")
def step_distinct_nodes(context, n):
distinct = set(context._served_by)
assert len(distinct) >= n, (
f"expected >= {n} distinct upstreams, saw {sorted(distinct)} "
f"(is the X-Served-By header configured on the LB?)")
@then("every load-balanced response should be {code:d}")
def step_all_lb_ok(context, code):
bad = [s for s in context._lb_statuses if s != code]
assert not bad, f"expected all {code}, got failures: {bad}"
# --------------------------------------------------------------------------- cross-node auth
@then("the current token should be accepted by every node")
def step_token_every_node(context):
token = _token(context)
for node in NODES:
status, body = _curl_on_node(node, "GET", "/api/v1/sources", token=token)
assert status == 200, f"{node} rejected the LB-minted token (HTTP {status}): {body[:200]}"
@then("the signing keys should be stored in the shared database")
def step_keys_in_db(context):
assert _psql_int("select count(*) from jwt_signing_keys") >= 1, \
"no rows in jwt_signing_keys - keys are not persisted in the shared DB"
@then("every stored private key should be encrypted at rest")
def step_keys_encrypted(context):
# A plaintext PKCS#8 RSA key base64 begins with 'MII'; an encrypted blob does not.
plaintext = _psql_int("select count(*) from jwt_signing_keys where signing_key like 'MII%'")
assert plaintext == 0, f"{plaintext} signing key(s) look like plaintext PKCS#8 (not encrypted)"
# --------------------------------------------------------------------------- shared state
@when('I create a team named "{name}" through the load balancer')
def step_create_team(context, name):
context._team_name = name
r = requests.post(f"{LB_URL}/api/v1/team/create",
headers={"Authorization": f"Bearer {_token(context)}"},
data={"name": name}, timeout=15)
assert r.status_code in (200, 201, 409), f"create team failed: HTTP {r.status_code}"
@then('the team "{name}" should exist in the shared database')
def step_team_in_db(context, name):
n = _psql_int(f"select count(*) from teams where name = '{name}'")
assert n >= 1, f"team '{name}' not found in the shared DB"
@then("every node should report the same number of sources")
def step_same_sources(context):
token = _token(context)
counts = {}
for node in NODES:
status, body = _curl_on_node(node, "GET", "/api/v1/sources", token=token)
assert status == 200, f"{node} /sources returned HTTP {status}"
counts[node] = body.count('"id"')
values = set(counts.values())
assert len(values) == 1 and values != {0}, f"source counts differ across nodes: {counts}"
@then('the "{table}" table should contain at least {n:d} row(s)')
def step_table_rows(context, table, n):
got = _psql_int(f"select count(*) from {table}")
assert got >= n, f"{table} has {got} rows, expected >= {n}"
# --------------------------------------------------------------------------- processor / ledger
@given("the processor workspace is clean")
def step_clean_workspace(context):
# Start from a known state so file/output/ledger counts reflect only this scenario.
_mc(context, f"mc rm --recursive --force local/{BUCKET}/{SOURCE_PREFIX} || true")
_mc(context, f"mc rm --recursive --force local/{BUCKET}/{OUTPUT_PREFIX} || true")
_psql("delete from policy_processed_files")
@when('I drop {count:d} PDF file(s) into the S3 source under "{prefix}"')
def step_drop_files(context, count, prefix):
context._dropped = []
for _ in range(count):
marker = uuid.uuid4().hex[:12]
key = f"{prefix}regr-{marker}.pdf"
rc, out, err = _mc(context, f"mc pipe local/{BUCKET}/{key}", stdin=_pdf_bytes(marker))
assert rc == 0, f"failed to upload {key}: {err.strip() or out.strip()}"
context._dropped.append(key)
@when('I trigger the policy "{name}" on every node simultaneously')
def step_trigger_all_nodes(context, name):
pid = _policy_id_by_name(context, name)
assert pid, f"policy '{name}' not found"
context._policy_id = pid
token = _token(context)
# Fire the trigger on both nodes as close together as possible to race the ledger claim.
for node in NODES:
_curl_on_node(node, "POST", f"/api/v1/policies/{pid}/trigger", token=token, timeout=15)
@then("within {seconds:d}s every dropped file should be processed across the cluster")
def step_files_processed(context, seconds):
# Consume-mode deletes processed files, so an empty source prefix signals every file was processed (ledger rows are pruned too, so counting them would be racy).
deadline = time.monotonic() + seconds
remaining = None
while time.monotonic() < deadline:
rc, out, _ = _mc(context, f"mc ls --recursive local/{BUCKET}/{SOURCE_PREFIX} | wc -l")
remaining = int(out.strip() or "0") if rc == 0 else -1
if remaining == 0:
break
time.sleep(3)
assert remaining == 0, (
f"{remaining} of {len(context._dropped)} dropped files were still unprocessed after "
f"{seconds}s")
for node in NODES:
rc, out, _ = _sh(["docker", "inspect", "-f", "{{.State.Status}}", node])
assert out.strip() == "running", f"{node} crashed during concurrent processing"
@then("a duplicate ledger claim for the same file and policy is rejected")
def step_ledger_claim_atomic(context):
# Exactly-once relies on the (identity_hash, policy_id) primary key: two nodes claiming the same file both insert it, but only one wins; this proves the constraint rejects the second claim.
ihash = "regr-" + uuid.uuid4().hex
pol = "regr-policy-" + uuid.uuid4().hex[:8]
insert = (f"insert into policy_processed_files (identity_hash, policy_id, status, attempts) "
f"values ('{ihash}', '{pol}', 'PROCESSING', 1)")
_psql(insert) # first claim wins
rc, out, err = _sh(["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling",
"-tAc", insert]) # second claim must be rejected
_psql(f"delete from policy_processed_files where identity_hash = '{ihash}'")
assert rc != 0 and "duplicate key" in (out + err).lower(), (
"a second claim for the same file and policy was NOT rejected - the ledger's exactly-once "
"guarantee is not enforced by the primary key")
# --------------------------------------------------------------------------- policy run coordination (gap)
@when('I run the policy "{name}" on node "{idx}"')
def step_run_policy_on_node(context, name, idx):
node = _node(idx)
context._run_node = node
pid = _policy_id_by_name(context, name)
assert pid, f"policy '{name}' not found"
# Drop an input so the trigger actually produces a run (the source is otherwise empty).
marker = uuid.uuid4().hex[:12]
_mc(context, f"mc pipe local/{BUCKET}/{SOURCE_PREFIX}runvis-{marker}.pdf",
stdin=_pdf_bytes(marker))
status, _ = _curl_on_node(node, "POST", f"/api/v1/policies/{pid}/trigger", token=_token(context))
assert status in (200, 202), f"triggering the policy on {node} failed: HTTP {status}"
# Grab the runId that node recorded for the run it just executed.
context._run_id = None
for _ in range(8):
s, body = _curl_on_node(node, "GET", "/api/v1/policies/runs", token=_token(context))
runs = json.loads(body) if s == 200 and body.strip().startswith("[") else []
if runs:
context._run_id = runs[0]["runId"]
break
time.sleep(2)
assert context._run_id, f"{node} recorded no run after triggering '{name}'"
@then("the run should be visible from every node")
def step_run_visible_every(context):
for node in NODES:
status, body = _curl_on_node(node, "GET", "/api/v1/policies/runs", token=_token(context))
assert status == 200, f"{node} /policies/runs returned HTTP {status}"
run_ids = [r.get("runId") for r in json.loads(body)] if body.strip().startswith("[") else []
assert context._run_id in run_ids, (
f"run {context._run_id} (executed on {context._run_node}) is not visible from {node} - "
f"PolicyRunRegistry is a per-node in-JVM map, so run status and cancellation do not "
f"cross nodes")
# --------------------------------------------------------------------------- rate limiting
@then("the rate-limit counter should be shared across nodes")
def step_ratelimit_shared(context):
# In cluster mode the ValkeyRateLimitStore holds counters in Valkey; probe that a key exists.
net = context._net or _network()
rc, out, err = _sh(["docker", "run", "--rm", "--network", net, "--entrypoint", "/bin/sh",
"valkey/valkey:8-alpine", "-c",
"valkey-cli -h valkey keys '*'"], timeout=30)
assert rc == 0, f"valkey probe failed: {err.strip()}"
assert out.strip(), "no keys in Valkey - rate-limit/backplane state is not shared"
# --------------------------------------------------------------------------- failover
@when('I kill node "{idx}"')
def step_kill_node(context, idx):
node = NODES[int(idx) - 1]
context._killed = getattr(context, "_killed", [])
_sh(["docker", "kill", node])
context._killed.append(node)
time.sleep(2)
@then("the load balancer should still serve requests")
def step_lb_survives(context):
ok = 0
for _ in range(6):
try:
r = requests.get(f"{LB_URL}/api/v1/info/status", timeout=10)
if r.status_code == 200:
ok += 1
except requests.RequestException:
pass
assert ok >= 4, f"LB served only {ok}/6 requests with a node down (does it drain dead nodes?)"
@when('I restart node "{idx}"')
def step_restart_node(context, idx):
node = NODES[int(idx) - 1]
_sh(["docker", "start", node])
context._killed = [n for n in getattr(context, "_killed", []) if n != node]
@then('node "{idx}" should become healthy again within {seconds:d}s')
def step_node_recovers(context, idx, seconds):
node = NODES[int(idx) - 1]
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
rc, out, _ = _sh(["docker", "inspect", "-f", "{{.State.Health.Status}}", node])
if out.strip() == "healthy":
return
time.sleep(5)
raise AssertionError(f"{node} did not become healthy within {seconds}s")
# ------------------------------------------------- policy / source / connection management (CRUD)
def _auth(context):
return {"Authorization": f"Bearer {_token(context)}"}
# --- policies ---
@when('I create a policy named "{name}" on node "{idx}"')
def step_create_policy_on_node(context, name, idx):
status, body = _curl_on_node(_node(idx), "POST", "/api/v1/policies", token=_token(context),
data=_policy_body(name), content_type="application/json")
assert status in (200, 201), f"create policy on {_node(idx)} failed: HTTP {status}: {body[:200]}"
@when('I create a policy named "{name}" referencing source "{src}" via the load balancer')
def step_create_policy_ref(context, name, src):
sid = _source_id_by_name(context, src)
assert sid, f"source '{src}' not found"
r = requests.post(f"{LB_URL}/api/v1/policies",
headers={**_auth(context), "Content-Type": "application/json"},
data=_policy_body(name, [sid]), timeout=15)
assert r.status_code in (200, 201), f"create referencing policy failed: HTTP {r.status_code}"
@when('I rename the policy "{old}" to "{new}" via the load balancer')
def step_rename_policy(context, old, new):
pid = _policy_id_by_name(context, old)
assert pid, f"policy '{old}' not found"
pol = requests.get(f"{LB_URL}/api/v1/policies/{pid}", headers=_auth(context), timeout=15).json()
pol["name"] = new
r = requests.post(f"{LB_URL}/api/v1/policies",
headers={**_auth(context), "Content-Type": "application/json"},
json=pol, timeout=15)
assert r.status_code in (200, 201), f"rename policy failed: HTTP {r.status_code}"
@when('I delete the policy "{name}" on node "{idx}"')
def step_delete_policy_on_node(context, name, idx):
pid = _policy_id_by_name(context, name)
assert pid, f"policy '{name}' not found"
status, body = _curl_on_node(_node(idx), "DELETE", f"/api/v1/policies/{pid}", token=_token(context))
assert status in (200, 204), f"delete policy on {_node(idx)} failed: HTTP {status}"
@then('the policy "{name}" should be visible from every node')
def step_policy_visible(context, name):
for node in NODES:
names = _names_on_node(node, "/api/v1/policies", _token(context))
assert name in names, f"{node} does not see policy '{name}' (sees {sorted(names)})"
@then('the policy "{name}" should be absent from every node')
def step_policy_absent(context, name):
for node in NODES:
names = _names_on_node(node, "/api/v1/policies", _token(context))
assert name not in names, f"{node} still sees deleted policy '{name}'"
@then("the trigger registry should be identical across nodes")
def step_triggers_identical(context):
seen = {}
for node in NODES:
status, body = _curl_on_node(node, "GET", "/api/v1/policies/triggers", token=_token(context))
assert status == 200, f"{node} GET /policies/triggers returned HTTP {status}"
items = json.loads(body)
# Trigger descriptors are dicts; normalise to a canonical, order-independent form.
seen[node] = sorted(json.dumps(t, sort_keys=True) for t in items)
a, b = (seen[n] for n in NODES)
assert a == b, f"trigger registry differs across nodes: {seen}"
# --- sources ---
@when('I create an S3 source named "{name}" on node "{idx}"')
def step_create_source_on_node(context, name, idx):
conn = _any_connection_id(context)
status, body = _curl_on_node(_node(idx), "POST", "/api/v1/sources", token=_token(context),
data=_s3_source_body(name, conn), content_type="application/json")
assert status in (200, 201), f"create source on {_node(idx)} failed: HTTP {status}: {body[:200]}"
@when('I delete the source "{name}" on node "{idx}"')
def step_delete_source_on_node(context, name, idx):
sid = _source_id_by_name(context, name)
assert sid, f"source '{name}' not found"
status, body = _curl_on_node(_node(idx), "DELETE", f"/api/v1/sources/{sid}", token=_token(context))
assert status in (200, 204), f"delete source on {_node(idx)} failed: HTTP {status}"
@then('the source "{name}" should be visible from every node')
def step_source_visible(context, name):
for node in NODES:
names = _names_on_node(node, "/api/v1/sources", _token(context))
assert name in names, f"{node} does not see source '{name}' (sees {sorted(names)})"
@then('the source "{name}" should be absent from every node')
def step_source_absent(context, name):
for node in NODES:
names = _names_on_node(node, "/api/v1/sources", _token(context))
assert name not in names, f"{node} still sees deleted source '{name}'"
@then('deleting the source "{name}" from node "{idx}" is rejected because it is referenced')
def step_source_delete_guarded(context, name, idx):
sid = _source_id_by_name(context, name)
assert sid, f"source '{name}' not found"
status, body = _curl_on_node(_node(idx), "DELETE", f"/api/v1/sources/{sid}", token=_token(context))
assert status == 409, (
f"expected 409 (source referenced by a policy created on another node), got HTTP {status}")
# --- connections (integration configs) ---
@when('I create an S3 connection named "{name}" via the load balancer')
def step_create_conn(context, name):
r = requests.post(f"{LB_URL}/api/v1/integrations",
headers={**_auth(context), "Content-Type": "application/json"},
data=_s3_connection_body(name), timeout=15)
assert r.status_code in (200, 201), f"create connection failed: HTTP {r.status_code}: {r.text[:200]}"
context._conn_id = r.json()["id"]
@then('the connection "{name}" should resolve from every node with its secret masked')
def step_conn_resolves(context, name):
cid = context._conn_id
for node in NODES:
status, body = _curl_on_node(node, "GET", f"/api/v1/integrations/{cid}", token=_token(context))
assert status == 200, f"{node} cannot resolve connection {cid}: HTTP {status}"
secret = json.loads(body).get("config", {}).get("secretAccessKey")
assert secret in (None, "", "********"), f"{node} leaked the connection secret on read"
@when('I delete the connection via the load balancer')
def step_delete_conn(context):
r = requests.delete(f"{LB_URL}/api/v1/integrations/{context._conn_id}",
headers=_auth(context), timeout=15)
assert r.status_code in (200, 204), f"delete connection failed: HTTP {r.status_code}"
@then("the connection should be gone from every node")
def step_conn_absent(context):
cid = context._conn_id
for node in NODES:
status, _ = _curl_on_node(node, "GET", f"/api/v1/integrations/{cid}", token=_token(context))
assert status in (403, 404), f"{node} still resolves deleted connection {cid}: HTTP {status}"