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

13 KiB

Werkprobe

A work sample for Werkdock: a real Testcontainers 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:

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:

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:

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.

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

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