Files
werkdock/docs/testcontainers.md
mhoennigandClaude Opus 5 4684693a74
werkator build succeeded after 00:52
Messaging: NATS, RabbitMQ and Mosquitto, each measured
Three servers, three lessons the quirk table could not express before.

NATS was the easy one: nats-server takes --port, and the flag wins over
the port in the config the image ships.

RabbitMQ reads RABBITMQ_NODE_PORT, but the port it is given is not the
only one it derives from it — the Erlang distribution port is that plus
20000. A node port above 45535 makes it refuse to boot, and the kernel's
ephemeral range, which freePort draws from, starts at 32768: more than
half of werkdock's choices would have been wrong. A quirk can now cap
what werkdock picks, and werkdock looks for a free port itself below the
cap. A requested port above it is refused with the reason rather than
handed to a server that dies in an Erlang stack trace.

Mosquitto cannot be moved at all. Its port comes from the config the
image ships, whose explicit listener switches off the default one that
-p sets; measured, the flag is simply ignored. Its entry therefore names
a user and no port knob — and it is still worth having, because without
the user mosquitto does not start (it drops privileges itself and cannot
setgroups in a user namespace), and with the entry werkdock reports 1883
unmoved instead of a port nothing answers on.

All five tests for these are in werkprobe, including queue and topic
delivery through RabbitMQ and a topic through MQTT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:01:15 +02:00

213 lines
9.0 KiB
Markdown

