Add utilities for creating test clusters

These utilities allow us to setup a test cluster for use during unit
tests. By default this will setup a cluster once per VM and also re-use
state from old test runs if it exists. Tests should ensure an empty
cluster when necessary by specifying the `empty` option when calling
`erlfdb_util:get_test_db/1`.
diff --git a/.gitignore b/.gitignore
index 2a481e7..6782584 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,5 @@
 c_src/*.d
 c_src/*.o
 ebin/
-priv/
+
+priv/erlfdb_nif.*
diff --git a/priv/monitor.py b/priv/monitor.py
new file mode 100755
index 0000000..643ace9
--- /dev/null
+++ b/priv/monitor.py
@@ -0,0 +1,24 @@
+#!/usr/bin/python
+
+import os
+import sys
+import time
+
+
+def main():
+    parent_pid = int(sys.argv[1])
+    target_pid = int(sys.argv[2])
+    while True:
+        try:
+            os.kill(parent_pid, 0)
+            time.sleep(1.0)
+        except OSError:
+            try:
+                os.kill(target_pid, 9)
+            except:
+                pass
+            exit(0)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/erlfdb_util.erl b/src/erlfdb_util.erl
index cd9f99d..a6846f2 100644
--- a/src/erlfdb_util.erl
+++ b/src/erlfdb_util.erl
@@ -14,6 +14,11 @@
 
 
 -export([
+    get_test_db/0,
+    get_test_db/1,
+
+    init_test_cluster/1,
+
     get/2,
     get/3,
 
@@ -21,6 +26,32 @@
 ]).
 
 
+get_test_db() ->
+    get_test_db([]).
+
+
+get_test_db(Options) ->
+    {ok, ClusterFile} = init_test_cluster(Options),
+    Db = erlfdb:open(ClusterFile),
+    case proplists:get_value(empty, Options) of
+        true ->
+            erlfdb:transactional(Db, fun(Tx) ->
+                erlfdb:clear_range(Tx, <<>>, <<16#FE, 16#FF, 16#FF, 16#FF>>)
+            end);
+        _ ->
+            ok
+    end,
+    Db.
+
+
+init_test_cluster(Options) ->
+    case application:get_env(erlfdb, test_cluster_file) of
+        {ok, ClusterFile} ->
+            {ok, ClusterFile};
+        undefined ->
+            init_test_cluster_int(Options)
+    end.
+
 
 get(List, Key) ->
     get(List, Key, undefined).
@@ -45,3 +76,153 @@
             _ -> io_lib:format("\\x~2.16.0b", [C])
         end
     end, binary_to_list(Bin)) ++ [$'].
+
+
+init_test_cluster_int(Options) ->
+    {ok, CWD} = file:get_cwd(),
+    DefaultIpAddr = {127, 0, 0, 1},
+    DefaultPort = get_available_port(),
+    DefaultDir = filename:join(CWD, ".erlfdb"),
+
+
+    IpAddr = ?MODULE:get(Options, ip_addr, DefaultIpAddr),
+    Port = ?MODULE:get(Options, port, DefaultPort),
+    Dir = ?MODULE:get(Options, dir, DefaultDir),
+    ClusterName = ?MODULE:get(Options, cluster_name, <<"erlfdbtest">>),
+    ClusterId = ?MODULE:get(Options, cluster_id, <<"erlfdbtest">>),
+
+    DefaultClusterFile = filename:join(Dir, <<"erlfdb.cluster">>),
+    ClusterFile = ?MODULE:get(Options, cluster_file, DefaultClusterFile),
+
+    write_cluster_file(ClusterFile, ClusterName, ClusterId, IpAddr, Port),
+
+    FDBServerBin = find_fdbserver_bin(Options),
+
+    {FDBPid, _} = spawn_monitor(fun() ->
+        % Open the fdbserver port
+        FDBPortName = {spawn_executable, FDBServerBin},
+        FDBPortArgs = [
+            <<"-p">>, ip_port_to_str(IpAddr, Port),
+            <<"-C">>, ClusterFile,
+            <<"-d">>, Dir,
+            <<"-L">>, Dir
+        ],
+        FDBPortOpts = [{args, FDBPortArgs}],
+        FDBServer = erlang:open_port(FDBPortName, FDBPortOpts),
+        {os_pid, FDBPid} = erlang:port_info(FDBServer, os_pid),
+
+        % Open the monitor pid
+        MonitorPath = get_monitor_path(),
+        ErlPid = os:getpid(),
+
+        MonitorPortName = {spawn_executable, MonitorPath},
+        MonitorPortArgs = [{args, [ErlPid, integer_to_binary(FDBPid)]}],
+        Monitor = erlang:open_port(MonitorPortName, MonitorPortArgs),
+
+        init_fdb_db(ClusterFile, Options),
+
+        receive
+            {wait_for_init, ParentPid} ->
+                ParentPid ! {initialized, self()}
+        after 5000 ->
+            true = erlang:port_close(FDBServer),
+            true = erlang:port_close(Monitor),
+            erlang:error(fdb_parent_died)
+        end,
+
+        port_loop(FDBServer, Monitor),
+
+        true = erlang:port_close(FDBServer),
+        true = erlang:port_close(Monitor)
+    end),
+
+    FDBPid ! {wait_for_init, self()},
+    receive
+        {initialized, FDBPid} ->
+            ok;
+        Msg ->
+            erlang:error({fdbserver_error, Msg})
+    end,
+
+    ok = application:set_env(erlfdb, test_cluster_file, ClusterFile),
+    ok = application:set_env(erlfdb, test_cluster_pid, FDBPid),
+    {ok, ClusterFile}.
+
+
+get_available_port() ->
+    {ok, Socket} = gen_tcp:listen(0, []),
+    {ok, Port} = inet:port(Socket),
+    ok = gen_tcp:close(Socket),
+    Port.
+
+
+find_fdbserver_bin(Options) ->
+    Locations = case ?MODULE:get(Options, fdbserver_bin) of
+        undefined ->
+            [
+                <<"/usr/sbin/fdbserver">>,
+                <<"/usr/local/sbin/fdbserver">>,
+                <<"/usr/local/libexec/fdbserver">>
+            ];
+        Else ->
+            [Else]
+    end,
+    case lists:filter(fun filelib:is_file/1, Locations) of
+        [Path | _] -> Path;
+        [] -> erlang:error(fdbserver_bin_not_found)
+    end.
+
+
+write_cluster_file(FileName, ClusterName, ClusterId, IpAddr, Port) ->
+    Args = [ClusterName, ClusterId, ip_port_to_str(IpAddr, Port)],
+    Contents = io_lib:format("~s:~s@~s~n", Args),
+    ok = filelib:ensure_dir(FileName),
+    ok = file:write_file(FileName, iolist_to_binary(Contents)).
+
+
+get_monitor_path() ->
+    PrivDir = case code:priv_dir(?MODULE) of
+        {error, _} ->
+            EbinDir = filename:dirname(code:which(?MODULE)),
+            AppPath = filename:dirname(EbinDir),
+            filename:join(AppPath, "priv");
+        Path ->
+            Path
+    end,
+    filename:join(PrivDir, "monitor.py").
+
+
+init_fdb_db(ClusterFile, Options) ->
+    DefaultFDBCli = os:find_executable("fdbcli"),
+    FDBCli = case ?MODULE:get(Options, fdbcli_bin, DefaultFDBCli) of
+        false -> erlang:error(fdbcli_not_found);
+        FDBCli0 -> FDBCli0
+    end,
+    Fmt = "~s -C ~s --exec 'configure new single ssd'",
+    Cmd = lists:flatten(io_lib:format(Fmt, [FDBCli, ClusterFile])),
+    case os:cmd(Cmd) of
+        "Database created" ++ _ -> ok;
+        "ERROR: Database already exists!" ++ _ -> ok;
+        Msg -> erlang:error({fdb_init_error, Msg})
+    end.
+
+
+port_loop(FDBServer, Monitor) ->
+    receive
+        close ->
+            ok;
+        {FDBServer, {data, "FDBD joined cluster.\n"}} ->
+            % Silence start message
+            port_loop(FDBServer, Monitor);
+        {Port, {data, Msg}} when Port == FDBServer orelse Port == Monitor ->
+            io:format(standard_error, "~p", [Msg]),
+            port_loop(FDBServer, Monitor);
+        Error ->
+            erlang:exit({fdb_cluster_error, Error})
+    end.
+
+
+ip_port_to_str({I1, I2, I3, I4}, Port) ->
+    Fmt = "~b.~b.~b.~b:~b",
+    iolist_to_binary(io_lib:format(Fmt, [I1, I2, I3, I4, Port])).
+
diff --git a/test/erlfdb_02_anon_fdbserver_test.erl b/test/erlfdb_02_anon_fdbserver_test.erl
new file mode 100644
index 0000000..aa37042
--- /dev/null
+++ b/test/erlfdb_02_anon_fdbserver_test.erl
@@ -0,0 +1,74 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+%   http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(erlfdb_02_anon_fdbserver_test).
+
+-include_lib("eunit/include/eunit.hrl").
+
+
+basic_init_test() ->
+    {ok, ClusterFile} = erlfdb_util:init_test_cluster([]),
+    ?assert(is_binary(ClusterFile)).
+
+
+basic_open_test() ->
+    {ok, ClusterFile} = erlfdb_util:init_test_cluster([]),
+    Db = erlfdb:open(ClusterFile),
+    erlfdb:transactional(Db, fun(Tx) ->
+        ?assert(true)
+    end).
+
+
+get_db_test() ->
+    Db = erlfdb_util:get_test_db(),
+    erlfdb:transactional(Db, fun(Tx) ->
+        ?assert(true)
+    end).
+
+
+get_set_get_test() ->
+    Db = erlfdb_util:get_test_db(),
+    Key = crypto:strong_rand_bytes(8),
+    Val = crypto:strong_rand_bytes(8),
+    erlfdb:transactional(Db, fun(Tx) ->
+        ?assertEqual(not_found, erlfdb:wait(erlfdb:get(Tx, Key)))
+    end),
+    erlfdb:transactional(Db, fun(Tx) ->
+        ?assertEqual(ok, erlfdb:set(Tx, Key, Val))
+    end),
+    erlfdb:transactional(Db, fun(Tx) ->
+        ?assertEqual(Val, erlfdb:wait(erlfdb:get(Tx, Key)))
+    end).
+
+
+get_empty_test() ->
+    Db1 = erlfdb_util:get_test_db(),
+    Key = crypto:strong_rand_bytes(8),
+    Val = crypto:strong_rand_bytes(8),
+    erlfdb:transactional(Db1, fun(Tx) ->
+        ok = erlfdb:set(Tx, Key, Val)
+    end),
+    erlfdb:transactional(Db1, fun(Tx) ->
+        ?assertEqual(Val, erlfdb:wait(erlfdb:get(Tx, Key)))
+    end),
+
+    % Check we can get an empty db
+    Db2 = erlfdb_util:get_test_db([empty]),
+    erlfdb:transactional(Db2, fun(Tx) ->
+        ?assertEqual(not_found, erlfdb:wait(erlfdb:get(Tx, Key)))
+    end),
+
+    % And check state that the old db handle is
+    % the same
+    erlfdb:transactional(Db1, fun(Tx) ->
+        ?assertEqual(not_found, erlfdb:wait(erlfdb:get(Tx, Key)))
+    end).