Files
werkdock/docs/testcontainers-api.md
mhoennigandClaude Opus 5 eee4d5184c
werkator build succeeded after 00:03
A file into a container, without writing into the shared image
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>
2026-09-11 07:53:24 +02:00

6.1 KiB

What Testcontainers Actually Sends

Measured, not read off the Engine API documentation: a real PostgreSQLContainer run recorded between Testcontainers and podman. This is the specification for werkdock's API server (RFC 0005 milestone 3) — what it must answer, and what it may leave at 501.

How it was recorded

tools/apirecord proxies a unix socket to another one and writes one JSON line per exchange. Podman serves the same Docker Engine API that werkdock is to serve, so its answers are a working reference:

go run ./tools/apirecord -listen /tmp/rec.sock -upstream $XDG_RUNTIME_DIR/podman/podman.sock -trace trace.jsonl

Against it, a plain Java program (Testcontainers 1.19.3, docker-java's zerodep transport): start postgres:16-alpine, connect over JDBC, select 42, execInContainer, read the logs, close.

DOCKER_HOST=unix:///tmp/rec.sock TESTCONTAINERS_RYUK_DISABLED=true java ...

Recorded on 2026-09-10 against podman 4.9.3, twice: with the image absent (24 requests) and present (22). The difference is the pull.

The traffic

All paths carry the prefix /v1.32/, the API version docker-java asks for. In call order, with the counts of the steady-state run:

# Request What werkdock answers with
1 GET /info one JSON object; identity and host facts
2 GET /version Components, ApiVersion, Os, Arch (called twice per run)
3 GET /images/json images
4 GET /images/{ref}/json inspect; 404 is the signal to pull
5 POST /images/create?fromImage=…&tag=… pull, answered as a stream of progress JSON lines
6 POST /containers/create create
7 POST /containers/{id}/start start, 204
8 GET /containers/{id}/json instance inspect (three times per run)
9 GET /containers/{id}/logs?stdout&stderr&follow&since logs, in docker's frame format
10 POST /containers/{id}/exec creates an exec record, 201 with its id
11 POST /exec/{id}/start runs it, streams the output framed
12 GET /exec/{id}/json its ExitCode
13 POST /containers/{id}/kill kill
14 DELETE /containers/{id}?v=true&force=true rm -f
15 GET /containers/json?all=true&filters={"label":…} ps -a with a label filter
16 POST /networks/prune, /volumes/prune, /images/prune cleanup; an empty result is a valid answer

Notable absences: no /_ping (the session opens with /info), no /containers/{id}/stop (Testcontainers kills), no attach, no /archive (nothing was copied in this test; see below for a trace that does), and no Ryuk container because TESTCONTAINERS_RYUK_DISABLED=true was set. Ryuk would want the API socket bind-mounted into a container of its own, which werkdock will not offer; disabling it stays a documented requirement.

The four details that decide the implementation

Ports. containers/create asks for "PortBindings":{"5432/tcp":[{"HostIp":"","HostPort":""}]} — an empty HostPort means "choose a free one". The answer comes back in containers/{id}/json as "NetworkSettings":{"Ports":{"5432/tcp":[{"HostIp":"","HostPort":"34259"}]}}, and that number is what getMappedPort(5432) returns and what the JDBC URL uses. Werkdock has no network namespace, so it cannot map anything: the port the service binds is the host port. It must therefore pick the free port itself and pass it into the service, through the environment variable the image honours (PGPORT for postgres), then report that same port. Without this the demo target fails outright, its port 5432 being taken by the webspace's own PostgreSQL.

Log framing. logs and exec/{id}/start answer application/octet-stream in docker's multiplexed format: per frame an 8-byte header, byte 0 the stream (1 stdout, 2 stderr), bytes 4 to 7 the payload length big-endian, then the payload. Podman emits one frame per line. Werkdock keeps stdout and stderr in separate files, so framing them is easy; the exact interleaving of the two streams is lost, which no wait strategy depends on.

Exec is not hijacked. POST /exec/{id}/start is a plain request with a framed response body, not an HTTP upgrade. No connection hijacking is needed. The exit code is fetched afterwards from GET /exec/{id}/json, so an exec must outlive its run as a record with an id.

Labels and filters. Every container is created with org.testcontainers, .lang, .version and .sessionId labels, and the cleanup at the end lists containers filtered by exactly those labels. Instances therefore need labels on disk and ps needs a label filter.

What werkdock does with it (0.6.0)

All of the above is implemented in package api and served by werkdock system service. Two things the trace against podman could not show, found when running against werkdock itself:

  • The reported version is checked. Testcontainers refuses a server below Docker 1.6.0, so /version reports the Engine version whose API is served (api.EngineVersion), not werkdock's own 0.x. Werkdock's version is reported beside it, in Components and in /info's OperatingSystem, where people read it.
  • /_ping is never called by this client, though it is served anyway; other clients open with it.

To record a session again, against werkdock this time:

werkdock system service /tmp/wd.sock &
go run ./tools/apirecord -listen /tmp/rec.sock -upstream /tmp/wd.sock -trace trace.jsonl
DOCKER_HOST=unix:///tmp/rec.sock TESTCONTAINERS_RYUK_DISABLED=true java ...

A 501 in that trace is the next endpoint to implement.

What withCopyFileToContainer sends

Measured separately, because the first trace had no file to copy. One call, between create and start:

PUT /v1.41/containers/{id}/archive?path=%2F&noOverwriteDirNonDir=false

The destination is /, not the directory the file is going to: the whole path lives in the tar entry names, as docker-entrypoint-initdb.d/copied-init.sql with a directory entry ahead of it. A server that refuses / — as werkdock did for an afternoon — refuses every copy there is. The answer is 200 with an empty body.