-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, <>); {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; _ -> <> end.