diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..73d9991
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,41 @@
+name: test
+
+on:
+ push:
+ branches:
+ - master
+ - main
+ pull_request:
+
+jobs:
+ unit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: erlef/setup-beam@v1
+ with:
+ otp-version: "28"
+ gleam-version: "1.16.0"
+ rebar3-version: "3"
+ - run: gleam deps download
+ - run: gleam test
+ - run: gleam format --check src test
+
+ integration:
+ runs-on: ubuntu-latest
+ needs: unit
+ steps:
+ - uses: actions/checkout@v4
+ - uses: erlef/setup-beam@v1
+ with:
+ otp-version: "28"
+ gleam-version: "1.16.0"
+ rebar3-version: "3"
+ # GitHub-hosted ubuntu runners ship Docker by default; verify it.
+ - name: Verify Docker is available
+ run: docker version
+ - run: gleam deps download
+ - name: Run integration tests
+ env:
+ TESTCONTAINERS_INTEGRATION: "true"
+ run: gleam test
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..db1e066
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,128 @@
+
+
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/).
+
+## [0.1.0] - 2026-04-26
+
+First public release. Package name: **`testcontainer`** (the plural form
+is taken on Hex).
+
+### Added
+
+#### Public API
+
+- `testcontainer`:
+ - Lifecycle: `start/1`, `stop/1`, `start_and_keep/1`,
+ `with_container/2`, `with_container_mapped/3`, `with_formula/2`,
+ `with_network/2`, `with_stack/2`
+ - Runtime ops: `exec/2` (separate stdout/stderr), `logs/1`,
+ `logs_tail/2`, `copy_file_to/3`
+- `testcontainer/container` builder: `new/1`, `with_env/3`,
+ `with_envs/2`, `with_secret_env/3`, `expose_port/2`, `expose_ports/2`,
+ `wait_for/2`, `with_command/2`, `with_entrypoint/2`,
+ `with_bind_mount/3`, `with_readonly_bind/3`, `with_tmpfs/2`,
+ `with_volume/2`, `on_network/2`, `with_name/2`, `with_label/3`,
+ `with_privileged/1`. `Volume` is opaque, built via `bind_mount/2`,
+ `readonly_bind_mount/2`, or `tmpfs/1`.
+- `testcontainer/wait`: `none`, `log`, `log_times`, `port`, `http`,
+ `http_with_status`, `health_check`, `command`, `all_of`, `any_of`,
+ plus `with_timeout/2` and `with_poll_interval/2` modifiers.
+- `testcontainer/network`: `create/1`, `remove/1`, `with_network/2`.
+ Backed by a linked guard process so a parent crash still triggers
+ network removal.
+- `testcontainer/formula`: bridge type consumed by the separate
+ `testcontainer_formulas` package.
+- `testcontainer/port`: validated constructors `try_tcp/1` / `try_udp/1`
+ return `Error.InvalidPort` for out-of-range numbers.
+- `testcontainer/stack`: typed multi-container builders.
+
+#### Lifecycle & cleanup
+
+- Linked guard process per container/network using
+ `proc_lib:spawn_link` so the link is established atomically with
+ the spawn, with no leak window on caller crash during startup.
+- `with_*` functions surface cleanup failures when the body succeeded,
+ so a leaked container/network is never silent.
+- `start_and_keep/1` forces the keep flag regardless of
+ `TESTCONTAINERS_KEEP`.
+- `testcontainer.force_stop/1` tears down a container regardless of
+ the keep flag (works on containers started with `start_and_keep/1`
+ or under `TESTCONTAINERS_KEEP=true`).
+- `TESTCONTAINERS_STOP_TIMEOUT` env var (default `10`s) controls the
+ stop grace period across `stop/1`, wait-failure cleanup, and the
+ guard's crash-cleanup spawn.
+- `Container` carries the configured stop timeout; internal
+ `container.stop_timeout_sec/1` accessor exposes it to lifecycle code.
+
+#### Configuration
+
+- Env-driven via [`envie`](https://hex.pm/packages/envie):
+ `DOCKER_HOST`, `TESTCONTAINERS_KEEP`, `TESTCONTAINERS_PULL_POLICY`,
+ `TESTCONTAINERS_HOST_OVERRIDE`, `TESTCONTAINERS_REGISTRY_USER`,
+ `TESTCONTAINERS_REGISTRY_PASSWORD`.
+- Pull policies: `always` / `missing` / `never`. `never` returns a
+ clear `ImagePullFailed` if the image is missing locally.
+- Private registries supported via `X-Registry-Auth`.
+
+#### Robustness
+
+- Pull-stream error detection: Docker's "200 OK with embedded error
+ payload" pattern is reported as `ImagePullFailed` instead of
+ cascading into a cryptic create error.
+- CR/LF validation on image references, container names, and volume
+ paths.
+- Port range validation (`1..=65535`) returning `Error.InvalidPort`.
+- `port_mapping` keyed by `(port_number, protocol)` so TCP and UDP
+ ports with the same number do not collide.
+- `start/1` fails fast with `PortMappingParseFailed(container_id, reason)`
+ when Docker's inspect port mapping cannot be decoded, instead of
+ silently falling back to an empty mapping.
+
+#### Secrets
+
+- Env values are wrapped in [`cowl.Secret`](https://hex.pm/packages/cowl)
+ so they never appear in `string.inspect` output. Verified by tests.
+
+#### Other
+
+- `DOCKER_HOST=tcp://host:port` supported as plain HTTP/1.1
+ (TLS planned separately).
+- MIT licence.
+
+### Internal
+
+- `wait_runner.run/3` takes the resolved host as an argument; the
+ runner no longer calls `config.load()` on every poll.
+- The wait-runner fetches the container's inspect payload once per
+ poll iteration and reuses it for every port resolution and health
+ check inside that iteration (including nested `all_of` / `any_of`).
+- `internal/docker.url_encode` rewritten as a single grapheme-pass
+ instead of 11 sequential `string.replace` calls.
+- `scan_pull_stream_for_error` rewritten as a single `split_once` pass.
+- `encode_registry_auth` emits standard base64 (no padding) instead
+ of URL-safe base64, matching the Docker SDK reference encoding.
+- `config.parse_pull_policy` is case-insensitive (`ALWAYS`, `Always`,
+ and `always` all map to `Always`).
+- `wait.with_timeout/2` clamps negative values to `0`;
+ `wait.with_poll_interval/2` clamps values `< 1` to `1` to prevent a
+ hot-spin loop.
+- `with_container_mapped/3` destructures the guarded result inline
+ for clarity (no behaviour change).
+
+### Notes
+
+- `woof` is a dev-only dependency. The library core does not log;
+ applications can wire their own logging on top of the public API.
+
+### Naming
+
+- `container.named/2` β `container.with_name/2`
+- `container.privileged/1` β `container.with_privileged/1`
+- `Volume` is now opaque. Construct via `container.bind_mount/2`,
+ `readonly_bind_mount/2`, or `tmpfs/1` instead of the data
+ constructors.
diff --git a/LICENSE b/LICENSE
index 4c945f1..82bdc50 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 Daniele
+Copyright (c) 2026 Daniele Scaratti - lupdoevelop
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 5653584..c6d8c37 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,73 @@
-# testcontainer
-A small, type-safe Gleam library for spinning up real Docker containers from your tests and dev tooling.
+
+
+
+
+testcontainer
+
+
+
+
+
+
+
+
+
+> The hermit-crab way to run Docker containers in your Gleam tests.
+> Meet **Pago**, your paguro mascot. He carries the container so you don't have to.
+
+A small, type-safe Gleam library for spinning up real Docker containers
+from your **tests** and **dev tooling**. Start a Postgres, run a query,
+shut it down. Typed lifecycle and automatic cleanup even if your test
+process crashes (except abrupt VM termination, e.g. `kill -9`).
+
+```gleam
+use redis <- testcontainer.with_container(
+ container.new("redis:7-alpine")
+ |> container.expose_port(port.tcp(6379))
+ |> container.wait_for(wait.log("Ready to accept connections")),
+)
+let assert Ok(host_port) = container.host_port(redis, port.tcp(6379))
+// connect to 127.0.0.1:host_port
+```
+
+## Why use it
+
+- π¦ **Crash-safe**: a linked guard process cleans containers up even
+ if your test panics
+- π **Type-safe lifecycle**: opaque builders, `use` syntax, errors
+ always carry context
+- π **Zero ceremony**: defaults that work, env vars when you need them
+- π **Fast**: talks to Docker over the Unix socket directly via
+ `gen_tcp` (no HTTP client to drag along)
+- π¦ **Formule** ([companion package](https://hex.pm/packages/testcontainer_formulas))
+ for ready-to-use Postgres / Redis / MySQL / RabbitMQ / Mongo with typed
+ connection records
+- π§± **Formulas Builder** ([testcontainer_formulas_builder](https://github.com/lupodevelop/testcontainer_formulas_builder)):
+ visual block editor + codegen for `testcontainer_formulas` snippets
+
+## Install
+
+```sh
+gleam add testcontainer
+```
+
+## Documentation
+
+- [Quickstart](docs/quickstart.md): 5-minute tour of the API
+- [Wait strategies](docs/wait-strategies.md): readiness probes that
+ stay green
+- [Formulas](docs/formulas.md): the customs paperwork that turns a raw
+ container into a typed service
+- [Formula Builder (Astro)](formula-builder/README.md): visual blocks + codegen
+ for `testcontainer_formulas` snippets
+- [Networks & Stacks](docs/networks-and-stacks.md): multi-container
+ setups
+- [Configuration](docs/configuration.md): env vars, host overrides,
+ registry auth
+- [Troubleshooting](docs/troubleshooting.md): common gotchas
+
+Full API docs:
+
+## License
+
+[MIT](LICENSE).
diff --git a/assets/img/logo.png b/assets/img/logo.png
new file mode 100644
index 0000000..b3fb840
Binary files /dev/null and b/assets/img/logo.png differ
diff --git a/dev/testcontainer_dev.gleam b/dev/testcontainer_dev.gleam
new file mode 100644
index 0000000..46b0fce
--- /dev/null
+++ b/dev/testcontainer_dev.gleam
@@ -0,0 +1,399 @@
+import gleam/int
+import gleam/result
+import gleam/string
+
+import testcontainer
+import testcontainer/container
+import testcontainer/error
+import testcontainer/formula
+import testcontainer/network
+import testcontainer/port
+import testcontainer/wait
+
+import woof
+
+// ---------------------------------------------------------------------------
+// Field-builder shorthands for woof 1.6+ FieldValue API
+// ---------------------------------------------------------------------------
+
+fn s(key: String, value: String) -> #(String, woof.FieldValue) {
+ #(key, woof.FString(value))
+}
+
+fn i(key: String, value: Int) -> #(String, woof.FieldValue) {
+ #(key, woof.FInt(value))
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+pub fn main() {
+ woof.set_colors(woof.Always)
+ woof.info("βββ testcontainer dev runner βββ", [])
+
+ run("1 - alpine: start / logs", demo_alpine_logs)
+ run("2 - alpine: exec", demo_alpine_exec)
+ run("3 - redis: wait.log + port mapping + mapped_url", demo_redis)
+ run("4 - alpine: wait.command", demo_wait_command)
+ run("5 - nginx: wait.port + wait.http", demo_nginx_wait)
+ run("6 - alpine: copy_file_to", demo_copy_file_to)
+ run("7 - formula: typed output", demo_formula)
+ run("8 - network: two containers, same bridge", demo_network)
+
+ woof.info("βββ all demos done βββ", [])
+}
+
+fn run(label: String, demo: fn() -> Result(Nil, error.Error)) -> Nil {
+ woof.info("βββ " <> label <> " βββ", [])
+ case demo() {
+ Ok(_) -> woof.info("ok", [])
+ Error(e) -> woof.error("FAILED", [s("reason", describe_error(e))])
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Demo 1 - basic lifecycle + logs
+// ---------------------------------------------------------------------------
+
+fn demo_alpine_logs() -> Result(Nil, error.Error) {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command([
+ "sh",
+ "-c",
+ "echo '=== container alive ===' && sleep 5",
+ ]),
+ fn(c) {
+ woof.info("container up", [
+ s("id", short(container.id(c))),
+ s("host", container.host(c)),
+ ])
+ use logs <- result.try(testcontainer.logs(c))
+ woof.info("logs", [s("output", string.trim(logs))])
+ Ok(Nil)
+ },
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Demo 2 - exec
+// ---------------------------------------------------------------------------
+
+fn demo_alpine_exec() -> Result(Nil, error.Error) {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(c) {
+ use exec_out <- result.try(
+ testcontainer.exec(c, ["sh", "-c", "echo host=$(hostname) && uname -r"]),
+ )
+ woof.info("exec", [
+ i("exit", exec_out.exit_code),
+ s("stdout", string.trim(exec_out.stdout)),
+ ])
+ Ok(Nil)
+ },
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Demo 3 - redis: wait.log + port mapping + mapped_url
+// ---------------------------------------------------------------------------
+
+fn demo_redis() -> Result(Nil, error.Error) {
+ let redis_port = port.tcp(6379)
+ testcontainer.with_container(
+ container.new("redis:7-alpine")
+ |> container.expose_port(redis_port)
+ |> container.wait_for(
+ wait.log("Ready to accept connections")
+ |> wait.with_timeout(30_000),
+ ),
+ fn(c) {
+ use hp <- result.try(container.host_port(c, redis_port))
+ use url <- result.try(container.mapped_url(c, redis_port, "redis"))
+ woof.info("redis ready", [
+ s("id", short(container.id(c))),
+ i("mapped_port", hp),
+ s("url", url),
+ ])
+
+ use logs <- result.try(testcontainer.logs(c))
+ let ready_line =
+ logs
+ |> string.split("\n")
+ |> find_first(fn(l) { string.contains(l, "Ready to accept") })
+ woof.info("readiness log line", [s("line", string.trim(ready_line))])
+
+ Ok(Nil)
+ },
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Demo 4 - wait.command
+// ---------------------------------------------------------------------------
+
+fn demo_wait_command() -> Result(Nil, error.Error) {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command([
+ "sh",
+ "-c",
+ "sleep 1 && touch /tmp/ready && sleep 30",
+ ])
+ |> container.wait_for(
+ wait.command(["test", "-f", "/tmp/ready"])
+ |> wait.with_timeout(15_000)
+ |> wait.with_poll_interval(300),
+ ),
+ fn(c) {
+ woof.info("ready file appeared", [s("id", short(container.id(c)))])
+ Ok(Nil)
+ },
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Demo 5 - nginx: wait.port + wait.http
+// ---------------------------------------------------------------------------
+
+fn demo_nginx_wait() -> Result(Nil, error.Error) {
+ let http_port = port.tcp(80)
+ testcontainer.with_container(
+ container.new("nginx:alpine")
+ |> container.expose_port(http_port)
+ |> container.wait_for(
+ wait.all_of([wait.port(80), wait.http(80, "/")])
+ |> wait.with_timeout(30_000),
+ ),
+ fn(c) {
+ use hp <- result.try(container.host_port(c, http_port))
+ use url <- result.try(container.mapped_url(c, http_port, "http"))
+ woof.info("nginx ready", [
+ s("id", short(container.id(c))),
+ i("host_port", hp),
+ s("url", url),
+ ])
+ Ok(Nil)
+ },
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Demo 6 - copy_file_to
+// ---------------------------------------------------------------------------
+
+fn demo_copy_file_to() -> Result(Nil, error.Error) {
+ let host_path = "/tmp/tc_dev_copy.txt"
+ let content = "hello from the host - copy_file_to works!"
+ let _ = write_file(host_path, content)
+
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(c) {
+ use _ <- result.try(testcontainer.copy_file_to(
+ c,
+ host_path,
+ "/tmp/copied.txt",
+ ))
+ woof.info("file copied to container", [
+ s("host", host_path),
+ s("container", "/tmp/copied.txt"),
+ ])
+
+ use exec_out <- result.try(
+ testcontainer.exec(c, ["cat", "/tmp/copied.txt"]),
+ )
+ woof.info("file contents verified", [
+ s("content", string.trim(exec_out.stdout)),
+ ])
+
+ Ok(Nil)
+ },
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Demo 7 - Formula(output): typed extraction
+// ---------------------------------------------------------------------------
+
+// A minimal inline formula that starts nginx and returns a typed record.
+type NginxInfo {
+ NginxInfo(id: String, host: String, http_port: Int, base_url: String)
+}
+
+fn nginx_formula() -> formula.Formula(NginxInfo) {
+ let p = port.tcp(80)
+ formula.new(
+ container.new("nginx:alpine")
+ |> container.expose_port(p)
+ |> container.wait_for(
+ wait.all_of([wait.port(80), wait.http(80, "/")])
+ |> wait.with_timeout(30_000),
+ ),
+ fn(c) {
+ use hp <- result.try(container.host_port(c, p))
+ use url <- result.try(container.mapped_url(c, p, "http"))
+ Ok(NginxInfo(
+ id: short(container.id(c)),
+ host: container.host(c),
+ http_port: hp,
+ base_url: url,
+ ))
+ },
+ )
+}
+
+fn demo_formula() -> Result(Nil, error.Error) {
+ testcontainer.with_formula(nginx_formula(), fn(info) {
+ woof.info("nginx formula output", [
+ s("id", info.id),
+ s("host", info.host),
+ i("port", info.http_port),
+ s("base_url", info.base_url),
+ ])
+ Ok(Nil)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Demo 8 - Network: two containers on the same bridge, ping each other
+// ---------------------------------------------------------------------------
+
+fn demo_network() -> Result(Nil, error.Error) {
+ use net <- result.try(
+ network.with_network("tc-dev-net", fn(net) {
+ woof.info("network created", [
+ s("id", short(network.id(net))),
+ s("name", network.name(net)),
+ ])
+
+ // Start a named "server" container on the network.
+ use _ <- result.try(
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_name("tc-dev-server")
+ |> container.on_network(network.name(net))
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(server) {
+ woof.info("server container up", [
+ s("id", short(container.id(server))),
+ s("name", "tc-dev-server"),
+ ])
+
+ // Start a "client" container on the same network and ping the server.
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.on_network(network.name(net))
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(client) {
+ woof.info("client container up", [
+ s("id", short(container.id(client))),
+ ])
+
+ use ping_out <- result.try(
+ testcontainer.exec(client, [
+ "ping", "-c", "2", "tc-dev-server",
+ ]),
+ )
+ woof.info("ping result", [
+ i("exit", ping_out.exit_code),
+ s(
+ "summary",
+ ping_out.stdout
+ |> string.split("\n")
+ |> find_last
+ |> string.trim,
+ ),
+ ])
+ Ok(Nil)
+ },
+ )
+ },
+ ),
+ )
+
+ Ok(net)
+ }),
+ )
+
+ woof.info("network removed", [s("name", network.name(net))])
+ Ok(Nil)
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+fn short(id: String) -> String {
+ string.slice(id, 0, 12)
+}
+
+fn find_first(lst: List(String), pred: fn(String) -> Bool) -> String {
+ case lst {
+ [] -> ""
+ [h, ..rest] ->
+ case pred(h) {
+ True -> h
+ False -> find_first(rest, pred)
+ }
+ }
+}
+
+fn find_last(lst: List(String)) -> String {
+ case lst {
+ [] -> ""
+ [x] -> x
+ [_, ..rest] -> find_last(rest)
+ }
+}
+
+@external(erlang, "file", "write_file")
+fn write_file(path: String, content: String) -> Result(Nil, String)
+
+fn describe_error(e: error.Error) -> String {
+ case e {
+ error.DockerUnavailable(path, reason) ->
+ "docker unavailable [" <> path <> "]: " <> reason
+ error.ImagePullFailed(image, reason) ->
+ "pull failed [" <> image <> "]: " <> reason
+ error.ContainerCreateFailed(image, reason) ->
+ "create failed [" <> image <> "]: " <> reason
+ error.ContainerStartFailed(id, reason) ->
+ "start failed [" <> short(id) <> "]: " <> reason
+ error.ContainerStopFailed(id, reason) ->
+ "stop failed [" <> short(id) <> "]: " <> reason
+ error.WaitTimedOut(strategy, ms) ->
+ "wait timed out [" <> strategy <> "] after " <> int.to_string(ms) <> "ms"
+ error.WaitFailed(strategy, reason) ->
+ "wait failed [" <> strategy <> "]: " <> reason
+ error.ExecFailed(id, cmd, exit_code, stderr) ->
+ "exec failed ["
+ <> short(id)
+ <> "] cmd="
+ <> string.join(cmd, " ")
+ <> " exit="
+ <> int.to_string(exit_code)
+ <> " stderr="
+ <> stderr
+ error.PortNotMapped(p) -> "port " <> int.to_string(p) <> " not mapped"
+ error.FileCopyFailed(path, reason) ->
+ "file copy failed [" <> path <> "]: " <> reason
+ error.PortMappingParseFailed(id, reason) ->
+ "port mapping parse failed [" <> short(id) <> "]: " <> reason
+ error.DockerApiError(method, path, status, body) ->
+ "docker api "
+ <> method
+ <> " "
+ <> path
+ <> " β "
+ <> int.to_string(status)
+ <> ": "
+ <> body
+ error.InvalidImageRef(raw) -> "invalid image ref: " <> raw
+ error.InvalidPort(n) -> "invalid port: " <> int.to_string(n)
+ }
+}
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..636ce02
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,77 @@
+# Configuration
+
+All knobs are environment variables. They are read once per
+`testcontainer.start/1` call and need no setup code in your tests.
+
+## `DOCKER_HOST`
+
+How `testcontainer` reaches the daemon.
+
+| Value | Behaviour |
+|--------------------------------|------------------------------------|
+| _(unset)_ | `unix:///var/run/docker.sock` |
+| `unix:///path/to/docker.sock` | Unix domain socket at that path |
+| `tcp://host:port` | Plain TCP HTTP/1.1 (no TLS) |
+
+For Docker Desktop on macOS (Colima, Rancher Desktop, OrbStackβ¦) the
+default Unix path usually works because Docker Desktop forwards a
+Unix socket into your $HOME. If your setup is non-standard,
+point `DOCKER_HOST` at the socket file directly.
+
+For remote CI runners hosting Docker on a TCP endpoint, use the
+`tcp://...` form. TLS is not handled in 0.1 - terminate it upstream
+or stick with Unix sockets.
+
+## `TESTCONTAINERS_KEEP`
+
+`true` to leave the container running for inspection after the test
+finishes (useful when something is failing and you want to `docker
+logs` / `docker exec` it). `false` (default) means stop+remove on
+test exit.
+
+## `TESTCONTAINERS_PULL_POLICY`
+
+When `start/1` should pull the image:
+
+- `missing` (default) - pull only if not present locally
+- `always` - pull every time
+- `never` - fail with a clear `ImagePullFailed` if missing locally
+ (great for hermetic CI: pre-pull images, then refuse network)
+
+## `TESTCONTAINERS_HOST_OVERRIDE`
+
+Hostname/IP your test runner should use to reach mapped ports.
+Default is `127.0.0.1`, which works for Docker Desktop / Colima /
+plain Linux Docker. Set this when the daemon lives somewhere else
+(remote host, separate VM):
+
+```sh
+export TESTCONTAINERS_HOST_OVERRIDE=ci-docker.internal
+```
+
+`Container.host/1` and `Container.mapped_url/3` will return that
+host instead of `127.0.0.1`.
+
+## `TESTCONTAINERS_REGISTRY_USER` / `TESTCONTAINERS_REGISTRY_PASSWORD`
+
+Credentials sent as `X-Registry-Auth` on `POST /images/create` for
+private images. Both must be set together; the password is wrapped
+in a `cowl.Secret` internally so it doesn't leak through
+`string.inspect` / logs.
+
+```sh
+export TESTCONTAINERS_REGISTRY_USER=ci-bot
+export TESTCONTAINERS_REGISTRY_PASSWORD="$REGISTRY_TOKEN"
+```
+
+If the variables are unset, no auth header is sent and pulls go
+through unauthenticated.
+
+## Secrets
+
+Env vars set on the container via `container.with_env/3` or
+`container.with_envs/2` are wrapped in `cowl.Secret` automatically.
+They never appear in `string.inspect(spec)` and are only revealed
+when serialised to the Docker API at create time. There's a unit
+test (`with_env_does_not_leak_value_in_inspect_test`) that pins this
+behaviour.
diff --git a/docs/formulas.md b/docs/formulas.md
new file mode 100644
index 0000000..3bd3e3f
--- /dev/null
+++ b/docs/formulas.md
@@ -0,0 +1,185 @@
+# Formulas: your container's shipping documents
+
+> _"In dogana non passi col container nudo: passi con i documenti."_
+>
+> _"At customs you don't pass with a naked container: you pass with the
+> paperwork."_
+
+A **Formula** is your container's **bill of lading**: a pre-packaged
+spec, with label, customs declaration and typed receipt. Instead of
+writing env vars, ports, wait strategy and then rebuilding the
+connection URL by hand every time, a formula hands it to you already
+compiled, signed and stamped.
+
+In less romantic terms: a `Formula(output)` pairs a `ContainerSpec`
+with a typed extraction function. When the core starts the container,
+it calls the extractor and returns a value of a specific type (e.g.
+`PostgresContainer` with `connection_url`, `host`, `port`, `database`,
+`username` already filled in) instead of a generic `Container`.
+
+```gleam
+use pg <- testcontainer.with_formula(
+ postgres.new()
+ |> postgres.with_database("myapp_test")
+ |> postgres.with_password("secret")
+ |> postgres.formula(),
+)
+
+// pg.connection_url
+// "postgresql://postgres:secret@127.0.0.1:54321/myapp_test"
+```
+
+## Why call them "formulas"
+
+The term evokes alchemists more than customs offices. The key point is
+that a Formula is **prescriptive**: it says "this is the official
+recipe for serving Postgres reliably". You use it as a base and add
+the overrides you need. All the bureaucracy (right env vars, wait
+strategy that actually works, healthcheck, URL composer) lives inside
+the formula. You sign it.
+
+## Where they live
+
+The core package (`testcontainer`) **knows nothing** about Postgres,
+Redis or Kafka. It only defines the `Formula(output)` type and the
+`with_formula` entry point. The actual formulas live in a separate
+package:
+
+```sh
+gleam add testcontainer_formulas
+```
+
+```gleam
+import testcontainer_formulas/postgres
+import testcontainer_formulas/redis
+```
+
+## The three levels of customization
+
+### Level 1: Builder (common case)
+
+Sensible defaults, override only what changes:
+
+```gleam
+postgres.new()
+|> postgres.with_database("myapp_test")
+|> postgres.with_username("app")
+|> postgres.with_password("secret")
+|> postgres.formula()
+```
+
+### Level 2: Custom image
+
+Swap only the image, keep everything else:
+
+```gleam
+postgres.new()
+|> postgres.with_image("registry.mycompany.com/postgres:hardened-16")
+|> postgres.formula()
+```
+
+### Level 3: No formula
+
+When you're overriding everything, it's more honest to start from
+`container.new`. No formula, no rich type, just `Container`:
+
+```gleam
+let spec =
+ container.new("bitnami/postgresql:16")
+ |> container.expose_port(port.tcp(5432))
+ |> container.with_env("POSTGRESQL_PASSWORD", "secret")
+ |> container.wait_for(wait.port(5432))
+
+use c <- testcontainer.with_container(spec)
+// build the URL yourself
+```
+
+## Writing a formula
+
+A formula is a single Gleam file. The skeleton:
+
+```gleam
+import cowl
+import gleam/option.{type Option, None, Some}
+import gleam/result
+
+import testcontainer/container
+import testcontainer/formula
+import testcontainer/network
+import testcontainer/port
+import testcontainer/wait
+
+pub type FooContainer {
+ FooContainer(container: container.Container, url: String, ...)
+}
+
+pub opaque type FooConfig {
+ FooConfig(image: String, ...)
+}
+
+pub fn new() -> FooConfig { ... }
+pub fn with_version(c: FooConfig, v: String) -> FooConfig { ... }
+// ...other builders...
+
+pub fn formula(c: FooConfig) -> formula.Formula(FooContainer) {
+ let spec =
+ container.new(c.image)
+ |> container.expose_port(port.tcp(c.port))
+ |> container.wait_for(wait.log("ready"))
+
+ formula.new(spec, fn(running) {
+ use p <- result.try(container.host_port(running, port.tcp(c.port)))
+ Ok(FooContainer(
+ container: running,
+ url: "foo://" <> container.host(running) <> ":" <> int.to_string(p),
+ ))
+ })
+}
+```
+
+All you need is `formula.new(spec, extract)`. The core does the rest.
+
+## Available formulas
+
+- `testcontainer_formulas/postgres`
+- `testcontainer_formulas/redis`
+- `testcontainer_formulas/mysql`
+- `testcontainer_formulas/rabbitmq`
+- `testcontainer_formulas/mongo`
+
+## Formula Builder (Astro)
+
+A visual builder lives in `formula-builder/` for composing advanced
+blocks and generating ready-to-paste Gleam snippets.
+
+Main features:
+
+- built-in templates for existing formulas (`postgres`, `redis`,
+ `mysql`, `rabbitmq`, `mongo`)
+- `Custom formula module` block for new services (e.g. Kafka) with
+ configurable import, alias and constructor
+- `Formula` mode (typed output) and `Container` mode (full control)
+- per-block configuration for image, env, labels, wait strategy,
+ script, entrypoint, exposed ports and custom pipeline
+- Docker Hub public image checker (tag existence) with fallback link
+ to the Docker Hub tag page when the API is unreachable from the
+ browser
+- explicit Vim mode (button toggle or `Ctrl+G`), with input
+ protection: `Esc` exits the text field
+
+Local run:
+
+```sh
+cd formula-builder
+npm install
+npm run dev
+```
+
+Static build:
+
+```sh
+npm run build
+```
+
+Coming: kafka, localstack, elasticsearch.
+See the [roadmap](../ROADMAP.md).
diff --git a/docs/networks-and-stacks.md b/docs/networks-and-stacks.md
new file mode 100644
index 0000000..a3bcc03
--- /dev/null
+++ b/docs/networks-and-stacks.md
@@ -0,0 +1,108 @@
+# Networks & Stacks
+
+When two containers need to talk - your app under test plus its
+database, your producer plus its broker - they have to share a
+Docker bridge network. `testcontainer` gives you two primitives:
+**`with_network`** for the simple case and **`Stack`** for the typed
+multi-container case.
+
+## `with_network` - single bridge
+
+```gleam
+import testcontainer
+import testcontainer/container
+import testcontainer/network
+
+use net <- testcontainer.with_network("test-net")
+
+use server <- testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_name("server")
+ |> container.on_network(network.name(net))
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+)
+
+use client <- testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.on_network(network.name(net))
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+)
+
+// "server" is reachable from "client" by name on `net`
+testcontainer.exec(client, ["ping", "-c", "1", "server"])
+```
+
+Cleanup ordering: each `with_container` cleans its container before
+its `use` returns; `with_network` removes the network last.
+
+## `Stack` - typed multi-container
+
+`Stack(output)` adds a typed network builder that survives across
+containers. The recommended pattern is to let the stack provide the
+network and nest `with_container` / `with_formula` calls inside the
+`with_stack` body:
+
+```gleam
+use net <- testcontainer.with_stack(
+ testcontainer.stack("app-test-net", fn(n) { Ok(n) }),
+)
+
+use pg <- testcontainer.with_formula(
+ postgres.new()
+ |> postgres.on_network(net)
+ |> postgres.formula(),
+)
+
+use cache <- testcontainer.with_formula(
+ redis.new()
+ |> redis.on_network(net)
+ |> redis.formula(),
+)
+
+// pg.connection_url, cache.url usable here
+```
+
+## Stack riutilizzabile
+
+Wrap the whole pattern once and call it from every test:
+
+```gleam
+import testcontainer
+import testcontainer/error
+import testcontainer_formulas/postgres
+import testcontainer_formulas/redis
+
+pub fn full_stack(
+ body: fn(postgres.PostgresContainer, redis.RedisContainer)
+ -> Result(a, error.Error),
+) -> Result(a, error.Error) {
+ use net <- testcontainer.with_stack(
+ testcontainer.stack("test-net", fn(n) { Ok(n) }),
+ )
+ use pg <- testcontainer.with_formula(
+ postgres.new() |> postgres.on_network(net) |> postgres.formula(),
+ )
+ use cache <- testcontainer.with_formula(
+ redis.new() |> redis.on_network(net) |> redis.formula(),
+ )
+ body(pg, cache)
+}
+
+pub fn user_signup_test() {
+ use pg, cache <- full_stack()
+ // ...
+}
+```
+
+## β οΈ Footgun: the typed-output stack
+
+`Stack(output)` is parametric so the build function can return any
+`output`. **Don't return live `Container` handles** from there: those
+are managed by their own `with_container` / `with_formula` guards
+which clean them up before the build function returns. Keep the
+build function returning `Network` (or a static record derived from
+it) and nest the lifecycle inside the `with_stack` body.
+
+If you genuinely need to start something inside `run` and keep it
+alive, call `testcontainer.start/1` directly - and accept that you
+own the manual teardown.
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 0000000..e06b55e
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,97 @@
+# Quickstart
+
+5-minute tour. By the end of this page, you'll start a real container
+in a test, talk to it, and let `testcontainer` clean it up for you.
+
+## Prerequisites
+
+- Gleam β₯ 1.1
+- A running local Docker daemon (Docker Desktop, Colima, OrbStack,
+ plain `dockerd` on Linux - all fine)
+
+## Install
+
+```sh
+gleam add testcontainer
+```
+
+## Hello, Redis
+
+```gleam
+import gleam/int
+import testcontainer
+import testcontainer/container
+import testcontainer/port
+import testcontainer/wait
+
+pub fn redis_test() {
+ use redis <- testcontainer.with_container(
+ container.new("redis:7-alpine")
+ |> container.expose_port(port.tcp(6379))
+ |> container.wait_for(wait.log("Ready to accept connections")),
+ )
+
+ let assert Ok(host_port) = container.host_port(redis, port.tcp(6379))
+ let _url = "redis://127.0.0.1:" <> int.to_string(host_port)
+ // hand `_url` to your Redis client
+ Ok(Nil)
+}
+```
+
+What just happened:
+
+1. `container.new` builds an immutable spec.
+2. `with_container` pulls the image (if missing), starts the
+ container, polls the wait strategy until it succeeds, then runs
+ your body.
+3. The library spawns a **linked guard process**. If your test
+ panics or the BEAM kills the parent, the guard stops & removes the
+ container. No dangling resources, no Ryuk, no shell scripts.
+4. When your body returns, the container is stopped and removed before
+ `with_container` hands the result back.
+
+## A quick exec
+
+```gleam
+use c <- testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+)
+
+use result <- result.try(testcontainer.exec(c, ["uname", "-a"]))
+io.println(result.stdout)
+Ok(Nil)
+```
+
+`exec` returns `ExecResult` with `exit_code`, `stdout`, `stderr` -
+stderr is split out for you (Docker streams them multiplexed; the
+library demuxes).
+
+## Custom error types
+
+Most projects already have an `AppError`. Don't fight it:
+
+```gleam
+import testcontainer/error
+
+type AppError {
+ Container(error.Error)
+ AppLogic(String)
+}
+
+use c <- testcontainer.with_container_mapped(
+ spec,
+ fn(e) { Container(e) },
+)
+// body returns Result(_, AppError)
+```
+
+## What's next
+
+- [Wait strategies](wait-strategies.md) - `log`, `port`, `http`,
+ `command`, `health_check`, `all_of`, `any_of`
+- [Formule](formule.md) - typed builders for Postgres, Redis & more
+- [Networks & Stacks](networks-and-stacks.md) - multiple containers
+ on a shared bridge
+- [Configuration](configuration.md) - `DOCKER_HOST`,
+ `TESTCONTAINERS_KEEP`, registry auth
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..096f875
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,111 @@
+# Troubleshooting
+
+Common failure modes and how to read them.
+
+## `DockerUnavailable("/var/run/docker.sock", reason)`
+
+The daemon is not reachable.
+
+- Is Docker actually running? `docker version` from the same shell.
+- On macOS, Docker Desktop forwards a Unix socket to `$HOME`.
+ If your `DOCKER_HOST` is unset, the library defaults to
+ `/var/run/docker.sock`. Symlink or set `DOCKER_HOST` explicitly:
+
+ ```sh
+ export DOCKER_HOST=unix://$HOME/.docker/run/docker.sock
+ ```
+
+- TCP daemons: confirm `tcp://...` URL and that no proxy intercepts.
+
+## `ImagePullFailed(image, "TESTCONTAINERS_PULL_POLICY=never and image not present locally")`
+
+Self-explanatory: with `pull_policy=never`, the image must already be
+in the local cache. Either pre-pull (`docker pull alpine:3.18`) or
+relax the policy.
+
+## `ImagePullFailed(image, reason)` mid-stream
+
+Docker returns HTTP 200 even when an image pull fails halfway -
+auth, network, manifest mismatch. The library scans the streamed
+JSON body for `errorDetail` / `error` and surfaces the first message
+it finds. Common causes:
+
+- Private image without `TESTCONTAINERS_REGISTRY_USER` /
+ `TESTCONTAINERS_REGISTRY_PASSWORD` set.
+- Rate limit (`toomanyrequests`) - log in to Docker Hub.
+- Manifest unknown - typo in tag.
+
+## `ContainerStartFailed`
+
+The container was created but Docker refused to start it. Causes:
+
+- Port already in use (when you set `HostPort` explicitly - the
+ default is dynamic, so this rarely happens).
+- Volume bind path doesn't exist on the host.
+- Image entrypoint crashed immediately.
+
+`docker logs ` (use `TESTCONTAINERS_KEEP=true` so the container
+sticks around) usually clarifies.
+
+## `InvalidPort(number)`
+
+The port is outside `1..=65535`.
+
+- `port.try_tcp/1` and `port.try_udp/1` return this immediately.
+- `port.tcp/1` / `port.udp/1` defer validation to startup; `start/1`
+ returns `InvalidPort(number)` before create/start calls continue.
+
+## `WaitTimedOut(strategy, elapsed_ms)`
+
+Your wait strategy didn't succeed within its configured timeout.
+`elapsed_ms` is real wall-clock time. Tactics:
+
+- Bump the timeout: `wait.with_timeout(120_000)`.
+- Add a second probe via `wait.all_of([port, log])` - sometimes the
+ port opens before the app is really ready.
+- Inspect with `TESTCONTAINERS_KEEP=true` and `docker logs ` -
+ the log line you're matching may not be exactly what you expect.
+- Health-check images: `wait.health_check()` only terminates if the
+ image has a `HEALTHCHECK` defined.
+
+## `WaitFailed(strategy, reason)`
+
+Same family as `WaitTimedOut`, but the strategy reported a reason
+that's not just "no signal yet" (e.g. HTTP returned a clearly wrong
+status repeatedly). The `reason` field carries the latest probe
+output.
+
+## `ExecFailed(id, cmd, exit_code, stderr)`
+
+The Docker API call to start exec returned an error, OR the exit
+code couldn't be parsed. Note: a non-zero exit from your command is
+**not** an error - you get `Ok(ExecResult(exit_code: N, ...))`.
+`ExecFailed` is reserved for "Docker said no".
+
+## `PortMappingParseFailed(container_id, reason)`
+
+`docker inspect` succeeded, but `NetworkSettings.Ports` was not in the
+expected format. This is treated as a hard error (no silent fallback),
+so mapped-port calls don't fail later with misleading `PortNotMapped`.
+
+## My test passed but a container is still running
+
+This shouldn't happen with `with_container`. If it does:
+
+- Check that you're using `with_container` (or `with_formula`), not
+ bare `start/1`.
+- Check `TESTCONTAINERS_KEEP` is not set to `true` in your shell.
+- The crash-cleanup is **fire-and-forget** by design. If the BEAM
+ itself exits abruptly (kernel-level crash, `kill -9`), pending
+ cleanups don't run. The next `with_container` call with the same
+ `with_name(...)` will conflict - remove manually with
+ `docker rm -f `.
+
+## Docker Desktop is slow on first pull
+
+The very first `with_container` after a reboot can take longer than
+the default 60 s wait timeout while Docker pulls the image and warms
+up. Either:
+
+- Pre-pull images in CI, then run with `pull_policy=never`.
+- Bump the strategy timeout for the initial test.
diff --git a/docs/wait-strategies.md b/docs/wait-strategies.md
new file mode 100644
index 0000000..3dfe412
--- /dev/null
+++ b/docs/wait-strategies.md
@@ -0,0 +1,108 @@
+# Wait strategies
+
+A container is "started" the moment Docker says so, but it's almost
+never **ready** at that point. A wait strategy is what `with_container`
+polls until your container is genuinely usable.
+
+The default for a fresh `container.new(...)` is `wait.none()` - no
+wait. For real services you almost always want a real probe.
+
+## The basics
+
+```gleam
+import testcontainer/wait
+
+container.new("redis:7-alpine")
+|> container.wait_for(wait.log("Ready to accept connections"))
+```
+
+`with_container` starts the container, then polls the strategy at the
+configured interval (default 1 s) until it succeeds or the timeout
+(default 60 s) elapses. On timeout it returns
+`Error(WaitTimedOut(strategy_description, elapsed_ms))` - `elapsed_ms`
+is the actual wall-clock time, not the configured timeout.
+
+## All the strategies
+
+### `wait.none()`
+
+Succeeds immediately. The default.
+
+### `wait.log(message)` / `wait.log_times(message, n)`
+
+Reads the container's combined stdout/stderr stream and counts how
+many times `message` appears. Useful when an image prints a clear
+"ready" line:
+
+```gleam
+wait.log("database system is ready to accept connections")
+wait.log_times("listening on port", 2)
+```
+
+### `wait.port(int)`
+
+TCP-connects to the host-mapped port. The simplest "is it listening?"
+probe.
+
+```gleam
+container.new("nginx:alpine")
+|> container.expose_port(port.tcp(80))
+|> container.wait_for(wait.port(80))
+```
+
+### `wait.http(port, path)` / `wait.http_with_status(port, path, status)`
+
+GETs `path` on the host-mapped port and checks the HTTP status.
+
+```gleam
+wait.http(8080, "/health")
+wait.http_with_status(8080, "/health", 204)
+```
+
+### `wait.health_check()`
+
+Reads `Docker inspect` and waits for `State.Health.Status == "healthy"`.
+The image must define a `HEALTHCHECK` for this to ever terminate.
+
+### `wait.command(cmd)`
+
+Runs a command inside the container via `docker exec` and waits for
+exit 0. Great when no external probe is exposed:
+
+```gleam
+wait.command(["pg_isready", "-U", "postgres"])
+```
+
+### `wait.all_of([...])` / `wait.any_of([...])`
+
+Compose strategies. `all_of` succeeds when every inner strategy
+succeeds (per poll cycle); `any_of` succeeds as soon as one does.
+
+```gleam
+wait.all_of([
+ wait.port(5432),
+ wait.log("database system is ready"),
+])
+```
+
+## Tuning
+
+```gleam
+wait.log("ready")
+|> wait.with_timeout(30_000)
+|> wait.with_poll_interval(500)
+```
+
+Both modifiers return a new `WaitStrategy`. Defaults are 60 s timeout
+and 1 s poll.
+
+## Tips
+
+- Prefer **`log`** when the image prints a clean readiness line - it's
+ the cheapest and most reliable probe.
+- Prefer **`http`** when there's a `/health` endpoint - it actually
+ exercises the server's I/O loop.
+- Use **`all_of`** when both port-open and a log line are independent
+ signals; it catches more flaky-startup bugs than either alone.
+- Use **`command`** for processes that are healthy purely from inside
+ (cron-style daemons, queues without an external port).
diff --git a/gleam.toml b/gleam.toml
new file mode 100644
index 0000000..c738920
--- /dev/null
+++ b/gleam.toml
@@ -0,0 +1,18 @@
+name = "testcontainer"
+version = "0.1.0"
+description = "Testcontainers-style library for Gleam - start Docker containers in tests with automatic cleanup."
+licences = ["MIT"]
+repository = { type = "github", user = "lupodevelop", repo = "testcontiner" }
+gleam = ">= 1.1.0"
+internal_modules = ["testcontainer/internal/*"]
+
+[dependencies]
+gleam_stdlib = ">= 0.44.0 and < 2.0.0"
+envie = ">= 1.0.0 and < 2.0.0"
+cowl = ">= 1.0.0 and < 2.0.0"
+gleam_json = ">= 3.1.0 and < 4.0.0"
+gleam_erlang = ">= 1.3.0 and < 2.0.0"
+
+[dev-dependencies]
+gleeunit = ">= 1.0.0 and < 2.0.0"
+woof = ">= 1.6.0 and < 2.0.0"
diff --git a/manifest.toml b/manifest.toml
new file mode 100644
index 0000000..5572470
--- /dev/null
+++ b/manifest.toml
@@ -0,0 +1,21 @@
+# This file was generated by Gleam
+# You typically do not need to edit this file
+
+packages = [
+ { name = "cowl", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "cowl", source = "hex", outer_checksum = "7849E7C789D7228243A4253138FC883720A0BB44AEF406102328CADC64C3CA2B" },
+ { name = "envie", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envie", source = "hex", outer_checksum = "0BE513C71111E259B77D671DE5006F3A8B16102F1CC070D147E6F8AFF9714BD8" },
+ { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
+ { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
+ { name = "gleam_stdlib", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "960090C2FB391784BB34267B099DC9315CC1B1F6013E7415BC763CEF1905D7D3" },
+ { name = "gleeunit", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "254B697FE72EEAD7BF82E941723918E421317813AC49923EE76A18C788C61E72" },
+ { name = "woof", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "woof", source = "hex", outer_checksum = "170B32BC4C0895F2E5669428A029261D75FE4A8FE4F2B043C31FEEDF88D8875E" },
+]
+
+[requirements]
+cowl = { version = ">= 1.0.0 and < 2.0.0" }
+envie = { version = ">= 1.0.0 and < 2.0.0" }
+gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" }
+gleam_json = { version = ">= 3.1.0 and < 4.0.0" }
+gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
+gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+woof = { version = ">= 1.6.0 and < 2.0.0" }
diff --git a/rebar.config b/rebar.config
new file mode 100644
index 0000000..0f5d40e
--- /dev/null
+++ b/rebar.config
@@ -0,0 +1 @@
+{erl_opts, [debug_info]}.
diff --git a/src/testcontainer.gleam b/src/testcontainer.gleam
new file mode 100644
index 0000000..66edde6
--- /dev/null
+++ b/src/testcontainer.gleam
@@ -0,0 +1,468 @@
+import gleam/dict
+import gleam/dynamic/decode
+import gleam/erlang/process
+import gleam/int
+import gleam/json
+import gleam/list
+import gleam/option.{type Option, None, Some}
+import gleam/result
+import gleam/string
+
+import testcontainer/container
+import testcontainer/error
+import testcontainer/exec
+import testcontainer/formula
+import testcontainer/internal/config
+import testcontainer/internal/docker
+import testcontainer/internal/wait_runner
+import testcontainer/network
+import testcontainer/stack as stack_mod
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/// Starts a container described by `spec` and returns it.
+/// The caller is responsible for calling `stop/1` when done.
+/// For automatic cleanup, prefer `with_container/2`.
+pub fn start(
+ spec: container.ContainerSpec,
+) -> Result(container.Container, error.Error) {
+ start_internal(spec)
+}
+
+fn start_internal(
+ spec: container.ContainerSpec,
+) -> Result(container.Container, error.Error) {
+ let cfg = config.load()
+ let keep = cfg.keep_containers
+ let stop_timeout = cfg.stop_timeout_sec
+ let gateway = resolve_gateway(cfg.host_override)
+
+ use _ <- result.try(docker.ping())
+
+ let image = container.image(spec)
+ use _ <- result.try(case cfg.pull_policy {
+ config.Never ->
+ case docker.image_exists(image) {
+ True -> Ok(Nil)
+ False ->
+ Error(error.ImagePullFailed(
+ image,
+ "TESTCONTAINERS_PULL_POLICY=never and image not present locally",
+ ))
+ }
+ config.IfMissing ->
+ case docker.image_exists(image) {
+ True -> Ok(Nil)
+ False -> docker.pull_image(image, cfg.registry_auth)
+ }
+ config.Always -> docker.pull_image(image, cfg.registry_auth)
+ })
+
+ use id <- result.try(docker.create_container(spec))
+
+ use _ <- result.try(
+ docker.start_container(id)
+ |> result.map_error(fn(e) {
+ let _ = docker.remove_container(id)
+ e
+ }),
+ )
+
+ let strategy = container.wait_strategy(spec)
+ use _ <- result.try(
+ wait_runner.run(strategy, id, gateway)
+ |> result.map_error(fn(e) {
+ // Container started but wait failed - clean it up before propagating.
+ let _ = docker.stop_container(id, stop_timeout)
+ let _ = docker.remove_container(id)
+ e
+ }),
+ )
+
+ use inspect <- result.try(
+ docker.inspect_container(id)
+ |> result.map_error(fn(e) {
+ let _ = docker.stop_container(id, stop_timeout)
+ let _ = docker.remove_container(id)
+ e
+ }),
+ )
+ use ports <- result.try(
+ parse_port_mapping(id, inspect)
+ |> result.map_error(fn(e) {
+ let _ = docker.stop_container(id, stop_timeout)
+ let _ = docker.remove_container(id)
+ e
+ }),
+ )
+ Ok(container.build(id, gateway, ports, keep, stop_timeout))
+}
+
+/// Stops and removes a container.
+/// When `TESTCONTAINERS_KEEP` is set (or the container was started via
+/// `start_and_keep/1`) the container is left running for manual
+/// inspection. Use `force_stop/1` to tear it down regardless.
+pub fn stop(c: container.Container) -> Result(Nil, error.Error) {
+ case container.keep(c) {
+ True -> Ok(Nil)
+ False -> force_stop(c)
+ }
+}
+
+/// Stops and removes a container ignoring the keep flag. Useful to
+/// programmatically tear down containers that were started with
+/// `start_and_keep/1` or under `TESTCONTAINERS_KEEP=true`.
+pub fn force_stop(c: container.Container) -> Result(Nil, error.Error) {
+ let id = container.id(c)
+ use _ <- result.try(docker.stop_container(id, container.stop_timeout_sec(c)))
+ docker.remove_container(id)
+}
+
+/// Starts a container, runs `body/1`, then stops and removes it.
+/// A linked guard process ensures cleanup also runs if the caller crashes.
+///
+/// use c <- testcontainer.with_container(spec)
+///
+pub fn with_container(
+ spec: container.ContainerSpec,
+ body: fn(container.Container) -> Result(a, error.Error),
+) -> Result(a, error.Error) {
+ use #(c, guard) <- result.try(start_guarded(spec))
+ let body_result = body(c)
+ combine(body_result, cleanup(c, guard))
+}
+
+/// Like `with_container/2` but maps the library's error type into a custom
+/// error type before propagating.
+///
+/// use c <- testcontainer.with_container_mapped(
+/// spec,
+/// fn(e) { MyError.Container(e) },
+/// )
+///
+pub fn with_container_mapped(
+ spec: container.ContainerSpec,
+ map_error: fn(error.Error) -> e,
+ body: fn(container.Container) -> Result(a, e),
+) -> Result(a, e) {
+ use #(started, guard) <- result.try(
+ start_guarded(spec) |> result.map_error(map_error),
+ )
+ let body_result = body(started)
+ combine(body_result, cleanup(started, guard) |> result.map_error(map_error))
+}
+
+/// Creates a Docker network, runs `body/1`, then removes it. Cleanup runs
+/// even if `body/1` returns an error. Re-export of `network.with_network/2`
+/// for one-import ergonomics.
+///
+/// use net <- testcontainer.with_network("test-net")
+///
+pub fn with_network(
+ name: String,
+ body: fn(network.Network) -> Result(a, error.Error),
+) -> Result(a, error.Error) {
+ network.with_network(name, body)
+}
+
+/// Builds a `Stack(output)` - a network plus a typed multi-container build
+/// function. See `with_stack/2`.
+pub fn stack(
+ network_name: String,
+ build: fn(network.Network) -> Result(output, error.Error),
+) -> stack_mod.Stack(output) {
+ stack_mod.new(network_name, build)
+}
+
+/// Creates the stack's network, runs the stack's build function to produce
+/// a typed `output`, then runs `body/1` against that output. The network is
+/// torn down after `body/1` returns. The recommended pattern is to let the
+/// stack provide a `Network` and nest `with_container` / `with_formula`
+/// calls inside `body`, so each container is cleaned up by its own guard
+/// before the network is removed:
+///
+/// use net <- testcontainer.with_stack(
+/// testcontainer.stack("app-test-net", fn(n) { Ok(n) }),
+/// )
+/// use pg <- testcontainer.with_formula(
+/// postgres.new() |> postgres.on_network(net) |> postgres.formula(),
+/// )
+/// // ...
+///
+/// See `testcontainer/stack.{Stack}` for notes on advanced builders.
+pub fn with_stack(
+ s: stack_mod.Stack(output),
+ body: fn(output) -> Result(a, error.Error),
+) -> Result(a, error.Error) {
+ network.with_network(stack_mod.name(s), fn(net) {
+ use out <- result.try(stack_mod.run(s, net))
+ body(out)
+ })
+}
+
+/// Executes a command inside a running container.
+pub fn exec(
+ c: container.Container,
+ cmd: List(String),
+) -> Result(exec.ExecResult, error.Error) {
+ docker.exec_container(container.id(c), cmd)
+}
+
+/// Returns the combined stdout+stderr log output of a running container
+/// (full log).
+pub fn logs(c: container.Container) -> Result(String, error.Error) {
+ docker.container_logs(container.id(c), None)
+}
+
+/// Like `logs/1` but returns only the last `n` lines.
+pub fn logs_tail(
+ c: container.Container,
+ n: Int,
+) -> Result(String, error.Error) {
+ docker.container_logs(container.id(c), Some(n))
+}
+
+/// Copies a file from the host filesystem into a running container.
+/// `host_path` must be an absolute path to a readable file on the host.
+/// `container_path` is the absolute destination path inside the container.
+///
+/// use _ <- result.try(testcontainer.copy_file_to(c, "/host/init.sql", "/tmp/init.sql"))
+///
+pub fn copy_file_to(
+ c: container.Container,
+ host_path: String,
+ container_path: String,
+) -> Result(Nil, error.Error) {
+ docker.copy_file_to(container.id(c), host_path, container_path)
+}
+
+/// Starts a container using a `Formula`, extracts the typed output, then
+/// calls `body/1`. Lifecycle and cleanup work exactly like `with_container/2`.
+///
+/// use pg <- testcontainer.with_formula(postgres.formula(config))
+/// // pg :: PostgresContainer
+///
+pub fn with_formula(
+ f: formula.Formula(output),
+ body: fn(output) -> Result(a, error.Error),
+) -> Result(a, error.Error) {
+ use #(c, guard) <- result.try(start_guarded(formula.spec(f)))
+ let body_result = result.try(formula.extract(f, c), body)
+ combine(body_result, cleanup(c, guard))
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle helpers
+// ---------------------------------------------------------------------------
+
+// Send GuardStop and synchronously stop the container.
+fn cleanup(
+ c: container.Container,
+ guard: process.Subject(GuardEvent),
+) -> Result(Nil, error.Error) {
+ process.send(guard, GuardStop)
+ stop(c)
+}
+
+// Body's outcome wins. If body succeeded but cleanup failed, surface the
+// cleanup failure so the caller knows the container was leaked.
+fn combine(body: Result(a, e), cleanup: Result(Nil, e)) -> Result(a, e) {
+ case body, cleanup {
+ Ok(_), Error(e) -> Error(e)
+ _, _ -> body
+ }
+}
+
+/// Starts a container and forces the keep flag, so it will NOT be removed
+/// by `stop/1` even if `TESTCONTAINERS_KEEP=false`. Useful for inspection
+/// and debugging from a REPL.
+pub fn start_and_keep(
+ spec: container.ContainerSpec,
+) -> Result(container.Container, error.Error) {
+ use #(c, guard) <- result.try(start_guarded(spec))
+ process.send(guard, GuardStop)
+ Ok(container.with_keep(c, True))
+}
+
+// ---------------------------------------------------------------------------
+// Guard process
+// ---------------------------------------------------------------------------
+//
+// GuardStop - parent finished normally; guard exits, no cleanup needed.
+// ParentDown - parent crashed; guard stops and removes the container.
+
+type GuardEvent {
+ GuardStop
+ ParentDown
+}
+
+fn start_guarded(
+ spec: container.ContainerSpec,
+) -> Result(#(container.Container, process.Subject(GuardEvent)), error.Error) {
+ let startup_subject: process.Subject(
+ Result(#(container.Container, process.Subject(GuardEvent)), error.Error),
+ ) = process.new_subject()
+
+ // process.spawn/1 from gleam_erlang is `proc_lib:spawn_link/1` - the link
+ // to the caller is established atomically with the spawn, so the guard is
+ // notified of any caller crash even during startup.
+ process.spawn(fn() {
+ process.trap_exits(True)
+ let guard = process.new_subject()
+ case start_internal(spec) {
+ Ok(c) -> {
+ process.send(startup_subject, Ok(#(c, guard)))
+ guard_loop(
+ container.id(c),
+ guard,
+ container.keep(c),
+ container.stop_timeout_sec(c),
+ )
+ }
+ Error(e) -> process.send(startup_subject, Error(e))
+ }
+ })
+ let selector = process.new_selector() |> process.select(startup_subject)
+ process.selector_receive_forever(selector)
+}
+
+fn guard_loop(
+ id: String,
+ subject: process.Subject(GuardEvent),
+ keep: Bool,
+ stop_timeout: Int,
+) -> Nil {
+ let selector =
+ process.new_selector()
+ |> process.select(subject)
+ |> process.select_trapped_exits(fn(_) { ParentDown })
+ case process.selector_receive_forever(selector) {
+ GuardStop -> Nil
+ ParentDown ->
+ case keep {
+ True -> Nil
+ False -> {
+ // Fire-and-forget: guard exits immediately; cleanup runs async.
+ // The transport layer enforces per-call timeouts (connect 5 s, recv 30 s).
+ process.spawn(fn() {
+ let _ = docker.stop_container(id, stop_timeout)
+ let _ = docker.remove_container(id)
+ Nil
+ })
+ Nil
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+// The "Gateway" field from inspect is the bridge IP - unreachable from the
+// host on macOS / WSL2 Docker Desktop. Mapped ports are bound to 127.0.0.1
+// on every supported platform, so localhost is the safe default. Users on
+// remote/CI Docker hosts can override with TESTCONTAINERS_HOST_OVERRIDE.
+fn resolve_gateway(host_override: Option(String)) -> String {
+ case host_override {
+ Some(h) -> h
+ None -> "127.0.0.1"
+ }
+}
+
+fn parse_port_mapping(
+ container_id: String,
+ inspect_json: String,
+) -> Result(dict.Dict(#(Int, String), Int), error.Error) {
+ let ports_decoder =
+ decode.at(
+ ["NetworkSettings", "Ports"],
+ decode.dict(
+ decode.string,
+ decode.optional(decode.list(port_binding_decoder())),
+ ),
+ )
+
+ use decoded <- result.try(
+ json.parse(inspect_json, ports_decoder)
+ |> result.map_error(fn(_) {
+ error.PortMappingParseFailed(
+ container_id,
+ "unable to decode inspect NetworkSettings.Ports payload",
+ )
+ }),
+ )
+ use mappings <- result.try(
+ decoded
+ |> dict.to_list
+ |> list.try_map(entry_to_mapping)
+ |> result.map_error(fn(reason) {
+ error.PortMappingParseFailed(container_id, reason)
+ }),
+ )
+ Ok(dict.from_list(mappings))
+}
+
+fn entry_to_mapping(
+ entry: #(String, Option(List(PortBinding))),
+) -> Result(#(#(Int, String), Int), String) {
+ let #(spec, bindings) = entry
+ use key <- result.try(case parse_port_spec(spec) {
+ Some(parsed) -> Ok(parsed)
+ None -> Error("invalid inspect port key: " <> spec)
+ })
+ use bs <- result.try(case bindings {
+ Some(value) -> Ok(value)
+ None -> Error("no host binding in inspect for port key: " <> spec)
+ })
+ use host_port <- result.try(case pick_binding(bs) {
+ Some(value) -> Ok(value)
+ None -> Error("invalid host port binding in inspect for key: " <> spec)
+ })
+ Ok(#(key, host_port))
+}
+
+// Prefer IPv4 (HostIp "" or "0.0.0.0") over IPv6 (::), then take the first.
+fn pick_binding(bs: List(PortBinding)) -> Option(Int) {
+ list.find(bs, fn(b: PortBinding) { b.host_ip == "0.0.0.0" || b.host_ip == "" })
+ |> result.try_recover(fn(_) { list.first(bs) })
+ |> result.try(fn(b) { int.parse(b.host_port) })
+ |> option_from_result
+}
+
+fn option_from_result(r: Result(a, b)) -> Option(a) {
+ case r {
+ Ok(v) -> Some(v)
+ Error(_) -> None
+ }
+}
+
+type PortBinding {
+ PortBinding(host_ip: String, host_port: String)
+}
+
+fn port_binding_decoder() -> decode.Decoder(PortBinding) {
+ use host_ip <- decode.field("HostIp", decode.string)
+ use host_port <- decode.field("HostPort", decode.string)
+ decode.success(PortBinding(host_ip, host_port))
+}
+
+// Parse a Docker "Ports" key (e.g. "5432/tcp", "53/udp") into
+// (port_number, protocol).
+fn parse_port_spec(port_spec: String) -> Option(#(Int, String)) {
+ case string.split(port_spec, "/") {
+ [p, proto] ->
+ case int.parse(p) {
+ Ok(i) -> Some(#(i, proto))
+ Error(_) -> None
+ }
+ [p] ->
+ case int.parse(p) {
+ Ok(i) -> Some(#(i, "tcp"))
+ Error(_) -> None
+ }
+ _ -> None
+ }
+}
diff --git a/src/testcontainer/container.gleam b/src/testcontainer/container.gleam
new file mode 100644
index 0000000..080c22e
--- /dev/null
+++ b/src/testcontainer/container.gleam
@@ -0,0 +1,331 @@
+import cowl
+
+import gleam/dict
+import gleam/int
+import gleam/list
+import gleam/option.{type Option, None, Some}
+
+import testcontainer/error
+import testcontainer/port
+import testcontainer/wait
+
+/// A volume mount applied to a container. Build with `bind_mount/2`,
+/// `readonly_bind_mount/2`, or `tmpfs/1`.
+pub opaque type Volume {
+ BindMount(host_path: String, container_path: String, read_only: Bool)
+ TmpfsMount(container_path: String)
+}
+
+/// Creates a read-write bind mount from `host_path` to `container_path`.
+pub fn bind_mount(host_path: String, container_path: String) -> Volume {
+ BindMount(host_path, container_path, False)
+}
+
+/// Creates a read-only bind mount.
+pub fn readonly_bind_mount(
+ host_path: String,
+ container_path: String,
+) -> Volume {
+ BindMount(host_path, container_path, True)
+}
+
+/// Creates an in-memory tmpfs mount at `container_path`.
+pub fn tmpfs(container_path: String) -> Volume {
+ TmpfsMount(container_path)
+}
+
+/// Internal - used by `internal/docker.gleam` to project a Volume into the
+/// shape expected by the Docker Engine API. Returns:
+/// - `Ok(Ok(#(host, container, read_only)))` for a bind mount,
+/// - `Ok(Error(container))` for a tmpfs mount.
+@internal
+pub fn volume_kind(v: Volume) -> Result(#(String, String, Bool), String) {
+ case v {
+ BindMount(h, c, ro) -> Ok(#(h, c, ro))
+ TmpfsMount(c) -> Error(c)
+ }
+}
+
+/// Immutable description of a container to be started.
+/// Build it via `new/1` and the `with_*` modifiers, then pass to
+/// `testcontainer.start/1` or `testcontainer.with_container/2`.
+pub opaque type ContainerSpec {
+ ContainerSpec(
+ image: String,
+ env: List(#(String, cowl.Secret(String))),
+ ports: List(port.Port),
+ wait_strategy: wait.WaitStrategy,
+ command: Option(List(String)),
+ entrypoint: Option(List(String)),
+ volumes: List(Volume),
+ network: Option(String),
+ name: Option(String),
+ labels: List(#(String, String)),
+ privileged: Bool,
+ )
+}
+
+/// Creates a new spec for the given image (e.g. `"redis:7-alpine"`).
+/// The default wait strategy is `wait.none/0` (no wait) - most images need
+/// a real `wait_for/2` (e.g. `wait.log("ready")`) to be reliable.
+pub fn new(image: String) -> ContainerSpec {
+ ContainerSpec(
+ image: image,
+ env: [],
+ ports: [],
+ wait_strategy: wait.none(),
+ command: None,
+ entrypoint: None,
+ volumes: [],
+ network: None,
+ name: None,
+ labels: [],
+ privileged: False,
+ )
+}
+
+/// Adds an environment variable. The value is wrapped in a `cowl.Secret`
+/// internally; use `with_secret_env/3` if you already have a Secret.
+pub fn with_env(
+ spec: ContainerSpec,
+ key: String,
+ value: String,
+) -> ContainerSpec {
+ ContainerSpec(
+ ..spec,
+ env: list.append(spec.env, [#(key, cowl.secret(value))]),
+ )
+}
+
+/// Adds multiple environment variables at once.
+pub fn with_envs(
+ spec: ContainerSpec,
+ pairs: List(#(String, String)),
+) -> ContainerSpec {
+ let wrapped = list.map(pairs, fn(p) { #(p.0, cowl.secret(p.1)) })
+ ContainerSpec(..spec, env: list.append(spec.env, wrapped))
+}
+
+/// Adds an environment variable whose value is already a `cowl.Secret`.
+pub fn with_secret_env(
+ spec: ContainerSpec,
+ key: String,
+ value: cowl.Secret(String),
+) -> ContainerSpec {
+ ContainerSpec(..spec, env: list.append(spec.env, [#(key, value)]))
+}
+
+/// Exposes a single container port to the host (host port assigned dynamically).
+pub fn expose_port(spec: ContainerSpec, p: port.Port) -> ContainerSpec {
+ ContainerSpec(..spec, ports: list.append(spec.ports, [p]))
+}
+
+/// Exposes multiple container ports to the host.
+pub fn expose_ports(spec: ContainerSpec, ps: List(port.Port)) -> ContainerSpec {
+ ContainerSpec(..spec, ports: list.append(spec.ports, ps))
+}
+
+/// Sets the readiness wait strategy. The default is "no wait".
+pub fn wait_for(
+ spec: ContainerSpec,
+ strategy: wait.WaitStrategy,
+) -> ContainerSpec {
+ ContainerSpec(..spec, wait_strategy: strategy)
+}
+
+/// Overrides the container's CMD.
+pub fn with_command(spec: ContainerSpec, cmd: List(String)) -> ContainerSpec {
+ ContainerSpec(..spec, command: Some(cmd))
+}
+
+/// Overrides the container's ENTRYPOINT.
+pub fn with_entrypoint(spec: ContainerSpec, ep: List(String)) -> ContainerSpec {
+ ContainerSpec(..spec, entrypoint: Some(ep))
+}
+
+/// Adds a read-write bind mount (host path β container path).
+pub fn with_bind_mount(
+ spec: ContainerSpec,
+ host: String,
+ container_path: String,
+) -> ContainerSpec {
+ with_volume(spec, bind_mount(host, container_path))
+}
+
+/// Adds a read-only bind mount.
+pub fn with_readonly_bind(
+ spec: ContainerSpec,
+ host: String,
+ container_path: String,
+) -> ContainerSpec {
+ with_volume(spec, readonly_bind_mount(host, container_path))
+}
+
+/// Mounts an in-memory tmpfs at the given path inside the container.
+pub fn with_tmpfs(spec: ContainerSpec, path: String) -> ContainerSpec {
+ with_volume(spec, tmpfs(path))
+}
+
+/// Adds an arbitrary `Volume` (built with `bind_mount/2`, `tmpfs/1`, β¦).
+pub fn with_volume(spec: ContainerSpec, v: Volume) -> ContainerSpec {
+ ContainerSpec(..spec, volumes: list.append(spec.volumes, [v]))
+}
+
+/// Attaches the container to the named Docker network.
+pub fn on_network(spec: ContainerSpec, network: String) -> ContainerSpec {
+ ContainerSpec(..spec, network: Some(network))
+}
+
+/// Assigns a fixed name to the container (otherwise Docker auto-generates one).
+pub fn with_name(spec: ContainerSpec, n: String) -> ContainerSpec {
+ ContainerSpec(..spec, name: Some(n))
+}
+
+/// Adds a label to the container.
+pub fn with_label(
+ spec: ContainerSpec,
+ key: String,
+ value: String,
+) -> ContainerSpec {
+ ContainerSpec(..spec, labels: list.append(spec.labels, [#(key, value)]))
+}
+
+/// Marks the container as privileged.
+pub fn with_privileged(spec: ContainerSpec) -> ContainerSpec {
+ ContainerSpec(..spec, privileged: True)
+}
+
+// --- ContainerSpec accessors ---
+
+pub fn image(spec: ContainerSpec) -> String {
+ spec.image
+}
+
+pub fn env(spec: ContainerSpec) -> List(#(String, cowl.Secret(String))) {
+ spec.env
+}
+
+pub fn ports(spec: ContainerSpec) -> List(port.Port) {
+ spec.ports
+}
+
+pub fn wait_strategy(spec: ContainerSpec) -> wait.WaitStrategy {
+ spec.wait_strategy
+}
+
+pub fn command(spec: ContainerSpec) -> Option(List(String)) {
+ spec.command
+}
+
+pub fn entrypoint(spec: ContainerSpec) -> Option(List(String)) {
+ spec.entrypoint
+}
+
+pub fn volumes(spec: ContainerSpec) -> List(Volume) {
+ spec.volumes
+}
+
+pub fn network(spec: ContainerSpec) -> Option(String) {
+ spec.network
+}
+
+pub fn name(spec: ContainerSpec) -> Option(String) {
+ spec.name
+}
+
+pub fn labels(spec: ContainerSpec) -> List(#(String, String)) {
+ spec.labels
+}
+
+pub fn is_privileged(spec: ContainerSpec) -> Bool {
+ spec.privileged
+}
+
+// --- Container (running) ---
+
+/// A handle to a running Docker container.
+/// Returned by `testcontainer.start/1`. Carries the container id,
+/// gateway host, port mapping and keep-alive flag.
+///
+/// `port_mapping` is keyed by `(container_port, protocol)` - `protocol` is
+/// always `"tcp"` or `"udp"` - so TCP and UDP ports with the same number
+/// don't collide.
+pub opaque type Container {
+ Container(
+ id: String,
+ gateway_host: String,
+ port_mapping: dict.Dict(#(Int, String), Int),
+ keep: Bool,
+ stop_timeout_sec: Int,
+ )
+}
+
+/// Internal constructor - used by `testcontainer.start/1`. The
+/// `stop_timeout_sec` is captured at start time so `stop/1` and the
+/// crash-cleanup guard do not re-read the env on every call.
+@internal
+pub fn build(
+ id: String,
+ gateway_host: String,
+ port_mapping: dict.Dict(#(Int, String), Int),
+ keep: Bool,
+ stop_timeout_sec: Int,
+) -> Container {
+ Container(id, gateway_host, port_mapping, keep, stop_timeout_sec)
+}
+
+/// Internal mutator - used by `testcontainer.start_and_keep/1` to force
+/// the keep flag regardless of `TESTCONTAINERS_KEEP`.
+@internal
+pub fn with_keep(c: Container, keep: Bool) -> Container {
+ Container(..c, keep: keep)
+}
+
+/// Internal accessor - used by `testcontainer.stop/1` and the crash-cleanup
+/// guard to know how long to wait for graceful shutdown before SIGKILL.
+@internal
+pub fn stop_timeout_sec(c: Container) -> Int {
+ c.stop_timeout_sec
+}
+
+/// Returns the Docker container id.
+pub fn id(c: Container) -> String {
+ c.id
+}
+
+/// Returns the host that the test runner should use to reach the
+/// container's mapped ports (typically the bridge gateway IP, or
+/// the value of `TESTCONTAINERS_HOST_OVERRIDE`).
+pub fn host(c: Container) -> String {
+ c.gateway_host
+}
+
+/// Whether this container should be kept alive after the test
+/// (`TESTCONTAINERS_KEEP=true` or `start_and_keep/1`).
+pub fn keep(c: Container) -> Bool {
+ c.keep
+}
+
+/// Returns the host port that maps to the given container port,
+/// or `PortNotMapped` if the port is not exposed.
+pub fn host_port(c: Container, p: port.Port) -> Result(Int, error.Error) {
+ let n = port.number(p)
+ let proto = port.protocol(p)
+ case dict.get(c.port_mapping, #(n, proto)) {
+ Ok(hp) -> Ok(hp)
+ Error(Nil) -> Error(error.PortNotMapped(n))
+ }
+}
+
+/// Builds a URL for the given container port using the configured scheme,
+/// host, and mapped host port (e.g. `"http://127.0.0.1:32768"`).
+pub fn mapped_url(
+ c: Container,
+ p: port.Port,
+ scheme: String,
+) -> Result(String, error.Error) {
+ case host_port(c, p) {
+ Ok(hp) -> Ok(scheme <> "://" <> host(c) <> ":" <> int.to_string(hp))
+ Error(e) -> Error(e)
+ }
+}
diff --git a/src/testcontainer/error.gleam b/src/testcontainer/error.gleam
new file mode 100644
index 0000000..a1f9951
--- /dev/null
+++ b/src/testcontainer/error.gleam
@@ -0,0 +1,58 @@
+/// Every error returned by the public API is one of these variants.
+/// Each carries enough context to diagnose the problem without scraping logs.
+pub type Error {
+ /// The Docker daemon could not be reached on the configured socket.
+ DockerUnavailable(socket_path: String, reason: String)
+
+ /// The image could not be pulled (network error, auth required, not found).
+ ImagePullFailed(image: String, reason: String)
+
+ /// `POST /containers/create` returned an error or the response could not
+ /// be parsed.
+ ContainerCreateFailed(image: String, reason: String)
+
+ /// `POST /containers/{id}/start` returned an error.
+ ContainerStartFailed(container_id: String, reason: String)
+
+ /// `POST /containers/{id}/stop` returned an error.
+ ContainerStopFailed(container_id: String, reason: String)
+
+ /// A wait strategy did not succeed within its configured timeout.
+ /// `elapsed_ms` is the actual elapsed wall-clock time.
+ WaitTimedOut(strategy_description: String, elapsed_ms: Int)
+
+ /// A wait strategy reported a transient failure (the poll loop will retry
+ /// until the deadline; this variant is also returned when the deadline is
+ /// reached without success for non-timeout reasons).
+ WaitFailed(strategy_description: String, reason: String)
+
+ /// An exec call failed at the Docker API level (not to be confused with
+ /// a non-zero exit code, which is returned via `exec.ExecResult`).
+ ExecFailed(
+ container_id: String,
+ cmd: List(String),
+ exit_code: Int,
+ stderr: String,
+ )
+
+ /// The requested container port is not in the spec's port mapping.
+ PortNotMapped(container_port: Int)
+
+ /// `copy_file_to/3` failed (read error, tar error, HTTP error).
+ FileCopyFailed(path: String, reason: String)
+
+ /// Docker inspect JSON was received but `NetworkSettings.Ports` could not be
+ /// decoded into a usable host-port mapping.
+ PortMappingParseFailed(container_id: String, reason: String)
+
+ /// A Docker Engine API call returned an unexpected non-2xx status that
+ /// did not map to a more specific variant above.
+ DockerApiError(method: String, path: String, status: Int, body: String)
+
+ /// The given image reference is malformed (currently used for CR/LF
+ /// validation; reserved for future stricter parsing).
+ InvalidImageRef(raw: String)
+
+ /// A port number outside the valid TCP/UDP range (1..=65535).
+ InvalidPort(number: Int)
+}
diff --git a/src/testcontainer/exec.gleam b/src/testcontainer/exec.gleam
new file mode 100644
index 0000000..04a842f
--- /dev/null
+++ b/src/testcontainer/exec.gleam
@@ -0,0 +1,24 @@
+/// The result of running a command inside a container via
+/// `testcontainer.exec/2`.
+///
+/// `stdout` and `stderr` are returned separately when the command runs
+/// without a TTY (the default). For TTY exec calls the combined output
+/// arrives in `stdout`.
+pub type ExecResult {
+ ExecResult(exit_code: Int, stdout: String, stderr: String)
+}
+
+/// True iff the command exited with status 0.
+pub fn succeeded(result: ExecResult) -> Bool {
+ case result {
+ ExecResult(code, _, _) -> code == 0
+ }
+}
+
+/// Returns `stdout <> stderr`, useful when the caller only cares about
+/// the combined human-readable output.
+pub fn output(result: ExecResult) -> String {
+ case result {
+ ExecResult(_, stdout, stderr) -> stdout <> stderr
+ }
+}
diff --git a/src/testcontainer/formula.gleam b/src/testcontainer/formula.gleam
new file mode 100644
index 0000000..284f87e
--- /dev/null
+++ b/src/testcontainer/formula.gleam
@@ -0,0 +1,48 @@
+import testcontainer/container
+import testcontainer/error
+
+/// A Formula combines a ContainerSpec with a typed extraction function.
+///
+/// When `testcontainer.with_formula/2` starts the container it calls
+/// `extract` on the running Container to produce a service-specific output
+/// type (e.g. `PostgresContainer`, `RedisContainer`).
+///
+/// Formulas are defined in the companion package `testcontainer_formulas`,
+/// not in this core package. The core only defines the type and the lifecycle
+/// entry point.
+///
+/// use pg <- testcontainer.with_formula(postgres.formula(config))
+/// // pg has type PostgresContainer with .connection_url, .host, .port, β¦
+///
+pub opaque type Formula(output) {
+ Formula(
+ spec: container.ContainerSpec,
+ extract: fn(container.Container) -> Result(output, error.Error),
+ )
+}
+
+/// Create a Formula from a ContainerSpec and an extraction function.
+/// Called by formula modules (e.g. `testcontainer_formulas/postgres`).
+pub fn new(
+ spec: container.ContainerSpec,
+ extract: fn(container.Container) -> Result(output, error.Error),
+) -> Formula(output) {
+ Formula(spec: spec, extract: extract)
+}
+
+// Internal accessors - used only by `testcontainer.gleam`. Marked
+// `@internal` so they are not part of the published API surface, but are
+// still accessible to the core package.
+
+@internal
+pub fn spec(f: Formula(output)) -> container.ContainerSpec {
+ f.spec
+}
+
+@internal
+pub fn extract(
+ f: Formula(output),
+ c: container.Container,
+) -> Result(output, error.Error) {
+ f.extract(c)
+}
diff --git a/src/testcontainer/internal/config.gleam b/src/testcontainer/internal/config.gleam
new file mode 100644
index 0000000..cbe519d
--- /dev/null
+++ b/src/testcontainer/internal/config.gleam
@@ -0,0 +1,80 @@
+import cowl
+import envie
+import gleam/option.{type Option, None, Some}
+import gleam/string
+
+pub type PullPolicy {
+ Always
+ IfMissing
+ Never
+}
+
+/// Credentials for pulling images from a private registry.
+/// Loaded from `TESTCONTAINERS_REGISTRY_USER` and
+/// `TESTCONTAINERS_REGISTRY_PASSWORD`.
+pub type RegistryAuth {
+ RegistryAuth(username: String, password: cowl.Secret(String))
+}
+
+pub type Config {
+ Config(
+ docker_host: String,
+ keep_containers: Bool,
+ pull_policy: PullPolicy,
+ /// When set, overrides the gateway host used to reach container ports.
+ /// Useful on macOS (Docker Desktop) or WSL2 where the bridge IP is not
+ /// directly reachable from the host. Set via TESTCONTAINERS_HOST_OVERRIDE.
+ host_override: Option(String),
+ /// When set, the X-Registry-Auth header is attached to every
+ /// `POST /images/create`.
+ registry_auth: Option(RegistryAuth),
+ /// Seconds the Docker Engine waits for graceful shutdown before sending
+ /// SIGKILL during stop. Set via TESTCONTAINERS_STOP_TIMEOUT.
+ stop_timeout_sec: Int,
+ )
+}
+
+@internal
+pub fn parse_pull_policy(value: String) -> PullPolicy {
+ case string.lowercase(value) {
+ "always" -> Always
+ "never" -> Never
+ _ -> IfMissing
+ }
+}
+
+/// Loads configuration from environment variables. Always succeeds -
+/// every setting has a sensible default.
+pub fn load() -> Config {
+ let docker_host =
+ envie.get_string("DOCKER_HOST", "unix:///var/run/docker.sock")
+ let keep = envie.get_bool("TESTCONTAINERS_KEEP", False)
+ let pull_policy =
+ parse_pull_policy(envie.get_string("TESTCONTAINERS_PULL_POLICY", "missing"))
+ let host_override = case
+ envie.get_string("TESTCONTAINERS_HOST_OVERRIDE", "")
+ {
+ "" -> None
+ h -> Some(h)
+ }
+
+ let registry_auth = case
+ envie.get_string("TESTCONTAINERS_REGISTRY_USER", ""),
+ envie.get_string("TESTCONTAINERS_REGISTRY_PASSWORD", "")
+ {
+ "", _ -> None
+ _, "" -> None
+ user, pass -> Some(RegistryAuth(user, cowl.secret(pass)))
+ }
+
+ let stop_timeout_sec = envie.get_int("TESTCONTAINERS_STOP_TIMEOUT", 10)
+
+ Config(
+ docker_host: docker_host,
+ keep_containers: keep,
+ pull_policy: pull_policy,
+ host_override: host_override,
+ registry_auth: registry_auth,
+ stop_timeout_sec: stop_timeout_sec,
+ )
+}
diff --git a/src/testcontainer/internal/docker.gleam b/src/testcontainer/internal/docker.gleam
new file mode 100644
index 0000000..63360f0
--- /dev/null
+++ b/src/testcontainer/internal/docker.gleam
@@ -0,0 +1,652 @@
+import cowl
+import gleam/dynamic/decode
+import gleam/int
+import gleam/json
+import gleam/list
+import gleam/option.{None, Some}
+import gleam/result
+import gleam/string
+
+import gleam/bit_array
+import testcontainer/container
+import testcontainer/error
+import testcontainer/exec
+import testcontainer/internal/config
+import testcontainer/internal/image_ref
+import testcontainer/port
+
+// This module is a thin wrapper around the Docker Engine API using the local
+// Docker Unix socket. It uses a small Erlang helper to talk to the socket via
+// raw gen_tcp.
+
+@external(erlang, "docker_transport", "request")
+fn transport_request(
+ method: String,
+ path: String,
+ headers: List(#(String, String)),
+ body: String,
+) -> Result(#(Int, String), String)
+
+@external(erlang, "docker_transport", "socket_path")
+fn transport_socket_path() -> String
+
+@external(erlang, "docker_transport", "parse_response")
+fn transport_parse_response(data: String) -> Result(#(Int, String), String)
+
+@external(erlang, "docker_transport", "strip_log_frames")
+fn strip_log_frames(data: String) -> String
+
+@external(erlang, "docker_transport", "split_log_streams")
+fn split_log_streams(data: String) -> #(String, String)
+
+@external(erlang, "docker_transport", "copy_file_to_container")
+fn transport_copy_file(
+ container_id: String,
+ host_path: String,
+ container_path: String,
+) -> Result(Nil, String)
+
+@internal
+pub fn parse_response(data: String) -> Result(#(Int, String), String) {
+ transport_parse_response(data)
+}
+
+fn url_encode(input: String) -> String {
+ // Single pass over graphemes; replace reserved characters in one walk
+ // instead of running `string.replace` 11 times over the whole string.
+ input
+ |> string.to_graphemes
+ |> list.map(encode_grapheme)
+ |> string.concat
+}
+
+fn encode_grapheme(g: String) -> String {
+ case g {
+ "%" -> "%25"
+ "\r" -> "%0D"
+ "\n" -> "%0A"
+ " " -> "%20"
+ "/" -> "%2F"
+ ":" -> "%3A"
+ "=" -> "%3D"
+ "?" -> "%3F"
+ "&" -> "%26"
+ "#" -> "%23"
+ "+" -> "%2B"
+ other -> other
+ }
+}
+
+fn contains_crlf(input: String) -> Bool {
+ string.contains(input, "\r") || string.contains(input, "\n")
+}
+
+fn request(
+ method: String,
+ path: String,
+ body: String,
+) -> Result(#(Int, String), error.Error) {
+ request_with_headers(
+ method,
+ path,
+ [#("Content-Type", "application/json")],
+ body,
+ )
+}
+
+fn request_with_headers(
+ method: String,
+ path: String,
+ headers: List(#(String, String)),
+ body: String,
+) -> Result(#(Int, String), error.Error) {
+ case transport_request(method, path, headers, body) {
+ Ok(#(status, response)) -> Ok(#(status, response))
+ Error(reason) ->
+ Error(error.DockerUnavailable(transport_socket_path(), reason))
+ }
+}
+
+fn check_ok(
+ method: String,
+ path: String,
+ status: Int,
+ body: String,
+) -> Result(Nil, error.Error) {
+ case status {
+ 200 -> Ok(Nil)
+ 201 -> Ok(Nil)
+ 204 -> Ok(Nil)
+ _ -> Error(error.DockerApiError(method, path, status, body))
+ }
+}
+
+fn request_ok(
+ method: String,
+ path: String,
+ body: String,
+) -> Result(Nil, error.Error) {
+ case request(method, path, body) {
+ Ok(#(status, response)) -> check_ok(method, path, status, response)
+ Error(e) -> Error(e)
+ }
+}
+
+fn request_json(
+ method: String,
+ path: String,
+ body: json.Json,
+) -> Result(String, error.Error) {
+ let body_string = json.to_string(body)
+ case request(method, path, body_string) {
+ Ok(#(200, response)) -> Ok(response)
+ Ok(#(201, response)) -> Ok(response)
+ Ok(#(status, response)) ->
+ Error(error.DockerApiError(method, path, status, response))
+ Error(e) -> Error(e)
+ }
+}
+
+pub fn ping() -> Result(Nil, error.Error) {
+ request_ok("GET", "/_ping", "")
+}
+
+/// Returns True if the image is already present in the local Docker cache.
+pub fn image_exists(image: String) -> Bool {
+ let encoded = url_encode(image)
+ case request("GET", "/images/" <> encoded <> "/json", "") {
+ Ok(#(200, _)) -> True
+ _ -> False
+ }
+}
+
+pub fn pull_image(
+ image: String,
+ auth: option.Option(config.RegistryAuth),
+) -> Result(Nil, error.Error) {
+ let ref = image_ref.parse(image)
+ let path =
+ "/images/create?fromImage="
+ <> url_encode(ref.name)
+ <> "&tag="
+ <> url_encode(ref.tag)
+ let headers = case auth {
+ Some(a) -> [
+ #("Content-Type", "application/json"),
+ #("X-Registry-Auth", encode_registry_auth(a)),
+ ]
+ None -> [#("Content-Type", "application/json")]
+ }
+ case request_with_headers("POST", path, headers, "") {
+ Ok(#(status, body)) if status == 200 ->
+ // Docker returns 200 even when the pull fails mid-stream. The body is
+ // a sequence of JSON objects, possibly chunked, with progress info or
+ // an `error` / `errorDetail` field at the end.
+ case scan_pull_stream_for_error(body) {
+ Some(reason) -> Error(error.ImagePullFailed(image, reason))
+ None -> Ok(Nil)
+ }
+ Ok(#(status, body)) ->
+ Error(error.ImagePullFailed(
+ image,
+ "HTTP " <> int.to_string(status) <> ": " <> body,
+ ))
+ Error(e) -> Error(e)
+ }
+}
+
+// Encode RegistryAuth as the base64-of-JSON value Docker expects in the
+// X-Registry-Auth header. Docker accepts either standard or URL-safe
+// base64; we use standard base64 (without padding) which is the form
+// shown in the official Docker SDK reference.
+fn encode_registry_auth(a: config.RegistryAuth) -> String {
+ let payload =
+ json.object([
+ #("username", json.string(a.username)),
+ #("password", json.string(cowl.reveal(a.password))),
+ ])
+ bit_array.base64_encode(<>, False)
+}
+
+// Single pass: split once on the first `"error":"` marker. If found, take
+// the value up to the next `"`. Avoids two scans of the same stream.
+fn scan_pull_stream_for_error(stream: String) -> option.Option(String) {
+ case string.split_once(stream, "\"error\":\"") {
+ Ok(#(_, rest)) ->
+ case string.split_once(rest, "\"") {
+ Ok(#(msg, _)) -> Some(msg)
+ Error(_) -> Some("image pull failed")
+ }
+ Error(_) -> None
+ }
+}
+
+pub fn create_container(
+ spec: container.ContainerSpec,
+) -> Result(String, error.Error) {
+ use _ <- result.try(validate_spec(spec))
+
+ let cmd = case container.command(spec) {
+ Some(c) -> json.array(c, json.string)
+ None -> json.null()
+ }
+
+ let entrypoint = case container.entrypoint(spec) {
+ Some(e) -> json.array(e, json.string)
+ None -> json.null()
+ }
+
+ let env_list =
+ list.map(container.env(spec), fn(pair) {
+ json.string(pair.0 <> "=" <> cowl.reveal(pair.1))
+ })
+
+ let port_keys =
+ list.map(container.ports(spec), fn(p) {
+ int.to_string(port.number(p)) <> "/" <> port.protocol(p)
+ })
+
+ let exposed_ports =
+ json.object(list.map(port_keys, fn(k) { #(k, json.object([])) }))
+
+ let port_bindings =
+ json.object(
+ list.map(port_keys, fn(k) {
+ #(
+ k,
+ json.array(
+ [
+ json.object([
+ #("HostIp", json.string("")),
+ #("HostPort", json.string("")),
+ ]),
+ ],
+ fn(x) { x },
+ ),
+ )
+ }),
+ )
+
+ let binds =
+ list.filter_map(container.volumes(spec), fn(v) {
+ case container.volume_kind(v) {
+ Ok(#(host, cpath, ro)) -> {
+ let mode = case ro {
+ True -> ":ro"
+ False -> ":rw"
+ }
+ Ok(json.string(host <> ":" <> cpath <> mode))
+ }
+ Error(_) -> Error(Nil)
+ }
+ })
+
+ let tmpfs_entries =
+ list.filter_map(container.volumes(spec), fn(v) {
+ case container.volume_kind(v) {
+ Error(cpath) -> Ok(#(cpath, json.string("")))
+ Ok(_) -> Error(Nil)
+ }
+ })
+
+ let labels_obj =
+ json.object(
+ list.map(container.labels(spec), fn(pair) {
+ #(pair.0, json.string(pair.1))
+ }),
+ )
+
+ let network_mode = case container.network(spec) {
+ Some(n) -> json.string(n)
+ None -> json.string("bridge")
+ }
+
+ let networking_config = case container.network(spec) {
+ Some(n) ->
+ json.object([
+ #("EndpointsConfig", json.object([#(n, json.object([]))])),
+ ])
+ None -> json.object([])
+ }
+
+ let host_config =
+ json.object([
+ #("PortBindings", port_bindings),
+ #("Binds", json.array(binds, fn(x) { x })),
+ #("Tmpfs", json.object(tmpfs_entries)),
+ #("Privileged", json.bool(container.is_privileged(spec))),
+ #("NetworkMode", network_mode),
+ ])
+
+ let body =
+ json.object([
+ #("Image", json.string(container.image(spec))),
+ #("Cmd", cmd),
+ #("Entrypoint", entrypoint),
+ #("Env", json.array(env_list, fn(x) { x })),
+ #("ExposedPorts", exposed_ports),
+ #("Labels", labels_obj),
+ #("HostConfig", host_config),
+ #("NetworkingConfig", networking_config),
+ ])
+
+ let path = case container.name(spec) {
+ Some(n) -> "/containers/create?name=" <> url_encode(n)
+ None -> "/containers/create"
+ }
+
+ case request_json("POST", path, body) {
+ Ok(response) ->
+ case json.parse(response, decode.at(["Id"], decode.string)) {
+ Ok(id) -> Ok(id)
+ Error(_) ->
+ Error(error.ContainerCreateFailed(
+ container.image(spec),
+ "unable to parse create response",
+ ))
+ }
+ Error(error.DockerApiError(_, _, status, body_str)) ->
+ Error(error.ContainerCreateFailed(
+ container.image(spec),
+ "HTTP " <> int.to_string(status) <> ": " <> body_str,
+ ))
+ Error(e) -> Error(e)
+ }
+}
+
+fn validate_spec(spec: container.ContainerSpec) -> Result(Nil, error.Error) {
+ let image = container.image(spec)
+ use _ <- result.try(case contains_crlf(image) {
+ True -> Error(error.InvalidImageRef(image))
+ False -> Ok(Nil)
+ })
+ use _ <- result.try(case container.name(spec) {
+ Some(n) ->
+ case contains_crlf(n) {
+ True ->
+ Error(error.ContainerCreateFailed(
+ image,
+ "container name contains CR/LF",
+ ))
+ False -> Ok(Nil)
+ }
+ None -> Ok(Nil)
+ })
+ use _ <- result.try(validate_ports(spec))
+ validate_volumes(spec)
+}
+
+fn validate_ports(spec: container.ContainerSpec) -> Result(Nil, error.Error) {
+ let bad =
+ list.find(container.ports(spec), fn(p) {
+ let n = port.number(p)
+ n < 1 || n > 65_535
+ })
+ case bad {
+ Ok(p) -> Error(error.InvalidPort(port.number(p)))
+ Error(Nil) -> Ok(Nil)
+ }
+}
+
+fn validate_volumes(spec: container.ContainerSpec) -> Result(Nil, error.Error) {
+ let bad =
+ list.find(container.volumes(spec), fn(v) {
+ case container.volume_kind(v) {
+ Ok(#(host, cpath, _)) ->
+ contains_crlf(host)
+ || contains_crlf(cpath)
+ || string.contains(host, ":")
+ Error(p) -> contains_crlf(p)
+ }
+ })
+ case bad {
+ Ok(_) ->
+ Error(error.ContainerCreateFailed(
+ container.image(spec),
+ "volume path invalid (CR/LF or ':' in host path)",
+ ))
+ Error(Nil) -> Ok(Nil)
+ }
+}
+
+pub fn start_container(id: String) -> Result(Nil, error.Error) {
+ case request("POST", "/containers/" <> id <> "/start", "") {
+ Ok(#(status, _)) if status == 204 || status == 304 -> Ok(Nil)
+ Ok(#(status, body)) ->
+ Error(error.ContainerStartFailed(
+ id,
+ "HTTP " <> int.to_string(status) <> ": " <> body,
+ ))
+ Error(error.DockerUnavailable(_, reason)) ->
+ Error(error.ContainerStartFailed(id, reason))
+ Error(e) -> Error(e)
+ }
+}
+
+pub fn stop_container(
+ id: String,
+ timeout_sec: Int,
+) -> Result(Nil, error.Error) {
+ let path = "/containers/" <> id <> "/stop?t=" <> int.to_string(timeout_sec)
+ case request("POST", path, "") {
+ // 204 = stopped, 304 = already stopped - both are fine
+ Ok(#(status, _)) if status == 204 || status == 304 -> Ok(Nil)
+ Ok(#(status, body)) ->
+ Error(error.ContainerStopFailed(
+ id,
+ "HTTP " <> int.to_string(status) <> ": " <> body,
+ ))
+ Error(error.DockerUnavailable(_, reason)) ->
+ Error(error.ContainerStopFailed(id, reason))
+ Error(e) -> Error(e)
+ }
+}
+
+pub fn remove_container(id: String) -> Result(Nil, error.Error) {
+ request_ok("DELETE", "/containers/" <> id <> "?force=true", "")
+}
+
+pub fn inspect_container(id: String) -> Result(String, error.Error) {
+ case request("GET", "/containers/" <> id <> "/json", "") {
+ Ok(#(200, response)) -> Ok(response)
+ Ok(#(status, response)) ->
+ Error(error.DockerApiError(
+ "GET",
+ "/containers/" <> id <> "/json",
+ status,
+ response,
+ ))
+ Error(e) -> Error(e)
+ }
+}
+
+pub fn container_logs(
+ id: String,
+ tail: option.Option(Int),
+) -> Result(String, error.Error) {
+ let tail_q = case tail {
+ Some(n) -> "&tail=" <> int.to_string(n)
+ None -> "&tail=all"
+ }
+ case
+ request(
+ "GET",
+ "/containers/" <> id <> "/logs?stdout=1&stderr=1" <> tail_q,
+ "",
+ )
+ {
+ Ok(#(200, response)) -> Ok(strip_log_frames(response))
+ Ok(#(status, response)) ->
+ Error(error.DockerApiError(
+ "GET",
+ "/containers/" <> id <> "/logs",
+ status,
+ response,
+ ))
+ Error(e) -> Error(e)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Exec
+// ---------------------------------------------------------------------------
+
+pub fn exec_container(
+ id: String,
+ cmd: List(String),
+) -> Result(exec.ExecResult, error.Error) {
+ let create_body =
+ json.object([
+ #("Cmd", json.array(cmd, json.string)),
+ #("AttachStdout", json.bool(True)),
+ #("AttachStderr", json.bool(True)),
+ ])
+
+ // Step 1: create exec instance
+ use exec_resp <- result.try(
+ request_json("POST", "/containers/" <> id <> "/exec", create_body)
+ |> result.map_error(fn(e) {
+ case e {
+ error.DockerApiError(_, _, status, body) ->
+ error.ExecFailed(
+ id,
+ cmd,
+ -1,
+ "create exec HTTP " <> int.to_string(status) <> ": " <> body,
+ )
+ _ -> e
+ }
+ }),
+ )
+ use exec_id <- result.try(
+ case json.parse(exec_resp, decode.at(["Id"], decode.string)) {
+ Ok(eid) -> Ok(eid)
+ Error(_) ->
+ Error(error.ExecFailed(
+ id,
+ cmd,
+ -1,
+ "unable to parse exec create response",
+ ))
+ },
+ )
+
+ // Step 2: start exec (blocking - returns multiplexed stdout+stderr stream)
+ let start_body =
+ json.object([#("Detach", json.bool(False)), #("Tty", json.bool(False))])
+ use raw <- result.try(
+ case
+ request(
+ "POST",
+ "/exec/" <> exec_id <> "/start",
+ json.to_string(start_body),
+ )
+ {
+ Ok(#(200, body)) -> Ok(body)
+ Ok(#(status, body)) ->
+ Error(error.ExecFailed(
+ id,
+ cmd,
+ -1,
+ "start exec HTTP " <> int.to_string(status) <> ": " <> body,
+ ))
+ Error(e) -> Error(e)
+ },
+ )
+
+ let #(stdout, stderr) = split_log_streams(raw)
+
+ // Step 3: inspect exec to get exit code (real error if decode fails)
+ use exit_code <- result.try(
+ case request("GET", "/exec/" <> exec_id <> "/json", "") {
+ Ok(#(200, body)) ->
+ case json.parse(body, decode.at(["ExitCode"], decode.int)) {
+ Ok(code) -> Ok(code)
+ Error(_) ->
+ Error(error.ExecFailed(
+ id,
+ cmd,
+ -1,
+ "unable to parse exec inspect response",
+ ))
+ }
+ Ok(#(status, body)) ->
+ Error(error.ExecFailed(
+ id,
+ cmd,
+ -1,
+ "inspect exec HTTP " <> int.to_string(status) <> ": " <> body,
+ ))
+ Error(e) -> Error(e)
+ },
+ )
+
+ Ok(exec.ExecResult(exit_code, stdout, stderr))
+}
+
+// ---------------------------------------------------------------------------
+// Network
+// ---------------------------------------------------------------------------
+
+pub fn create_network(name: String) -> Result(String, error.Error) {
+ case contains_crlf(name) {
+ True ->
+ Error(error.DockerApiError(
+ "POST",
+ "/networks/create",
+ 0,
+ "network name contains CR/LF",
+ ))
+ False -> {
+ let body =
+ json.object([
+ #("Name", json.string(name)),
+ #("Driver", json.string("bridge")),
+ ])
+ case request_json("POST", "/networks/create", body) {
+ Ok(response) ->
+ case json.parse(response, decode.at(["Id"], decode.string)) {
+ Ok(nid) -> Ok(nid)
+ Error(_) ->
+ Error(error.DockerApiError(
+ "POST",
+ "/networks/create",
+ 0,
+ "parse error",
+ ))
+ }
+ Error(e) -> Error(e)
+ }
+ }
+ }
+}
+
+pub fn remove_network(id: String) -> Result(Nil, error.Error) {
+ request_ok("DELETE", "/networks/" <> id, "")
+}
+
+// ---------------------------------------------------------------------------
+// File copy
+// ---------------------------------------------------------------------------
+
+/// Copies a file from the host into a running container.
+/// Uses erl_tar to create a tar archive in a temp file, then PUTs it to
+/// the Docker Engine API at PUT /containers/{id}/archive.
+pub fn copy_file_to(
+ container_id: String,
+ host_path: String,
+ container_path: String,
+) -> Result(Nil, error.Error) {
+ case
+ contains_crlf(container_id)
+ || contains_crlf(host_path)
+ || contains_crlf(container_path)
+ {
+ True -> Error(error.FileCopyFailed(container_path, "path contains CR/LF"))
+ False ->
+ case transport_copy_file(container_id, host_path, container_path) {
+ Ok(Nil) -> Ok(Nil)
+ Error(reason) -> Error(error.FileCopyFailed(container_path, reason))
+ }
+ }
+}
diff --git a/src/testcontainer/internal/docker_transport.erl b/src/testcontainer/internal/docker_transport.erl
new file mode 100644
index 0000000..dd37762
--- /dev/null
+++ b/src/testcontainer/internal/docker_transport.erl
@@ -0,0 +1,403 @@
+-module(docker_transport).
+-export([request/4, parse_response/1,
+ tcp_can_connect/2, http_get_status/3,
+ now_ms/0, sleep_ms/1,
+ strip_log_frames/1, split_log_streams/1,
+ copy_file_to_container/3,
+ socket_path/0, docker_endpoint/0]).
+
+%% Raw HTTP/1.1 over the Docker daemon socket.
+%%
+%% Two transports are supported, selected via the DOCKER_HOST env var:
+%%
+%% - unix:// β Unix domain socket via gen_tcp:{local, Path}
+%% - tcp://host:port β plain TCP connect to host:port
+%%
+%% Default: unix:///var/run/docker.sock (when DOCKER_HOST is unset).
+%% No TLS support - TCP transports must be plain HTTP. For HTTPS Docker
+%% endpoints the user is expected to terminate TLS upstream.
+
+-define(DEFAULT_SOCKET, "/var/run/docker.sock").
+-define(RECV_TIMEOUT, 30000).
+-define(CONNECT_TIMEOUT, 5000).
+
+%% Legacy alias kept for callers that only need the unix path.
+socket_path() ->
+ case docker_endpoint() of
+ {unix, Path} -> Path;
+ _ -> ?DEFAULT_SOCKET
+ end.
+
+%% Resolve the Docker endpoint from DOCKER_HOST.
+%% Returns: {unix, Path :: string()} | {tcp, Host :: string(), Port :: integer()}.
+docker_endpoint() ->
+ case os:getenv("DOCKER_HOST") of
+ false -> {unix, ?DEFAULT_SOCKET};
+ "" -> {unix, ?DEFAULT_SOCKET};
+ "unix://" ++ Rest -> {unix, Rest};
+ "tcp://" ++ Rest -> parse_tcp(Rest);
+ "/" ++ _ = Raw -> {unix, Raw};
+ _Other -> {unix, ?DEFAULT_SOCKET}
+ end.
+
+parse_tcp(HostPort) ->
+ case string:split(HostPort, ":") of
+ [H, P] ->
+ case string:to_integer(P) of
+ {Int, ""} when is_integer(Int) -> {tcp, H, Int};
+ _ -> {unix, ?DEFAULT_SOCKET}
+ end;
+ [H] -> {tcp, H, 2375};
+ _ -> {unix, ?DEFAULT_SOCKET}
+ end.
+
+connect_endpoint() ->
+ case docker_endpoint() of
+ {unix, Path} ->
+ gen_tcp:connect({local, Path}, 0,
+ [binary, {active, false}, {packet, raw}],
+ ?CONNECT_TIMEOUT);
+ {tcp, Host, Port} ->
+ gen_tcp:connect(Host, Port,
+ [binary, {active, false}, {packet, raw}],
+ ?CONNECT_TIMEOUT)
+ end.
+
+endpoint_label() ->
+ case docker_endpoint() of
+ {unix, Path} -> Path;
+ {tcp, Host, Port} -> Host ++ ":" ++ integer_to_list(Port)
+ end.
+
+request(Method, Path, Headers, Body) ->
+ case connect_endpoint() of
+ {ok, Socket} ->
+ Result = try
+ do_http_request(Socket, Method, Path, Headers, Body)
+ after
+ gen_tcp:close(Socket)
+ end,
+ Result;
+ {error, Reason} ->
+ {error, list_to_binary(
+ io_lib:format("connect ~s: ~p", [endpoint_label(), Reason]))}
+ end.
+
+do_http_request(Socket, Method, Path, Headers, Body) ->
+ BodyBin = to_binary(Body),
+ MethodBin = method_bin(Method),
+ ContentLength = byte_size(BodyBin),
+ HeaderLines = format_headers(Headers),
+ Request = iolist_to_binary([
+ MethodBin, " ", Path, " HTTP/1.1\r\n",
+ "Host: localhost\r\n",
+ "Content-Length: ", integer_to_binary(ContentLength), "\r\n",
+ HeaderLines,
+ "Connection: close\r\n",
+ "\r\n",
+ BodyBin
+ ]),
+ case gen_tcp:send(Socket, Request) of
+ ok ->
+ case recv_all(Socket, []) of
+ {ok, RawResp} -> parse_response(RawResp);
+ {error, Err} -> {error, list_to_binary(io_lib:format("recv: ~p", [Err]))}
+ end;
+ {error, Err} ->
+ {error, list_to_binary(io_lib:format("send: ~p", [Err]))}
+ end.
+
+%% Accumulate as iolist, concat once at end (avoid O(n^2) binary grow).
+recv_all(Socket, Acc) ->
+ case gen_tcp:recv(Socket, 0, ?RECV_TIMEOUT) of
+ {ok, Data} -> recv_all(Socket, [Acc, Data]);
+ {error, closed} -> {ok, iolist_to_binary(Acc)};
+ {error, Reason} -> {error, Reason}
+ end.
+
+parse_response(Data) when is_list(Data) ->
+ parse_response(iolist_to_binary(Data));
+parse_response(Data) ->
+ case binary:split(Data, <<"\r\n\r\n">>) of
+ [Head, RawBody] ->
+ case binary:split(Head, <<"\r\n">>) of
+ [StatusLine | _] ->
+ case binary:split(StatusLine, <<" ">>, [global]) of
+ [_, CodeBin | _] ->
+ Code = binary_to_integer(CodeBin),
+ Body = dechunk(RawBody),
+ {ok, {Code, Body}};
+ _ ->
+ {error, <<"bad status line">>}
+ end;
+ _ ->
+ {error, <<"bad response head">>}
+ end;
+ _ ->
+ {error, <<"incomplete HTTP response">>}
+ end.
+
+%% Decode HTTP chunked transfer encoding.
+%% If the body does not start with a hex chunk-size line, returns it unchanged.
+dechunk(Body) ->
+ case re:run(Body, <<"^[0-9a-fA-F]+\r\n">>) of
+ {match, _} -> dechunk_body(Body, []);
+ nomatch -> Body
+ end.
+
+dechunk_body(<<"0\r\n", _/binary>>, Acc) -> iolist_to_binary(Acc);
+dechunk_body(Data, Acc) ->
+ case binary:split(Data, <<"\r\n">>) of
+ [SizeBin, Rest] ->
+ Size = binary_to_integer(SizeBin, 16),
+ case Size of
+ 0 -> iolist_to_binary(Acc);
+ _ ->
+ case Rest of
+ <> ->
+ dechunk_body(Next, [Acc, Chunk]);
+ _ ->
+ iolist_to_binary([Acc, Rest])
+ end
+ end;
+ _ -> iolist_to_binary(Acc)
+ end.
+
+method_bin(Method) when is_atom(Method) -> atom_to_binary(Method, utf8);
+method_bin(Method) when is_binary(Method) -> Method;
+method_bin(Method) when is_list(Method) -> list_to_binary(Method).
+
+to_binary(B) when is_binary(B) -> B;
+to_binary(L) when is_list(L) -> list_to_binary(L);
+to_binary(_) -> <<>>.
+
+format_headers(Headers) ->
+ iolist_to_binary([[K, ": ", V, "\r\n"] || {K, V} <- Headers]).
+
+%% ---------------------------------------------------------------------------
+%% copy_file_to_container/3
+%%
+%% Reads HostPath from disk, wraps it in a minimal tar archive, and PUTs it
+%% to the Docker Engine API at /containers/{Id}/archive?path={ContainerDir}.
+%% The file appears in the container at ContainerPath.
+%%
+%% Returns ok | {error, Reason :: binary()}.
+%% ---------------------------------------------------------------------------
+
+copy_file_to_container(ContainerId, HostPath, ContainerPath)
+ when is_binary(ContainerId) ->
+ copy_file_to_container(binary_to_list(ContainerId), HostPath, ContainerPath);
+copy_file_to_container(ContainerId, HostPath, ContainerPath)
+ when is_binary(HostPath) ->
+ copy_file_to_container(ContainerId, binary_to_list(HostPath), ContainerPath);
+copy_file_to_container(ContainerId, HostPath, ContainerPath)
+ when is_binary(ContainerPath) ->
+ copy_file_to_container(ContainerId, HostPath, binary_to_list(ContainerPath));
+copy_file_to_container(ContainerId, HostPath, ContainerPath) ->
+ case has_crlf(ContainerPath) orelse has_crlf(ContainerId) of
+ true ->
+ {error, <<"path contains CR/LF">>};
+ false ->
+ ContainerDir = filename:dirname(ContainerPath),
+ FileBaseName = filename:basename(ContainerPath),
+ TmpTar = unique_tmp_path("tc_", ".tar"),
+ Result = case erl_tar:open(TmpTar, [write]) of
+ {ok, Tar} ->
+ case erl_tar:add(Tar, HostPath, FileBaseName, []) of
+ ok ->
+ case erl_tar:close(Tar) of
+ ok ->
+ case file:read_file(TmpTar) of
+ {ok, TarBin} ->
+ put_tar(ContainerId, ContainerDir, TarBin);
+ {error, ReadErr} ->
+ {error, list_to_binary(io_lib:format("read tar: ~p", [ReadErr]))}
+ end;
+ {error, CloseErr} ->
+ {error, list_to_binary(io_lib:format("close tar: ~p", [CloseErr]))}
+ end;
+ {error, AddErr} ->
+ erl_tar:close(Tar),
+ {error, list_to_binary(io_lib:format("add to tar: ~p", [AddErr]))}
+ end;
+ {error, OpenErr} ->
+ {error, list_to_binary(io_lib:format("open tar: ~p", [OpenErr]))}
+ end,
+ file:delete(TmpTar),
+ Result
+ end.
+
+has_crlf(S) when is_list(S) ->
+ lists:any(fun(C) -> C =:= $\r orelse C =:= $\n end, S);
+has_crlf(B) when is_binary(B) ->
+ has_crlf(binary_to_list(B));
+has_crlf(_) -> false.
+
+%% Build a tmp filename unlikely to collide across processes or BEAM nodes
+%% sharing the same TMPDIR. Composed of: OS pid + microsecond timestamp +
+%% per-node unique integer. Honours $TMPDIR when set, falls back to /tmp.
+unique_tmp_path(Prefix, Suffix) ->
+ TmpDir = case os:getenv("TMPDIR") of
+ false -> "/tmp";
+ "" -> "/tmp";
+ Dir -> Dir
+ end,
+ Pid = os:getpid(),
+ Time = integer_to_list(erlang:system_time(microsecond)),
+ Uniq = integer_to_list(erlang:unique_integer([positive])),
+ Name = Prefix ++ Pid ++ "_" ++ Time ++ "_" ++ Uniq ++ Suffix,
+ filename:join(TmpDir, Name).
+
+%% PUT a tar binary to /containers/{Id}/archive?path={Dir}.
+put_tar(ContainerId, ContainerDir, TarBin) ->
+ EncodedDir = query_encode(ContainerDir),
+ Path = iolist_to_binary([
+ "/containers/", ContainerId, "/archive?path=", EncodedDir
+ ]),
+ case connect_endpoint() of
+ {ok, Socket} ->
+ ContentLength = byte_size(TarBin),
+ Request = iolist_to_binary([
+ "PUT ", Path, " HTTP/1.1\r\n",
+ "Host: localhost\r\n",
+ "Content-Type: application/x-tar\r\n",
+ "Content-Length: ", integer_to_binary(ContentLength), "\r\n",
+ "Connection: close\r\n",
+ "\r\n",
+ TarBin
+ ]),
+ Result = try
+ case gen_tcp:send(Socket, Request) of
+ ok ->
+ case recv_all(Socket, []) of
+ {ok, RawResp} ->
+ case parse_response(RawResp) of
+ {ok, {200, _}} -> {ok, nil};
+ {ok, {Status, Body}} ->
+ {error, list_to_binary(
+ io_lib:format("HTTP ~p: ~s", [Status, Body]))};
+ {error, E} -> {error, E}
+ end;
+ {error, Err} ->
+ {error, list_to_binary(io_lib:format("recv: ~p", [Err]))}
+ end;
+ {error, Err} ->
+ {error, list_to_binary(io_lib:format("send: ~p", [Err]))}
+ end
+ after
+ gen_tcp:close(Socket)
+ end,
+ Result;
+ {error, Reason} ->
+ {error, list_to_binary(
+ io_lib:format("connect ~s: ~p", [endpoint_label(), Reason]))}
+ end.
+
+%% Percent-encode characters that are special in URL query strings.
+%% Keeps "/" unchanged (it is already inside a path segment here).
+query_encode(S) when is_list(S) ->
+ list_to_binary(lists:flatmap(fun query_encode_char/1, S)).
+
+query_encode_char($/) -> "/";
+query_encode_char($ ) -> "%20";
+query_encode_char($&) -> "%26";
+query_encode_char($=) -> "%3D";
+query_encode_char($+) -> "%2B";
+query_encode_char($?) -> "%3F";
+query_encode_char($#) -> "%23";
+query_encode_char($\r) -> "%0D";
+query_encode_char($\n) -> "%0A";
+query_encode_char(C) -> [C].
+
+%% ---------------------------------------------------------------------------
+%% Wait strategy helpers
+%% ---------------------------------------------------------------------------
+
+%% Try a TCP connect to Host:Port. Returns ok | {error, Reason}.
+tcp_can_connect(Host, Port) when is_binary(Host) ->
+ tcp_can_connect(binary_to_list(Host), Port);
+tcp_can_connect(Host, Port) ->
+ case gen_tcp:connect(Host, Port, [binary, {active, false}], 2000) of
+ {ok, Sock} ->
+ gen_tcp:close(Sock),
+ {ok, nil};
+ {error, Reason} ->
+ {error, list_to_binary(io_lib:format("~p", [Reason]))}
+ end.
+
+%% Perform an HTTP GET to Host:Port/Path, return {ok, StatusCode} or {error, Reason}.
+http_get_status(Host, Port, Path) when is_binary(Host) ->
+ http_get_status(binary_to_list(Host), Port, Path);
+http_get_status(Host, Port, Path) ->
+ case gen_tcp:connect(Host, Port, [binary, {active, false}, {packet, raw}], 2000) of
+ {ok, Sock} ->
+ PathBin = to_binary(Path),
+ Req = iolist_to_binary([
+ "GET ", PathBin, " HTTP/1.1\r\n",
+ "Host: ", Host, ":", integer_to_list(Port), "\r\n",
+ "Connection: close\r\n",
+ "\r\n"
+ ]),
+ Result = case gen_tcp:send(Sock, Req) of
+ ok ->
+ case recv_all(Sock, []) of
+ {ok, Resp} ->
+ case parse_response(Resp) of
+ {ok, {Code, _}} -> {ok, Code};
+ {error, E} -> {error, E}
+ end;
+ {error, E} -> {error, list_to_binary(io_lib:format("recv: ~p", [E]))}
+ end;
+ {error, E} -> {error, list_to_binary(io_lib:format("send: ~p", [E]))}
+ end,
+ gen_tcp:close(Sock),
+ Result;
+ {error, Reason} ->
+ {error, list_to_binary(io_lib:format("connect: ~p", [Reason]))}
+ end.
+
+%% Current time in milliseconds (monotonic).
+now_ms() ->
+ erlang:monotonic_time(millisecond).
+
+%% Sleep for Ms milliseconds.
+sleep_ms(Ms) ->
+ timer:sleep(Ms),
+ nil.
+
+%% Strip Docker multiplexed log stream framing - concatenated stdout+stderr.
+%% Each frame: 1 byte type | 3 bytes padding | 4 bytes size (big-endian) | bytes data
+strip_log_frames(<<>>) -> <<>>;
+strip_log_frames(<<_Type:8, _Pad:24, Size:32/big, Rest/binary>>) ->
+ case Size =< byte_size(Rest) of
+ true ->
+ <> = Rest,
+ <>;
+ false ->
+ Rest
+ end;
+strip_log_frames(Data) -> Data.
+
+%% Split Docker multiplexed exec output into {Stdout, Stderr}.
+%% Type byte: 1 = stdout, 2 = stderr (0 = stdin, ignored).
+split_log_streams(Data) ->
+ {Out, Err} = split_loop(Data, [], []),
+ {iolist_to_binary(Out), iolist_to_binary(Err)}.
+
+split_loop(<<>>, Out, Err) -> {Out, Err};
+split_loop(<>, Out, Err) ->
+ case Size =< byte_size(Rest) of
+ true ->
+ <> = Rest,
+ case Type of
+ 1 -> split_loop(Next, [Out, Frame], Err);
+ 2 -> split_loop(Next, Out, [Err, Frame]);
+ _ -> split_loop(Next, Out, Err)
+ end;
+ false ->
+ %% Truncated frame - append remainder to stdout as best-effort.
+ {[Out, Rest], Err}
+ end;
+split_loop(Data, Out, Err) ->
+ %% No frame header - treat everything as stdout (e.g. when TTY=true).
+ {[Out, Data], Err}.
diff --git a/src/testcontainer/internal/image_ref.gleam b/src/testcontainer/internal/image_ref.gleam
new file mode 100644
index 0000000..683518e
--- /dev/null
+++ b/src/testcontainer/internal/image_ref.gleam
@@ -0,0 +1,46 @@
+import gleam/list
+import gleam/string
+
+pub type ImageRef {
+ ImageRef(name: String, tag: String)
+}
+
+/// Splits an image reference into `(name, tag)`. Falls back to
+/// `tag = "latest"` when no tag is present.
+///
+/// Heuristic: a Docker tag may contain `[A-Za-z0-9._-]` but never `/`.
+/// The trailing colon-segment is treated as a tag only when it has no
+/// `/`. With this rule the parse is unambiguous in every case except a
+/// bare `host:port` reference: e.g. `registry:5000` is parsed as
+/// `name="registry", tag="5000"`. To force the registry-port
+/// interpretation, append the image segment - `registry:5000/image[:tag]` -
+/// which the parser detects via the `/` in the second-to-last segment.
+pub fn parse(raw: String) -> ImageRef {
+ let parts = string.split(raw, ":")
+ case list.reverse(parts) {
+ [potential_tag, name_part, ..rest_reversed] -> {
+ // A tag never contains "/". If the last colon-segment does, the whole
+ // string is the name with no explicit tag (e.g. "registry:5000/image").
+ case string.contains(potential_tag, "/") {
+ True -> ImageRef(name: raw, tag: "latest")
+ False ->
+ case string.contains(name_part, "/") {
+ True -> {
+ let name =
+ string.join(list.reverse([name_part, ..rest_reversed]), ":")
+ ImageRef(name: name, tag: potential_tag)
+ }
+ False -> {
+ let name =
+ string.join(
+ list.append(list.reverse(rest_reversed), [name_part]),
+ ":",
+ )
+ ImageRef(name: name, tag: potential_tag)
+ }
+ }
+ }
+ }
+ _ -> ImageRef(name: raw, tag: "latest")
+ }
+}
diff --git a/src/testcontainer/internal/wait_runner.gleam b/src/testcontainer/internal/wait_runner.gleam
new file mode 100644
index 0000000..6aee1da
--- /dev/null
+++ b/src/testcontainer/internal/wait_runner.gleam
@@ -0,0 +1,350 @@
+import gleam/dict
+import gleam/dynamic/decode
+import gleam/int
+import gleam/json
+import gleam/list
+import gleam/option.{type Option, None, Some}
+import gleam/result
+import gleam/string
+
+import testcontainer/error
+import testcontainer/exec
+import testcontainer/internal/docker
+import testcontainer/wait
+
+// ---------------------------------------------------------------------------
+// FFI - timing and network helpers from docker_transport.erl
+// ---------------------------------------------------------------------------
+
+@external(erlang, "docker_transport", "tcp_can_connect")
+fn tcp_can_connect(host: String, port: Int) -> Result(Nil, String)
+
+@external(erlang, "docker_transport", "http_get_status")
+fn http_get_status(host: String, port: Int, path: String) -> Result(Int, String)
+
+@external(erlang, "docker_transport", "now_ms")
+fn now_ms() -> Int
+
+@external(erlang, "docker_transport", "sleep_ms")
+fn sleep_ms(ms: Int) -> Nil
+
+// ---------------------------------------------------------------------------
+// Port-binding decoder. Built once and reused for every parse.
+// ---------------------------------------------------------------------------
+
+type PortBinding {
+ PortBinding(host_port: String)
+}
+
+fn port_binding_decoder() -> decode.Decoder(PortBinding) {
+ use hp <- decode.field("HostPort", decode.string)
+ decode.success(PortBinding(hp))
+}
+
+fn ports_decoder() -> decode.Decoder(
+ dict.Dict(String, Option(List(PortBinding))),
+) {
+ decode.at(
+ ["NetworkSettings", "Ports"],
+ decode.dict(
+ decode.string,
+ decode.optional(decode.list(port_binding_decoder())),
+ ),
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Public entry point
+// ---------------------------------------------------------------------------
+
+/// Runs the wait strategy for the given container, polling until it succeeds
+/// or the strategy's configured timeout expires. `host` is the host the
+/// runner should use to reach mapped ports (already resolved by the caller
+/// from `Config.host_override`, so the runner does not re-read the env on
+/// every poll).
+pub fn run(
+ strategy: wait.WaitStrategy,
+ container_id: String,
+ host: String,
+) -> Result(Nil, error.Error) {
+ let start = now_ms()
+ let deadline = start + wait.timeout_ms(strategy)
+ let poll = wait.poll_interval_ms(strategy)
+ poll_loop(strategy, container_id, host, dict.new(), start, deadline, poll)
+}
+
+// ---------------------------------------------------------------------------
+// Poll loop
+// ---------------------------------------------------------------------------
+
+fn poll_loop(
+ strategy: wait.WaitStrategy,
+ container_id: String,
+ host: String,
+ port_map: dict.Dict(Int, Int),
+ start_ms: Int,
+ deadline: Int,
+ poll_ms: Int,
+) -> Result(Nil, error.Error) {
+ let now = now_ms()
+ case now >= deadline {
+ True -> {
+ let elapsed = now - start_ms
+ Error(error.WaitTimedOut(wait.describe(strategy), elapsed))
+ }
+ False -> {
+ // One inspect call per poll iteration, shared by health checks and
+ // (when needed) port resolution. Once the port map is non-empty,
+ // mappings are stable for a running container - keep reusing it
+ // across iterations to close the resolve/connect race window.
+ let inspect = case docker.inspect_container(container_id) {
+ Ok(body) -> Some(body)
+ Error(_) -> None
+ }
+ let pm = case dict.is_empty(port_map), inspect {
+ True, Some(body) -> parse_port_map(body)
+ _, _ -> port_map
+ }
+ case check_once(strategy, container_id, host, pm, inspect) {
+ Ok(Nil) -> Ok(Nil)
+ Error(_) -> {
+ sleep_ms(poll_ms)
+ poll_loop(
+ strategy,
+ container_id,
+ host,
+ pm,
+ start_ms,
+ deadline,
+ poll_ms,
+ )
+ }
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Single check per strategy variant
+// ---------------------------------------------------------------------------
+
+fn check_once(
+ strategy: wait.WaitStrategy,
+ container_id: String,
+ host: String,
+ port_map: dict.Dict(Int, Int),
+ inspect: Option(String),
+) -> Result(Nil, error.Error) {
+ case wait.base(strategy) {
+ wait.ForNone -> Ok(Nil)
+ wait.ForLog(message, times) -> check_log(container_id, message, times)
+ wait.ForPort(container_port) -> check_port(container_port, host, port_map)
+ wait.ForHttp(container_port, path, expected_status) ->
+ check_http(container_port, path, expected_status, host, port_map)
+ wait.ForHealthCheck -> check_health(inspect)
+ wait.ForCommand(cmd, expected_exit) ->
+ check_command(container_id, cmd, expected_exit)
+ wait.AllOf(strategies) ->
+ check_all_of(strategies, container_id, host, port_map, inspect)
+ wait.AnyOf(strategies) ->
+ check_any_of(strategies, container_id, host, port_map, inspect)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Strategy implementations
+// ---------------------------------------------------------------------------
+
+fn check_log(
+ container_id: String,
+ message: String,
+ times: Int,
+) -> Result(Nil, error.Error) {
+ case docker.container_logs(container_id, option.None) {
+ Ok(logs) -> {
+ let count = count_occurrences(logs, message)
+ case count >= times {
+ True -> Ok(Nil)
+ False ->
+ Error(error.WaitFailed(
+ "log(" <> message <> ")",
+ "found " <> int.to_string(count) <> "/" <> int.to_string(times),
+ ))
+ }
+ }
+ Error(e) -> Error(e)
+ }
+}
+
+fn count_occurrences(haystack: String, needle: String) -> Int {
+ case string.split(haystack, needle) {
+ [_] -> 0
+ parts -> list.length(parts) - 1
+ }
+}
+
+fn check_port(
+ container_port: Int,
+ host: String,
+ port_map: dict.Dict(Int, Int),
+) -> Result(Nil, error.Error) {
+ use host_port <- result.try(resolve_host_port(container_port, port_map))
+ case tcp_can_connect(host, host_port) {
+ Ok(Nil) -> Ok(Nil)
+ Error(reason) ->
+ Error(error.WaitFailed(
+ "port(" <> int.to_string(container_port) <> ")",
+ reason,
+ ))
+ }
+}
+
+fn check_http(
+ container_port: Int,
+ path: String,
+ expected_status: Int,
+ host: String,
+ port_map: dict.Dict(Int, Int),
+) -> Result(Nil, error.Error) {
+ use host_port <- result.try(resolve_host_port(container_port, port_map))
+ case http_get_status(host, host_port, path) {
+ Ok(status) ->
+ case status == expected_status {
+ True -> Ok(Nil)
+ False ->
+ Error(error.WaitFailed(
+ "http(" <> int.to_string(container_port) <> ", " <> path <> ")",
+ "got HTTP "
+ <> int.to_string(status)
+ <> ", want "
+ <> int.to_string(expected_status),
+ ))
+ }
+ Error(reason) ->
+ Error(error.WaitFailed(
+ "http(" <> int.to_string(container_port) <> ", " <> path <> ")",
+ reason,
+ ))
+ }
+}
+
+fn check_health(inspect: Option(String)) -> Result(Nil, error.Error) {
+ case inspect {
+ None ->
+ Error(error.WaitFailed("health_check", "unable to inspect container"))
+ Some(body) ->
+ case
+ json.parse(
+ body,
+ decode.at(["State", "Health", "Status"], decode.string),
+ )
+ {
+ Ok("healthy") -> Ok(Nil)
+ Ok(status) ->
+ Error(error.WaitFailed("health_check", "status=" <> status))
+ Error(_) ->
+ Error(error.WaitFailed("health_check", "no health status in inspect"))
+ }
+ }
+}
+
+fn check_command(
+ container_id: String,
+ cmd: List(String),
+ expected_exit: Int,
+) -> Result(Nil, error.Error) {
+ case docker.exec_container(container_id, cmd) {
+ Ok(exec.ExecResult(exit_code, _, _)) ->
+ case exit_code == expected_exit {
+ True -> Ok(Nil)
+ False ->
+ Error(error.WaitFailed(
+ "command(" <> string.join(cmd, " ") <> ")",
+ "exit=" <> int.to_string(exit_code),
+ ))
+ }
+ Error(e) -> Error(e)
+ }
+}
+
+fn check_all_of(
+ strategies: List(wait.WaitStrategy),
+ container_id: String,
+ host: String,
+ port_map: dict.Dict(Int, Int),
+ inspect: Option(String),
+) -> Result(Nil, error.Error) {
+ list.try_map(strategies, fn(s) {
+ check_once(s, container_id, host, port_map, inspect)
+ })
+ |> result.map(fn(_) { Nil })
+}
+
+fn check_any_of(
+ strategies: List(wait.WaitStrategy),
+ container_id: String,
+ host: String,
+ port_map: dict.Dict(Int, Int),
+ inspect: Option(String),
+) -> Result(Nil, error.Error) {
+ case strategies {
+ [] -> Error(error.WaitFailed("any_of", "no strategies provided"))
+ [first, ..rest] ->
+ case check_once(first, container_id, host, port_map, inspect) {
+ Ok(Nil) -> Ok(Nil)
+ Error(_) -> check_any_of(rest, container_id, host, port_map, inspect)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Port-mapping helpers - parsed once per `run`, then reused for every poll.
+// ---------------------------------------------------------------------------
+
+fn resolve_host_port(
+ container_port: Int,
+ port_map: dict.Dict(Int, Int),
+) -> Result(Int, error.Error) {
+ case dict.get(port_map, container_port) {
+ Ok(hp) -> Ok(hp)
+ Error(Nil) -> Error(error.PortNotMapped(container_port))
+ }
+}
+
+fn parse_port_map(inspect_json: String) -> dict.Dict(Int, Int) {
+ case json.parse(inspect_json, ports_decoder()) {
+ Ok(raw) ->
+ raw
+ |> dict.to_list
+ |> list.filter_map(parse_entry)
+ |> dict.from_list
+ Error(_) -> dict.new()
+ }
+}
+
+fn parse_entry(
+ entry: #(String, Option(List(PortBinding))),
+) -> Result(#(Int, Int), Nil) {
+ let #(key, bindings) = entry
+ use container_port <- result.try(parse_tcp_key(key))
+ use bs <- result.try(case bindings {
+ Some(value) -> Ok(value)
+ None -> Error(Nil)
+ })
+ use host_port <- result.try(first_host_port(bs))
+ Ok(#(container_port, host_port))
+}
+
+fn parse_tcp_key(key: String) -> Result(Int, Nil) {
+ case string.split(key, "/") {
+ [p, "tcp"] -> int.parse(p)
+ _ -> Error(Nil)
+ }
+}
+
+fn first_host_port(bs: List(PortBinding)) -> Result(Int, Nil) {
+ case bs {
+ [] -> Error(Nil)
+ [PortBinding(hp), ..] -> int.parse(hp)
+ }
+}
diff --git a/src/testcontainer/network.gleam b/src/testcontainer/network.gleam
new file mode 100644
index 0000000..6963443
--- /dev/null
+++ b/src/testcontainer/network.gleam
@@ -0,0 +1,107 @@
+import gleam/erlang/process
+import gleam/result
+
+import testcontainer/error
+import testcontainer/internal/docker
+
+/// A Docker bridge network. Construct with `create/1` (or `with_network/2`
+/// for automatic cleanup) and pass the name to `container.on_network/2`.
+pub opaque type Network {
+ Network(id: String, name: String)
+}
+
+/// Creates a new bridge network with the given name.
+pub fn create(name: String) -> Result(Network, error.Error) {
+ use id <- result.try(docker.create_network(name))
+ Ok(Network(id, name))
+}
+
+/// Removes a network created via `create/1`.
+pub fn remove(network: Network) -> Result(Nil, error.Error) {
+ docker.remove_network(network.id)
+}
+
+/// Creates a network, runs `body/1`, then removes it. Cleanup runs even if
+/// `body/1` returns an error or the caller process crashes (a linked guard
+/// process performs the removal asynchronously on caller down).
+///
+/// use net <- network.with_network("test-net")
+///
+pub fn with_network(
+ name: String,
+ body: fn(Network) -> Result(a, error.Error),
+) -> Result(a, error.Error) {
+ use #(net, guard) <- result.try(create_guarded(name))
+ let body_result = body(net)
+ process.send(guard, GuardStop)
+ let remove_result = remove(net)
+ case body_result, remove_result {
+ Ok(_), Error(rm_e) -> Error(rm_e)
+ _, _ -> body_result
+ }
+}
+
+/// The network's name (as passed to `create/1`).
+pub fn name(network: Network) -> String {
+ network.name
+}
+
+/// The Docker-assigned network id.
+pub fn id(network: Network) -> String {
+ network.id
+}
+
+// ---------------------------------------------------------------------------
+// Guarded creation
+// ---------------------------------------------------------------------------
+
+type GuardEvent {
+ GuardStop
+ ParentDown
+}
+
+// Spawns a linked process that creates the network, sends the result back,
+// and (on success) waits for either GuardStop (clean exit) or a trapped
+// caller-exit signal. On caller crash it fires the network removal in a
+// detached process so the BEAM can shut down cleanly without blocking on
+// Docker.
+fn create_guarded(
+ name: String,
+) -> Result(#(Network, process.Subject(GuardEvent)), error.Error) {
+ let startup_subject: process.Subject(
+ Result(#(Network, process.Subject(GuardEvent)), error.Error),
+ ) = process.new_subject()
+
+ process.spawn(fn() {
+ process.trap_exits(True)
+ let guard = process.new_subject()
+ case create(name) {
+ Ok(net) -> {
+ process.send(startup_subject, Ok(#(net, guard)))
+ guard_loop(net.id, guard)
+ }
+ Error(e) -> process.send(startup_subject, Error(e))
+ }
+ })
+
+ let selector = process.new_selector() |> process.select(startup_subject)
+ process.selector_receive_forever(selector)
+}
+
+fn guard_loop(id: String, subject: process.Subject(GuardEvent)) -> Nil {
+ let selector =
+ process.new_selector()
+ |> process.select(subject)
+ |> process.select_trapped_exits(fn(_) { ParentDown })
+ case process.selector_receive_forever(selector) {
+ GuardStop -> Nil
+ ParentDown -> {
+ // Fire-and-forget remove; transport enforces its own per-call timeouts.
+ process.spawn(fn() {
+ let _ = docker.remove_network(id)
+ Nil
+ })
+ Nil
+ }
+ }
+}
diff --git a/src/testcontainer/port.gleam b/src/testcontainer/port.gleam
new file mode 100644
index 0000000..8e1db0b
--- /dev/null
+++ b/src/testcontainer/port.gleam
@@ -0,0 +1,52 @@
+import testcontainer/error
+
+/// A container port together with its protocol.
+/// Build with `tcp/1` / `udp/1` (panic-free, but trust the caller to pass
+/// a valid number) or `try_tcp/1` / `try_udp/1` (validated `Result`).
+pub opaque type Port {
+ Tcp(Int)
+ Udp(Int)
+}
+
+/// Creates a TCP port. Numbers outside `1..=65535` will be rejected later
+/// by `start/1`. For up-front validation, prefer `try_tcp/1`.
+pub fn tcp(number: Int) -> Port {
+ Tcp(number)
+}
+
+/// Creates a UDP port. See `tcp/1` for validation notes.
+pub fn udp(number: Int) -> Port {
+ Udp(number)
+}
+
+/// Validated TCP port constructor.
+pub fn try_tcp(number: Int) -> Result(Port, error.Error) {
+ case number >= 1 && number <= 65_535 {
+ True -> Ok(Tcp(number))
+ False -> Error(error.InvalidPort(number))
+ }
+}
+
+/// Validated UDP port constructor.
+pub fn try_udp(number: Int) -> Result(Port, error.Error) {
+ case number >= 1 && number <= 65_535 {
+ True -> Ok(Udp(number))
+ False -> Error(error.InvalidPort(number))
+ }
+}
+
+/// Returns the port number.
+pub fn number(port: Port) -> Int {
+ case port {
+ Tcp(n) -> n
+ Udp(n) -> n
+ }
+}
+
+/// Returns `"tcp"` or `"udp"`.
+pub fn protocol(port: Port) -> String {
+ case port {
+ Tcp(_) -> "tcp"
+ Udp(_) -> "udp"
+ }
+}
diff --git a/src/testcontainer/stack.gleam b/src/testcontainer/stack.gleam
new file mode 100644
index 0000000..631a0ff
--- /dev/null
+++ b/src/testcontainer/stack.gleam
@@ -0,0 +1,62 @@
+import testcontainer/error
+import testcontainer/network
+
+/// A `Stack(output)` represents a Docker network whose lifetime spans
+/// multiple containers. The companion entry point is
+/// `testcontainer.with_stack/2`.
+///
+/// ## Recommended pattern
+///
+/// `output` is typically just `Network` - the build function returns the
+/// running network unchanged, and the caller nests `with_container` or
+/// `with_formula` calls inside the `with_stack` body so that each container
+/// is cleaned up by its own guard before the network is removed:
+///
+/// use net <- testcontainer.with_stack(
+/// testcontainer.stack("app-test-net", fn(n) { Ok(n) }),
+/// )
+/// use pg <- testcontainer.with_formula(
+/// postgres.new() |> postgres.on_network(net) |> postgres.formula(),
+/// )
+/// // ...
+///
+/// ## Note on advanced builders
+///
+/// The build function can return any `output`, but it must be a value that
+/// is still meaningful after the function returns. Containers started via
+/// `with_container`/`with_formula` are stopped before their wrapping `use`
+/// returns, so a record carrying live `Container` handles is **not** a
+/// valid `output`. Either return `Network` (or a static record derived
+/// from it) and nest the lifecycle calls in the `with_stack` body, or call
+/// `testcontainer.start/1` directly inside `run` and accept manual
+/// teardown responsibility.
+pub opaque type Stack(output) {
+ Stack(
+ network_name: String,
+ run: fn(network.Network) -> Result(output, error.Error),
+ )
+}
+
+/// Builds a `Stack` with the given network name and a function that, given
+/// the running `Network`, returns the typed output the test will consume.
+///
+/// testcontainer.stack("app-test-net", fn(net) { Ok(net) })
+pub fn new(
+ network_name: String,
+ run: fn(network.Network) -> Result(output, error.Error),
+) -> Stack(output) {
+ Stack(network_name, run)
+}
+
+@internal
+pub fn name(s: Stack(output)) -> String {
+ s.network_name
+}
+
+@internal
+pub fn run(
+ s: Stack(output),
+ net: network.Network,
+) -> Result(output, error.Error) {
+ s.run(net)
+}
diff --git a/src/testcontainer/wait.gleam b/src/testcontainer/wait.gleam
new file mode 100644
index 0000000..8418ae8
--- /dev/null
+++ b/src/testcontainer/wait.gleam
@@ -0,0 +1,175 @@
+import gleam/int
+import gleam/list
+import gleam/string
+
+/// A readiness strategy. Built via constructors (`log/1`, `port/1`, `http/2`,
+/// `health_check/0`, `command/1`, `all_of/1`, `any_of/1`) and tweaked via
+/// `with_timeout/2` and `with_poll_interval/2`.
+pub opaque type WaitStrategy {
+ WaitStrategy(base: WaitStrategyBase, timeout_ms: Int, poll_interval_ms: Int)
+}
+
+/// Internal - exposed only so the polling loop in `internal/wait_runner.gleam`
+/// can pattern-match on the variants. Not part of the stable public API.
+@internal
+pub type WaitStrategyBase {
+ /// "No wait" - succeeds immediately. Used as the default in
+ /// `container.new/1`.
+ ForNone
+ ForLog(String, Int)
+ ForPort(Int)
+ ForHttp(Int, String, Int)
+ ForHealthCheck
+ ForCommand(List(String), Int)
+ AllOf(List(WaitStrategy))
+ AnyOf(List(WaitStrategy))
+}
+
+const default_timeout_ms = 60_000
+
+const default_poll_interval_ms = 1000
+
+fn wrap(base: WaitStrategyBase) -> WaitStrategy {
+ WaitStrategy(base, default_timeout_ms, default_poll_interval_ms)
+}
+
+/// "No wait" - succeeds immediately. This is the default when a
+/// `ContainerSpec` is built with `container.new/1` and no `wait_for/2`
+/// is set.
+pub fn none() -> WaitStrategy {
+ wrap(ForNone)
+}
+
+/// Waits until the container's combined stdout/stderr stream contains the
+/// given message at least once.
+pub fn log(message: String) -> WaitStrategy {
+ wrap(ForLog(message, 1))
+}
+
+/// Like `log/1` but waits until the message appears at least `times` times.
+pub fn log_times(message: String, times: Int) -> WaitStrategy {
+ wrap(ForLog(message, times))
+}
+
+/// Waits until the given (TCP) container port accepts connections from the host.
+pub fn port(port: Int) -> WaitStrategy {
+ wrap(ForPort(port))
+}
+
+/// Waits until an HTTP GET to the given path on the given container port
+/// returns status 200.
+pub fn http(port: Int, path: String) -> WaitStrategy {
+ wrap(ForHttp(port, path, 200))
+}
+
+/// Like `http/2` but waits for a custom expected status code.
+pub fn http_with_status(port: Int, path: String, status: Int) -> WaitStrategy {
+ wrap(ForHttp(port, path, status))
+}
+
+/// Waits for Docker to report the container's HEALTHCHECK as `healthy`.
+/// The image must define a HEALTHCHECK for this to terminate.
+pub fn health_check() -> WaitStrategy {
+ wrap(ForHealthCheck)
+}
+
+/// Runs a command inside the container and waits until it exits 0.
+pub fn command(cmd: List(String)) -> WaitStrategy {
+ wrap(ForCommand(cmd, 0))
+}
+
+/// Composes strategies - succeeds when ALL inner strategies succeed.
+pub fn all_of(strategies: List(WaitStrategy)) -> WaitStrategy {
+ wrap(AllOf(strategies))
+}
+
+/// Composes strategies - succeeds as soon as ANY inner strategy succeeds.
+pub fn any_of(strategies: List(WaitStrategy)) -> WaitStrategy {
+ wrap(AnyOf(strategies))
+}
+
+/// Sets the per-strategy timeout (default 60 s). Negative values are
+/// clamped to 0 (the strategy times out immediately).
+pub fn with_timeout(strategy: WaitStrategy, ms: Int) -> WaitStrategy {
+ let safe = case ms < 0 {
+ True -> 0
+ False -> ms
+ }
+ case strategy {
+ WaitStrategy(base, _, poll) -> WaitStrategy(base, safe, poll)
+ }
+}
+
+/// Sets the polling interval (default 1 s). Values <= 0 are clamped to 1
+/// to avoid a hot-spin loop.
+pub fn with_poll_interval(strategy: WaitStrategy, ms: Int) -> WaitStrategy {
+ let safe = case ms < 1 {
+ True -> 1
+ False -> ms
+ }
+ case strategy {
+ WaitStrategy(base, timeout, _) -> WaitStrategy(base, timeout, safe)
+ }
+}
+
+/// Internal - returns the inner base strategy for pattern matching in
+/// `internal/wait_runner.gleam`.
+@internal
+pub fn base(strategy: WaitStrategy) -> WaitStrategyBase {
+ case strategy {
+ WaitStrategy(b, _, _) -> b
+ }
+}
+
+/// Returns the configured timeout in milliseconds.
+pub fn timeout_ms(strategy: WaitStrategy) -> Int {
+ case strategy {
+ WaitStrategy(_, t, _) -> t
+ }
+}
+
+/// Returns the configured poll interval in milliseconds.
+pub fn poll_interval_ms(strategy: WaitStrategy) -> Int {
+ case strategy {
+ WaitStrategy(_, _, p) -> p
+ }
+}
+
+/// Human-readable description used in `WaitTimedOut` / `WaitFailed` errors.
+pub fn describe(strategy: WaitStrategy) -> String {
+ case strategy {
+ WaitStrategy(base, timeout, poll) -> {
+ let base_desc = case base {
+ ForNone -> "none"
+ ForLog(message, times) ->
+ "log(" <> message <> ", " <> int.to_string(times) <> ")"
+ ForPort(port) -> "port(" <> int.to_string(port) <> ")"
+ ForHttp(port, path, status) ->
+ "http("
+ <> int.to_string(port)
+ <> ", "
+ <> path
+ <> ", "
+ <> int.to_string(status)
+ <> ")"
+ ForHealthCheck -> "health_check"
+ ForCommand(cmd, expected) ->
+ "command("
+ <> string.join(cmd, " ")
+ <> ", "
+ <> int.to_string(expected)
+ <> ")"
+ AllOf(strats) ->
+ "all_of(" <> string.join(list.map(strats, describe), ", ") <> ")"
+ AnyOf(strats) ->
+ "any_of(" <> string.join(list.map(strats, describe), ", ") <> ")"
+ }
+ base_desc
+ <> " [timeout="
+ <> int.to_string(timeout)
+ <> ", poll="
+ <> int.to_string(poll)
+ <> "]"
+ }
+ }
+}
diff --git a/test/integration_docker_test.gleam b/test/integration_docker_test.gleam
new file mode 100644
index 0000000..ba47d31
--- /dev/null
+++ b/test/integration_docker_test.gleam
@@ -0,0 +1,604 @@
+import envie
+import gleam/erlang/process
+import gleam/int
+import gleam/result
+import gleam/string
+import gleeunit/should
+import testcontainer
+import testcontainer/container
+import testcontainer/error
+import testcontainer/formula
+import testcontainer/network
+import testcontainer/port
+import testcontainer/wait
+
+fn integration_enabled() -> Bool {
+ envie.get_bool("TESTCONTAINERS_INTEGRATION", False)
+}
+
+// ---------------------------------------------------------------------------
+// Basic lifecycle
+// ---------------------------------------------------------------------------
+
+pub fn docker_integration_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 10"]),
+ fn(c) {
+ { container.id(c) == "" } |> should.be_false()
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Logs
+// ---------------------------------------------------------------------------
+
+pub fn container_logs_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "echo hello-from-logs"]),
+ fn(c) {
+ let logs = testcontainer.logs(c) |> should.be_ok()
+ logs |> should.not_equal("")
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Exec
+// ---------------------------------------------------------------------------
+
+pub fn container_exec_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(c) {
+ let result =
+ testcontainer.exec(c, ["echo", "hello"]) |> should.be_ok()
+ result.exit_code |> should.equal(0)
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+pub fn exec_nonzero_exit_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(c) {
+ let result =
+ testcontainer.exec(c, ["sh", "-c", "exit 42"]) |> should.be_ok()
+ result.exit_code |> should.equal(42)
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Wait strategies
+// ---------------------------------------------------------------------------
+
+pub fn wait_for_log_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command([
+ "sh",
+ "-c",
+ "echo container-ready && sleep 30",
+ ])
+ |> container.wait_for(wait.log("container-ready")),
+ fn(c) {
+ { container.id(c) == "" } |> should.be_false()
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+pub fn wait_for_port_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("nginx:alpine")
+ |> container.expose_port(port.tcp(80))
+ |> container.wait_for(wait.port(80)),
+ fn(c) {
+ let hp = container.host_port(c, port.tcp(80)) |> should.be_ok()
+ { hp > 0 } |> should.be_true()
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+pub fn wait_for_command_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ // Container writes /tmp/ready after 1 s; wait.command polls until it exists.
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command([
+ "sh",
+ "-c",
+ "sleep 1 && touch /tmp/ready && sleep 30",
+ ])
+ |> container.wait_for(
+ wait.command(["test", "-f", "/tmp/ready"])
+ |> wait.with_timeout(10_000)
+ |> wait.with_poll_interval(300),
+ ),
+ fn(c) {
+ { container.id(c) == "" } |> should.be_false()
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+pub fn wait_timeout_returns_error_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ // File never appears - wait must time out and return an error.
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 30"])
+ |> container.wait_for(
+ wait.command(["test", "-f", "/tmp/never"])
+ |> wait.with_timeout(1000)
+ |> wait.with_poll_interval(200),
+ ),
+ fn(_c) { Ok(Nil) },
+ )
+ |> should.be_error()
+ Nil
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Network lifecycle
+// ---------------------------------------------------------------------------
+
+pub fn network_create_remove_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let network_name = unique_name("testcontainer-test-net")
+ network.with_network(network_name, fn(net) {
+ { network.id(net) == "" } |> should.be_false()
+ { network.name(net) == network_name } |> should.be_true()
+ Ok(Nil)
+ })
+ |> should.be_ok()
+ }
+ }
+}
+
+pub fn container_on_network_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let network_name = unique_name("testcontainer-containers-net")
+ network.with_network(network_name, fn(net) {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 10"])
+ |> container.on_network(network.name(net)),
+ fn(c) {
+ { container.id(c) == "" } |> should.be_false()
+ Ok(Nil)
+ },
+ )
+ })
+ |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Guard crash cleanup
+//
+// Spawns a process that starts a container then exits abnormally.
+// After a short delay we verify the container is no longer running by
+// checking that a second start/stop cycle does not conflict (Docker would
+// error on duplicate named containers if the first were still alive).
+// ---------------------------------------------------------------------------
+
+pub fn guard_crash_cleanup_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ // `process.spawn/1` is linked; trap exits in this test process so the
+ // child panic used for simulation does not fail the test itself.
+ process.trap_exits(True)
+ let container_name = unique_name("guard-crash-test")
+ // Subject used to signal this test process once the container is up.
+ let ready: process.Subject(Nil) = process.new_subject()
+
+ // Spawn a process that starts a named container then panics,
+ // simulating a test process crash before cleanup runs.
+ process.spawn(fn() {
+ let _ =
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_name(container_name)
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(_c) {
+ process.send(ready, Nil)
+ panic as "simulated crash - guard must clean up"
+ },
+ )
+ Nil
+ })
+
+ // Wait up to 60 s for the container to be running.
+ let _ = process.receive(ready, 60_000)
+
+ // Cleanup is fire-and-forget, so poll until name reuse succeeds.
+ assert_name_reusable(container_name, 30_000)
+ |> should.be_ok()
+ process.trap_exits(False)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// copy_file_to
+// ---------------------------------------------------------------------------
+
+pub fn copy_file_to_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let host_path = "/tmp/tc_copy_test_gleam.txt"
+ let content = "hello from copy_file_to"
+ let _ = write_host_file(host_path, content)
+
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(c) {
+ use _ <- result.try(testcontainer.copy_file_to(
+ c,
+ host_path,
+ "/tmp/copied.txt",
+ ))
+ use exec_result <- result.try(
+ testcontainer.exec(c, ["cat", "/tmp/copied.txt"]),
+ )
+ exec_result.stdout |> string.trim() |> should.equal(content)
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// with_formula
+// ---------------------------------------------------------------------------
+
+pub fn with_formula_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let alpine_formula =
+ formula.new(
+ container.new("alpine:3.18")
+ |> container.with_command([
+ "sh",
+ "-c",
+ "echo formula-ready && sleep 30",
+ ])
+ |> container.wait_for(wait.log("formula-ready")),
+ fn(c) { Ok(#(container.id(c), container.host(c))) },
+ )
+
+ testcontainer.with_formula(alpine_formula, fn(output) {
+ let #(id, host) = output
+ { id == "" } |> should.be_false()
+ { host == "" } |> should.be_false()
+ Ok(Nil)
+ })
+ |> should.be_ok()
+ }
+ }
+}
+
+pub fn with_formula_extract_error_propagates_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let bad_formula =
+ formula.new(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 5"]),
+ fn(_c) { Error(error.InvalidImageRef("forced error")) },
+ )
+
+ testcontainer.with_formula(bad_formula, fn(_) { Ok(Nil) })
+ |> should.be_error()
+ Nil
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// with_container_mapped - body returns custom error type
+// ---------------------------------------------------------------------------
+
+type AppError {
+ ContainerErr(error.Error)
+ AppLogic(String)
+}
+
+pub fn with_container_mapped_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let outcome =
+ testcontainer.with_container_mapped(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 5"]),
+ ContainerErr,
+ fn(c) {
+ { container.id(c) == "" } |> should.be_false()
+ Ok(42)
+ },
+ )
+ case outcome {
+ Ok(v) -> v |> should.equal(42)
+ Error(_) -> should.equal("unexpected error", "")
+ }
+ }
+ }
+}
+
+pub fn with_container_mapped_propagates_app_error_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let outcome =
+ testcontainer.with_container_mapped(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 5"]),
+ ContainerErr,
+ fn(_c) { Error(AppLogic("boom")) },
+ )
+ case outcome {
+ Error(AppLogic(msg)) -> msg |> should.equal("boom")
+ _ -> should.equal("expected AppLogic error", "")
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// start_and_keep forces keep regardless of TESTCONTAINERS_KEEP
+// ---------------------------------------------------------------------------
+
+pub fn start_and_keep_forces_keep_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let name = unique_name("start-and-keep")
+ let c =
+ testcontainer.start_and_keep(
+ container.new("alpine:3.18")
+ |> container.with_name(name)
+ |> container.with_command(["sh", "-c", "sleep 60"]),
+ )
+ |> should.be_ok()
+
+ container.keep(c) |> should.be_true()
+ testcontainer.stop(c) |> should.be_ok()
+ testcontainer.exec(c, ["echo", "still-running"]) |> should.be_ok()
+ testcontainer.force_stop(c) |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// AllOf / AnyOf - composed wait strategies must terminate
+// ---------------------------------------------------------------------------
+
+pub fn wait_all_of_terminates_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("nginx:alpine")
+ |> container.expose_port(port.tcp(80))
+ |> container.wait_for(
+ wait.all_of([wait.port(80), wait.http(80, "/")])
+ |> wait.with_timeout(30_000),
+ ),
+ fn(c) {
+ container.host_port(c, port.tcp(80)) |> should.be_ok()
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+pub fn wait_any_of_terminates_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command([
+ "sh",
+ "-c",
+ "echo any-of-ready && sleep 30",
+ ])
+ |> container.wait_for(
+ wait.any_of([
+ wait.log("any-of-ready"),
+ // This second branch will never succeed, ensuring the first path
+ // really is what completes the wait.
+ wait.command(["test", "-f", "/never"]),
+ ])
+ |> wait.with_timeout(15_000),
+ ),
+ fn(_c) { Ok(Nil) },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Exec stderr split - non-zero command must populate stderr
+// ---------------------------------------------------------------------------
+
+pub fn exec_stderr_split_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(c) {
+ let r =
+ testcontainer.exec(c, [
+ "sh",
+ "-c",
+ "echo on-stdout && echo on-stderr 1>&2 && exit 3",
+ ])
+ |> should.be_ok()
+ r.exit_code |> should.equal(3)
+ string.contains(r.stdout, "on-stdout") |> should.be_true()
+ string.contains(r.stderr, "on-stderr") |> should.be_true()
+ Ok(Nil)
+ },
+ )
+ |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Stack - two containers on a shared network, cleanup ordering
+// ---------------------------------------------------------------------------
+
+pub fn with_stack_pings_across_containers_test() {
+ case integration_enabled() {
+ False -> Nil
+ True -> {
+ let stack_network = unique_name("tc-stack-test-net")
+ let stack_server = unique_name("tc-stack-server")
+ let outcome =
+ testcontainer.with_stack(
+ testcontainer.stack(stack_network, fn(net) { Ok(net) }),
+ fn(net) {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_name(stack_server)
+ |> container.on_network(network.name(net))
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(_server) {
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.on_network(network.name(net))
+ |> container.with_command(["sh", "-c", "sleep 30"]),
+ fn(client) {
+ let r =
+ testcontainer.exec(client, [
+ "ping", "-c", "1", stack_server,
+ ])
+ |> should.be_ok()
+ r.exit_code |> should.equal(0)
+ Ok(Nil)
+ },
+ )
+ },
+ )
+ },
+ )
+ outcome |> should.be_ok()
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+@external(erlang, "file", "write_file")
+fn write_host_file(path: String, content: String) -> Result(Nil, String)
+
+@external(erlang, "erlang", "unique_integer")
+fn unique_integer() -> Int
+
+fn unique_name(prefix: String) -> String {
+ prefix <> "-" <> int.to_string(unique_integer())
+}
+
+fn assert_name_reusable(
+ name: String,
+ remaining_ms: Int,
+) -> Result(Nil, error.Error) {
+ let reuse_attempt =
+ testcontainer.with_container(
+ container.new("alpine:3.18")
+ |> container.with_name(name)
+ |> container.with_command(["sh", "-c", "sleep 1"]),
+ fn(_c) { Ok(Nil) },
+ )
+
+ case reuse_attempt {
+ Ok(Nil) -> Ok(Nil)
+ Error(error.ContainerCreateFailed(image, reason)) ->
+ case remaining_ms > 0 && string.contains(reason, "already in use") {
+ True -> {
+ process.sleep(500)
+ assert_name_reusable(name, remaining_ms - 500)
+ }
+ False -> Error(error.ContainerCreateFailed(image, reason))
+ }
+ Error(e) -> Error(e)
+ }
+}
diff --git a/test/testcontainer_core_test.gleam b/test/testcontainer_core_test.gleam
new file mode 100644
index 0000000..ce7878c
--- /dev/null
+++ b/test/testcontainer_core_test.gleam
@@ -0,0 +1,348 @@
+import cowl
+import gleam/string
+import gleeunit/should
+import testcontainer/port
+import testcontainer/wait
+
+pub fn port_number_test() {
+ let p = port.tcp(5432)
+ port.number(p) |> should.equal(5432)
+ port.protocol(p) |> should.equal("tcp")
+}
+
+pub fn port_udp_test() {
+ let p = port.udp(53)
+ port.number(p) |> should.equal(53)
+ port.protocol(p) |> should.equal("udp")
+}
+
+pub fn wait_describe_log_test() {
+ let s = wait.log("ready")
+ let d = wait.describe(s)
+ string.contains(d, "log(ready") |> should.be_true()
+}
+
+pub fn wait_describe_http_test() {
+ let s = wait.http(8080, "/health")
+ let d = wait.describe(s)
+ string.contains(d, "http(8080") |> should.be_true()
+}
+
+pub fn wait_describe_port_test() {
+ let s = wait.port(6379)
+ let d = wait.describe(s)
+ string.contains(d, "port(6379") |> should.be_true()
+}
+
+pub fn wait_timeout_test() {
+ let s = wait.log("ready") |> wait.with_timeout(5000)
+ wait.timeout_ms(s) |> should.equal(5000)
+}
+
+pub fn wait_poll_interval_test() {
+ let s = wait.log("ready") |> wait.with_poll_interval(500)
+ wait.poll_interval_ms(s) |> should.equal(500)
+}
+
+pub fn wait_all_of_describe_test() {
+ let s = wait.all_of([wait.log("ready"), wait.port(5432)])
+ let d = wait.describe(s)
+ string.contains(d, "all_of") |> should.be_true()
+}
+
+pub fn wait_any_of_describe_test() {
+ let s = wait.any_of([wait.log("ready"), wait.http(8080, "/health")])
+ let d = wait.describe(s)
+ string.contains(d, "any_of") |> should.be_true()
+}
+
+// ---------------------------------------------------------------------------
+// ExecResult helpers
+// ---------------------------------------------------------------------------
+
+import testcontainer/exec
+
+pub fn exec_result_succeeded_test() {
+ let r = exec.ExecResult(0, "ok\n", "")
+ exec.succeeded(r) |> should.be_true()
+}
+
+pub fn exec_result_failed_test() {
+ let r = exec.ExecResult(1, "", "error\n")
+ exec.succeeded(r) |> should.be_false()
+}
+
+pub fn exec_result_output_test() {
+ let r = exec.ExecResult(0, "out", "err")
+ exec.output(r) |> should.equal("outerr")
+}
+
+// ---------------------------------------------------------------------------
+// ImageRef parsing
+// ---------------------------------------------------------------------------
+
+import testcontainer/internal/image_ref
+
+pub fn image_ref_simple_test() {
+ let ref = image_ref.parse("alpine:3.18")
+ ref.name |> should.equal("alpine")
+ ref.tag |> should.equal("3.18")
+}
+
+pub fn image_ref_no_tag_test() {
+ let ref = image_ref.parse("alpine")
+ ref.name |> should.equal("alpine")
+ ref.tag |> should.equal("latest")
+}
+
+pub fn image_ref_registry_with_port_test() {
+ // registry:5000/org/image:tag - the "5000" should NOT be taken as tag
+ let ref = image_ref.parse("registry.io:5000/postgres:16")
+ ref.name |> should.equal("registry.io:5000/postgres")
+ ref.tag |> should.equal("16")
+}
+
+pub fn image_ref_no_tag_with_registry_test() {
+ let ref = image_ref.parse("registry.io:5000/postgres")
+ ref.tag |> should.equal("latest")
+}
+
+// ---------------------------------------------------------------------------
+// Docker transport
+// ---------------------------------------------------------------------------
+
+import testcontainer/internal/docker
+
+pub fn docker_transport_chunked_response_test() {
+ let raw =
+ "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n"
+ <> "58\r\n{"
+ <> "\"Id\":\"abc\"}"
+ <> "\r\n0\r\n\r\n"
+
+ case docker.parse_response(raw) {
+ Ok(#(201, body)) ->
+ string.contains(body, "\"Id\":\"abc\"") |> should.be_true()
+ Ok(#(_, _)) -> should.equal("unexpected status", "")
+ Error(e) -> should.equal(e, "")
+ }
+}
+
+pub fn docker_transport_plain_response_test() {
+ let raw = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nOK"
+ case docker.parse_response(raw) {
+ Ok(#(200, body)) -> body |> should.equal("OK")
+ Ok(#(_, _)) -> should.equal("unexpected status", "")
+ Error(e) -> should.equal(e, "")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Container.with_keep - used by start_and_keep to force the keep flag
+// ---------------------------------------------------------------------------
+
+import gleam/dict
+import testcontainer/container
+
+pub fn container_with_keep_flips_flag_test() {
+ let c = container.build("id-123", "127.0.0.1", dict.new(), False, 10)
+ container.keep(c) |> should.be_false()
+
+ let kept = container.with_keep(c, True)
+ container.keep(kept) |> should.be_true()
+ container.id(kept) |> should.equal("id-123")
+}
+
+pub fn container_host_default_test() {
+ let c = container.build("id", "127.0.0.1", dict.new(), False, 10)
+ container.host(c) |> should.equal("127.0.0.1")
+}
+
+// ---------------------------------------------------------------------------
+// Wait composition describe
+// ---------------------------------------------------------------------------
+
+pub fn wait_all_of_describe_includes_children_test() {
+ let s = wait.all_of([wait.log("ready"), wait.port(5432)])
+ let d = wait.describe(s)
+ string.contains(d, "log(ready") |> should.be_true()
+ string.contains(d, "port(5432") |> should.be_true()
+}
+
+pub fn wait_any_of_describe_includes_children_test() {
+ let s = wait.any_of([wait.log("a"), wait.http(80, "/healthz")])
+ let d = wait.describe(s)
+ string.contains(d, "log(a") |> should.be_true()
+ string.contains(d, "http(80") |> should.be_true()
+}
+
+pub fn wait_none_describe_test() {
+ wait.none() |> wait.describe |> string.contains("none") |> should.be_true()
+}
+
+// ---------------------------------------------------------------------------
+// Port validated constructors
+// ---------------------------------------------------------------------------
+
+pub fn port_try_tcp_valid_test() {
+ port.try_tcp(5432) |> should.be_ok()
+}
+
+pub fn port_try_tcp_zero_rejected_test() {
+ port.try_tcp(0) |> should.be_error()
+}
+
+pub fn port_try_tcp_above_range_rejected_test() {
+ port.try_tcp(70_000) |> should.be_error()
+}
+
+pub fn port_try_udp_valid_test() {
+ port.try_udp(53) |> should.be_ok()
+}
+
+pub fn port_try_udp_negative_rejected_test() {
+ port.try_udp(-1) |> should.be_error()
+}
+
+// ---------------------------------------------------------------------------
+// Secret redaction - env values must never leak via string.inspect
+// ---------------------------------------------------------------------------
+
+pub fn with_env_does_not_leak_value_in_inspect_test() {
+ let spec =
+ container.new("alpine:3.18")
+ |> container.with_env("DB_PASSWORD", "supersecret-do-not-leak")
+ let inspected = string.inspect(spec)
+ string.contains(inspected, "supersecret-do-not-leak")
+ |> should.be_false()
+}
+
+pub fn with_secret_env_does_not_leak_value_in_inspect_test() {
+ let spec =
+ container.new("alpine:3.18")
+ |> container.with_secret_env(
+ "API_TOKEN",
+ cowl.secret("token-must-stay-redacted"),
+ )
+ let inspected = string.inspect(spec)
+ string.contains(inspected, "token-must-stay-redacted")
+ |> should.be_false()
+}
+
+pub fn with_envs_does_not_leak_values_in_inspect_test() {
+ let spec =
+ container.new("alpine:3.18")
+ |> container.with_envs([
+ #("USER", "app"),
+ #("PASSWORD", "another-leak-canary"),
+ ])
+ let inspected = string.inspect(spec)
+ string.contains(inspected, "another-leak-canary")
+ |> should.be_false()
+}
+
+// ---------------------------------------------------------------------------
+// Wait input clamping
+// ---------------------------------------------------------------------------
+
+pub fn wait_with_timeout_clamps_negative_test() {
+ let s = wait.log("ready") |> wait.with_timeout(-1)
+ wait.timeout_ms(s) |> should.equal(0)
+}
+
+pub fn wait_with_timeout_accepts_zero_test() {
+ let s = wait.log("ready") |> wait.with_timeout(0)
+ wait.timeout_ms(s) |> should.equal(0)
+}
+
+pub fn wait_with_poll_interval_clamps_zero_test() {
+ let s = wait.log("ready") |> wait.with_poll_interval(0)
+ wait.poll_interval_ms(s) |> should.equal(1)
+}
+
+pub fn wait_with_poll_interval_clamps_negative_test() {
+ let s = wait.log("ready") |> wait.with_poll_interval(-5)
+ wait.poll_interval_ms(s) |> should.equal(1)
+}
+
+// ---------------------------------------------------------------------------
+// Pull policy parsing (case-insensitive)
+// ---------------------------------------------------------------------------
+
+import testcontainer/internal/config
+
+pub fn pull_policy_lowercase_test() {
+ config.parse_pull_policy("always") |> should.equal(config.Always)
+ config.parse_pull_policy("never") |> should.equal(config.Never)
+ config.parse_pull_policy("missing") |> should.equal(config.IfMissing)
+}
+
+pub fn pull_policy_uppercase_test() {
+ config.parse_pull_policy("ALWAYS") |> should.equal(config.Always)
+ config.parse_pull_policy("NEVER") |> should.equal(config.Never)
+}
+
+pub fn pull_policy_mixed_case_test() {
+ config.parse_pull_policy("Always") |> should.equal(config.Always)
+ config.parse_pull_policy("Never") |> should.equal(config.Never)
+}
+
+pub fn pull_policy_unknown_falls_back_test() {
+ config.parse_pull_policy("nonsense") |> should.equal(config.IfMissing)
+ config.parse_pull_policy("") |> should.equal(config.IfMissing)
+}
+
+// ---------------------------------------------------------------------------
+// ImageRef edge cases (heuristic documented in image_ref.gleam)
+// ---------------------------------------------------------------------------
+
+pub fn image_ref_bare_host_port_takes_port_as_tag_test() {
+ // Documented edge case: with no `/` segment, the trailing colon-segment
+ // is treated as a tag. To force the registry-port interpretation,
+ // append the image name (see test below).
+ let ref = image_ref.parse("registry:5000")
+ ref.name |> should.equal("registry")
+ ref.tag |> should.equal("5000")
+}
+
+pub fn image_ref_localhost_with_port_test() {
+ let ref = image_ref.parse("localhost:8000/myimg")
+ ref.name |> should.equal("localhost:8000/myimg")
+ ref.tag |> should.equal("latest")
+}
+
+pub fn image_ref_localhost_with_port_and_tag_test() {
+ let ref = image_ref.parse("localhost:8000/myimg:1.2.3")
+ ref.name |> should.equal("localhost:8000/myimg")
+ ref.tag |> should.equal("1.2.3")
+}
+
+// ---------------------------------------------------------------------------
+// Container.stop_timeout_sec accessor
+// ---------------------------------------------------------------------------
+
+pub fn container_carries_stop_timeout_test() {
+ let c = container.build("id", "127.0.0.1", dict.new(), False, 15)
+ container.stop_timeout_sec(c) |> should.equal(15)
+}
+
+pub fn container_with_keep_preserves_stop_timeout_test() {
+ let c =
+ container.build("id", "127.0.0.1", dict.new(), False, 7)
+ |> container.with_keep(True)
+ container.stop_timeout_sec(c) |> should.equal(7)
+}
+
+// ---------------------------------------------------------------------------
+// Public API surface: force_stop must exist and have the expected signature
+// (compile-time check; runtime behaviour is covered by integration tests).
+// ---------------------------------------------------------------------------
+
+import testcontainer
+import testcontainer/error as tc_error
+
+pub fn force_stop_is_public_test() {
+ let _ref: fn(container.Container) -> Result(Nil, tc_error.Error) =
+ testcontainer.force_stop
+ Nil
+}
diff --git a/test/testcontainer_test.gleam b/test/testcontainer_test.gleam
new file mode 100644
index 0000000..902c4da
--- /dev/null
+++ b/test/testcontainer_test.gleam
@@ -0,0 +1,5 @@
+import gleeunit
+
+pub fn main() -> Nil {
+ gleeunit.main()
+}