-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, with_standalone_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", 461). -spec option_from_result({ok, HFM} | {error, any()}) -> gleam@option:option(HFM). option_from_result(R) -> case R of {ok, V} -> {some, V}; {error, _} -> none end. -file("src/testcontainer.gleam", 454). -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", 480). -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", 435). -spec entry_to_mapping({binary(), gleam@option:option(list(port_binding()))}) -> {ok, gleam@option:option({{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) -> case Bindings of none -> {ok, none}; {some, Bs} -> case pick_binding(Bs) of {some, Host_port} -> {ok, {some, {Key, Host_port}}}; none -> {error, <<"invalid host port binding in inspect for key: "/utf8, Spec/binary>>} end end end). -file("src/testcontainer.gleam", 472). -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", 390). -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) -> Bound = gleam@list:filter_map(Mappings, fun(O) -> case O of {some, V} -> {ok, V}; none -> {error, nil} end end), {ok, maps:from_list(Bound)} end ) end ). -file("src/testcontainer.gleam", 383). -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", 276). -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", 286). -spec combine({ok, HEM} | {error, HEN}, {ok, nil} | {error, HEN}) -> {ok, HEM} | {error, HEN}. combine(Body, Cleanup) -> case {Body, Cleanup} of {{ok, _}, {error, E}} -> {error, E}; {_, _} -> Body end. -file("src/testcontainer.gleam", 346). -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", 316). -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, HCJ} | {error, testcontainer@error:error()}) ) -> {ok, HCJ} | {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()) -> HCO), fun((testcontainer@container:container()) -> {ok, HCP} | {error, HCO}) ) -> {ok, HCP} | {error, HCO}. 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, HCU} | {error, testcontainer@error:error()}) ) -> {ok, HCU} | {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, HCZ} | {error, testcontainer@error:error()}) ) -> testcontainer@stack:stack(HCZ). 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(HDD), fun((HDD) -> {ok, HDF} | {error, testcontainer@error:error()}) ) -> {ok, HDF} | {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(HDT), fun((HDT) -> {ok, HDV} | {error, testcontainer@error:error()}) ) -> {ok, HDV} | {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", 262). ?DOC( " Acquires a resource using a `StandaloneFormula`, calls `body/1`, then\n" " releases the resource. Release always runs even if body returns an error.\n" " If body succeeded but release failed, the release error is surfaced.\n" "\n" " use stack <- testcontainer.with_standalone_formula(compose_formula)\n" ). -spec with_standalone_formula( testcontainer@formula:standalone_formula(HEA, HEB), fun((HEA) -> {ok, HEE} | {error, HEB}) ) -> {ok, HEE} | {error, HEB}. with_standalone_formula(F, Body) -> gleam@result:'try'( testcontainer@formula:standalone_acquire(F), fun(Output) -> Body_result = Body(Output), combine(Body_result, testcontainer@formula:standalone_release(F)) end ). -file("src/testcontainer.gleam", 296). ?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 ).