Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions .github/workflows/restheart-mongo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# restheart-mongo sample CI — keploy-independent end-to-end smoke +
# coverage gate.
#
# Triggers ONLY on changes under restheart-mongo/ (or this workflow
# file). Other samples in this repo have their own orthogonal CI;
# gating the whole repo on every restheart 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
# the admin db + collections, 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 `RESTHEART_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.<id>.outputs.coverage → needs.<job>.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). The sample's coverage is route-based
# (single percentage), so the gate is a 3-line subtraction.
#
# Sample is genuinely keploy-independent here: the workflow uses
# flow.sh's $RESTHEART_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::restheart_list_recorded_routes`.
name: restheart-mongo sample

on:
pull_request:
paths:
- 'restheart-mongo/**'
- '.github/workflows/restheart-mongo.yml'
push:
branches: [main]
paths:
- 'restheart-mongo/**'
- '.github/workflows/restheart-mongo.yml'
workflow_dispatch: {}

concurrency:
group: restheart-mongo-${{ github.ref }}
cancel-in-progress: true

env:
COVERAGE_THRESHOLD: ${{ vars.RESTHEART_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: restheart-mongo
env:
RESTHEART_FIRED_ROUTES_FILE: ${{ runner.temp }}/fired-routes-build.log
RESTHEART_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: restheart-mongo/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 restheart-mongo/ sample has no baseline
# (restheart-mongo/ 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 restheart-mongo ] && [ -x restheart-mongo/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 restheart-mongo/ 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: restheart-mongo
env:
RESTHEART_FIRED_ROUTES_FILE: ${{ runner.temp }}/fired-routes-release.log
RESTHEART_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: restheart-mongo/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::restheart-mongo coverage dropped from ${RELEASE}% → ${BUILD}% (-${drop}pp), exceeding the ${THRESHOLD}pp threshold."
echo "Suggested actions:"
echo " * Add curl(s) to flow.sh::restheart_record_traffic that exercise the routes you changed/touched."
echo " * If the route(s) was intentionally retired, drop it from restheart-mongo/flow.sh::restheart_list_routes' SCOPE_PATHS too so it's removed from the denominator."
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: restheart-mongo-coverage
message: |
### restheart-mongo 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 `RESTHEART_COVERAGE_THRESHOLD` actions variable.
78 changes: 78 additions & 0 deletions .github/workflows/scripts/run-and-measure.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# run-and-measure.sh — bring restheart-mongo up under the
# coverage overlay (JaCoCo agent attached via JAVA_TOOL_OPTIONS),
# run flow.sh bootstrap + record-traffic, dump JaCoCo execution
# data over the agent's TCP server, render a Java line-coverage
# report, and emit `coverage=PCT` onto $GITHUB_OUTPUT for the
# downstream coverage-gate job.
#
# Coverage isolation contract:
# * Base `Dockerfile` and `docker-compose.yml` are untouched.
# * The overlay `Dockerfile.coverage` + `docker-compose.coverage.yml`
# attach JaCoCo and expose its TCP server. ONLY this script
# applies the overlay; keploy/integrations and keploy/enterprise
# CI lanes consume the base compose and pay zero JVM-instrument
# cost (jacocoagent adds ~5-10% per-call overhead).
#
# Inputs (from the workflow env):
# RESTHEART_PHASE — label for log diffing.
# GITHUB_OUTPUT — standard GH Actions sink for step outputs.
set -Eeuo pipefail

export RESTHEART_APP_CONTAINER="${RESTHEART_APP_CONTAINER:-restheart_app}"
export RESTHEART_MONGO_CONTAINER="${RESTHEART_MONGO_CONTAINER:-restheart_mongo}"
export RESTHEART_APP_PORT="${RESTHEART_APP_PORT:-8080}"
export RESTHEART_MONGO_IP="${RESTHEART_MONGO_IP:-172.36.0.10}"
export RESTHEART_NETWORK_SUBNET="${RESTHEART_NETWORK_SUBNET:-172.36.0.0/24}"
export RESTHEART_ADMIN_AUTH="${RESTHEART_ADMIN_AUTH:-Basic YWRtaW46c2VjcmV0}"

mkdir -p coverage
chmod 777 coverage
sudo rm -rf coverage/jacoco.exec coverage/report.xml coverage/coverage_report.txt 2>/dev/null \
|| rm -rf coverage/jacoco.exec coverage/report.xml coverage/coverage_report.txt 2>/dev/null \
|| true

COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.coverage.yml)

"${COMPOSE[@]}" up -d --build

# Both 200 and 401 are success signals.
for i in $(seq 1 120); do
code=$(curl -sS -o /dev/null -w '%{http_code}' \
"http://127.0.0.1:${RESTHEART_APP_PORT}/" 2>/dev/null || echo "")
if [ "$code" = "200" ] || [ "$code" = "401" ]; then break; fi
sleep 2
done

if [ "$code" != "200" ] && [ "$code" != "401" ]; then
echo "::error::restheart did not bind on port ${RESTHEART_APP_PORT} within 240s (last code: ${code:-empty})"
echo "----- restheart container logs -----"
docker logs "${RESTHEART_APP_CONTAINER}" --tail 200 2>&1 || true
echo "----- mongo container logs -----"
docker logs "${RESTHEART_MONGO_CONTAINER}" --tail 100 2>&1 || true
"${COMPOSE[@]}" down -v --remove-orphans || true
exit 1
fi

bash flow.sh bootstrap 240
bash flow.sh record-traffic

# JaCoCo TCP-dump + report (no JVM stop needed).
COVERAGE_REPORT_FILE="$PWD/coverage_report.txt" bash flow.sh coverage

if [ ! -f coverage_report.txt ]; then
echo "::error::flow.sh coverage produced no coverage_report.txt"
exit 1
fi

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}% (Java line coverage via JaCoCo)"

