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.
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
-
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 namespace Tomcat base image javax.servlettomcat:9.0-jdk17-temurinjakarta.servlettomcat:10.1-jdk17To check an app, inspect its
WEB-INF/web.xmlroot namespace:http://java.sun.com/...andjavax.servlet.jsp.jstlmean javax;jakarta.*means jakarta. The reference demo uses Tomcat 9 because JPetStore isjavax.servlet. -
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 copyJacocoAgentThat yields
build/libs/basquin-0.3.0.jar(agent),tomcat-valve/build/libs/basquin-valve-0.3.0.jar(valve), andbuild/jacoco/jacocoagent.jar. -
Bake a self-contained image
Copy your WAR in as
ROOT.war, drop the valve into Tomcat'slib/, put the agent and JaCoCo jars on disk, and mount the globalcontext.xmlthat registers the valve for every deployed app. This is the demo'sDockerfile.jpetstoregeneralized — swap the base image and the WAR, and change the JaCoCoincludesfilter 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 6300importantScope the JaCoCo
includesto 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.jarandbasquin-valve.jarfrom the build outputs above,jacocoagent.jarfrombuild/jacoco/, andcontext.xmlfromdeploy/valve/context.xml. -
Deploy: Deployment + ClusterIP Service
Apply a Deployment for the pod and a Service exposing both ports. The Service is deliberately
ClusterIP, notNodePort: 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.shis worth copying wholesale — it builds the agents, stages the image context, tags the image uniquely per build (a fixed:latestmakeskubectl applya silent no-op that keeps a stale image), loads it into kind, and pins everykubectlcall to the kind context so a re-run can't deploy into whatever cluster happens to be current. -
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/*') -
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.classesat the classes you just extracted and-Dbasquin.grammarat 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:7070Here
examples/grammar/yourapp.grammaris a placeholder for your own routes — the repo ships a real, working grammar atexamples/grammar/jpetstore.grammaryou 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? UserunHttpDrive.
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.
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.