# 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.