diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..73d9991 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,41 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.16.0" + rebar3-version: "3" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test + + integration: + runs-on: ubuntu-latest + needs: unit + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.16.0" + rebar3-version: "3" + # GitHub-hosted ubuntu runners ship Docker by default; verify it. + - name: Verify Docker is available + run: docker version + - run: gleam deps download + - name: Run integration tests + env: + TESTCONTAINERS_INTEGRATION: "true" + run: gleam test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db1e066 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,128 @@ + + +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +## [0.1.0] - 2026-04-26 + +First public release. Package name: **`testcontainer`** (the plural form +is taken on Hex). + +### Added + +#### Public API + +- `testcontainer`: + - Lifecycle: `start/1`, `stop/1`, `start_and_keep/1`, + `with_container/2`, `with_container_mapped/3`, `with_formula/2`, + `with_network/2`, `with_stack/2` + - Runtime ops: `exec/2` (separate stdout/stderr), `logs/1`, + `logs_tail/2`, `copy_file_to/3` +- `testcontainer/container` builder: `new/1`, `with_env/3`, + `with_envs/2`, `with_secret_env/3`, `expose_port/2`, `expose_ports/2`, + `wait_for/2`, `with_command/2`, `with_entrypoint/2`, + `with_bind_mount/3`, `with_readonly_bind/3`, `with_tmpfs/2`, + `with_volume/2`, `on_network/2`, `with_name/2`, `with_label/3`, + `with_privileged/1`. `Volume` is opaque, built via `bind_mount/2`, + `readonly_bind_mount/2`, or `tmpfs/1`. +- `testcontainer/wait`: `none`, `log`, `log_times`, `port`, `http`, + `http_with_status`, `health_check`, `command`, `all_of`, `any_of`, + plus `with_timeout/2` and `with_poll_interval/2` modifiers. +- `testcontainer/network`: `create/1`, `remove/1`, `with_network/2`. + Backed by a linked guard process so a parent crash still triggers + network removal. +- `testcontainer/formula`: bridge type consumed by the separate + `testcontainer_formulas` package. +- `testcontainer/port`: validated constructors `try_tcp/1` / `try_udp/1` + return `Error.InvalidPort` for out-of-range numbers. +- `testcontainer/stack`: typed multi-container builders. + +#### Lifecycle & cleanup + +- Linked guard process per container/network using + `proc_lib:spawn_link` so the link is established atomically with + the spawn, with no leak window on caller crash during startup. +- `with_*` functions surface cleanup failures when the body succeeded, + so a leaked container/network is never silent. +- `start_and_keep/1` forces the keep flag regardless of + `TESTCONTAINERS_KEEP`. +- `testcontainer.force_stop/1` tears down a container regardless of + the keep flag (works on containers started with `start_and_keep/1` + or under `TESTCONTAINERS_KEEP=true`). +- `TESTCONTAINERS_STOP_TIMEOUT` env var (default `10`s) controls the + stop grace period across `stop/1`, wait-failure cleanup, and the + guard's crash-cleanup spawn. +- `Container` carries the configured stop timeout; internal + `container.stop_timeout_sec/1` accessor exposes it to lifecycle code. + +#### Configuration + +- Env-driven via [`envie`](https://hex.pm/packages/envie): + `DOCKER_HOST`, `TESTCONTAINERS_KEEP`, `TESTCONTAINERS_PULL_POLICY`, + `TESTCONTAINERS_HOST_OVERRIDE`, `TESTCONTAINERS_REGISTRY_USER`, + `TESTCONTAINERS_REGISTRY_PASSWORD`. +- Pull policies: `always` / `missing` / `never`. `never` returns a + clear `ImagePullFailed` if the image is missing locally. +- Private registries supported via `X-Registry-Auth`. + +#### Robustness + +- Pull-stream error detection: Docker's "200 OK with embedded error + payload" pattern is reported as `ImagePullFailed` instead of + cascading into a cryptic create error. +- CR/LF validation on image references, container names, and volume + paths. +- Port range validation (`1..=65535`) returning `Error.InvalidPort`. +- `port_mapping` keyed by `(port_number, protocol)` so TCP and UDP + ports with the same number do not collide. +- `start/1` fails fast with `PortMappingParseFailed(container_id, reason)` + when Docker's inspect port mapping cannot be decoded, instead of + silently falling back to an empty mapping. + +#### Secrets + +- Env values are wrapped in [`cowl.Secret`](https://hex.pm/packages/cowl) + so they never appear in `string.inspect` output. Verified by tests. + +#### Other + +- `DOCKER_HOST=tcp://host:port` supported as plain HTTP/1.1 + (TLS planned separately). +- MIT licence. + +### Internal + +- `wait_runner.run/3` takes the resolved host as an argument; the + runner no longer calls `config.load()` on every poll. +- The wait-runner fetches the container's inspect payload once per + poll iteration and reuses it for every port resolution and health + check inside that iteration (including nested `all_of` / `any_of`). +- `internal/docker.url_encode` rewritten as a single grapheme-pass + instead of 11 sequential `string.replace` calls. +- `scan_pull_stream_for_error` rewritten as a single `split_once` pass. +- `encode_registry_auth` emits standard base64 (no padding) instead + of URL-safe base64, matching the Docker SDK reference encoding. +- `config.parse_pull_policy` is case-insensitive (`ALWAYS`, `Always`, + and `always` all map to `Always`). +- `wait.with_timeout/2` clamps negative values to `0`; + `wait.with_poll_interval/2` clamps values `< 1` to `1` to prevent a + hot-spin loop. +- `with_container_mapped/3` destructures the guarded result inline + for clarity (no behaviour change). + +### Notes + +- `woof` is a dev-only dependency. The library core does not log; + applications can wire their own logging on top of the public API. + +### Naming + +- `container.named/2` β†’ `container.with_name/2` +- `container.privileged/1` β†’ `container.with_privileged/1` +- `Volume` is now opaque. Construct via `container.bind_mount/2`, + `readonly_bind_mount/2`, or `tmpfs/1` instead of the data + constructors. diff --git a/LICENSE b/LICENSE index 4c945f1..82bdc50 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Daniele +Copyright (c) 2026 Daniele Scaratti - lupdoevelop Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5653584..c6d8c37 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ -# testcontainer -A small, type-safe Gleam library for spinning up real Docker containers from your tests and dev tooling. +

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

+ +

testcontainer

+ +

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

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