Files
werkdock/docs/testcontainers.md
mhoennigandClaude Opus 5 b14cf40cce
werkator build succeeded after 00:08
docs: jdbc:tc URLs work, and the archive gap is narrower than written
Werkprobe now drives werkdock through Testcontainers' JDBC driver as
well as programmatically, with a Spring Boot application whose
application.yml holds the URLs. It asked for no endpoint outside the
subset, so this is documentation only, no version bump.

Two corrections come with it. A JDBC init script is not a reason to
want PUT /containers/{id}/archive: Testcontainers runs it over the
connection. And the subset now has 1.21.4 behind it as well as 1.19.3,
which says it did not depend on that release.

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

137 lines
5.3 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:
| Image | Variable |
|---|---|
| `postgres` | `PGPORT` |
| `mariadb` | `MARIADB_TCP_PORT` |
| `mysql` | `MYSQL_TCP_PORT` |
| `redis` | `REDIS_PORT` |
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`).
**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: `PUT /containers/{id}/archive`
(`withCopyFileToContainer` and anything else that puts a file inside a
container — a JDBC init script does not, it travels over the
connection), networks, and `docker build`.
## 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.