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.
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.
Squashed from branch container-archive and pushed to main directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
/versionreports the Engine version whose API is served (api.EngineVersion), not werkdock's own 0.x. Werkdock's version is reported beside it, inComponentsand in/info'sOperatingSystem, where people read it. /_pingis 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.