From bda237ca3a941b822917346d3a50adf039034ba3 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:27:38 +0200 Subject: [PATCH 01/14] Create testcontainer.gleam --- src/testcontainer.gleam | 468 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 src/testcontainer.gleam 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 + } +} From aa3afd9bb23f6d53a8b214c0700d9751eba50862 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:29:58 +0200 Subject: [PATCH 02/14] Add testcontainer core types and API Introduce core testcontainer modules: container.gleam, error.gleam, and exec.gleam. container.gleam provides Volume, ContainerSpec builder/modifiers and accessors, a runtime Container handle with port mapping, host/gateway helpers, and internal helpers (volume_kind, build, with_keep). --- src/testcontainer/container.gleam | 331 ++++++++++++++++++++++++++++++ src/testcontainer/error.gleam | 58 ++++++ src/testcontainer/exec.gleam | 24 +++ 3 files changed, 413 insertions(+) create mode 100644 src/testcontainer/container.gleam create mode 100644 src/testcontainer/error.gleam create mode 100644 src/testcontainer/exec.gleam 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 + } +} From e01828a3839ccd757e962c3c7e69063758e2558a Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:31:44 +0200 Subject: [PATCH 03/14] Add Formula type for container formulas Introduce src/testcontainer/formula.gleam defining an opaque Formula(output) that pairs a ContainerSpec with a typed extraction function. --- src/testcontainer/formula.gleam | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/testcontainer/formula.gleam 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) +} From 137f90ee2574cddea745567b7da9c0764073b456 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:32:41 +0200 Subject: [PATCH 04/14] Add Docker Engine API transport and helpers Introduce internal Docker integration: an Erlang docker_transport module for raw HTTP over unix/tcp sockets (connect, request/response parsing, chunked dechunking, file copy via tar, log frame handling), and Gleam wrappers/utilities: docker.gleam (Docker Engine API client: ping, image pull, container create/start/stop/remove/inspect/logs/exec, network and file copy helpers), config.gleam (env-based config, pull policy, registry auth), image_ref.gleam (image:tag parsing), and wait_runner.gleam (wait strategies: port, http, logs, health, command, compose combinators). Adds registry auth encoding, port mapping parsing, and robust error handling for Docker API interactions. --- src/testcontainer/internal/config.gleam | 80 +++ src/testcontainer/internal/docker.gleam | 652 ++++++++++++++++++ .../internal/docker_transport.erl | 403 +++++++++++ src/testcontainer/internal/image_ref.gleam | 46 ++ src/testcontainer/internal/wait_runner.gleam | 350 ++++++++++ 5 files changed, 1531 insertions(+) create mode 100644 src/testcontainer/internal/config.gleam create mode 100644 src/testcontainer/internal/docker.gleam create mode 100644 src/testcontainer/internal/docker_transport.erl create mode 100644 src/testcontainer/internal/image_ref.gleam create mode 100644 src/testcontainer/internal/wait_runner.gleam 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) + } +} From feb5781d9c59dcfb28622b0439aca852b0657c1e Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:33:36 +0200 Subject: [PATCH 05/14] Add testcontainer network/port/stack/wait modules Introduce four new testcontainer modules: network, port, stack, and wait. network provides Docker bridge lifecycle (create/remove) and with_network that spawns a linked guard process to ensure cleanup on normal exit or caller crash. port defines a Port type with tcp/udp constructors and validated try_tcp/try_udp helpers. stack adds a Stack builder to manage a network lifetime spanning multiple containers. wait implements a WaitStrategy type with common readiness checks (log, port, http, health_check, command, all_of/any_of), timeout/poll configuration. --- src/testcontainer/network.gleam | 107 +++++++++++++++++++ src/testcontainer/port.gleam | 52 ++++++++++ src/testcontainer/stack.gleam | 62 +++++++++++ src/testcontainer/wait.gleam | 175 ++++++++++++++++++++++++++++++++ 4 files changed, 396 insertions(+) create mode 100644 src/testcontainer/network.gleam create mode 100644 src/testcontainer/port.gleam create mode 100644 src/testcontainer/stack.gleam create mode 100644 src/testcontainer/wait.gleam 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) + <> "]" + } + } +} From 326704d44ae1a42b60ef5f7cec0ef1de656d8ec0 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:34:43 +0200 Subject: [PATCH 06/14] Add Gleam unit and integration tests Add three new test files --- test/integration_docker_test.gleam | 604 +++++++++++++++++++++++++++++ test/testcontainer_core_test.gleam | 348 +++++++++++++++++ test/testcontainer_test.gleam | 5 + 3 files changed, 957 insertions(+) create mode 100644 test/integration_docker_test.gleam create mode 100644 test/testcontainer_core_test.gleam create mode 100644 test/testcontainer_test.gleam 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() +} From ee36cda7b10fa123bfd3ba3990a810c6b98bf2d6 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:41:04 +0200 Subject: [PATCH 07/14] Add dev runner and docs for testcontainer Add a Gleam dev runner (dev/testcontainer_dev.gleam) with interactive demos covering container lifecycle, logs, exec, wait strategies, port mapping, file copy, formulas, and network examples. Add comprehensive documentation pages (docs/) for configuration, quickstart, formulas, networks & stacks, wait strategies, and troubleshooting to guide usage and debugging. These additions provide examples and reference material to help developers onboard and debug testcontainer usage. --- dev/testcontainer_dev.gleam | 399 ++++++++++++++++++++++++++++++++++++ docs/configuration.md | 77 +++++++ docs/formulas.md | 185 +++++++++++++++++ docs/networks-and-stacks.md | 108 ++++++++++ docs/quickstart.md | 97 +++++++++ docs/troubleshooting.md | 111 ++++++++++ docs/wait-strategies.md | 108 ++++++++++ 7 files changed, 1085 insertions(+) create mode 100644 dev/testcontainer_dev.gleam create mode 100644 docs/configuration.md create mode 100644 docs/formulas.md create mode 100644 docs/networks-and-stacks.md create mode 100644 docs/quickstart.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/wait-strategies.md 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). From ba13ea8d84511a63cc98ccf2129af84ee412141c Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:41:17 +0200 Subject: [PATCH 08/14] Create test.yml --- .github/workflows/test.yml | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ef15a46 --- /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.14.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.14.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 From 25e396c73141d23bc178f46d9faaa6be7d90f468 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:43:03 +0200 Subject: [PATCH 09/14] Create logo.png --- assets/img/logo.png | Bin 0 -> 103859 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/img/logo.png diff --git a/assets/img/logo.png b/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b3fb8404c5a5f801426e8aa60173c9c19b9b02ac GIT binary patch literal 103859 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 IM6N<$f&|OrAOHXW literal 0 HcmV?d00001 From 5ded87cc2f10202e78075708ab8b65ef174b9d7d Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:44:53 +0200 Subject: [PATCH 10/14] Add Gleam project config and manifest Initialize a new Gleam library 'testcontainer' by adding project configuration files. Includes gleam.toml with package metadata, internal_modules, dependencies and dev-dependencies; a generated manifest.toml for resolved packages; and a simple rebar.config enabling debug_info. --- gleam.toml | 18 ++++++++++++++++++ manifest.toml | 21 +++++++++++++++++++++ rebar.config | 1 + 3 files changed, 40 insertions(+) create mode 100644 gleam.toml create mode 100644 manifest.toml create mode 100644 rebar.config 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]}. From 9f345ba6774d226ed2b3aabe744ec5ffb234e311 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:45:44 +0200 Subject: [PATCH 11/14] Create CHANGELOG.md --- CHANGELOG.md | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 CHANGELOG.md 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. From d6f1737768de8de24b3baafbc8e7295b5e3d5d30 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:49:47 +0200 Subject: [PATCH 12/14] Update copyright in LICENSE Replace the copyright holder line to 'Daniele Scaratti - lupdoevelop' in the LICENSE file to reflect the full name/handle. No other license text changes were made. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 7c8ccd317f126202aeb5d17851cba77ef7ec417d Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:50:22 +0200 Subject: [PATCH 13/14] Enhance README with logo, badges & docs README with a full project landing page: adds centered logo and Pago mascot, status badges, a short intro, features list, installation instructions, a Gleam usage example, and links to documentation (quickstart, wait strategies, formulas, networks, configuration, troubleshooting). Also includes license badge and pointer to Hex/HexDocs. --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) 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. +

+ Pago, the paguro mascot, carrying a Docker container on his shell +

+ +

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). From 1cc388b228350dedd773a505f536ada1cb8998b1 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 28 Apr 2026 17:57:03 +0200 Subject: [PATCH 14/14] Update test.yml --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef15a46..73d9991 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - uses: erlef/setup-beam@v1 with: otp-version: "28" - gleam-version: "1.14.0" + gleam-version: "1.16.0" rebar3-version: "3" - run: gleam deps download - run: gleam test @@ -29,7 +29,7 @@ jobs: - uses: erlef/setup-beam@v1 with: otp-version: "28" - gleam-version: "1.14.0" + gleam-version: "1.16.0" rebar3-version: "3" # GitHub-hosted ubuntu runners ship Docker by default; verify it. - name: Verify Docker is available

+ Hex Package + Hex Docs + CI + License + Made with Gleam +