diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..caaf82d --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,34 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e: + name: ${{ matrix.scenario }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + scenario: + - stream-mode + steps: + - uses: actions/checkout@v6 + - name: Run scenario + run: ./tests/e2e/scenarios/${{ matrix.scenario }}/run.sh + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: logs-${{ matrix.scenario }} + path: /tmp/e2e-${{ matrix.scenario }}.log + if-no-files-found: ignore diff --git a/Makefile b/Makefile index 4785399..eab2bb5 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ -.PHONY: lint test vendor clean +.PHONY: lint test vendor clean e2e export GO111MODULE=on +E2E_SCENARIOS := stream-mode + default: lint test lint: @@ -13,6 +15,11 @@ test: yaegi_test: yaegi test -v . +e2e: $(addprefix e2e_,$(E2E_SCENARIOS)) + +e2e_%: + ./tests/e2e/scenarios/$*/run.sh + vendor: go mod vendor diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..b991e72 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,49 @@ +# End-to-end test suite + +These tests spin up real Traefik + Crowdsec containers and exercise the +plugin in the same conditions Traefik uses in production: loaded from a +local path, no module download, no mocking. + +Each scenario lives in its own directory under `scenarios/` and owns: + +- `docker-compose.yml` — the stack to spin up +- `run.sh` — orchestration + assertions +- optional fixtures (`acquis.yaml`, `ban.html`, ...) + +## Running locally + +Prerequisites: `docker`, `docker compose`, `curl`, `bash`. + +Run a single scenario: + +```bash +./tests/e2e/scenarios/stream-mode/run.sh +# or +make e2e_stream-mode +``` + +Run everything: + +```bash +make e2e +``` + +Each scenario uses an isolated Docker Compose project (`-p e2e-`), +so multiple scenarios can run in parallel without colliding on container +names — except for the `crowdsec` container, which keeps its canonical +name so `cscli` commands work uniformly across scenarios. Run scenarios +serially if you need to invoke them on the same host at the same time. + +## Writing a new scenario + +1. Copy `scenarios/stream-mode/` as a template. +2. Rename `container_name`s (keep `crowdsec` for the LAPI container). +3. Edit `run.sh` to express the behavior under test. +4. Add the scenario name to the `matrix` in `.github/workflows/e2e.yml` + and to `E2E_SCENARIOS` in the `Makefile`. + +## CI + +`.github/workflows/e2e.yml` runs one parallel job per scenario on every +PR and push to `main`. On failure, container logs are uploaded as an +artifact named `logs-`. diff --git a/tests/e2e/lib/common.sh b/tests/e2e/lib/common.sh new file mode 100755 index 0000000..64e4cfd --- /dev/null +++ b/tests/e2e/lib/common.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Shared helpers for end-to-end scenarios. +# Dependencies: bash, curl, docker. + +# Wait until the Crowdsec LAPI reports ready, or fail after timeout. +# Usage: wait_crowdsec_ready [container_name] [timeout_seconds] +wait_crowdsec_ready() { + local container="${1:-crowdsec}" + local timeout="${2:-90}" + local elapsed=0 + while (( elapsed < timeout )); do + if docker exec "$container" cscli lapi status >/dev/null 2>&1; then + return 0 + fi + sleep 1 + ((elapsed++)) + done + echo "wait_crowdsec_ready: timed out after ${timeout}s waiting for $container" >&2 + return 1 +} + +# Poll URL until it returns the expected status code, or fail. +# Usage: wait_for_status URL CODE [TIMEOUT_SECONDS] [curl args...] +wait_for_status() { + local url="$1" expected="$2" timeout="${3:-30}" + shift 3 || true + local elapsed=0 + local got="" + while (( elapsed < timeout )); do + got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url" || true) + if [[ "$got" == "$expected" ]]; then + return 0 + fi + sleep 1 + ((elapsed++)) + done + echo "wait_for_status: $url expected $expected, last seen ${got:-}" >&2 + return 1 +} + +# Assert a single curl returns the expected status code. +# Usage: assert_status URL CODE [curl args...] +assert_status() { + local url="$1" expected="$2" + shift 2 || true + local got + got=$(curl -s -o /dev/null -w '%{http_code}' "$@" "$url") + if [[ "$got" != "$expected" ]]; then + echo "assert_status: $url expected $expected, got $got" >&2 + return 1 + fi +} + +# Assert a response header matches a value (case-insensitive name). +# Usage: assert_header URL HEADER VALUE [curl args...] +assert_header() { + local url="$1" header="$2" expected="$3" + shift 3 || true + local got + got=$(curl -s -D - -o /dev/null "$@" "$url" | tr -d '\r' \ + | awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }') + if [[ "$got" != "$expected" ]]; then + echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2 + return 1 + fi +} + +# Dump diagnostic info for a compose project; called on failure. +# Usage: dump_diagnostics PROJECT COMPOSE_FILE +dump_diagnostics() { + local project="$1" compose_file="$2" + echo "=== docker compose ps ===" + docker compose -p "$project" -f "$compose_file" ps || true + echo "=== docker compose logs ===" + docker compose -p "$project" -f "$compose_file" logs --no-color || true + echo "=== cscli decisions list ===" + docker exec crowdsec cscli decisions list 2>/dev/null || true +} diff --git a/tests/e2e/scenarios/stream-mode/docker-compose.yml b/tests/e2e/scenarios/stream-mode/docker-compose.yml new file mode 100644 index 0000000..c850647 --- /dev/null +++ b/tests/e2e/scenarios/stream-mode/docker-compose.yml @@ -0,0 +1,39 @@ +services: + traefik: + image: traefik:v3.5.0 + container_name: e2e-stream-traefik + command: + - "--log.level=INFO" + - "--accesslog" + - "--api.insecure=true" + - "--providers.file.filename=/etc/traefik/dynamic.yml" + - "--providers.file.watch=false" + - "--entrypoints.web.address=:80" + - "--entryPoints.web.forwardedHeaders.insecure=true" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro + - ../../../..:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + ports: + - "8000:80" + depends_on: + crowdsec: + condition: service_healthy + + whoami: + image: traefik/whoami + container_name: e2e-stream-whoami + + crowdsec: + image: crowdsecurity/crowdsec:v1.6.8 + container_name: crowdsec + environment: + DISABLE_ONLINE_API: "true" + BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5 + CUSTOM_HOSTNAME: crowdsec + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/tests/e2e/scenarios/stream-mode/run.sh b/tests/e2e/scenarios/stream-mode/run.sh new file mode 100755 index 0000000..1988c23 --- /dev/null +++ b/tests/e2e/scenarios/stream-mode/run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=stream-mode +PROJECT="e2e-${SCENARIO}" +COMPOSE_FILE="$HERE/docker-compose.yml" +LOG_FILE="/tmp/e2e-${SCENARIO}.log" + +cleanup() { + local rc=$? + if (( rc != 0 )); then + dump_diagnostics "$PROJECT" "$COMPOSE_FILE" > "$LOG_FILE" 2>&1 || true + echo "Scenario failed. Logs written to $LOG_FILE" + fi + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + exit $rc +} +trap cleanup EXIT + +echo "[$SCENARIO] starting stack..." +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait + +echo "[$SCENARIO] waiting for crowdsec readiness..." +wait_crowdsec_ready + +echo "[$SCENARIO] waiting for traefik readiness..." +wait_for_status http://localhost:8000/foo 200 30 + +echo "[$SCENARIO] no decision yet -> request allowed" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] adding ban decision for 1.2.3.4" +docker exec crowdsec cscli decisions add --ip 1.2.3.4 --type ban --duration 5m + +echo "[$SCENARIO] waiting one stream tick + buffer..." +sleep 6 + +echo "[$SCENARIO] banned IP must be blocked (HTTP 403)" +assert_status http://localhost:8000/foo 403 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] non-banned IP must still pass (HTTP 200)" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 5.6.7.8" + +echo "[$SCENARIO] deleting ban decision" +docker exec crowdsec cscli decisions delete --ip 1.2.3.4 + +echo "[$SCENARIO] waiting one stream tick + buffer..." +sleep 6 + +echo "[$SCENARIO] previously banned IP must pass again" +assert_status http://localhost:8000/foo 200 -H "X-Forwarded-For: 1.2.3.4" + +echo "[$SCENARIO] OK" diff --git a/tests/e2e/scenarios/stream-mode/traefik-dynamic.yml b/tests/e2e/scenarios/stream-mode/traefik-dynamic.yml new file mode 100644 index 0000000..9cbe9cf --- /dev/null +++ b/tests/e2e/scenarios/stream-mode/traefik-dynamic.yml @@ -0,0 +1,22 @@ +http: + routers: + whoami: + rule: "PathPrefix(`/foo`)" + entryPoints: [web] + service: whoami + middlewares: [bouncer] + services: + whoami: + loadBalancer: + servers: + - url: "http://whoami:80" + middlewares: + bouncer: + plugin: + bouncer: + enabled: true + crowdsecMode: stream + updateIntervalSeconds: 3 + crowdsecLapiKey: "40796d93c2958f9e58345514e67740e5" + forwardedHeadersTrustedIPs: + - "172.16.0.0/12"