Run Basquin against your Tomcat app in Kubernetes

Bake the valve, the Basquin agent, and the JaCoCo coverage agent into a Tomcat image, run your WAR as a pod, and drive it from outside with coverage-guided exploration and the dashboard — every feature working in-cluster.

docker · kind · kubectl Tomcat 9 (javax) or 10+ (jakarta)

This guide generalizes the repo's deploy/k8s/ demo — which instruments an unmodified JPetStore WAR — to your own WAR. The approach is the same three agents baked into one Tomcat image, a ClusterIP Service exposing the app and the coverage port, and a driver that reaches the pod through kubectl port-forward.

Try the reference demo first

If you have a built JPetStore WAR, one command builds the agents, bakes the image, creates a kind cluster, and deploys the pod:

JPETSTORE_WAR=/abs/jpetstore.war deploy/k8s/up.sh

When it finishes, the JPetStore pod is Running with port 8080 (the app, every request wrapped by the valve) and 6300 (the JaCoCo coverage tcpserver, scoped to org.mybatis.jpetstore.*). Full walkthrough: deploy/k8s/README.md. The rest of this page shows what to change to point that same machinery at your app.


Instrumenting your WAR

  1. Match the servlet namespace to the Tomcat base image

    The valve jar is namespace-free — one artifact runs on both Tomcat lines — but the base image must match your app's servlet namespace:

    App servlet namespaceTomcat base image
    javax.servlettomcat:9.0-jdk17-temurin
    jakarta.servlettomcat:10.1-jdk17

    To check an app, inspect its WEB-INF/web.xml root namespace: http://java.sun.com/... and javax.servlet.jsp.jstl mean javax; jakarta.* means jakarta. The reference demo uses Tomcat 9 because JPetStore is javax.servlet.

  2. Build the three agents

    Same command the demo runs — it produces the fat agent jar, the valve jar, and stages the JaCoCo agent:

    ./gradlew jar :tomcat-valve:jar copyJacocoAgent

    That yields build/libs/basquin-0.3.0.jar (agent), tomcat-valve/build/libs/basquin-valve-0.3.0.jar (valve), and build/jacoco/jacocoagent.jar.

  3. Bake a self-contained image

    Copy your WAR in as ROOT.war, drop the valve into Tomcat's lib/, put the agent and JaCoCo jars on disk, and mount the global context.xml that registers the valve for every deployed app. This is the demo's Dockerfile.jpetstore generalized — swap the base image and the WAR, and change the JaCoCo includes filter and invariant flags to suit your app:

    Dockerfile
    # pick the base image to match your app's servlet namespace (step 1)
    FROM tomcat:9.0-jdk17-temurin
    
    COPY your-app.war         /usr/local/tomcat/webapps/ROOT.war
    COPY basquin-agent.jar /usr/local/tomcat/basquin-agent.jar
    COPY basquin-valve.jar /usr/local/tomcat/lib/basquin-valve.jar
    COPY jacocoagent.jar      /jacocoagent.jar
    COPY context.xml          /usr/local/tomcat/conf/context.xml
    
    # JaCoCo tcpserver (scope includes to YOUR packages) + the Basquin agent, both in the app JVM
    ENV CATALINA_OPTS="-javaagent:/jacocoagent.jar=output=tcpserver,address=0.0.0.0,port=6300,includes=com.yourco.yourapp.* \
    -javaagent:/usr/local/tomcat/basquin-agent.jar -Xbootclasspath/a:/usr/local/tomcat/basquin-agent.jar \
    -Dbasquin.invariant.mode=soft -Dbasquin.invariant.latency.maxMs=25 -Dbasquin.invariant.heapDelta.maxKb=256"
    
    EXPOSE 8080 6300
    important

    Scope the JaCoCo includes to your application packages. A wildcard (*) also instruments Tomcat and your framework — it inflates the coverage denominator and adds enough overhead to fake latency violations. Narrow-by-default is the deliberate choice here.

    Copy the source files Basquin already ships alongside your WAR into the build context: basquin-agent.jar and basquin-valve.jar from the build outputs above, jacocoagent.jar from build/jacoco/, and context.xml from deploy/valve/context.xml.

  4. Deploy: Deployment + ClusterIP Service

    Apply a Deployment for the pod and a Service exposing both ports. The Service is deliberately ClusterIP, not NodePort: JaCoCo's remote-control protocol is unauthenticated and lets any client dump and reset execution data, so port 6300 must never be reachable from outside the cluster. You reach both ports through a port-forward instead.

    yourapp.yaml — Service (ports)
    apiVersion: v1
    kind: Service
    metadata: { name: yourapp }
    spec:
      type: ClusterIP            # never NodePort — port 6300 stays in-cluster
      selector: { app: yourapp }
      ports:
        - { name: http,   port: 8080, targetPort: 8080 }
        - { name: jacoco, port: 6300, targetPort: 6300 }

    The demo's deploy/k8s/up.sh is worth copying wholesale — it builds the agents, stages the image context, tags the image uniquely per build (a fixed :latest makes kubectl apply a silent no-op that keeps a stale image), loads it into kind, and pins every kubectl call to the kind context so a re-run can't deploy into whatever cluster happens to be current.

  5. Port-forward and extract your classes

    Forward the app and coverage ports, then unzip your WAR's compiled classes so the coverage analyzer can compute covered / total:

    kubectl --context kind-basquin port-forward svc/yourapp 8080:8080 6300:6300 &
    
    # extract the app's classes for the coverage analyzer
    D=$(mktemp -d); (cd "$D" && unzip -q /abs/your-app.war 'WEB-INF/classes/*')
  6. Drive it — coverage-guided, with the dashboard

    Start the standalone dashboard once, then run the coverage-guided driver against the forwarded pod. Point -Dbasquin.coverage.classes at the classes you just extracted and -Dbasquin.grammar at a request grammar for your routes:

    # standalone dashboard on 127.0.0.1:7070 (its own process)
    ./gradlew runDashboard &
    
    ./gradlew runCoverageGuided \
      -Dexamples.http.baseUrl=http://localhost:8080 \
      -Dbasquin.coverage.jacoco=localhost:6300 \
      -Dbasquin.coverage.classes=$D/WEB-INF/classes \
      -Dbasquin.grammar=examples/grammar/yourapp.grammar \
      -Dbasquin.invariant.latency.maxMs=25 \
      -Dbasquin.invariant.mode=soft \
      -Dbasquin.dashboard.push=localhost:7070

    Here examples/grammar/yourapp.grammar is a placeholder for your own routes — the repo ships a real, working grammar at examples/grammar/jpetstore.grammar you can copy and adapt. (Grammar syntax is covered in How it works and USAGE.md.)

    The live panel shows execs, latency, invariant finds (harvested server-side from the pod via the valve headers), and the coverage % of your app's code (pulled from the pod's JaCoCo agent). Prefer coverage without guided mutation? Use runHttpDriveCoverage. No coverage at all? Use runHttpDrive.

