Add support for mix run flags to the Mix runtime (#1108)

* Add support for mix run flags to the Mix runtime

* Support mix run flags in runtime config string

* Add flag validation

* Update lib/livebook_web/live/session_live/mix_standalone_live.ex

Co-authored-by: José Valim <jose.valim@dashbit.co>

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2022-04-14 11:44:55 +02:00 committed by GitHub
parent e7f62e4ec8
commit aa3b58d708
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 106 additions and 40 deletions

View file

@ -153,7 +153,7 @@ The following environment variables configure Livebook:
* LIVEBOOK_DEFAULT_RUNTIME - sets the runtime type that is used by default * LIVEBOOK_DEFAULT_RUNTIME - sets the runtime type that is used by default
when none is started explicitly for the given notebook. Must be either when none is started explicitly for the given notebook. Must be either
"standalone" (Elixir standalone), "mix[:PATH]" (Mix standalone), "standalone" (Elixir standalone), "mix[:PATH][:FLAGS]" (Mix standalone),
"attached:NODE:COOKIE" (Attached node) or "embedded" (Embedded). "attached:NODE:COOKIE" (Attached node) or "embedded" (Embedded).
Defaults to "standalone". Defaults to "standalone".

View file

@ -244,10 +244,16 @@ defmodule Livebook.Config do
) )
end end
"mix:" <> path -> "mix:" <> config ->
{path, flags} = parse_mix_config!(config)
case mix_path(path) do case mix_path(path) do
{:ok, path} -> {:ok, path} ->
Livebook.Runtime.MixStandalone.new(path) if Livebook.Utils.valid_cli_flags?(flags) do
Livebook.Runtime.MixStandalone.new(path, flags)
else
abort!(~s{"#{flags}" is not a valid flag sequence})
end
:error -> :error ->
abort!(~s{"#{path}" does not point to a Mix project}) abort!(~s{"#{path}" does not point to a Mix project})
@ -264,6 +270,13 @@ defmodule Livebook.Config do
end end
end end
defp parse_mix_config!(config) do
case String.split(config, ":", parts: 2) do
[path] -> {path, ""}
[path, flags] -> {path, flags}
end
end
defp mix_path(path) do defp mix_path(path) do
path = Path.expand(path) path = Path.expand(path)
mixfile = Path.join(path, "mix.exs") mixfile = Path.join(path, "mix.exs")

View file

