o
odinpkg.dev
packages / library / odin-testcontainers

odin-testcontainers

e0be21alibrary

No description provided.

MIT · updated 3 weeks ago

Testcontainers for Odin

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=disable

Build with the collection mapped to the repo:

odin build your_app -collection:testcontainers=/path/to/odin-test-containers

Why

Integration 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.

Design

  • Zero dependencies. Only the Odin core library. No CGo, no libcurl, no Docker SDK.
  • Talks to the Docker Engine API directly over its Unix socket via core:sys/posix (core:net is 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.

Requirements

  • 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.

Installation

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-containers

For editor/LSP support, add the collection to your ols.json:

{ "collections": [ { "name": "testcontainers", "path": "/path/to/odin-test-containers" } ] }

Project layout

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

Usage

Container

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

start 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

Wait strategies

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}

Custom waits

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.

Lower-level verbs

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)

Modules

Module presets package a sensible image, configuration, readiness strategy, and helpers for a specific technology.

Postgres

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.

Cleanup & crash safety

Two layers:

  1. Explicitremove_container(c) / postgres.stop(&pg), typically via defer.
  2. Ryuk reaper — for everything defer can'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: defer does not run when you call os.exit(). On exit paths that bypass defer, Ryuk is your safety net.

Configuration

Socket resolution

make_client() (no argument) resolves the daemon socket the way the Docker CLI does:

  1. DOCKER_HOST environment variable (unix:// prefix is stripped)
  2. context named by DOCKER_CONTEXT, else currentContext in ~/.docker/config.json — looked up in ~/.docker/contexts/meta/<sha256(name)>/meta.json
  3. fallback to /var/run/docker.sock

Override explicitly when needed:

client := docker.make_client("/Users/me/.rd/docker.sock") // this is rancher desktop

Environment variables

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

Running the example

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.

Limitations & roadmap

  • Unix sockets only — a tcp:// DOCKER_HOST resolves 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-side net.dial_tcp to mapped ports during readiness probes isn't bounded yet (core:net doesn't expose a connect timeout). Rare in practice (localhost connects succeed or refuse immediately); bounding it would mean reimplementing that dial over core: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.

License

MIT © Adam Shelton