Throwaway Docker containers for integration tests, written in pure Odin. Spin up a real Postgres, Redis, or anything that runs in a container, wait until it's actually ready, talk to it over its mapped port, and have it cleaned up automatically — even if your test process crashes.
Inspired by testcontainers.
import docker "testcontainers:docker"
import postgres "testcontainers:modules/postgres"
client := docker.make_client()
pg, ok := postgres.start(client, postgres.Config{password = "secret", database = "appdb"})
defer postgres.stop(&pg)
url := postgres.connection_string(pg)
// postgresql://postgres:secret@127.0.0.1:32802/appdb?sslmode=disableBuild with the collection mapped to the repo:
odin build your_app -collection:testcontainers=/path/to/odin-test-containersIntegration tests want real dependencies, not mocks — but managing their lifecycle by hand (start before tests, stop after, don't leak when something panics) is tedious and error-prone. This library does it for you: a container is tied to your test process, becomes addressable once it's genuinely ready, and is reaped when the process dies.
- Zero dependencies. Only the Odin
corelibrary. No CGo, no libcurl, no Docker SDK. - Talks to the Docker Engine API directly over its Unix socket via
core:sys/posix(core:netis IP-only and can't reach a Unix socket). The HTTP/1.1 client and JSON handling are purpose-built and small. - Crash-safe cleanup via Ryuk — the same resource reaper the official Testcontainers projects use.
- Odin (recent dev build).
- A Docker-compatible daemon exposing a Unix socket: Docker Desktop, Docker Engine, Rancher Desktop, Colima, Podman (with the Docker-compatible socket), etc.
- macOS or Linux (the transport uses AF_UNIX domain sockets).
The daemon socket is auto-discovered (see Configuration). No manual setup required for the common cases.
The library is consumed as an Odin collection. Point a collection
named testcontainers at the repo root, then import the docker package with import "testcontainers:docker" and modules
with "testcontainers:modules/<name>":
odin build your_app -collection:testcontainers=/path/to/odin-test-containersFor editor/LSP support, add the collection to your ols.json:
{ "collections": [ { "name": "testcontainers", "path": "/path/to/odin-test-containers" } ] }package docker ← the library
transport.odin AF_UNIX bytes to the Docker daemon (core:sys/posix)
http.odin purpose-built HTTP/1.1: request build + response parse
client.odin make_client (socket resolution) + request()
container.odin Container type + lifecycle: start / pull / inspect / remove / mapped_port
wait.odin wait strategies
builder.odin container construction helpers (new_container + with_*)
reaper.odin Ryuk crash-safe cleanup
modules/
postgres/ package postgres ← a module preset
example/
main.odin runnable demo
A Container is one type for the whole lifecycle: you configure it with the with_* helpers, start it, then use the same value to look up ports, inspect, and re-check readiness. (This mirrors docker' GenericContainer.)
import docker "testcontainers:docker"
client := docker.make_client()
c := docker.new_container("nginx:alpine")
defer docker.container_destroy(&c)
docker.with_exposed_port(&c, "80/tcp")
docker.with_env(&c, "SOME_VAR", "value")
docker.with_wait(&c, testcontainers.Wait_Http{port = "80/tcp", path = "/", status = 200})
if !docker.start(&c, client) {
// failed to start or never became ready
}
defer docker.remove_container(c)
port, _ := docker.mapped_port(c, "80/tcp") // -> ephemeral host port
// connect to docker.host(c):portstart does everything — ensures the reaper, pulls the image, creates + starts the container, and waits for the configured readiness strategy.
| Helper | Purpose |
|---|---|
with_exposed_port(&c, "6379/tcp") |
publish a container port to an ephemeral host port |
with_env(&c, "KEY", "VALUE") |
set an environment variable |
with_cmd(&c, "arg1", "arg2") |
override the container command |
with_name(&c, "my-container") |
set a fixed name (Docker auto-names otherwise) |
with_healthcheck(&c, "CMD-SHELL", "pg_isready") |
define a Docker HEALTHCHECK |
with_wait(&c, strategy) |
set the readiness strategy |
with_startup_timeout(&c, 30 * time.Second) |
cap how long to wait for readiness |
A started container is not necessarily ready. Set the signal that actually means "ready" with with_wait; start waits on it, and you can re-check anytime with wait_until_ready(c).
// TCP: a mapped port accepts a connection
docker.Wait_Port{port = "6379/tcp"}
// Log: a substring appears in stdout/stderr
docker.Wait_Log{text = "database system is ready to accept connections"}
// HTTP: a GET to a mapped port returns the expected status (0 = any 2xx)
docker.Wait_Http{port = "80/tcp", path = "/health", status = 200}
// Healthcheck: the container's Docker HEALTHCHECK reports "healthy"
docker.Wait_Healthcheck{}
// Func: your own probe, polled until it returns true
docker.Wait_Func{probe = my_probe}Wait_Func makes readiness fully extensible — a module or an application can register any probe. It receives the live container, so it can read mapped ports and configured env:
my_probe :: proc(c: ^docker.Container, user_data: rawptr) -> bool {
port, ok := docker.mapped_port(c^, "5432/tcp")
if !ok { return false }
// ...attempt a real connection / handshake against 127.0.0.1:port...
return connected
}
docker.with_wait(&c, testcontainers.Wait_Func{probe = my_probe})The Postgres module (below) uses exactly this — its readiness check is a Wait_Func that performs the Postgres v3 startup handshake.
Beyond start, you can drive pieces directly:
docker.pull_image(client, "redis:alpine")
insp, _ := docker.inspect_container(c) // typed Inspect: State, Health, Ports
port, _ := docker.mapped_port(c, "6379/tcp")
val, _ := docker.container_env(c, "POSTGRES_USER")
docker.remove_container(c)Module presets package a sensible image, configuration, readiness strategy, and helpers for a specific technology.
import postgres "docker:modules/postgres"
pg, ok := postgres.start(client, postgres.Config{
image = "postgres:16-alpine", // optional; this is the default
user = "postgres", // default
password = "secret", // default "postgres"
database = "appdb", // default = user
})
defer postgres.stop(&pg)
url := postgres.connection_string(pg)
// pg is a Container too: docker.mapped_port(pg, "5432/tcp"), etc.Readiness is gated on a custom Wait_Func that performs the Postgres v3 startup handshake — a real connection test — so by the time start returns the server genuinely accepts connections.
Two layers:
- Explicit —
remove_container(c)/postgres.stop(&pg), typically viadefer. - Ryuk reaper — for everything
defercan't cover (panics,os.exit,kill -9).
On first container creation the library starts the docker/ryuk sidecar and holds a TCP connection to it, registering a per-process session label. Every container the library creates is tagged with that label. When your process dies and the connection drops, Ryuk reaps every container carrying the label. This is the only cleanup that survives a hard crash — and it's why you should never rely on defer alone.
Disable it (e.g. in CI that handles its own cleanup) with OTC_RYUK_DISABLED=1.
Note:
deferdoes not run when you callos.exit(). On exit paths that bypassdefer, Ryuk is your safety net.
make_client() (no argument) resolves the daemon socket the way the Docker CLI does:
DOCKER_HOSTenvironment variable (unix://prefix is stripped)- context named by
DOCKER_CONTEXT, elsecurrentContextin~/.docker/config.json— looked up in~/.docker/contexts/meta/<sha256(name)>/meta.json - fallback to
/var/run/docker.sock
Override explicitly when needed:
client := docker.make_client("/Users/me/.rd/docker.sock") // this is rancher desktop| Variable | Effect |
|---|---|
DOCKER_HOST |
force a specific daemon socket |
DOCKER_CONTEXT |
use a specific docker context |
OTC_RYUK_DISABLED |
1/true disables the Ryuk reaper |
odin run example -collection:testcontainers=.Starts Postgres, prints its connection string, and proves it by performing the Postgres v3 startup handshake against the mapped port.
- Unix sockets only — a
tcp://DOCKER_HOSTresolves but the transport can't dial it yet. - No streaming — responses are read fully, so live log-follow / pull-progress aren't available (readiness polls instead).
- One connection per request — no HTTP keep-alive yet.
- Host-side connect has no timeout — all daemon-socket I/O is bounded (the unix connect uses non-blocking connect +
poll(); recv/send use inactivity timeouts), but the host-sidenet.dial_tcpto mapped ports during readiness probes isn't bounded yet (core:netdoesn't expose a connect timeout). Rare in practice (localhost connects succeed or refuse immediately); bounding it would mean reimplementing that dial overcore:sys/posix. - No container
exec— out of scope by design. Talk to services over their mapped ports with a real client library (e.g. a database package), not by shelling into the container. - More module presets (Redis, MySQL, …) welcome.
MIT © Adam Shelton