Scaling to replicas

When one driver drives a Service backed by several pods, name every JaCoCo endpoint so coverage reflects the whole fleet rather than the one pod your reader connected to. Pass an explicit list, or a headless Service name that resolves to all pod IPs on its own:

# explicit list of pod endpoints...
-Dbasquin.coverage.jacoco=10.0.1.4:6300,10.0.1.5:6300,10.0.1.6:6300
# ...or a headless Service name, which resolves to all pod IPs
-Dbasquin.coverage.jacoco=yourapp-jacoco.default.svc.cluster.local:6300

Coverage is union-merged across replicas. The status panel shows [N/M pods] whenever a replica is unreachable or more than one is expected, so an under-count from a restarting pod is visible rather than mistaken for genuinely low coverage.


You probably want the operator instead

Baking agents into every image is the demo path, not the goal. The page above shows the mechanics; in practice the Kubernetes operator does all of it for you against an unmodified image. kubectl apply (or basquin instrument) a BasquinTarget naming one Deployment; the operator copies the agents in via an init container + shared volume and appends the assembled flags to the container's existing CATALINA_OPTS / JAVA_TOOL_OPTIONS — no custom rebuild, and deleting the CR restores the Deployment byte-for-byte.

It is an explicit-patch controller, not a mutating admission webhook: a namespaced Role that only patches Deployments you name, never cluster-wide pod mutation.

Then a BasquinCampaign runs a whole bounded test against that target — either explore (coverage-guided fuzzing, emitting a corpus) or load (replaying that corpus at a fixed concurrency) — launching the driver and a per-campaign dashboard and aggregating results into status. Install it with Helm and drive it with the basquin CLI: see the operator guide, OPERATOR-USAGE.md, and the design in OPERATOR-DESIGN.md.