werkator build succeeded after 00:12
0.9.0 listed mosquitto as the image werkdock cannot move, and that was true of everything mosquitto offers: -p is ignored once a config file declares a listener (the manual says so, and it was measured), no environment variable names the port, and the config file expands none. What mosquitto does read is the config file — and a config file is the one thing werkdock can replace that a flag or a variable cannot reach. A quirk can now name a PortFile: which file the image ships carries the port and how the line reads. Werkdock copies that file into the instance directory with the line rewritten to the chosen port and binds the copy read-only over the image's original, which is never touched. Both of mosquitto's listeners live in the same file, so the dashboard's HTTP API on 9883 moves along with MQTT on 1883. Measured with 2.1.2 through the API socket: two instances at once, each on its own pair of ports, each accepting an anonymous client and answering on its dashboard, 1883 left free. The rewritten copy applies only while the command still names the file and the client has not bound a file of their own over that path; either way the copy would not be what the server reads, and the port is then reported unmoved. An image whose file no longer has the expected line is refused at create with the line named, not started on a port werkdock only claimed. Squashed from branch mosquitto-port-file and pushed to main directly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
219 lines
9.4 KiB
Markdown
219 lines
9.4 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` | `listener 1883`, `listener 9883` | config file, rewritten copy |
|
|
|
|
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** offers nothing at all: `-p` is ignored once a
|
|
config file declares a listener, no environment variable names the
|
|
port, and the config file expands none. What it does read is the config
|
|
file, so werkdock copies the one the image ships into the instance
|
|
directory with `listener 1883` rewritten and binds the copy read-only
|
|
over the original. The image's file is never touched, the dashboard's
|
|
HTTP API on 9883 moves the same way, and the mount shows in `inspect`
|
|
at the file's own path. This applies only while the command still names
|
|
the file and the client has not bound a file of their own over it; a
|
|
config copied in with `withCopyFileToContainer` *after* create lands
|
|
over werkdock's copy, so a config of your own is better supplied as a
|
|
bind (`withFileSystemBind`), which werkdock steps aside for.
|
|
|
|
**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.
|