mirror of
				https://github.com/livebook-dev/livebook.git
				synced 2025-10-31 15:56:05 +08:00 
			
		
		
		
	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:
		
							parent
							
								
									e7f62e4ec8
								
							
						
					
					
						commit
						aa3b58d708
					
				
					 7 changed files with 106 additions and 40 deletions
				
			
		|  | @ -153,7 +153,7 @@ The following environment variables configure Livebook: | |||
| 
 | ||||
|   * LIVEBOOK_DEFAULT_RUNTIME - sets the runtime type that is used by default | ||||
|     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). | ||||
|     Defaults to "standalone". | ||||
| 
 | ||||
|  |  | |||
|  | @ -244,10 +244,16 @@ defmodule Livebook.Config do | |||
|             ) | ||||
|         end | ||||
| 
 | ||||
|       "mix:" <> path -> | ||||
|       "mix:" <> config -> | ||||
|         {path, flags} = parse_mix_config!(config) | ||||
| 
 | ||||
|         case mix_path(path) do | ||||
|           {: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 -> | ||||
|             abort!(~s{"#{path}" does not point to a Mix project}) | ||||
|  | @ -264,6 +270,13 @@ defmodule Livebook.Config do | |||
|     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 | ||||
|     path = Path.expand(path) | ||||
|     mixfile = Path.join(path, "mix.exs") | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| 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. | ||||
|   # | ||||
|  | @ -13,6 +13,7 @@ defmodule Livebook.Runtime.MixStandalone do | |||
| 
 | ||||
|   @type t :: %__MODULE__{ | ||||
|           project_path: String.t(), | ||||
|           flags: String.t(), | ||||
|           node: node() | nil, | ||||
|           server_pid: pid() | nil | ||||
|         } | ||||
|  | @ -20,9 +21,9 @@ defmodule Livebook.Runtime.MixStandalone do | |||
|   @doc """ | ||||
|   Returns a new runtime instance. | ||||
|   """ | ||||
|   @spec new(String.t()) :: t() | ||||
|   def new(project_path) do | ||||
|     %__MODULE__{project_path: project_path} | ||||
|   @spec new(String.t(), String.t()) :: t() | ||||
|   def new(project_path, flags \\ "") do | ||||
|     %__MODULE__{project_path: project_path, flags: flags} | ||||
|   end | ||||
| 
 | ||||
|   @doc """ | ||||
|  | @ -46,6 +47,7 @@ defmodule Livebook.Runtime.MixStandalone do | |||
|   @spec connect_async(t(), Emitter.t()) :: :ok | ||||
|   def connect_async(runtime, emitter) do | ||||
|     %{project_path: project_path} = runtime | ||||
|     flags = OptionParser.split(runtime.flags) | ||||
|     output_emitter = Emitter.mapper(emitter, fn output -> {:output, output} end) | ||||
| 
 | ||||
|     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("compile", project_path, output_emitter), | ||||
|              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 | ||||
|           runtime = %{runtime | node: child_node, server_pid: server_pid} | ||||
|           Emitter.emit(emitter, {:ok, runtime}) | ||||
|  | @ -115,7 +118,7 @@ defmodule Livebook.Runtime.MixStandalone do | |||
|     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. | ||||
|     Port.open({:spawn_executable, elixir_path}, [ | ||||
|       :binary, | ||||
|  | @ -127,7 +130,8 @@ defmodule Livebook.Runtime.MixStandalone do | |||
|       cd: project_path, | ||||
|       args: | ||||
|         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 | ||||
|  | @ -138,13 +142,11 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.MixStandalone do | |||
|   def describe(runtime) do | ||||
|     [ | ||||
|       {"Type", "Mix standalone"}, | ||||
|       {"Project", runtime.project_path} | ||||
|     ] ++ | ||||
|       if connected?(runtime) do | ||||
|         [{"Node name", Atom.to_string(runtime.node)}] | ||||
|       else | ||||
|         [] | ||||
|       end | ||||
|       {"Project", runtime.project_path}, | ||||
|       runtime.flags != "" && {"Flags", runtime.flags}, | ||||
|       connected?(runtime) && {"Node name", Atom.to_string(runtime.node)} | ||||
|     ] | ||||
|     |> Enum.filter(&is_tuple/1) | ||||
|   end | ||||
| 
 | ||||
|   def connect(runtime) do | ||||
|  | @ -166,7 +168,7 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.MixStandalone do | |||
|   end | ||||
| 
 | ||||
|   def duplicate(runtime) do | ||||
|     Livebook.Runtime.MixStandalone.new(runtime.project_path) | ||||
|     Livebook.Runtime.MixStandalone.new(runtime.project_path, runtime.flags) | ||||
|   end | ||||
| 
 | ||||
|   def evaluate_code(runtime, code, locator, base_locator, opts \\ []) do | ||||
|  |  | |||
|  | @ -110,7 +110,7 @@ defmodule Livebook.Runtime.StandaloneInit do | |||
|           loop.(loop) | ||||
| 
 | ||||
|         {:DOWN, ^port_ref, :port, _object, _reason} -> | ||||
|           {:error, "Elixir process terminated unexpectedly"} | ||||
|           {:error, "Elixir terminated unexpectedly, please check the terminal for errors"} | ||||
|       after | ||||
|         # Use a longer timeout to account for longer child node startup, | ||||
|         # as may happen when starting with Mix. | ||||
|  |  | |||
|  | @ -193,6 +193,30 @@ defmodule Livebook.Utils do | |||
|   @spec valid_hex_color?(String.t()) :: boolean() | ||||
|   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 """ | ||||
|   Changes the first letter in the given string to upper case. | ||||
| 
 | ||||
|  |  | |||
|  | @ -45,7 +45,7 @@ defmodule LivebookCLI.Server do | |||
|                            explicitly for the given notebook, defaults to standalone | ||||
|                            Supported options: | ||||
|                              * standalone - Elixir standalone | ||||
|                              * mix[:PATH] - Mix standalone | ||||
|                              * mix[:PATH][:FLAGS] - Mix standalone | ||||
|                              * attached:NODE:COOKIE - Attached | ||||
|                              * embedded - Embedded | ||||
|       --home               The home path for the Livebook instance | ||||
|  |  | |||
|  | @ -20,8 +20,9 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do | |||
|        session: session, | ||||
|        status: :initial, | ||||
|        current_runtime: current_runtime, | ||||
|        file: initial_file(current_runtime), | ||||
|        data: initial_data(current_runtime), | ||||
|        outputs: [], | ||||
|        outputs_version: 0, | ||||
|        emitter: nil | ||||
|      ), temporary_assigns: [outputs: []]} | ||||
|   end | ||||
|  | @ -41,27 +42,40 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do | |||
|         inside a Mix project because the dependencies of the project | ||||
|         itself have been installed instead. | ||||
|       </p> | ||||
|       <%= if @status == :initial do %> | ||||
|       <%= if @status != :initializing do %> | ||||
|         <div class="h-full h-52"> | ||||
|           <.live_component module={LivebookWeb.FileSelectComponent} | ||||
|               id="mix-project-dir" | ||||
|               file={@file} | ||||
|               file={@data.file} | ||||
|               extnames={[]} | ||||
|               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} /> | ||||
|         </div> | ||||
|         <button class="button-base button-blue" phx-click="init" disabled={disabled?(@file.path)}> | ||||
|           <%= if(matching_runtime?(@current_runtime, @file.path), do: "Reconnect", else: "Connect") %> | ||||
|         </button> | ||||
|         <form phx-change="validate" phx-submit="init"> | ||||
|           <div> | ||||
|             <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 %> | ||||
|       <%= if @status != :initial do %> | ||||
|         <div class="markdown"> | ||||
|           <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-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> | ||||
|       <% end %> | ||||
|     </div> | ||||
|  | @ -73,9 +87,13 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do | |||
|     handle_init(socket) | ||||
|   end | ||||
| 
 | ||||
