diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 27c7304..7c33c63 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -623,15 +623,208 @@ jobs: docker rm -f bridge 2>/dev/null docker rm -f plane-stub 2>/dev/null + # Real-Plane round-trip. Brings up a minimum Plane CE v1.3.1 stack + # (api+worker+deps, no frontends), seeds an admin user + workspace + + # project + API token via direct Django ORM (Plane CE has no headless + # signup flow), then runs the **bridge image built by build-image** + # against that real Plane and POSTs a synthetic forge issue webhook + # at it. Asserts the bridge translated the webhook into a real work + # item in real Plane (looked up via external_source/_id, the same + # round-trip the production deploy relies on). + # + # Catches the wire-shape drift class of bug that produced PFB-22, + # PFB-24, and PFB-25 — those all passed CI because the e2e-docker + # plane-stub happily agreed with the bridge's synthetic testdata. + # + # Separate from the e2e-docker matrix: Plane CE boot is ~90s on a + # cold cache, and the wire shape doesn't vary by forge flavor, so + # running once is enough. + e2e-real-plane: + name: e2e-real-plane + needs: build-image + runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }} + timeout-minutes: 20 + env: + PFB_FORGE_WEBHOOK_SECRET: ci-forge-secret-${{ github.run_id }} + PFB_PLANE_API_KEY: ci-plane-token-${{ github.run_id }} + PFB_PLANE_WEBHOOK_SECRET: ci-plane-secret-${{ github.run_id }} + steps: + - uses: actions/checkout@v6 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Bring up Plane CE v1.3.1 + seed + id: seed + run: | + set -euo pipefail + seed_json=$(bash test/e2e-docker/plane-ce/run.sh up | tail -n1) + echo "seed_json=$seed_json" + token=$(echo "$seed_json" | jq -r '.api_token') + project_id=$(echo "$seed_json" | jq -r '.project_id') + workspace_slug=$(echo "$seed_json" | jq -r '.workspace_slug') + echo "::add-mask::$token" + # Override the run-id-derived API key with the actual seeded token + # (the seed honours the env hint but we re-export to be sure). + echo "PFB_PLANE_API_KEY=$token" >> "$GITHUB_ENV" + echo "PFB_PLANE_WORKSPACE_SLUG=$workspace_slug" >> "$GITHUB_ENV" + echo "PFB_PLANE_PROJECT_ID=$project_id" >> "$GITHUB_ENV" + + - name: Pull bridge image (with retry against ghcr transient denies) + env: + IMAGE: ${{ needs.build-image.outputs.image }} + run: | + set -euo pipefail + # ghcr.io intermittently returns "denied: denied" on freshly-pushed + # multi-arch manifests for a few seconds after the push. Re-login + # and retry up to 6 times with backoff before giving up. + for i in 1 2 3 4 5 6; do + if docker pull "$IMAGE"; then + echo "pulled after $i attempt(s)"; exit 0 + fi + echo "pull failed; re-authenticating and retrying in $((i*5))s" + echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io \ + -u '${{ github.actor }}' --password-stdin + sleep $((i*5)) + done + echo "ERROR: docker pull never succeeded"; exit 1 + + - name: Write bridge config pointing at real Plane + run: | + set -euo pipefail + # The bridge joins the pfb-e2e-plane-ce network (the one the + # compose creates) so it can address plane-api by service name. + # /api/v1 prefix is part of base_url (matches config.example.yaml). + cat > /tmp/pfb-config.yaml < plane direction the test + # exercises (the bridge only writes to plane). Point at a stub + # URL so config validation passes. + base_url: http://forge.invalid + token_env: PFB_FORGE_TOKEN + webhook_secret_env: PFB_FORGE_WEBHOOK_SECRET + plane: + base_url: http://plane-api:8000/api/v1 + workspace_slug: ${PFB_PLANE_WORKSPACE_SLUG} + api_key_env: PFB_PLANE_API_KEY + webhook_secret_env: PFB_PLANE_WEBHOOK_SECRET + bridge_bot: + forge_username: pfb-bot + plane_member_id: 00000000-0000-0000-0000-000000000000 + links: + - forge_repo: acme/widget + plane_project_id: ${PFB_PLANE_PROJECT_ID} + project_identifier: PFB + state_map: + open: Backlog + closed: Done + pr_state_map: + opened: "In Progress" + merged: "Done" + closed: "Cancelled" + users: {} + YAML + + - name: Launch bridge on the plane-ce network + env: + IMAGE: ${{ needs.build-image.outputs.image }} + run: | + set -euo pipefail + docker run -d --name pfb-bridge \ + --network pfb-e2e-plane-ce \ + -p 8080:8080 \ + -v /tmp/pfb-config.yaml:/etc/pfb/config.yaml:ro \ + -e PFB_FORGE_TOKEN=placeholder \ + -e PFB_FORGE_WEBHOOK_SECRET="$PFB_FORGE_WEBHOOK_SECRET" \ + -e PFB_PLANE_API_KEY="$PFB_PLANE_API_KEY" \ + -e PFB_PLANE_WEBHOOK_SECRET="$PFB_PLANE_WEBHOOK_SECRET" \ + "$IMAGE" \ + --config /etc/pfb/config.yaml + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then + echo "bridge healthy after ${i}s"; break + fi + sleep 1 + done + curl -fsS http://127.0.0.1:8080/healthz + + - name: POST synthetic forge issue webhook + run: | + set -euo pipefail + body=$(cat internal/forge/testdata/issues_opened.json) + sig=$(printf '%s' "$body" | openssl dgst -sha256 \ + -hmac "$PFB_FORGE_WEBHOOK_SECRET" -hex | awk '{print $NF}') + status=$(curl -sS -o /tmp/resp -w '%{http_code}' \ + -X POST http://127.0.0.1:8080/forge/webhook \ + -H "X-Gitea-Signature: $sig" \ + -H "X-Gitea-Event: issues" \ + -H "X-Gitea-Delivery: e2e-real-plane-${{ github.run_id }}" \ + -H 'Content-Type: application/json' \ + --data-binary "$body") + echo "status=$status body=$(cat /tmp/resp)" + [ "$status" = "202" ] || { echo "ERROR: bridge returned $status"; exit 1; } + + - name: Assert real Plane has the mirrored work item + run: | + set -euo pipefail + # The bridge stamps external_source="forge:acme/widget" and + # external_id="42" on the create. Plane's per-project issues + # endpoint short-circuits to a single get when both are set, + # returning 200 with the bare WorkItem on a hit or 404 on a + # miss. Translator + decode + POST happens asynchronously + # relative to the 202 ack, so poll briefly. + for i in $(seq 1 30); do + http=$(curl -sS -o /tmp/wi.json -w '%{http_code}' \ + -H "X-API-Key: $PFB_PLANE_API_KEY" \ + "http://localhost:8765/api/v1/workspaces/$PFB_PLANE_WORKSPACE_SLUG/projects/$PFB_PLANE_PROJECT_ID/issues/?external_source=forge:acme/widget&external_id=42") + if [ "$http" = "200" ]; then + echo "work item found after ${i}s" + jq '{id, name, sequence_id, external_source, external_id, state}' /tmp/wi.json + # PFB-25 regression check: state must decode, not break the + # bridge. If decode broke, the bridge's POST log would show + # the unmarshal error and no work item would land. + state_id=$(jq -r '.state // empty' /tmp/wi.json) + [ -n "$state_id" ] || { echo "ERROR: state empty in response"; exit 1; } + exit 0 + fi + sleep 2 + done + echo "ERROR: work item never appeared in real Plane" + docker logs pfb-bridge --tail=80 || true + exit 1 + + - name: Bridge logs (always) + if: always() + run: docker logs pfb-bridge --tail=120 || true + + - name: Plane logs (always) + if: always() + run: | + docker compose -f test/e2e-docker/plane-ce/docker-compose.yaml \ + logs --tail=80 plane-api plane-worker || true + + - name: Tear down + if: always() + run: | + docker rm -f pfb-bridge 2>/dev/null || true + bash test/e2e-docker/plane-ce/run.sh down + # Publish on push to main (rolling `:latest`) and on v* tag pushes (semver # tags). Never on PRs. Retag the build-image artifact rather than rebuild — # the bytes we publish are the bytes e2e-docker just validated. publish: name: publish - needs: [lint, build-image, e2e-docker] + needs: [lint, build-image, e2e-docker, e2e-real-plane] if: | github.event_name != 'pull_request' && needs.e2e-docker.result == 'success' && + needs.e2e-real-plane.result == 'success' && needs.lint.result == 'success' runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 263fc95..4b93dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to plane-forge-bridge are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- **CI gains a real-Plane round-trip job (PFB-28).** The + `e2e-real-plane` workflow stage brings up a minimum Plane CE v1.3.1 + stack (api+worker+postgres+redis+rabbitmq+minio) via + `test/e2e-docker/plane-ce/docker-compose.yaml`, seeds an admin user / + workspace / project / API token via direct Django ORM + (`seed.py` — Plane CE has no headless signup flow), then runs the + **bridge image built by `build-image`** against that real Plane and + POSTs a synthetic Forgejo `issues.opened` webhook. Asserts the + resulting work item in real Plane via REST (`external_source` + + `external_id` round-trip) — exactly the path PFB-25 broke in + production. The publish gate now requires this job alongside `lint` + and `e2e-docker`. Goal: future Plane wire-shape changes (the + PFB-22 / PFB-24 / PFB-25 class) fail in CI instead of in production — + the existing `plane-stub` happily agrees with synthetic testdata, so + it can't catch upstream drift on its own. + ## [0.1.4] — 2026-05-24 ### Fixed diff --git a/test/e2e-docker/plane-ce/README.md b/test/e2e-docker/plane-ce/README.md new file mode 100644 index 0000000..83ca061 --- /dev/null +++ b/test/e2e-docker/plane-ce/README.md @@ -0,0 +1,140 @@ +# test/e2e-docker/plane-ce — real Plane CE in CI + +A minimal Plane CE v1.3.1 stack the bridge spins up to round-trip a +real forge webhook → translator → POST `/issues/` → assertion exchange +against the actual Plane backend (not the recording stub). This is the +structural fix from PFB-28 for the class of bug that produced PFB-22, +PFB-24, and PFB-25 — testdata that diverged from real Plane wire +shape, with the existing e2e stub happy to agree with synthetic data. + +## What runs + +`docker-compose.yaml` brings up the minimum Plane CE topology needed +for the REST API + webhook delivery worker: + +- `plane-db` — postgres 15 (ephemeral, no volume) +- `plane-redis` — valkey 7 +- `plane-mq` — rabbitmq 3.13 with management plugin +- `plane-minio` — S3-compatible storage for attachments (Plane requires + it even if nothing uses it) +- `plane-migrator` — one-shot django migrations +- `plane-api` — gunicorn serving `/api/v1/*` and `/auth/*` +- `plane-worker` — celery worker (processes webhook deliveries) +- `plane-beat-worker` — celery beat scheduler + +Trimmed from the upstream `makeplane/plane` compose by dropping the +frontends (web, space, admin, live), the proxy, and all persistent +volumes — none are needed for headless REST + webhook coverage. + +## What CI does + +The `e2e-real-plane` job in `.github/workflows/ci.yaml`: + +1. Brings up this stack via `run.sh up` (~90s on a cold cache) +2. Seeds an admin user + workspace + project + API token via direct + Django ORM (see `seed.py`) +3. Pulls **the bridge image built by `build-image`** (not the source — + this is the artifact that's about to be published) +4. Runs that image as a sibling container on the `pfb-e2e-plane-ce` + network, configured to talk to `plane-api:8000/api/v1` +5. POSTs a synthetic Forgejo `issues.opened` webhook + (`internal/forge/testdata/issues_opened.json`) at the bridge +6. Polls real Plane via REST for the work item the bridge should + have created, looking it up by `external_source=forge:acme/widget` + + `external_id=42`. A 200 with a populated `state` field proves + the full round-trip (forge webhook → bridge decode → translator → + bridge POST → real Plane decode → REST GET round-trip) — exactly + the path PFB-25 broke in production. +7. Tears down on success or failure (`if: always()`). + +The `publish` job depends on `e2e-real-plane` succeeding, alongside +`lint` and `e2e-docker`. + +## Why these versions + +The Plane backend is pinned to `v1.3.1` because that is the version +`plane.stern.ca` runs and that `internal/plane/testdata/` is locked to +(see `internal/plane/testdata/README.md`). Bumping the version requires: + +1. Re-capturing the webhook payloads in `internal/plane/testdata/` + against the new version +2. Verifying that `seed.py` still finds the expected ORM surface (Plane + has moved User/Workspace models across module boundaries before) +3. Verifying that the `register_instance` and `configure_instance` + management commands still exist with the same arguments + +The `valkey:7.2.11-alpine` / `postgres:15.7-alpine` / `rabbitmq:3.13.6-management-alpine` +pins match the upstream compose at the same Plane release; no reason to +drift them independently. + +## Local run + +```bash +# Bring the stack up + seed admin/workspace/project/token (~90s cold cache): +bash test/e2e-docker/plane-ce/run.sh up +# Last stdout line is JSON: +# {"workspace_slug":"pfb-ci","project_id":"...","api_token":"...", ...} + +# Inspect the seeded REST surface: +TOKEN= +curl -H "X-API-Key: $TOKEN" \ + http://localhost:8765/api/v1/workspaces/pfb-ci/projects/ | jq + +# Tear down: +bash test/e2e-docker/plane-ce/run.sh down +``` + +The seed is idempotent — re-running it returns the same workspace, +project, and token; pass `PFB_PLANE_API_KEY=` to lock the +token to a stable string (CI does this for log grep-ability). + +To exercise the full bridge round-trip locally, follow the same +sequence the CI job does (compose up + seed → run bridge image with +config pointing at `plane-api:8000` on the compose network → POST +signed webhook → assert via REST). See the `e2e-real-plane` block in +`.github/workflows/ci.yaml` for the exact commands. + +### Podman wart + +On rootless podman, `rabbitmq:3.13.6-management-alpine` fails to start +with `eacces` on `/var/lib/rabbitmq/.erlang.cookie` because the +container runs as root, which maps to a UID that lacks read access to +the in-image `/var/lib/rabbitmq/` (owned by uid 100). The compose pins +`user: "100:101"` on the rabbitmq service to work around this. Docker +(in CI) is unaffected either way. + +Separately, rootless podman occasionally interprets `-v file:file` +bind mounts as directories (the destination doesn't exist yet, so +podman creates a directory at that path before the source is mounted). +The Linux Docker daemon in GitHub Actions doesn't have this quirk; +the e2e-real-plane CI job uses the same `-v config.yaml:...` pattern +the existing e2e-docker job uses without issue. + +## Why the healthcheck uses 127.0.0.1, not localhost + +`plane-api` gunicorn binds `0.0.0.0:8000` (IPv4 only). On some rootless +podman setups, `localhost` inside the container resolves to `::1` +first, and the wget healthcheck gets ECONNREFUSED. `127.0.0.1` is +unambiguous and works across Docker and Podman. + +## Bootstrap details (seed.py) + +Plane CE has no documented headless seed path. The browser flow +(register instance → sign up admin → create workspace → create project → +mint API token) is human-oriented and runs through Django sessions +with CSRF cookies; driving it from a script is more wire than it's +worth. `seed.py` runs inside the `plane-api` container and goes +directly through Django ORM: + +- `Instance` row with `is_setup_done=True` (the gate the auth views + check before allowing sign-up) +- `User` with a hashed password + `is_email_verified=True` +- `InstanceAdmin` linking the user to the instance as Owner (role 20) +- `Workspace` (slug `pfb-ci`) + `WorkspaceMember` +- `Project` (identifier `PFB`) + `ProjectIdentifier` + `ProjectMember` +- Default `State` rows (Backlog/Todo/In Progress/Done/Cancelled) — Plane + normally seeds these via a post-create signal, but we create them + explicitly so state-map tests are deterministic +- `APIToken` with a known value (env override or random UUID hex) + +Every step is `get_or_create`, so the seed is safe to re-run. diff --git a/test/e2e-docker/plane-ce/docker-compose.yaml b/test/e2e-docker/plane-ce/docker-compose.yaml new file mode 100644 index 0000000..0a3ef0f --- /dev/null +++ b/test/e2e-docker/plane-ce/docker-compose.yaml @@ -0,0 +1,191 @@ +# Minimal Plane CE v1.3.1 stack for the bridge's real-Plane e2e tests. +# +# Trimmed from the upstream makeplane/plane docker-compose: we keep only +# the services needed for REST + webhooks (api, worker, beat-worker, +# migrator, postgres, redis, rabbitmq, minio). No frontends (web/space/ +# admin/live), no proxy — the bridge talks to Plane over the REST API at +# http://plane-api:8000/ and receives webhooks back from plane-worker. +# +# Pinned to v1.3.1 to match internal/plane/testdata/. Bumping the version +# requires recapturing testdata; see internal/plane/testdata/README.md. + +x-db-env: &db-env + PGHOST: plane-db + PGDATABASE: plane + POSTGRES_USER: plane + POSTGRES_PASSWORD: plane + POSTGRES_DB: plane + POSTGRES_PORT: "5432" + PGDATA: /var/lib/postgresql/data + +x-redis-env: &redis-env + REDIS_HOST: plane-redis + REDIS_PORT: "6379" + REDIS_URL: redis://plane-redis:6379/ + +x-minio-env: &minio-env + MINIO_ROOT_USER: access-key + MINIO_ROOT_PASSWORD: secret-key + +x-aws-s3-env: &aws-s3-env + AWS_REGION: "" + AWS_ACCESS_KEY_ID: access-key + AWS_SECRET_ACCESS_KEY: secret-key + AWS_S3_ENDPOINT_URL: http://plane-minio:9000 + AWS_S3_BUCKET_NAME: uploads + +x-mq-env: &mq-env + RABBITMQ_HOST: plane-mq + RABBITMQ_PORT: "5672" + RABBITMQ_DEFAULT_USER: plane + RABBITMQ_DEFAULT_PASS: plane + RABBITMQ_DEFAULT_VHOST: plane + RABBITMQ_VHOST: plane + +x-app-env: &app-env + # Plane's anti-SSRF webhook validator DNS-resolves and rejects private + # IPs. The bridge is reachable on this compose network as + # http://pfb-bridge:8080; WEBHOOK_ALLOWED_HOSTS bypasses the check. + # This must match the bridge container's hostname. + WEBHOOK_ALLOWED_HOSTS: pfb-bridge,host.docker.internal + WEBHOOK_ALLOWED_IPS: "" + WEB_URL: http://localhost:8000 + DEBUG: "0" + CORS_ALLOWED_ORIGINS: http://localhost:8000 + GUNICORN_WORKERS: "1" + USE_MINIO: "1" + DATABASE_URL: postgresql://plane:plane@plane-db/plane + SECRET_KEY: ci-secret-key-not-for-production-use-32chr + AMQP_URL: amqp://plane:plane@plane-mq:5672/plane + API_KEY_RATE_LIMIT: 600/minute + MINIO_ENDPOINT_SSL: "0" + LIVE_SERVER_SECRET_KEY: ci-live-secret-not-for-production-use32 + +services: + plane-db: + image: postgres:15.7-alpine + command: postgres -c 'max_connections=1000' + environment: + <<: *db-env + # No volume — CI is one-shot, data dies with the compose down. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U plane -d plane"] + interval: 5s + timeout: 3s + retries: 20 + + plane-redis: + image: valkey/valkey:7.2.11-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + plane-mq: + image: rabbitmq:3.13.6-management-alpine + # Run as the rabbitmq user inside the container. Default is root, + # which on rootless podman maps to a host UID that lacks read access + # to the in-image /var/lib/rabbitmq/ (owned by uid 100). With + # `user:` set, the container starts as rabbitmq directly and the + # .erlang.cookie permission dance works. Docker (CI) is unaffected + # either way; this also helps anyone running the e2e locally on + # podman. + user: "100:101" + environment: + <<: *mq-env + healthcheck: + # rabbitmq-diagnostics check_port_connectivity is the official + # readiness probe per upstream docs. + test: ["CMD", "rabbitmq-diagnostics", "-q", "check_port_connectivity"] + interval: 10s + timeout: 5s + retries: 20 + + plane-minio: + image: minio/minio:RELEASE.2025-01-20T14-49-07Z + command: server /export --console-address ":9090" + environment: + <<: *minio-env + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 3s + retries: 20 + + plane-migrator: + image: makeplane/plane-backend:v1.3.1 + command: ./bin/docker-entrypoint-migrator.sh + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env] + depends_on: + plane-db: + condition: service_healthy + plane-redis: + condition: service_healthy + restart: on-failure + + plane-api: + image: makeplane/plane-backend:v1.3.1 + command: ./bin/docker-entrypoint-api.sh + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env] + depends_on: + plane-migrator: + condition: service_completed_successfully + plane-db: + condition: service_healthy + plane-redis: + condition: service_healthy + plane-mq: + condition: service_healthy + plane-minio: + condition: service_healthy + ports: + # Expose to the host so seed scripts and integration tests outside + # the compose network can hit the REST API. 8765 is unlikely to + # collide with a developer's local services; the CI workflow can + # override via `services.plane-api.ports`. + - "8765:8000" + healthcheck: + # plane-backend image has wget but no curl. /api/instances/ returns + # 200 even before register_instance — Plane always renders the + # Instance row (or its absence) as JSON. + # + # Use 127.0.0.1, not localhost: gunicorn binds 0.0.0.0:8000 (IPv4 + # only) and on some rootless podman setups `localhost` inside the + # container resolves to ::1, which fails ECONNREFUSED. 127.0.0.1 + # is unambiguous across docker/podman/CI. + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8000/api/instances/"] + interval: 5s + timeout: 5s + retries: 30 + # Cold-cache gunicorn boot is ~30s; static collectstatic + + # bucket creation adds more. Don't fail-healthy before ~60s. + start_period: 60s + + plane-worker: + image: makeplane/plane-backend:v1.3.1 + command: ./bin/docker-entrypoint-worker.sh + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + depends_on: + plane-api: + condition: service_healthy + plane-mq: + condition: service_healthy + + plane-beat-worker: + image: makeplane/plane-backend:v1.3.1 + command: ./bin/docker-entrypoint-beat.sh + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + depends_on: + plane-api: + condition: service_healthy + plane-mq: + condition: service_healthy + +networks: + default: + name: pfb-e2e-plane-ce diff --git a/test/e2e-docker/plane-ce/run.sh b/test/e2e-docker/plane-ce/run.sh new file mode 100755 index 0000000..6e64759 --- /dev/null +++ b/test/e2e-docker/plane-ce/run.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Bring up the Plane CE v1.3.1 stack used by the bridge's real-Plane +# round-trip integration test, seed an admin user / workspace / project / +# API token, and emit a single JSON line with the connection details for +# callers to source. +# +# Usage: +# bash test/e2e-docker/plane-ce/run.sh up # bring up + seed; print JSON +# bash test/e2e-docker/plane-ce/run.sh down # tear down +# bash test/e2e-docker/plane-ce/run.sh seed # re-run the seed (idempotent) +# +# The "up" form is the entry point for CI: it blocks until plane-api is +# accepting requests, runs the seed, and prints +# {"workspace_slug":"...","project_id":"...","api_token":"...", ...} +# on the last stdout line. Earlier lines are progress diagnostics. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yaml" +SEED_SCRIPT="${SCRIPT_DIR}/seed.py" + +API_HOST_PORT="${PFB_PLANE_HOST_PORT:-8765}" +API_HEALTH_URL="http://localhost:${API_HOST_PORT}/api/instances/" + +# Compose helper. Prefer the v2 plugin (`docker compose`); fall back to +# the standalone `docker-compose` so CI images without the plugin work. +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose -f "$COMPOSE_FILE" "$@" + else + docker-compose -f "$COMPOSE_FILE" "$@" + fi +} + +log() { echo "pfb-e2e-plane:" "$@" >&2; } + +wait_for_api() { + local i max=60 + for ((i=1; i<=max; i++)); do + # /api/instances/ returns 200 even before configure_instance — the + # endpoint always renders the Instance record (or its absence) as JSON. + if curl -fsS "$API_HEALTH_URL" >/dev/null 2>&1; then + log "plane-api healthy after ${i}s" + return 0 + fi + sleep 5 + done + log "ERROR: plane-api never became healthy" + compose logs --tail=80 plane-api >&2 + return 1 +} + +cmd_up() { + log "bringing up Plane CE v1.3.1 stack (this takes ~90s on a cold cache)" + compose up -d + wait_for_api + cmd_seed +} + +cmd_seed() { + log "seeding admin user + workspace + project + API token" + # Pass the desired API token through if the caller wants a stable value + # (CI sets PFB_PLANE_API_KEY=ci-plane-key- for grep-ability in + # logs); otherwise seed.py generates a random one. + local token_env="" + if [[ -n "${PFB_PLANE_API_KEY:-}" ]]; then + token_env="-e PFB_PLANE_API_KEY=${PFB_PLANE_API_KEY}" + fi + + # Copy the seed script into the container, then run it with the plane + # codebase on PYTHONPATH. We don't bind-mount because compose-managed + # containers in CI may not have host paths accessible. + docker cp "$SEED_SCRIPT" plane-ce-plane-api-1:/tmp/seed.py + # shellcheck disable=SC2086 + compose exec -T $token_env -e PYTHONPATH=/code plane-api \ + python /tmp/seed.py +} + +cmd_down() { + log "tearing down Plane CE stack" + compose down -v --remove-orphans +} + +case "${1:-up}" in + up) cmd_up ;; + seed) cmd_seed ;; + down) cmd_down ;; + *) + echo "usage: $0 {up|seed|down}" >&2 + exit 64 + ;; +esac diff --git a/test/e2e-docker/plane-ce/seed.py b/test/e2e-docker/plane-ce/seed.py new file mode 100644 index 0000000..f43d880 --- /dev/null +++ b/test/e2e-docker/plane-ce/seed.py @@ -0,0 +1,227 @@ +"""Bootstrap a Plane CE v1.3.1 instance for the bridge's e2e test. + +Runs inside the plane-api container via: + docker compose exec plane-api python /etc/pfb-seed/seed.py + +Creates: + - the singleton Instance row + InstanceConfiguration entries + - an admin user (uid=pfb-admin, email=pfb-admin@pfb.test) with a known password + - a Workspace ("pfb-ci", slug=pfb-ci) + - a Project ("PFB", identifier="PFB") + - an APIToken with a known value (PFB_PLANE_API_KEY env var, falls back to a generated one) + +Bypasses the /auth/sign-up/ HTML flow (session cookies, CSRF, redirects) and +the UI workspace onboarding flow — both are expensive to drive from a script +and exist for human operators, not bridge bootstrap. Inserting directly +through Django ORM keeps the seed self-contained and idempotent: re-running +the seed is a no-op once everything exists. + +Emits the API token to stdout (last line); the surrounding shell script +captures it for the bridge config + integration test env. +""" +import os +import sys +import uuid + +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") +django.setup() + +from django.contrib.auth.hashers import make_password +from django.utils import timezone + +from plane.db.models import ( + APIToken, + Project, + ProjectIdentifier, + ProjectMember, + User, + Workspace, + WorkspaceMember, +) +from plane.license.models import Instance, InstanceAdmin, InstanceEdition + + +ADMIN_EMAIL = "pfb-admin@pfb.test" +ADMIN_PASSWORD = "pfb-admin-pass-ci-only" +ADMIN_USERNAME = "pfb-admin" +WORKSPACE_NAME = "pfb-ci" +WORKSPACE_SLUG = "pfb-ci" +PROJECT_NAME = "PFB Bridge E2E" +PROJECT_IDENTIFIER = "PFB" +TOKEN_LABEL = "pfb-bridge-e2e" + + +def ensure_instance() -> Instance: + instance = Instance.objects.first() + if instance is None: + instance = Instance.objects.create( + instance_name="Plane CE - pfb e2e", + instance_id=uuid.uuid4().hex[:24], + current_version=os.environ.get("APP_VERSION", "v1.3.1"), + latest_version=os.environ.get("APP_VERSION", "v1.3.1"), + last_checked_at=timezone.now(), + is_test=True, + edition=InstanceEdition.PLANE_COMMUNITY.value, + ) + if not instance.is_setup_done: + instance.is_setup_done = True + instance.save(update_fields=["is_setup_done"]) + return instance + + +def ensure_admin_user() -> User: + user, created = User.objects.get_or_create( + email=ADMIN_EMAIL, + defaults={ + "username": ADMIN_USERNAME, + "password": make_password(ADMIN_PASSWORD), + "is_active": True, + "is_email_verified": True, + "first_name": "PFB", + "last_name": "Admin", + "display_name": ADMIN_USERNAME, + }, + ) + if created: + # Newly created users land without the password hash bit being + # marked "set" — fix that up so downstream auth flows work. + user.set_password(ADMIN_PASSWORD) + user.is_password_autoset = False + user.save() + return user + + +def ensure_instance_admin(instance: Instance, user: User) -> None: + InstanceAdmin.objects.get_or_create( + instance=instance, + user=user, + defaults={"role": 20}, # ROLE = Owner; see plane.license.models + ) + + +def ensure_workspace(owner: User) -> Workspace: + workspace, created = Workspace.objects.get_or_create( + slug=WORKSPACE_SLUG, + defaults={ + "name": WORKSPACE_NAME, + "owner": owner, + "organization_size": "Just myself", + }, + ) + WorkspaceMember.objects.get_or_create( + workspace=workspace, + member=owner, + defaults={"role": 20}, # Admin + ) + return workspace + + +def ensure_project(workspace: Workspace, owner: User) -> Project: + project, created = Project.objects.get_or_create( + workspace=workspace, + identifier=PROJECT_IDENTIFIER, + defaults={ + "name": PROJECT_NAME, + "created_by": owner, + "updated_by": owner, + "network": 2, # workspace-public + }, + ) + ProjectIdentifier.objects.get_or_create( + workspace=workspace, + name=PROJECT_IDENTIFIER, + defaults={"project": project}, + ) + ProjectMember.objects.get_or_create( + workspace=workspace, + project=project, + member=owner, + defaults={"role": 20, "is_active": True}, + ) + return project + + +def ensure_default_states(project: Project, owner: User) -> None: + """Plane normally seeds default states (Backlog, Todo, In Progress, + Done, Cancelled) on workspace/project creation via a signal. We + create them explicitly so the bridge's state-map lookups have + something to find.""" + from plane.db.models import State + + defaults = [ + ("Backlog", "backlog", "#5e6ad2"), + ("Todo", "unstarted", "#3f76ff"), + ("In Progress", "started", "#f59e0b"), + ("Done", "completed", "#22c55e"), + ("Cancelled", "cancelled", "#ef4444"), + ] + for name, group, color in defaults: + State.objects.get_or_create( + workspace=project.workspace, + project=project, + name=name, + defaults={ + "group": group, + "color": color, + "default": name == "Backlog", + "created_by": owner, + "updated_by": owner, + }, + ) + + +def ensure_api_token(user: User, workspace: Workspace) -> APIToken: + desired_token = os.environ.get("PFB_PLANE_API_KEY", "").strip() + existing = APIToken.objects.filter( + user=user, workspace=workspace, label=TOKEN_LABEL + ).first() + if existing: + if desired_token and existing.token != desired_token: + existing.token = desired_token + existing.save(update_fields=["token"]) + return existing + return APIToken.objects.create( + user=user, + workspace=workspace, + label=TOKEN_LABEL, + description="bridge e2e token (CI only)", + token=desired_token or uuid.uuid4().hex, + user_type=0, + is_active=True, + is_service=False, + allowed_rate_limit="600/minute", + ) + + +def main() -> None: + instance = ensure_instance() + user = ensure_admin_user() + ensure_instance_admin(instance, user) + workspace = ensure_workspace(user) + project = ensure_project(workspace, user) + ensure_default_states(project, user) + token = ensure_api_token(user, workspace) + + sys.stderr.write( + f"seed: instance={instance.instance_id} user={user.email} " + f"workspace_slug={workspace.slug} workspace_id={workspace.id} " + f"project_id={project.id} project_identifier={project.identifier}\n" + ) + # stdout: a single JSON line with everything the caller needs to wire + # the bridge config. Captured by seed.sh. + import json + print(json.dumps({ + "workspace_slug": workspace.slug, + "workspace_id": str(workspace.id), + "project_id": str(project.id), + "project_identifier": project.identifier, + "admin_user_id": str(user.id), + "admin_email": user.email, + "api_token": token.token, + })) + + +if __name__ == "__main__": + main()