From 547dce4cf35b58167a51abb07b4ceeccbdc90422 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:09:11 +0100 Subject: [PATCH] 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= ./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. --- .github/workflows/build-enterprise.yml | 67 ++ testing/compose/docker-compose-multinode.yml | 225 +++++++ testing/compose/multinode/nginx.conf | 49 ++ testing/compose/multinode/seed.sh | 140 +++++ testing/compose/run-multinode-regression.sh | 69 ++ testing/compose/start-multinode-test.sh | 65 ++ testing/compose/validate-multinode-test.sh | 81 +++ testing/cucumber/behave.ini | 14 +- .../features/multinode/cluster_health.feature | 18 + .../features/multinode/connections.feature | 12 + .../multinode/cross_node_auth.feature | 14 + .../features/multinode/failover.feature | 21 + .../features/multinode/load_balancing.feature | 15 + .../multinode/policy_management.feature | 19 + .../multinode/policy_run_coordination.feature | 10 + .../multinode/processor_ledger.feature | 20 + .../features/multinode/rate_limiting.feature | 9 + .../features/multinode/shared_state.feature | 20 + .../multinode/source_management.feature | 25 + .../steps/multinode_step_definitions.py | 595 ++++++++++++++++++ 20 files changed, 1481 insertions(+), 7 deletions(-) create mode 100644 testing/compose/docker-compose-multinode.yml create mode 100644 testing/compose/multinode/nginx.conf create mode 100644 testing/compose/multinode/seed.sh create mode 100644 testing/compose/run-multinode-regression.sh create mode 100644 testing/compose/start-multinode-test.sh create mode 100644 testing/compose/validate-multinode-test.sh create mode 100644 testing/cucumber/features/multinode/cluster_health.feature create mode 100644 testing/cucumber/features/multinode/connections.feature create mode 100644 testing/cucumber/features/multinode/cross_node_auth.feature create mode 100644 testing/cucumber/features/multinode/failover.feature create mode 100644 testing/cucumber/features/multinode/load_balancing.feature create mode 100644 testing/cucumber/features/multinode/policy_management.feature create mode 100644 testing/cucumber/features/multinode/policy_run_coordination.feature create mode 100644 testing/cucumber/features/multinode/processor_ledger.feature create mode 100644 testing/cucumber/features/multinode/rate_limiting.feature create mode 100644 testing/cucumber/features/multinode/shared_state.feature create mode 100644 testing/cucumber/features/multinode/source_management.feature create mode 100644 testing/cucumber/features/steps/multinode_step_definitions.py diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 8c06175a5d..ce06352996 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -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 diff --git a/testing/compose/docker-compose-multinode.yml b/testing/compose/docker-compose-multinode.yml new file mode 100644 index 0000000000..b50ad4741c --- /dev/null +++ b/testing/compose/docker-compose-multinode.yml @@ -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: diff --git a/testing/compose/multinode/nginx.conf b/testing/compose/multinode/nginx.conf new file mode 100644 index 0000000000..ef0fea45b5 --- /dev/null +++ b/testing/compose/multinode/nginx.conf @@ -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; + } + } +} diff --git a/testing/compose/multinode/seed.sh b/testing/compose/multinode/seed.sh new file mode 100644 index 0000000000..3fc777b193 --- /dev/null +++ b/testing/compose/multinode/seed.sh @@ -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' the in-cluster MinIO 'policy-data' bucket --------------- +conn_body=$(cat </dev/null) +log "S3 connection id: ${conn_id:-}" + +# --- a scheduled S3 -> compress -> S3 policy --------------------------------- +if [ -n "${conn_id:-}" ]; then + src_body=$(cat </dev/null) + log "S3 source id: ${src_id:-}" + + if [ -n "${src_id:-}" ]; then + pol_body=$(cat </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" diff --git a/testing/compose/run-multinode-regression.sh b/testing/compose/run-multinode-regression.sh new file mode 100644 index 0000000000..01a0710a5b --- /dev/null +++ b/testing/compose/run-multinode-regression.sh @@ -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 diff --git a/testing/compose/start-multinode-test.sh b/testing/compose/start-multinode-test.sh new file mode 100644 index 0000000000..d842d68019 --- /dev/null +++ b/testing/compose/start-multinode-test.sh @@ -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= 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 < 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 ] diff --git a/testing/cucumber/behave.ini b/testing/cucumber/behave.ini index b6f3417728..0b2a9e4bac 100644 --- a/testing/cucumber/behave.ini +++ b/testing/cucumber/behave.ini @@ -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 diff --git a/testing/cucumber/features/multinode/cluster_health.feature b/testing/cucumber/features/multinode/cluster_health.feature new file mode 100644 index 0000000000..def781d895 --- /dev/null +++ b/testing/cucumber/features/multinode/cluster_health.feature @@ -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 diff --git a/testing/cucumber/features/multinode/connections.feature b/testing/cucumber/features/multinode/connections.feature new file mode 100644 index 0000000000..c20f615cdd --- /dev/null +++ b/testing/cucumber/features/multinode/connections.feature @@ -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 diff --git a/testing/cucumber/features/multinode/cross_node_auth.feature b/testing/cucumber/features/multinode/cross_node_auth.feature new file mode 100644 index 0000000000..9018004c49 --- /dev/null +++ b/testing/cucumber/features/multinode/cross_node_auth.feature @@ -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 diff --git a/testing/cucumber/features/multinode/failover.feature b/testing/cucumber/features/multinode/failover.feature new file mode 100644 index 0000000000..bc2453ba76 --- /dev/null +++ b/testing/cucumber/features/multinode/failover.feature @@ -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 diff --git a/testing/cucumber/features/multinode/load_balancing.feature b/testing/cucumber/features/multinode/load_balancing.feature new file mode 100644 index 0000000000..b5f14c2be1 --- /dev/null +++ b/testing/cucumber/features/multinode/load_balancing.feature @@ -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 diff --git a/testing/cucumber/features/multinode/policy_management.feature b/testing/cucumber/features/multinode/policy_management.feature new file mode 100644 index 0000000000..9a99d72bb5 --- /dev/null +++ b/testing/cucumber/features/multinode/policy_management.feature @@ -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 diff --git a/testing/cucumber/features/multinode/policy_run_coordination.feature b/testing/cucumber/features/multinode/policy_run_coordination.feature new file mode 100644 index 0000000000..c89cafe59a --- /dev/null +++ b/testing/cucumber/features/multinode/policy_run_coordination.feature @@ -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 diff --git a/testing/cucumber/features/multinode/processor_ledger.feature b/testing/cucumber/features/multinode/processor_ledger.feature new file mode 100644 index 0000000000..62abae6cdc --- /dev/null +++ b/testing/cucumber/features/multinode/processor_ledger.feature @@ -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. diff --git a/testing/cucumber/features/multinode/rate_limiting.feature b/testing/cucumber/features/multinode/rate_limiting.feature new file mode 100644 index 0000000000..423d26b308 --- /dev/null +++ b/testing/cucumber/features/multinode/rate_limiting.feature @@ -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 diff --git a/testing/cucumber/features/multinode/shared_state.feature b/testing/cucumber/features/multinode/shared_state.feature new file mode 100644 index 0000000000..60de9dea08 --- /dev/null +++ b/testing/cucumber/features/multinode/shared_state.feature @@ -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) diff --git a/testing/cucumber/features/multinode/source_management.feature b/testing/cucumber/features/multinode/source_management.feature new file mode 100644 index 0000000000..ff3fd586a2 --- /dev/null +++ b/testing/cucumber/features/multinode/source_management.feature @@ -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 diff --git a/testing/cucumber/features/steps/multinode_step_definitions.py b/testing/cucumber/features/steps/multinode_step_definitions.py new file mode 100644 index 0000000000..e43673dc86 --- /dev/null +++ b/testing/cucumber/features/steps/multinode_step_definitions.py @@ -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<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"trailer<>\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}"