defmodule PLDSCli.Server do @moduledoc false # Starts the PLDS server. # Most of the code here was copied from Livebook code. # Ref: https://github.com/livebook-dev/livebook/blob/2a338b6b83671a54c4d3fce5e58b3ccba0666c37/lib/livebook_cli/server.ex def usage do """ Usage: plds server [options] Available options: -c, --connect One or more node names to connect. You can specify more nodes by using this option multiple times. It accepts both short and long names. For short names you can omit the host. If you pass a long name, then PLDS will start with a long name with host "127.0.0.1". You cannot use short and long names together. --cookie Sets a cookie for the app distributed node. This is optional and defaults to `Node.get_cookie()`. --ip The ip address to start the web application on, defaults to 127.0.0.1 Must be a valid IPv4 or IPv6 address. --name Set a name for the app distributed node. --open Open browser window pointing to the application. -p, --port The port to start the web application on. Defaults to 8090. --sname Set a short name for the app distributed node. Defaults to "plds". The --help option can be given to print this notice. """ end def call(args) do opts = args_to_options(args) config_entries = opts_to_config(opts, []) put_config_entries(config_entries) endpoint_config = Application.fetch_env!(:plds, PLDSWeb.Endpoint) port = endpoint_config[:http][:port] base_url = "http://localhost:#{port}" case check_endpoint_availability(base_url) do :plds_running -> IO.puts("PLDS already running on #{base_url}") open_from_options(base_url, opts) :taken -> print_error( "Another application is already running on port #{port}." <> " Either ensure this port is free or specify a different port using the --port option" ) :available -> case start_server() do :ok -> open_from_options(PLDSWeb.Endpoint.url(), opts) Process.sleep(:infinity) :error -> print_error("PLDS failed to start") end end end # Takes a list of {app, key, value} config entries # and overrides the current applications' configuration accordingly. # Multiple values for the same key are deeply merged (provided they are keyword lists). defp put_config_entries(config_entries) do config_entries |> Enum.reduce([], fn {app, key, value}, acc -> acc = Keyword.put_new_lazy(acc, app, fn -> Application.get_all_env(app) end) Config.Reader.merge(acc, [{app, [{key, value}]}]) end) |> Application.put_all_env(persistent: true) end defp check_endpoint_availability(base_url) do url = base_url <> "/health" {:ok, _} = Application.ensure_all_started(:inets) case :httpc.request(:get, {url, []}, [timeout: 4_000], body_format: :binary) do {:ok, {{_, status, _}, _headers, body}} -> with 200 <- status, {:ok, body} <- Jason.decode(body), %{"application" => "plds"} <- body do :plds_running else _ -> :taken end {:error, _error} -> :available end end defp start_server() do # We configure the endpoint in prod with `server: true`, # so it's gonna start listening case Application.ensure_all_started(:plds) do {:ok, _} -> :ok {:error, _} -> :error end end defp open_from_options(base_url, opts) do if opts[:open] do browser_open(base_url) end end @switches [ cookie: :string, ip: :string, name: :string, open: :boolean, port: :integer, connect: :keep, sname: :string ] @aliases [ p: :port, c: :connect ] # TODO: add tests to the name/sname and connect validations @doc false def args_to_options(args) do {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) validate_options!(opts) opts end defp validate_options!(opts) do if Keyword.has_key?(opts, :name) do validate_opts_for_name!(opts) end if Keyword.has_key?(opts, :sname) do validate_opts_for_short_name!(opts) end end defp validate_opts_for_name!(opts) do cond do Keyword.has_key?(opts, :sname) -> raise "the provided --sname and --name options are mutually exclusive, please specify only one of them" connecting_to_short_names_with_hosts?(opts) -> raise "the provided connections should have long names because you specified the --name option" not PLDS.Utils.long_name?(opts[:name]) -> raise "the provided --name option should include the full host name" true -> :ok end end defp validate_opts_for_short_name!(opts) do has_full_names? = opts |> Keyword.take([:connect]) |> Enum.any?(fn {:connect, name} -> PLDS.Utils.long_name?(name) end) if has_full_names? do raise "the provided connections should have short names because you specified the --sname option" end end defp connecting_to_short_names_with_hosts?(opts) do opts |> Keyword.take([:connect]) |> Enum.any?(fn {:connect, name} -> PLDS.Utils.short_name_with_host?(name) end) end defp opts_to_config([], config), do: config defp opts_to_config([{:port, port} | opts], config) do opts_to_config(opts, [{:plds, PLDSWeb.Endpoint, http: [port: port]} | config]) end defp opts_to_config([{:ip, ip} | opts], config) do ip = PLDS.Utils.ip!("--ip", ip) opts_to_config(opts, [{:plds, PLDSWeb.Endpoint, http: [ip: ip]} | config]) end defp opts_to_config([{:sname, sname} | opts], config) do sname = String.to_atom(sname) opts_to_config(opts, [{:plds, :node, {:shortnames, sname}} | config]) end defp opts_to_config([{:name, name} | opts], config) do name = String.to_atom(name) opts_to_config(opts, [{:plds, :node, {:longnames, name}} | config]) end defp opts_to_config([{:cookie, cookie} | opts], config) do cookie = String.to_atom(cookie) opts_to_config(opts, [{:plds, :cookie, cookie} | config]) end defp opts_to_config([{:connect, name} | opts], config) do name = String.to_atom(name) previous_nodes_to_connect = Enum.find_index(config, &match?({:plds, :nodes_to_connect, _}, &1)) config = case previous_nodes_to_connect do nil -> [{:plds, :nodes_to_connect, [name]} | config] idx -> List.update_at(config, idx, fn {:plds, :nodes_to_connect, existing_names} -> {:plds, :nodes_to_connect, existing_names ++ [name]} end) end opts_to_config(opts, config) end defp opts_to_config([_opt | opts], config), do: opts_to_config(opts, config) defp browser_open(url) do {cmd, args} = case :os.type() do {:win32, _} -> {"cmd", ["/c", "start", url]} {:unix, :darwin} -> {"open", [url]} {:unix, _} -> {"xdg-open", [url]} end System.cmd(cmd, args) end defp print_error(message) do IO.ANSI.format([:red, message]) |> IO.puts() end end