|   def handle_event("validate", %{"flags" => flags}, socket) do | ||||
|     {:noreply, update(socket, :data, &%{&1 | flags: flags})} | ||||
|   end | ||||
| 
 | ||||
|   @impl true | ||||
|   def handle_info({:set_file, file, _info}, socket) do | ||||
|     {:noreply, assign(socket, :file, file)} | ||||
|     {:noreply, update(socket, :data, &%{&1 | file: file})} | ||||
|   end | ||||
| 
 | ||||
|   def handle_info(:init, socket) do | ||||
|  | @ -104,31 +122,40 @@ defmodule LivebookWeb.SessionLive.MixStandaloneLive do | |||
| 
 | ||||
|   defp handle_init(socket) do | ||||
|     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) | ||||
|     {:noreply, assign(socket, status: :initializing, emitter: emitter)} | ||||
| 
 | ||||
|     {:noreply, | ||||
|      socket | ||||
|      |> assign(status: :initializing, emitter: emitter, outputs: []) | ||||
|      |> update(:outputs_version, &(&1 + 1))} | ||||
|   end | ||||
| 
 | ||||
|   defp add_output(socket, output) do | ||||
|     assign(socket, outputs: socket.assigns.outputs ++ [{output, Utils.random_id()}]) | ||||
|   end | ||||
| 
 | ||||
|   defp initial_file(%Runtime.MixStandalone{} = current_runtime) do | ||||
|     FileSystem.File.local(current_runtime.project_path) | ||||
|   defp initial_data(%Runtime.MixStandalone{project_path: project_path, flags: flags}) do | ||||
|     file = | ||||
|       project_path | ||||
|       |> FileSystem.Utils.ensure_dir_path() | ||||
|       |> FileSystem.File.local() | ||||
| 
 | ||||
|     %{file: file, flags: flags} | ||||
|   end | ||||
| 
 | ||||
|   defp initial_file(_runtime) do | ||||
|     Livebook.Config.local_filesystem_home() | ||||
|   defp initial_data(_runtime) do | ||||
|     %{file: Livebook.Config.local_filesystem_home(), flags: ""} | ||||
|   end | ||||
| 
 | ||||
|   defp matching_runtime?(%Runtime.MixStandalone{} = runtime, path) do | ||||
|     Path.expand(runtime.project_path) == Path.expand(path) | ||||
|   defp matching_runtime?(%Runtime.MixStandalone{} = runtime, data) do | ||||
|     Path.expand(runtime.project_path) == Path.expand(data.file.path) | ||||
|   end | ||||
| 
 | ||||
|   defp matching_runtime?(_runtime, _path), do: false | ||||
| 
 | ||||
|   defp disabled?(path) do | ||||
|     not mix_project_root?(path) | ||||
|   defp data_valid?(data) do | ||||
|     mix_project_root?(data.file.path) and Livebook.Utils.valid_cli_flags?(data.flags) | ||||
|   end | ||||
| 
 | ||||
|   defp mix_project_root?(path) do | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue