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

5.3 KiB

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

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:

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:

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 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):

[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), 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:

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.