2021-04-08 17:41:52 +08:00
|
|
|
defmodule LivebookCLI.Server do
|
|
|
|
@moduledoc false
|
|
|
|
|
|
|
|
@behaviour LivebookCLI.Task
|
2021-04-27 22:34:02 +08:00
|
|
|
|
2021-04-15 21:50:29 +08:00
|
|
|
@external_resource "README.md"
|
|
|
|
|
|
|
|
[_, environment_variables, _] =
|
|
|
|
"README.md"
|
|
|
|
|> File.read!()
|
|
|
|
|> String.split("<!-- Environment variables -->")
|
|
|
|
|
|
|
|
@environment_variables String.trim(environment_variables)
|
2021-04-08 17:41:52 +08:00
|
|
|
|
|
|
|
@impl true
|
|
|
|
def usage() do
|
|
|
|
"""
|
|
|
|
Usage: livebook server [options]
|
|
|
|
|
|
|
|
Available options:
|
|
|
|
|
2021-12-04 23:29:14 +08:00
|
|
|
--autosave-path The directory where notebooks with no file are persisted.
|
|
|
|
Defaults to livebook/notebooks/ under the default user cache
|
|
|
|
location. You can pass "none" to disable this behaviour
|
2021-06-09 22:24:02 +08:00
|
|
|
--cookie Sets a cookie for the app distributed node
|
|
|
|
--default-runtime Sets the runtime type that is used by default when none is started
|
|
|
|
explicitly for the given notebook, defaults to standalone
|
|
|
|
Supported options:
|
|
|
|
* standalone - Elixir standalone
|
2021-06-25 00:03:00 +08:00
|
|
|
* mix[:PATH] - Mix standalone
|
|
|
|
* attached:NODE:COOKIE - Attached
|
2021-06-09 22:24:02 +08:00
|
|
|
* embedded - Embedded
|
|
|
|
--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
|
|
|
|
--no-token Disable token authentication, enabled by default
|
|
|
|
If LIVEBOOK_PASSWORD is set, it takes precedence over token auth
|
2021-07-01 18:17:49 +08:00
|
|
|
--open Open browser window pointing to the application
|
2021-08-31 02:52:08 +08:00
|
|
|
--open-new Open browser window pointing to a new notebook
|
2021-06-09 22:24:02 +08:00
|
|
|
-p, --port The port to start the web application on, defaults to 8080
|
|
|
|
--root-path The root path to use for file selection
|
|
|
|
--sname Set a short name for the app distributed node
|
2021-04-15 21:50:29 +08:00
|
|
|
|
|
|
|
The --help option can be given to print this notice.
|
|
|
|
|
|
|
|
## Environment variables
|
|
|
|
|
|
|
|
#{@environment_variables}
|
2021-04-08 17:41:52 +08:00
|
|
|
|
|
|
|
"""
|
|
|
|
end
|
|
|
|
|
|
|
|
@impl true
|
|
|
|
def call(args) do
|
2021-07-01 18:17:49 +08:00
|
|
|
opts = args_to_options(args)
|
|
|
|
config_entries = opts_to_config(opts, [])
|
2021-04-08 17:41:52 +08:00
|
|
|
put_config_entries(config_entries)
|
|
|
|
|
2021-08-31 05:23:05 +08:00
|
|
|
port = Application.get_env(:livebook, LivebookWeb.Endpoint)[:http][:port]
|
|
|
|
base_url = "http://localhost:#{port}"
|
|
|
|
|
|
|
|
case check_endpoint_availability(base_url) do
|
|
|
|
:livebook_running ->
|
|
|
|
IO.puts("Livebook 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 ->
|
2021-11-12 21:36:02 +08:00
|
|
|
# We configure the endpoint with `server: true`,
|
|
|
|
# so it's gonna start listening
|
|
|
|
case Application.ensure_all_started(:livebook) do
|
|
|
|
{:ok, _} ->
|
2021-08-31 05:23:05 +08:00
|
|
|
open_from_options(LivebookWeb.Endpoint.access_url(), opts)
|
|
|
|
Process.sleep(:infinity)
|
|
|
|
|
2021-11-12 21:36:02 +08:00
|
|
|
{:error, error} ->
|
|
|
|
print_error("Livebook failed to start with reason: #{inspect(error)}")
|
2021-07-01 18:17:49 +08:00
|
|
|
end
|
2021-04-08 17:41:52 +08:00
|
|
|
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
|
|
|
|
|
2021-08-31 05:23:05 +08:00
|
|
|
defp check_endpoint_availability(base_url) do
|
|
|
|
Application.ensure_all_started(:inets)
|
|
|
|
|
Fix urls on Elixir master (#647)
Before Elixir master, empty path is represented as `nil`:
~% asdf shell elixir 1.12.3-otp-24 ; elixir -e 'IO.inspect URI.parse("http://localhost")'
%URI{
authority: "localhost",
fragment: nil,
host: "localhost",
path: nil,
port: 80,
query: nil,
scheme: "http",
userinfo: nil
}
On Elixir master, it is an empty string:
~% asdf shell elixir git ; elixir -e 'IO.inspect URI.parse("http://localhost")'
%URI{
authority: "localhost",
fragment: nil,
host: "localhost",
path: "",
port: 80,
query: nil,
scheme: "http",
userinfo: nil
}
The new default, the empty string, caused a bug on this line:
Map.update!(:path, &((&1 || "/") <> path))
because we never prepended the leading `/` and thus we ended up with
path `"health"`, not `"/health"`, which now on Elixir master crashes:
iex> URI.parse("http://localhost") |> Map.replace!(:path, "health") |> to_string()
** (ArgumentError) :path in URI must be empty or an absolute path if URL has a :host, got: %URI{authority: "localhost", fragment: nil, host: "localhost", path: "health", port: 80, query: nil, scheme: "http", userinfo: nil}
(elixir 1.13.0-dev) lib/uri.ex:863: String.Chars.URI.to_string/1
2021-10-26 22:48:17 +08:00
|
|
|
health_url = append_path(base_url, "/health")
|
2021-08-31 05:23:05 +08:00
|
|
|
|
|
|
|
case Livebook.Utils.HTTP.request(:get, health_url) do
|
|
|
|
{:ok, status, _headers, body} ->
|
|
|
|
with 200 <- status,
|
|
|
|
{:ok, body} <- Jason.decode(body),
|
|
|
|
%{"application" => "livebook"} <- body do
|
|
|
|
:livebook_running
|
|
|
|
else
|
|
|
|
_ -> :taken
|
|
|
|
end
|
|
|
|
|
|
|
|
{:error, _error} ->
|
|
|
|
:available
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
defp open_from_options(base_url, opts) do
|
|
|
|
if opts[:open] do
|
2022-01-18 00:34:38 +08:00
|
|
|
Livebook.Utils.browser_open(base_url)
|
2021-08-31 05:23:05 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
if opts[:open_new] do
|
|
|
|
base_url
|
Fix urls on Elixir master (#647)
Before Elixir master, empty path is represented as `nil`:
~% asdf shell elixir 1.12.3-otp-24 ; elixir -e 'IO.inspect URI.parse("http://localhost")'
%URI{
authority: "localhost",
fragment: nil,
host: "localhost",
path: nil,
port: 80,
query: nil,
scheme: "http",
userinfo: nil
}
On Elixir master, it is an empty string:
~% asdf shell elixir git ; elixir -e 'IO.inspect URI.parse("http://localhost")'
%URI{
authority: "localhost",
fragment: nil,
host: "localhost",
path: "",
port: 80,
query: nil,
scheme: "http",
userinfo: nil
}
The new default, the empty string, caused a bug on this line:
Map.update!(:path, &((&1 || "/") <> path))
because we never prepended the leading `/` and thus we ended up with
path `"health"`, not `"/health"`, which now on Elixir master crashes:
iex> URI.parse("http://localhost") |> Map.replace!(:path, "health") |> to_string()
** (ArgumentError) :path in URI must be empty or an absolute path if URL has a :host, got: %URI{authority: "localhost", fragment: nil, host: "localhost", path: "health", port: 80, query: nil, scheme: "http", userinfo: nil}
(elixir 1.13.0-dev) lib/uri.ex:863: String.Chars.URI.to_string/1
2021-10-26 22:48:17 +08:00
|
|
|
|> append_path("/explore/notebooks/new")
|
2022-01-18 00:34:38 +08:00
|
|
|
|> Livebook.Utils.browser_open()
|
2021-08-31 05:23:05 +08:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2021-04-20 01:50:09 +08:00
|
|
|
@switches [
|
2021-12-04 23:29:14 +08:00
|
|
|
autosave_path: :string,
|
2021-04-27 22:34:02 +08:00
|
|
|
cookie: :string,
|
2021-06-09 22:24:02 +08:00
|
|
|
default_runtime: :string,
|
2021-04-27 22:34:02 +08:00
|
|
|
ip: :string,
|
2021-04-20 01:50:09 +08:00
|
|
|
name: :string,
|
2021-07-01 18:17:49 +08:00
|
|
|
open: :boolean,
|
2021-08-31 02:52:08 +08:00
|
|
|
open_new: :boolean,
|
2021-04-27 22:34:02 +08:00
|
|
|
port: :integer,
|
|
|
|
root_path: :string,
|
2021-04-20 01:50:09 +08:00
|
|
|
sname: :string,
|
2021-04-27 22:34:02 +08:00
|
|
|
token: :boolean
|
2021-04-20 01:50:09 +08:00
|
|
|
]
|
|
|
|
|
|
|
|
@aliases [
|
|
|
|
p: :port
|
|
|
|
]
|
2021-04-08 17:41:52 +08:00
|
|
|
|
2021-07-01 18:17:49 +08:00
|
|
|
defp args_to_options(args) do
|
2021-04-20 01:50:09 +08:00
|
|
|
{opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
|
2021-04-08 17:41:52 +08:00
|
|
|
validate_options!(opts)
|
2021-07-01 18:17:49 +08:00
|
|
|
opts
|
2021-04-08 17:41:52 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
defp validate_options!(opts) do
|
|
|
|
if Keyword.has_key?(opts, :name) and Keyword.has_key?(opts, :sname) do
|
|
|
|
raise "the provided --sname and --name options are mutually exclusive, please specify only one of them"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
defp opts_to_config([], config), do: config
|
|
|
|
|
2021-04-15 21:50:29 +08:00
|
|
|
defp opts_to_config([{:token, false} | opts], config) do
|
|
|
|
if Livebook.Config.auth_mode() == :token do
|
|
|
|
opts_to_config(opts, [{:livebook, :authentication_mode, :disabled} | config])
|
|
|
|
else
|
|
|
|
opts_to_config(opts, config)
|
|
|
|
end
|
2021-04-08 17:41:52 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
defp opts_to_config([{:port, port} | opts], config) do
|
|
|
|
opts_to_config(opts, [{:livebook, LivebookWeb.Endpoint, http: [port: port]} | config])
|
|
|
|
end
|
|
|
|
|
2021-04-27 22:34:02 +08:00
|
|
|
defp opts_to_config([{:ip, ip} | opts], config) do
|
|
|
|
ip = Livebook.Config.ip!("--ip", ip)
|
|
|
|
opts_to_config(opts, [{:livebook, LivebookWeb.Endpoint, http: [ip: ip]} | config])
|
|
|
|
end
|
|
|
|
|
2021-04-20 01:50:09 +08:00
|
|
|
defp opts_to_config([{:root_path, root_path} | opts], config) do
|
2021-08-14 03:17:43 +08:00
|
|
|
root_path =
|
|
|
|
Livebook.Config.root_path!("--root-path", root_path)
|
|
|
|
|> Livebook.FileSystem.Utils.ensure_dir_path()
|
|
|
|
|
|
|
|
local_file_system = Livebook.FileSystem.Local.new(default_path: root_path)
|
|
|
|
opts_to_config(opts, [{:livebook, :file_systems, [local_file_system]} | config])
|
2021-04-20 01:50:09 +08:00
|
|
|
end
|
|
|
|
|
2021-04-08 17:41:52 +08:00
|
|
|
defp opts_to_config([{:sname, sname} | opts], config) do
|
|
|
|
sname = String.to_atom(sname)
|
|
|
|
opts_to_config(opts, [{:livebook, :node, {:shortnames, sname}} | config])
|
|
|
|
end
|
|
|
|
|
|
|
|
defp opts_to_config([{:name, name} | opts], config) do
|
|
|
|
name = String.to_atom(name)
|
|
|
|
opts_to_config(opts, [{:livebook, :node, {:longnames, name}} | config])
|
|
|
|
end
|
|
|
|
|
2021-04-27 22:34:02 +08:00
|
|
|
defp opts_to_config([{:cookie, cookie} | opts], config) do
|
|
|
|
cookie = String.to_atom(cookie)
|
|
|
|
opts_to_config(opts, [{:livebook, :cookie, cookie} | config])
|
|
|
|
end
|
|
|
|
|
2021-06-09 22:24:02 +08:00
|
|
|
defp opts_to_config([{:default_runtime, default_runtime} | opts], config) do
|
|
|
|
default_runtime = Livebook.Config.default_runtime!("--default-runtime", default_runtime)
|
|
|
|
opts_to_config(opts, [{:livebook, :default_runtime, default_runtime} | config])
|
|
|
|
end
|
|
|
|
|
2021-12-04 23:29:14 +08:00
|
|
|
defp opts_to_config([{:autosave_path, path} | opts], config) do
|
|
|
|
autosave_path = Livebook.Config.autosave_path!("--autosave-path", path)
|
|
|
|
opts_to_config(opts, [{:livebook, :autosave_path, autosave_path} | config])
|
|
|
|
end
|
|
|
|
|
2021-04-08 17:41:52 +08:00
|
|
|
defp opts_to_config([_opt | opts], config), do: opts_to_config(opts, config)
|
2021-07-01 18:17:49 +08:00
|
|
|
|
2021-08-31 05:23:05 +08:00
|
|
|
defp append_path(url, path) do
|
|
|
|
url
|
2021-12-10 03:46:45 +08:00
|
|
|
|> URI.parse()
|
|
|
|
|> Map.update!(:path, &((&1 || "") <> path))
|
2021-08-31 05:23:05 +08:00
|
|
|
|> URI.to_string()
|
|
|
|
end
|
|
|
|
|
|
|
|
defp print_error(message) do
|
|
|
|
IO.ANSI.format([:red, message]) |> IO.puts()
|
|
|
|
end
|
2021-04-08 17:41:52 +08:00
|
|
|
end
|