diff --git a/.github/workflows/doccano-django.yml b/.github/workflows/doccano-django.yml new file mode 100644 index 0000000..426496b --- /dev/null +++ b/.github/workflows/doccano-django.yml @@ -0,0 +1,197 @@ +# doccano-django sample CI — keploy-independent end-to-end smoke + +# coverage gate. +# +# Triggers ONLY on changes under doccano-django/ (or this workflow +# file). Other samples in this repo have their own orthogonal CI; +# gating the whole repo on every doccano change would slow them +# all down for no benefit. +# +# What it gates: +# * `release-coverage` — checks out the PR's base branch (main) +# and runs the sample end-to-end: docker compose up, bootstrap +# admin token, drive flow.sh record-traffic with the per-call +# audit log enabled, capture the route-coverage percentage from +# `flow.sh coverage`. This is the baseline. +# * `build-coverage` — same end-to-end against the PR's HEAD ref. +# * `coverage-gate` — fails the PR if `build`'s coverage drops +# more than COVERAGE_THRESHOLD percentage points below +# `release`. Default threshold is 1.0pp; override via repo +# variable `DOCCANO_COVERAGE_THRESHOLD` for a tighter or +# looser bar. +# +# On push to main, only `build-coverage` runs (no baseline to +# compare against — main IS the baseline). +# +# Standards-aligned choices: +# * `paths:` filter on both push and pull_request triggers — the +# canonical GH Actions way to scope a workflow to one +# subdirectory. +# * Job outputs (steps..outputs.coverage → needs..outputs) +# to thread the captured percentage between jobs. +# * `concurrency:` cancel-in-progress on the same ref so a stale +# run doesn't waste runner minutes. +# * actions/upload-artifact for the human-readable +# coverage_report.txt — reviewers can inspect missing routes +# directly from the PR's "checks" tab. +# * marocchino/sticky-pull-request-comment for the PR-side diff +# comment. Pinned-by-header so successive runs update the same +# comment instead of fanning out. +# * The compare step is plain bash + python3 (no external +# coverage service). For full Python coverage.py XMLs you'd +# want diff-cover or codecov, but the sample's coverage is +# API-route-based (single percentage), so the gate is a 3-line +# subtraction. +# +# Sample is genuinely keploy-independent here: the workflow uses +# flow.sh's $DOCCANO_FIRED_ROUTES_FILE per-call audit log as its +# numerator source, not a keploy recording. The lane scripts in +# keploy/integrations and keploy/enterprise consume the same +# flow.sh, but use the keploy/test-set-*/tests/*.yaml tree as +# their numerator (authoritative — only calls keploy actually +# CAPTURED count). Both modes are wired into +# `flow.sh::doccano_list_recorded_routes`. +name: doccano-django sample + +on: + pull_request: + paths: + - 'doccano-django/**' + - '.github/workflows/doccano-django.yml' + push: + branches: [main] + paths: + - 'doccano-django/**' + - '.github/workflows/doccano-django.yml' + workflow_dispatch: {} + +concurrency: + group: doccano-django-${{ github.ref }} + cancel-in-progress: true + +env: + COVERAGE_THRESHOLD: ${{ vars.DOCCANO_COVERAGE_THRESHOLD || '1.0' }} + +jobs: + build-coverage: + name: build (current ref) coverage + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + coverage: ${{ steps.measure.outputs.coverage }} + steps: + - uses: actions/checkout@v4 + - id: measure + name: Run sample end-to-end + measure coverage + working-directory: doccano-django + env: + DOCCANO_FIRED_ROUTES_FILE: ${{ runner.temp }}/fired-routes-build.log + DOCCANO_PHASE: ci-build + run: ../.github/workflows/scripts/run-and-measure.sh + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-build + path: doccano-django/coverage_report.txt + if-no-files-found: warn + + release-coverage: + if: github.event_name == 'pull_request' + name: release (base ref) coverage + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + coverage: ${{ steps.measure.outputs.coverage || steps.empty-baseline.outputs.coverage }} + sample-existed: ${{ steps.detect.outputs.sample-existed }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + + # First-PR bootstrap escape hatch: the very PR that + # introduces the doccano-django/ sample has no baseline + # (doccano-django/ doesn't exist on the base ref). Detect + # that and short-circuit to coverage=0; the gate then + # treats build's coverage as the new baseline and trivially + # passes for any percentage > 0. After the introducing PR + # merges, every subsequent PR has a real baseline to diff + # against. + - id: detect + name: Detect baseline presence + run: | + if [ -d doccano-django ] && [ -x doccano-django/flow.sh ]; then + echo "sample-existed=true" >>"$GITHUB_OUTPUT" + echo "Sample exists on base ref — running full measurement." + else + echo "sample-existed=false" >>"$GITHUB_OUTPUT" + echo "No doccano-django/ on base ref — first-PR bootstrap; baseline coverage treated as 0%." + fi + + - id: measure + name: Run sample end-to-end + measure coverage + if: steps.detect.outputs.sample-existed == 'true' + working-directory: doccano-django + env: + DOCCANO_FIRED_ROUTES_FILE: ${{ runner.temp }}/fired-routes-release.log + DOCCANO_PHASE: ci-release + run: ../.github/workflows/scripts/run-and-measure.sh + + - id: empty-baseline + name: Emit zero baseline (first-PR bootstrap) + if: steps.detect.outputs.sample-existed != 'true' + run: echo "coverage=0.0" >>"$GITHUB_OUTPUT" + + - name: Upload coverage report + if: always() && steps.detect.outputs.sample-existed == 'true' + uses: actions/upload-artifact@v4 + with: + name: coverage-release + path: doccano-django/coverage_report.txt + if-no-files-found: warn + + coverage-gate: + if: github.event_name == 'pull_request' + name: coverage gate + needs: [build-coverage, release-coverage] + runs-on: ubuntu-latest + steps: + - name: Compare build vs release + env: + BUILD: ${{ needs.build-coverage.outputs.coverage }} + RELEASE: ${{ needs.release-coverage.outputs.coverage }} + THRESHOLD: ${{ env.COVERAGE_THRESHOLD }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -Eeuo pipefail + if [ -z "${BUILD:-}" ] || [ -z "${RELEASE:-}" ]; then + echo "::error::missing coverage outputs — build='${BUILD:-}' release='${RELEASE:-}'" + exit 1 + fi + drop=$(python3 -c "print(round(${RELEASE} - ${BUILD}, 2))") + echo "Release (${BASE_REF}): ${RELEASE}%" + echo "Build (this PR): ${BUILD}%" + echo "Drop: ${drop}pp (threshold ${THRESHOLD}pp)" + if python3 -c "import sys; sys.exit(0 if (${RELEASE} - ${BUILD}) > ${THRESHOLD} else 1)"; then + echo "::error::doccano-django coverage dropped from ${RELEASE}% → ${BUILD}% (-${drop}pp), exceeding the ${THRESHOLD}pp threshold." + echo "Suggested actions:" + echo " * Add curl(s) to flow.sh::doccano_record_traffic that exercise the new code paths." + echo " * Or extend the .coveragerc 'omit' list if the new module is not part of the runtime backend (migrations, management commands, tests)." + exit 1 + fi + echo "OK — coverage delta within ${THRESHOLD}pp threshold." + + - name: Sticky PR comment + if: ${{ !cancelled() }} + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: doccano-django-coverage + message: | + ### doccano-django sample coverage + + | ref | coverage | + |---|---| + | base (`${{ github.event.pull_request.base.ref }}`) | **${{ needs.release-coverage.outputs.coverage }}%** | + | this PR | **${{ needs.build-coverage.outputs.coverage }}%** | + + Threshold: PR may not drop coverage by more than **${{ env.COVERAGE_THRESHOLD }}pp**. Override per-repo via the `DOCCANO_COVERAGE_THRESHOLD` actions variable. diff --git a/.github/workflows/scripts/run-and-measure.sh b/.github/workflows/scripts/run-and-measure.sh new file mode 100755 index 0000000..b803679 --- /dev/null +++ b/.github/workflows/scripts/run-and-measure.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# +# run-and-measure.sh — bring doccano up under the coverage overlay, +# run flow.sh bootstrap + record-traffic, flush coverage from each +# gunicorn worker, run flow.sh coverage to combine + report, and +# emit `coverage=PCT` onto $GITHUB_OUTPUT for the downstream +# coverage-gate job. +# +# Called from .github/workflows/doccano-django.yml's build-coverage +# and release-coverage jobs (one per ref under comparison). Both +# jobs source the same script so the measurement is identical +# across refs — any drift in the numerator definition would +# otherwise produce a misleading delta. +# +# Coverage isolation contract: +# * Base `Dockerfile` and `docker-compose.yml` are untouched. +# * The overlay `Dockerfile.coverage` + `docker-compose.coverage.yml` +# adds coverage.py + the auto-start .pth file. ONLY this script +# applies the overlay; the keploy/integrations and +# keploy/enterprise CI lanes consume the base compose and pay +# zero coverage-instrumentation cost. +# +# Inputs (from the workflow env): +# DOCCANO_PHASE — label spliced into the project name so +# build vs release runs don't collide. +# GITHUB_OUTPUT — standard GH Actions sink for step outputs. +set -Eeuo pipefail + +export DOCCANO_BACKEND_CONTAINER="${DOCCANO_BACKEND_CONTAINER:-doccano_backend}" +export DOCCANO_DB_CONTAINER="${DOCCANO_DB_CONTAINER:-doccano_db}" +export DOCCANO_APP_PORT="${DOCCANO_APP_PORT:-18080}" +export DOCCANO_FIXED_TOKEN="${DOCCANO_FIXED_TOKEN:-ac38262065f0ae1476b6a707d9d697a101764a6b}" + +mkdir -p coverage +chmod 777 coverage # worker UID inside container differs from runner UID +sudo rm -rf coverage/.coverage* 2>/dev/null || rm -rf coverage/.coverage* 2>/dev/null || true + +COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.coverage.yml) + +# Stage 1: bring up doccano with bootstrap so the schema migrations +# and the admin user persist into the named DB volume. The overlay +# image runs gunicorn with coverage.process_startup() auto-armed in +# every forked worker. +DOCCANO_SKIP_BOOTSTRAP=0 "${COMPOSE[@]}" up -d --build + +# Wait for the backend to start serving (cold doccano boot runs +# Django migrations + admin user create — on a GH runner this can +# hit 90-120s). +for i in $(seq 1 120); do + code=$(curl -sS -o /dev/null -w '%{http_code}' \ + "http://127.0.0.1:${DOCCANO_APP_PORT}/v1/health/" 2>/dev/null || echo "") + if [ -n "$code" ] && [ "$code" != "000" ]; then break; fi + sleep 2 +done + +bash flow.sh bootstrap 240 +"${COMPOSE[@]}" down --remove-orphans + +# Stage 2: re-launch in skip-bootstrap mode against the populated +# volume; same shape the keploy lanes use. The overlay layer is +# preserved across compose-down (only `down -v` would wipe the +# named volume), so coverage tooling is still wired in. +DOCCANO_SKIP_BOOTSTRAP=1 "${COMPOSE[@]}" up -d + +# flow.sh::doccano_record_traffic gates on doccano_wait_for_fixed_token +# internally, so this won't fire curls at a half-booted backend. +bash flow.sh record-traffic + +# Flush coverage from each gunicorn worker. coverage.py with +# sigterm = true writes the in-flight per-worker .coverage. +# data file to /coverage on SIGTERM; `compose kill -s SIGTERM` +# delivers it to the container's main process which propagates to +# its workers via gunicorn's graceful shutdown. +"${COMPOSE[@]}" kill -s SIGTERM backend +# coverage.py's sigterm hook is synchronous but the OS-level +# write+fsync needs a moment. +sleep 3 + +# Bring backend back up so `flow.sh coverage` can docker-exec +# `coverage combine` + `coverage report` inside. +"${COMPOSE[@]}" up -d backend +for i in $(seq 1 60); do + if docker exec "$DOCCANO_BACKEND_CONTAINER" sh -c 'ls /coverage/.coverage.* >/dev/null 2>&1'; then + break + fi + sleep 1 +done + +COVERAGE_REPORT_FILE="$PWD/coverage_report.txt" bash flow.sh coverage + +# Parse `Covered N/M (XX.X%)` — anchored on the parenthesised form +# so a future report-prose change doesn't break the parse. +pct=$(grep -oE '\([0-9]+\.[0-9]+%\)' coverage_report.txt | head -1 | tr -d '()%') +if [ -z "$pct" ]; then + echo "::error::Could not parse coverage percentage from coverage_report.txt" + cat coverage_report.txt || true + exit 1 +fi +echo "coverage=${pct}" >>"$GITHUB_OUTPUT" +echo "coverage: ${pct}% (Python line coverage via coverage.py)" + +"${COMPOSE[@]}" down -v --remove-orphans diff --git a/doccano-django/.coveragerc b/doccano-django/.coveragerc new file mode 100644 index 0000000..134430e --- /dev/null +++ b/doccano-django/.coveragerc @@ -0,0 +1,21 @@ +[run] +# Per-process line coverage of the backend Django code. +# +# parallel + sigterm: gunicorn forks WORKERS subprocesses; each +# writes its own .coverage.. file under /coverage. +# `combine` merges them at report time. `sigterm = true` flushes +# the in-flight data on SIGTERM so the reaper from the workflow +# captures it. +parallel = true +sigterm = true +branch = false +data_file = /coverage/.coverage +source = /backend + +omit = + */tests/* + */migrations/* + */__pycache__/* + /backend/manage.py + /backend/config/wsgi.py + /backend/config/asgi.py diff --git a/doccano-django/.gitignore b/doccano-django/.gitignore new file mode 100644 index 0000000..ac3950e --- /dev/null +++ b/doccano-django/.gitignore @@ -0,0 +1,2 @@ +coverage/ +coverage_report.txt diff --git a/doccano-django/Dockerfile b/doccano-django/Dockerfile new file mode 100644 index 0000000..66668ec --- /dev/null +++ b/doccano-django/Dockerfile @@ -0,0 +1,21 @@ +# Thin wrapper around doccano's official backend image at the version +# this sample tracks. Pinning here (rather than in each lane script +# under keploy/integrations / keploy/enterprise) means a future +# doccano release that changes the bug-triggering shape is a one-line +# retag in this repo, not a hunt across the CI tree. +# +# Upstream tag: doccano/doccano:backend (the rolling backend tag) +# Source pin: doccano/doccano @ v1.8.5 +# https://github.com/doccano/doccano/releases/tag/v1.8.5 +# +# v1.8.5 was the version exercised on keploy/enterprise pipeline 3556 +# (PR #1889) and pipeline 3572 (PR #1964 minimal repro) where the +# bug originally manifested. +FROM doccano/doccano:backend + +USER root +COPY doccano-entrypoint.sh /opt/bin/doccano-keploy-entrypoint.sh +RUN chmod +x /opt/bin/doccano-keploy-entrypoint.sh +USER doccano + +ENTRYPOINT ["/opt/bin/doccano-keploy-entrypoint.sh"] diff --git a/doccano-django/Dockerfile.coverage b/doccano-django/Dockerfile.coverage new file mode 100644 index 0000000..0ee9cb4 --- /dev/null +++ b/doccano-django/Dockerfile.coverage @@ -0,0 +1,37 @@ +# Coverage-instrumented variant of the doccano backend image. +# +# Base `Dockerfile` (and `docker-compose.yml`) are deliberately +# untouched so the keploy enterprise / integrations lanes — which +# consume them as-is — pay zero coverage-instrumentation cost. This +# overlay image is built and run ONLY by the standalone GitHub +# Actions workflow under `.github/workflows/doccano-django.yml`, +# wired in via `docker-compose.coverage.yml`. +# +# What the overlay adds: +# * `coverage` (Python coverage.py) installed into the same +# site-packages as gunicorn / Django. +# * `.coveragerc` placed at /backend/.coveragerc — the working +# directory the upstream image starts gunicorn from. With +# `COVERAGE_PROCESS_START=/backend/.coveragerc` exported into +# the container env (set in the compose overlay), every +# gunicorn worker that imports `coverage.process_startup` via +# site-packages will pick the rcfile up; combined with `parallel +# = true` and `sigterm = true` in the rcfile, this gives us +# real per-worker line coverage that flushes on SIGTERM. +FROM doccano/doccano:backend + +USER root +RUN pip install --no-cache-dir 'coverage[toml]==7.6.1' + +# Subprocess auto-start: a .pth file in site-packages is processed +# at every Python startup, so each gunicorn worker that forks calls +# coverage.process_startup() before any Django code runs. This is +# the canonical way coverage.py instruments forked subprocesses +# (see "Measuring sub-processes" in the coverage.py docs). +RUN echo 'import coverage; coverage.process_startup()' \ + > /usr/local/lib/python3.10/site-packages/coverage_subprocess.pth + +COPY .coveragerc /backend/.coveragerc +RUN mkdir -p /coverage \ + && chown -R doccano:doccano /coverage /backend/.coveragerc +USER doccano diff --git a/doccano-django/README.md b/doccano-django/README.md new file mode 100644 index 0000000..86c6973 --- /dev/null +++ b/doccano-django/README.md @@ -0,0 +1,147 @@ +# doccano-django — keploy postgres-v3 simple-Query bind regression sample + +Minimal reproducer for the doccano polymorphic-resourcetype failure +that motivated [keploy/integrations#177](https://github.com/keploy/integrations/pull/177) +("fix(postgres-v3): extract simple-Query literals into bindValues"). + +The sample wraps doccano (Django + django-rest-polymorphic + psycopg2) +at version `v1.8.5` against postgres `13.3-alpine`. The shape under +test: a polymorphic Django model (`Project` with subclass +`TextClassificationProject`) created over the REST API and re-read via +DRF's polymorphic queryset. Without the integrations fix, every +`SELECT … FROM django_content_type WHERE app_label = $1 AND model = $2` +at replay returns the same recorded mock (the matcher's +`pickSessionFallback` FIFO-collapses every variant onto the first +recording when the bind signature is empty), so the polymorphic +serializer can't resolve the project's subclass and `resourcetype` +flips from `"TextClassificationProject"` to `"Project"`. + +The bug is in keploy's recorder + replayer simple-Query path; doccano +is just a vehicle. Same pattern would reproduce on any Django app +that: + +* Uses a polymorphic ORM (django-polymorphic / django-rest-polymorphic). +* Sends parameterised reads via psycopg2's simple-Query mode + (literals interpolated into the SQL text rather than carried in a + separate Bind packet). +* Exercises the polymorphic queryset across multiple HTTP requests + against the same recorded backend. + +## What's in here + +* `Dockerfile` — thin wrapper around `doccano/doccano:backend` pinning + the upstream version this sample tracks and installing the + sample entrypoint that honors `DOCCANO_SKIP_BOOTSTRAP=1`. Future + doccano releases that change the bug-triggering shape are addressed + by retagging here, not by scattering version pins across the lane + scripts in `keploy/integrations` / `keploy/enterprise`. +* `docker-compose.yml` — the orchestration: postgres-13 alongside + the doccano backend, on a fixed subnet so the lane scripts can + rely on stable IPs across record/replay phases. +* `flow.sh` — the minimum reproducer traffic, ~10 HTTP calls. POST + `/v1/projects` (creates a `TextClassificationProject`), then GET + list / GET single / PATCH single / a few dependent reads. The + GET / PATCH responses are what diverge under the bug — POST + passes either way because the in-memory subclass instance shapes + the response without consulting the DB. +* `keploy.yml.template` — keploy config skeleton (proxy port, DNS + port, container name placeholders) that lane scripts in + `keploy/integrations` and `keploy/enterprise` `envsubst` into a + per-job copy. + +## Running locally + +### Without keploy — smoke check + +```sh +docker compose up -d +./flow.sh bootstrap + +docker compose down +DOCCANO_SKIP_BOOTSTRAP=1 docker compose up -d +./flow.sh record-traffic + +docker compose down -v +``` + +This is what the keploy/integrations and keploy/enterprise CI +lanes wrap in `keploy record` / `keploy test` — the base compose +is uninstrumented and runs unchanged inside those lanes. The first +launch runs doccano's migrations and creates the admin user; the +second launch skips bootstrap and starts gunicorn directly against +the populated database volume. + +### Without keploy — measuring real Python line coverage + +The base image is uninstrumented. Apply the coverage overlay to +add `coverage.py` per-worker tracking: + +```sh +mkdir -p coverage +docker compose -f docker-compose.yml -f docker-compose.coverage.yml up -d --build +./flow.sh bootstrap + +docker compose -f docker-compose.yml -f docker-compose.coverage.yml down +DOCCANO_SKIP_BOOTSTRAP=1 docker compose -f docker-compose.yml -f docker-compose.coverage.yml up -d --build +./flow.sh record-traffic + +docker compose -f docker-compose.yml -f docker-compose.coverage.yml kill -s SIGTERM backend +sleep 3 +DOCCANO_SKIP_BOOTSTRAP=1 docker compose -f docker-compose.yml -f docker-compose.coverage.yml up -d backend +./flow.sh coverage +docker compose -f docker-compose.yml -f docker-compose.coverage.yml down -v +``` + +The overlay (`Dockerfile.coverage` + `docker-compose.coverage.yml`) +adds `coverage[toml]` and a `coverage_subprocess.pth` so each +gunicorn worker auto-starts coverage tracking. It is consumed +ONLY by the standalone GH Actions workflow — keploy CI lanes +ignore it and run the base compose, paying zero coverage cost. + +### With keploy — record + replay + +```sh +docker compose up -d +./flow.sh bootstrap +docker compose down + +# In one shell: +keploy record -c "DOCCANO_SKIP_BOOTSTRAP=1 docker compose up" --container-name doccano_backend \ + --proxy-port 18081 --dns-port 18082 + +# In another shell: +./flow.sh record-traffic +# SIGINT keploy when traffic returns + +keploy test -c "DOCCANO_SKIP_BOOTSTRAP=1 docker compose up" --containerName doccano_backend \ + --apiTimeout 60 --delay 20 --host 127.0.0.1 --port 18080 \ + --proxy-port 18081 --dns-port 18082 +``` + +Expected outcome with the integrations fix in place: 0 failures, +all `is_text_project: true` / `resourcetype: "TextClassificationProject"` +across the project-read responses. + +Expected outcome **without** the fix: tests covering GET-after-POST +project reads fail with `is_text_project: true → false` and +`resourcetype: "TextClassificationProject" → "Project"`. + +## CI lanes that consume this sample + +* `keploy/integrations` — `.woodpecker/doccano-postgres.yml` / + `.ci/scripts/python/doccano/doccano-linux.sh`. Three-way matrix + (record-build × replay-build, record-latest × replay-build, + record-build × replay-latest) — the cross-binary cells stay red + until both keploy releases pick up the bind-extraction fix. +* `keploy/enterprise` — `.woodpecker/doccano-linux.yml` / + `.ci/scripts/doccano-linux.sh`. Same three-way matrix wired to + the enterprise compat-matrix harness. + +Both clone this directory at the branch / tag pinned by the +respective lane script. + +## Related + +* [keploy/integrations#177](https://github.com/keploy/integrations/pull/177) — the fix this sample falsifies. +* [keploy/enterprise#1889](https://github.com/keploy/enterprise/pull/1889) — original failing PR where the bug surfaced. +* [django-rest-polymorphic](https://github.com/apirobot/django-rest-polymorphic) — the upstream library whose serialisation path the bug breaks. diff --git a/doccano-django/doccano-entrypoint.sh b/doccano-django/doccano-entrypoint.sh new file mode 100755 index 0000000..dc669ba --- /dev/null +++ b/doccano-django/doccano-entrypoint.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [ "${DOCCANO_SKIP_BOOTSTRAP:-0}" = "1" ]; then + echo "Making staticfiles" + if [ ! -d staticfiles ] || ! find staticfiles -mindepth 1 -print -quit | grep -q .; then + echo "Executing collectstatic" + python manage.py collectstatic --noinput + fi + + echo "Starting django without bootstrap" + exec gunicorn \ + "--bind=${HOST:-0.0.0.0}:${PORT:-8000}" \ + "--workers=${WORKERS:-1}" \ + --timeout=300 \ + --capture-output \ + --log-level info \ + config.wsgi +fi + +exec /opt/bin/prod-django.sh "$@" diff --git a/doccano-django/docker-compose.coverage.yml b/doccano-django/docker-compose.coverage.yml new file mode 100644 index 0000000..9aecbe8 --- /dev/null +++ b/doccano-django/docker-compose.coverage.yml @@ -0,0 +1,19 @@ +# Coverage overlay — applied with: +# +# docker compose -f docker-compose.yml -f docker-compose.coverage.yml up -d --build +# +# Used ONLY by the standalone .github/workflows/doccano-django.yml +# CI workflow. Keploy CI lanes (enterprise, integrations) ignore +# this file and run the base compose unchanged, so they pay zero +# coverage-instrumentation cost. +services: + backend: + build: + context: . + dockerfile: Dockerfile.coverage + environment: + # Tells the .pth-file-installed coverage.process_startup() to + # actually start tracking; see Dockerfile.coverage. + COVERAGE_PROCESS_START: /backend/.coveragerc + volumes: + - ./coverage:/coverage diff --git a/doccano-django/docker-compose.yml b/doccano-django/docker-compose.yml new file mode 100644 index 0000000..f583bbf --- /dev/null +++ b/doccano-django/docker-compose.yml @@ -0,0 +1,78 @@ +# doccano-django sample compose. Postgres + doccano backend on a +# fixed subnet so the lane scripts in keploy/integrations and +# keploy/enterprise can pin the DB IP without runtime discovery. +# +# Two-phase boot pattern (used by the lane scripts but valid for +# local runs too): +# +# 1. DOCCANO_SKIP_BOOTSTRAP=0 → backend runs migrations, creates +# the admin user, sets the auth token; once that returns we +# `compose down` the stack but keep the named volume so the +# DB state persists. +# 2. DOCCANO_SKIP_BOOTSTRAP=1 → backend re-launches in +# gunicorn-only mode against the populated volume; recording / +# replay run against this incarnation. The sample defaults to one +# worker to keep Django's per-process ContentType cache +# deterministic across keploy replay. +# +# The split is what gives the lane a deterministic DB starting state +# without paying the migration cost on every record/replay invocation. +services: + backend: + build: + context: . + dockerfile: Dockerfile + # Every name is env-driven so multiple matrix cells can run in + # parallel on the same docker daemon without colliding. Lane + # scripts that spin up doccano per-cell pass cell-scoped values + # (e.g. DOCCANO_BACKEND_CONTAINER=doccano_backend_). + # Local single-runs use the shorter defaults. + container_name: ${DOCCANO_BACKEND_CONTAINER:-doccano_backend} + init: true + stop_grace_period: 5s + ports: + - "${DOCCANO_APP_PORT:-18080}:8000" + environment: + ADMIN_USERNAME: ${DOCCANO_ADMIN_USER:-admin} + ADMIN_PASSWORD: ${DOCCANO_ADMIN_PASSWORD:-password} + ADMIN_EMAIL: ${DOCCANO_ADMIN_EMAIL:-admin@example.com} + DATABASE_URL: postgres://doccano:doccano@${DOCCANO_DB_IP:-172.34.0.10}:5432/doccano?sslmode=disable + ALLOW_SIGNUP: "False" + DEBUG: "False" + DJANGO_SETTINGS_MODULE: config.settings.production + DOCCANO_SKIP_BOOTSTRAP: "${DOCCANO_SKIP_BOOTSTRAP:-0}" + WORKERS: "${DOCCANO_WORKERS:-1}" + depends_on: + postgres: + condition: service_healthy + networks: + - doccano-net + + postgres: + image: postgres:13.3-alpine + container_name: ${DOCCANO_DB_CONTAINER:-doccano_db} + stop_grace_period: 5s + environment: + POSTGRES_USER: doccano + POSTGRES_PASSWORD: doccano + POSTGRES_DB: doccano + healthcheck: + test: ["CMD-SHELL", "pg_isready -U doccano -d doccano"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - doccano-db-data:/var/lib/postgresql/data + networks: + doccano-net: + ipv4_address: ${DOCCANO_DB_IP:-172.34.0.10} + +networks: + doccano-net: + driver: bridge + ipam: + config: + - subnet: ${DOCCANO_NETWORK_SUBNET:-172.34.0.0/24} + +volumes: + doccano-db-data: diff --git a/doccano-django/flow.sh b/doccano-django/flow.sh new file mode 100755 index 0000000..51d7a27 --- /dev/null +++ b/doccano-django/flow.sh @@ -0,0 +1,331 @@ +#!/usr/bin/env bash +# +# Minimum-reproducer traffic for the keploy postgres-v3 simple-Query +# bind regression on doccano. Two subcommands: +# +# bootstrap — log in as admin, replace the random +# authtoken_token row with a fixed token so +# record-time and replay-time API calls share +# the same Authorization header. Runs once +# against the DOCCANO_SKIP_BOOTSTRAP=0 launch. +# record-traffic — drive the actual recording: POST a polymorphic +# project, GET it back twice, PATCH it, plus a +# couple of dependent reads. The GET / PATCH +# responses are what diverge under the bug. +# +# Inputs (all overrideable, defaults chosen to match the +# docker-compose.yml in this directory): +# +# DOCCANO_APP_PORT host-side port the backend is exposed on +# DOCCANO_ADMIN_USER admin login (set on first boot) +# DOCCANO_ADMIN_PASSWORD admin password +# DOCCANO_FIXED_TOKEN deterministic auth token to install +# DOCCANO_DB_CONTAINER postgres container name (for psql) +# DOCCANO_PHASE a label spliced into the project name so +# record/replay phase logs are +# distinguishable; safe-to-omit for local +# runs. +set -Eeuo pipefail + +DOCCANO_APP_PORT="${DOCCANO_APP_PORT:-18080}" +DOCCANO_ADMIN_USER="${DOCCANO_ADMIN_USER:-admin}" +DOCCANO_ADMIN_PASSWORD="${DOCCANO_ADMIN_PASSWORD:-password}" +DOCCANO_FIXED_TOKEN="${DOCCANO_FIXED_TOKEN:-ac38262065f0ae1476b6a707d9d697a101764a6b}" +DOCCANO_DB_CONTAINER="${DOCCANO_DB_CONTAINER:-doccano_db}" +DOCCANO_BACKEND_CONTAINER="${DOCCANO_BACKEND_CONTAINER:-doccano_backend}" +DOCCANO_PHASE="${DOCCANO_PHASE:-local}" + +# Optional per-call audit log written by record-traffic. When set, +# each curl below appends " " so a downstream caller +# can compute coverage WITHOUT a keploy recording in the picture. +# This is what the standalone GitHub Actions workflow consumes +# (lane scripts use the keploy/test-set-*/tests/*.yaml tree +# instead). When unset / empty: silent no-op, no extra disk writes. +DOCCANO_FIRED_ROUTES_FILE="${DOCCANO_FIRED_ROUTES_FILE:-}" + +# log_fired — append " " to the audit log if enabled. +# Cheap (one printf, no fork) so we can inline it before each curl +# in doccano_record_traffic without bloating the function. +log_fired() { + [ -z "$DOCCANO_FIRED_ROUTES_FILE" ] && return 0 + printf '%s %s\n' "$1" "$2" >>"$DOCCANO_FIRED_ROUTES_FILE" +} + +base="http://127.0.0.1:${DOCCANO_APP_PORT}" +h_token="Authorization: Token ${DOCCANO_FIXED_TOKEN}" +h_json='Content-Type: application/json' + +# Login + fixed-token install. Deterministic auth header is what lets +# the recorded HTTP test cases match at replay — without it, every +# replay run would carry a fresh random token in the headers and the +# matcher would diff on the Authorization line. +# doccano_wait_for_fixed_token — poll /v1/me with the deterministic +# Authorization header until the backend returns 200 with the +# admin user's username. Used as a backend-readiness gate, both at +# the end of bootstrap (proves the token install took effect) and +# at the start of record-traffic (proves the second-stage +# skip-bootstrap compose has finished gunicorn boot before we +# fire any test traffic). Distinct from a plain port-open check — +# this gate doesn't return until the auth+DB+Django stack is +# actually serving. +doccano_wait_for_fixed_token() { + local timeout=${1:-180} + local start_ts code + start_ts=$(date +%s) + while true; do + code=$(curl -sS -o /tmp/doccano-me.json -w '%{http_code}' \ + -H "$h_token" "${base}/v1/me" 2>/dev/null || echo "") + if [ "$code" = "200" ] && jq -e ".username == \"${DOCCANO_ADMIN_USER}\"" /tmp/doccano-me.json >/dev/null 2>&1; then + return 0 + fi + if [ $(( $(date +%s) - start_ts )) -ge "$timeout" ]; then + echo "doccano_wait_for_fixed_token: timed out waiting for /v1/me to return 200 (last code: ${code:-})" >&2 + cat /tmp/doccano-me.json >&2 || true + return 1 + fi + sleep 2 + done +} + +doccano_bootstrap_token() { + local timeout=${1:-180} + local start_ts + start_ts=$(date +%s) + + while true; do + local code + code=$(curl -sS -o /tmp/doccano-login.json -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -X POST "${base}/v1/auth/login/" \ + -d "{\"username\":\"${DOCCANO_ADMIN_USER}\",\"password\":\"${DOCCANO_ADMIN_PASSWORD}\"}" || true) + if [ "$code" = "200" ] && jq -e '.key' /tmp/doccano-login.json >/dev/null 2>&1; then + break + fi + if [ $(( $(date +%s) - start_ts )) -ge "$timeout" ]; then + echo "Timed out waiting for doccano login (last code: ${code})" >&2 + cat /tmp/doccano-login.json >&2 || true + return 1 + fi + sleep 2 + done + + docker exec -i "$DOCCANO_DB_CONTAINER" psql -U doccano -d doccano -v ON_ERROR_STOP=1 </dev/null + + # Worker-cache warmup. The sample defaults to one gunicorn worker + # for deterministic keploy replay, but DOCCANO_WORKERS can raise + # that count for local experiments. Each worker keeps its own + # per-process Django ContentType cache and populates it lazily on + # the first polymorphic-resolver query that worker handles. + # Recording lanes that terminate with SIGINT (rather than waiting + # on a long --record-timer) need every worker's cache warmed before + # the explicit test traffic fires — otherwise cold workers fire + # their own django_content_type lookups at replay-time, find empty + # perTest cohorts, and the dependent endpoints return HTTP 500. + # + # The fixed 16 calls are gunicorn-dispatch-jitter safe for the + # previous 4-worker default; /v1/me is the cheapest authenticated + # endpoint and exercises the same auth-token + ContentType chain + # as real test calls. + local warm_idx + for warm_idx in $(seq 1 16); do + curl -sS -H "$h_token" "$base/v1/me" >/dev/null 2>&1 || true + done + log_fired GET "$base/v1/me" + + log_fired GET "$base/v1/users" + curl -sS -H "$h_token" "$base/v1/users" >/dev/null || true + log_fired GET "$base/v1/health/" + curl -sS "$base/v1/health/" >/dev/null || true + + # POST a polymorphic project. resourcetype="TextClassificationProject" + # is the polymorphic discriminator that django-rest-polymorphic + # uses to instantiate the right subclass; the bug shows up at + # the GET / PATCH side, not on this POST (the in-memory subclass + # instance shapes the response without consulting the DB). + log_fired POST "$base/v1/projects" + project_resp=$(curl -fsS -H "$h_token" -H "$h_json" -X POST "$base/v1/projects" \ + -d "{\"name\":\"keploy-${DOCCANO_PHASE}-project\",\"project_type\":\"DocumentClassification\",\"description\":\"sample project\",\"guideline\":\"label the text\",\"resourcetype\":\"TextClassificationProject\"}") + project_id=$(printf '%s' "$project_resp" | jq -r '.id') + [ -n "$project_id" ] && [ "$project_id" != "null" ] + + p="$base/v1/projects/${project_id}" + + # The reads that fail under the bug (GET list / GET single / + # PATCH single all return resourcetype="Project" instead of + # "TextClassificationProject" because the polymorphic queryset + # can't resolve the subclass without working bind-discrimination + # on django_content_type). + log_fired GET "$base/v1/projects" + curl -sS -H "$h_token" "$base/v1/projects" >/dev/null || true + log_fired GET "$p" + curl -sS -H "$h_token" "$p" >/dev/null || true + log_fired PATCH "$p" + curl -sS -H "$h_token" -H "$h_json" -X PATCH "$p" \ + -d '{"description":"updated by sample"}' >/dev/null || true + + # Dependent reads — exercise the polymorphic resolver on the + # nested resources so the cohort surfaces multiple variants of + # the django_content_type lookup at record time. Without these, + # the recording wouldn't capture the multi-bind shape and the + # falsifying half of the matrix wouldn't have anything to fail + # on. + log_fired GET "$p/my-role" + curl -sS -H "$h_token" "$p/my-role" >/dev/null || true + log_fired GET "$p/members" + curl -sS -H "$h_token" "$p/members" >/dev/null || true + + log_fired POST "$p/category-types" + label_resp=$(curl -sS -H "$h_token" -H "$h_json" -X POST "$p/category-types" \ + -d '{"text":"positive","background_color":"#00ff00","text_color":"#ffffff"}' 2>/dev/null || true) + label_id=$(jq -r '.id // empty' <<<"$label_resp" 2>/dev/null || true) + log_fired GET "$p/category-types" + curl -sS -H "$h_token" "$p/category-types" >/dev/null || true + + log_fired POST "$p/examples" + example_resp=$(curl -fsS -H "$h_token" -H "$h_json" -X POST "$p/examples" \ + -d '{"text":"Keploy CI sample text","meta":{"source":"sample"}}') + example_id=$(jq -r '.id' <<<"$example_resp") + if [ -n "$example_id" ] && [ "$example_id" != "null" ]; then + log_fired GET "$p/examples/${example_id}" + curl -sS -H "$h_token" "$p/examples/${example_id}" >/dev/null || true + if [ -n "$label_id" ]; then + log_fired POST "$p/examples/${example_id}/categories" + curl -sS -H "$h_token" -H "$h_json" -X POST "$p/examples/${example_id}/categories" \ + -d "{\"label\":${label_id}}" >/dev/null || true + fi + fi + + # Metrics endpoints — additional polymorphic queries. + log_fired GET "$p/metrics/progress" + curl -sS -H "$h_token" "$p/metrics/progress" >/dev/null || true + log_fired GET "$p/metrics/member-progress" + curl -sS -H "$h_token" "$p/metrics/member-progress" >/dev/null || true +} + +# doccano_report_coverage (real Python line coverage via coverage.py). +# +# Requires the docker-compose.coverage.yml overlay — the base compose +# is uninstrumented so keploy CI lanes (enterprise, integrations) pay +# zero overhead. When called from a base-compose run the function +# detects the missing data and exits 0 cleanly so `flow.sh coverage +# || true` informational hooks don't break. +# +# Mechanics: +# - The coverage overlay's Dockerfile.coverage installs coverage.py +# and a `coverage_subprocess.pth` so each gunicorn worker auto- +# starts coverage.process_startup(). +# - .coveragerc has parallel = true → per-worker .coverage. +# files in /coverage (volume-mounted from ./coverage on host). +# - This function shells into the running backend container, +# combines the per-worker files in place, and emits the line % +# in the same `Covered N/M (XX.X%)` shape the helper script's +# regex expects. +doccano_report_coverage() { + local backend="${DOCCANO_BACKEND_CONTAINER:-doccano_backend}" + local data_dir="${DOCCANO_COVERAGE_DATA_DIR:-/coverage}" + local report_file="${COVERAGE_REPORT_FILE:-coverage_report.txt}" + + if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${backend}$"; then + echo "INFO: ${backend} not running — coverage report skipped" + : >"$report_file" + return 0 + fi + + local data_count + data_count=$(docker exec "$backend" sh -c "ls -1 ${data_dir}/.coverage.* 2>/dev/null | wc -l" 2>/dev/null | tr -d ' \r\n') + if [ "${data_count:-0}" -eq 0 ]; then + echo "INFO: no coverage data at ${data_dir}/.coverage.* in ${backend} — base image is uninstrumented (apply docker-compose.coverage.yml overlay to enable)" + : >"$report_file" + return 0 + fi + + # Combine in-place; -a appends repeated runs (re-trigger safe). + docker exec "$backend" sh -c "cd /backend && coverage combine -a ${data_dir}/.coverage.* >/dev/null 2>&1" || true + + # Pull the integer % via --format=total (newer coverage.py emits + # just the number) plus the textual TOTAL line for the artefact. + local pct lines covered missed + pct=$(docker exec "$backend" sh -c "cd /backend && coverage report --rcfile=/backend/.coveragerc --format=total 2>/dev/null" | tr -d ' \r\n') + if [ -z "$pct" ]; then + echo "ERROR: coverage report --format=total returned empty" + docker exec "$backend" sh -c "cd /backend && coverage report --rcfile=/backend/.coveragerc 2>&1 | tail -10" >&2 || true + return 1 + fi + + # Pull statements/missed off the TOTAL row of the textual report. + read -r lines missed < <(docker exec "$backend" sh -c "cd /backend && coverage report --rcfile=/backend/.coveragerc 2>/dev/null | awk '/^TOTAL/{print \$2, \$3}'" | tr -d '\r') + covered=$(( ${lines:-0} - ${missed:-0} )) + + { + echo "================ doccano line coverage (Python coverage.py) ================" + docker exec "$backend" sh -c "cd /backend && coverage report --rcfile=/backend/.coveragerc 2>/dev/null | tail -15" + echo "" + printf 'Covered %s/%s (%s.0%%)\n' "${covered}" "${lines:-0}" "${pct}" + echo "============================================================================" + } | tee "$report_file" +} + + +case "${1:-}" in + bootstrap) + doccano_bootstrap_token "${2:-180}" + ;; + record-traffic) + doccano_record_traffic + ;; + coverage) + # Reads coverage.py data from the running backend container + # (requires the docker-compose.coverage.yml overlay; exits 0 + # cleanly if the base image is uninstrumented). + doccano_report_coverage + ;; + *) + cat >&2 <