Current section
Files
Jump to
Current section
Files
src/testcontainer.gleam
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
}
}