werkator build succeeded after 00:03
PUT /containers/{id}/archive was the last endpoint Testcontainers asks
for that werkdock answered with a 501. It is what withCopyFileToContainer
sends, and with it an image's own init mechanism works: a script copied
to /docker-entrypoint-initdb.d is run by the postgres entrypoint.
Docker unpacks the tar into the container's writable layer. Werkdock has
none, and its rootfs is the image's own directory in the store, shared by
every instance and bound read-only — unpacking there would change the
image for everyone. So the tar goes into the instance directory and is
bound into the sandbox, one of two ways, and the image decides which:
whole where the image has nothing at the destination, file by file where
it has something, so its own files stay visible beside the copied ones.
That is docker's merge semantics, reached by another road.
The real client corrected the design once: Testcontainers sends path=/
and carries the whole path in the tar entry names. An afternoon's
assumption that / should be refused would have refused every copy there
is. The measured call is now written down in docs/testcontainers-api.md,
where the guesses can be checked against it.
Refused rather than ignored: a copy into a running instance (mounts are
fixed at start), a directory onto one the image fills (the bind would
hide it), symlinks and device nodes, entries that climb out of the
destination, and archives above 64 entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
6.2 KiB
Markdown
159 lines
6.2 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: 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.
|