# Testcontainers against Werkdock
Werkdock serves the Docker Engine API subset Testcontainers uses, so a
test that asks for a real PostgreSQL gets one — without docker, without
podman, without root. Validated with Testcontainers 1.19.3 and 1.21.4
and `postgres:16-alpine`, on a developer machine and on a Hostsharing
managed webspace (RFC 0005 milestones 3 and 4), both programmatically
and through `jdbc:tc` URLs.
## Setting it up
```bash
werkdock pull postgres:16-alpine # once; needs the crane binary
werkdock system service & # listens on $XDG_RUNTIME_DIR/werkdock.sock
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock
export TESTCONTAINERS_RYUK_DISABLED=true
```
That is the whole adaptation: one socket and one environment variable.
Ryuk, Testcontainers' cleanup container, wants the API socket
bind-mounted into a container of its own and is therefore not
supported; `--rm`, `werkdock rm`, and Testcontainers' own session
cleanup cover the same ground.
A test then reads as it always does:
```java
try (PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine")) {
pg.start();
try (Connection c = DriverManager.getConnection(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword())) {
// pg.getMappedPort(5432) is where the server really listens
}
}
```
## Or no container code at all: `jdbc:tc` URLs
Testcontainers can be the JDBC driver itself, which is how most
projects use it — the database is then a line of configuration:
```yaml
spring:
datasource:
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
url: jdbc:tc:postgresql:16-alpine:///mydb?TC_DAEMON=true&TC_INITSCRIPT=init.sql
```
This works against werkdock with the same two environment variables and
asks for no endpoint the programmatic form does not. Two flags are
worth knowing:
- `TC_DAEMON=true` keeps the container while the JVM lives. Without it
Testcontainers stops the database when the last connection closes,
and a connection pool closes connections whenever it likes.
- `TC_INITSCRIPT` names a script on the classpath, which Testcontainers
runs **over JDBC**. No file is copied into the container, so it needs
nothing werkdock lacks.
[Werkprobe](https://git.javagil.de/mi/werkprobe) exercises both forms
against werkdock in its test suite.
To keep the socket available without starting it by hand, a systemd
user unit does it (the managed webspaces have a user manager and
lingering enabled):
```ini
[Service]
ExecStart=%h/opt/werkdock/bin/werkdock system service
Environment=WERKDOCK_HOME=%h/.werkdock
```
`werkdock system service --time 30m` exits when nothing has used it for
that long, podman's manner; without `--time` it runs until stopped.
## What is different from docker, and why
**Ports are moved, not mapped.** The sandbox shares the host's network
(RFC 0002), so there is nothing to map a port with. When a client asks
for a published port, werkdock picks a free one and tells the *service*
to bind it, through the environment variable the image honours, then
reports that port to the client. `getMappedPort(5432)` therefore
returns a real, free port and the JDBC URL works — but the server
inside is genuinely listening on that port, not on 5432.
The images werkdock knows how to move, each verified against the real
image by starting it and connecting to the port werkdock reported:
| Image | How | |
|---|---|---|
| `postgres` | `PGPORT` | environment |
| `mariadb` | `--port=` | flag, on `mariadbd` |
| `mysql` | `--port=`, `--mysqlx-port=` | flag, on `mysqld` |
| `redis` | `--port` | flag, on `redis-server` |
| `nats` | `--port` | flag, on `nats-server` |
| `rabbitmq` | `RABBITMQ_NODE_PORT` | environment, capped at 45535 |
| `eclipse-mosquitto` | — | cannot be moved; keeps 1883 |
Only `postgres` reads an environment variable. The other three were
listed as doing so until 0.8.0 and did not: the server stayed on its own
port while werkdock reported a free one, which is the one failure this
whole mechanism exists to prevent. A flag is appended only while the
container still runs the image's own program — werkdock does not add
arguments to a command a client chose — and where it cannot, the port is
reported unmoved.
Any other image keeps its own port, which is correct as long as one
instance of it runs at a time. Asking to publish such a port somewhere
else fails with the reason rather than reporting a port nothing listens
on. Adding an image is one line in `quirks` (package `api`) — and, as
0.8.0 showed, an hour with the image itself, because the documentation
of what moves a server's port is unreliable.
MySQL keeps one more port than it publishes: the X protocol on 33060.
It moves only when a client asks for it, so two instances at once share
it and the second one's X plugin fails while its SQL port works.
Two entries are shaped differently from the rest. **RabbitMQ** derives
its Erlang distribution port from the one it is given, by adding 20000,
so a node port above 45535 makes it refuse to boot; its quirk caps
werkdock's choice, and a requested port above the cap is refused with
the reason. **Mosquitto** cannot be moved at all — its shipped config
declares its listeners, and an explicit listener switches off the
default one `-p` would set — so its entry names only the user it needs
to start, and its port is reported unmoved. An entry that moves nothing
is still worth having: without it mosquitto does not run, and with it
werkdock tells the truth about the port.
**An image's `/run` is copied, not shown through.** `/run` is the
instance's own directory so that sockets and pid files survive a stop,
which means the image's own `/run` does not show through it as it would
on docker. What the image puts there — `mariadb`'s `/run/mysqld`, say —
is therefore copied in when the instance is created.
**One user.** An unprivileged user namespace maps the caller to exactly
one uid, so an entrypoint that starts as root to drop privileges later
(the official `postgres` image and its gosu dance) is started as its
target user right away; those entrypoints detect a non-root uid and
skip the dance. The same table holds that knowledge.
**Refused rather than ignored.** A parameter that would change the
answer is not quietly dropped: `logs?tail=10`, `timestamps=true`, and a
`since`/`until` other than zero all answer 501 with the reason, because
werkdock streams the recorded output whole and records no timestamps
beside it.
**Everything else answers 501 with the reason.** Werkdock implements
the traffic a real session sends
([docs/testcontainers-api.md](testcontainers-api.md)), not the whole
Engine API. A client that needs more gets a plain explanation, never a
404, and the service log names the endpoint. Known gaps, none of which
`PostgreSQLContainer` needs: networks and `docker build`.
## Copying a file in
`withCopyFileToContainer` works (0.7.0), so an image's own init
mechanism does too:
```java
new PostgreSQLContainer<>("postgres:16-alpine")
.withCopyFileToContainer(MountableFile.forClasspathResource("init.sql"),
"/docker-entrypoint-initdb.d/init.sql")
```
Underneath it is not docker's copy, because it cannot be: the rootfs in
the store is shared by every instance of that image and bound
read-only. The tar is unpacked into the instance directory and bound
into the sandbox, as high as a bind can sit without hiding anything the
image put there: bound whole where the image has nothing, descended
into where it has a directory, and the file bound where it has a file.
The two trees therefore meet at whatever depth they first differ —
`MariaDBContainer` copies its configuration to `/etc/mysql/conf.d` and
gets one file bound four levels down, with `/etc` untouched.
One consequence is answered with 501 and the reason rather than done
quietly: a copy into a **running** instance is refused, because a
sandbox's mounts are fixed when it starts and there is no writable
container layer to put the file in. Copy before start, as
`withCopyFileToContainer` does, or write through a volume.
## Inside a build sandbox
The intended use is an integration test *inside* a werkdock build,
which reaches the service by having the socket bound in:
```bash
werkdock run --rm -v /repo:/repo -w /repo \
-v $XDG_RUNTIME_DIR/werkdock.sock:/run/werkdock.sock \
-e DOCKER_HOST=unix:///run/werkdock.sock \
-e TESTCONTAINERS_RYUK_DISABLED=true \
buildenv sh -c './gradlew test'
```
The database then runs as a sibling of the build sandbox, not inside
it, which is exactly how docker-in-docker is done with a mounted
socket.
Under Werkator this needs no mount at all. Its werkdock runner already
binds a repository's build-environment home into every sandbox as
`/root`, so a socket placed there is reachable as
`unix:///root/werkdock.sock`, and `.werkator.yml` only has to say so:
```yaml
builds:
default:
werkdock:
env:
DOCKER_HOST: "unix:///root/werkdock.sock"
TESTCONTAINERS_RYUK_DISABLED: "true"
```
A systemd user unit keeps that socket alive, one per repository whose
builds need it. Werkprobe and Werkator itself both run this way.