Packages

Testcontainers for Gleam: typed container lifecycle with crash-safe cleanup and a native Unix-socket Docker client.

Current section

Files

Jump to
testcontainer src testcontainer.erl
Raw

src/testcontainer.erl

-module(testcontainer).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/testcontainer.gleam").
-export([start/1, force_stop/1, stop/1, with_container/2, with_container_mapped/3, with_network/2, stack/2, with_stack/2, exec/2, logs/1, logs_tail/2, copy_file_to/3, with_formula/2, start_and_keep/1]).
-export_type([guard_event/0, port_binding/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
-type guard_event() :: guard_stop | parent_down.
-type port_binding() :: {port_binding, binary(), binary()}.
-file("src/testcontainer.gleam", 435).
-spec option_from_result({ok, HBG} | {error, any()}) -> gleam@option:option(HBG).
option_from_result(R) ->
case R of
{ok, V} ->
{some, V};
{error, _} ->
none
end.
-file("src/testcontainer.gleam", 428).
-spec pick_binding(list(port_binding())) -> gleam@option:option(integer()).
pick_binding(Bs) ->
_pipe = gleam@list:find(
Bs,
fun(B) ->
(erlang:element(2, B) =:= <<"0.0.0.0"/utf8>>) orelse (erlang:element(
2,
B
)
=:= <<""/utf8>>)
end
),
_pipe@1 = gleam@result:try_recover(
_pipe,
fun(_) -> gleam@list:first(Bs) end
),
_pipe@2 = gleam@result:'try'(
_pipe@1,
fun(B@1) -> gleam_stdlib:parse_int(erlang:element(3, B@1)) end
),
option_from_result(_pipe@2).
-file("src/testcontainer.gleam", 454).
-spec parse_port_spec(binary()) -> gleam@option:option({integer(), binary()}).
parse_port_spec(Port_spec) ->
case gleam@string:split(Port_spec, <<"/"/utf8>>) of
[P, Proto] ->
case gleam_stdlib:parse_int(P) of
{ok, I} ->
{some, {I, Proto}};
{error, _} ->
none
end;
[P@1] ->
case gleam_stdlib:parse_int(P@1) of
{ok, I@1} ->
{some, {I@1, <<"tcp"/utf8>>}};
{error, _} ->
none
end;
_ ->
none
end.
-file("src/testcontainer.gleam", 408).
-spec entry_to_mapping({binary(), gleam@option:option(list(port_binding()))}) -> {ok,
{{integer(), binary()}, integer()}} |
{error, binary()}.
entry_to_mapping(Entry) ->
{Spec, Bindings} = Entry,
gleam@result:'try'(case parse_port_spec(Spec) of
{some, Parsed} ->
{ok, Parsed};
none ->
{error, <<"invalid inspect port key: "/utf8, Spec/binary>>}
end, fun(Key) -> gleam@result:'try'(case Bindings of
{some, Value} ->
{ok, Value};
none ->
{error,
<<"no host binding in inspect for port key: "/utf8,
Spec/binary>>}
end, fun(Bs) -> gleam@result:'try'(case pick_binding(Bs) of
{some, Value@1} ->
{ok, Value@1};
none ->
{error,
<<"invalid host port binding in inspect for key: "/utf8,
Spec/binary>>}
end, fun(Host_port) -> {ok, {Key, Host_port}} end) end) end).
-file("src/testcontainer.gleam", 446).
-spec port_binding_decoder() -> gleam@dynamic@decode:decoder(port_binding()).
port_binding_decoder() ->
gleam@dynamic@decode:field(
<<"HostIp"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Host_ip) ->
gleam@dynamic@decode:field(
<<"HostPort"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Host_port) ->
gleam@dynamic@decode:success(
{port_binding, Host_ip, Host_port}
)
end
)
end
).
-file("src/testcontainer.gleam", 375).
-spec parse_port_mapping(binary(), binary()) -> {ok,
gleam@dict:dict({integer(), binary()}, integer())} |
{error, testcontainer@error:error()}.
parse_port_mapping(Container_id, Inspect_json) ->
Ports_decoder = gleam@dynamic@decode:at(
[<<"NetworkSettings"/utf8>>, <<"Ports"/utf8>>],
gleam@dynamic@decode:dict(
{decoder, fun gleam@dynamic@decode:decode_string/1},
gleam@dynamic@decode:optional(
gleam@dynamic@decode:list(port_binding_decoder())
)
)
),
gleam@result:'try'(
begin
_pipe = gleam@json:parse(Inspect_json, Ports_decoder),
gleam@result:map_error(
_pipe,
fun(_) ->
{port_mapping_parse_failed,
Container_id,
<<"unable to decode inspect NetworkSettings.Ports payload"/utf8>>}
end
)
end,
fun(Decoded) ->
gleam@result:'try'(
begin
_pipe@1 = Decoded,
_pipe@2 = maps:to_list(_pipe@1),
_pipe@3 = gleam@list:try_map(
_pipe@2,
fun entry_to_mapping/1
),
gleam@result:map_error(
_pipe@3,
fun(Reason) ->
{port_mapping_parse_failed, Container_id, Reason}
end
)
end,
fun(Mappings) -> {ok, maps:from_list(Mappings)} end
)
end
).
-file("src/testcontainer.gleam", 368).
-spec resolve_gateway(gleam@option:option(binary())) -> binary().
resolve_gateway(Host_override) ->
case Host_override of
{some, H} ->
H;
none ->
<<"127.0.0.1"/utf8>>
end.
-file("src/testcontainer.gleam", 34).
-spec start_internal(testcontainer@container:container_spec()) -> {ok,
testcontainer@container:container()} |
{error, testcontainer@error:error()}.
start_internal(Spec) ->
Cfg = testcontainer@internal@config:load(),
Keep = erlang:element(3, Cfg),
Stop_timeout = erlang:element(7, Cfg),
Gateway = resolve_gateway(erlang:element(5, Cfg)),
gleam@result:'try'(
testcontainer@internal@docker:ping(),
fun(_) ->
Image = testcontainer@container:image(Spec),
gleam@result:'try'(case erlang:element(4, Cfg) of
never ->
case testcontainer@internal@docker:image_exists(Image) of
true ->
{ok, nil};
false ->
{error,
{image_pull_failed,
Image,
<<"TESTCONTAINERS_PULL_POLICY=never and image not present locally"/utf8>>}}
end;
if_missing ->
case testcontainer@internal@docker:image_exists(Image) of
true ->
{ok, nil};
false ->
testcontainer@internal@docker:pull_image(
Image,
erlang:element(6, Cfg)
)
end;
always ->
testcontainer@internal@docker:pull_image(
Image,
erlang:element(6, Cfg)
)
end, fun(_) ->
gleam@result:'try'(
testcontainer@internal@docker:create_container(Spec),
fun(Id) ->
gleam@result:'try'(
begin
_pipe = testcontainer@internal@docker:start_container(
Id
),
gleam@result:map_error(
_pipe,
fun(E) ->
_ = testcontainer@internal@docker:remove_container(
Id
),
E
end
)
end,
fun(_) ->
Strategy = testcontainer@container:wait_strategy(
Spec
),
gleam@result:'try'(
begin
_pipe@1 = testcontainer@internal@wait_runner:run(
Strategy,
Id,
Gateway
),
gleam@result:map_error(
_pipe@1,
fun(E@1) ->
_ = testcontainer@internal@docker:stop_container(
Id,
Stop_timeout
),
_ = testcontainer@internal@docker:remove_container(
Id
),
E@1
end
)
end,
fun(_) ->
gleam@result:'try'(
begin
_pipe@2 = testcontainer@internal@docker:inspect_container(
Id
),
gleam@result:map_error(
_pipe@2,
fun(E@2) ->
_ = testcontainer@internal@docker:stop_container(
Id,
Stop_timeout
),
_ = testcontainer@internal@docker:remove_container(
Id
),
E@2
end
)
end,
fun(Inspect) ->
gleam@result:'try'(
begin
_pipe@3 = parse_port_mapping(
Id,
Inspect
),
gleam@result:map_error(
_pipe@3,
fun(E@3) ->
_ = testcontainer@internal@docker:stop_container(
Id,
Stop_timeout
),
_ = testcontainer@internal@docker:remove_container(
Id
),
E@3
end
)
end,
fun(Ports) ->
{ok,
testcontainer@container:build(
Id,
Gateway,
Ports,
Keep,
Stop_timeout
)}
end
)
end
)
end
)
end
)
end
)
end)
end
).
-file("src/testcontainer.gleam", 28).
?DOC(
" Starts a container described by `spec` and returns it.\n"
" The caller is responsible for calling `stop/1` when done.\n"
" For automatic cleanup, prefer `with_container/2`.\n"
).
-spec start(testcontainer@container:container_spec()) -> {ok,
testcontainer@container:container()} |
{error, testcontainer@error:error()}.
start(Spec) ->
start_internal(Spec).
-file("src/testcontainer.gleam", 117).
?DOC(
" Stops and removes a container ignoring the keep flag. Useful to\n"
" programmatically tear down containers that were started with\n"
" `start_and_keep/1` or under `TESTCONTAINERS_KEEP=true`.\n"
).
-spec force_stop(testcontainer@container:container()) -> {ok, nil} |
{error, testcontainer@error:error()}.
force_stop(C) ->
Id = testcontainer@container:id(C),
gleam@result:'try'(
testcontainer@internal@docker:stop_container(
Id,
testcontainer@container:stop_timeout_sec(C)
),
fun(_) -> testcontainer@internal@docker:remove_container(Id) end
).
-file("src/testcontainer.gleam", 107).
?DOC(
" Stops and removes a container.\n"
" When `TESTCONTAINERS_KEEP` is set (or the container was started via\n"
" `start_and_keep/1`) the container is left running for manual\n"
" inspection. Use `force_stop/1` to tear it down regardless.\n"
).
-spec stop(testcontainer@container:container()) -> {ok, nil} |
{error, testcontainer@error:error()}.
stop(C) ->
case testcontainer@container:keep(C) of
true ->
{ok, nil};
false ->
force_stop(C)
end.
-file("src/testcontainer.gleam", 261).
-spec cleanup(
testcontainer@container:container(),
gleam@erlang@process:subject(guard_event())
) -> {ok, nil} | {error, testcontainer@error:error()}.
cleanup(C, Guard) ->
gleam@erlang@process:send(Guard, guard_stop),
stop(C).
-file("src/testcontainer.gleam", 271).
-spec combine({ok, HAH} | {error, HAI}, {ok, nil} | {error, HAI}) -> {ok, HAH} |
{error, HAI}.
combine(Body, Cleanup) ->
case {Body, Cleanup} of
{{ok, _}, {error, E}} ->
{error, E};
{_, _} ->
Body
end.
-file("src/testcontainer.gleam", 331).
-spec guard_loop(
binary(),
gleam@erlang@process:subject(guard_event()),
boolean(),
integer()
) -> nil.
guard_loop(Id, Subject, Keep, Stop_timeout) ->
Selector = begin
_pipe = gleam_erlang_ffi:new_selector(),
_pipe@1 = gleam@erlang@process:select(_pipe, Subject),
gleam@erlang@process:select_trapped_exits(
_pipe@1,
fun(_) -> parent_down end
)
end,
case gleam_erlang_ffi:select(Selector) of
guard_stop ->
nil;
parent_down ->
case Keep of
true ->
nil;
false ->
proc_lib:spawn_link(
fun() ->
_ = testcontainer@internal@docker:stop_container(
Id,
Stop_timeout
),
_ = testcontainer@internal@docker:remove_container(
Id
),
nil
end
),
nil
end
end.
-file("src/testcontainer.gleam", 301).
-spec start_guarded(testcontainer@container:container_spec()) -> {ok,
{testcontainer@container:container(),
gleam@erlang@process:subject(guard_event())}} |
{error, testcontainer@error:error()}.
start_guarded(Spec) ->
Startup_subject = gleam@erlang@process:new_subject(),
proc_lib:spawn_link(
fun() ->
gleam_erlang_ffi:trap_exits(true),
Guard = gleam@erlang@process:new_subject(),
case start_internal(Spec) of
{ok, C} ->
gleam@erlang@process:send(Startup_subject, {ok, {C, Guard}}),
guard_loop(
testcontainer@container:id(C),
Guard,
testcontainer@container:keep(C),
testcontainer@container:stop_timeout_sec(C)
);
{error, E} ->
gleam@erlang@process:send(Startup_subject, {error, E})
end
end
),
Selector = begin
_pipe = gleam_erlang_ffi:new_selector(),
gleam@erlang@process:select(_pipe, Startup_subject)
end,
gleam_erlang_ffi:select(Selector).
-file("src/testcontainer.gleam", 128).
?DOC(
" Starts a container, runs `body/1`, then stops and removes it.\n"
" A linked guard process ensures cleanup also runs if the caller crashes.\n"
"\n"
" use c <- testcontainer.with_container(spec)\n"
).
-spec with_container(
testcontainer@container:container_spec(),
fun((testcontainer@container:container()) -> {ok, GYN} |
{error, testcontainer@error:error()})
) -> {ok, GYN} | {error, testcontainer@error:error()}.
with_container(Spec, Body) ->
gleam@result:'try'(
start_guarded(Spec),
fun(_use0) ->
{C, Guard} = _use0,
Body_result = Body(C),
combine(Body_result, cleanup(C, Guard))
end
).
-file("src/testcontainer.gleam", 145).
?DOC(
" Like `with_container/2` but maps the library's error type into a custom\n"
" error type before propagating.\n"
"\n"
" use c <- testcontainer.with_container_mapped(\n"
" spec,\n"
" fn(e) { MyError.Container(e) },\n"
" )\n"
).
-spec with_container_mapped(
testcontainer@container:container_spec(),
fun((testcontainer@error:error()) -> GYS),
fun((testcontainer@container:container()) -> {ok, GYT} | {error, GYS})
) -> {ok, GYT} | {error, GYS}.
with_container_mapped(Spec, Map_error, Body) ->
gleam@result:'try'(
begin
_pipe = start_guarded(Spec),
gleam@result:map_error(_pipe, Map_error)
end,
fun(_use0) ->
{Started, Guard} = _use0,
Body_result = Body(Started),
combine(
Body_result,
begin
_pipe@1 = cleanup(Started, Guard),
gleam@result:map_error(_pipe@1, Map_error)
end
)
end
).
-file("src/testcontainer.gleam", 163).
?DOC(
" Creates a Docker network, runs `body/1`, then removes it. Cleanup runs\n"
" even if `body/1` returns an error. Re-export of `network.with_network/2`\n"
" for one-import ergonomics.\n"
"\n"
" use net <- testcontainer.with_network(\"test-net\")\n"
).
-spec with_network(
binary(),
fun((testcontainer@network:network()) -> {ok, GYY} |
{error, testcontainer@error:error()})
) -> {ok, GYY} | {error, testcontainer@error:error()}.
with_network(Name, Body) ->
testcontainer@network:with_network(Name, Body).
-file("src/testcontainer.gleam", 172).
?DOC(
" Builds a `Stack(output)` - a network plus a typed multi-container build\n"
" function. See `with_stack/2`.\n"
).
-spec stack(
binary(),
fun((testcontainer@network:network()) -> {ok, GZD} |
{error, testcontainer@error:error()})
) -> testcontainer@stack:stack(GZD).
stack(Network_name, Build) ->
testcontainer@stack:new(Network_name, Build).
-file("src/testcontainer.gleam", 195).
?DOC(
" Creates the stack's network, runs the stack's build function to produce\n"
" a typed `output`, then runs `body/1` against that output. The network is\n"
" torn down after `body/1` returns. The recommended pattern is to let the\n"
" stack provide a `Network` and nest `with_container` / `with_formula`\n"
" calls inside `body`, so each container is cleaned up by its own guard\n"
" before the network is removed:\n"
"\n"
" use net <- testcontainer.with_stack(\n"
" testcontainer.stack(\"app-test-net\", fn(n) { Ok(n) }),\n"
" )\n"
" use pg <- testcontainer.with_formula(\n"
" postgres.new() |> postgres.on_network(net) |> postgres.formula(),\n"
" )\n"
" // ...\n"
"\n"
" See `testcontainer/stack.{Stack}` for notes on advanced builders.\n"
).
-spec with_stack(
testcontainer@stack:stack(GZH),
fun((GZH) -> {ok, GZJ} | {error, testcontainer@error:error()})
) -> {ok, GZJ} | {error, testcontainer@error:error()}.
with_stack(S, Body) ->
testcontainer@network:with_network(
testcontainer@stack:name(S),
fun(Net) ->
gleam@result:'try'(
testcontainer@stack:run(S, Net),
fun(Out) -> Body(Out) end
)
end
).
-file("src/testcontainer.gleam", 206).
?DOC(" Executes a command inside a running container.\n").
-spec exec(testcontainer@container:container(), list(binary())) -> {ok,
testcontainer@exec:exec_result()} |
{error, testcontainer@error:error()}.
exec(C, Cmd) ->
testcontainer@internal@docker:exec_container(
testcontainer@container:id(C),
Cmd
).
-file("src/testcontainer.gleam", 215).
?DOC(
" Returns the combined stdout+stderr log output of a running container\n"
" (full log).\n"
).
-spec logs(testcontainer@container:container()) -> {ok, binary()} |
{error, testcontainer@error:error()}.
logs(C) ->
testcontainer@internal@docker:container_logs(
testcontainer@container:id(C),
none
).
-file("src/testcontainer.gleam", 220).
?DOC(" Like `logs/1` but returns only the last `n` lines.\n").
-spec logs_tail(testcontainer@container:container(), integer()) -> {ok,
binary()} |
{error, testcontainer@error:error()}.
logs_tail(C, N) ->
testcontainer@internal@docker:container_logs(
testcontainer@container:id(C),
{some, N}
).
-file("src/testcontainer.gleam", 233).
?DOC(
" Copies a file from the host filesystem into a running container.\n"
" `host_path` must be an absolute path to a readable file on the host.\n"
" `container_path` is the absolute destination path inside the container.\n"
"\n"
" use _ <- result.try(testcontainer.copy_file_to(c, \"/host/init.sql\", \"/tmp/init.sql\"))\n"
).
-spec copy_file_to(testcontainer@container:container(), binary(), binary()) -> {ok,
nil} |
{error, testcontainer@error:error()}.
copy_file_to(C, Host_path, Container_path) ->
testcontainer@internal@docker:copy_file_to(
testcontainer@container:id(C),
Host_path,
Container_path
).
-file("src/testcontainer.gleam", 247).
?DOC(
" Starts a container using a `Formula`, extracts the typed output, then\n"
" calls `body/1`. Lifecycle and cleanup work exactly like `with_container/2`.\n"
"\n"
" use pg <- testcontainer.with_formula(postgres.formula(config))\n"
" // pg :: PostgresContainer\n"
).
-spec with_formula(
testcontainer@formula:formula(GZX),
fun((GZX) -> {ok, GZZ} | {error, testcontainer@error:error()})
) -> {ok, GZZ} | {error, testcontainer@error:error()}.
with_formula(F, Body) ->
gleam@result:'try'(
start_guarded(testcontainer@formula:spec(F)),
fun(_use0) ->
{C, Guard} = _use0,
Body_result = gleam@result:'try'(
testcontainer@formula:extract(F, C),
Body
),
combine(Body_result, cleanup(C, Guard))
end
).
-file("src/testcontainer.gleam", 281).
?DOC(
" Starts a container and forces the keep flag, so it will NOT be removed\n"
" by `stop/1` even if `TESTCONTAINERS_KEEP=false`. Useful for inspection\n"
" and debugging from a REPL.\n"
).
-spec start_and_keep(testcontainer@container:container_spec()) -> {ok,
testcontainer@container:container()} |
{error, testcontainer@error:error()}.
start_and_keep(Spec) ->
gleam@result:'try'(
start_guarded(Spec),
fun(_use0) ->
{C, Guard} = _use0,
gleam@erlang@process:send(Guard, guard_stop),
{ok, testcontainer@container:with_keep(C, true)}
end
).