"${COMPOSE[@]}" down -v --remove-orphans
2 changes: 2 additions & 0 deletions restheart-mongo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
coverage/
coverage_report.txt
7 changes: 7 additions & 0 deletions restheart-mongo/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Thin wrapper around RESTHeart's official image at the version
# this sample tracks. Pin lives here so a future RESTHeart release
# is a one-line retag, not a hunt across keploy CI lanes.
#
# Upstream: https://github.com/SoftInstigate/restheart
# Image: docker.io/softinstigate/restheart:9.2.1
FROM softinstigate/restheart:9.2.1
43 changes: 43 additions & 0 deletions restheart-mongo/Dockerfile.coverage
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Coverage overlay image for restheart-mongo.
#
# Adds the JaCoCo agent (jacocoagent.jar) and CLI (jacococli.jar)
# alongside the upstream restheart 9.2.1 image. The agent is
# attached at JVM start via JAVA_TOOL_OPTIONS (set in
# docker-compose.coverage.yml) so we don't have to rewrite the
# upstream entrypoint, which is `java -jar restheart.jar` with
# specific JVM flags.
#
# The agent runs in `tcpserver` mode so the workflow can dump
# coverage data on demand without restarting the JVM —
# important for distroless-style upstream images that don't
# ship a shell.
#
# IMPORTANT: this image is only consumed by docker-compose.coverage.yml.
# The base Dockerfile and docker-compose.yml stay uninstrumented so
# enterprise's keploy compat lane pays no JVM-instrumentation cost
# (jacocoagent adds ~5-10% per-call overhead through bytecode
# rewriting, which would slow record/replay measurably).

# Stage 1: pull JaCoCo zip in an alpine builder. The upstream
# restheart image is distroless (no shell, no curl/unzip), so we
# can't fetch JaCoCo from inside it.
FROM alpine:3.19 AS jacoco-fetch
ARG JACOCO_VERSION=0.8.13
RUN apk add --no-cache curl ca-certificates unzip \
&& curl -fsSL "https://repo1.maven.org/maven2/org/jacoco/jacoco/${JACOCO_VERSION}/jacoco-${JACOCO_VERSION}.zip" -o /tmp/jacoco.zip \
&& mkdir -p /tmp/jacoco \
&& unzip -j /tmp/jacoco.zip lib/jacocoagent.jar lib/jacococli.jar -d /tmp/jacoco

# Stage 2: layer JaCoCo into the upstream image. We can't `RUN`
# anything because the base image has no shell — only COPY and
# WORKDIR work. COPY --chown sets ownership at copy time so the
# distroless user (uid 65532) can read the agent.
FROM softinstigate/restheart:9.2.1
COPY --from=jacoco-fetch --chown=65532:65532 /tmp/jacoco/jacocoagent.jar /opt/jacoco/jacocoagent.jar
COPY --from=jacoco-fetch --chown=65532:65532 /tmp/jacoco/jacococli.jar /opt/jacoco/jacococli.jar

# Pre-create /coverage as an empty WORKDIR so docker has a
# mountpoint for the bind-mount in docker-compose.coverage.yml.
# WORKDIR doesn't require a shell.
WORKDIR /coverage
WORKDIR /opt/restheart
Loading
Loading