werkator build succeeded after 00:03
Three of its four entries were wrong. redis, mariadb and mysql were all listed as moving their port through an environment variable — REDIS_PORT, MARIADB_TCP_PORT, MYSQL_TCP_PORT. None of the three images reads one. Werkdock picked a free port, told nobody, and reported it: a client got a number nothing was listening on, which is the single failure the whole quirk mechanism exists to prevent. It was found the only way it could be — by starting the images and connecting to the port werkdock named. redis-server takes --port; the two database entrypoints read the port out of the server's own configuration, so the flag has to reach the command line there too. A quirk can now move a port by a flag, and does so only while the container still runs the image's own program: appending an argument to a command the client chose would be a guess, and guessing is what this commit is about. Two more things surfaced on the way. Redis could not start at all — its entrypoint chowns its data directory as root, which an unprivileged user namespace cannot do, so it needs the same treatment postgres has. And an image's /run did not show through: /run is the instance's own directory so that sockets survive a stop, which left mariadb without /run/mysqld and dying on "Bind on unix socket: No such file or directory". The instance's /run is now seeded from the image's. postgres was right all along and is untouched; it really does read PGPORT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
7.5 KiB
Markdown
180 lines
7.5 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` |
|
|
|
|
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.
|
|
|
|
**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 — whole where the image has nothing at the
|
|
destination, file by file where it has something, so the image's own
|
|
files stay visible beside the copied ones.
|
|
|
|
Two consequences follow, both 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; and a copied **directory**
|
|
aimed at one the image fills is refused, because binding it would hide
|
|
what is inside. Copy the files themselves, or use 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.
|