@ -1,5 +1,5 @@
defmodule Livebook.Runtime.MixStandalone do defmodule Livebook.Runtime.MixStandalone do
defstruct [:node, :server_pid, :project_path] defstruct [:node, :server_pid, :project_path, :flags]
# A runtime backed by a standalone Elixir node managed by Livebook. # A runtime backed by a standalone Elixir node managed by Livebook.
# #
@ -13,6 +13,7 @@ defmodule Livebook.Runtime.MixStandalone do
@type t :: %__MODULE__{ @type t :: %__MODULE__{
project_path: String.t(), project_path: String.t(),
flags: String.t(),
node: node() | nil, node: node() | nil,
server_pid: pid() | nil server_pid: pid() | nil
} }
@ -20,9 +21,9 @@ defmodule Livebook.Runtime.MixStandalone do
@doc """ @doc """
Returns a new runtime instance. Returns a new runtime instance.
""" """
@spec new(String.t()) :: t() @spec new(String.t(), String.t()) :: t()
def new(project_path) do def new(project_path, flags \\ "") do
%__MODULE__{project_path: project_path} %__MODULE__{project_path: project_path, flags: flags}
end end
@doc """ @doc """
@ -46,6 +47,7 @@ defmodule Livebook.Runtime.MixStandalone do
@spec connect_async(t(), Emitter.t()) :: :ok @spec connect_async(t(), Emitter.t()) :: :ok
def connect_async(runtime, emitter) do def connect_async(runtime, emitter) do
%{project_path: project_path} = runtime %{project_path: project_path} = runtime
flags = OptionParser.split(runtime.flags)
output_emitter = Emitter.mapper(emitter, fn output -> {:output, output} end) output_emitter = Emitter.mapper(emitter, fn output -> {:output, output} end)
spawn_link(fn -> spawn_link(fn ->
@ -59,7 +61,8 @@ defmodule Livebook.Runtime.MixStandalone do
:ok <- run_mix_task("deps.get", project_path, output_emitter), :ok <- run_mix_task("deps.get", project_path, output_emitter),
:ok <- run_mix_task("compile", project_path, output_emitter), :ok <- run_mix_task("compile", project_path, output_emitter),
eval = child_node_eval_string(), eval = child_node_eval_string(),
port = start_elixir_mix_node(elixir_path, child_node, eval, argv, project_path), port =
start_elixir_mix_node(elixir_path, child_node, flags, eval, argv, project_path),
{:ok, server_pid} <- parent_init_sequence(child_node, port, emitter: output_emitter) do {:ok, server_pid} <- parent_init_sequence(child_node, port, emitter: output_emitter) do
runtime = %{runtime | node: child_node, server_pid: server_pid} runtime = %{runtime | node: child_node, server_pid: server_pid}
Emitter.emit(emitter, {:ok, runtime}) Emitter.emit(emitter, {:ok, runtime})
@ -115,7 +118,7 @@ defmodule Livebook.Runtime.MixStandalone do
end end
end end
defp start_elixir_mix_node(elixir_path, node_name, eval, argv, project_path) do defp start_elixir_mix_node(elixir_path, node_name, flags, eval, argv, project_path) do
# Here we create a port to start the system process in a non-blocking way. # Here we create a port to start the system process in a non-blocking way.
Port.open({:spawn_executable, elixir_path}, [ Port.open({:spawn_executable, elixir_path}, [
:binary, :binary,
@ -127,7 +130,8 @@ defmodule Livebook.Runtime.MixStandalone do
cd: project_path, cd: project_path,
args: args:
elixir_flags(node_name) ++ elixir_flags(node_name) ++
["-S", "mix", "run", "--eval", eval, "--" | Enum.map(argv, &to_string/1)] ["-S", "mix", "run", "--eval", eval | flags] ++
["--" | Enum.map(argv, &to_string/1)]
]) ])
end end
end end
@ -138,13 +142,11 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.MixStandalone do
def describe(runtime) do def describe(runtime) do
[ [
{"Type", "Mix standalone"}, {"Type", "Mix standalone"},
{"Project", runtime.project_path} {"Project", runtime.project_path},
] ++ runtime.flags != "" && {"Flags", runtime.flags},
if connected?(runtime) do connected?(runtime) && {"Node name", Atom.to_string(runtime.node)}
[{"Node name", Atom.to_string(runtime.node)}] ]
else |> Enum.filter(&is_tuple/1)
[]
end
end end
def connect(runtime) do def connect(runtime) do
@ -166,7 +168,7 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.MixStandalone do
end end
def duplicate(runtime) do def duplicate(runtime) do
Livebook.Runtime.MixStandalone.new(runtime.project_path) Livebook.Runtime.MixStandalone.new(runtime.project_path, runtime.flags)
end end
def evaluate_code(runtime, code, locator, base_locator, opts \\ []) do def evaluate_code(runtime, code, locator, base_locator, opts \\ []) do

View file

@ -110,7 +110,7 @@ defmodule Livebook.Runtime.StandaloneInit do
loop.(loop) loop.(loop)
{:DOWN, ^port_ref, :port, _object, _reason} -> {:DOWN, ^port_ref, :port, _object, _reason} ->
{:error, "Elixir process terminated unexpectedly"} {:error, "Elixir terminated unexpectedly, please check the terminal for errors"}
after after
# Use a longer timeout to account for longer child node startup, # Use a longer timeout to account for longer child node startup,
# as may happen when starting with Mix. # as may happen when starting with Mix.

View file

@ -193,6 +193,30 @@ defmodule Livebook.Utils do
@spec valid_hex_color?(String.t()) :: boolean() @spec valid_hex_color?(String.t()) :: boolean()
def valid_hex_color?(hex_color), do: hex_color =~ ~r/^#[0-9a-fA-F]{6}$/ def valid_hex_color?(hex_color), do: hex_color =~ ~r/^#[0-9a-fA-F]{6}$/
@doc ~S"""
Validates if the given string forms valid CLI flags.
## Examples
iex> Livebook.Utils.valid_cli_flags?("")
true
iex> Livebook.Utils.valid_cli_flags?("--arg1 value --arg2 'value'")
true
iex> Livebook.Utils.valid_cli_flags?("--arg1 \"")
false
"""
@spec valid_cli_flags?(String.t()) :: boolean()
def valid_cli_flags?(flags) do
try do
OptionParser.split(flags)
true
rescue
_ -> false
end
end
@doc """ @doc """
Changes the first letter in the given string to upper case. Changes the first letter in the given string to upper case.

View file

@ -45,7 +45,7 @@ defmodule LivebookCLI.Server do
explicitly for the given notebook, defaults to standalone explicitly for the given notebook, defaults to standalone
Supported options: Supported options:
* standalone - Elixir standalone * standalone - Elixir standalone
* mix[:PATH] - Mix standalone * mix[:PATH][:FLAGS] - Mix standalone
* attached:NODE:COOKIE - Attached * attached:NODE:COOKIE - Attached
* embedded - Embedded * embedded - Embedded
--home The home path for the Livebook instance --home The home path for the Livebook instance

View file

@ -20,8 +20,9 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do
session: session, session: session,
status: :initial, status: :initial,
current_runtime: current_runtime, current_runtime: current_runtime,
file: initial_file(current_runtime), data: initial_data(current_runtime),
outputs: [], outputs: [],
outputs_version: 0,
emitter: nil emitter: nil
), temporary_assigns: [outputs: []]} ), temporary_assigns: [outputs: []]}
end end
@ -41,27 +42,40 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do
inside a Mix project because the dependencies of the project inside a Mix project because the dependencies of the project
itself have been installed instead. itself have been installed instead.
</p> </p>
<%= if @status == :initial do %> <%= if @status != :initializing do %>
<div class="h-full h-52"> <div class="h-full h-52">
<.live_component module={LivebookWeb.FileSelectComponent} <.live_component module={LivebookWeb.FileSelectComponent}
id="mix-project-dir" id="mix-project-dir"
file={@file} file={@data.file}
extnames={[]} extnames={[]}
running_files={[]} running_files={[]}
submit_event={if(disabled?(@file.path), do: nil, else: :init)} submit_event={if(data_valid?(@data), do: :init, else: nil)}
file_system_select_disabled={true} /> file_system_select_disabled={true} />
</div> </div>
<button class="button-base button-blue" phx-click="init" disabled={disabled?(@file.path)}> <form phx-change="validate" phx-submit="init">
<%= if(matching_runtime?(@current_runtime, @file.path), do: "Reconnect", else: "Connect") %> <div>
</button> <div class="input-label"><code>mix run</code> command-line flags</div>
<input class="input"
type="text"
name="flags"
value={@data.flags}
spellcheck="false"
autocomplete="off" />
</div>
<button class="mt-5 button-base button-blue"
type="submit"
disabled={not data_valid?(@data)}>
<%= if(matching_runtime?(@current_runtime, @data), do: "Reconnect", else: "Connect") %>
</button>
</form>
<% end %> <% end %>
<%= if @status != :initial do %> <%= if @status != :initial do %>
<div class="markdown"> <div class="markdown">
<pre><code class="max-h-40 overflow-y-auto tiny-scrollbar" <pre><code class="max-h-40 overflow-y-auto tiny-scrollbar"
id="mix-standalone-init-output" id={"mix-standalone-init-output-#{@outputs_version}"}
phx-update="append" phx-update="append"
phx-hook="ScrollOnUpdate" phx-hook="ScrollOnUpdate"
><%= for {output, i} <- @outputs do %><span id={"output-#{i}"}><%= ansi_string_to_html(output) %></span><% end %></code></pre> ><%= for {output, i} <- @outputs do %><span id={"mix-standalone-init-output-#{@outputs_version}-#{i}"}><%= ansi_string_to_html(output) %></span><% end %></code></pre>
</div> </div>
<% end %> <% end %>
</div> </div>
@ -73,9 +87,13 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do
handle_init(socket) handle_init(socket)
end end
def handle_event("validate", %{"flags" => flags}, socket) do
{:noreply, update(socket, :data, &%{&1 | flags: flags})}
end
@impl true @impl true
def handle_info({:set_file, file, _info}, socket) do def handle_info({:set_file, file, _info}, socket) do
{:noreply, assign(socket, :file, file)} {:noreply, update(socket, :data, &%{&1 | file: file})}
end end
def handle_info(:init, socket) do def handle_info(:init, socket) do
@ -104,31 +122,40 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do
defp handle_init(socket) do defp handle_init(socket) do
emitter = Utils.Emitter.new(self()) emitter = Utils.Emitter.new(self())
runtime = Runtime.MixStandalone.new(socket.assigns.file.path) runtime = Runtime.MixStandalone.new(socket.assigns.data.file.path, socket.assigns.data.flags)
Runtime.MixStandalone.connect_async(runtime, emitter) Runtime.MixStandalone.connect_async(runtime, emitter)
{:noreply, assign(socket, status: :initializing, emitter: emitter)}
{:noreply,
socket
|> assign(status: :initializing, emitter: emitter, outputs: [])
|> update(:outputs_version, &(&1 + 1))}
end end
defp add_output(socket, output) do defp add_output(socket, output) do
assign(socket, outputs: socket.assigns.outputs ++ [{output, Utils.random_id()}]) assign(socket, outputs: socket.assigns.outputs ++ [{output, Utils.random_id()}])
end end
defp initial_file(%Runtime.MixStandalone{} = current_runtime) do defp initial_data(%Runtime.MixStandalone{project_path: project_path, flags: flags}) do
FileSystem.File.local(current_runtime.project_path) file =
project_path
|> FileSystem.Utils.ensure_dir_path()
|> FileSystem.File.local()
%{file: file, flags: flags}
end end
defp initial_file(_runtime) do defp initial_data(_runtime) do
Livebook.Config.local_filesystem_home() %{file: Livebook.Config.local_filesystem_home(), flags: ""}
end end
defp matching_runtime?(%Runtime.MixStandalone{} = runtime, path) do defp matching_runtime?(%Runtime.MixStandalone{} = runtime, data) do
Path.expand(runtime.project_path) == Path.expand(path) Path.expand(runtime.project_path) == Path.expand(data.file.path)
end end
defp matching_runtime?(_runtime, _path), do: false defp matching_runtime?(_runtime, _path), do: false
defp disabled?(path) do defp data_valid?(data) do
not mix_project_root?(path) mix_project_root?(data.file.path) and Livebook.Utils.valid_cli_flags?(data.flags)
end end
defp mix_project_root?(path) do defp mix_project_root?(path) do