Files
claude-code a6c4df1ea9
werkator build succeeded after 04:26
werkator/mutation build succeeded after 00:29
werkprobe builds with Temurin 25 (#15)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-11 13:42:48 +02:00

239 lines
13 KiB
Markdown

# Werkprobe
A work sample for [Werkdock](https://git.javagil.de/mi/werkdock): a
real [Testcontainers](https://testcontainers.org) program, written as
any project would write it, run against werkdock instead of docker.
Werkdock is a sandbox CLI over `bwrap` that runs without root on a
Hostsharing managed webspace, and since 0.6.0 it serves the Docker
Engine API subset Testcontainers uses. Whether that is true is not a
matter of opinion, so this repository asks the question in the only
form that settles it: a `PostgreSQLContainer`, started, queried,
executed in, and stopped — and, the other way round, a Spring Boot
application whose database is nothing but a `jdbc:tc` URL in
`application.yml`.
Nothing here knows it is talking to werkdock. The whole adaptation is
two environment variables:
```bash
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock
export TESTCONTAINERS_RYUK_DISABLED=true
```
## Running it
The Gradle wrapper is committed, so a JDK is all the build needs.
Against a werkdock on this machine:
```bash
werkdock pull postgres:16-alpine # once
werkdock system service &
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock
export TESTCONTAINERS_RYUK_DISABLED=true
./gradlew test # both test classes
./gradlew bootRun # the Spring Boot application on its own
```
Against a webspace, which is the environment that has to work:
```bash
tools/deploy.sh mih09-werkdock@mih09.hostsharing.net
ssh mih09-werkdock@mih09.hostsharing.net '~/probe/run-probe.sh'
```
`--spring` runs the Spring Boot application instead of the plain
`Probe`; both are in the same directory, on one flat classpath.
`deploy.sh` builds one directory holding the program and every jar it
needs, copies it in a single ssh connection (the webspaces refuse
connections that follow too quickly), and leaves `run-probe.sh` beside
it. That script starts werkdock's socket, runs the probe, prints the
API traffic werkdock saw, and stops the socket again.
Against a real docker or podman, for comparison, set `DOCKER_HOST` at
that socket instead — the program does not change.
## What is in here
| Path | |
|---|---|
| `src/test/java/werkprobe/PostgresContainerTest.java` | five JUnit tests, container code written out: query, published port, exec, recorded log, two databases side by side |
| `src/test/java/werkprobe/JdbcUrlDatabaseTest.java` | four JUnit tests, no container code at all: the database is the `jdbc:tc` URL from `application.yml` |
| `src/test/java/werkprobe/CopyFileToContainerTest.java` | the third way to a schema: a file copied into the container, run by the image's entrypoint (needs werkdock 0.7.0) |
| `src/test/java/werkprobe/RedisContainerTest.java` | not a database: PING over a plain socket, because werkdock's quirk table claimed a port redis was not listening on until 0.8.0 |
| `src/test/java/werkprobe/MariaDbContainerTest.java` | the same question for MariaDB, which asks the server itself with `select @@port` |
| `src/test/java/werkprobe/MySqlContainerTest.java` | and for MySQL |
| `src/test/java/werkprobe/NatsContainerTest.java` | NATS, greeted over a plain socket — it announces itself with `INFO` |
| `src/test/java/werkprobe/RabbitMqContainerTest.java` | RabbitMQ: a queue, a topic exchange, and the port cap its Erlang port forces |
| `src/test/java/werkprobe/MosquittoContainerTest.java` | MQTT, and the one server moved through a rewritten config file |
| `src/main/resources/application.yml` | where those URLs live, with what each part of them means |
| `src/main/java/werkprobe/WerkprobeApplication.java` | the Spring Boot application they configure |
| `src/main/java/werkprobe/Probe.java` | the same ground as a plain `main`, for a host with a JVM and no build tool |
| `tools/deploy.sh` | build, copy to a host, in one connection |
| `tools/run-probe.sh` | run it there, with the socket started and stopped around it |
## The database as configuration
A `jdbc:tc` URL makes Testcontainers the JDBC driver: it reads the
image out of the URL, starts the container on the first connection, and
hands over to the real driver. No Java code mentions a container, so
this is the form most projects actually reach for.
```yaml
spring:
datasource:
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
url: jdbc:tc:postgresql:16-alpine:///werkprobe?TC_DAEMON=true&TC_INITSCRIPT=werkprobe-init.sql
```
`TC_DAEMON=true` matters here: without it Testcontainers stops the
container as soon as the last connection closes, and a connection pool
closes connections whenever it likes. `TC_INITSCRIPT` names a script on
the classpath that Testcontainers runs **over JDBC** — no file is
copied into the container, which is why it works on werkdock, whose API
has no `PUT /containers/{id}/archive` yet.
This path asked nothing new of werkdock: the same endpoints that serve
the programmatic form serve this one. A second URL is a second server,
on its own free port, the same way.
The third way is `withCopyFileToContainer`, which puts the script in
`/docker-entrypoint-initdb.d` and lets the image's entrypoint run it.
That one *did* ask something new — `PUT /containers/{id}/archive`,
which werkdock grew in 0.7.0 — and is the only test here that needs
that version.
## The images werkdock claims to know
Werkdock cannot map ports — the sandbox shares the host's network — so
for every image it publishes a port for, it has to move the *server*
instead, and it keeps a table of how. Every entry in that table is a
claim about someone else's image, and three of the four were wrong
until werkdock 0.8.0: the server stayed where it was while werkdock
reported a free port that nothing answered on.
Each is now a test here, which is the only place such a claim can be
checked:
| Image | How werkdock moves it | Tested by |
|---|---|---|
| `postgres:16-alpine` | `PGPORT` | three classes, the oldest tests here |
| `redis:7-alpine` | `--port` | `RedisContainerTest` |
| `mariadb:11` | `--port=` | `MariaDbContainerTest` |
| `mysql:8` | `--port=` | `MySqlContainerTest` |
| `nats:2.10-alpine` | `--port` | `NatsContainerTest` |
| `rabbitmq:4` | `RABBITMQ_NODE_PORT`, capped | `RabbitMqContainerTest` |
| `eclipse-mosquitto:2` | config file, rewritten copy (werkdock 0.10.0) | `MosquittoContainerTest` |
Only postgres was right. Redis reads no `REDIS_PORT`, and neither
database reads a `*_TCP_PORT`; all three take a command-line flag. The
two SQL tests do not stop at connecting — they ask the server itself,
`select @@port`, because a JDBC URL that connects only proves something
answered, not that werkdock and the server agree.
The three messaging servers each added something the first four had not
asked for. **NATS** was clean. **RabbitMQ** derives its Erlang
distribution port by adding 20000 to the one it is given, so werkdock
must not choose above 45535 — and the kernel's ephemeral range, where it
used to draw from, starts at 32768. **Mosquitto** offers no port knob
at all: its shipped config declares its listeners, which switches off
the default one `-p` would set, and neither the environment nor the
config file expands anything. Werkdock 0.9.0 reported 1883 unmoved, and
`MosquittoContainerTest` asserted that, meant to fail the day werkdock
learned better. Werkdock 0.10.0 did: it copies the shipped config with
the listener lines rewritten and binds the copy over the original. The
test now asserts the opposite, for both listeners — MQTT and the
dashboard's HTTP API.
The RabbitMQ test covers both shapes a broker is used in, a queue and a
topic exchange; the Mosquitto test covers an MQTT topic.
Each of the first four also found a second fault on the way: redis could not start at all
(its entrypoint chowns as root, which an unprivileged user namespace
cannot do), mariadb died without `/run/mysqld` (werkdock's `/run` is the
instance's own, so nothing of the image showed through), and
`MariaDBContainer` copies a config directory into `/etc/mysql/conf.d`,
which werkdock refused until 0.8.1.
## What it proves, and what it does not
It passes when werkdock can pull an image, create and start a
container, stream logs so a wait strategy sees them, publish a port a
JDBC client can reach, run a command inside and report its exit code,
and clean up afterwards.
Two instances at once work: werkdock picks a free port for each and
tells each server to bind its own, so the two JDBC URLs address two
independent databases. The test proves it by making a table through
one URL and not finding it through the other.
## Socket or IP
Both, on different levels. The client talks to werkdock over a **unix
socket** (`DOCKER_HOST=unix://…`, mode 0600, so only its owner reaches
it) — that is the control plane, the docker API. The database traffic
is **TCP to localhost** on the published port, because werkdock shares
the host's network and the server binds a real port there. Inside a
sandbox, `werkdock exec … psql` reaches the same server over the unix
socket the image keeps in `/var/run/postgresql`, which lives in the
instance's own directory.
One consequence worth knowing on a shared host: the official postgres
image listens on all interfaces, not only loopback, so the port is open
to whatever the machine's firewall allows. On the Hostsharing webspace
it was measured as refused from the outside, but that is the firewall's
doing, not werkdock's. Where it matters, bind loopback explicitly:
`withCommand("postgres", "-c", "listen_addresses=127.0.0.1")`.
It says nothing about the features werkdock deliberately does not have:
no networks, no port mapping, no build. The port test asserts only that
the published port is not the container's own — under docker because
the port is mapped, under werkdock because the server was moved to a
free port and told about it. A test that assumes 5432 would be wrong on
both.
The gradle wrapper is committed, so a JDK is all a build needs — any JDK from 17 up runs Gradle,
which builds with Temurin 25 and loads it with Foojay where it is missing (werkregie ADR 0001).
The bytecode targets Java 17, which is what the managed webspaces have, so `tools/deploy.sh` can send it to one.
The Testcontainers version comes from Spring Boot's BOM — 1.21.4 at
the time of writing. Werkdock's API subset was measured against 1.19.3
(werkdock `docs/testcontainers-api.md`), and both pass here, which says
the subset did not depend on that release. `postgres:16-alpine` is the
image everything was validated with; see werkdock's
`docs/testcontainers.md` for the setup.
## The demo of werkcrappit's charts
[werkcrappit](https://git.javagil.de/mi/werkcrappit) measures Mutation-CRAP, cohesion and coupling of JVM code
and draws them as a CodeCharta code city, a structure page and a dependency matrix.
The package `werkprobe.shop` is a small shop written to show each of their colours once;
its flaws are on purpose.
```bash
./gradlew pitest mutationCrapCodeCharta
```
PIT mutates only the shop and runs only its unit tests, never the container tests.
The nightly build `mutation` on werkator.javagil.de does the same on `main`
and keeps `build/reports/codecharta` with `index.html`, `structure.html` and `matrix.html`.
| Where | What it shows |
| --------------------------------------- | ------------------------------------------------------------------------------ |
| `service.OrderService.place` | Mutation-CRAP 4: complexity 4, every mutant killed — green |
| `service.Shipping.cost` | Mutation-CRAP 6: complexity 6, every mutant killed — yellow by complexity alone |
| `domain.Discounts.rate` | Mutation-CRAP 13.4: one weak test, most mutants survive — red |
| `domain.Catalog.reorderQuantity` | Mutation-CRAP 42: no test at all — red |
| `domain.Order` | LCOM4 2: its lines and its notes share nothing — yellow fill |
| `domain.Catalog` | LCOM4 3 and change risk 168: three groups, one untested — red |
| `domain.Order` and `domain.Invoice` | a class cycle — red frames |
| `service` and `persistence` | a package cycle across the hierarchy — red edges |
| `web` and `web.forms` | a package cycle along the hierarchy — amber edges |
The values are those of werkcrappit 0.26.0 on 2026-10-11; a later version or PIT may shift them a little.
`build.gradle` sets `maxMutationCrap = 10` only for the colours:
red starts at 90 % of it, 9, so the classes to look at stand out before a gate would fail; yellow starts at half of that, 4.5.
No build runs the gate `mutationCrapCheck`, which fails on the red methods.