lib/kantan_cluster.ex

defmodule KantanCluster do
  @moduledoc """
  Form a simple Erlang cluster easily in Elixir.
  """

  @typedoc """
  A node type.
  See https://hexdocs.pm/elixir/1.12/Node.html#start/3
  """
  @type node_type :: :longnames | :shortnames

  @typedoc """
  Options for a cluster.

  * `:node` - the name of a node that we want to start (default: `{:longnames, :"xxxx@127.0.0.1"}` where `xxxx` is a random string)
  * `:cookie` - [Erlang magic cookie] to form a cluster (default: random cookie)
  * `:connect_to` - a list of nodes we want our node to be connected with (default: `[]`)

  """
  @type option() ::
          {:node, {node_type(), node()}}
          | {:cookie, atom()}
          | {:connect_to, node() | [node()]}

  @doc """
  Starts a node and attempts to connect it to specified nodes. Configuration
  options can be specified as an argument

      KantanCluster.start(
        node: {:longnames, :"node1@127.0.0.1"},
        cookie: :hello,
        connect_to: [:"nerves@nerves-mn00.local"]
      )

  or in your `config/config.exs`.

      config :kantan_cluster,
        node: {:longnames, :"node1@127.0.0.1"},
        cookie: :hello,
        connect_to: [:"nerves@nerves-mn00.local"]

  """
  @spec start([option()]) :: GenServer.on_start() | [GenServer.on_start()]
  def start(opts \\ []) when is_list(opts) do
    ensure_distribution!(opts)
    validate_hostname_resolution!()
    set_cookie(opts)
    KantanCluster.Config.get_connect_to_option(opts) |> connect()
  end

  @doc """
  Stops a node and `KantanCluster.NodeConnector` server.
  """
  def stop() do
    # KantanCluster.NodeConnector will be stopped when node gets stopped.
    Node.stop()
  end

  @doc """
  Connects current node to specified nodes.
  """
  @spec connect(node() | [node()]) :: GenServer.on_start() | [GenServer.on_start()]
  def connect(connect_to) when is_atom(connect_to) do
    KantanCluster.NodeConnectorSupervisor.find_or_start_child_process(connect_to)
  end

  def connect(connect_to) when is_list(connect_to) do
    connect_to |> Enum.map(&KantanCluster.NodeConnectorSupervisor.find_or_start_child_process/1)
  end

  @doc """
  Disconnects current node from speficied nodes.
  """
  @spec disconnect(node() | [node()]) :: :ok
  def disconnect(node_name) when is_atom(node_name) do
    KantanCluster.NodeConnector.disconnect(node_name)
  end

  def disconnect(node_names) when is_list(node_names) do
    node_names |> Enum.each(&KantanCluster.NodeConnector.disconnect/1)
    :ok
  end

  @spec ensure_distribution!(keyword()) :: :ok
  defp ensure_distribution!(opts) do
    unless Node.alive?() do
      case System.cmd("epmd", ["-daemon"]) do
        {_, 0} -> :ok
        _ -> raise("could not start epmd (Erlang Port Mapper Driver).")
      end

      {type, name} = KantanCluster.Config.get_node_option(opts)

      case Node.start(name, type) do
        {:ok, _} -> :ok
        {:error, reason} -> raise("could not start distributed node: #{inspect(reason)}")
      end
    end
  end

  import Record
  defrecordp :hostent, Record.extract(:hostent, from_lib: "kernel/include/inet.hrl")

  defp validate_hostname_resolution!() do
    validate_hostname_resolution!(KantanCluster.Utils.shortnames_mode?())
  end

  defp validate_hostname_resolution!(_shortnames_mode = true) do
    hostname = KantanCluster.Utils.node_hostname() |> to_charlist()

    case :inet.gethostbyname(hostname) do
      {:error, :nxdomain} ->
        invalid_hostname!("your hostname \"#{hostname}\" does not resolve to an IP address")

      {:ok, hostent(h_addrtype: :inet, h_addr_list: addresses)} ->
        any_loopback? = Enum.any?(addresses, &match?({127, _, _, _}, &1))

        unless any_loopback? do
          invalid_hostname!(
            "your hostname \"#{hostname}\" does not resolve to a loopback address (127.0.0.0/8)"
          )
        end

      _ ->
        :ok
    end
  end

  defp validate_hostname_resolution!(_), do: :ok

  @spec invalid_hostname!(binary()) :: no_return()
  defp invalid_hostname!(prelude) do
    raise("""
    #{prelude}, which indicates something wrong in your OS configuration.

    Make sure your computer's name resolves locally or start KantanCluster using a long distribution name.
    """)
  end

  @spec set_cookie(keyword()) :: true
  defp set_cookie(opts) do
    KantanCluster.Config.get_cookie_option(opts) |> Node.set_cookie()
  end
end