-module(testcontainer_compose_ffi).
-export([run_compose_up/3, run_compose_down/2, compose_config_json/2]).
%% Timeout safety: kill the port if Docker hangs
-define(PORT_TIMEOUT_MS, 60000).
run_compose_up(FilePath, ProjectName, EnvOverrides) ->
with_env_overrides(EnvOverrides, fun() ->
docker_compose([
"compose", "-f", binary_to_list(FilePath),
"-p", binary_to_list(ProjectName),
"up", "-d", "--wait"
])
end).
run_compose_down(FilePath, ProjectName) ->
docker_compose([
"compose", "-f", binary_to_list(FilePath),
"-p", binary_to_list(ProjectName),
"down", "--volumes", "--remove-orphans"
]).
compose_config_json(FilePath, EnvOverrides) ->
with_env_overrides(EnvOverrides, fun() ->
docker_compose([
"compose", "-f", binary_to_list(FilePath),
"config", "--format", "json"
])
end).
%% Set env overrides, run Fun, then restore old values.
with_env_overrides([], Fun) ->
Fun();
with_env_overrides(Overrides, Fun) ->
Saved = lists:map(fun({Key, Val}) ->
K = binary_to_list(Key),
Old = os:getenv(K),
os:putenv(K, binary_to_list(Val)),
{K, Old}
end, Overrides),
Result = Fun(),
lists:foreach(fun({K, Old}) ->
case Old of
false -> os:unsetenv(K);
_ -> os:putenv(K, Old)
end
end, Saved),
Result.
docker_compose(Args) ->
case os:find_executable("docker") of
false ->
{error, <<"docker executable not found in PATH">>};
Docker ->
try
Port = erlang:open_port(
{spawn_executable, Docker},
[
exit_status, binary, use_stdio,
stderr_to_stdout, {args, Args}
]
),
collect_output(Port, <<>>)
catch
Class:Reason ->
Msg = io_lib:format("port spawn failed: ~p:~p", [Class, Reason]),
{error, list_to_binary(Msg)}
end
end.
collect_output(Port, Acc) ->
receive
{Port, {data, Data}} ->
collect_output(Port, <<Acc/binary, Data/binary>>);
{Port, {exit_status, 0}} ->
{ok, string:trim(Acc)};
{Port, {exit_status, Code}} ->
Trimmed = string:trim(Acc),
Reason = format_exit_reason(Code, Trimmed),
{error, Reason}
after ?PORT_TIMEOUT_MS ->
try erlang:port_close(Port) of
_ -> ok
catch
_:_ -> ok
end,
Msg = io_lib:format("docker compose timed out after ~Bms", [?PORT_TIMEOUT_MS]),
{error, list_to_binary(Msg)}
end.
format_exit_reason(Code, Output) ->
Prefix = case Code of
125 -> <<"docker daemon error: ">>;
126 -> <<"docker not executable: ">>;
127 -> <<"docker not found: ">>;
_ -> Prefix0 = io_lib:format("docker exit ~B: ", [Code]),
list_to_binary(Prefix0)
end,
case Output of
<<>> -> Prefix;
_ -> <<Prefix/binary, Output/binary>>
end.