From 36b0672d75e202ec9c7e492cfa0ba1bdaa974981 Mon Sep 17 00:00:00 2001 From: Pacerino Date: Mon, 15 Jun 2026 22:59:20 +0200 Subject: [PATCH] [CI] Add per-PR E2E deployment and teardown workflow Deploy the PR image to the self-hosted runner in API mode and run a full end-to-end test (login, host creation, live reverse-proxy to whoami, basic auth plugin, module discovery). The environment is published on a fixed port 3001 and left running until the PR is closed or a /teardown comment is posted. - docker-compose.e2e.yml: cpm (API mode) + whoami upstream, per-PR isolation - test/e2e/run.sh: full api-mode E2E assertions against the live container - .github/workflows/pr-e2e.yml: deploy/E2E, teardown on close, /teardown comment --- .github/workflows/pr-e2e.yml | 145 +++++++++++++++++++++++++++++++++++ docker-compose.e2e.yml | 42 ++++++++++ test/e2e/run.sh | 108 ++++++++++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 .github/workflows/pr-e2e.yml create mode 100644 docker-compose.e2e.yml create mode 100755 test/e2e/run.sh diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml new file mode 100644 index 0000000..8a9a4ce --- /dev/null +++ b/.github/workflows/pr-e2e.yml @@ -0,0 +1,145 @@ +name: PR E2E + +# Deploys the PR's image to the self-hosted runner and runs a full end-to-end +# test against it in API mode. The environment is left running until the PR is +# closed (or torn down early via a "/teardown" comment) so it can be inspected +# through a Cloudflare Tunnel. + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + issue_comment: + types: [created] + +# Only ever run one PR environment at a time (fixed port 3001). +concurrency: + group: pr-e2e + cancel-in-progress: false + +env: + COMPOSE_FILE: docker-compose.e2e.yml + CPM_PORT: "3001" + +jobs: + deploy-e2e: + name: Deploy & E2E + runs-on: self-hosted + # Own PRs only (not forks), and not on the closed event. + if: > + github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + CPM_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL || 'admin@example.com' }} + CPM_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD || 'changeme' }} + steps: + - uses: actions/checkout@v4 + + # Fail fast if a *different* PR's stack already holds the fixed port. + - name: Ensure port 3001 is free for this PR + run: | + set -euo pipefail + project="cpm-pr-${PR_NUMBER}" + # Any container publishing :3001 that is NOT this PR's stack blocks us. + holder=$(docker ps --filter "publish=${CPM_PORT}" --format '{{.Names}}' | grep -v "^cpm-pr-${PR_NUMBER}" || true) + if [ -n "$holder" ]; then + echo "::error::Port ${CPM_PORT} is in use by another PR environment: $holder" + echo "Tear it down first (close that PR or comment /teardown there)." + exit 1 + fi + echo "Port ${CPM_PORT} is available for ${project}." + + - name: Build PR image + run: | + set -euo pipefail + IMAGE="cpm-pr-${PR_NUMBER}:${GITHUB_SHA::8}" + docker build \ + --build-arg VERSION="pr-${PR_NUMBER}" \ + --build-arg COMMIT="${GITHUB_SHA::8}" \ + -t "$IMAGE" . + echo "CPM_IMAGE=$IMAGE" >> "$GITHUB_ENV" + + - name: Bring up the environment + run: | + set -euo pipefail + # Recreate cleanly (handles the synchronize re-run case). + docker compose -p "cpm-pr-${PR_NUMBER}" -f "$COMPOSE_FILE" up -d --force-recreate + + - name: Run E2E tests + env: + CPM_URL: http://localhost:3001 + CADDY_URL: http://localhost:8080 + ADMIN_EMAIL: ${{ env.CPM_ADMIN_EMAIL }} + ADMIN_PASSWORD: ${{ env.CPM_ADMIN_PASSWORD }} + TEST_DOMAIN: app.e2e.local + run: bash test/e2e/run.sh + + - name: Dump logs on failure + if: failure() + run: docker compose -p "cpm-pr-${PR_NUMBER}" -f "$COMPOSE_FILE" logs --no-color || true + + - name: Comment environment URL + if: success() + uses: actions/github-script@v7 + with: + script: | + const n = context.payload.pull_request.number; + const marker = ""; + const body = `${marker}\nāœ… **PR E2E environment is up** (port \`3001\`).\n\n` + + `Point your Cloudflare Tunnel at \`localhost:3001\` to inspect it.\n\n` + + `Comment \`/teardown\` to stop it early; it is otherwise removed when the PR closes.`; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: n, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: n, body }); + } + + teardown-on-close: + name: Teardown (PR closed) + runs-on: self-hosted + if: > + github.event_name == 'pull_request' && + github.event.action == 'closed' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - uses: actions/checkout@v4 + - name: Tear down the environment + run: | + set -euo pipefail + docker compose -p "cpm-pr-${PR_NUMBER}" -f "$COMPOSE_FILE" down -v --remove-orphans || true + docker image rm "cpm-pr-${PR_NUMBER}:${GITHUB_SHA::8}" 2>/dev/null || true + echo "Environment for PR #${PR_NUMBER} torn down." + + teardown-on-comment: + name: Teardown (/teardown comment) + runs-on: self-hosted + # Only on PR comments containing "/teardown", authored by the repo owner. + if: > + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + contains(github.event.comment.body, '/teardown') && + github.event.comment.user.login == github.repository_owner + env: + PR_NUMBER: ${{ github.event.issue.number }} + steps: + - uses: actions/checkout@v4 + - name: Tear down the environment + run: | + set -euo pipefail + docker compose -p "cpm-pr-${PR_NUMBER}" -f "$COMPOSE_FILE" down -v --remove-orphans || true + echo "Environment for PR #${PR_NUMBER} torn down." + - name: Acknowledge + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: ${{ env.PR_NUMBER }}, + body: "🧹 PR E2E environment torn down.", + }); diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..0c80867 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,42 @@ +# Per-PR end-to-end environment. CPM runs in API mode and manages Caddy via its +# admin API; whoami is a test upstream that CPM reverse-proxies to. +# +# The compose project name (set via -p cpm-pr- in CI) isolates the network, +# volume and container names per PR. CPM is published on a fixed host port so a +# Cloudflare Tunnel can target it. +services: + cpm: + image: ${CPM_IMAGE:?set CPM_IMAGE to the PR image tag} + container_name: cpm-pr-${PR_NUMBER:?set PR_NUMBER} + restart: unless-stopped + environment: + CPM_CADDY_MODE: api + CPM_CADDY_ADMINURL: http://localhost:2019 + # Caddy needs to listen on :80 inside the container for proxied requests. + CPM_CADDY_LISTEN: ":80" + CPM_ADMIN_EMAIL: ${CPM_ADMIN_EMAIL:-admin@example.com} + CPM_ADMIN_PASSWORD: ${CPM_ADMIN_PASSWORD:-changeme} + CPM_LOG_LEVEL: info + ports: + # Fixed published port for the tunnel + E2E (CPM API/UI). + - "3001:3001" + # Caddy's HTTP port, exposed so the E2E can verify proxied requests. + - "8080:80" + networks: + - cpm + labels: + cpm.pr: "${PR_NUMBER}" + + whoami: + image: traefik/whoami:latest + container_name: cpm-pr-${PR_NUMBER}-whoami + restart: unless-stopped + command: ["--port", "80"] + networks: + - cpm + labels: + cpm.pr: "${PR_NUMBER}" + +networks: + cpm: + name: cpm-pr-${PR_NUMBER} diff --git a/test/e2e/run.sh b/test/e2e/run.sh new file mode 100755 index 0000000..19b7b15 --- /dev/null +++ b/test/e2e/run.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# End-to-end test for a deployed CPM PR environment running in API mode. +# +# Verifies the full stack: API health, admin login, host creation, that Caddy +# actually reverse-proxies a request to the whoami upstream, the per-host basic +# auth plugin, and module discovery. Hits the containers locally on the runner +# (independent of any tunnel). +# +# Env: +# CPM_URL base URL of the CPM API (default http://localhost:3001) +# CADDY_URL base URL of Caddy's HTTP port (default http://localhost:8080) +# ADMIN_EMAIL seeded admin email (default admin@example.com) +# ADMIN_PASSWORD seeded admin password (default changeme) +# TEST_DOMAIN host header used for the proxied request (default app.e2e.local) +set -euo pipefail + +CPM_URL="${CPM_URL:-http://localhost:3001}" +CADDY_URL="${CADDY_URL:-http://localhost:8080}" +ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-changeme}" +TEST_DOMAIN="${TEST_DOMAIN:-app.e2e.local}" + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1" >&2; exit 1; } + +# jqr — extract a field with jq. +jqr() { printf '%s' "$1" | jq -r "$2"; } + +echo "== Waiting for CPM API to become healthy ==" +for i in $(seq 1 30); do + if curl -fsS "${CPM_URL}/api/auth/config" >/dev/null 2>&1; then + break + fi + if [ "$i" = "30" ]; then fail "CPM API did not become healthy in time"; fi + sleep 2 +done +pass "CPM API is up" + +echo "== Auth config reports api/local mode ==" +cfg=$(curl -fsS "${CPM_URL}/api/auth/config") +mode=$(jqr "$cfg" '.result.mode') +[ "$mode" = "local" ] || fail "expected local auth mode, got '$mode'" +pass "auth mode = local" + +echo "== Admin login ==" +login=$(curl -fsS -X POST "${CPM_URL}/api/users/login" \ + -H 'Content-Type: application/json' \ + -d "{\"Email\":\"${ADMIN_EMAIL}\",\"Secret\":\"${ADMIN_PASSWORD}\"}") +TOKEN=$(jqr "$login" '.result.token') +[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || fail "login did not return a token: $login" +AUTH="Authorization: Bearer ${TOKEN}" +pass "logged in, got JWT" + +echo "== Module discovery (list-modules in-container) ==" +mods=$(curl -fsS -H "$AUTH" "${CPM_URL}/api/caddy/modules") +mcount=$(jqr "$mods" '.result.modules | length') +[ "$mcount" -gt 0 ] || fail "expected modules from caddy build, got $mcount" +pass "discovered $mcount caddy modules" + +echo "== Create a host that proxies ${TEST_DOMAIN} -> whoami ==" +created=$(curl -fsS -X POST "${CPM_URL}/api/hosts" \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"domains\":\"${TEST_DOMAIN}\",\"matcher\":\"\",\"Upstreams\":[{\"backend\":\"whoami:80\"}]}") +HOST_ID=$(jqr "$created" '.result.ID') +[ -n "$HOST_ID" ] && [ "$HOST_ID" != "null" ] || fail "host create failed: $created" +pass "created host id=$HOST_ID" + +echo "== Verify Caddy reverse-proxies the request to whoami ==" +# CPM applies the route asynchronously via the job queue; allow a short window. +ok="" +for i in $(seq 1 15); do + body=$(curl -fsS -H "Host: ${TEST_DOMAIN}" "${CADDY_URL}/" 2>/dev/null || true) + # whoami echoes a "Hostname:" line in its response. + if printf '%s' "$body" | grep -qi "Hostname:"; then + ok="yes"; break + fi + sleep 2 +done +[ -n "$ok" ] || fail "proxied request did not reach whoami" +pass "request proxied to whoami" + +echo "== Add basic auth plugin to the host ==" +curl -fsS -X PUT "${CPM_URL}/api/hosts/${HOST_ID}/plugins" \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"moduleId":"http.handlers.authentication","values":{"username":"e2e","password":"e2epass"}}' >/dev/null +pass "basic auth plugin configured" + +echo "== Request without credentials should be 401 ==" +ok="" +for i in $(seq 1 15); do + code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${CADDY_URL}/" || true) + if [ "$code" = "401" ]; then ok="yes"; break; fi + sleep 2 +done +[ -n "$ok" ] || fail "expected 401 without credentials" +pass "unauthenticated request rejected (401)" + +echo "== Request with credentials should be 200 ==" +code=$(curl -s -o /dev/null -w '%{http_code}' -u "e2e:e2epass" -H "Host: ${TEST_DOMAIN}" "${CADDY_URL}/" || true) +[ "$code" = "200" ] || fail "expected 200 with credentials, got $code" +pass "authenticated request succeeded (200)" + +echo "== Delete the host ==" +del=$(curl -fsS -X DELETE -H "$AUTH" "${CPM_URL}/api/hosts/${HOST_ID}") +pass "host deleted" + +echo +echo "ALL E2E CHECKS PASSED"