239 lines
13 KiB
Markdown
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.
|
|
|