mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 01:55:56 +08:00
Introduce Fly.io runtime (#2708)
This commit is contained in:
parent
b7ca4d6135
commit
c5ba8f8f81
|
@ -1,5 +1,5 @@
|
|||
[
|
||||
import_deps: [:phoenix, :ecto],
|
||||
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
|
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "rel/*/overlays/**/*.exs"]
|
||||
]
|
||||
|
|
36
.github/workflows/test.yml
vendored
36
.github/workflows/test.yml
vendored
|
@ -10,6 +10,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
env:
|
||||
MIX_ENV: test
|
||||
ELIXIR_ERL_OPTIONS: "-epmd_module Elixir.Livebook.EPMD"
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
|
@ -59,41 +60,6 @@ jobs:
|
|||
- name: Run assets tests
|
||||
run: npm test --prefix assets
|
||||
|
||||
epmdless:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
MIX_ENV: test
|
||||
LIVEBOOK_EPMDLESS: true
|
||||
ELIXIR_ERL_OPTIONS: "-epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0"
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Read ./versions
|
||||
run: |
|
||||
. versions
|
||||
echo "elixir=$elixir" >> $GITHUB_ENV
|
||||
echo "otp=$otp" >> $GITHUB_ENV
|
||||
echo "openssl=$openssl" >> $GITHUB_ENV
|
||||
- name: Install Erlang & Elixir
|
||||
uses: erlef/setup-beam@v1
|
||||
with:
|
||||
otp-version: ${{ env.otp }}
|
||||
elixir-version: ${{ env.elixir }}
|
||||
- name: Cache Mix
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
_build
|
||||
key: ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-
|
||||
- name: Install mix dependencies
|
||||
run: mix deps.get
|
||||
- name: Run tests
|
||||
run: mix test
|
||||
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
if: github.event_name == 'push'
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -33,6 +33,3 @@ npm-debug.log
|
|||
|
||||
# The built Escript
|
||||
/livebook
|
||||
|
||||
# The priv directory with the EPMD file
|
||||
/priv/epmd
|
||||
|
|
|
@ -217,12 +217,9 @@ The following environment variables can be used to configure Livebook on boot:
|
|||
|
||||
* `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), "attached:NODE:COOKIE" (Attached node)
|
||||
"standalone" (Standalone), "attached:NODE:COOKIE" (Attached node)
|
||||
or "embedded" (Embedded). Defaults to "standalone".
|
||||
|
||||
* `LIVEBOOK_EPMDLESS` - if set to "true", it disables the usage of EPMD. This is
|
||||
only supported within releases and defaults to true for the Desktop app.
|
||||
|
||||
* `LIVEBOOK_FIPS` - if set to "true", it enables the FIPS mode on startup.
|
||||
See more details in [the documentation](https://hexdocs.pm/livebook/fips.html).
|
||||
|
||||
|
|
|
@ -61,6 +61,8 @@ export function registerGlobalEventHandlers() {
|
|||
});
|
||||
|
||||
window.addEventListener("lb:scroll_into_view", (event) => {
|
||||
const options = event.detail || {};
|
||||
|
||||
// If the element is going to be shown, we want to wait for that
|
||||
waitUntilVisible(event.target).then(() => {
|
||||
scrollIntoView(event.target, {
|
||||
|
@ -68,6 +70,7 @@ export function registerGlobalEventHandlers() {
|
|||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
...options,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -30,7 +30,6 @@ config :livebook,
|
|||
app_service_url: nil,
|
||||
authentication: :token,
|
||||
aws_credentials: false,
|
||||
epmdless: false,
|
||||
feature_flags: [],
|
||||
force_ssl_host: nil,
|
||||
learn_notebooks: [],
|
||||
|
|
|
@ -149,22 +149,19 @@ defmodule Livebook do
|
|||
config :livebook, :aws_credentials, true
|
||||
end
|
||||
|
||||
if Livebook.Config.boolean!("LIVEBOOK_EPMDLESS", false) do
|
||||
config :livebook, :epmdless, true
|
||||
end
|
||||
|
||||
config :livebook,
|
||||
:default_runtime,
|
||||
Livebook.Config.default_runtime!("LIVEBOOK_DEFAULT_RUNTIME") ||
|
||||
Livebook.Runtime.ElixirStandalone.new()
|
||||
Livebook.Runtime.Standalone.new()
|
||||
|
||||
config :livebook, :default_app_runtime, Livebook.Runtime.ElixirStandalone.new()
|
||||
config :livebook, :default_app_runtime, Livebook.Runtime.Standalone.new()
|
||||
|
||||
config :livebook,
|
||||
:runtime_modules,
|
||||
[
|
||||
Livebook.Runtime.ElixirStandalone,
|
||||
Livebook.Runtime.Attached
|
||||
Livebook.Runtime.Standalone,
|
||||
Livebook.Runtime.Attached,
|
||||
Livebook.Runtime.Fly
|
||||
]
|
||||
|
||||
if home = Livebook.Config.writable_dir!("LIVEBOOK_HOME") do
|
||||
|
|
|
@ -7,14 +7,8 @@ defmodule Livebook.Application do
|
|||
ensure_directories!()
|
||||
set_local_file_system!()
|
||||
|
||||
if Livebook.Config.epmdless?() do
|
||||
validate_epmdless!()
|
||||
ensure_distribution!()
|
||||
else
|
||||
ensure_epmd!()
|
||||
ensure_distribution!()
|
||||
end
|
||||
|
||||
validate_epmd_module!()
|
||||
start_distribution!()
|
||||
set_cookie()
|
||||
|
||||
children =
|
||||
|
@ -48,6 +42,8 @@ defmodule Livebook.Application do
|
|||
Livebook.EPMD.NodePool,
|
||||
# Start the server responsible for associating files with sessions
|
||||
Livebook.Session.FileGuard,
|
||||
# Start the supervisor dynamically managing runtimes
|
||||
{DynamicSupervisor, name: Livebook.RuntimeSupervisor, strategy: :one_for_one},
|
||||
# Start the supervisor dynamically managing sessions
|
||||
{DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one},
|
||||
# Start the registry for managing unique connections
|
||||
|
@ -124,60 +120,33 @@ defmodule Livebook.Application do
|
|||
:persistent_term.put(:livebook_local_file_system, local_file_system)
|
||||
end
|
||||
|
||||
defp validate_epmdless!() do
|
||||
with {:ok, [[~c"Elixir.Livebook.EPMD"]]} <- :init.get_argument(:epmd_module),
|
||||
{:ok, [[~c"false"]]} <- :init.get_argument(:start_epmd),
|
||||
{:ok, [[~c"0"]]} <- :init.get_argument(:erl_epmd_port) do
|
||||
:ok
|
||||
else
|
||||
defp validate_epmd_module!() do
|
||||
# We use a custom EPMD module. In releases and Escript, we make
|
||||
# sure the necessary erl flags are set. When running from source,
|
||||
# those need to be passed explicitly.
|
||||
case :init.get_argument(:epmd_module) do
|
||||
{:ok, [[~c"Elixir.Livebook.EPMD"]]} ->
|
||||
:ok
|
||||
|
||||
_ ->
|
||||
Livebook.Config.abort!("""
|
||||
You must specify ELIXIR_ERL_OPTIONS=\"-epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0\" with LIVEBOOK_EPMDLESS. \
|
||||
The epmd module can be found inside #{Application.app_dir(:livebook, "priv/ebin")}.
|
||||
You must set the environment variable ELIXIR_ERL_OPTIONS="-epmd_module Elixir.Livebook.EPMD"
|
||||
""")
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_epmd!() do
|
||||
unless Node.alive?() do
|
||||
case System.cmd("epmd", ["-daemon"]) do
|
||||
{_, 0} ->
|
||||
:ok
|
||||
defp start_distribution!() do
|
||||
node = get_node_name()
|
||||
|
||||
_ ->
|
||||
Livebook.Config.abort!("""
|
||||
Could not start epmd (Erlang Port Mapper Daemon). Livebook uses epmd to \
|
||||
talk to different runtimes. You may have to start epmd explicitly by calling:
|
||||
case Node.start(node, :longnames) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
epmd -daemon
|
||||
|
||||
Or by calling:
|
||||
|
||||
elixir --sname test -e "IO.puts node()"
|
||||
|
||||
Then you can try booting Livebook again
|
||||
""")
|
||||
end
|
||||
{:error, reason} ->
|
||||
Livebook.Config.abort!("Could not start distributed node: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_distribution!() do
|
||||
unless Node.alive?() do
|
||||
node = get_node_name()
|
||||
|
||||
case Node.start(node, :longnames) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Livebook.Config.abort!("Could not start distributed node: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
import Record
|
||||
defrecordp :hostent, Record.extract(:hostent, from_lib: "kernel/include/inet.hrl")
|
||||
|
||||
defp set_cookie() do
|
||||
cookie = Application.fetch_env!(:livebook, :cookie)
|
||||
Node.set_cookie(cookie)
|
||||
|
@ -356,10 +325,10 @@ defmodule Livebook.Application do
|
|||
})
|
||||
end
|
||||
|
||||
# We set ELIXIR_ERL_OPTIONS when LIVEBOOK_EPMDLESS is set to true.
|
||||
# By design, we don't allow ELIXIR_ERL_OPTIONS to pass through.
|
||||
# Use ERL_AFLAGS and ERL_ZFLAGS if you want to configure both
|
||||
# Livebook and spawned runtimes.
|
||||
# We set ELIXIR_ERL_OPTIONS to set our custom EPMD module when
|
||||
# running from source. By design, we don't allow ELIXIR_ERL_OPTIONS
|
||||
# to pass through. Use ERL_AFLAGS and ERL_ZFLAGS if you want to
|
||||
# configure both Livebook and spawned runtimes.
|
||||
defp config_env_var?("ELIXIR_ERL_OPTIONS"), do: true
|
||||
defp config_env_var?("LIVEBOOK_" <> _), do: true
|
||||
defp config_env_var?("RELEASE_" <> _), do: true
|
||||
|
|
|
@ -60,12 +60,26 @@ defmodule Livebook.Config do
|
|||
})
|
||||
def docker_images() do
|
||||
version = app_version()
|
||||
base = if version =~ "dev", do: "latest", else: version
|
||||
|
||||
{version, version_cuda} =
|
||||
if version =~ "dev" do
|
||||
{"edge", "latest"}
|
||||
else
|
||||
{version, version}
|
||||
end
|
||||
|
||||
[
|
||||
%{tag: base, name: "Livebook", env: []},
|
||||
%{tag: "#{base}-cuda11.8", name: "Livebook + CUDA 11.8", env: [{"XLA_TARGET", "cuda118"}]},
|
||||
%{tag: "#{base}-cuda12.1", name: "Livebook + CUDA 12.1", env: [{"XLA_TARGET", "cuda120"}]}
|
||||
%{tag: version, name: "Livebook", env: []},
|
||||
%{
|
||||
tag: "#{version_cuda}-cuda11.8",
|
||||
name: "Livebook + CUDA 11.8",
|
||||
env: [{"XLA_TARGET", "cuda118"}]
|
||||
},
|
||||
%{
|
||||
tag: "#{version_cuda}-cuda12.1",
|
||||
name: "Livebook + CUDA 12.1",
|
||||
env: [{"XLA_TARGET", "cuda120"}]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
|
@ -158,7 +172,7 @@ defmodule Livebook.Config do
|
|||
@spec tmp_path() :: String.t()
|
||||
def tmp_path() do
|
||||
tmp_dir = System.tmp_dir!() |> Path.expand()
|
||||
Path.join(tmp_dir, "livebook")
|
||||
Path.join([tmp_dir, "livebook", app_version()])
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
@ -353,13 +367,6 @@ defmodule Livebook.Config do
|
|||
Application.fetch_env!(:livebook, :update_instructions_url)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a boolean if epmdless mode is configured.
|
||||
"""
|
||||
def epmdless? do
|
||||
Application.fetch_env!(:livebook, :epmdless)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the force ssl host if any.
|
||||
"""
|
||||
|
@ -673,7 +680,7 @@ defmodule Livebook.Config do
|
|||
nil
|
||||
|
||||
"standalone" ->
|
||||
Livebook.Runtime.ElixirStandalone.new()
|
||||
Livebook.Runtime.Standalone.new()
|
||||
|
||||
"embedded" ->
|
||||
Livebook.Runtime.Embedded.new()
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
defmodule Livebook.EPMD do
|
||||
# A custom EPMD module used to bypass the epmd OS daemon
|
||||
# on both Livebook and the runtimes.
|
||||
@after_compile __MODULE__
|
||||
# A custom EPMD module used to bypass the epmd OS daemon on Livebook.
|
||||
#
|
||||
# We also use it for the Fly runtime, such that we connect to the
|
||||
# remote node via a local proxy port.
|
||||
|
||||
# From Erlang/OTP 23+
|
||||
@epmd_dist_version 6
|
||||
@external_resource "priv/epmd/Elixir.Livebook.EPMD.beam"
|
||||
|
||||
@doc """
|
||||
Gets a random child node name.
|
||||
"""
|
||||
def random_child_node do
|
||||
def random_child_node() do
|
||||
String.to_atom(Livebook.EPMD.NodePool.get_name())
|
||||
end
|
||||
|
||||
|
@ -30,82 +30,51 @@ defmodule Livebook.EPMD do
|
|||
|
||||
# Custom EPMD callbacks
|
||||
|
||||
# Custom callback that registers the parent information.
|
||||
# We read this information when trying to connect to the parent.
|
||||
def start_link() do
|
||||
with {:ok, [[node, port]]} <- :init.get_argument(:livebook_parent) do
|
||||
[name, host] = :string.split(node, ~c"@")
|
||||
|
||||
:persistent_term.put(
|
||||
:livebook_parent,
|
||||
{name, host, List.to_atom(node), List.to_integer(port)}
|
||||
)
|
||||
end
|
||||
|
||||
:erl_epmd.start_link()
|
||||
end
|
||||
|
||||
# Custom callback to register our current node port.
|
||||
def register_node(name, port), do: register_node(name, port, :inet)
|
||||
|
||||
def register_node(name, port, family) do
|
||||
:persistent_term.put(:livebook_dist_port, port)
|
||||
:erl_epmd.register_node(name, port, family)
|
||||
|
||||
case :erl_epmd.register_node(name, port, family) do
|
||||
{:ok, creation} -> {:ok, creation}
|
||||
{:error, :already_registered} -> {:error, :already_registered}
|
||||
# If registration fails because EPMD is not running, we ignore
|
||||
# that, because we do not rely on EPMD
|
||||
_ -> {:ok, -1}
|
||||
end
|
||||
end
|
||||
|
||||
# Custom callback that accesses the parent information.
|
||||
def port_please(name, host), do: port_please(name, host, :infinity)
|
||||
|
||||
def port_please(~c"remote_runtime_" ++ port, _host, _timeout) do
|
||||
# The node name includes the local port proxied to a remote machine
|
||||
port = List.to_integer(port)
|
||||
{:port, port, @epmd_dist_version}
|
||||
end
|
||||
|
||||
def port_please(name, host, timeout) do
|
||||
case livebook_port(name) do
|
||||
0 -> :erl_epmd.port_please(name, host, timeout)
|
||||
port -> {:port, port, @epmd_dist_version}
|
||||
:erl_epmd.port_please(name, host, timeout)
|
||||
end
|
||||
|
||||
# Custom callback for resolving remote runtime node domain, such as
|
||||
# Fly .internal, to loopback, because we communicate via a local
|
||||
# proxied port
|
||||
def address_please(~c"remote_runtime_" ++ _, _host, address_family) do
|
||||
case address_family do
|
||||
:inet -> {:ok, {127, 0, 0, 1}}
|
||||
:inet6 -> {:ok, {0, 0, 0, 0, 0, 0, 0, 1}}
|
||||
end
|
||||
end
|
||||
|
||||
# If we are running inside a Livebook Runtime,
|
||||
# we should be able to reach the parent directly
|
||||
# or reach siblings through the parent.
|
||||
defp livebook_port(name) do
|
||||
case :persistent_term.get(:livebook_parent, nil) do
|
||||
{parent_name, parent_host, parent_node, parent_port} ->
|
||||
case match_name(name, parent_name) do
|
||||
:parent -> parent_port
|
||||
:sibling -> sibling_port(parent_node, name, parent_host)
|
||||
:none -> 0
|
||||
end
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp match_name([x | name], [x | parent_name]), do: match_name(name, parent_name)
|
||||
defp match_name([?-, ?- | _name], _parent), do: :sibling
|
||||
defp match_name([], []), do: :parent
|
||||
defp match_name(_name, _parent), do: :none
|
||||
|
||||
defp sibling_port(parent_node, name, host) do
|
||||
:gen_server.call(
|
||||
{Livebook.EPMD.NodePool, parent_node},
|
||||
{:get_port, :erlang.list_to_binary(name ++ [?@] ++ host)},
|
||||
5000
|
||||
)
|
||||
catch
|
||||
_, _ -> 0
|
||||
def address_please(name, host, address_family) do
|
||||
:erl_epmd.address_please(name, host, address_family)
|
||||
end
|
||||
|
||||
# Default EPMD callbacks
|
||||
|
||||
defdelegate start_link(), to: :erl_epmd
|
||||
defdelegate listen_port_please(name, host), to: :erl_epmd
|
||||
defdelegate names(host_name), to: :erl_epmd
|
||||
defdelegate address_please(name, host, address_family), to: :erl_epmd
|
||||
|
||||
# Store .beam file in priv as well
|
||||
|
||||
def __after_compile__(_env, binary) do
|
||||
File.mkdir_p!("priv/epmd")
|
||||
File.write!("priv/epmd/Elixir.Livebook.EPMD.beam", binary)
|
||||
Mix.Project.build_structure()
|
||||
end
|
||||
end
|
||||
|
|
|
@ -58,7 +58,7 @@ defmodule Livebook.EPMD.NodePool do
|
|||
|
||||
# Server side code
|
||||
|
||||
@impl GenServer
|
||||
@impl true
|
||||
def init(opts) do
|
||||
:net_kernel.monitor_nodes(true, node_type: :all)
|
||||
[name, host] = node() |> Atom.to_string() |> :binary.split("@")
|
||||
|
@ -74,23 +74,21 @@ defmodule Livebook.EPMD.NodePool do
|
|||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
@impl true
|
||||
def handle_call(:get_name, _, state) do
|
||||
{name, state} = server_get_name(state)
|
||||
{:reply, name, put_in(state.active_names[name], 0)}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_call({:get_port, name}, _, state) do
|
||||
{:reply, Map.get(state.active_names, name, 0), state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_call({:update_name, name, port}, _, state) do
|
||||
{:reply, :ok, server_update_name(name, port, state)}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
@impl true
|
||||
def handle_info({:nodedown, node, _info}, state) do
|
||||
case state.buffer_time do
|
||||
0 -> send(self(), {:release_node, node})
|
||||
|
@ -100,12 +98,10 @@ defmodule Livebook.EPMD.NodePool do
|
|||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info({:nodeup, _node, _info}, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info({:release_node, node}, state) do
|
||||
{:noreply, server_release_name(Atom.to_string(node), state)}
|
||||
end
|
||||
|
|
274
lib/livebook/fly_api.ex
Normal file
274
lib/livebook/fly_api.ex
Normal file
|
@ -0,0 +1,274 @@
|
|||
defmodule Livebook.FlyAPI do
|
||||
# Calls to the Fly API.
|
||||
#
|
||||
# Note that Fly currently exposes both a REST Machines API [1] and
|
||||
# a more elaborate GraphQL API [2]. The Machines API should be
|
||||
# preferred whenever possible. The Go client [3] serves as a good
|
||||
# reference for various operations.
|
||||
#
|
||||
# [1]: https://fly.io/docs/machines/api
|
||||
# [2]: https://github.com/superfly/fly-go/blob/v0.1.18/schema.graphql
|
||||
# [3]: https://github.com/superfly/fly-go
|
||||
|
||||
# See https://github.com/superfly/fly-go/blob/ea7601fc38ba5e9786155711471646dcb0bf63b8/flaps/flaps_volumes.go#L12
|
||||
@destroyed_volume_states ~w(scheduling_destroy fork_cleanup waiting_for_detach pending_destroy destroying)
|
||||
|
||||
@api_url "https://api.fly.io/graphql"
|
||||
@flaps_url "https://api.machines.dev"
|
||||
|
||||
@type error :: %{message: String.t(), status: pos_integer() | nil}
|
||||
|
||||
@doc """
|
||||
The valid values for CPU kind.
|
||||
"""
|
||||
@spec cpu_kinds() :: list(String.t())
|
||||
def cpu_kinds(), do: ~w(shared performance)
|
||||
|
||||
@doc """
|
||||
The valid values for GPU kind.
|
||||
"""
|
||||
@spec gpu_kinds() :: list(String.t())
|
||||
def gpu_kinds(), do: ~w(a10 a100-pcie-40gb a100-sxm4-80gb l40s)
|
||||
|
||||
@doc """
|
||||
Fetches information about organizations visible to the given token
|
||||
and also regions data.
|
||||
"""
|
||||
@spec get_orgs_and_regions(String.t()) :: {:ok, data} | {:error, error}
|
||||
when data: %{
|
||||
orgs: list(%{name: String.t(), slug: String.t()}),
|
||||
regions: %{name: String.t(), code: String.t()},
|
||||
closest_region: String.t()
|
||||
}
|
||||
def get_orgs_and_regions(token) do
|
||||
query = """
|
||||
query {
|
||||
organizations {
|
||||
nodes {
|
||||
rawSlug
|
||||
name
|
||||
}
|
||||
}
|
||||
platform {
|
||||
requestRegion
|
||||
regions {
|
||||
name
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
with {:ok, data} <- api_request(token, query) do
|
||||
{:ok,
|
||||
%{
|
||||
orgs: Enum.map(data["organizations"]["nodes"], &parse_org/1),
|
||||
regions: Enum.map(data["platform"]["regions"], &parse_region/1),
|
||||
closest_region: data["platform"]["requestRegion"]
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_org(org) do
|
||||
%{name: org["name"], slug: org["rawSlug"]}
|
||||
end
|
||||
|
||||
defp parse_region(region) do
|
||||
%{code: region["code"], name: region["name"]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches volumes in the given app.
|
||||
|
||||
Note that destroyed volumes are ignored.
|
||||
"""
|
||||
@spec get_app_volumes(String.t(), String.t()) :: {:ok, data} | {:error, error}
|
||||
when data:
|
||||
list(%{
|
||||
id: String.t(),
|
||||
name: String.t(),
|
||||
region: String.t(),
|
||||
size_gb: pos_integer()
|
||||
})
|
||||
def get_app_volumes(token, app_name) do
|
||||
with {:ok, data} <- flaps_request(token, "/v1/apps/#{app_name}/volumes") do
|
||||
volumes =
|
||||
for volume <- data,
|
||||
volume["state"] not in @destroyed_volume_states,
|
||||
do: parse_volume(volume)
|
||||
|
||||
{:ok, volumes}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_volume(volume) do
|
||||
%{
|
||||
id: volume["id"],
|
||||
name: volume["name"],
|
||||
region: volume["region"],
|
||||
size_gb: volume["size_gb"]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an app under the given organization.
|
||||
"""
|
||||
@spec create_app(String.t(), String.t(), String.t()) :: :ok | {:error, error}
|
||||
def create_app(token, app_name, org_slug) do
|
||||
with {:ok, _data} <-
|
||||
flaps_request(token, "/v1/apps",
|
||||
method: :post,
|
||||
json: %{app_name: app_name, org_slug: org_slug}
|
||||
) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new volume in the given app.
|
||||
|
||||
The `compute` attributes hint the expected machine specs that this
|
||||
volume will be attached to. This helps to ensure that the volume is
|
||||
placed on the right hardware (e.g. GPU-enabled).
|
||||
"""
|
||||
@spec create_volume(String.t(), String.t(), String.t(), String.t(), pos_integer(), map()) ::
|
||||
{:ok, data} | {:error, error}
|
||||
when data: %{
|
||||
id: String.t(),
|
||||
name: String.t(),
|
||||
region: String.t(),
|
||||
size_gb: pos_integer()
|
||||
}
|
||||
def create_volume(token, app_name, name, region, size_gb, compute) do
|
||||
with {:ok, data} <-
|
||||
flaps_request(token, "/v1/apps/#{app_name}/volumes",
|
||||
method: :post,
|
||||
json: %{
|
||||
name: name,
|
||||
size_gb: size_gb,
|
||||
region: region,
|
||||
compute: compute
|
||||
}
|
||||
) do
|
||||
{:ok, parse_volume(data)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the given volume.
|
||||
"""
|
||||
@spec delete_volume(String.t(), String.t(), String.t()) :: :ok | {:error, error}
|
||||
def delete_volume(token, app_name, volume_id) do
|
||||
with {:ok, _data} <-
|
||||
flaps_request(token, "/v1/apps/#{app_name}/volumes/#{volume_id}", method: :delete) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new machine in the given app.
|
||||
"""
|
||||
@spec create_machine(String.t(), String.t(), String.t(), String.t(), map()) ::
|
||||
{:ok, data} | {:error, error}
|
||||
when data: %{id: String.t(), private_ip: String.t()}
|
||||
def create_machine(token, app_name, name, region, config) do
|
||||
boot_timeout = 30_000
|
||||
|
||||
with {:ok, data} <-
|
||||
flaps_request(token, "/v1/apps/#{app_name}/machines",
|
||||
method: :post,
|
||||
json: %{name: name, region: region, config: config},
|
||||
receive_timeout: boot_timeout
|
||||
) do
|
||||
{:ok, parse_machine(data)}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_machine(machine) do
|
||||
%{id: machine["id"], private_ip: machine["private_ip"]}
|
||||
end
|
||||
|
||||
defp flaps_request(token, path, opts \\ []) do
|
||||
opts =
|
||||
[base_url: @flaps_url, url: path, auth: {:bearer, token}]
|
||||
|> Keyword.merge(opts)
|
||||
|> Keyword.merge(test_options())
|
||||
|
||||
case Req.request(opts) do
|
||||
{:ok, %{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
message =
|
||||
case body do
|
||||
%{"error" => error} when is_binary(error) ->
|
||||
Livebook.Utils.downcase_first(error)
|
||||
|
||||
_ ->
|
||||
"HTTP status #{status}"
|
||||
end
|
||||
|
||||
{:error, %{message: message, status: status}}
|
||||
|
||||
{:error, exception} ->
|
||||
{:error, %{message: "reason: #{Exception.message(exception)}", status: nil}}
|
||||
end
|
||||
end
|
||||
|
||||
defp api_request(token, query) do
|
||||
opts =
|
||||
[
|
||||
base_url: @api_url,
|
||||
method: :post,
|
||||
auth: {:bearer, token},
|
||||
json: %{query: query}
|
||||
]
|
||||
|> Keyword.merge(test_options())
|
||||
|
||||
case Req.request(opts) do
|
||||
{:ok, %{status: 200, body: body}} ->
|
||||
case body do
|
||||
%{"errors" => [%{"extensions" => %{"code" => "UNAUTHORIZED"}} | _]} ->
|
||||
{:error, %{message: "could not authorize with the given token", status: 401}}
|
||||
|
||||
%{"errors" => [%{"extensions" => %{"message" => message}} | _]} ->
|
||||
{:error, %{message: Livebook.Utils.downcase_first(message), status: nil}}
|
||||
|
||||
%{"data" => data} ->
|
||||
{:ok, data}
|
||||
end
|
||||
|
||||
{:ok, %{status: status}} ->
|
||||
{:error, %{message: "HTTP status #{status}", status: status}}
|
||||
|
||||
{:error, exception} ->
|
||||
{:error, %{message: "reason: #{Exception.message(exception)}", status: nil}}
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: do not rely on private APIs. Also, ideally we should still
|
||||
# be able to use Req.Test.expect/2
|
||||
if Mix.env() == :test do
|
||||
defp test_options() do
|
||||
case Req.Test.__fetch_plug__(__MODULE__) do
|
||||
:passthrough ->
|
||||
[]
|
||||
|
||||
_plug ->
|
||||
[plug: {Req.Test, __MODULE__}]
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def stub(plug) do
|
||||
Req.Test.stub(__MODULE__, plug)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def passthrough() do
|
||||
Req.Test.stub(__MODULE__, :passthrough)
|
||||
end
|
||||
else
|
||||
defp test_options(), do: []
|
||||
end
|
||||
end
|
|
@ -785,19 +785,32 @@ defprotocol Livebook.Runtime do
|
|||
def describe(runtime)
|
||||
|
||||
@doc """
|
||||
Synchronously initializes the given runtime.
|
||||
Asynchronously initializes the given runtime.
|
||||
|
||||
This function starts the necessary resources and processes.
|
||||
The initialization should take care of starting any OS processes
|
||||
necessary, setting up resources and communication.
|
||||
|
||||
Since the initialization may take time, it should always happen in
|
||||
a separate process. This function should return the `pid` of that
|
||||
process. Once the initialization is finished, the process should
|
||||
send the following message to the caller:
|
||||
|
||||
* `{:runtime_connect_done, pid, {:ok, runtime} | {:error, message}}`
|
||||
|
||||
The `runtime` should be the struct updated with all information
|
||||
necessary for further communication.
|
||||
|
||||
In case the initialization is a particularly involved process, the
|
||||
process may send updates to the caller:
|
||||
|
||||
* `{:runtime_connect_info, pid, info}`
|
||||
|
||||
Where `info` is a few word text describing the current initialization
|
||||
step.
|
||||
"""
|
||||
@spec connect(t()) :: {:ok, t()} | {:error, String.t()}
|
||||
@spec connect(t()) :: pid()
|
||||
def connect(runtime)
|
||||
|
||||
@doc """
|
||||
Checks if the given runtime is in a connected state.
|
||||
"""
|
||||
@spec connected?(t()) :: boolean()
|
||||
def connected?(runtime)
|
||||
|
||||
@doc """
|
||||
Sets the caller as the runtime owner.
|
||||
|
||||
|
@ -824,13 +837,15 @@ defprotocol Livebook.Runtime do
|
|||
Synchronously disconnects the runtime and cleans up the underlying
|
||||
resources.
|
||||
"""
|
||||
@spec disconnect(t()) :: {:ok, t()}
|
||||
@spec disconnect(t()) :: :ok
|
||||
def disconnect(runtime)
|
||||
|
||||
@doc """
|
||||
Returns a fresh runtime of the same type with the same configuration.
|
||||
|
||||
Note that the runtime is in a stopped state.
|
||||
This function is expected to only modify the runtime struct, unsetting
|
||||
any information added by `connect/1`. It should not have any side
|
||||
effects.
|
||||
"""
|
||||
@spec duplicate(Runtime.t()) :: Runtime.t()
|
||||
def duplicate(runtime)
|
||||
|
@ -889,6 +904,9 @@ defprotocol Livebook.Runtime do
|
|||
* `:smart_cell_ref` - a reference of the smart cell which code is
|
||||
to be evaluated, if applicable
|
||||
|
||||
* `:disable_dependencies_cache` - disables dependencies cache, so
|
||||
they are fetched and compiled from scratch
|
||||
|
||||
"""
|
||||
@spec evaluate_code(t(), atom(), String.t(), locator(), parent_locators(), keyword()) :: :ok
|
||||
def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ [])
|
||||
|
@ -1076,13 +1094,6 @@ defprotocol Livebook.Runtime do
|
|||
@spec search_packages(t(), pid(), String.t()) :: reference()
|
||||
def search_packages(runtime, send_to, search)
|
||||
|
||||
@doc """
|
||||
Disables dependencies cache, so they are fetched and compiled from
|
||||
scratch.
|
||||
"""
|
||||
@spec disable_dependencies_cache(t()) :: :ok
|
||||
def disable_dependencies_cache(runtime)
|
||||
|
||||
@doc """
|
||||
Sets the given environment variables.
|
||||
"""
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
defmodule Livebook.Runtime.Attached do
|
||||
# A runtime backed by an Elixir node managed externally.
|
||||
#
|
||||
# Such node must be already started and available, Livebook doesn't
|
||||
# manage its lifetime in any way and only loads/unloads the
|
||||
# necessary elements. The node can be an ordinary Elixir runtime,
|
||||
# a Mix project shell, a running release or anything else.
|
||||
# Such node must be already started and accessible. Livebook doesn't
|
||||
# manage the node's lifetime in any way and only loads/unloads the
|
||||
# necessary modules and processes. The node can be an ordinary Elixir
|
||||
# runtime, a Mix project shell, a running release or anything else.
|
||||
|
||||
defstruct [:node, :cookie, :server_pid]
|
||||
|
||||
|
@ -22,17 +22,20 @@ defmodule Livebook.Runtime.Attached do
|
|||
%__MODULE__{node: node, cookie: cookie}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the given node is available for use and initializes
|
||||
it with Livebook-specific modules and processes.
|
||||
"""
|
||||
@spec connect(t()) :: {:ok, t()} | {:error, String.t()}
|
||||
def connect(runtime) do
|
||||
%{node: node, cookie: cookie} = runtime
|
||||
def __connect__(runtime) do
|
||||
caller = self()
|
||||
|
||||
# We need to append the hostname on connect because
|
||||
# net_kernel has not yet started during new/2.
|
||||
node = append_hostname(node)
|
||||
{:ok, pid} =
|
||||
DynamicSupervisor.start_child(
|
||||
Livebook.RuntimeSupervisor,
|
||||
{Task, fn -> do_connect(runtime, caller) end}
|
||||
)
|
||||
|
||||
pid
|
||||
end
|
||||
|
||||
defp do_connect(runtime, caller) do
|
||||
%{node: node, cookie: cookie} = runtime
|
||||
|
||||
# Set cookie for connecting to this specific node
|
||||
Node.set_cookie(node, cookie)
|
||||
|
@ -44,7 +47,11 @@ defmodule Livebook.Runtime.Attached do
|
|||
node_manager_opts: [parent_node: node(), capture_orphan_logs: false]
|
||||
)
|
||||
|
||||
{:ok, %{runtime | node: node, server_pid: server_pid}}
|
||||
runtime = %{runtime | node: node, server_pid: server_pid}
|
||||
send(caller, {:runtime_connect_done, self(), {:ok, runtime}})
|
||||
else
|
||||
{:error, error} ->
|
||||
send(caller, {:runtime_connect_done, self(), {:error, error}})
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -57,26 +64,45 @@ defmodule Livebook.Runtime.Attached do
|
|||
end
|
||||
end
|
||||
|
||||
@elixir_version_requirement Keyword.fetch!(Mix.Project.config(), :elixir)
|
||||
|
||||
defp check_attached_node_version(node) do
|
||||
attached_node_version = :erpc.call(node, System, :version, [])
|
||||
|
||||
if Version.match?(attached_node_version, @elixir_version_requirement) do
|
||||
requirement = elixir_version_requirement()
|
||||
|
||||
if Version.match?(attached_node_version, requirement) do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
"the node uses Elixir #{attached_node_version}, but #{@elixir_version_requirement} is required"}
|
||||
{:error, "the node uses Elixir #{attached_node_version}, but #{requirement} is required"}
|
||||
end
|
||||
end
|
||||
|
||||
defp append_hostname(node) do
|
||||
with :nomatch <- :string.find(Atom.to_string(node), "@"),
|
||||
<<suffix::binary>> <- :string.find(Atom.to_string(:net_kernel.nodename()), "@") do
|
||||
:"#{node}#{suffix}"
|
||||
else
|
||||
_ -> node
|
||||
end
|
||||
@elixir_version_requirement Keyword.fetch!(Mix.Project.config(), :elixir)
|
||||
|
||||
@doc """
|
||||
Returns requirement for the attached node Elixir version.
|
||||
"""
|
||||
@spec elixir_version_requirement() :: String.t()
|
||||
def elixir_version_requirement() do
|
||||
# We load compiled modules binary into the remote node. Erlang
|
||||
# provides rather good compatibility of the binary format, and
|
||||
# in case loading fails we show an appropriate message. However,
|
||||
# it is more likely that the Elixir core functions used in the
|
||||
# compiled module differ across versions. We assume that such
|
||||
# changes are unlikely within the same minor version, so that's
|
||||
# the requirement we enforce.
|
||||
|
||||
current = Version.parse!(System.version())
|
||||
same_minor = "#{current.major}.#{current.minor}.0"
|
||||
|
||||
# Make sure Livebook does not enforce a higher patch version
|
||||
min_version =
|
||||
if Version.match?(same_minor, @elixir_version_requirement) do
|
||||
same_minor
|
||||
else
|
||||
current
|
||||
end
|
||||
|
||||
"~> " <> min_version
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -85,17 +111,13 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Attached do
|
|||
|
||||
def describe(runtime) do
|
||||
[
|
||||
{"Type", "Attached"},
|
||||
{"Type", "Attached node"},
|
||||
{"Node name", Atom.to_string(runtime.node)}
|
||||
]
|
||||
end
|
||||
|
||||
def connect(runtime) do
|
||||
Livebook.Runtime.Attached.connect(runtime)
|
||||
end
|
||||
|
||||
def connected?(runtime) do
|
||||
runtime.server_pid != nil
|
||||
Livebook.Runtime.Attached.__connect__(runtime)
|
||||
end
|
||||
|
||||
def take_ownership(runtime, opts \\ []) do
|
||||
|
@ -105,7 +127,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Attached do
|
|||
|
||||
def disconnect(runtime) do
|
||||
RuntimeServer.stop(runtime.server_pid)
|
||||
{:ok, %{runtime | server_pid: nil}}
|
||||
Node.disconnect(runtime.node)
|
||||
:ok
|
||||
end
|
||||
|
||||
def duplicate(runtime) do
|
||||
|
@ -181,10 +204,6 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Attached do
|
|||
raise "not supported"
|
||||
end
|
||||
|
||||
def disable_dependencies_cache(runtime) do
|
||||
RuntimeServer.disable_dependencies_cache(runtime.server_pid)
|
||||
end
|
||||
|
||||
def put_system_envs(runtime, envs) do
|
||||
RuntimeServer.put_system_envs(runtime.server_pid, envs)
|
||||
end
|
||||
|
|
|
@ -2,7 +2,13 @@ defmodule Livebook.Runtime.Embedded do
|
|||
# A runtime backed by the same node Livebook is running in.
|
||||
#
|
||||
# This runtime is reserved for specific use cases, where there is
|
||||
# no option of starting a separate Elixir runtime.
|
||||
# no option of starting a separate Elixir OS process.
|
||||
#
|
||||
# As we run in the Livebook node, all the necessary modules are in
|
||||
# place, so we just ensure the node manager process is running and
|
||||
# we start a new runtime server. We also disable modules cleanup
|
||||
# on termination, since we don't want to unload any modules from
|
||||
# the current node.
|
||||
|
||||
defstruct [:server_pid]
|
||||
|
||||
|
@ -18,30 +24,26 @@ defmodule Livebook.Runtime.Embedded do
|
|||
%__MODULE__{}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Initializes new runtime by starting the necessary processes within
|
||||
the current node.
|
||||
"""
|
||||
@spec connect(t()) :: {:ok, t()}
|
||||
def connect(runtime) do
|
||||
# As we run in the Livebook node, all the necessary modules
|
||||
# are in place, so we just start the manager process.
|
||||
# We make it anonymous, so that multiple embedded runtimes
|
||||
# can be started (for different notebooks).
|
||||
# We also disable cleanup, as we don't want to unload any
|
||||
# modules or revert the configuration (because other runtimes
|
||||
# may rely on it). If someone uses embedded runtimes,
|
||||
# this cleanup is not particularly important anyway.
|
||||
# We tell manager to not override :standard_error,
|
||||
# as we already do it for the Livebook application globally
|
||||
# (see Livebook.Application.start/2).
|
||||
def __connect__(runtime) do
|
||||
caller = self()
|
||||
|
||||
{:ok, pid} =
|
||||
DynamicSupervisor.start_child(
|
||||
Livebook.RuntimeSupervisor,
|
||||
{Task, fn -> do_connect(runtime, caller) end}
|
||||
)
|
||||
|
||||
pid
|
||||
end
|
||||
|
||||
defp do_connect(runtime, caller) do
|
||||
server_pid =
|
||||
ErlDist.initialize(node(),
|
||||
node_manager_opts: [unload_modules_on_termination: false]
|
||||
)
|
||||
|
||||
{:ok, %{runtime | server_pid: server_pid}}
|
||||
runtime = %{runtime | server_pid: server_pid}
|
||||
send(caller, {:runtime_connect_done, self(), {:ok, runtime}})
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -53,11 +55,7 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Embedded do
|
|||
end
|
||||
|
||||
def connect(runtime) do
|
||||
Livebook.Runtime.Embedded.connect(runtime)
|
||||
end
|
||||
|
||||
def connected?(runtime) do
|
||||
runtime.server_pid != nil
|
||||
Livebook.Runtime.Embedded.__connect__(runtime)
|
||||
end
|
||||
|
||||
def take_ownership(runtime, opts \\ []) do
|
||||
|
@ -66,8 +64,7 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Embedded do
|
|||
end
|
||||
|
||||
def disconnect(runtime) do
|
||||
RuntimeServer.stop(runtime.server_pid)
|
||||
{:ok, %{runtime | server_pid: nil}}
|
||||
:ok = RuntimeServer.stop(runtime.server_pid)
|
||||
end
|
||||
|
||||
def duplicate(_runtime) do
|
||||
|
@ -147,10 +144,6 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Embedded do
|
|||
Livebook.Runtime.Dependencies.search_packages_in_list(packages, send_to, search)
|
||||
end
|
||||
|
||||
def disable_dependencies_cache(runtime) do
|
||||
RuntimeServer.disable_dependencies_cache(runtime.server_pid)
|
||||
end
|
||||
|
||||
def put_system_envs(runtime, envs) do
|
||||
RuntimeServer.put_system_envs(runtime.server_pid, envs)
|
||||
end
|
||||
|
|
87
lib/livebook/runtime/epmd.ex
Normal file
87
lib/livebook/runtime/epmd.ex
Normal file
|
@ -0,0 +1,87 @@
|
|||
defmodule Livebook.Runtime.EPMD do
|
||||
# A custom EPMD module used to bypass the epmd OS daemon in the
|
||||
# standalone runtime.
|
||||
#
|
||||
# We used to start epmd on application boot, however sometimes it
|
||||
# would fail. In particular, on Windows starting epmd may require
|
||||
# accepting a firewall pop up, and the first boot could still fail.
|
||||
# To avoid this, we use a custom port resolution that does not rely
|
||||
# on the epmd OS daemon running.
|
||||
|
||||
# From Erlang/OTP 23+
|
||||
@epmd_dist_version 6
|
||||
|
||||
@doc """
|
||||
Persists parent information, used when connecting to the parent.
|
||||
"""
|
||||
def register_parent(parent_node, parent_port) do
|
||||
[name, host] = parent_node |> Atom.to_charlist() |> :string.split(~c"@")
|
||||
:persistent_term.put(:livebook_parent, {name, host, parent_node, parent_port})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the current distribution port.
|
||||
"""
|
||||
def dist_port do
|
||||
:persistent_term.get(:livebook_dist_port)
|
||||
end
|
||||
|
||||
# Custom EPMD callbacks
|
||||
|
||||
# Custom callback to register our current node port.
|
||||
def register_node(name, port), do: register_node(name, port, :inet)
|
||||
|
||||
def register_node(name, port, family) do
|
||||
:persistent_term.put(:livebook_dist_port, port)
|
||||
|
||||
case :erl_epmd.register_node(name, port, family) do
|
||||
{:ok, creation} -> {:ok, creation}
|
||||
{:error, :already_registered} -> {:error, :already_registered}
|
||||
# If registration fails because EPMD is not running, we ignore
|
||||
# that, because we do not rely on EPMD
|
||||
_ -> {:ok, -1}
|
||||
end
|
||||
end
|
||||
|
||||
# Custom callback for resolving parent and sibling node ports.
|
||||
def port_please(name, host), do: port_please(name, host, :infinity)
|
||||
|
||||
def port_please(name, host, timeout) do
|
||||
case livebook_port(name) do
|
||||
0 -> :erl_epmd.port_please(name, host, timeout)
|
||||
port -> {:port, port, @epmd_dist_version}
|
||||
end
|
||||
end
|
||||
|
||||
defp livebook_port(name) do
|
||||
{parent_name, parent_host, parent_node, parent_port} = :persistent_term.get(:livebook_parent)
|
||||
|
||||
case match_name(name, parent_name) do
|
||||
:parent -> parent_port
|
||||
:sibling -> sibling_port(parent_node, name, parent_host)
|
||||
:none -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp match_name([x | name], [x | parent_name]), do: match_name(name, parent_name)
|
||||
defp match_name([?-, ?- | _name], _parent), do: :sibling
|
||||
defp match_name([], []), do: :parent
|
||||
defp match_name(_name, _parent), do: :none
|
||||
|
||||
defp sibling_port(parent_node, name, host) do
|
||||
:gen_server.call(
|
||||
{Livebook.EPMD.NodePool, parent_node},
|
||||
{:get_port, :erlang.list_to_binary(name ++ [?@] ++ host)},
|
||||
5000
|
||||
)
|
||||
catch
|
||||
_, _ -> 0
|
||||
end
|
||||
|
||||
# Default EPMD callbacks
|
||||
|
||||
defdelegate start_link(), to: :erl_epmd
|
||||
defdelegate address_please(name, host, address_family), to: :erl_epmd
|
||||
defdelegate listen_port_please(name, host), to: :erl_epmd
|
||||
defdelegate names(host_name), to: :erl_epmd
|
||||
end
|
|
@ -5,7 +5,7 @@ defmodule Livebook.Runtime.ErlDist do
|
|||
# To ensure proper isolation between sessions, code evaluation may
|
||||
# take place in a separate Elixir runtime, which also makes it easy
|
||||
# to terminate the whole evaluation environment without stopping
|
||||
# Livebook. Both `Runtime.ElixirStandalone` and `Runtime.Attached`
|
||||
# Livebook. Both `Runtime.Standalone` and `Runtime.Attached`
|
||||
# do that and this module contains the shared functionality.
|
||||
#
|
||||
# To work with a separate node, we have to inject the necessary
|
||||
|
@ -40,7 +40,6 @@ defmodule Livebook.Runtime.ErlDist do
|
|||
Livebook.Runtime.ErlDist.EvaluatorSupervisor,
|
||||
Livebook.Runtime.ErlDist.IOForwardGL,
|
||||
Livebook.Runtime.ErlDist.LoggerGLHandler,
|
||||
Livebook.Runtime.ErlDist.Sink,
|
||||
Livebook.Runtime.ErlDist.SmartCellGL,
|
||||
Livebook.Proxy.Adapter,
|
||||
Livebook.Proxy.Handler
|
||||
|
@ -62,15 +61,22 @@ defmodule Livebook.Runtime.ErlDist do
|
|||
"""
|
||||
@spec initialize(node(), keyword()) :: pid()
|
||||
def initialize(node, opts \\ []) do
|
||||
unless modules_loaded?(node) do
|
||||
load_required_modules(node)
|
||||
end
|
||||
# First, we attempt to communicate with the node manager, in case
|
||||
# there is one running. Otherwise, the node is not initialized,
|
||||
# so we need to initialize it and try again
|
||||
case start_runtime_server(node, opts[:runtime_server_opts] || []) do
|
||||
{:ok, pid} ->
|
||||
pid
|
||||
|
||||
unless node_manager_started?(node) do
|
||||
start_node_manager(node, opts[:node_manager_opts] || [])
|
||||
end
|
||||
{:error, :down} ->
|
||||
unless modules_loaded?(node) do
|
||||
load_required_modules(node)
|
||||
end
|
||||
|
||||
start_runtime_server(node, opts[:runtime_server_opts] || [])
|
||||
{:ok, _} = start_node_manager(node, opts[:node_manager_opts] || [])
|
||||
{:ok, pid} = start_runtime_server(node, opts[:runtime_server_opts] || [])
|
||||
pid
|
||||
end
|
||||
end
|
||||
|
||||
defp load_required_modules(node) do
|
||||
|
@ -109,13 +115,6 @@ defmodule Livebook.Runtime.ErlDist do
|
|||
:rpc.call(node, Code, :ensure_loaded?, [Livebook.Runtime.ErlDist.NodeManager])
|
||||
end
|
||||
|
||||
defp node_manager_started?(node) do
|
||||
case :rpc.call(node, Process, :whereis, [Livebook.Runtime.ErlDist.NodeManager]) do
|
||||
nil -> false
|
||||
_pid -> true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unloads the previously loaded Livebook modules from the caller node.
|
||||
"""
|
||||
|
|
|
@ -3,7 +3,7 @@ defmodule Livebook.Runtime.ErlDist.LoggerGLHandler do
|
|||
def log(%{meta: meta} = event, %{formatter: {formatter_module, formatter_config}}) do
|
||||
message = apply(formatter_module, :format, [event, formatter_config])
|
||||
|
||||
if Livebook.Runtime.ErlDist.NodeManager.known_io_proxy?(meta.gl) do
|
||||
if Livebook.Runtime.Evaluator.IOProxy.io_proxy?(meta.gl) do
|
||||
async_io(meta.gl, message)
|
||||
else
|
||||
send(Livebook.Runtime.ErlDist.NodeManager, {:orphan_log, message})
|
||||
|
@ -11,7 +11,7 @@ defmodule Livebook.Runtime.ErlDist.LoggerGLHandler do
|
|||
end
|
||||
|
||||
def async_io(device, output) when is_pid(device) do
|
||||
reply_to = Livebook.Runtime.ErlDist.Sink.pid()
|
||||
reply_to = Livebook.Runtime.ErlDist.NodeManager.sink_pid()
|
||||
send(device, {:io_request, reply_to, make_ref(), {:put_chars, :unicode, output}})
|
||||
end
|
||||
|
||||
|
|
|
@ -15,7 +15,6 @@ defmodule Livebook.Runtime.ErlDist.NodeManager do
|
|||
alias Livebook.Runtime.ErlDist
|
||||
|
||||
@name __MODULE__
|
||||
@io_proxy_registry_name __MODULE__.IOProxyRegistry
|
||||
|
||||
@doc """
|
||||
Starts the node manager.
|
||||
|
@ -52,21 +51,44 @@ defmodule Livebook.Runtime.ErlDist.NodeManager do
|
|||
|
||||
@doc """
|
||||
Starts a new `Livebook.Runtime.ErlDist.RuntimeServer` for evaluation.
|
||||
"""
|
||||
@spec start_runtime_server(node(), keyword()) :: pid()
|
||||
def start_runtime_server(node, opts \\ []) do
|
||||
GenServer.call(server(node), {:start_runtime_server, opts})
|
||||
end
|
||||
|
||||
@doc false
|
||||
def known_io_proxy?(pid) do
|
||||
case Registry.keys(@io_proxy_registry_name, pid) do
|
||||
[_] -> true
|
||||
[] -> false
|
||||
This function fails gracefully when the node manager is not running
|
||||
or is about to terminate. This is why we do not use `GenServer.call/2`.
|
||||
|
||||
To start a runtime server we could check if the node manager is alive
|
||||
and then try to call it, however it could terminate between these
|
||||
operations (if the last runtime server terminated). This race condition
|
||||
could happen when reconnecting to the same runtime node. To avoid
|
||||
this, we combine the check and start into an atomic operation.
|
||||
"""
|
||||
@spec start_runtime_server(node(), keyword()) :: {:ok, pid()} | {:error, :down}
|
||||
def start_runtime_server(node, opts \\ []) do
|
||||
if pid = :rpc.call(node, Process, :whereis, [@name]) do
|
||||
ref = Process.monitor(pid)
|
||||
send(pid, {:start_runtime_server, self(), ref, opts})
|
||||
|
||||
receive do
|
||||
{:reply, ^ref, pid} ->
|
||||
Process.demonitor(ref, [:flush])
|
||||
{:ok, pid}
|
||||
|
||||
{:DOWN, ^ref, :process, _, _} ->
|
||||
{:error, :down}
|
||||
end
|
||||
else
|
||||
{:error, :down}
|
||||
end
|
||||
end
|
||||
|
||||
defp server(node) when is_atom(node), do: {@name, node}
|
||||
@sink_key {__MODULE__, :sink}
|
||||
|
||||
@doc """
|
||||
Returns a process that ignores all incoming messages.
|
||||
"""
|
||||
@spec sink_pid() :: pid()
|
||||
def sink_pid() do
|
||||
:persistent_term.get(@sink_key)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
|
@ -77,13 +99,17 @@ defmodule Livebook.Runtime.ErlDist.NodeManager do
|
|||
|
||||
## Initialize the node
|
||||
|
||||
# Note that we intentionally do not name any processes other than
|
||||
# the manager itself. This way, when the manager terminates, another
|
||||
# one can be started immediately without the possibility of the
|
||||
# linked processes to be still around and cause name conflicts.
|
||||
# This scenario could be the case when reconnecting to the same
|
||||
# runtime node.
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
|
||||
{:ok, server_supervisor} = DynamicSupervisor.start_link(strategy: :one_for_one)
|
||||
|
||||
{:ok, io_proxy_registry} =
|
||||
Registry.start_link(name: @io_proxy_registry_name, keys: :duplicate)
|
||||
|
||||
# Register our own standard error IO device that proxies to
|
||||
# sender's group leader.
|
||||
original_standard_error = Process.whereis(:standard_error)
|
||||
|
@ -91,7 +117,7 @@ defmodule Livebook.Runtime.ErlDist.NodeManager do
|
|||
Process.unregister(:standard_error)
|
||||
Process.register(io_forward_gl_pid, :standard_error)
|
||||
|
||||
{:ok, _pid} = Livebook.Runtime.ErlDist.Sink.start_link()
|
||||
:persistent_term.put(@sink_key, spawn_link(&sink_loop/0))
|
||||
|
||||
:logger.add_handler(:livebook_gl_handler, Livebook.Runtime.ErlDist.LoggerGLHandler, %{
|
||||
formatter: Logger.Formatter.new(),
|
||||
|
@ -131,8 +157,7 @@ defmodule Livebook.Runtime.ErlDist.NodeManager do
|
|||
original_standard_error: original_standard_error,
|
||||
parent_node: parent_node,
|
||||
capture_orphan_logs: capture_orphan_logs,
|
||||
tmp_dir: tmp_dir,
|
||||
io_proxy_registry: io_proxy_registry
|
||||
tmp_dir: tmp_dir
|
||||
}}
|
||||
end
|
||||
|
||||
|
@ -151,6 +176,8 @@ defmodule Livebook.Runtime.ErlDist.NodeManager do
|
|||
|
||||
:logger.remove_handler(:livebook_gl_handler)
|
||||
|
||||
:persistent_term.erase(@sink_key)
|
||||
|
||||
if state.unload_modules_on_termination do
|
||||
ErlDist.unload_required_modules()
|
||||
end
|
||||
|
@ -193,25 +220,26 @@ defmodule Livebook.Runtime.ErlDist.NodeManager do
|
|||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(_message, state), do: {:noreply, state}
|
||||
|
||||
@impl true
|
||||
def handle_call({:start_runtime_server, opts}, _from, state) do
|
||||
def handle_info({:start_runtime_server, pid, ref, opts}, state) do
|
||||
opts =
|
||||
opts
|
||||
|> Keyword.put_new(:ebin_path, ebin_path(state.tmp_dir))
|
||||
|> Keyword.put_new(:tmp_dir, child_tmp_dir(state.tmp_dir))
|
||||
|> Keyword.put_new(:base_path_env, System.get_env("PATH", ""))
|
||||
|> Keyword.put_new(:io_proxy_registry, @io_proxy_registry_name)
|
||||
|
||||
{:ok, server_pid} =
|
||||
DynamicSupervisor.start_child(state.server_supervisor, {ErlDist.RuntimeServer, opts})
|
||||
|
||||
Process.monitor(server_pid)
|
||||
state = update_in(state.runtime_servers, &[server_pid | &1])
|
||||
{:reply, server_pid, state}
|
||||
|
||||
send(pid, {:reply, ref, server_pid})
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(_message, state), do: {:noreply, state}
|
||||
|
||||
defp make_tmp_dir() do
|
||||
path = Path.join([System.tmp_dir!(), "livebook_runtime", random_long_id()])
|
||||
|
||||
|
@ -229,4 +257,10 @@ defmodule Livebook.Runtime.ErlDist.NodeManager do
|
|||
defp random_long_id() do
|
||||
:crypto.strong_rand_bytes(20) |> Base.encode32(case: :lower)
|
||||
end
|
||||
|
||||
defp sink_loop() do
|
||||
receive do
|
||||
_ -> sink_loop()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -49,9 +49,6 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
to merge new values into when setting environment variables.
|
||||
Defaults to `System.get_env("PATH", "")`
|
||||
|
||||
* `:io_proxy_registry` - the registry to register IO proxy
|
||||
processes in
|
||||
|
||||
"""
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts)
|
||||
|
@ -269,14 +266,6 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
GenServer.call(pid, {:has_dependencies?, dependencies})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disables dependencies cache globally.
|
||||
"""
|
||||
@spec disable_dependencies_cache(pid()) :: :ok
|
||||
def disable_dependencies_cache(pid) do
|
||||
GenServer.cast(pid, :disable_dependencies_cache)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the given environment variables.
|
||||
"""
|
||||
|
@ -378,7 +367,6 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
base_env_path:
|
||||
Keyword.get_lazy(opts, :base_env_path, fn -> System.get_env("PATH", "") end),
|
||||
ebin_path: Keyword.get(opts, :ebin_path),
|
||||
io_proxy_registry: Keyword.get(opts, :io_proxy_registry),
|
||||
tmp_dir: Keyword.get(opts, :tmp_dir),
|
||||
mix_install_project_dir: nil
|
||||
}}
|
||||
|
@ -391,7 +379,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
if state.owner do
|
||||
{:noreply, state}
|
||||
else
|
||||
{:stop, :no_owner, state}
|
||||
{:stop, {:shutdown, :no_owner}, state}
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -656,12 +644,6 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_cast(:disable_dependencies_cache, state) do
|
||||
System.put_env("MIX_INSTALL_FORCE", "true")
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_cast({:put_system_envs, envs}, state) do
|
||||
envs
|
||||
|> Enum.map(fn
|
||||
|
@ -799,8 +781,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
|
|||
object_tracker: state.object_tracker,
|
||||
client_tracker: state.client_tracker,
|
||||
ebin_path: state.ebin_path,
|
||||
tmp_dir: evaluator_tmp_dir(state),
|
||||
io_proxy_registry: state.io_proxy_registry
|
||||
tmp_dir: evaluator_tmp_dir(state)
|
||||
)
|
||||
|
||||
Process.monitor(evaluator.pid)
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
defmodule Livebook.Runtime.ErlDist.Sink do
|
||||
# An idle process that ignores all incoming messages.
|
||||
|
||||
use GenServer
|
||||
|
||||
@name __MODULE__
|
||||
|
||||
@doc """
|
||||
Starts the process.
|
||||
"""
|
||||
@spec start_link() :: GenServer.on_start()
|
||||
def start_link() do
|
||||
GenServer.start_link(__MODULE__, {}, name: @name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns pid of the global sink process.
|
||||
"""
|
||||
@spec pid() :: pid()
|
||||
def pid() do
|
||||
Process.whereis(@name)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init({}) do
|
||||
{:ok, {}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_message, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
|
@ -88,9 +88,6 @@ defmodule Livebook.Runtime.Evaluator do
|
|||
* `:tmp_dir` - a temporary directory for arbitrary use during
|
||||
evaluation
|
||||
|
||||
* `:io_proxy_registry` - the registry to register IO proxy
|
||||
processes in
|
||||
|
||||
"""
|
||||
@spec start_link(keyword()) :: {:ok, pid(), t()} | {:error, term()}
|
||||
def start_link(opts \\ []) do
|
||||
|
@ -273,7 +270,6 @@ defmodule Livebook.Runtime.Evaluator do
|
|||
client_tracker = Keyword.fetch!(opts, :client_tracker)
|
||||
ebin_path = Keyword.get(opts, :ebin_path)
|
||||
tmp_dir = Keyword.get(opts, :tmp_dir)
|
||||
io_proxy_registry = Keyword.get(opts, :io_proxy_registry)
|
||||
|
||||
{:ok, io_proxy} =
|
||||
Evaluator.IOProxy.start(%{
|
||||
|
@ -283,8 +279,7 @@ defmodule Livebook.Runtime.Evaluator do
|
|||
object_tracker: object_tracker,
|
||||
client_tracker: client_tracker,
|
||||
ebin_path: ebin_path,
|
||||
tmp_dir: tmp_dir,
|
||||
registry: io_proxy_registry
|
||||
tmp_dir: tmp_dir
|
||||
})
|
||||
|
||||
io_proxy_monitor = Process.monitor(io_proxy)
|
||||
|
@ -430,6 +425,10 @@ defmodule Livebook.Runtime.Evaluator do
|
|||
|
||||
set_pdict(context, state.ignored_pdict_keys)
|
||||
|
||||
if opts[:disable_dependencies_cache] do
|
||||
System.put_env("MIX_INSTALL_FORCE", "true")
|
||||
end
|
||||
|
||||
start_time = System.monotonic_time()
|
||||
{eval_result, code_markers} = eval(language, code, context.binding, context.env)
|
||||
evaluation_time_ms = time_diff_ms(start_time)
|
||||
|
|
|
@ -31,8 +31,7 @@ defmodule Livebook.Runtime.Evaluator.IOProxy do
|
|||
object_tracker: pid(),
|
||||
client_tracker: pid(),
|
||||
ebin_path: String.t() | nil,
|
||||
tmp_dir: String.t() | nil,
|
||||
registry: atom() | nil
|
||||
tmp_dir: String.t() | nil
|
||||
}) :: GenServer.on_start()
|
||||
def start(args) do
|
||||
GenServer.start(__MODULE__, args)
|
||||
|
@ -71,6 +70,28 @@ defmodule Livebook.Runtime.Evaluator.IOProxy do
|
|||
GenServer.cast(pid, {:tracer_updates, updates})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the given process is a IO proxy.
|
||||
|
||||
The check happens against the process dictionary.
|
||||
"""
|
||||
def io_proxy?(pid) do
|
||||
process_get_key(pid, :io_proxy) == true
|
||||
end
|
||||
|
||||
defp process_get_key(pid, key) do
|
||||
try do
|
||||
case Process.info(pid, {:dictionary, key}) do
|
||||
{{:dictionary, ^key}, :undefined} -> nil
|
||||
{{:dictionary, ^key}, value} -> value
|
||||
nil -> nil
|
||||
end
|
||||
rescue
|
||||
# TODO: remove error handler once we require OTP 26.2
|
||||
_ -> Process.info(pid, [:dictionary])[:dictionary][key]
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(args) do
|
||||
%{
|
||||
|
@ -80,15 +101,12 @@ defmodule Livebook.Runtime.Evaluator.IOProxy do
|
|||
object_tracker: object_tracker,
|
||||
client_tracker: client_tracker,
|
||||
ebin_path: ebin_path,
|
||||
tmp_dir: tmp_dir,
|
||||
registry: registry
|
||||
tmp_dir: tmp_dir
|
||||
} = args
|
||||
|
||||
evaluator_monitor = Process.monitor(evaluator)
|
||||
|
||||
if registry do
|
||||
Registry.register(registry, nil, nil)
|
||||
end
|
||||
Process.put(:io_proxy, true)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
|
|
467
lib/livebook/runtime/fly.ex
Normal file
467
lib/livebook/runtime/fly.ex
Normal file
|
@ -0,0 +1,467 @@
|
|||
defmodule Livebook.Runtime.Fly do
|
||||
# A runtime backed by a Fly.io machine managed by Livebook.
|
||||
#
|
||||
# This runtime uses a Livebook-managed Elixir node, similarly to
|
||||
# the Standalone runtime, however it runs on a temporary Fly.io
|
||||
# machine. The machine is configured to automatically shutdown
|
||||
# as soon as the runtime is disconnected.
|
||||
#
|
||||
# Note: this runtime requires `flyctl` executable to be available
|
||||
# in the system.
|
||||
#
|
||||
# ## Communication
|
||||
#
|
||||
# The machine runs the Livebook Docker image and we configure it to
|
||||
# invoke the start_runtime.exs script, by setting LIVEBOOK_RUNTIME.
|
||||
# This environment variable also includes encoded information passed
|
||||
# from the parent. Once the Elixir node starts on the machine, it
|
||||
# waits for the parent to connect and finish the initialization.
|
||||
#
|
||||
# Now, we want to establish a distribution connection from the local
|
||||
# Livebook node to the node on the Fly.io machine. We could reach
|
||||
# the node directly, by requiring the user to set up WireGuard.
|
||||
# However, that would require the user to install WireGuard and go
|
||||
# through a few configuration steps. Instead, we use flyctl proxy
|
||||
# feature and only require flyctl to be installed.
|
||||
#
|
||||
# With flyctl proxy, we proxy a local port to the the distribution
|
||||
# port of the Fly.io node. Then, in our EPMD module (`Livebook.EPMD`)
|
||||
# we special case those nodes in two ways: (1) we infer the
|
||||
# distribution port from the node name; (2) we resolve the node
|
||||
# address to loopback, ignoring its hostname.
|
||||
#
|
||||
# ### Distribution protocol
|
||||
#
|
||||
# Usually, nodes need to be configured to use the same distribution
|
||||
# protocol (`-proto_dist`). We configure the Fly.io node to use IPv6
|
||||
# distribution (`-proto_dist inet6_tcp`). However, depending whether
|
||||
# the local node runs IPv4 or IPv6 distribution, we configure the
|
||||
# flyctl proxy to bind to a IPv4 or IPv6 loopback respectively. The
|
||||
# proxy always communicates with the Fly.io machine over IPv6, as
|
||||
# is the case with all internal networking. Consequently, regardless
|
||||
# of the protocol used by the local node, the remote node perceives
|
||||
# it as IPv6.
|
||||
#
|
||||
# Sidenote, a node using IPv6 distribution may accept connections
|
||||
# from a node using IPv4, depending on the `:kernel` application
|
||||
# configuration `inet_dist_listen_options` -> `ipv6_v6only`, which
|
||||
# has OS-specific value. However, we don't rely on this here.
|
||||
|
||||
defstruct [:config, :node, :server_pid]
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
require Logger
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
config: config(),
|
||||
node: node() | nil,
|
||||
server_pid: pid() | nil
|
||||
}
|
||||
|
||||
@type config :: %{
|
||||
token: String.t(),
|
||||
app_name: String.t(),
|
||||
region: String.t(),
|
||||
cpu_kind: String.t(),
|
||||
cpus: pos_integer(),
|
||||
memory_gb: pos_integer(),
|
||||
gpu_kind: String.t() | nil,
|
||||
gpus: pos_integer() | nil,
|
||||
volume_id: String.t() | nil,
|
||||
docker_tag: String.t()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns a new runtime instance.
|
||||
"""
|
||||
@spec new(config()) :: t()
|
||||
def new(config) do
|
||||
%__MODULE__{config: config}
|
||||
end
|
||||
|
||||
def __connect__(runtime) do
|
||||
{:ok, pid} =
|
||||
DynamicSupervisor.start_child(Livebook.RuntimeSupervisor, {__MODULE__, {runtime, self()}})
|
||||
|
||||
pid
|
||||
end
|
||||
|
||||
@doc false
|
||||
def start_link({runtime, caller}) do
|
||||
GenServer.start_link(__MODULE__, {runtime, caller})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init({runtime, caller}) do
|
||||
state = %{primary_ref: nil, proxy_port: nil}
|
||||
{:ok, state, {:continue, {:init, runtime, caller}}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_continue({:init, runtime, caller}, state) do
|
||||
config = runtime.config
|
||||
local_port = get_free_port!()
|
||||
remote_port = 44444
|
||||
node_base = "remote_runtime_#{local_port}"
|
||||
|
||||
runtime_data =
|
||||
%{
|
||||
node_base: node_base,
|
||||
cookie: Node.get_cookie(),
|
||||
dist_port: remote_port
|
||||
}
|
||||
|> :erlang.term_to_binary()
|
||||
|> Base.encode64()
|
||||
|
||||
with {:ok, machine_id, machine_ip} <-
|
||||
with_log(caller, "create machine", fn ->
|
||||
create_machine(config, runtime_data)
|
||||
end),
|
||||
child_node <- :"#{node_base}@#{machine_id}.vm.#{config.app_name}.internal",
|
||||
{:ok, proxy_port} <-
|
||||
with_log(caller, "start proxy", fn ->
|
||||
start_fly_proxy(config.app_name, machine_ip, local_port, remote_port, config.token)
|
||||
end),
|
||||
:ok <-
|
||||
with_log(caller, "connect to node", fn ->
|
||||
connect_loop(child_node, 40, 250)
|
||||
end),
|
||||
{:ok, primary_pid} <- fetch_runtime_info(child_node) do
|
||||
primary_ref = Process.monitor(primary_pid)
|
||||
|
||||
server_pid =
|
||||
with_log(caller, "initialize node", fn ->
|
||||
initialize_node(child_node)
|
||||
end)
|
||||
|
||||
send(primary_pid, :node_initialized)
|
||||
|
||||
runtime = %{runtime | node: child_node, server_pid: server_pid}
|
||||
send(caller, {:runtime_connect_done, self(), {:ok, runtime}})
|
||||
|
||||
{:noreply, %{state | primary_ref: primary_ref, proxy_port: proxy_port}}
|
||||
else
|
||||
{:error, error} ->
|
||||
send(caller, {:runtime_connect_done, self(), {:error, error}})
|
||||
|
||||
{:stop, :shutdown, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:DOWN, ref, :process, _pid, _reason}, state) when ref == state.primary_ref do
|
||||
{:stop, :shutdown, state}
|
||||
end
|
||||
|
||||
def handle_info({port, _message}, state) when state.proxy_port == port do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp create_machine(config, runtime_data) do
|
||||
base_image = Enum.find(Livebook.Config.docker_images(), &(&1.tag == config.docker_tag))
|
||||
image = "ghcr.io/livebook-dev/livebook:#{base_image.tag}"
|
||||
|
||||
env =
|
||||
Map.merge(
|
||||
Map.new(base_image.env),
|
||||
%{
|
||||
"LIVEBOOK_RUNTIME" => runtime_data,
|
||||
"ERL_AFLAGS" => "-proto_dist inet6_tcp"
|
||||
}
|
||||
)
|
||||
|
||||
name = "#{config.app_name}-livebook-runtime-#{Livebook.Utils.random_id()}"
|
||||
|
||||
machine_config = %{
|
||||
image: image,
|
||||
guest: %{
|
||||
cpu_kind: config.cpu_kind,
|
||||
cpus: config.cpus,
|
||||
memory_mb: config.memory_gb * 1024,
|
||||
gpu_kind: config.gpu_kind,
|
||||
gpus: config.gpus
|
||||
},
|
||||
mounts: config.volume_id && [%{volume: config.volume_id, path: "/home/livebook"}],
|
||||
auto_destroy: true,
|
||||
restart: %{policy: "no"},
|
||||
env: env
|
||||
}
|
||||
|
||||
case Livebook.FlyAPI.create_machine(
|
||||
config.token,
|
||||
config.app_name,
|
||||
name,
|
||||
config.region,
|
||||
machine_config
|
||||
) do
|
||||
{:ok, %{id: machine_id, private_ip: machine_ip}} ->
|
||||
{:ok, machine_id, machine_ip}
|
||||
|
||||
{:error, %{message: message}} ->
|
||||
{:error, "could not create machine, reason: #{message}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp connect_loop(_node, 0, _interval) do
|
||||
{:error, "could not establish connection with the node"}
|
||||
end
|
||||
|
||||
defp connect_loop(node, attempts, interval) do
|
||||
if Node.connect(node) do
|
||||
:ok
|
||||
else
|
||||
Process.sleep(interval)
|
||||
connect_loop(node, attempts - 1, interval)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_free_port!() do
|
||||
{:ok, socket} = :gen_tcp.listen(0, active: false, reuseaddr: true)
|
||||
{:ok, port} = :inet.port(socket)
|
||||
:gen_tcp.close(socket)
|
||||
port
|
||||
end
|
||||
|
||||
defp start_fly_proxy(app_name, host, local_port, remote_port, token) do
|
||||
with {:ok, flyctl_path} <- find_fly_executable() do
|
||||
ports = "#{local_port}:#{remote_port}"
|
||||
|
||||
# We want the proxy to accept the same protocol that we are
|
||||
# going to use for distribution
|
||||
bind_addr =
|
||||
if Livebook.Utils.proto_dist() == :inet6_tcp do
|
||||
"[::1]"
|
||||
else
|
||||
"127.0.0.1"
|
||||
end
|
||||
|
||||
args = [
|
||||
"proxy",
|
||||
ports,
|
||||
host,
|
||||
"--app",
|
||||
app_name,
|
||||
"--bind-addr",
|
||||
bind_addr,
|
||||
"--access-token",
|
||||
token,
|
||||
"--watch-stdin"
|
||||
]
|
||||
|
||||
port =
|
||||
Port.open(
|
||||
{:spawn_executable, flyctl_path},
|
||||
[:binary, :hide, :stderr_to_stdout, args: args]
|
||||
)
|
||||
|
||||
port_ref = Port.monitor(port)
|
||||
|
||||
result =
|
||||
receive do
|
||||
{^port, {:data, "Proxying " <> _}} ->
|
||||
{:ok, port}
|
||||
|
||||
{^port, {:data, "Error: unknown flag: --watch-stdin\n"}} ->
|
||||
{:error,
|
||||
"failed to open fly proxy, because the current version " <>
|
||||
"is missing a required feature. Please update flyctl"}
|
||||
|
||||
{^port, {:data, "Error: " <> error}} ->
|
||||
{:error, "failed to open fly proxy. Error: #{String.trim(error)}"}
|
||||
|
||||
{:DOWN, ^port_ref, :port, _object, reason} ->
|
||||
{:error, "failed to open fly proxy. Process terminated, reason: #{inspect(reason)}"}
|
||||
after
|
||||
30_000 ->
|
||||
{:error, "failed to open fly proxy. Timed out after 30s"}
|
||||
end
|
||||
|
||||
Port.demonitor(port_ref, [:flush])
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
defp find_fly_executable() do
|
||||
if path = System.find_executable("flyctl") do
|
||||
{:ok, path}
|
||||
else
|
||||
{:error,
|
||||
"no flyctl executable found in PATH. For installation instructions" <>
|
||||
" refer to https://fly.io/docs/flyctl/install"}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_runtime_info(child_node) do
|
||||
# Note: it is Livebook that starts the runtime node, so we know
|
||||
# that the node runs Livebook release of the exact same version
|
||||
|
||||
%{
|
||||
pid: pid,
|
||||
elixir_version: elixir_version
|
||||
} = :erpc.call(child_node, :persistent_term, :get, [:livebook_runtime_info])
|
||||
|
||||
if elixir_version != System.version() do
|
||||
{:error,
|
||||
"the local Elixir version (#{inspect(System.version())}) does not" <>
|
||||
" match the one used by the runtime (#{elixir_version})"}
|
||||
else
|
||||
{:ok, pid}
|
||||
end
|
||||
end
|
||||
|
||||
defp initialize_node(child_node) do
|
||||
init_opts = [
|
||||
runtime_server_opts: [
|
||||
extra_smart_cell_definitions: Livebook.Runtime.Definitions.smart_cell_definitions()
|
||||
]
|
||||
]
|
||||
|
||||
Livebook.Runtime.ErlDist.initialize(child_node, init_opts)
|
||||
end
|
||||
|
||||
defp with_log(caller, name, fun) do
|
||||
send(caller, {:runtime_connect_info, self(), name})
|
||||
|
||||
{microseconds, result} = :timer.tc(fun)
|
||||
milliseconds = div(microseconds, 1000)
|
||||
|
||||
case result do
|
||||
{:error, error} ->
|
||||
Logger.debug("[fly runtime] #{name} FAILED in #{milliseconds}ms, error: #{error}")
|
||||
|
||||
_ ->
|
||||
Logger.debug("[fly runtime] #{name} finished in #{milliseconds}ms")
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Livebook.Runtime, for: Livebook.Runtime.Fly do
|
||||
alias Livebook.Runtime.ErlDist.RuntimeServer
|
||||
|
||||
def describe(runtime) do
|
||||
[{"Type", "Fly.io machine"}] ++
|
||||
if runtime.node do
|
||||
[{"Node name", Atom.to_string(runtime.node)}]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def connect(runtime) do
|
||||
Livebook.Runtime.Fly.__connect__(runtime)
|
||||
end
|
||||
|
||||
def take_ownership(runtime, opts \\ []) do
|
||||
RuntimeServer.attach(runtime.server_pid, self(), opts)
|
||||
Process.monitor(runtime.server_pid)
|
||||
end
|
||||
|
||||
def disconnect(runtime) do
|
||||
:ok = RuntimeServer.stop(runtime.server_pid)
|
||||
end
|
||||
|
||||
def duplicate(runtime) do
|
||||
Livebook.Runtime.Fly.new(runtime.config)
|
||||
end
|
||||
|
||||
def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ []) do
|
||||
RuntimeServer.evaluate_code(
|
||||
runtime.server_pid,
|
||||
language,
|
||||
code,
|
||||
locator,
|
||||
parent_locators,
|
||||
opts
|
||||
)
|
||||
end
|
||||
|
||||
def forget_evaluation(runtime, locator) do
|
||||
RuntimeServer.forget_evaluation(runtime.server_pid, locator)
|
||||
end
|
||||
|
||||
def drop_container(runtime, container_ref) do
|
||||
RuntimeServer.drop_container(runtime.server_pid, container_ref)
|
||||
end
|
||||
|
||||
def handle_intellisense(runtime, send_to, request, parent_locators, node) do
|
||||
RuntimeServer.handle_intellisense(runtime.server_pid, send_to, request, parent_locators, node)
|
||||
end
|
||||
|
||||
def read_file(runtime, path) do
|
||||
RuntimeServer.read_file(runtime.server_pid, path)
|
||||
end
|
||||
|
||||
def transfer_file(runtime, path, file_id, callback) do
|
||||
RuntimeServer.transfer_file(runtime.server_pid, path, file_id, callback)
|
||||
end
|
||||
|
||||
def relabel_file(runtime, file_id, new_file_id) do
|
||||
RuntimeServer.relabel_file(runtime.server_pid, file_id, new_file_id)
|
||||
end
|
||||
|
||||
def revoke_file(runtime, file_id) do
|
||||
RuntimeServer.revoke_file(runtime.server_pid, file_id)
|
||||
end
|
||||
|
||||
def start_smart_cell(runtime, kind, ref, attrs, parent_locators) do
|
||||
RuntimeServer.start_smart_cell(runtime.server_pid, kind, ref, attrs, parent_locators)
|
||||
end
|
||||
|
||||
def set_smart_cell_parent_locators(runtime, ref, parent_locators) do
|
||||
RuntimeServer.set_smart_cell_parent_locators(runtime.server_pid, ref, parent_locators)
|
||||
end
|
||||
|
||||
def stop_smart_cell(runtime, ref) do
|
||||
RuntimeServer.stop_smart_cell(runtime.server_pid, ref)
|
||||
end
|
||||
|
||||
def fixed_dependencies?(_runtime), do: false
|
||||
|
||||
def add_dependencies(_runtime, code, dependencies) do
|
||||
Livebook.Runtime.Dependencies.add_dependencies(code, dependencies)
|
||||
end
|
||||
|
||||
def has_dependencies?(runtime, dependencies) do
|
||||
RuntimeServer.has_dependencies?(runtime.server_pid, dependencies)
|
||||
end
|
||||
|
||||
def snippet_definitions(_runtime) do
|
||||
Livebook.Runtime.Definitions.snippet_definitions()
|
||||
end
|
||||
|
||||
def search_packages(_runtime, send_to, search) do
|
||||
Livebook.Runtime.Dependencies.search_packages_on_hex(send_to, search)
|
||||
end
|
||||
|
||||
def put_system_envs(runtime, envs) do
|
||||
RuntimeServer.put_system_envs(runtime.server_pid, envs)
|
||||
end
|
||||
|
||||
def delete_system_envs(runtime, names) do
|
||||
RuntimeServer.delete_system_envs(runtime.server_pid, names)
|
||||
end
|
||||
|
||||
def restore_transient_state(runtime, transient_state) do
|
||||
RuntimeServer.restore_transient_state(runtime.server_pid, transient_state)
|
||||
end
|
||||
|
||||
def register_clients(runtime, clients) do
|
||||
RuntimeServer.register_clients(runtime.server_pid, clients)
|
||||
end
|
||||
|
||||
def unregister_clients(runtime, client_ids) do
|
||||
RuntimeServer.unregister_clients(runtime.server_pid, client_ids)
|
||||
end
|
||||
|
||||
def fetch_proxy_handler_spec(runtime) do
|
||||
RuntimeServer.fetch_proxy_handler_spec(runtime.server_pid)
|
||||
end
|
||||
|
||||
def disconnect_node(runtime, node) do
|
||||
RuntimeServer.disconnect_node(runtime.server_pid, node)
|
||||
end
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Livebook.Runtime.ElixirStandalone do
|
||||
defmodule Livebook.Runtime.Standalone do
|
||||
defstruct [:node, :server_pid]
|
||||
|
||||
# A runtime backed by a standalone Elixir node managed by Livebook.
|
||||
|
@ -7,6 +7,26 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
# Most importantly we have to make sure the started node doesn't
|
||||
# stay in the system when the session or the entire Livebook
|
||||
# terminates.
|
||||
#
|
||||
# Note: this runtime requires `elixir` executable to be available in
|
||||
# the system.
|
||||
#
|
||||
# ## Connecting
|
||||
#
|
||||
# Connecting the runtime starts a new Elixir node (a system process).
|
||||
# That child node connects back to the parent and notifies that it
|
||||
# is ready by sending a `:node_started` message. Next, the parent
|
||||
# initializes the child node by loading the necessary modules and
|
||||
# starting processes, in particular the node manager and one runtime
|
||||
# server. Once done, the parent sends a `:node_initialized` message
|
||||
# to the child, and the child starts monitoring the node manager.
|
||||
# Once the node manager terminates, the node shuts down.
|
||||
#
|
||||
# If no process calls `Livebook.Runtime.take_ownership/1` for a
|
||||
# period of time, the node automatically terminates. Whoever takes
|
||||
# the ownership, becomes the owner and as soon as it terminates,
|
||||
# the node shuts down. The node may also be shut down by calling
|
||||
# `Livebook.Runtime.disconnect/1`.
|
||||
|
||||
alias Livebook.Utils
|
||||
|
||||
|
@ -23,20 +43,19 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
%__MODULE__{}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a new Elixir node (a system process) and initializes it with
|
||||
Livebook-specific modules and processes.
|
||||
def __connect__(runtime) do
|
||||
caller = self()
|
||||
|
||||
If no process calls `Runtime.take_ownership/1` for a period of time,
|
||||
the node automatically terminates. Whoever takes the ownersihp,
|
||||
becomes the owner and as soon as it terminates, the node terminates
|
||||
as well. The node may also be terminated by calling `Runtime.disconnect/1`.
|
||||
{:ok, pid} =
|
||||
DynamicSupervisor.start_child(
|
||||
Livebook.RuntimeSupervisor,
|
||||
{Task, fn -> do_connect(runtime, caller) end}
|
||||
)
|
||||
|
||||
Note: to start the node it is required that `elixir` is a recognised
|
||||
executable within the system.
|
||||
"""
|
||||
@spec connect(t()) :: {:ok, t()} | {:error, String.t()}
|
||||
def connect(runtime) do
|
||||
pid
|
||||
end
|
||||
|
||||
defp do_connect(runtime, caller) do
|
||||
child_node = Livebook.EPMD.random_child_node()
|
||||
|
||||
Utils.temporarily_register(self(), child_node, fn ->
|
||||
|
@ -50,9 +69,10 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
port = start_elixir_node(elixir_path, child_node),
|
||||
{:ok, server_pid} <- parent_init_sequence(child_node, port, init_opts) do
|
||||
runtime = %{runtime | node: child_node, server_pid: server_pid}
|
||||
{:ok, runtime}
|
||||
send(caller, {:runtime_connect_done, self(), {:ok, runtime}})
|
||||
else
|
||||
{:error, error} -> {:error, error}
|
||||
{:error, error} ->
|
||||
send(caller, {:runtime_connect_done, self(), {:error, error}})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
@ -77,31 +97,6 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
])
|
||||
end
|
||||
|
||||
# ---
|
||||
#
|
||||
# Once the new node is spawned we need to establish a connection,
|
||||
# initialize it and make sure it correctly reacts to the parent node terminating.
|
||||
#
|
||||
# The procedure goes as follows:
|
||||
#
|
||||
# 1. The child sends {:node_initialized, ref} message to the parent
|
||||
# to communicate it's ready for initialization.
|
||||
#
|
||||
# 2. The parent initializes the child node - loads necessary modules,
|
||||
# starts the NodeManager process and a single RuntimeServer process.
|
||||
#
|
||||
# 3. The parent sends {:node_initialized, ref} message back to the child,
|
||||
# to communicate successful initialization.
|
||||
#
|
||||
# 4. The child starts monitoring the NodeManager process and freezes
|
||||
# until the NodeManager process terminates. The NodeManager process
|
||||
# serves as the leading remote process and represents the node from now on.
|
||||
#
|
||||
# The nodes either successfully go through this flow or return an error,
|
||||
# either if the other node dies or is not responding for too long.
|
||||
#
|
||||
# ---
|
||||
|
||||
defp parent_init_sequence(child_node, port, init_opts) do
|
||||
port_ref = Port.monitor(port)
|
||||
|
||||
|
@ -131,76 +126,108 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
loop.(loop)
|
||||
end
|
||||
|
||||
# Note Windows does not handle escaped quotes and newlines the same way as Unix,
|
||||
# so the string cannot have constructs newlines nor strings. That's why we pass
|
||||
# the parent node name as ARGV and write the code avoiding newlines.
|
||||
#
|
||||
# This boot script must be kept in sync with Livebook.EPMD.
|
||||
#
|
||||
# Also note that we explicitly halt, just in case `System.no_halt(true)` is
|
||||
# called within the runtime.
|
||||
@child_node_eval_string """
|
||||
{:ok, [[node]]} = :init.get_argument(:livebook_current);\
|
||||
{:ok, _} = :net_kernel.start(List.to_atom(node), %{name_domain: :longnames});\
|
||||
{:ok, [[parent_node, _port]]} = :init.get_argument(:livebook_parent);\
|
||||
dist_port = :persistent_term.get(:livebook_dist_port, 0);\
|
||||
init_ref = make_ref();\
|
||||
parent_process = {node(), List.to_atom(parent_node)};\
|
||||
send(parent_process, {:node_started, init_ref, node(), dist_port, self()});\
|
||||
receive do {:node_initialized, ^init_ref} ->\
|
||||
manager_ref = Process.monitor(Livebook.Runtime.ErlDist.NodeManager);\
|
||||
receive do {:DOWN, ^manager_ref, :process, _object, _reason} -> :ok end;\
|
||||
after 10_000 ->\
|
||||
:timeout;\
|
||||
end;\
|
||||
System.halt()\
|
||||
"""
|
||||
defp child_node_eval_string(node, parent_node, parent_port) do
|
||||
# We pass the child node code as --eval argument. Windows handles
|
||||
# escaped quotes and newlines differently from Unix, so to avoid
|
||||
# those kind of issues, we encode the string in base 64 and pass
|
||||
# as positional argument. Then, we use a simple --eval that decodes
|
||||
# and evaluates the string.
|
||||
|
||||
if @child_node_eval_string =~ "\n" do
|
||||
raise "invalid @child_node_eval_string, newline found: #{inspect(@child_node_eval_string)}"
|
||||
quote do
|
||||
node = unquote(node)
|
||||
parent_node = unquote(parent_node)
|
||||
parent_port = unquote(parent_port)
|
||||
|
||||
# We start distribution here, rather than on node boot, so that
|
||||
# -pa takes effect and Livebook.EPMD is available
|
||||
{:ok, _} = :net_kernel.start(node, %{name_domain: :longnames})
|
||||
Livebook.Runtime.EPMD.register_parent(parent_node, parent_port)
|
||||
dist_port = Livebook.Runtime.EPMD.dist_port()
|
||||
|
||||
init_ref = make_ref()
|
||||
parent_process = {node(), parent_node}
|
||||
send(parent_process, {:node_started, init_ref, node(), dist_port, self()})
|
||||
|
||||
receive do
|
||||
{:node_initialized, ^init_ref} ->
|
||||
manager_ref = Process.monitor(Livebook.Runtime.ErlDist.NodeManager)
|
||||
|
||||
receive do
|
||||
{:DOWN, ^manager_ref, :process, _object, _reason} -> :ok
|
||||
end
|
||||
after
|
||||
10_000 -> :timeout
|
||||
end
|
||||
|
||||
# We explicitly halt at the end, just in case `System.no_halt(true)`
|
||||
# is called within the runtime
|
||||
System.halt()
|
||||
end
|
||||
|> Macro.to_string()
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
defp elixir_flags(node_name) do
|
||||
parent_name = node()
|
||||
parent_port = Livebook.EPMD.dist_port()
|
||||
|
||||
epmdless_flags =
|
||||
if parent_port != 0 do
|
||||
"-epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0 "
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
[
|
||||
"--erl",
|
||||
# Minimize schedulers busy wait threshold,
|
||||
# so that they go to sleep immediately after evaluation.
|
||||
# Increase the default stack for dirty io threads (cuda requires it).
|
||||
# Enable ANSI escape codes as we handle them with HTML.
|
||||
# Disable stdin, so that the system process never tries to read terminal input.
|
||||
# Note: keep these flags in sync with the remote runtime.
|
||||
#
|
||||
# * minimize schedulers busy wait threshold, so that they go
|
||||
# to sleep immediately after evaluation
|
||||
#
|
||||
# * increase the default stack for dirty IO threads, necessary
|
||||
# for CUDA
|
||||
#
|
||||
# * enable ANSI escape codes as we handle them with HTML
|
||||
#
|
||||
# * disable stdin, so that the system process never tries to
|
||||
# read terminal input
|
||||
#
|
||||
# * specify a custom EPMD module and disable automatic EPMD
|
||||
# startup
|
||||
#
|
||||
"+sbwt none +sbwtdcpu none +sbwtdio none +sssdio 128 -elixir ansi_enabled true -noinput " <>
|
||||
epmdless_flags <>
|
||||
"-livebook_parent #{parent_name} #{parent_port} -livebook_current #{node_name}",
|
||||
# Add the location of Livebook.EPMD
|
||||
"-epmd_module Elixir.Livebook.Runtime.EPMD",
|
||||
# Add the location of Livebook.Runtime.EPMD
|
||||
"-pa",
|
||||
Application.app_dir(:livebook, "priv/epmd"),
|
||||
epmd_module_path!(),
|
||||
# Make the node hidden, so it doesn't automatically join the cluster
|
||||
"--hidden",
|
||||
# Use the cookie in Livebook
|
||||
"--cookie",
|
||||
Atom.to_string(Node.get_cookie()),
|
||||
"--eval",
|
||||
@child_node_eval_string
|
||||
"System.argv() |> hd() |> Base.decode64!() |> Code.eval_string()",
|
||||
child_node_eval_string(node_name, parent_name, parent_port)
|
||||
]
|
||||
end
|
||||
|
||||
defp epmd_module_path!() do
|
||||
# We need to make the custom Livebook.Runtime.EPMD module available
|
||||
# before the child node starts distrubtion. We persist the module
|
||||
# into a temporary directory and add to the code paths. Note that
|
||||
# we could persist it to priv/ at build time, however for Escript
|
||||
# priv/ is packaged into the archive, so it is not accessible in
|
||||
# the file system.
|
||||
|
||||
epmd_path = Path.join(Livebook.Config.tmp_path(), "epmd")
|
||||
File.rm_rf!(epmd_path)
|
||||
File.mkdir_p!(epmd_path)
|
||||
{_module, binary, path} = :code.get_object_code(Livebook.Runtime.EPMD)
|
||||
File.write!(Path.join(epmd_path, Path.basename(path)), binary)
|
||||
epmd_path
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do
|
||||
defimpl Livebook.Runtime, for: Livebook.Runtime.Standalone do
|
||||
alias Livebook.Runtime.ErlDist.RuntimeServer
|
||||
|
||||
def describe(runtime) do
|
||||
[{"Type", "Elixir standalone"}] ++
|
||||
if connected?(runtime) do
|
||||
[{"Type", "Standalone"}] ++
|
||||
if runtime.node do
|
||||
[{"Node name", Atom.to_string(runtime.node)}]
|
||||
else
|
||||
[]
|
||||
|
@ -208,11 +235,7 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do
|
|||
end
|
||||
|
||||
def connect(runtime) do
|
||||
Livebook.Runtime.ElixirStandalone.connect(runtime)
|
||||
end
|
||||
|
||||
def connected?(runtime) do
|
||||
runtime.server_pid != nil
|
||||
Livebook.Runtime.Standalone.__connect__(runtime)
|
||||
end
|
||||
|
||||
def take_ownership(runtime, opts \\ []) do
|
||||
|
@ -222,11 +245,10 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do
|
|||
|
||||
def disconnect(runtime) do
|
||||
:ok = RuntimeServer.stop(runtime.server_pid)
|
||||
{:ok, %{runtime | node: nil, server_pid: nil}}
|
||||
end
|
||||
|
||||
def duplicate(_runtime) do
|
||||
Livebook.Runtime.ElixirStandalone.new()
|
||||
Livebook.Runtime.Standalone.new()
|
||||
end
|
||||
|
||||
def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ []) do
|
||||
|
@ -298,10 +320,6 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do
|
|||
Livebook.Runtime.Dependencies.search_packages_on_hex(send_to, search)
|
||||
end
|
||||
|
||||
def disable_dependencies_cache(runtime) do
|
||||
RuntimeServer.disable_dependencies_cache(runtime.server_pid)
|
||||
end
|
||||
|
||||
def put_system_envs(runtime, envs) do
|
||||
RuntimeServer.put_system_envs(runtime.server_pid, envs)
|
||||
end
|
|
@ -111,6 +111,7 @@ defmodule Livebook.Session do
|
|||
data: Data.t(),
|
||||
client_pids_with_id: %{pid() => Data.client_id()},
|
||||
created_at: DateTime.t(),
|
||||
runtime_connect: %{ref: reference(), pid: pid()} | nil,
|
||||
runtime_monitor_ref: reference() | nil,
|
||||
autosave_timer_ref: reference() | nil,
|
||||
autosave_path: String.t() | nil,
|
||||
|
@ -452,20 +453,12 @@ defmodule Livebook.Session do
|
|||
GenServer.cast(pid, {:add_dependencies, dependencies})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends disable dependencies cache request to the server.
|
||||
"""
|
||||
@spec disable_dependencies_cache(pid()) :: :ok
|
||||
def disable_dependencies_cache(pid) do
|
||||
GenServer.cast(pid, :disable_dependencies_cache)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends cell evaluation request to the server.
|
||||
"""
|
||||
@spec queue_cell_evaluation(pid(), Cell.id()) :: :ok
|
||||
def queue_cell_evaluation(pid, cell_id) do
|
||||
GenServer.cast(pid, {:queue_cell_evaluation, self(), cell_id})
|
||||
@spec queue_cell_evaluation(pid(), Cell.id(), keyword()) :: :ok
|
||||
def queue_cell_evaluation(pid, cell_id, evaluation_opts \\ []) do
|
||||
GenServer.cast(pid, {:queue_cell_evaluation, self(), cell_id, evaluation_opts})
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
@ -586,14 +579,22 @@ defmodule Livebook.Session do
|
|||
|
||||
@doc """
|
||||
Sends runtime update to the server.
|
||||
|
||||
If the runtime is connected, the session takes the ownership.
|
||||
"""
|
||||
@spec set_runtime(pid(), Runtime.t()) :: :ok
|
||||
def set_runtime(pid, runtime) do
|
||||
GenServer.cast(pid, {:set_runtime, self(), runtime})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends request to connect to the configured runtime.
|
||||
|
||||
Once the runtime is connected, the session takes the ownership.
|
||||
"""
|
||||
@spec connect_runtime(pid()) :: :ok
|
||||
def connect_runtime(pid) do
|
||||
GenServer.cast(pid, {:connect_runtime, self()})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends file location update request to the server.
|
||||
"""
|
||||
|
@ -890,6 +891,7 @@ defmodule Livebook.Session do
|
|||
data: data,
|
||||
client_pids_with_id: %{},
|
||||
created_at: DateTime.utc_now(),
|
||||
runtime_connect: nil,
|
||||
runtime_monitor_ref: nil,
|
||||
autosave_timer_ref: nil,
|
||||
autosave_path: opts[:autosave_path],
|
||||
|
@ -997,7 +999,7 @@ defmodule Livebook.Session do
|
|||
@impl true
|
||||
def handle_continue(:app_init, state) do
|
||||
cell_ids = Data.cell_ids_for_full_evaluation(state.data, [])
|
||||
operation = {:queue_cells_evaluation, @client_id, cell_ids}
|
||||
operation = {:queue_cells_evaluation, @client_id, cell_ids, []}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
|
@ -1031,18 +1033,16 @@ defmodule Livebook.Session do
|
|||
Notebook.find_asset_info(state.data.notebook, hash) ||
|
||||
Enum.find_value(state.client_id_with_assets, fn {_client_id, assets} -> assets[hash] end)
|
||||
|
||||
runtime = state.data.runtime
|
||||
|
||||
reply =
|
||||
cond do
|
||||
assets_info == nil ->
|
||||
{:error, "unknown hash"}
|
||||
|
||||
not Runtime.connected?(runtime) ->
|
||||
state.data.runtime_status != :connected ->
|
||||
{:error, "runtime not started"}
|
||||
|
||||
true ->
|
||||
{:ok, runtime, assets_info.archive_path}
|
||||
{:ok, state.data.runtime, assets_info.archive_path}
|
||||
end
|
||||
|
||||
{:reply, reply, state}
|
||||
|
@ -1076,17 +1076,7 @@ defmodule Livebook.Session do
|
|||
|
||||
def handle_call({:disconnect_runtime, client_pid}, _from, state) do
|
||||
client_id = client_id(state, client_pid)
|
||||
|
||||
state =
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
{:ok, runtime} = Runtime.disconnect(state.data.runtime)
|
||||
|
||||
%{state | runtime_monitor_ref: nil}
|
||||
|> handle_operation({:set_runtime, client_id, runtime})
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
state = handle_operation(state, {:disconnect_runtime, client_id})
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
|
@ -1099,7 +1089,7 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
def handle_call(:fetch_proxy_handler_spec, _from, state) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
{:reply, Runtime.fetch_proxy_handler_spec(state.data.runtime), state}
|
||||
else
|
||||
{:reply, {:error, :disconnected}, state}
|
||||
|
@ -1233,17 +1223,9 @@ defmodule Livebook.Session do
|
|||
{:noreply, do_add_dependencies(state, dependencies)}
|
||||
end
|
||||
|
||||
def handle_cast(:disable_dependencies_cache, state) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
Runtime.disable_dependencies_cache(state.data.runtime)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_cast({:queue_cell_evaluation, client_pid, cell_id}, state) do
|
||||
def handle_cast({:queue_cell_evaluation, client_pid, cell_id, evaluation_opts}, state) do
|
||||
client_id = client_id(state, client_pid)
|
||||
operation = {:queue_cells_evaluation, client_id, [cell_id]}
|
||||
operation = {:queue_cells_evaluation, client_id, [cell_id], evaluation_opts}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
|
@ -1253,7 +1235,7 @@ defmodule Livebook.Session do
|
|||
case Notebook.fetch_section(state.data.notebook, section_id) do
|
||||
{:ok, section} ->
|
||||
cell_ids = for cell <- section.cells, Cell.evaluable?(cell), do: cell.id
|
||||
operation = {:queue_cells_evaluation, client_id, cell_ids}
|
||||
operation = {:queue_cells_evaluation, client_id, cell_ids, []}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
|
||||
:error ->
|
||||
|
@ -1268,7 +1250,7 @@ defmodule Livebook.Session do
|
|||
for {bound_cell, _} <- Data.bound_cells_with_section(state.data, input_id),
|
||||
do: bound_cell.id
|
||||
|
||||
operation = {:queue_cells_evaluation, client_id, cell_ids}
|
||||
operation = {:queue_cells_evaluation, client_id, cell_ids, []}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
|
@ -1277,7 +1259,7 @@ defmodule Livebook.Session do
|
|||
|
||||
cell_ids = Data.cell_ids_for_full_evaluation(state.data, forced_cell_ids)
|
||||
|
||||
operation = {:queue_cells_evaluation, client_id, cell_ids}
|
||||
operation = {:queue_cells_evaluation, client_id, cell_ids, []}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
|
@ -1286,7 +1268,7 @@ defmodule Livebook.Session do
|
|||
|
||||
cell_ids = Data.cell_ids_for_reevaluation(state.data)
|
||||
|
||||
operation = {:queue_cells_evaluation, client_id, cell_ids}
|
||||
operation = {:queue_cells_evaluation, client_id, cell_ids, []}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
|
@ -1343,21 +1325,14 @@ defmodule Livebook.Session do
|
|||
|
||||
def handle_cast({:set_runtime, client_pid, runtime}, state) do
|
||||
client_id = client_id(state, client_pid)
|
||||
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
{:ok, _} = Runtime.disconnect(state.data.runtime)
|
||||
end
|
||||
|
||||
state =
|
||||
if Runtime.connected?(runtime) do
|
||||
own_runtime(runtime, state)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:noreply, handle_operation(state, {:set_runtime, client_id, runtime})}
|
||||
end
|
||||
|
||||
def handle_cast({:connect_runtime, client_pid}, state) do
|
||||
client_id = client_id(state, client_pid)
|
||||
{:noreply, handle_operation(state, {:connect_runtime, client_id})}
|
||||
end
|
||||
|
||||
def handle_cast({:set_file, client_pid, file}, state) do
|
||||
client_id = client_id(state, client_pid)
|
||||
|
||||
|
@ -1489,18 +1464,28 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:DOWN, ref, :process, _, reason}, state)
|
||||
when ref == state.runtime_connect.ref do
|
||||
broadcast_error(
|
||||
state.session_id,
|
||||
"connecting runtime failed unexpectedly - #{Exception.format_exit(reason)}"
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
%{state | runtime_connect: nil}
|
||||
|> handle_operation({:runtime_down, @client_id})}
|
||||
end
|
||||
|
||||
def handle_info({:DOWN, ref, :process, _, reason}, state)
|
||||
when ref == state.runtime_monitor_ref do
|
||||
broadcast_error(
|
||||
state.session_id,
|
||||
"runtime node terminated unexpectedly - #{Exception.format_exit(reason)}"
|
||||
"runtime terminated unexpectedly - #{Exception.format_exit(reason)}"
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
%{state | runtime_monitor_ref: nil}
|
||||
|> handle_operation(
|
||||
{:set_runtime, @client_id, Livebook.Runtime.duplicate(state.data.runtime)}
|
||||
)}
|
||||
|> handle_operation({:runtime_down, @client_id})}
|
||||
end
|
||||
|
||||
def handle_info({:DOWN, ref, :process, _, _}, state) when ref == state.save_task_ref do
|
||||
|
@ -1540,6 +1525,30 @@ defmodule Livebook.Session do
|
|||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info({:runtime_connect_info, pid, info}, state)
|
||||
when pid == state.runtime_connect.pid do
|
||||
state = handle_operation(state, {:set_runtime_connect_info, @client_id, info})
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info({:runtime_connect_done, pid, result}, state)
|
||||
when pid == state.runtime_connect.pid do
|
||||
Process.demonitor(state.runtime_connect.ref, [:flush])
|
||||
|
||||
state =
|
||||
case result do
|
||||
{:ok, runtime} ->
|
||||
state = own_runtime(runtime, state)
|
||||
handle_operation(state, {:runtime_connected, @client_id, runtime})
|
||||
|
||||
{:error, message} ->
|
||||
broadcast_error(state.session_id, "connecting runtime failed - #{message}")
|
||||
handle_operation(state, {:runtime_down, @client_id})
|
||||
end
|
||||
|
||||
{:noreply, %{state | runtime_connect: nil}}
|
||||
end
|
||||
|
||||
def handle_info({:runtime_evaluation_output, cell_id, output}, state) do
|
||||
output = normalize_runtime_output(output)
|
||||
operation = {:add_cell_evaluation_output, @client_id, cell_id, output}
|
||||
|
@ -1840,7 +1849,8 @@ defmodule Livebook.Session do
|
|||
state =
|
||||
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(state.data.notebook, cell_id),
|
||||
:evaluating <- state.data.cell_infos[cell.id].eval.status do
|
||||
start_evaluation(state, cell, section)
|
||||
evaluation_opts = state.data.cell_infos[cell.id].eval.evaluation_opts
|
||||
start_evaluation(state, cell, section, evaluation_opts)
|
||||
else
|
||||
_ -> state
|
||||
end
|
||||
|
@ -1854,7 +1864,7 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
def handle_info({:env_var_set, env_var}, state) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
Runtime.put_system_envs(state.data.runtime, [{env_var.name, env_var.value}])
|
||||
end
|
||||
|
||||
|
@ -1862,7 +1872,7 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
def handle_info({:env_var_unset, env_var}, state) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
Runtime.delete_system_envs(state.data.runtime, [env_var.name])
|
||||
end
|
||||
|
||||
|
@ -1874,7 +1884,7 @@ defmodule Livebook.Session do
|
|||
|
||||
case File.rm_rf(path) do
|
||||
{:ok, _} ->
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
{:file, file_id} = file_ref
|
||||
Runtime.revoke_file(state.data.runtime, file_id)
|
||||
end
|
||||
|
@ -2223,17 +2233,14 @@ defmodule Livebook.Session do
|
|||
notify_update(state)
|
||||
end
|
||||
|
||||
defp after_operation(state, _prev_state, {:set_runtime, _client_id, runtime}) do
|
||||
if Runtime.connected?(runtime) do
|
||||
set_runtime_secrets(state, state.data.secrets)
|
||||
set_runtime_env_vars(state)
|
||||
defp after_operation(state, _prev_state, {:runtime_connected, _client_id, _runtime}) do
|
||||
set_runtime_secrets(state, state.data.secrets)
|
||||
set_runtime_env_vars(state)
|
||||
state
|
||||
end
|
||||
|
||||
state
|
||||
else
|
||||
state
|
||||
|> put_memory_usage(nil)
|
||||
|> notify_update()
|
||||
end
|
||||
defp after_operation(state, _prev_state, {:runtime_down, _client_id}) do
|
||||
after_runtime_disconnected(state)
|
||||
end
|
||||
|
||||
defp after_operation(state, prev_state, {:set_file, _client_id, _file}) do
|
||||
|
@ -2273,7 +2280,7 @@ defmodule Livebook.Session do
|
|||
|
||||
state = put_in(state.client_id_with_assets[client_id], %{})
|
||||
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
Runtime.register_clients(state.data.runtime, [client_id])
|
||||
end
|
||||
|
||||
|
@ -2292,7 +2299,7 @@ defmodule Livebook.Session do
|
|||
state = delete_client_files(state, client_id)
|
||||
{_, state} = pop_in(state.client_id_with_assets[client_id])
|
||||
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
Runtime.unregister_clients(state.data.runtime, [client_id])
|
||||
end
|
||||
|
||||
|
@ -2357,12 +2364,18 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
defp after_operation(state, _prev_state, {:set_secret, _client_id, secret}) do
|
||||
if Runtime.connected?(state.data.runtime), do: set_runtime_secret(state, secret)
|
||||
if state.data.runtime_status == :connected do
|
||||
set_runtime_secret(state, secret)
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
defp after_operation(state, _prev_state, {:unset_secret, _client_id, secret_name}) do
|
||||
if Runtime.connected?(state.data.runtime), do: delete_runtime_secrets(state, [secret_name])
|
||||
if state.data.runtime_status == :connected do
|
||||
delete_runtime_secrets(state, [secret_name])
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
|
@ -2405,18 +2418,18 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
defp handle_action(state, :connect_runtime) do
|
||||
case Runtime.connect(state.data.runtime) do
|
||||
{:ok, runtime} ->
|
||||
state = own_runtime(runtime, state)
|
||||
handle_operation(state, {:set_runtime, @client_id, runtime})
|
||||
|
||||
{:error, error} ->
|
||||
broadcast_error(state.session_id, "failed to connect runtime - #{error}")
|
||||
handle_operation(state, {:set_runtime, @client_id, state.data.runtime})
|
||||
end
|
||||
pid = Runtime.connect(state.data.runtime)
|
||||
ref = Process.monitor(pid)
|
||||
%{state | runtime_connect: %{pid: pid, ref: ref}}
|
||||
end
|
||||
|
||||
defp handle_action(state, {:start_evaluation, cell, section}) do
|
||||
defp handle_action(state, {:disconnect_runtime, runtime}) do
|
||||
Runtime.disconnect(runtime)
|
||||
state = %{state | runtime_monitor_ref: nil}
|
||||
after_runtime_disconnected(state)
|
||||
end
|
||||
|
||||
defp handle_action(state, {:start_evaluation, cell, section, evaluation_opts}) do
|
||||
info = state.data.cell_infos[cell.id]
|
||||
|
||||
if is_struct(cell, Cell.Smart) and info.status == :started do
|
||||
|
@ -2429,12 +2442,12 @@ defmodule Livebook.Session do
|
|||
|
||||
state
|
||||
else
|
||||
start_evaluation(state, cell, section)
|
||||
start_evaluation(state, cell, section, evaluation_opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_action(state, {:stop_evaluation, section}) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
Runtime.drop_container(state.data.runtime, container_ref_for_section(section))
|
||||
end
|
||||
|
||||
|
@ -2442,7 +2455,7 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
defp handle_action(state, {:forget_evaluation, cell, section}) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
Runtime.forget_evaluation(state.data.runtime, {container_ref_for_section(section), cell.id})
|
||||
end
|
||||
|
||||
|
@ -2450,7 +2463,7 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
defp handle_action(state, {:start_smart_cell, cell, _section}) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
parent_locators = parent_locators_for_cell(state.data, cell)
|
||||
|
||||
Runtime.start_smart_cell(
|
||||
|
@ -2466,7 +2479,7 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
defp handle_action(state, {:set_smart_cell_parents, cell, _section, parents}) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
parent_locators = evaluation_parents_to_locators(parents)
|
||||
Runtime.set_smart_cell_parent_locators(state.data.runtime, cell.id, parent_locators)
|
||||
end
|
||||
|
@ -2475,7 +2488,7 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
defp handle_action(state, {:stop_smart_cell, cell}) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
Runtime.stop_smart_cell(state.data.runtime, cell.id)
|
||||
end
|
||||
|
||||
|
@ -2497,20 +2510,6 @@ defmodule Livebook.Session do
|
|||
state
|
||||
end
|
||||
|
||||
defp handle_action(state, :app_recover) do
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
{:ok, _} = Runtime.disconnect(state.data.runtime)
|
||||
end
|
||||
|
||||
new_runtime = Livebook.Runtime.duplicate(state.data.runtime)
|
||||
cell_ids = Data.cell_ids_for_full_evaluation(state.data, [])
|
||||
|
||||
state
|
||||
|> handle_operation({:erase_outputs, @client_id})
|
||||
|> handle_operation({:set_runtime, @client_id, new_runtime})
|
||||
|> handle_operation({:queue_cells_evaluation, @client_id, cell_ids})
|
||||
end
|
||||
|
||||
defp handle_action(state, :app_terminate) do
|
||||
send(self(), :close)
|
||||
|
||||
|
@ -2519,7 +2518,7 @@ defmodule Livebook.Session do
|
|||
|
||||
defp handle_action(state, _action), do: state
|
||||
|
||||
defp start_evaluation(state, cell, section) do
|
||||
defp start_evaluation(state, cell, section, evaluation_opts) do
|
||||
path =
|
||||
case state.data.file || default_notebook_file(state) do
|
||||
nil -> ""
|
||||
|
@ -2534,7 +2533,7 @@ defmodule Livebook.Session do
|
|||
_ -> nil
|
||||
end
|
||||
|
||||
opts = [file: file, smart_cell_ref: smart_cell_ref]
|
||||
opts = evaluation_opts ++ [file: file, smart_cell_ref: smart_cell_ref]
|
||||
|
||||
locator = {container_ref_for_section(section), cell.id}
|
||||
parent_locators = parent_locators_for_cell(state.data, cell)
|
||||
|
@ -2601,6 +2600,12 @@ defmodule Livebook.Session do
|
|||
Runtime.put_system_envs(state.data.runtime, env_vars)
|
||||
end
|
||||
|
||||
defp after_runtime_disconnected(state) do
|
||||
state
|
||||
|> put_memory_usage(nil)
|
||||
|> notify_update()
|
||||
end
|
||||
|
||||
defp notify_update(state) do
|
||||
session = self_from_state(state)
|
||||
Livebook.Sessions.update_session(session)
|
||||
|
@ -2880,7 +2885,7 @@ defmodule Livebook.Session do
|
|||
cache_file = file_entry_cache_file(state.session_id, name)
|
||||
FileSystem.File.remove(cache_file)
|
||||
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
file_id = file_entry_file_id(name)
|
||||
Runtime.revoke_file(state.data.runtime, file_id)
|
||||
end
|
||||
|
@ -2901,7 +2906,7 @@ defmodule Livebook.Session do
|
|||
FileSystem.File.rename(file, new_file)
|
||||
end
|
||||
|
||||
if Runtime.connected?(state.data.runtime) do
|
||||
if state.data.runtime_status == :connected do
|
||||
file_id = file_entry_file_id(name)
|
||||
new_file_id = file_entry_file_id(new_name)
|
||||
Runtime.relabel_file(state.data.runtime, file_id, new_file_id)
|
||||
|
|
|
@ -27,6 +27,8 @@ defmodule Livebook.Session.Data do
|
|||
:input_infos,
|
||||
:bin_entries,
|
||||
:runtime,
|
||||
:runtime_status,
|
||||
:runtime_connect_info,
|
||||
:runtime_transient_state,
|
||||
:runtime_connected_nodes,
|
||||
:smart_cell_definitions,
|
||||
|
@ -55,6 +57,8 @@ defmodule Livebook.Session.Data do
|
|||
input_infos: %{input_id() => input_info()},
|
||||
bin_entries: list(cell_bin_entry()),
|
||||
runtime: Runtime.t(),
|
||||
runtime_status: runtime_status(),
|
||||
runtime_connect_info: String.t() | nil,
|
||||
runtime_transient_state: Runtime.transient_state(),
|
||||
runtime_connected_nodes: list(node()),
|
||||
smart_cell_definitions: list(Runtime.smart_cell_definition()),
|
||||
|
@ -125,6 +129,8 @@ defmodule Livebook.Session.Data do
|
|||
deleted_at: DateTime.t()
|
||||
}
|
||||
|
||||
@type runtime_status :: :disconnected | :connecting | :connected
|
||||
|
||||
@type cell_revision :: non_neg_integer()
|
||||
|
||||
@type cell_evaluation_validity :: :fresh | :evaluated | :stale | :aborted
|
||||
|
@ -188,7 +194,7 @@ defmodule Livebook.Session.Data do
|
|||
| {:restore_cell, client_id(), Cell.id()}
|
||||
| {:move_cell, client_id(), Cell.id(), offset :: integer()}
|
||||
| {:move_section, client_id(), Section.id(), offset :: integer()}
|
||||
| {:queue_cells_evaluation, client_id(), list(Cell.id())}
|
||||
| {:queue_cells_evaluation, client_id(), list(Cell.id()), evaluation_opts :: keyword()}
|
||||
| {:add_cell_doctest_report, client_id(), Cell.id(), Runtime.doctest_report()}
|
||||
| {:add_cell_evaluation_output, client_id(), Cell.id(), term()}
|
||||
| {:add_cell_evaluation_response, client_id(), Cell.id(), term(), metadata :: map()}
|
||||
|
@ -215,6 +221,11 @@ defmodule Livebook.Session.Data do
|
|||
| {:set_cell_attributes, client_id(), Cell.id(), map()}
|
||||
| {:set_input_value, client_id(), input_id(), value :: term()}
|
||||
| {:set_runtime, client_id(), Runtime.t()}
|
||||
| {:connect_runtime, client_id()}
|
||||
| {:set_runtime_connect_info, client_id(), String.t()}
|
||||
| {:runtime_connected, client_id(), Runtime.t()}
|
||||
| {:disconnect_runtime, client_id()}
|
||||
| {:runtime_down, client_id()}
|
||||
| {:set_runtime_transient_state, client_id(), Runtime.transient_state()}
|
||||
| {:set_runtime_connected_nodes, client_id(), list(node())}
|
||||
| {:set_smart_cell_definitions, client_id(), list(Runtime.smart_cell_definition())}
|
||||
|
@ -237,6 +248,7 @@ defmodule Livebook.Session.Data do
|
|||
|
||||
@type action ::
|
||||
:connect_runtime
|
||||
| {:disconnect_runtime, Runtime.t()}
|
||||
| {:start_evaluation, Cell.t(), Section.t()}
|
||||
| {:stop_evaluation, Section.t()}
|
||||
| {:forget_evaluation, Cell.t(), Section.t()}
|
||||
|
@ -246,7 +258,6 @@ defmodule Livebook.Session.Data do
|
|||
| {:report_delta, client_id(), Cell.t(), cell_source_tag(), Text.Delta.t()}
|
||||
| {:clean_up_input_values, %{input_id() => input_info()}}
|
||||
| :app_report_status
|
||||
| :app_recover
|
||||
| :app_terminate
|
||||
|
||||
@doc """
|
||||
|
@ -305,6 +316,8 @@ defmodule Livebook.Session.Data do
|
|||
input_infos: initial_input_infos(notebook),
|
||||
bin_entries: [],
|
||||
runtime: default_runtime,
|
||||
runtime_status: :disconnected,
|
||||
runtime_connect_info: nil,
|
||||
runtime_transient_state: %{},
|
||||
runtime_connected_nodes: [],
|
||||
smart_cell_definitions: [],
|
||||
|
@ -552,7 +565,7 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:queue_cells_evaluation, _client_id, cell_ids}) do
|
||||
def apply_operation(data, {:queue_cells_evaluation, _client_id, cell_ids, evaluation_opts}) do
|
||||
cells_with_section =
|
||||
data.notebook
|
||||
|> Notebook.evaluable_cells_with_section()
|
||||
|
@ -568,7 +581,7 @@ defmodule Livebook.Session.Data do
|
|||
|> with_actions()
|
||||
|> queue_prerequisite_cells_evaluation(cell_ids)
|
||||
|> reduce(cells_with_section, fn data_actions, {cell, section} ->
|
||||
queue_cell_evaluation(data_actions, cell, section)
|
||||
queue_cell_evaluation(data_actions, cell, section, evaluation_opts)
|
||||
end)
|
||||
|> maybe_connect_runtime(data)
|
||||
|> update_validity_and_evaluation()
|
||||
|
@ -862,10 +875,71 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
|
||||
def apply_operation(data, {:set_runtime, _client_id, runtime}) do
|
||||
data
|
||||
|> with_actions()
|
||||
|> set_runtime(data, runtime)
|
||||
|> wrap_ok()
|
||||
with true <- data.runtime_status in [:connected, :disconnected] do
|
||||
data
|
||||
|> with_actions()
|
||||
|> set_runtime(runtime)
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:connect_runtime, _client_id}) do
|
||||
with :disconnected <- data.runtime_status do
|
||||
data
|
||||
|> with_actions()
|
||||
|> connect_runtime()
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:set_runtime_connect_info, _client_id, info}) do
|
||||
with :connecting <- data.runtime_status do
|
||||
data
|
||||
|> with_actions()
|
||||
|> set_runtime_connect_info(info)
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:runtime_connected, _client_id, runtime}) do
|
||||
with :connecting <- data.runtime_status do
|
||||
data
|
||||
|> with_actions()
|
||||
|> runtime_connected(runtime)
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:disconnect_runtime, _client_id}) do
|
||||
with :connected <- data.runtime_status do
|
||||
data
|
||||
|> with_actions()
|
||||
|> disconnect_runtime()
|
||||
|> app_update_execution_status()
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:runtime_down, _client_id}) do
|
||||
with true <- data.runtime_status in [:connecting, :connected] do
|
||||
data
|
||||
|> with_actions()
|
||||
|> clear_runtime()
|
||||
|> app_update_execution_status()
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:set_runtime_transient_state, _client_id, transient_state}) do
|
||||
|
@ -1261,16 +1335,17 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
end
|
||||
|
||||
defp queue_cell_evaluation(data_actions, cell, section) do
|
||||
defp queue_cell_evaluation(data_actions, cell, section, evaluation_opts \\ []) do
|
||||
data_actions
|
||||
|> update_section_info!(section.id, fn section ->
|
||||
update_in(section.evaluation_queue, &MapSet.put(&1, cell.id))
|
||||
end)
|
||||
|> update_cell_eval_info!(cell.id, fn eval_info ->
|
||||
update_in(eval_info.status, fn
|
||||
:ready -> :queued
|
||||
other -> other
|
||||
end)
|
||||
if eval_info.status == :ready do
|
||||
%{eval_info | status: :queued, evaluation_opts: evaluation_opts}
|
||||
else
|
||||
eval_info
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
|
@ -1374,9 +1449,9 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
|
||||
defp maybe_connect_runtime({data, _} = data_actions, prev_data) do
|
||||
if not Runtime.connected?(data.runtime) and not any_cell_queued?(prev_data) and
|
||||
if data.runtime_status == :disconnected and not any_cell_queued?(prev_data) and
|
||||
any_cell_queued?(data) do
|
||||
add_action(data_actions, :connect_runtime)
|
||||
connect_runtime(data_actions)
|
||||
else
|
||||
data_actions
|
||||
end
|
||||
|
@ -1403,8 +1478,10 @@ defmodule Livebook.Session.Data do
|
|||
queue_prerequisite_cells_evaluation(data_actions, trailing_queued_cell_ids)
|
||||
end
|
||||
|
||||
defp maybe_evaluate_queued({data, _} = data_actions) do
|
||||
if Runtime.connected?(data.runtime) do
|
||||
defp maybe_evaluate_queued(data_actions) do
|
||||
{data, _} = data_actions = check_setup_cell_for_reevaluation(data_actions)
|
||||
|
||||
if data.runtime_status == :connected do
|
||||
main_flow_evaluating? = main_flow_evaluating?(data)
|
||||
|
||||
{awaiting_branch_sections, awaiting_regular_sections} =
|
||||
|
@ -1453,6 +1530,43 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
end
|
||||
|
||||
defp check_setup_cell_for_reevaluation({data, _} = data_actions) do
|
||||
# When setup cell has been evaluated and is queued again, we need
|
||||
# to reconnect the runtime to get a fresh evaluation environment
|
||||
# for setup. We subsequently queue all cells that are currently
|
||||
# queued
|
||||
|
||||
case data.cell_infos[Cell.setup_cell_id()].eval do
|
||||
%{status: :queued, validity: :evaluated} when data.runtime_status == :connected ->
|
||||
queued_cells_with_section =
|
||||
data.notebook
|
||||
|> Notebook.evaluable_cells_with_section()
|
||||
|> Enum.filter(fn {cell, _} ->
|
||||
data.cell_infos[cell.id].eval.status == :queued
|
||||
end)
|
||||
|> Enum.map(fn {cell, section} ->
|
||||
{cell, section, data.cell_infos[cell.id].eval.evaluation_opts}
|
||||
end)
|
||||
|
||||
cell_ids =
|
||||
for {cell, _section, _evaluation_opts} <- queued_cells_with_section, do: cell.id
|
||||
|
||||
data_actions
|
||||
|> disconnect_runtime()
|
||||
|> connect_runtime()
|
||||
|> queue_prerequisite_cells_evaluation(cell_ids)
|
||||
|> reduce(
|
||||
queued_cells_with_section,
|
||||
fn data_actions, {cell, section, evaluation_opts} ->
|
||||
queue_cell_evaluation(data_actions, cell, section, evaluation_opts)
|
||||
end
|
||||
)
|
||||
|
||||
_ ->
|
||||
data_actions
|
||||
end
|
||||
end
|
||||
|
||||
defp first_queued_cell(data, section) do
|
||||
find_queued_cell(data, section.cells)
|
||||
end
|
||||
|
@ -1533,7 +1647,9 @@ defmodule Livebook.Session.Data do
|
|||
evaluating_cell_id: cell.id,
|
||||
evaluation_queue: MapSet.delete(section_info.evaluation_queue, cell.id)
|
||||
)
|
||||
|> add_action({:start_evaluation, cell, section})
|
||||
|> add_action(
|
||||
{:start_evaluation, cell, section, data.cell_infos[cell.id].eval.evaluation_opts}
|
||||
)
|
||||
else
|
||||
data_actions
|
||||
end
|
||||
|
@ -1596,7 +1712,7 @@ defmodule Livebook.Session.Data do
|
|||
|> Notebook.parent_cells_with_section(cell_ids)
|
||||
|> Enum.filter(fn {cell, _section} ->
|
||||
info = data.cell_infos[cell.id]
|
||||
Cell.evaluable?(cell) and cell_outdated?(data, cell) and info.eval.status == :ready
|
||||
Cell.evaluable?(cell) and cell_outdated?(data, cell.id) and info.eval.status == :ready
|
||||
end)
|
||||
|> Enum.reverse()
|
||||
|
||||
|
@ -1709,7 +1825,7 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
|
||||
defp recover_smart_cell({data, _} = data_actions, cell, section) do
|
||||
if Runtime.connected?(data.runtime) do
|
||||
if data.runtime_status == :connected do
|
||||
start_smart_cell(data_actions, cell, section)
|
||||
else
|
||||
data_actions
|
||||
|
@ -1965,24 +2081,53 @@ defmodule Livebook.Session.Data do
|
|||
|> set!(input_infos: Map.put(data.input_infos, input_id, input_info(value)))
|
||||
end
|
||||
|
||||
defp set_runtime(data_actions, prev_data, runtime) do
|
||||
{data, _} =
|
||||
data_actions =
|
||||
set!(data_actions,
|
||||
runtime: runtime,
|
||||
runtime_connected_nodes: [],
|
||||
smart_cell_definitions: []
|
||||
)
|
||||
defp set_runtime({data, _} = data_actions, runtime) do
|
||||
data_actions =
|
||||
case data.runtime_status do
|
||||
:connected ->
|
||||
disconnect_runtime(data_actions)
|
||||
|
||||
if not Runtime.connected?(prev_data.runtime) and Runtime.connected?(data.runtime) do
|
||||
data_actions
|
||||
|> maybe_evaluate_queued()
|
||||
else
|
||||
data_actions
|
||||
|> clear_all_evaluation()
|
||||
|> clear_smart_cells()
|
||||
|> app_update_execution_status()
|
||||
end
|
||||
:disconnected ->
|
||||
data_actions
|
||||
end
|
||||
|
||||
set!(data_actions, runtime: runtime)
|
||||
end
|
||||
|
||||
defp connect_runtime(data_actions) do
|
||||
data_actions
|
||||
|> set!(runtime_status: :connecting)
|
||||
|> add_action(:connect_runtime)
|
||||
end
|
||||
|
||||
defp set_runtime_connect_info(data_actions, info) do
|
||||
data_actions
|
||||
|> set!(runtime_connect_info: info)
|
||||
end
|
||||
|
||||
defp runtime_connected(data_actions, runtime) do
|
||||
data_actions
|
||||
|> set!(runtime: runtime, runtime_status: :connected, runtime_connect_info: nil)
|
||||
|> maybe_evaluate_queued()
|
||||
end
|
||||
|
||||
defp disconnect_runtime({data, _} = data_actions) do
|
||||
data_actions
|
||||
|> add_action({:disconnect_runtime, data.runtime})
|
||||
|> clear_runtime()
|
||||
end
|
||||
|
||||
defp clear_runtime({data, _} = data_actions) do
|
||||
data_actions
|
||||
|> set!(
|
||||
runtime: Runtime.duplicate(data.runtime),
|
||||
runtime_status: :disconnected,
|
||||
runtime_connect_info: nil,
|
||||
runtime_connected_nodes: [],
|
||||
smart_cell_definitions: []
|
||||
)
|
||||
|> clear_all_evaluation()
|
||||
|> clear_smart_cells()
|
||||
end
|
||||
|
||||
defp set_secret({data, _} = data_actions, secret) do
|
||||
|
@ -2037,7 +2182,7 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
|
||||
defp maybe_start_smart_cells({data, _} = data_actions) do
|
||||
if Runtime.connected?(data.runtime) do
|
||||
if data.runtime_status == :connected do
|
||||
dead_cells = dead_smart_cells_with_section(data)
|
||||
|
||||
kinds =
|
||||
|
@ -2219,6 +2364,7 @@ defmodule Livebook.Session.Data do
|
|||
status: :ready,
|
||||
errored: false,
|
||||
interrupted: false,
|
||||
evaluation_opts: [],
|
||||
evaluation_digest: nil,
|
||||
evaluation_time_ms: nil,
|
||||
evaluation_start: nil,
|
||||
|
@ -2619,25 +2765,38 @@ defmodule Livebook.Session.Data do
|
|||
|
||||
# If everything was executed and an error happened, it means it
|
||||
# was a runtime crash and everything is aborted
|
||||
data_actions =
|
||||
{data_actions, execution_status} =
|
||||
if data.app_data.status.execution == :executed and execution_status == :error do
|
||||
add_action(data_actions, :app_recover)
|
||||
{app_recover(data_actions), :executing}
|
||||
else
|
||||
data_actions
|
||||
{data_actions, execution_status}
|
||||
end
|
||||
|
||||
update_app_data!(data_actions, &put_in(&1.status.execution, execution_status))
|
||||
end
|
||||
|
||||
defp app_recover({data, _} = data_actions) do
|
||||
evaluable_cells_with_section = Notebook.evaluable_cells_with_section(data.notebook)
|
||||
|
||||
data_actions
|
||||
|> disconnect_runtime()
|
||||
|> connect_runtime()
|
||||
|> erase_outputs()
|
||||
|> garbage_collect_input_infos()
|
||||
|> reduce(evaluable_cells_with_section, fn data_actions, {cell, section} ->
|
||||
queue_cell_evaluation(data_actions, cell, section)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the given cell is outdated.
|
||||
|
||||
A cell is considered outdated if its new/fresh or its content
|
||||
has changed since the last evaluation.
|
||||
A cell is considered outdated if its fresh/stale or its content has
|
||||
changed since the last evaluation.
|
||||
"""
|
||||
@spec cell_outdated?(t(), Cell.t()) :: boolean()
|
||||
def cell_outdated?(data, cell) do
|
||||
info = data.cell_infos[cell.id]
|
||||
@spec cell_outdated?(t(), Cell.id()) :: boolean()
|
||||
def cell_outdated?(data, cell_id) do
|
||||
info = data.cell_infos[cell_id]
|
||||
info.eval.validity != :evaluated or info.eval.evaluation_digest != info.sources.primary.digest
|
||||
end
|
||||
|
||||
|
@ -2649,28 +2808,36 @@ defmodule Livebook.Session.Data do
|
|||
"""
|
||||
@spec cell_ids_for_full_evaluation(t(), list(Cell.id())) :: list(Cell.id())
|
||||
def cell_ids_for_full_evaluation(data, forced_cell_ids) do
|
||||
requires_reconnect? =
|
||||
data.cell_infos[Cell.setup_cell_id()].eval.validity == :evaluated and
|
||||
cell_outdated?(data, Cell.setup_cell_id())
|
||||
|
||||
evaluable_cells_with_section = Notebook.evaluable_cells_with_section(data.notebook)
|
||||
|
||||
evaluable_cell_ids =
|
||||
for {cell, _} <- evaluable_cells_with_section,
|
||||
cell_outdated?(data, cell) or cell.id in forced_cell_ids,
|
||||
do: cell.id,
|
||||
into: MapSet.new()
|
||||
if requires_reconnect? do
|
||||
for {cell, _} <- evaluable_cells_with_section, do: cell.id
|
||||
else
|
||||
evaluable_cell_ids =
|
||||
for {cell, _} <- evaluable_cells_with_section,
|
||||
cell_outdated?(data, cell.id) or cell.id in forced_cell_ids,
|
||||
do: cell.id,
|
||||
into: MapSet.new()
|
||||
|
||||
cell_identifier_parents = cell_identifier_parents(data)
|
||||
cell_identifier_parents = cell_identifier_parents(data)
|
||||
|
||||
child_ids =
|
||||
for {cell_id, cell_identifier_parents} <- cell_identifier_parents,
|
||||
Enum.any?(cell_identifier_parents, &(&1 in evaluable_cell_ids)),
|
||||
do: cell_id
|
||||
child_ids =
|
||||
for {cell_id, cell_identifier_parents} <- cell_identifier_parents,
|
||||
Enum.any?(cell_identifier_parents, &(&1 in evaluable_cell_ids)),
|
||||
do: cell_id
|
||||
|
||||
child_ids
|
||||
|> Enum.into(evaluable_cell_ids)
|
||||
|> Enum.to_list()
|
||||
|> Enum.filter(fn cell_id ->
|
||||
info = data.cell_infos[cell_id]
|
||||
info.eval.status == :ready
|
||||
end)
|
||||
child_ids
|
||||
|> Enum.into(evaluable_cell_ids)
|
||||
|> Enum.to_list()
|
||||
|> Enum.filter(fn cell_id ->
|
||||
info = data.cell_infos[cell_id]
|
||||
info.eval.status == :ready
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Builds identifier parent list for every evaluable cell.
|
||||
|
|
|
@ -487,8 +487,11 @@ defmodule LivebookWeb.FormComponents do
|
|||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"w-full px-3 py-2 pr-7 appearance-none bg-gray-50 text-sm border rounded-lg placeholder-gray-400 text-gray-600 disabled:opacity-70 disabled:cursor-not-allowed",
|
||||
if(@errors == [], do: "border-gray-200", else: "border-red-300"),
|
||||
"w-full px-3 py-2 pr-7 appearance-none text-sm border rounded-lg placeholder-gray-400 disabled:opacity-70 disabled:cursor-not-allowed",
|
||||
if(@errors == [],
|
||||
do: "bg-gray-50 border-gray-200 text-gray-600",
|
||||
else: "bg-red-50 border-red-600 text-red-600"
|
||||
),
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
|
|
|
@ -263,30 +263,38 @@ defmodule LivebookWeb.SessionLive do
|
|||
data = socket.private.data
|
||||
%{"section_id" => section_id, "cell_id" => cell_id} = params
|
||||
|
||||
if Livebook.Runtime.connected?(socket.private.data.runtime) do
|
||||
case example_snippet_definition_by_name(data, params["definition_name"]) do
|
||||
{:ok, definition} ->
|
||||
variant = Enum.fetch!(definition.variants, params["variant_idx"])
|
||||
socket =
|
||||
case socket.private.data.runtime_status do
|
||||
:disconnected ->
|
||||
reason = "To insert this block, you need a connected runtime."
|
||||
confirm_setup_runtime(socket, reason)
|
||||
|
||||
socket =
|
||||
ensure_packages_then(socket, variant.packages, definition.name, "block", fn socket ->
|
||||
with {:ok, section, index} <-
|
||||
section_with_next_index(socket.private.data.notebook, section_id, cell_id) do
|
||||
attrs = %{source: variant.source}
|
||||
Session.insert_cell(socket.assigns.session.pid, section.id, index, :code, attrs)
|
||||
{:ok, socket}
|
||||
:connecting ->
|
||||
message = "To insert this block, wait for the runtime to finish connecting."
|
||||
{:noreply, put_flash(socket, :info, message)}
|
||||
|
||||
:connected ->
|
||||
case example_snippet_definition_by_name(data, params["definition_name"]) do
|
||||
{:ok, definition} ->
|
||||
variant = Enum.fetch!(definition.variants, params["variant_idx"])
|
||||
|
||||
fun = fn socket ->
|
||||
with {:ok, section, index} <-
|
||||
section_with_next_index(socket.private.data.notebook, section_id, cell_id) do
|
||||
attrs = %{source: variant.source}
|
||||
Session.insert_cell(socket.assigns.session.pid, section.id, index, :code, attrs)
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
ensure_packages_then(socket, variant.packages, definition.name, "block", fun)
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
_ ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
else
|
||||
reason = "To insert this block, you need a connected runtime."
|
||||
{:noreply, confirm_setup_default_runtime(socket, reason)}
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("insert_smart_cell_below", params, socket) do
|
||||
|
@ -486,24 +494,14 @@ defmodule LivebookWeb.SessionLive do
|
|||
end
|
||||
|
||||
def handle_event("queue_cell_evaluation", %{"cell_id" => cell_id} = params, socket) do
|
||||
data = socket.private.data
|
||||
|
||||
{status, socket} =
|
||||
with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
|
||||
true <- Cell.setup?(cell),
|
||||
false <- data.cell_infos[cell.id].eval.validity == :fresh do
|
||||
maybe_reconnect_runtime(socket)
|
||||
opts =
|
||||
if params["disable_dependencies_cache"] do
|
||||
[disable_dependencies_cache: true]
|
||||
else
|
||||
_ -> {:ok, socket}
|
||||
[]
|
||||
end
|
||||
|
||||
if params["disable_dependencies_cache"] do
|
||||
Session.disable_dependencies_cache(socket.assigns.session.pid)
|
||||
end
|
||||
|
||||
if status == :ok do
|
||||
Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id)
|
||||
end
|
||||
Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id, opts)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
@ -559,18 +557,15 @@ defmodule LivebookWeb.SessionLive do
|
|||
end
|
||||
end
|
||||
|
||||
def handle_event("reconnect_runtime", %{}, socket) do
|
||||
{_, socket} = maybe_reconnect_runtime(socket)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("connect_runtime", %{}, socket) do
|
||||
{_, socket} = connect_runtime(socket)
|
||||
Session.connect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("setup_default_runtime", %{"reason" => reason}, socket) do
|
||||
{:noreply, confirm_setup_default_runtime(socket, reason)}
|
||||
def handle_event("reconnect_runtime", %{}, socket) do
|
||||
Session.disconnect_runtime(socket.assigns.session.pid)
|
||||
Session.connect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("disconnect_runtime", %{}, socket) do
|
||||
|
@ -578,12 +573,15 @@ defmodule LivebookWeb.SessionLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("setup_runtime", %{"reason" => reason}, socket) do
|
||||
{:noreply, confirm_setup_runtime(socket, reason)}
|
||||
end
|
||||
|
||||
def handle_event("runtime_disconnect_node", %{"node" => node}, socket) do
|
||||
node = Enum.find(socket.private.data.runtime_connected_nodes, &(Atom.to_string(&1) == node))
|
||||
runtime = socket.private.data.runtime
|
||||
|
||||
if node && Runtime.connected?(runtime) do
|
||||
Runtime.disconnect_node(runtime, node)
|
||||
if node do
|
||||
Runtime.disconnect_node(socket.private.data.runtime, node)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
|
@ -628,7 +626,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
data = socket.private.data
|
||||
|
||||
with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id) do
|
||||
if Runtime.connected?(data.runtime) do
|
||||
if data.runtime_status == :connected do
|
||||
parent_locators = Session.parent_locators_for_cell(data, cell)
|
||||
node = intellisense_node(cell)
|
||||
|
||||
|
@ -636,19 +634,20 @@ defmodule LivebookWeb.SessionLive do
|
|||
|
||||
{:reply, %{"ref" => inspect(ref)}, socket}
|
||||
else
|
||||
info =
|
||||
reason =
|
||||
cond do
|
||||
params["type"] == "completion" and not params["editor_auto_completion"] ->
|
||||
"You need to start a runtime (or evaluate a cell) for code completion"
|
||||
"You need a connected runtime to enable code completion."
|
||||
|
||||
params["type"] == "format" ->
|
||||
"You need to start a runtime (or evaluate a cell) to enable code formatting"
|
||||
"You need a connected runtime to enable code formatting."
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
|
||||
socket = if info, do: put_flash(socket, :info, info), else: socket
|
||||
socket = if reason, do: confirm_setup_runtime(socket, reason), else: socket
|
||||
|
||||
{:reply, %{"ref" => nil}, socket}
|
||||
end
|
||||
else
|
||||
|
@ -782,21 +781,27 @@ defmodule LivebookWeb.SessionLive do
|
|||
socket
|
||||
) do
|
||||
if file_entry = find_file_entry(socket, file_entry_name) do
|
||||
if Livebook.Runtime.connected?(socket.private.data.runtime) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
insert_file_metadata: %{
|
||||
section_id: section_id,
|
||||
cell_id: cell_id,
|
||||
file_entry: file_entry,
|
||||
handlers: handlers_for_file_entry(file_entry, socket.private.data.runtime)
|
||||
}
|
||||
)
|
||||
|> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/insert-file")}
|
||||
else
|
||||
reason = "To see the available options, you need a connected runtime."
|
||||
{:noreply, confirm_setup_default_runtime(socket, reason)}
|
||||
case socket.private.data.runtime_status do
|
||||
:connected ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
insert_file_metadata: %{
|
||||
section_id: section_id,
|
||||
cell_id: cell_id,
|
||||
file_entry: file_entry,
|
||||
handlers: handlers_for_file_entry(file_entry, socket.private.data.runtime)
|
||||
}
|
||||
)
|
||||
|> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/insert-file")}
|
||||
|
||||
:connecting ->
|
||||
message = "To see the available options, wait for the runtime to finish connecting."
|
||||
{:noreply, put_flash(socket, :info, message)}
|
||||
|
||||
:disconnected ->
|
||||
reason = "To see the available options, you need a connected runtime."
|
||||
{:noreply, confirm_setup_runtime(socket, reason)}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
|
@ -843,15 +848,21 @@ defmodule LivebookWeb.SessionLive do
|
|||
%{"section_id" => section_id, "cell_id" => cell_id},
|
||||
socket
|
||||
) do
|
||||
if Livebook.Runtime.connected?(socket.private.data.runtime) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(file_drop_metadata: %{section_id: section_id, cell_id: cell_id})
|
||||
|> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/add-file/upload")
|
||||
|> push_event("finish_file_drop", %{})}
|
||||
else
|
||||
reason = "To see the available options, you need a connected runtime."
|
||||
{:noreply, confirm_setup_default_runtime(socket, reason)}
|
||||
case socket.private.data.runtime_status do
|
||||
:disconnected ->
|
||||
reason = "To see the available options, you need a connected runtime."
|
||||
{:noreply, confirm_setup_runtime(socket, reason)}
|
||||
|
||||
:connecting ->
|
||||
message = "To see the available options, wait for the runtime to finish connecting."
|
||||
{:noreply, put_flash(socket, :info, message)}
|
||||
|
||||
:connected ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(file_drop_metadata: %{section_id: section_id, cell_id: cell_id})
|
||||
|> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/add-file/upload")
|
||||
|> push_event("finish_file_drop", %{})}
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -883,6 +894,20 @@ defmodule LivebookWeb.SessionLive do
|
|||
{:noreply, handle_operation(socket, operation)}
|
||||
end
|
||||
|
||||
def handle_info({:error, error}, socket) when socket.assigns.live_action == :runtime_settings do
|
||||
# When the runtime settings modal is open we assume the error is
|
||||
# related to connecting the runtime and we show it dirrectly there
|
||||
|
||||
message = error |> to_string() |> upcase_first()
|
||||
|
||||
send_update(LivebookWeb.SessionLive.RuntimeComponent,
|
||||
id: "runtime-settings",
|
||||
event: {:error, message}
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:error, error}, socket) do
|
||||
message = error |> to_string() |> upcase_first()
|
||||
socket = put_flash(socket, :error, message)
|
||||
|
@ -1527,49 +1552,15 @@ defmodule LivebookWeb.SessionLive do
|
|||
defp autofocus_cell_id(%Notebook{sections: [%{cells: [%{id: id, source: ""}]}]}), do: id
|
||||
defp autofocus_cell_id(_notebook), do: nil
|
||||
|
||||
defp connect_runtime(socket) do
|
||||
case Runtime.connect(socket.private.data.runtime) do
|
||||
{:ok, runtime} ->
|
||||
Session.set_runtime(socket.assigns.session.pid, runtime)
|
||||
{:ok, socket}
|
||||
|
||||
{:error, message} ->
|
||||
{:error, put_flash(socket, :error, "Failed to connect runtime - #{message}")}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_reconnect_runtime(%{private: %{data: data}} = socket) do
|
||||
if Runtime.connected?(data.runtime) do
|
||||
data.runtime
|
||||
|> Runtime.duplicate()
|
||||
|> Runtime.connect()
|
||||
|> case do
|
||||
{:ok, new_runtime} ->
|
||||
Session.set_runtime(socket.assigns.session.pid, new_runtime)
|
||||
{:ok, clear_flash(socket, :error)}
|
||||
|
||||
{:error, message} ->
|
||||
{:error, put_flash(socket, :error, "Failed to connect runtime - #{message}")}
|
||||
end
|
||||
else
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp confirm_setup_default_runtime(socket, reason) do
|
||||
defp confirm_setup_runtime(socket, reason) do
|
||||
on_confirm = fn socket ->
|
||||
{status, socket} = connect_runtime(socket)
|
||||
|
||||
if status == :ok do
|
||||
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
|
||||
end
|
||||
|
||||
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
|
||||
socket
|
||||
end
|
||||
|
||||
confirm(socket, on_confirm,
|
||||
title: "Setup runtime",
|
||||
description: "#{reason} Do you want to connect and setup the default one?",
|
||||
description: "#{reason} Do you want to connect and setup the current one?",
|
||||
confirm_text: "Setup runtime",
|
||||
confirm_icon: "play-line",
|
||||
danger: false
|
||||
|
@ -1582,7 +1573,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
|
||||
defp example_snippet_definition_by_name(data, name) do
|
||||
data.runtime
|
||||
|> Livebook.Runtime.snippet_definitions()
|
||||
|> Runtime.snippet_definitions()
|
||||
|> Enum.find_value(:error, &(&1.type == :example && &1.name == name && {:ok, &1}))
|
||||
end
|
||||
|
||||
|
@ -1590,25 +1581,12 @@ defmodule LivebookWeb.SessionLive do
|
|||
Enum.find_value(data.smart_cell_definitions, :error, &(&1.kind == kind && {:ok, &1}))
|
||||
end
|
||||
|
||||
defp add_dependencies_and_reevaluate(socket, dependencies) do
|
||||
Session.add_dependencies(socket.assigns.session.pid, dependencies)
|
||||
|
||||
{status, socket} = maybe_reconnect_runtime(socket)
|
||||
|
||||
if status == :ok do
|
||||
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
|
||||
Session.queue_cells_reevaluation(socket.assigns.session.pid)
|
||||
end
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp ensure_packages_then(socket, packages, target_name, target_type, fun) do
|
||||
dependencies = Enum.map(packages, & &1.dependency)
|
||||
|
||||
has_dependencies? =
|
||||
dependencies == [] or
|
||||
Livebook.Runtime.has_dependencies?(socket.private.data.runtime, dependencies)
|
||||
Runtime.has_dependencies?(socket.private.data.runtime, dependencies)
|
||||
|
||||
cond do
|
||||
has_dependencies? ->
|
||||
|
@ -1617,7 +1595,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
:error -> socket
|
||||
end
|
||||
|
||||
Livebook.Runtime.fixed_dependencies?(socket.private.data.runtime) ->
|
||||
Runtime.fixed_dependencies?(socket.private.data.runtime) ->
|
||||
put_flash(socket, :error, "This runtime doesn't support adding dependencies")
|
||||
|
||||
true ->
|
||||
|
@ -1632,6 +1610,13 @@ defmodule LivebookWeb.SessionLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp add_dependencies_and_reevaluate(socket, dependencies) do
|
||||
Session.add_dependencies(socket.assigns.session.pid, dependencies)
|
||||
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
|
||||
Session.queue_cells_reevaluation(socket.assigns.session.pid)
|
||||
socket
|
||||
end
|
||||
|
||||
defp confirm_add_packages(socket, on_confirm, packages, target_name, target_type) do
|
||||
assigns = %{packages: packages, target_name: target_name, target_type: target_type}
|
||||
|
||||
|
@ -1728,7 +1713,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
|
||||
defp handlers_for_file_entry(file_entry, runtime) do
|
||||
handlers =
|
||||
for definition <- Livebook.Runtime.snippet_definitions(runtime),
|
||||
for definition <- Runtime.snippet_definitions(runtime),
|
||||
definition.type == :file_action,
|
||||
do: %{definition: definition, cell_type: :code}
|
||||
|
||||
|
@ -1789,11 +1774,13 @@ defmodule LivebookWeb.SessionLive do
|
|||
dirty: data.dirty,
|
||||
persistence_warnings: data.persistence_warnings,
|
||||
runtime: data.runtime,
|
||||
runtime_status: data.runtime_status,
|
||||
runtime_connect_info: data.runtime_connect_info,
|
||||
runtime_connected_nodes: Enum.sort(data.runtime_connected_nodes),
|
||||
smart_cell_definitions: Enum.sort_by(data.smart_cell_definitions, & &1.name),
|
||||
example_snippet_definitions:
|
||||
data.runtime
|
||||
|> Livebook.Runtime.snippet_definitions()
|
||||
|> Runtime.snippet_definitions()
|
||||
|> Enum.filter(&(&1.type == :example))
|
||||
|> Enum.sort_by(& &1.name),
|
||||
global_status: global_status(data),
|
||||
|
|
|
@ -1,33 +1,39 @@
|
|||
defmodule LivebookWeb.SessionLive.AttachedLive do
|
||||
use LivebookWeb, :live_view
|
||||
defmodule LivebookWeb.SessionLive.AttachedRuntimeComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Livebook.{Session, Runtime}
|
||||
|
||||
@impl true
|
||||
def mount(
|
||||
_params,
|
||||
%{"session_pid" => session_pid, "current_runtime" => current_runtime},
|
||||
socket
|
||||
) do
|
||||
session = Session.get_by_pid(session_pid)
|
||||
|
||||
def mount(socket) do
|
||||
unless Livebook.Config.runtime_enabled?(Livebook.Runtime.Attached) do
|
||||
raise "runtime module not allowed"
|
||||
end
|
||||
|
||||
if connected?(socket) do
|
||||
Session.subscribe(session.id)
|
||||
end
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
session: session,
|
||||
current_runtime: current_runtime,
|
||||
error_message: nil,
|
||||
changeset: changeset(current_runtime)
|
||||
)}
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
changeset =
|
||||
case socket.assigns[:changeset] do
|
||||
nil ->
|
||||
changeset(assigns.runtime)
|
||||
|
||||
changeset when socket.assigns.runtime == assigns.runtime ->
|
||||
changeset
|
||||
|
||||
changeset ->
|
||||
changeset(assigns.runtime, changeset.params)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:changeset, changeset)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp changeset(runtime, attrs \\ %{}) do
|
||||
|
@ -50,13 +56,10 @@ defmodule LivebookWeb.SessionLive.AttachedLive do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="flex-col space-y-5">
|
||||
<div :if={@error_message} class="error-box">
|
||||
<%= @error_message %>
|
||||
</div>
|
||||
<p class="text-gray-700">
|
||||
Connect the session to an already running node
|
||||
and evaluate code in the context of that node.
|
||||
The node must run Erlang/OTP <%= :erlang.system_info(:otp_release) %> and Elixir <%= System.version() %> (or later).
|
||||
The node must run Elixir <%= Livebook.Runtime.Attached.elixir_version_requirement() %>.
|
||||
Make sure to give the node a name and a cookie, for example:
|
||||
</p>
|
||||
<div class="text-gray-700 markdown">
|
||||
|
@ -71,6 +74,7 @@ defmodule LivebookWeb.SessionLive.AttachedLive do
|
|||
as={:data}
|
||||
phx-submit="init"
|
||||
phx-change="validate"
|
||||
phx-target={@myself}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
|
@ -78,62 +82,52 @@ defmodule LivebookWeb.SessionLive.AttachedLive do
|
|||
<.text_field field={f[:name]} label="Name" placeholder={test_node()} />
|
||||
<.text_field field={f[:cookie]} label="Cookie" placeholder="mycookie" />
|
||||
</div>
|
||||
<.button type="submit" disabled={not @changeset.valid?}>
|
||||
<%= if(reconnecting?(@changeset), do: "Reconnect", else: "Connect") %>
|
||||
<.button type="submit" disabled={@runtime_status == :connecting or not @changeset.valid?}>
|
||||
<%= label(@changeset, @runtime_status) %>
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp label(changeset, runtime_status) do
|
||||
reconnecting? = changeset.valid? and changeset.data == apply_changes(changeset)
|
||||
|
||||
case {reconnecting?, runtime_status} do
|
||||
{true, :connected} -> "Reconnect"
|
||||
{true, :connecting} -> "Connecting..."
|
||||
_ -> "Connect"
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"data" => data}, socket) do
|
||||
changeset =
|
||||
socket.assigns.current_runtime |> changeset(data) |> Map.replace!(:action, :validate)
|
||||
socket.assigns.runtime
|
||||
|> changeset(data)
|
||||
|> Map.replace!(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
|
||||
def handle_event("init", %{"data" => data}, socket) do
|
||||
socket.assigns.current_runtime
|
||||
socket.assigns.runtime
|
||||
|> changeset(data)
|
||||
|> apply_action(:insert)
|
||||
|> case do
|
||||
{:ok, data} ->
|
||||
node = String.to_atom(data.name)
|
||||
cookie = String.to_atom(data.cookie)
|
||||
|
||||
runtime = Runtime.Attached.new(node, cookie)
|
||||
|
||||
case Runtime.connect(runtime) do
|
||||
{:ok, runtime} ->
|
||||
Session.set_runtime(socket.assigns.session.pid, runtime)
|
||||
{:noreply, assign(socket, changeset: changeset(runtime), error_message: nil)}
|
||||
|
||||
{:error, message} ->
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
changeset: changeset(socket.assigns.current_runtime, data),
|
||||
error_message: Livebook.Utils.upcase_first(message)
|
||||
)}
|
||||
end
|
||||
Session.set_runtime(socket.assigns.session.pid, runtime)
|
||||
Session.connect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:operation, {:set_runtime, _pid, runtime}}, socket) do
|
||||
{:noreply, assign(socket, current_runtime: runtime)}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
|
||||
defp reconnecting?(changeset) do
|
||||
changeset.valid? and changeset.data == apply_changes(changeset)
|
||||
end
|
||||
|
||||
defp test_node() do
|
||||
"test@#{Livebook.Utils.node_host()}"
|
||||
end
|
|
@ -1,65 +0,0 @@
|
|||
defmodule LivebookWeb.SessionLive.ElixirStandaloneLive do
|
||||
use LivebookWeb, :live_view
|
||||
|
||||
alias Livebook.{Session, Runtime}
|
||||
|
||||
@impl true
|
||||
def mount(
|
||||
_params,
|
||||
%{"session_pid" => session_pid, "current_runtime" => current_runtime},
|
||||
socket
|
||||
) do
|
||||
session = Session.get_by_pid(session_pid)
|
||||
|
||||
unless Livebook.Config.runtime_enabled?(Livebook.Runtime.ElixirStandalone) do
|
||||
raise "runtime module not allowed"
|
||||
end
|
||||
|
||||
if connected?(socket) do
|
||||
Session.subscribe(session.id)
|
||||
end
|
||||
|
||||
{:ok, assign(socket, session: session, current_runtime: current_runtime, error_message: nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="flex-col space-y-5">
|
||||
<div :if={@error_message} class="error-box">
|
||||
<%= @error_message %>
|
||||
</div>
|
||||
<p class="text-gray-700">
|
||||
Start a new local node to evaluate code.
|
||||
</p>
|
||||
<.button phx-click="init">
|
||||
<%= if(matching_runtime?(@current_runtime), do: "Reconnect", else: "Connect") %>
|
||||
</.button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp matching_runtime?(%Runtime.ElixirStandalone{} = runtime), do: Runtime.connected?(runtime)
|
||||
defp matching_runtime?(_runtime), do: false
|
||||
|
||||
@impl true
|
||||
def handle_event("init", _params, socket) do
|
||||
Runtime.ElixirStandalone.new()
|
||||
|> Runtime.connect()
|
||||
|> case do
|
||||
{:ok, runtime} ->
|
||||
Session.set_runtime(socket.assigns.session.pid, runtime)
|
||||
{:noreply, assign(socket, error_message: nil)}
|
||||
|
||||
{:error, message} ->
|
||||
{:noreply, assign(socket, error_message: message)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:operation, {:set_runtime, _pid, runtime}}, socket) do
|
||||
{:noreply, assign(socket, current_runtime: runtime)}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
end
|
|
@ -1,25 +1,15 @@
|
|||
defmodule LivebookWeb.SessionLive.EmbeddedLive do
|
||||
use LivebookWeb, :live_view
|
||||
defmodule LivebookWeb.SessionLive.EmbeddedRuntimeComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
alias Livebook.{Session, Runtime}
|
||||
|
||||
@impl true
|
||||
def mount(
|
||||
_params,
|
||||
%{"session_pid" => session_pid, "current_runtime" => current_runtime},
|
||||
socket
|
||||
) do
|
||||
session = Session.get_by_pid(session_pid)
|
||||
|
||||
def mount(socket) do
|
||||
unless Livebook.Config.runtime_enabled?(Livebook.Runtime.Embedded) do
|
||||
raise "runtime module not allowed"
|
||||
end
|
||||
|
||||
if connected?(socket) do
|
||||
Session.subscribe(session.id)
|
||||
end
|
||||
|
||||
{:ok, assign(socket, session: session, current_runtime: current_runtime)}
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
@ -31,7 +21,7 @@ defmodule LivebookWeb.SessionLive.EmbeddedLive do
|
|||
This is reserved for specific cases where there is no option
|
||||
of starting a separate Elixir runtime (for example, on embedded
|
||||
devices or cases where the amount of memory available is
|
||||
limited). Prefer the "Elixir standalone" runtime whenever possible.
|
||||
limited). Prefer the "Standalone" runtime whenever possible.
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
<span class="font-semibold">Warning:</span>
|
||||
|
@ -39,27 +29,22 @@ defmodule LivebookWeb.SessionLive.EmbeddedLive do
|
|||
you restart Livebook. Furthermore, code in one notebook
|
||||
may interfere with code from another notebook.
|
||||
</p>
|
||||
<.button phx-click="init">
|
||||
<%= if(matching_runtime?(@current_runtime), do: "Reconnect", else: "Connect") %>
|
||||
<.button phx-click="init" phx-target={@myself} disabled={@runtime_status == :connecting}>
|
||||
<%= label(@runtime, @runtime_status) %>
|
||||
</.button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp matching_runtime?(%Runtime.Embedded{}), do: true
|
||||
defp matching_runtime?(_runtime), do: false
|
||||
defp label(%Runtime.Embedded{}, :connecting), do: "Connecting..."
|
||||
defp label(%Runtime.Embedded{}, :connected), do: "Reconnect"
|
||||
defp label(_runtime, _runtime_status), do: "Connect"
|
||||
|
||||
@impl true
|
||||
def handle_event("init", _params, socket) do
|
||||
{:ok, runtime} = Runtime.Embedded.new() |> Runtime.connect()
|
||||
runtime = Runtime.Embedded.new()
|
||||
Session.set_runtime(socket.assigns.session.pid, runtime)
|
||||
Session.connect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:operation, {:set_runtime, _pid, runtime}}, socket) do
|
||||
{:noreply, assign(socket, current_runtime: runtime)}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
end
|
748
lib/livebook_web/live/session_live/fly_runtime_component.ex
Normal file
748
lib/livebook_web/live/session_live/fly_runtime_component.ex
Normal file
|
@ -0,0 +1,748 @@
|
|||
defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Livebook.{Session, Runtime}
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
unless Livebook.Config.runtime_enabled?(Livebook.Runtime.Fly) do
|
||||
raise "runtime module not allowed"
|
||||
end
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
token: nil,
|
||||
token_check: %{status: :initial, error: nil},
|
||||
org: nil,
|
||||
regions: nil,
|
||||
app_name: nil,
|
||||
app_check: %{status: :initial, error: nil},
|
||||
volumes: nil,
|
||||
region: nil,
|
||||
specs_changeset: specs_changeset(%{}),
|
||||
volume_id: nil,
|
||||
volume_action: nil
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
case assigns.runtime do
|
||||
%Runtime.Fly{config: config} when not is_map_key(socket.assigns, :runtime) ->
|
||||
assign(socket,
|
||||
token: config.token,
|
||||
app_name: config.app_name,
|
||||
specs_changeset: specs_changeset(config)
|
||||
)
|
||||
|> load_org_and_regions()
|
||||
|> load_app()
|
||||
|
||||
_ ->
|
||||
socket
|
||||
end
|
||||
|
||||
socket = assign(socket, assigns)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<p class="text-gray-700">
|
||||
Start a temporary Fly.io machine with an Elixir node to evaluate code.
|
||||
The machine is automatically destroyed, once you disconnect the runtime.
|
||||
</p>
|
||||
|
||||
<form class="mt-4 flex flex-col gap-4" phx-change="set_token" phx-nosubmit phx-target={@myself}>
|
||||
<.password_field name="token" value={@token} label="Token" />
|
||||
<.message_box :if={@token == nil} kind={:info}>
|
||||
Go to <a
|
||||
class="text-blue-600 hover:text-blue-700"
|
||||
href="https://fly.io/dashboard"
|
||||
phx-no-format
|
||||
>Fly dashboard</a>, click "Tokens" in the left sidebar and create a new
|
||||
token for your organization of choice. This functionality is restricted
|
||||
to organization admins. Alternatively, you can create an app in the
|
||||
organization by running <code>fly app create</code>
|
||||
and generate a deploy token in
|
||||
the app dashboard.
|
||||
</.message_box>
|
||||
<.loader :if={@token_check.status == :inflight} />
|
||||
<.message_box
|
||||
:if={error = @token_check.error}
|
||||
kind={:error}
|
||||
message={"Error: " <> error.message}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<.app_config
|
||||
:if={@token_check.status == :ok}
|
||||
org_name={@org.name}
|
||||
regions={@regions}
|
||||
app_name={@app_name}
|
||||
app_check={@app_check}
|
||||
volumes={@volumes}
|
||||
region={@region}
|
||||
myself={@myself}
|
||||
/>
|
||||
|
||||
<div :if={@token_check.status == :ok and @app_check.status == :ok}>
|
||||
<.specs_config specs_changeset={@specs_changeset} myself={@myself} />
|
||||
|
||||
<.storage_config
|
||||
volumes={@volumes}
|
||||
volume_id={@volume_id}
|
||||
region={@region}
|
||||
volume_action={@volume_action}
|
||||
myself={@myself}
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<.button
|
||||
phx-click="init"
|
||||
phx-target={@myself}
|
||||
disabled={
|
||||
@runtime_status == :connecting or not @specs_changeset.valid? or
|
||||
volume_errors(@volume_id, @volumes, @region) != []
|
||||
}
|
||||
>
|
||||
<%= label(@app_name, @runtime, @runtime_status) %>
|
||||
</.button>
|
||||
<div
|
||||
:if={reconnecting?(@app_name, @runtime) && @runtime_connect_info}
|
||||
class="mt-4 scroll-mb-8"
|
||||
phx-mounted={JS.dispatch("lb:scroll_into_view", detail: %{behavior: "instant"})}
|
||||
>
|
||||
<.message_box kind={:info}>
|
||||
<div class="flex items-center gap-2">
|
||||
<.spinner />
|
||||
<span>Step: <%= @runtime_connect_info %></span>
|
||||
</div>
|
||||
</.message_box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp loader(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-gray-700">Loading</span>
|
||||
<.spinner />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp app_config(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<.text_field name="org" label="Organization" value={@org_name} readonly />
|
||||
<form
|
||||
phx-change="set_app_name"
|
||||
phx-nosubmit
|
||||
phx-target={@myself}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<.text_field name="app_name" label="App" value={@app_name} phx-debounce="500" />
|
||||
</form>
|
||||
<form phx-change="set_region" phx-nosubmit phx-target={@myself}>
|
||||
<.select_field
|
||||
name="region"
|
||||
label="Region"
|
||||
value={@region}
|
||||
options={region_options(@regions)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<.message_box
|
||||
:if={@app_name == nil}
|
||||
kind={:info}
|
||||
message="Specify the app where machines should be created."
|
||||
/>
|
||||
<.loader :if={@app_check.status == :inflight} />
|
||||
<.app_check_error
|
||||
:if={@app_check.error}
|
||||
error={@app_check.error}
|
||||
app_name={@app_name}
|
||||
myself={@myself}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp app_check_error(%{error: %{status: 404}} = assigns) do
|
||||
~H"""
|
||||
<.message_box kind={:info}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
App <span class="font-semibold"><%= @app_name %></span> does not exist yet.
|
||||
</div>
|
||||
<.button phx-click="create_app" phx-target={@myself}>
|
||||
Create
|
||||
</.button>
|
||||
</div>
|
||||
</.message_box>
|
||||
"""
|
||||
end
|
||||
|
||||
defp app_check_error(assigns) do
|
||||
~H"""
|
||||
<.message_box kind={:error} message={"Error: " <> @error.message} />
|
||||
"""
|
||||
end
|
||||
|
||||
defp specs_config(assigns) do
|
||||
~H"""
|
||||
<div class="mt-8">
|
||||
<div class="text-lg text-gray-800 font-semibold">
|
||||
Specs
|
||||
</div>
|
||||
<div class="mt-1 text-gray-700">
|
||||
For more details refer to
|
||||
<a
|
||||
class="text-blue-600 hover:text-blue-700"
|
||||
href="https://fly.io/docs/machines/guides-examples/machine-sizing"
|
||||
>
|
||||
Machine sizing
|
||||
</a>
|
||||
and
|
||||
<a class="text-blue-600 hover:text-blue-700" href="https://fly.io/docs/about/pricing">
|
||||
Pricing
|
||||
</a>
|
||||
pages in the Fly.io documentation.
|
||||
</div>
|
||||
<.form
|
||||
:let={f}
|
||||
for={@specs_changeset}
|
||||
as={:specs}
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
phx-change="validate_specs"
|
||||
phx-nosubmit
|
||||
phx-target={@myself}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<.select_field field={f[:cpu_kind]} label="CPU kind" options={cpu_kind_options()} />
|
||||
<.text_field field={f[:cpus]} label="CPUs" type="number" min="1" />
|
||||
<.text_field field={f[:memory_gb]} label="Memory (GB)" type="number" step="1" min="1" />
|
||||
<.select_field field={f[:gpu_kind]} label="GPU kind" options={gpu_kind_options()} />
|
||||
<.text_field
|
||||
field={f[:gpus]}
|
||||
label="GPUs"
|
||||
type="number"
|
||||
min="1"
|
||||
disabled={get_field(@specs_changeset, :gpu_kind) == nil}
|
||||
/>
|
||||
<div class="col-span-5 text-sm text-gray-700">
|
||||
GPUs are available only in certain regions, see
|
||||
<a
|
||||
class="text-blue-600 hover:text-blue-700"
|
||||
href="https://fly.io/docs/gpus/getting-started-gpus/#specify-the-region"
|
||||
>
|
||||
Getting started with GPUs.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<.radio_field
|
||||
field={f[:docker_tag]}
|
||||
label="Base Docker image"
|
||||
options={LivebookWeb.AppComponents.docker_tag_options()}
|
||||
/>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp storage_config(assigns) do
|
||||
~H"""
|
||||
<div class="mt-8">
|
||||
<div class="text-lg text-gray-800 font-semibold">
|
||||
Storage
|
||||
</div>
|
||||
<div class="mt-1 text-gray-700">
|
||||
Every time you connect to the runtime, a fresh machine is created.
|
||||
In order to persist data and caches, you can optionally mount a
|
||||
volume at <code>/home/livebook</code>.
|
||||
Keep in mind that volumes are billed even when not in use, so you
|
||||
may want to remove those no longer needed.
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<div class="flex items-start gap-1">
|
||||
<div class="grow">
|
||||
<form phx-change="set_volume_id" phx-nosubmit phx-target={@myself}>
|
||||
<.select_field
|
||||
name="volume_id"
|
||||
label="Volume"
|
||||
value={@volume_id}
|
||||
options={[{"None", ""} | volume_options(@volumes)]}
|
||||
errors={volume_errors(@volume_id, @volumes, @region)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-7 flex items-center gap-1">
|
||||
<span class="tooltip left" data-tooltip="Delete selected volume">
|
||||
<.icon_button
|
||||
phx-click="delete_volume"
|
||||
phx-target={@myself}
|
||||
disabled={@volume_id == nil or (@volume_action != nil and @volume_action.inflight)}
|
||||
>
|
||||
<.remix_icon icon="delete-bin-6-line" />
|
||||
</.icon_button>
|
||||
</span>
|
||||
<span class="tooltip left" data-tooltip="Create new volume">
|
||||
<.icon_button phx-click="new_volume" phx-target={@myself}>
|
||||
<.remix_icon icon="add-line" />
|
||||
</.icon_button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:if={@volume_action[:type] == :delete}
|
||||
class="px-4 py-3 flex space-x-4 items-center border border-gray-200 rounded-lg"
|
||||
>
|
||||
<p class="grow text-gray-700 text-sm">
|
||||
Are you sure you want to irreversibly delete <span class="font-semibold"><%= @volume_id %></span>?
|
||||
</p>
|
||||
<div class="flex space-x-4">
|
||||
<button
|
||||
class="text-red-600 font-medium text-sm whitespace-nowrap"
|
||||
phx-click="confirm_delete_volume"
|
||||
phx-target={@myself}
|
||||
disabled={@volume_action.inflight}
|
||||
>
|
||||
<.remix_icon icon="delete-bin-6-line" class="align-middle mr-1" /> Delete
|
||||
</button>
|
||||
<button
|
||||
class="text-gray-600 font-medium text-sm"
|
||||
phx-click="cancel_delete_volume"
|
||||
phx-target={@myself}
|
||||
disabled={@volume_action.inflight}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<.form
|
||||
:let={f}
|
||||
:if={@volume_action[:type] == :new}
|
||||
for={@volume_action.changeset}
|
||||
as={:volume}
|
||||
phx-submit="create_volume"
|
||||
phx-change="validate_volume"
|
||||
phx-target={@myself}
|
||||
class="flex gap-2 items-center"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<div>
|
||||
<.remix_icon icon="corner-down-right-line" class="text-gray-400 text-lg" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 grow">
|
||||
<.text_field field={f[:name]} placeholder="Name" />
|
||||
<.text_field field={f[:size_gb]} placeholder="Size (GB)" type="number" min="1" />
|
||||
</div>
|
||||
<.button
|
||||
type="button"
|
||||
color="gray"
|
||||
outlined
|
||||
phx-click="cancel_new_volume"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Cancel
|
||||
</.button>
|
||||
<.button
|
||||
type="submit"
|
||||
disabled={not @volume_action.changeset.valid? or @volume_action.inflight}
|
||||
>
|
||||
<%= if(@volume_action.inflight, do: "Creating...", else: "Create") %>
|
||||
</.button>
|
||||
</.form>
|
||||
<div :if={error = @volume_action[:error]}>
|
||||
<.message_box kind={:error} message={error} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("set_token", %{"token" => token}, socket) do
|
||||
{:noreply, socket |> assign(token: nullify(token)) |> load_org_and_regions()}
|
||||
end
|
||||
|
||||
def handle_event("set_app_name", %{"app_name" => app_name}, socket) do
|
||||
{:noreply, socket |> assign(app_name: nullify(app_name)) |> load_app()}
|
||||
end
|
||||
|
||||
def handle_event("set_region", %{"region" => region}, socket) do
|
||||
{:noreply, assign(socket, region: region)}
|
||||
end
|
||||
|
||||
def handle_event("create_app", %{}, socket) do
|
||||
{:noreply, create_app(socket)}
|
||||
end
|
||||
|
||||
def handle_event("set_volume_id", %{"volume_id" => volume_id}, socket) do
|
||||
{:noreply, assign(socket, volume_id: nullify(volume_id), volume_action: nil)}
|
||||
end
|
||||
|
||||
def handle_event("delete_volume", %{}, socket) do
|
||||
volume_action = %{type: :delete, inflight: false, error: nil}
|
||||
{:noreply, assign(socket, volume_action: volume_action)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_delete_volume", %{}, socket) do
|
||||
{:noreply, assign(socket, volume_action: nil)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_delete_volume", %{}, socket) do
|
||||
{:noreply, delete_volume(socket)}
|
||||
end
|
||||
|
||||
def handle_event("new_volume", %{}, socket) do
|
||||
volume_action = %{type: :new, changeset: volume_changeset(), inflight: false, error: false}
|
||||
{:noreply, assign(socket, volume_action: volume_action)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_new_volume", %{}, socket) do
|
||||
{:noreply, assign(socket, volume_action: nil)}
|
||||
end
|
||||
|
||||
def handle_event("validate_volume", %{"volume" => volume}, socket) do
|
||||
changeset =
|
||||
volume
|
||||
|> volume_changeset()
|
||||
|> Map.replace!(:action, :validate)
|
||||
|
||||
{:noreply, assign_nested(socket, :volume_action, changeset: changeset)}
|
||||
end
|
||||
|
||||
def handle_event("create_volume", %{"volume" => volume}, socket) do
|
||||
volume
|
||||
|> volume_changeset()
|
||||
|> apply_action(:insert)
|
||||
|> case do
|
||||
{:ok, %{name: name, size_gb: size_gb}} ->
|
||||
{:noreply, create_volume(socket, name, size_gb)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign_nested(socket, :volume_action, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_specs", %{"specs" => specs}, socket) do
|
||||
changeset =
|
||||
socket.assigns.specs_changeset.data
|
||||
|> specs_changeset(specs)
|
||||
|> Map.replace!(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, specs_changeset: changeset)}
|
||||
end
|
||||
|
||||
def handle_event("init", %{}, socket) do
|
||||
socket.assigns.specs_changeset
|
||||
|> apply_action(:insert)
|
||||
|> case do
|
||||
{:ok, specs} ->
|
||||
config = %{
|
||||
token: socket.assigns.token,
|
||||
app_name: socket.assigns.app_name,
|
||||
region: socket.assigns.region,
|
||||
cpu_kind: specs.cpu_kind,
|
||||
cpus: specs.cpus,
|
||||
memory_gb: specs.memory_gb,
|
||||
gpu_kind: specs.gpu_kind,
|
||||
gpus: specs.gpus,
|
||||
volume_id: socket.assigns.volume_id,
|
||||
docker_tag: specs.docker_tag
|
||||
}
|
||||
|
||||
runtime = Runtime.Fly.new(config)
|
||||
Session.set_runtime(socket.assigns.session.pid, runtime)
|
||||
Session.connect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, specs_changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_async(:load_org_and_regions, {:ok, result}, socket) do
|
||||
socket =
|
||||
case result do
|
||||
{:ok, %{orgs: [org]} = data} ->
|
||||
region =
|
||||
case socket.assigns.runtime do
|
||||
%Runtime.Fly{config: config} -> config.region
|
||||
_ -> data.closest_region
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(org: org, regions: data.regions, region: region)
|
||||
|> assign(:token_check, %{status: :ok, error: nil})
|
||||
|
||||
{:ok, %{orgs: orgs}} ->
|
||||
error =
|
||||
"expected organization-specific auth token, but the given one gives access to #{length(orgs)} organizations"
|
||||
|
||||
assign(socket, :token_check, %{status: :error, error: error})
|
||||
|
||||
{:error, error} ->
|
||||
assign(socket, :token_check, %{status: :error, error: error})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_async(:load_app, {:ok, result}, socket) do
|
||||
socket =
|
||||
case result do
|
||||
{:ok, volumes} ->
|
||||
volume_id =
|
||||
case socket.assigns.runtime do
|
||||
%Runtime.Fly{config: %{volume_id: volume_id}} ->
|
||||
# Ignore the volume if it no longer exists
|
||||
if Enum.any?(volumes, &(&1.id == volume_id)), do: volume_id
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(volumes: volumes, volume_id: volume_id)
|
||||
|> assign(:app_check, %{status: :ok, error: nil})
|
||||
|
||||
{:error, error} ->
|
||||
assign(socket, :app_check, %{status: :error, error: error})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_async(:create_app, {:ok, result}, socket) do
|
||||
socket =
|
||||
case result do
|
||||
:ok ->
|
||||
socket
|
||||
|> assign(volumes: [], volume_id: nil)
|
||||
|> assign(:app_check, %{status: :ok, error: nil})
|
||||
|
||||
{:error, error} ->
|
||||
assign(socket, :app_check, %{status: :error, error: error})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_async(:create_volume, {:ok, result}, socket) do
|
||||
socket =
|
||||
case result do
|
||||
{:ok, volume} ->
|
||||
volumes = [volume | socket.assigns.volumes]
|
||||
assign(socket, volumes: volumes, volume_id: volume.id, volume_action: nil)
|
||||
|
||||
{:error, error} ->
|
||||
assign_nested(socket, :volume_action, error: error, inflight: false)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_async(:delete_volume, {:ok, result}, socket) do
|
||||
volume_id = socket.assigns.volume_id
|
||||
|
||||
socket =
|
||||
case result do
|
||||
:ok ->
|
||||
volumes = Enum.reject(socket.assigns.volumes, &(&1.id == volume_id))
|
||||
assign(socket, volumes: volumes, volume_id: nil, volume_action: nil)
|
||||
|
||||
{:error, error} ->
|
||||
assign_nested(socket, :volume_action, error: error, inflight: false)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp label(app_name, runtime, runtime_status) do
|
||||
reconnecting? = reconnecting?(app_name, runtime)
|
||||
|
||||
case {reconnecting?, runtime_status} do
|
||||
{true, :connected} -> "Reconnect"
|
||||
{true, :connecting} -> "Connecting..."
|
||||
_ -> "Connect"
|
||||
end
|
||||
end
|
||||
|
||||
defp reconnecting?(app_name, runtime) do
|
||||
match?(%Runtime.Fly{config: %{app_name: ^app_name}}, runtime)
|
||||
end
|
||||
|
||||
defp cpu_kind_options() do
|
||||
Enum.map(Livebook.FlyAPI.cpu_kinds(), &{&1, &1})
|
||||
end
|
||||
|
||||
defp gpu_kind_options() do
|
||||
[{"None", ""}] ++ Enum.map(Livebook.FlyAPI.gpu_kinds(), &{&1, &1})
|
||||
end
|
||||
|
||||
defp region_options(regions) do
|
||||
for region <- regions,
|
||||
do: {"#{region.name} (#{region.code})", region.code}
|
||||
end
|
||||
|
||||
defp volume_options(volumes) do
|
||||
for volume <- Enum.sort_by(volumes, &{&1.name, &1.id}),
|
||||
do: {
|
||||
"#{volume.id} (name: #{volume.name}, region: #{volume.region}, size: #{volume.size_gb} GB)",
|
||||
volume.id
|
||||
}
|
||||
end
|
||||
|
||||
defp specs_changeset(config, attrs \\ %{}) do
|
||||
defaults = %{
|
||||
cpu_kind: "shared",
|
||||
cpus: 1,
|
||||
memory_gb: 1,
|
||||
gpu_kind: nil,
|
||||
gpus: nil,
|
||||
docker_tag: Livebook.Config.docker_images() |> hd() |> Map.fetch!(:tag)
|
||||
}
|
||||
|
||||
data = for {key, default} <- defaults, into: %{}, do: {key, Map.get(config, key, default)}
|
||||
|
||||
types = %{
|
||||
cpu_kind: :string,
|
||||
cpus: :integer,
|
||||
memory_gb: :integer,
|
||||
gpu_kind: :string,
|
||||
gpus: :integer,
|
||||
docker_tag: :string
|
||||
}
|
||||
|
||||
changeset =
|
||||
cast({data, types}, attrs, Map.keys(types))
|
||||
|> validate_required([:cpu_kind, :cpus, :memory_gb, :docker_tag])
|
||||
|
||||
if get_field(changeset, :gpu_kind) do
|
||||
changeset
|
||||
else
|
||||
# We may be reverting back to the defult, so we force the change
|
||||
# to take precedence over form params in Phoenix.HTML.FormData
|
||||
force_change(changeset, :gpus, nil)
|
||||
end
|
||||
end
|
||||
|
||||
defp volume_changeset(attrs \\ %{}) do
|
||||
data = %{name: nil, size_gb: nil}
|
||||
|
||||
types = %{
|
||||
name: :string,
|
||||
size_gb: :integer
|
||||
}
|
||||
|
||||
cast({data, types}, attrs, Map.keys(types))
|
||||
|> validate_required([:name, :size_gb])
|
||||
end
|
||||
|
||||
defp volume_errors(nil, _volumes, _region), do: []
|
||||
|
||||
defp volume_errors(volume_id, volumes, region) do
|
||||
volume = Enum.find(volumes, &(&1.id == volume_id))
|
||||
|
||||
if volume.region == region do
|
||||
[]
|
||||
else
|
||||
["must be in the same region as the machine (#{region})"]
|
||||
end
|
||||
end
|
||||
|
||||
defp load_org_and_regions(socket) when socket.assigns.token == nil do
|
||||
assign(socket, :token_check, %{status: :initial, error: nil})
|
||||
end
|
||||
|
||||
defp load_org_and_regions(socket) do
|
||||
token = socket.assigns.token
|
||||
|
||||
socket
|
||||
|> start_async(:load_org_and_regions, fn ->
|
||||
Livebook.FlyAPI.get_orgs_and_regions(token)
|
||||
end)
|
||||
|> assign(:token_check, %{status: :inflight, error: nil})
|
||||
end
|
||||
|
||||
defp load_app(socket) when socket.assigns.app_name == nil do
|
||||
assign(socket, :app_check, %{status: :initial, error: nil})
|
||||
end
|
||||
|
||||
defp load_app(socket) do
|
||||
%{token: token, app_name: app_name} = socket.assigns
|
||||
|
||||
socket
|
||||
|> start_async(:load_app, fn ->
|
||||
Livebook.FlyAPI.get_app_volumes(token, app_name)
|
||||
end)
|
||||
|> assign(:app_check, %{status: :inflight, error: nil})
|
||||
end
|
||||
|
||||
defp create_app(socket) do
|
||||
%{token: token, app_name: app_name} = socket.assigns
|
||||
org_slug = socket.assigns.org.slug
|
||||
|
||||
socket
|
||||
|> start_async(:create_app, fn ->
|
||||
Livebook.FlyAPI.create_app(token, app_name, org_slug)
|
||||
end)
|
||||
|> assign(:app_check, %{status: :inflight, error: nil})
|
||||
end
|
||||
|
||||
defp delete_volume(socket) do
|
||||
%{token: token, app_name: app_name, volume_id: volume_id} = socket.assigns
|
||||
|
||||
socket
|
||||
|> start_async(:delete_volume, fn ->
|
||||
Livebook.FlyAPI.delete_volume(token, app_name, volume_id)
|
||||
end)
|
||||
|> assign_nested(:volume_action, inflight: true)
|
||||
end
|
||||
|
||||
defp create_volume(socket, name, size_gb) do
|
||||
%{token: token, app_name: app_name, region: region} = socket.assigns
|
||||
|
||||
specs = apply_changes(socket.assigns.specs_changeset)
|
||||
|
||||
compute = %{
|
||||
cpu_kind: specs.cpu_kind,
|
||||
cpus: specs.cpus,
|
||||
memory_mb: specs.memory_gb * 1024,
|
||||
gpu_kind: specs.gpu_kind,
|
||||
gpus: specs.gpus
|
||||
}
|
||||
|
||||
socket
|
||||
|> start_async(:create_volume, fn ->
|
||||
Livebook.FlyAPI.create_volume(token, app_name, name, region, size_gb, compute)
|
||||
end)
|
||||
|> assign_nested(:volume_action, inflight: true)
|
||||
end
|
||||
|
||||
defp assign_nested(socket, key, keyword) do
|
||||
update(socket, key, fn map ->
|
||||
Enum.reduce(keyword, map, fn {key, value}, map -> Map.replace!(map, key, value) end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp nullify(""), do: nil
|
||||
defp nullify(value), do: value
|
||||
end
|
|
@ -148,9 +148,9 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
|||
<% end %>
|
||||
</.menu>
|
||||
<%= cond do %>
|
||||
<% not Livebook.Runtime.connected?(@runtime) -> %>
|
||||
<% @runtime_status == :disconnected -> %>
|
||||
<.insert_button phx-click={
|
||||
JS.push("setup_default_runtime",
|
||||
JS.push("setup_runtime",
|
||||
value: %{reason: "To see the available smart cells, you need a connected runtime."}
|
||||
)
|
||||
}>
|
||||
|
|
|
@ -33,7 +33,7 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
dirty={@data_view.dirty}
|
||||
persistence_warnings={@data_view.persistence_warnings}
|
||||
autosave_interval_s={@data_view.autosave_interval_s}
|
||||
runtime={@data_view.runtime}
|
||||
runtime_status={@data_view.runtime_status}
|
||||
global_status={@data_view.global_status}
|
||||
/>
|
||||
<.notebook_content
|
||||
|
@ -61,6 +61,8 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
id="runtime-settings"
|
||||
session={@session}
|
||||
runtime={@data_view.runtime}
|
||||
runtime_status={@data_view.runtime_status}
|
||||
runtime_connect_info={@data_view.runtime_connect_info}
|
||||
/>
|
||||
</.modal>
|
||||
|
||||
|
@ -652,23 +654,24 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
</.labeled_text>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||
<%= if Runtime.connected?(@data_view.runtime) do %>
|
||||
<.button phx-click="reconnect_runtime">
|
||||
<.remix_icon icon="wireless-charging-line" />
|
||||
<span>Reconnect</span>
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button phx-click="connect_runtime">
|
||||
<.remix_icon icon="wireless-charging-line" />
|
||||
<span>Connect</span>
|
||||
</.button>
|
||||
<% end %>
|
||||
<.button :if={@data_view.runtime_status == :disconnected} phx-click="connect_runtime">
|
||||
<.remix_icon icon="wireless-charging-line" />
|
||||
<span>Connect</span>
|
||||
</.button>
|
||||
<.button :if={@data_view.runtime_status == :connecting} disabled>
|
||||
<.remix_icon icon="wireless-charging-line" />
|
||||
<span>Connecting...</span>
|
||||
</.button>
|
||||
<.button :if={@data_view.runtime_status == :connected} phx-click="reconnect_runtime">
|
||||
<.remix_icon icon="wireless-charging-line" />
|
||||
<span>Reconnect</span>
|
||||
</.button>
|
||||
<.button color="gray" outlined patch={~p"/sessions/#{@session.id}/settings/runtime"}>
|
||||
Configure
|
||||
</.button>
|
||||
|
||||
<.button
|
||||
:if={Runtime.connected?(@data_view.runtime)}
|
||||
:if={@data_view.runtime_status == :connected}
|
||||
color="red"
|
||||
outlined
|
||||
type="button"
|
||||
|
@ -679,6 +682,15 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
</.button>
|
||||
</div>
|
||||
|
||||
<div :if={@data_view.runtime_connect_info} class="mt-4">
|
||||
<.message_box kind={:info}>
|
||||
<div class="flex items-center gap-2">
|
||||
<.spinner />
|
||||
<span>Step: <%= @data_view.runtime_connect_info %></span>
|
||||
</div>
|
||||
</.message_box>
|
||||
</div>
|
||||
|
||||
<.memory_usage_info memory_usage={@session.memory_usage} />
|
||||
|
||||
<.runtime_connected_nodes_info runtime_connected_nodes={@data_view.runtime_connected_nodes} />
|
||||
|
@ -690,13 +702,8 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
defp memory_usage_info(assigns) do
|
||||
~H"""
|
||||
<div class="mt-8 flex flex-col gap-2">
|
||||
<div class="text-sm text-gray-800 flex flex-row justify-between">
|
||||
<span class="text-gray-500 font-semibold uppercase">
|
||||
Memory
|
||||
</span>
|
||||
<span :if={uses_memory?(@memory_usage)}>
|
||||
<%= format_bytes(@memory_usage.system.free) %> available
|
||||
</span>
|
||||
<div class="text-sm text-gray-500 font-semibold uppercase">
|
||||
Memory
|
||||
</div>
|
||||
<%= if uses_memory?(@memory_usage) do %>
|
||||
<.runtime_memory_info memory_usage={@memory_usage} />
|
||||
|
@ -1003,7 +1010,7 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
session_id={@session_id}
|
||||
/>
|
||||
<.runtime_indicator
|
||||
runtime={@runtime}
|
||||
runtime_status={@runtime_status}
|
||||
global_status={@global_status}
|
||||
session_id={@session_id}
|
||||
/>
|
||||
|
@ -1133,9 +1140,7 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
|
||||
defp runtime_indicator(assigns) do
|
||||
~H"""
|
||||
<%= if Livebook.Runtime.connected?(@runtime) do %>
|
||||
<.global_status status={elem(@global_status, 0)} cell_id={elem(@global_status, 1)} />
|
||||
<% else %>
|
||||
<%= if @runtime_status == :disconnected do %>
|
||||
<span class="tooltip left" data-tooltip="Choose a runtime to run the notebook in">
|
||||
<.link
|
||||
patch={~p"/sessions/#{@session_id}/settings/runtime"}
|
||||
|
@ -1145,6 +1150,8 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
<.remix_icon icon="loader-3-line" />
|
||||
</.link>
|
||||
</span>
|
||||
<% else %>
|
||||
<.global_status status={elem(@global_status, 0)} cell_id={elem(@global_status, 1)} />
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
@ -1344,7 +1351,7 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
session_id={@session.id}
|
||||
session_pid={@session.pid}
|
||||
client_id={@client_id}
|
||||
runtime={@data_view.runtime}
|
||||
runtime_status={@data_view.runtime_status}
|
||||
smart_cell_definitions={@data_view.smart_cell_definitions}
|
||||
example_snippet_definitions={@data_view.example_snippet_definitions}
|
||||
installing?={@data_view.installing?}
|
||||
|
|
|
@ -5,20 +5,21 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
|
|||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, type: nil)}
|
||||
{:ok, assign(socket, error_message: nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
assigns =
|
||||
if socket.assigns.type == nil do
|
||||
type = runtime_type(assigns.runtime)
|
||||
Map.put(assigns, :type, type)
|
||||
else
|
||||
assigns
|
||||
end
|
||||
def update(%{event: {:error, message}}, socket) do
|
||||
{:ok, assign(socket, error_message: message)}
|
||||
end
|
||||
|
||||
{:ok, assign(socket, assigns)}
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:type, fn -> runtime_type(assigns.runtime) end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
@ -31,13 +32,13 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
|
|||
<div class="w-full flex-col space-y-5">
|
||||
<div class="flex space-x-4">
|
||||
<.choice_button
|
||||
:if={Livebook.Config.runtime_enabled?(Livebook.Runtime.ElixirStandalone)}
|
||||
active={@type == "elixir_standalone"}
|
||||
:if={Livebook.Config.runtime_enabled?(Livebook.Runtime.Standalone)}
|
||||
active={@type == "standalone"}
|
||||
phx-click="set_runtime_type"
|
||||
phx-value-type="elixir_standalone"
|
||||
phx-value-type="standalone"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Elixir standalone
|
||||
Standalone
|
||||
</.choice_button>
|
||||
<.choice_button
|
||||
:if={Livebook.Config.runtime_enabled?(Livebook.Runtime.Attached)}
|
||||
|
@ -57,25 +58,46 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
|
|||
>
|
||||
Embedded
|
||||
</.choice_button>
|
||||
<.choice_button
|
||||
:if={Livebook.Config.runtime_enabled?(Livebook.Runtime.Fly)}
|
||||
active={@type == "fly"}
|
||||
phx-click="set_runtime_type"
|
||||
phx-value-type="fly"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Fly.io machine
|
||||
</.choice_button>
|
||||
</div>
|
||||
<div
|
||||
:if={@error_message && @type == runtime_type(@runtime) && @runtime_status == :disconnected}
|
||||
class="error-box"
|
||||
>
|
||||
<%= @error_message %>
|
||||
</div>
|
||||
<div>
|
||||
<%= live_render(@socket, live_view_for_type(@type),
|
||||
id: "runtime-config-#{@type}",
|
||||
session: %{"session_pid" => @session.pid, "current_runtime" => @runtime}
|
||||
) %>
|
||||
<.live_component
|
||||
id={"runtime-config-#{@type}"}
|
||||
module={component_for_type(@type)}
|
||||
session={@session}
|
||||
runtime={@runtime}
|
||||
runtime_status={@runtime_status}
|
||||
runtime_connect_info={@runtime_connect_info}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp runtime_type(%Runtime.ElixirStandalone{}), do: "elixir_standalone"
|
||||
defp runtime_type(%Runtime.Standalone{}), do: "standalone"
|
||||
defp runtime_type(%Runtime.Attached{}), do: "attached"
|
||||
defp runtime_type(%Runtime.Embedded{}), do: "embedded"
|
||||
defp runtime_type(%Runtime.Fly{}), do: "fly"
|
||||
|
||||
defp live_view_for_type("elixir_standalone"), do: LivebookWeb.SessionLive.ElixirStandaloneLive
|
||||
defp live_view_for_type("attached"), do: LivebookWeb.SessionLive.AttachedLive
|
||||
defp live_view_for_type("embedded"), do: LivebookWeb.SessionLive.EmbeddedLive
|
||||
defp component_for_type("standalone"), do: LivebookWeb.SessionLive.StandaloneRuntimeComponent
|
||||
defp component_for_type("attached"), do: LivebookWeb.SessionLive.AttachedRuntimeComponent
|
||||
defp component_for_type("embedded"), do: LivebookWeb.SessionLive.EmbeddedRuntimeComponent
|
||||
defp component_for_type("fly"), do: LivebookWeb.SessionLive.FlyRuntimeComponent
|
||||
|
||||
@impl true
|
||||
def handle_event("set_runtime_type", %{"type" => type}, socket) do
|
||||
|
|
|
@ -147,7 +147,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
|||
persistent={@section_view.cell_views == []}
|
||||
smart_cell_definitions={@smart_cell_definitions}
|
||||
example_snippet_definitions={@example_snippet_definitions}
|
||||
runtime={@runtime}
|
||||
runtime_status={@runtime_status}
|
||||
section_id={@section_view.id}
|
||||
cell_id={nil}
|
||||
session_id={@session_id}
|
||||
|
@ -160,7 +160,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
|||
session_id={@session_id}
|
||||
session_pid={@session_pid}
|
||||
client_id={@client_id}
|
||||
runtime={@runtime}
|
||||
runtime_status={@runtime_status}
|
||||
installing?={@installing?}
|
||||
allowed_uri_schemes={@allowed_uri_schemes}
|
||||
cell_view={cell_view}
|
||||
|
@ -171,7 +171,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
|||
persistent={false}
|
||||
smart_cell_definitions={@smart_cell_definitions}
|
||||
example_snippet_definitions={@example_snippet_definitions}
|
||||
runtime={@runtime}
|
||||
runtime_status={@runtime_status}
|
||||
section_id={@section_view.id}
|
||||
cell_id={cell_view.id}
|
||||
session_id={@session_id}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
defmodule LivebookWeb.SessionLive.StandaloneRuntimeComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
alias Livebook.{Session, Runtime}
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
unless Livebook.Config.runtime_enabled?(Livebook.Runtime.Standalone) do
|
||||
raise "runtime module not allowed"
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="flex-col space-y-5">
|
||||
<p class="text-gray-700">
|
||||
Start a new local Elixir node to evaluate code. Whenever you reconnect this runtime,
|
||||
a fresh node is started.
|
||||
</p>
|
||||
<.button phx-click="init" phx-target={@myself} disabled={@runtime_status == :connecting}>
|
||||
<%= label(@runtime, @runtime_status) %>
|
||||
</.button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp label(%Runtime.Standalone{}, :connecting), do: "Connecting..."
|
||||
defp label(%Runtime.Standalone{}, :connected), do: "Reconnect"
|
||||
defp label(_runtime, _runtime_status), do: "Connect"
|
||||
|
||||
@impl true
|
||||
def handle_event("init", _params, socket) do
|
||||
runtime = Runtime.Standalone.new()
|
||||
Session.set_runtime(socket.assigns.session.pid, runtime)
|
||||
Session.connect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
30
mix.exs
30
mix.exs
|
@ -75,7 +75,8 @@ defmodule Livebook.MixProject do
|
|||
defp escript do
|
||||
[
|
||||
main_module: LivebookCLI,
|
||||
app: nil
|
||||
app: nil,
|
||||
emu_args: "-epmd_module Elixir.Livebook.EPMD"
|
||||
]
|
||||
end
|
||||
|
||||
|
@ -121,7 +122,7 @@ defmodule Livebook.MixProject do
|
|||
{:bypass, "~> 2.1", only: :test},
|
||||
# ZTA deps
|
||||
{:jose, "~> 1.11.5"},
|
||||
{:req, "~> 0.4.4"},
|
||||
{:req, "~> 0.5.2"},
|
||||
# Docs
|
||||
{:ex_doc, "~> 0.30", only: :dev, runtime: false}
|
||||
]
|
||||
|
@ -163,7 +164,7 @@ defmodule Livebook.MixProject do
|
|||
include_executables_for: [:unix, :windows],
|
||||
include_erts: false,
|
||||
rel_templates_path: "rel/server",
|
||||
steps: [:assemble, &remove_cookie/1]
|
||||
steps: [:assemble, &remove_cookie/1, &write_runtime_modules/1]
|
||||
],
|
||||
app: [
|
||||
applications: @release_apps,
|
||||
|
@ -179,10 +180,33 @@ defmodule Livebook.MixProject do
|
|||
end
|
||||
|
||||
defp remove_cookie(release) do
|
||||
# We remove the COOKIE file when assembling the release, because we
|
||||
# don't want to share the same cookie across users.
|
||||
|
||||
File.rm!(Path.join(release.path, "releases/COOKIE"))
|
||||
release
|
||||
end
|
||||
|
||||
defp write_runtime_modules(release) do
|
||||
# We copy the subset of Livebook modules that are injected into
|
||||
# the runtime node. See overlays/bin/server for more details
|
||||
|
||||
app = release.applications[:livebook]
|
||||
|
||||
source = Path.join([release.path, "lib", "livebook-#{app[:vsn]}", "ebin"])
|
||||
destination = Path.join([release.path, "lib", "livebook_runtime_ebin"])
|
||||
|
||||
File.mkdir_p!(destination)
|
||||
|
||||
for module <- Livebook.Runtime.ErlDist.required_modules() do
|
||||
from = Path.join(source, "#{module}.beam")
|
||||
to = Path.join(destination, "#{module}.beam")
|
||||
File.cp!(from, to)
|
||||
end
|
||||
|
||||
release
|
||||
end
|
||||
|
||||
@compile {:no_warn_undefined, Standalone}
|
||||
|
||||
defp standalone_erlang_elixir(release) do
|
||||
|
|
4
mix.lock
4
mix.lock
|
@ -24,7 +24,7 @@
|
|||
"makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
|
||||
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
|
||||
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
|
||||
"mint": {:hex, :mint, "1.6.1", "065e8a5bc9bbd46a41099dfea3e0656436c5cbcb6e741c80bd2bad5cd872446f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4fc518dcc191d02f433393a72a7ba3f6f94b101d094cb6bf532ea54c89423780"},
|
||||
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
|
||||
|
@ -44,7 +44,7 @@
|
|||
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
|
||||
"protobuf": {:hex, :protobuf, "0.12.0", "58c0dfea5f929b96b5aa54ec02b7130688f09d2de5ddc521d696eec2a015b223", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "75fa6cbf262062073dd51be44dd0ab940500e18386a6c4e87d5819a58964dc45"},
|
||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||
"req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"},
|
||||
"req": {:hex, :req, "0.5.2", "70b4976e5fbefe84e5a57fd3eea49d4e9aa0ac015301275490eafeaec380f97f", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0c63539ab4c2d6ced6114d2684276cef18ac185ee00674ee9af4b1febba1f986"},
|
||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
|
||||
|
|
|
@ -2,14 +2,6 @@ if exist "!USERPROFILE!\.livebookdesktop.bat" (
|
|||
call "!USERPROFILE!\.livebookdesktop.bat"
|
||||
)
|
||||
|
||||
if not defined LIVEBOOK_EPMDLESS set LIVEBOOK_EPMDLESS=true
|
||||
if "!LIVEBOOK_EPMDLESS!"=="1" goto epmdless
|
||||
if "!LIVEBOOK_EPMDLESS!"=="true" goto epmdless
|
||||
goto continue
|
||||
:epmdless
|
||||
set ELIXIR_ERL_OPTIONS=!ELIXIR_ERL_OPTIONS! -epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0
|
||||
:continue
|
||||
|
||||
set RELEASE_MODE=interactive
|
||||
set RELEASE_DISTRIBUTION=none
|
||||
|
||||
|
|
|
@ -2,11 +2,6 @@ if [ -f "$HOME/.livebookdesktop.sh" ]; then
|
|||
. "$HOME/.livebookdesktop.sh"
|
||||
fi
|
||||
|
||||
export LIVEBOOK_EPMDLESS=${LIVEBOOK_EPMDLESS:-true}
|
||||
if [ "$LIVEBOOK_EPMDLESS" = "true" ] || [ "$LIVEBOOK_EPMDLESS" = "1" ]; then
|
||||
export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS} -epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0"
|
||||
fi
|
||||
|
||||
export RELEASE_MODE="interactive"
|
||||
export RELEASE_DISTRIBUTION="none"
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Disable busy waiting so that we don't waste resources
|
||||
# Limit the maximal number of ports for the same reason
|
||||
+sbwt none +sbwtdcpu none +sbwtdio none +Q 65536
|
||||
# Set the custom EPMD module
|
||||
+sbwt none +sbwtdcpu none +sbwtdio none +Q 65536 -epmd_module Elixir.Livebook.EPMD
|
||||
|
|
|
@ -2,13 +2,6 @@ if exist "!RELEASE_ROOT!\user\env.bat" (
|
|||
call "!RELEASE_ROOT!\user\env.bat"
|
||||
)
|
||||
|
||||
if "!LIVEBOOK_EPMDLESS!"=="1" goto epmdless
|
||||
if "!LIVEBOOK_EPMDLESS!"=="true" goto epmdless
|
||||
goto continue
|
||||
:epmdless
|
||||
set ELIXIR_ERL_OPTIONS=!ELIXIR_ERL_OPTIONS! -epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0
|
||||
:continue
|
||||
|
||||
set RELEASE_MODE=interactive
|
||||
set RELEASE_DISTRIBUTION=none
|
||||
|
||||
|
@ -19,3 +12,5 @@ if not defined RELEASE_COOKIE (
|
|||
for /f "skip=1" %%X in ('wmic os get localdatetime') do if not defined TIMESTAMP set TIMESTAMP=%%X
|
||||
set RELEASE_COOKIE=cookie-!TIMESTAMP:~0,11!-!RANDOM!
|
||||
)
|
||||
|
||||
cd !HOMEDRIVE!!HOMEPATH!
|
||||
|
|
|
@ -18,10 +18,6 @@ if [ -f "${RELEASE_ROOT}/user/env.sh" ]; then
|
|||
. "${RELEASE_ROOT}/user/env.sh"
|
||||
fi
|
||||
|
||||
if [ "$LIVEBOOK_EPMDLESS" = "true" ] || [ "$LIVEBOOK_EPMDLESS" = "1" ]; then
|
||||
export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS} -epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0"
|
||||
fi
|
||||
|
||||
export RELEASE_MODE="interactive"
|
||||
export RELEASE_DISTRIBUTION="none"
|
||||
|
||||
|
@ -39,3 +35,5 @@ if [ ! -z "${LIVEBOOK_COOKIE}" ]; then export RELEASE_COOKIE=${LIVEBOOK_COOKIE};
|
|||
# a fixed value. Note that this value is overriden on boot, so other
|
||||
# than being the initial node cookie, we don't really use it.
|
||||
export RELEASE_COOKIE="${RELEASE_COOKIE:-$(cat /dev/urandom | env LC_ALL=C tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)}"
|
||||
|
||||
cd $HOME
|
||||
|
|
|
@ -3,9 +3,23 @@ set -e
|
|||
|
||||
cd -P -- "$(dirname -- "$0")"
|
||||
|
||||
# Livebook does not start EPMD automatically, but we want to start it
|
||||
# here, becasue we need it for clustering
|
||||
epmd -daemon
|
||||
|
||||
if [ -n "${FLAME_PARENT}" ]; then
|
||||
epmd -daemon
|
||||
elixir ./start_flame.exs
|
||||
exec elixir ./start_flame.exs
|
||||
elif [ -n "${LIVEBOOK_RUNTIME}" ]; then
|
||||
# Note: keep the flags in sync with the standalone runtime
|
||||
erl_flags="+sbwt none +sbwtdcpu none +sbwtdio none +sssdio 128 -elixir ansi_enabled true -noinput"
|
||||
|
||||
# We add Livebook modules to the path, so that they are loaded from
|
||||
# from disk, rather than having module binaries sent from the parent
|
||||
# node. This cuts down the initialization time.
|
||||
livebook_beams="$(dirname -- "$(pwd)")/lib/livebook_runtime_ebin"
|
||||
erl_flags="$erl_flags -pa $livebook_beams"
|
||||
|
||||
exec elixir --erl "$erl_flags" ./start_runtime.exs
|
||||
else
|
||||
exec ./livebook start
|
||||
fi
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
File.cd!(System.fetch_env!("HOME"))
|
||||
|
||||
flame_parent = System.fetch_env!("FLAME_PARENT") |> Base.decode64!() |> :erlang.binary_to_term()
|
||||
|
||||
%{
|
||||
|
|
42
rel/server/overlays/bin/start_runtime.exs
Normal file
42
rel/server/overlays/bin/start_runtime.exs
Normal file
|
@ -0,0 +1,42 @@
|
|||
File.cd!(System.fetch_env!("HOME"))
|
||||
|
||||
%{
|
||||
node_base: node_base,
|
||||
cookie: cookie,
|
||||
dist_port: dist_port
|
||||
} = System.fetch_env!("LIVEBOOK_RUNTIME") |> Base.decode64!() |> :erlang.binary_to_term()
|
||||
|
||||
# This is the only Fly-specific part of starting Livebook as runtime
|
||||
app = System.fetch_env!("FLY_APP_NAME")
|
||||
machine_id = System.fetch_env!("FLY_MACHINE_ID")
|
||||
node = :"#{node_base}@#{machine_id}.vm.#{app}.internal"
|
||||
|
||||
# We persist the information before the node is reachable
|
||||
:persistent_term.put(:livebook_runtime_info, %{
|
||||
pid: self(),
|
||||
elixir_version: System.version()
|
||||
})
|
||||
|
||||
Application.put_env(:kernel, :inet_dist_listen_min, dist_port)
|
||||
Application.put_env(:kernel, :inet_dist_listen_max, dist_port)
|
||||
|
||||
{:ok, _} = :net_kernel.start(node, %{name_domain: :longnames, hidden: true})
|
||||
Node.set_cookie(cookie)
|
||||
|
||||
IO.puts("Runtime node started, waiting for the parent finish initialization")
|
||||
|
||||
receive do
|
||||
:node_initialized ->
|
||||
manager_ref = Process.monitor(Livebook.Runtime.ErlDist.NodeManager)
|
||||
|
||||
receive do
|
||||
{:DOWN, ^manager_ref, :process, _object, _reason} -> :ok
|
||||
end
|
||||
|
||||
IO.puts("The owner disconnected from the runtime, shutting down")
|
||||
after
|
||||
20_000 ->
|
||||
IO.puts(:stderr, "No node initialization within 20s, shutting down")
|
||||
end
|
||||
|
||||
System.halt()
|
|
@ -1,3 +1,4 @@
|
|||
# Disable busy waiting so that we don't waste resources
|
||||
# Limit the maximal number of ports for the same reason
|
||||
+sbwt none +sbwtdcpu none +sbwtdio none +Q 65536
|
||||
# Set the custom EPMD module
|
||||
+sbwt none +sbwtdcpu none +sbwtdio none +Q 65536 -epmd_module Elixir.Livebook.EPMD
|
||||
|
|
|
@ -1,18 +1,7 @@
|
|||
defmodule Livebook.EPMDTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
describe "with epmd" do
|
||||
@describetag :with_epmd
|
||||
test "has a random dist port" do
|
||||
assert Livebook.EPMD.dist_port() == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "without epmd" do
|
||||
@describetag :without_epmd
|
||||
|
||||
test "has a custom dist port" do
|
||||
assert Livebook.EPMD.dist_port() != 0
|
||||
end
|
||||
test "has a custom dist port" do
|
||||
assert Livebook.EPMD.dist_port() != 0
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,9 +7,9 @@ defmodule Livebook.Hubs.DockerfileTest do
|
|||
alias Livebook.Hubs
|
||||
alias Livebook.Secrets.Secret
|
||||
|
||||
@docker_tag if Livebook.Config.app_version() =~ "-dev",
|
||||
do: "latest",
|
||||
else: Livebook.Config.app_version()
|
||||
@versions if Livebook.Config.app_version() =~ "-dev",
|
||||
do: %{base: "edge", cuda: "latest"},
|
||||
else: %{base: Livebook.Config.app_version(), cuda: Livebook.Config.app_version()}
|
||||
|
||||
describe "airgapped_dockerfile/7" do
|
||||
test "deploying a single notebook in personal hub" do
|
||||
|
@ -20,7 +20,7 @@ defmodule Livebook.Hubs.DockerfileTest do
|
|||
dockerfile = Dockerfile.airgapped_dockerfile(config, hub, [], [], file, [], %{})
|
||||
|
||||
assert dockerfile == """
|
||||
FROM ghcr.io/livebook-dev/livebook:#{@docker_tag}
|
||||
FROM ghcr.io/livebook-dev/livebook:#{@versions.base}
|
||||
|
||||
# Apps configuration
|
||||
ENV LIVEBOOK_APPS_PATH "/apps"
|
||||
|
@ -97,7 +97,7 @@ defmodule Livebook.Hubs.DockerfileTest do
|
|||
dockerfile = Dockerfile.airgapped_dockerfile(config, hub, [], [], file, [], %{})
|
||||
|
||||
assert dockerfile == """
|
||||
FROM ghcr.io/livebook-dev/livebook:#{@docker_tag}
|
||||
FROM ghcr.io/livebook-dev/livebook:#{@versions.base}
|
||||
|
||||
ARG TEAMS_KEY="lb_tk_fn0pL3YLWzPoPFWuHeV3kd0o7_SFuIOoU4C_k6OWDYg"
|
||||
|
||||
|
@ -166,14 +166,14 @@ defmodule Livebook.Hubs.DockerfileTest do
|
|||
end
|
||||
|
||||
test "deploying with different base image" do
|
||||
config = dockerfile_config(%{docker_tag: "#{@docker_tag}-cuda11.8"})
|
||||
config = dockerfile_config(%{docker_tag: "#{@versions.cuda}-cuda11.8"})
|
||||
hub = personal_hub()
|
||||
file = Livebook.FileSystem.File.local(p("/notebook.livemd"))
|
||||
|
||||
dockerfile = Dockerfile.airgapped_dockerfile(config, hub, [], [], file, [], %{})
|
||||
|
||||
assert dockerfile =~ """
|
||||
FROM ghcr.io/livebook-dev/livebook:#{@docker_tag}-cuda11.8
|
||||
FROM ghcr.io/livebook-dev/livebook:#{@versions.cuda}-cuda11.8
|
||||
|
||||
ENV XLA_TARGET "cuda118"
|
||||
"""
|
||||
|
@ -247,13 +247,13 @@ defmodule Livebook.Hubs.DockerfileTest do
|
|||
end
|
||||
|
||||
test "deploying with different base image" do
|
||||
config = dockerfile_config(%{docker_tag: "#{@docker_tag}-cuda11.8"})
|
||||
config = dockerfile_config(%{docker_tag: "#{@versions.cuda}-cuda11.8"})
|
||||
hub = team_hub()
|
||||
agent_key = Livebook.Factory.build(:agent_key)
|
||||
|
||||
%{image: image, env: env} = Dockerfile.online_docker_info(config, hub, agent_key)
|
||||
|
||||
assert image == "ghcr.io/livebook-dev/livebook:#{@docker_tag}-cuda11.8"
|
||||
assert image == "ghcr.io/livebook-dev/livebook:#{@versions.cuda}-cuda11.8"
|
||||
assert {"XLA_TARGET", "cuda118"} in env
|
||||
end
|
||||
|
||||
|
|
|
@ -1863,7 +1863,8 @@ defmodule Livebook.IntellisenseTest do
|
|||
# in the past we used :peer.start, but it was often failing on CI
|
||||
# (the start was timing out)
|
||||
|
||||
{:ok, runtime} = Livebook.Runtime.ElixirStandalone.new() |> Livebook.Runtime.connect()
|
||||
pid = Livebook.Runtime.Standalone.new() |> Livebook.Runtime.connect()
|
||||
assert_receive {:runtime_connect_done, ^pid, {:ok, runtime}}
|
||||
|
||||
parent = self()
|
||||
|
||||
|
|
|
@ -6,7 +6,10 @@ defmodule Livebook.Runtime.AttachedTest do
|
|||
describe "Runtime.connect/1" do
|
||||
test "given an invalid node returns an error" do
|
||||
runtime = Runtime.Attached.new(:nonexistent@node)
|
||||
assert {:error, "node :nonexistent@node is unreachable"} = Runtime.connect(runtime)
|
||||
pid = Runtime.connect(runtime)
|
||||
|
||||
assert_receive {:runtime_connect_done, ^pid,
|
||||
{:error, "node :nonexistent@node is unreachable"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,15 +7,16 @@ defmodule Livebook.Runtime.ErlDist.NodeManagerTest do
|
|||
test "terminates when the last runtime server terminates" do
|
||||
# We use a standalone runtime, so that we have an isolated node
|
||||
# with its own node manager
|
||||
assert {:ok, %{node: node, server_pid: server1} = runtime} =
|
||||
Runtime.ElixirStandalone.new() |> Runtime.connect()
|
||||
pid = Runtime.Standalone.new() |> Runtime.connect()
|
||||
assert_receive {:runtime_connect_done, ^pid, {:ok, runtime}}
|
||||
%{node: node, server_pid: server1} = runtime
|
||||
|
||||
Runtime.take_ownership(runtime)
|
||||
|
||||
manager_pid = :erpc.call(node, Process, :whereis, [Livebook.Runtime.ErlDist.NodeManager])
|
||||
ref = Process.monitor(manager_pid)
|
||||
|
||||
server2 = NodeManager.start_runtime_server(node)
|
||||
{:ok, server2} = NodeManager.start_runtime_server(node)
|
||||
|
||||
RuntimeServer.stop(server1)
|
||||
RuntimeServer.stop(server2)
|
||||
|
|
|
@ -4,7 +4,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServerTest do
|
|||
alias Livebook.Runtime.ErlDist.{NodeManager, RuntimeServer}
|
||||
|
||||
setup ctx do
|
||||
runtime_server_pid = NodeManager.start_runtime_server(node(), ctx[:opts] || [])
|
||||
{:ok, runtime_server_pid} = NodeManager.start_runtime_server(node(), ctx[:opts] || [])
|
||||
RuntimeServer.attach(runtime_server_pid, self())
|
||||
{:ok, %{pid: runtime_server_pid}}
|
||||
end
|
||||
|
@ -24,7 +24,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServerTest do
|
|||
end
|
||||
end)
|
||||
|
||||
pid = NodeManager.start_runtime_server(node())
|
||||
{:ok, pid} = NodeManager.start_runtime_server(node())
|
||||
RuntimeServer.attach(pid, owner)
|
||||
|
||||
# Make sure the node is running.
|
||||
|
|
93
test/livebook/runtime/fly_test.exs
Normal file
93
test/livebook/runtime/fly_test.exs
Normal file
|
@ -0,0 +1,93 @@
|
|||
defmodule Livebook.Runtime.FlyTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
# To run these tests create a Fly app, generate deployment token,
|
||||
# then set TEST_FLY_APP_NAME and TEST_FLY_API_TOKEN
|
||||
@moduletag :fly
|
||||
|
||||
alias Livebook.Runtime
|
||||
|
||||
@assert_receive_timeout 10_000
|
||||
|
||||
setup do
|
||||
Livebook.FlyAPI.passthrough()
|
||||
:ok
|
||||
end
|
||||
|
||||
test "connecting flow" do
|
||||
fly = fly!()
|
||||
config = config(%{token: fly.token, app_name: fly.app_name})
|
||||
|
||||
assert [] = fly_run(fly, ~w(machine list))
|
||||
|
||||
pid = Runtime.Fly.new(config) |> Runtime.connect()
|
||||
|
||||
Req.Test.allow(Livebook.FlyAPI, self(), pid)
|
||||
|
||||
assert_receive {:runtime_connect_info, ^pid, "create machine"}, @assert_receive_timeout
|
||||
assert_receive {:runtime_connect_info, ^pid, "start proxy"}, @assert_receive_timeout
|
||||
assert_receive {:runtime_connect_info, ^pid, "connect to node"}, @assert_receive_timeout
|
||||
assert_receive {:runtime_connect_info, ^pid, "initialize node"}, @assert_receive_timeout
|
||||
assert_receive {:runtime_connect_done, ^pid, {:ok, runtime}}, @assert_receive_timeout
|
||||
|
||||
Runtime.take_ownership(runtime)
|
||||
|
||||
assert [_] = fly_run(fly, ~w(machine list))
|
||||
|
||||
# Verify that we can actually evaluate code on the Fly machine
|
||||
Runtime.evaluate_code(runtime, :elixir, ~s/System.fetch_env!("FLY_APP_NAME")/, {:c1, :e1}, [])
|
||||
assert_receive {:runtime_evaluation_response, :e1, %{type: :terminal_text, text: text}, _meta}
|
||||
assert text =~ fly.app_name
|
||||
|
||||
Runtime.disconnect(runtime)
|
||||
|
||||
# The machine should be automatically destroyed. Blocking in tests
|
||||
# is bad, but this test suit is inherently time-consuming and it
|
||||
# is opt-in anyway, so it is fine in this case.
|
||||
Process.sleep(2000)
|
||||
|
||||
assert [] = fly_run(fly, ~w(machine list))
|
||||
end
|
||||
|
||||
test "connecting fails with invalid token" do
|
||||
fly = fly!()
|
||||
config = config(%{token: "invalid", app_name: fly.app_name})
|
||||
|
||||
pid = Runtime.Fly.new(config) |> Runtime.connect()
|
||||
|
||||
Req.Test.allow(Livebook.FlyAPI, self(), pid)
|
||||
|
||||
assert_receive {:runtime_connect_done, ^pid, {:error, error}}, @assert_receive_timeout
|
||||
assert error == "could not create machine, reason: authenticate: token validation error"
|
||||
end
|
||||
|
||||
defp config(attrs) do
|
||||
defaults = %{
|
||||
token: nil,
|
||||
app_name: nil,
|
||||
region: "fra",
|
||||
cpu_kind: "shared",
|
||||
cpus: 1,
|
||||
memory_gb: 1,
|
||||
gpu_kind: nil,
|
||||
gpus: nil,
|
||||
volume_id: nil,
|
||||
docker_tag: "edge"
|
||||
}
|
||||
|
||||
Map.merge(defaults, attrs)
|
||||
end
|
||||
|
||||
defp fly_run(fly, args) do
|
||||
{output, 0} =
|
||||
System.cmd("fly", args ++ ["--app", fly.app_name, "--access-token", fly.token, "--json"])
|
||||
|
||||
Jason.decode!(output)
|
||||
end
|
||||
|
||||
defp fly!() do
|
||||
token = System.fetch_env!("TEST_FLY_API_TOKEN")
|
||||
app_name = System.fetch_env!("TEST_FLY_APP_NAME")
|
||||
%{token: token, app_name: app_name}
|
||||
end
|
||||
end
|
|
@ -1,11 +1,13 @@
|
|||
defmodule Livebook.Runtime.ElixirStandaloneTest do
|
||||
defmodule Livebook.Runtime.StandaloneTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.Runtime
|
||||
|
||||
describe "Runtime.connect/1" do
|
||||
test "starts a new Elixir runtime in distribution mode and ties its lifetime to the NodeManager process" do
|
||||
assert {:ok, %{node: node} = runtime} = Runtime.ElixirStandalone.new() |> Runtime.connect()
|
||||
pid = Runtime.Standalone.new() |> Runtime.connect()
|
||||
assert_receive {:runtime_connect_done, ^pid, {:ok, runtime}}
|
||||
%{node: node} = runtime
|
||||
Runtime.take_ownership(runtime)
|
||||
|
||||
# Make sure the node is running.
|
||||
|
@ -21,7 +23,9 @@ defmodule Livebook.Runtime.ElixirStandaloneTest do
|
|||
end
|
||||
|
||||
test "loads necessary modules and starts manager process" do
|
||||
assert {:ok, %{node: node} = runtime} = Runtime.ElixirStandalone.new() |> Runtime.connect()
|
||||
pid = Runtime.Standalone.new() |> Runtime.connect()
|
||||
assert_receive {:runtime_connect_done, ^pid, {:ok, runtime}}
|
||||
%{node: node} = runtime
|
||||
Runtime.take_ownership(runtime)
|
||||
|
||||
assert evaluator_module_loaded?(node)
|
||||
|
@ -30,7 +34,9 @@ defmodule Livebook.Runtime.ElixirStandaloneTest do
|
|||
end
|
||||
|
||||
test "Runtime.disconnect/1 makes the node terminate" do
|
||||
assert {:ok, %{node: node} = runtime} = Runtime.ElixirStandalone.new() |> Runtime.connect()
|
||||
pid = Runtime.Standalone.new() |> Runtime.connect()
|
||||
assert_receive {:runtime_connect_done, ^pid, {:ok, runtime}}
|
||||
%{node: node} = runtime
|
||||
Runtime.take_ownership(runtime)
|
||||
|
||||
# Make sure the node is running.
|
File diff suppressed because it is too large
Load diff
|
@ -3,6 +3,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
import Livebook.HubHelpers
|
||||
import Livebook.AppHelpers
|
||||
import Livebook.SessionHelpers
|
||||
import Livebook.TestHelpers
|
||||
|
||||
alias Livebook.{Session, Text, Runtime, Utils, Notebook, FileSystem, Apps, App}
|
||||
|
@ -217,9 +218,6 @@ defmodule Livebook.SessionTest do
|
|||
test "applies source change to the setup cell to include the given dependencies" do
|
||||
session = start_session()
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
Session.add_dependencies(session.pid, [%{dep: {:jason, "~> 1.3.0"}, config: []}])
|
||||
|
@ -248,9 +246,6 @@ defmodule Livebook.SessionTest do
|
|||
notebook = Notebook.new() |> Notebook.update_cell("setup", &%{&1 | source: "[,]"})
|
||||
session = start_session(notebook: notebook)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
Session.add_dependencies(session.pid, [%{dep: {:jason, "~> 1.3.0"}, config: []}])
|
||||
|
@ -269,7 +264,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.queue_cell_evaluation(session.pid, cell_id)
|
||||
|
||||
assert_receive {:operation, {:queue_cells_evaluation, _client_id, [^cell_id]}}
|
||||
assert_receive {:operation, {:queue_cells_evaluation, _client_id, [^cell_id], []}}
|
||||
|
||||
assert_receive {:operation,
|
||||
{:add_cell_evaluation_response, _, ^cell_id, _,
|
||||
|
@ -392,10 +387,14 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
runtime = Livebook.Runtime.NoopRuntime.new()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
|
||||
Session.connect_runtime(session.pid)
|
||||
|
||||
assert_receive {:operation, {:set_runtime, _client_id, ^runtime}}
|
||||
assert_receive {:operation, {:connect_runtime, _client_id}}
|
||||
assert_receive {:operation, {:runtime_connected, _client_id, _runtime}}
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -405,16 +404,13 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
assert_receive {:operation, {:set_runtime, _client_id, _}}
|
||||
set_noop_runtime(session.pid)
|
||||
|
||||
# Calling twice can happen in a race, make sure it doesn't crash
|
||||
Session.disconnect_runtime(session.pid)
|
||||
Session.disconnect_runtime([session.pid])
|
||||
|
||||
assert_receive {:operation, {:set_runtime, _client_id, runtime}}
|
||||
refute Runtime.connected?(runtime)
|
||||
assert_receive {:operation, {:disconnect_runtime, _client_id}}
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -570,8 +566,9 @@ defmodule Livebook.SessionTest do
|
|||
File.write!(source_path, "content")
|
||||
{:ok, old_file_ref} = Session.register_file(session.pid, source_path, "key")
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
set_noop_runtime(session.pid, self())
|
||||
connect_and_await_runtime(session.pid)
|
||||
send(session.pid, {:runtime_file_path_request, self(), old_file_ref})
|
||||
assert_receive {:runtime_file_path_reply, {:ok, old_path}}
|
||||
|
||||
|
@ -604,8 +601,9 @@ defmodule Livebook.SessionTest do
|
|||
{:ok, file_ref} =
|
||||
Session.register_file(session.pid, source_path, "key", linked_client_id: client_id)
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
set_noop_runtime(session.pid, self())
|
||||
connect_and_await_runtime(session.pid)
|
||||
send(session.pid, {:runtime_file_path_request, self(), file_ref})
|
||||
assert_receive {:runtime_file_path_reply, {:ok, path}}
|
||||
|
||||
|
@ -643,8 +641,9 @@ defmodule Livebook.SessionTest do
|
|||
client_name: "data.txt"
|
||||
})
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
set_noop_runtime(session.pid, self())
|
||||
connect_and_await_runtime(session.pid)
|
||||
send(session.pid, {:runtime_file_path_request, self(), file_ref})
|
||||
assert_receive {:runtime_file_path_reply, {:ok, path}}
|
||||
|
||||
|
@ -800,7 +799,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
# For most tests we use the lightweight embedded runtime,
|
||||
# so that they are cheap to run. Here go several integration
|
||||
# tests that actually start a Elixir standalone runtime (default in production)
|
||||
# tests that actually start a Standalone runtime (default in production)
|
||||
# to verify session integrates well with it properly.
|
||||
|
||||
test "starts a standalone runtime upon first evaluation if there was none set explicitly" do
|
||||
|
@ -819,20 +818,19 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
test "if the runtime node goes down, notifies the subscribers" do
|
||||
session = start_session()
|
||||
{:ok, runtime} = Runtime.ElixirStandalone.new() |> Runtime.connect()
|
||||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
# Wait for the runtime to be set
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
assert_receive {:operation, {:set_runtime, _, ^runtime}}
|
||||
Session.set_runtime(session.pid, Runtime.Standalone.new())
|
||||
Session.connect_runtime(session.pid)
|
||||
assert_receive {:operation, {:runtime_connected, _, runtime}}
|
||||
|
||||
# Terminate the other node, the session should detect that
|
||||
Node.spawn(runtime.node, System, :halt, [])
|
||||
|
||||
assert_receive {:operation, {:set_runtime, _, runtime}}
|
||||
refute Runtime.connected?(runtime)
|
||||
assert_receive {:error, "runtime node terminated unexpectedly - no connection"}
|
||||
assert_receive {:operation, {:runtime_down, _}}
|
||||
assert_receive {:error, "runtime terminated unexpectedly - no connection"}
|
||||
end
|
||||
|
||||
test "on user change sends an update operation subscribers" do
|
||||
|
@ -934,8 +932,7 @@ defmodule Livebook.SessionTest do
|
|||
notebook = %{Notebook.new() | sections: [%{Notebook.Section.new() | cells: [smart_cell]}]}
|
||||
session = start_session(notebook: notebook)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid)
|
||||
|
||||
send(
|
||||
session.pid,
|
||||
|
@ -962,8 +959,9 @@ defmodule Livebook.SessionTest do
|
|||
notebook = %{Notebook.new() | sections: [%{Notebook.Section.new() | cells: [smart_cell]}]}
|
||||
session = start_session(notebook: notebook)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
set_noop_runtime(session.pid)
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
send(
|
||||
session.pid,
|
||||
|
@ -1000,8 +998,9 @@ defmodule Livebook.SessionTest do
|
|||
notebook = %{Notebook.new() | sections: [%{Notebook.Section.new() | cells: [smart_cell]}]}
|
||||
session = start_session(notebook: notebook)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
set_noop_runtime(session.pid)
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
send(
|
||||
session.pid,
|
||||
|
@ -1039,8 +1038,9 @@ defmodule Livebook.SessionTest do
|
|||
notebook = %{Notebook.new() | sections: [%{Notebook.Section.new() | cells: [smart_cell]}]}
|
||||
session = start_session(notebook: notebook)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
set_noop_runtime(session.pid)
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
send(
|
||||
session.pid,
|
||||
|
@ -1048,8 +1048,6 @@ defmodule Livebook.SessionTest do
|
|||
[%{kind: "text", name: "Text", requirement_presets: []}]}
|
||||
)
|
||||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
editor = %{language: nil, placement: :bottom, source: "", intellisense_node: nil}
|
||||
|
||||
send(
|
||||
|
@ -1087,8 +1085,9 @@ defmodule Livebook.SessionTest do
|
|||
notebook = %{Notebook.new() | sections: [%{Notebook.Section.new() | cells: [smart_cell]}]}
|
||||
session = start_session(notebook: notebook)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
set_noop_runtime(session.pid)
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
send(
|
||||
session.pid,
|
||||
|
@ -1096,8 +1095,6 @@ defmodule Livebook.SessionTest do
|
|||
[%{kind: "text", name: "Text", requirement_presets: []}]}
|
||||
)
|
||||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
send(
|
||||
session.pid,
|
||||
{:runtime_smart_cell_started, smart_cell.id,
|
||||
|
@ -1145,8 +1142,10 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
data =
|
||||
data_after_operations!(data, [
|
||||
{:set_runtime, self(), connected_noop_runtime()},
|
||||
{:queue_cells_evaluation, self(), ["c1"]},
|
||||
{:set_runtime, self(), Livebook.Runtime.NoopRuntime.new()},
|
||||
{:connect_runtime, self()},
|
||||
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
|
||||
{:queue_cells_evaluation, self(), ["c1"], []},
|
||||
{:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta},
|
||||
{:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta}
|
||||
])
|
||||
|
@ -1174,8 +1173,10 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
data =
|
||||
data_after_operations!(data, [
|
||||
{:set_runtime, self(), connected_noop_runtime()},
|
||||
{:queue_cells_evaluation, self(), ["c1"]},
|
||||
{:set_runtime, self(), Livebook.Runtime.NoopRuntime.new()},
|
||||
{:connect_runtime, self()},
|
||||
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
|
||||
{:queue_cells_evaluation, self(), ["c1"], []},
|
||||
{:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta},
|
||||
{:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta}
|
||||
])
|
||||
|
@ -1205,8 +1206,10 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
data =
|
||||
data_after_operations!(data, [
|
||||
{:set_runtime, self(), connected_noop_runtime()},
|
||||
{:queue_cells_evaluation, self(), ["c1"]},
|
||||
{:set_runtime, self(), Livebook.Runtime.NoopRuntime.new()},
|
||||
{:connect_runtime, self()},
|
||||
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
|
||||
{:queue_cells_evaluation, self(), ["c1"], []},
|
||||
{:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta},
|
||||
{:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta}
|
||||
])
|
||||
|
@ -1261,8 +1264,8 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
{_section_id, cell_id} = insert_section_and_cell(session.pid)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid)
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
archive_path = Path.expand("../support/assets.tar.gz", __DIR__)
|
||||
hash = "test-" <> Utils.random_id()
|
||||
|
@ -1285,13 +1288,15 @@ defmodule Livebook.SessionTest do
|
|||
test "restores transient state when restarting runtimes" do
|
||||
session = start_session()
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
set_noop_runtime(session.pid, self())
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
transient_state = %{state: "anything"}
|
||||
send(session.pid, {:runtime_transient_state, transient_state})
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
assert_receive {:runtime_trace, :restore_transient_state, [^transient_state]}
|
||||
end
|
||||
|
@ -1419,9 +1424,6 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Process.exit(Process.whereis(test), :shutdown)
|
||||
|
||||
assert_receive {:app_updated,
|
||||
%{pid: ^app_pid, sessions: [%{app_status: %{execution: :error}}]}}
|
||||
|
||||
assert_receive {:app_updated,
|
||||
%{pid: ^app_pid, sessions: [%{app_status: %{execution: :executing}}]}}
|
||||
|
||||
|
@ -1596,8 +1598,7 @@ defmodule Livebook.SessionTest do
|
|||
test "replies with error when file entry does not exist" do
|
||||
session = start_session()
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply,
|
||||
|
@ -1623,8 +1624,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
session = start_session(notebook: notebook)
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "document.pdf"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:error, :forbidden}}
|
||||
|
@ -1640,8 +1640,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.add_file_entries(session.pid, [%{type: :attachment, name: "image.jpg"}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:error, "no file exists at path " <> _}}
|
||||
|
@ -1659,8 +1658,7 @@ defmodule Livebook.SessionTest do
|
|||
:ok = FileSystem.File.write(image_file, "")
|
||||
Session.add_file_entries(session.pid, [%{type: :attachment, name: "image.jpg"}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
path = image_file.path
|
||||
|
@ -1680,8 +1678,7 @@ defmodule Livebook.SessionTest do
|
|||
:ok = FileSystem.File.write(image_file, "content")
|
||||
Session.add_file_entries(session.pid, [%{type: :file, name: "image.jpg", file: image_file}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
path = image_file.path
|
||||
|
@ -1707,8 +1704,7 @@ defmodule Livebook.SessionTest do
|
|||
image_file = FileSystem.File.new(s3_fs, "/image.jpg")
|
||||
Session.add_file_entries(session.pid, [%{type: :file, name: "image.jpg", file: image_file}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:ok, path}}
|
||||
|
@ -1745,8 +1741,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.add_file_entries(session.pid, [%{type: :url, name: "image.jpg", url: url}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:ok, path}}
|
||||
|
@ -1773,8 +1768,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.add_file_entries(session.pid, [%{type: :url, name: "image.jpg", url: url}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:ok, path}}
|
||||
|
@ -1800,8 +1794,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.add_file_entries(session.pid, [%{type: :url, name: "image.jpg", url: url}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:ok, path}}
|
||||
|
@ -1824,8 +1817,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.add_file_entries(session.pid, [%{type: :url, name: "image.jpg", url: url}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:ok, path}}
|
||||
|
@ -1853,8 +1845,7 @@ defmodule Livebook.SessionTest do
|
|||
:ok = FileSystem.File.write(image_file, "")
|
||||
Session.add_file_entries(session.pid, [%{type: :attachment, name: "image2.jpg"}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:ok, path}}
|
||||
|
@ -1877,8 +1868,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
Session.add_file_entries(session.pid, [%{type: :url, name: "image.jpg", url: url}])
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_file_entry_path_request, self(), "image.jpg"})
|
||||
|
||||
assert_receive {:runtime_file_entry_path_reply, {:ok, path}}
|
||||
|
@ -1905,8 +1895,7 @@ defmodule Livebook.SessionTest do
|
|||
test "replies with error when the session does not use teams hub" do
|
||||
session = start_session()
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_user_info_request, self(), "c1"})
|
||||
|
||||
assert_receive {:runtime_user_info_reply, {:error, :not_available}}
|
||||
|
@ -1916,8 +1905,7 @@ defmodule Livebook.SessionTest do
|
|||
notebook = %{Notebook.new() | teams_enabled: true}
|
||||
session = start_session(notebook: notebook)
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_user_info_request, self(), "c1"})
|
||||
|
||||
assert_receive {:runtime_user_info_reply, {:error, :not_found}}
|
||||
|
@ -1936,8 +1924,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
{_, client_id} = Session.register_client(session.pid, self(), user)
|
||||
|
||||
runtime = connected_noop_runtime(self())
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid, self())
|
||||
send(session.pid, {:runtime_user_info_request, self(), client_id})
|
||||
|
||||
assert_receive {:runtime_user_info_reply, {:ok, user_info}}
|
||||
|
@ -1959,8 +1946,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
{_section_id, cell_id} = insert_section_and_cell(session.pid)
|
||||
|
||||
runtime = connected_noop_runtime()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
set_noop_runtime(session.pid)
|
||||
|
||||
user = Livebook.Users.User.new()
|
||||
Session.register_client(session.pid, self(), user)
|
||||
|
@ -2056,15 +2042,8 @@ defmodule Livebook.SessionTest do
|
|||
{section_id, cell_id}
|
||||
end
|
||||
|
||||
defp connected_noop_runtime(trace_to \\ nil) do
|
||||
{:ok, runtime} = Livebook.Runtime.NoopRuntime.new(trace_to) |> Livebook.Runtime.connect()
|
||||
runtime
|
||||
end
|
||||
|
||||
defp wait_for_session_update(session_pid) do
|
||||
# This call is synchronous, so it gives the session time
|
||||
# for handling the previously sent change messages.
|
||||
Session.get_data(session_pid)
|
||||
:ok
|
||||
defp set_noop_runtime(session_pid, trace_to \\ nil) do
|
||||
runtime = Livebook.Runtime.NoopRuntime.new(trace_to)
|
||||
Session.set_runtime(session_pid, runtime)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -438,9 +438,11 @@ defmodule LivebookWeb.SessionControllerTest do
|
|||
|
||||
defp start_session_and_request_asset(conn, notebook, hash) do
|
||||
{:ok, session} = Sessions.create_session(notebook: notebook)
|
||||
|
||||
# We need runtime in place to actually copy the archive
|
||||
{:ok, runtime} = Livebook.Runtime.Embedded.new() |> Livebook.Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
Session.connect_runtime(session.pid)
|
||||
assert_receive {:operation, {:runtime_connected, _, _}}
|
||||
|
||||
conn = get(conn, ~p"/public/sessions/#{session.id}/assets/#{hash}/main.js")
|
||||
|
||||
|
|
|
@ -134,26 +134,12 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
continue_fun.()
|
||||
end
|
||||
|
||||
test "reevaluting the setup cell", %{conn: conn, session: session} do
|
||||
Session.subscribe(session.id)
|
||||
evaluate_setup(session.pid)
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element(~s{[data-el-session]})
|
||||
|> render_hook("queue_cell_evaluation", %{"cell_id" => "setup"})
|
||||
|
||||
assert_receive {:operation, {:set_runtime, _pid, %{} = _runtime}}
|
||||
end
|
||||
|
||||
test "reevaluting the setup cell with dependencies cache disabled",
|
||||
%{conn: conn, session: session} do
|
||||
Session.subscribe(session.id)
|
||||
|
||||
# Start the standalone runtime, to encapsulate env var changes
|
||||
{:ok, runtime} = Runtime.ElixirStandalone.new() |> Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
# Use the standalone runtime, to encapsulate env var changes
|
||||
Session.set_runtime(session.pid, Runtime.Standalone.new())
|
||||
|
||||
evaluate_setup(session.pid)
|
||||
|
||||
|
@ -294,8 +280,9 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
:ok = FileSystem.File.write(image_file, "content")
|
||||
Session.add_file_entries(session.pid, [%{type: :attachment, name: "file.bin"}])
|
||||
|
||||
{:ok, runtime} = Livebook.Runtime.NoopRuntime.new() |> Livebook.Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
Session.set_runtime(session.pid, Livebook.Runtime.NoopRuntime.new(self()))
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
|
@ -340,8 +327,9 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
:ok = FileSystem.File.write(image_file, "content")
|
||||
Session.add_file_entries(session.pid, [%{type: :attachment, name: "image.jpg"}])
|
||||
|
||||
{:ok, runtime} = Livebook.Runtime.NoopRuntime.new() |> Livebook.Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
Session.set_runtime(session.pid, Livebook.Runtime.NoopRuntime.new(self()))
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
|
@ -370,8 +358,9 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
section_id = insert_section(session.pid)
|
||||
cell_id = insert_text_cell(session.pid, section_id, :code)
|
||||
|
||||
{:ok, runtime} = Livebook.Runtime.NoopRuntime.new() |> Livebook.Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
Session.set_runtime(session.pid, Livebook.Runtime.NoopRuntime.new(self()))
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
|
@ -887,8 +876,8 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
%{conn: conn, session: session} do
|
||||
insert_section(session.pid)
|
||||
|
||||
{:ok, runtime} = Runtime.Embedded.new() |> Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
|
@ -907,23 +896,22 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
end
|
||||
|
||||
describe "runtime settings" do
|
||||
test "connecting to elixir standalone updates connect button to reconnect",
|
||||
test "connecting to standalone updates connect button to reconnect",
|
||||
%{conn: conn, session: session} do
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/settings/runtime")
|
||||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
view
|
||||
|> element("button", "Elixir standalone")
|
||||
|> element("#runtime-settings-modal button", "Standalone")
|
||||
|> render_click()
|
||||
|
||||
[elixir_standalone_view] = live_children(view)
|
||||
|
||||
elixir_standalone_view
|
||||
|> element("button", "Connect")
|
||||
view
|
||||
|> element("#runtime-settings-modal button", "Connect")
|
||||
|> render_click()
|
||||
|
||||
assert_receive {:operation, {:set_runtime, _pid, %Runtime.ElixirStandalone{} = runtime}}
|
||||
assert_receive {:operation, {:set_runtime, _pid, %Runtime.Standalone{}}}
|
||||
assert_receive {:operation, {:runtime_connected, _pid, %Runtime.Standalone{} = runtime}}
|
||||
|
||||
page = render(view)
|
||||
assert page =~ Atom.to_string(runtime.node)
|
||||
|
@ -932,13 +920,12 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
end
|
||||
|
||||
test "disconnecting a connected node", %{conn: conn, session: session} do
|
||||
{:ok, runtime} = Livebook.Runtime.NoopRuntime.new(self()) |> Livebook.Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
Session.set_runtime(session.pid, Livebook.Runtime.NoopRuntime.new(self()))
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
assert render(view) =~ "No connected nodes"
|
||||
|
||||
# Mimic the runtime reporting a connected node
|
||||
|
@ -956,6 +943,229 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
|
||||
assert_receive {:runtime_trace, :disconnect_node, [^node]}
|
||||
end
|
||||
|
||||
test "configuring fly runtime", %{conn: conn, session: session} do
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/settings/runtime")
|
||||
|
||||
Session.subscribe(session.id)
|
||||
|
||||
view
|
||||
|> element("#runtime-settings-modal button", "Fly.io machine")
|
||||
|> render_click()
|
||||
|
||||
Livebook.FlyAPI.stub(fn conn when conn.method == "POST" ->
|
||||
Req.Test.json(conn, %{
|
||||
"data" => nil,
|
||||
"errors" => [
|
||||
%{
|
||||
"extensions" => %{"code" => "UNAUTHORIZED"},
|
||||
"locations" => [%{"column" => 3, "line" => 2}],
|
||||
"message" => "You must be authenticated to view this.",
|
||||
"path" => ["organizations"]
|
||||
}
|
||||
]
|
||||
})
|
||||
end)
|
||||
|
||||
view
|
||||
|> element(~s{form[phx-change="set_token"]})
|
||||
|> render_change(%{token: "invalid"})
|
||||
|
||||
assert render_async(view) =~ "Error: could not authorize with the given token"
|
||||
|
||||
Livebook.FlyAPI.stub(fn conn when conn.method == "POST" ->
|
||||
Req.Test.json(conn, %{
|
||||
"data" => %{
|
||||
"organizations" => %{
|
||||
"nodes" => [
|
||||
%{
|
||||
"id" => "1",
|
||||
"name" => "Grumpy Cat",
|
||||
"rawSlug" => "grumpy-cat",
|
||||
"slug" => "personal"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platform" => %{
|
||||
"regions" => [
|
||||
%{"code" => "ams", "name" => "Amsterdam, Netherlands"},
|
||||
%{"code" => "fra", "name" => "Frankfurt, Germany"}
|
||||
],
|
||||
"requestRegion" => "fra"
|
||||
}
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
view
|
||||
|> element(~s{form[phx-change="set_token"]})
|
||||
|> render_change(%{token: "valid"})
|
||||
|
||||
assert render_async(view) =~ "Grumpy Cat"
|
||||
|
||||
# Selects the closest region by default
|
||||
assert view
|
||||
|> element(~s/select[name="region"] option[value="fra"][selected]/)
|
||||
|> has_element?()
|
||||
|
||||
Livebook.FlyAPI.stub(fn conn
|
||||
when conn.method == "GET" and
|
||||
conn.path_info == ["v1", "apps", "new-app", "volumes"] ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(404)
|
||||
|> Req.Test.json(%{"error" => "App not found"})
|
||||
end)
|
||||
|
||||
# Create a new app
|
||||
view
|
||||
|> element(~s{form[phx-change="set_app_name"]})
|
||||
|> render_change(%{app_name: "new-app"})
|
||||
|
||||
assert render_async(view) =~ ~r/App .*new-app.* does not exist yet/
|
||||
|
||||
Livebook.FlyAPI.stub(fn conn
|
||||
when conn.method == "POST" and conn.path_info == ["v1", "apps"] ->
|
||||
Plug.Conn.send_resp(conn, 201, "")
|
||||
end)
|
||||
|
||||
view
|
||||
|> element(~s/button[phx-click="create_app"]/)
|
||||
|> render_click()
|
||||
|
||||
assert render_async(view) =~ "CPU kind"
|
||||
|
||||
# Create a new volume
|
||||
|
||||
Livebook.FlyAPI.stub(fn conn
|
||||
when conn.method == "POST" and
|
||||
conn.path_info == ["v1", "apps", "new-app", "volumes"] ->
|
||||
Req.Test.json(conn, %{
|
||||
"id" => "vol_1",
|
||||
"name" => "new_volume",
|
||||
"region" => "ams",
|
||||
"size_gb" => 1,
|
||||
"state" => "created"
|
||||
})
|
||||
end)
|
||||
|
||||
view
|
||||
|> element(~s/button[phx-click="new_volume"]/)
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element(~s/form[phx-submit="create_volume"]/)
|
||||
|> render_submit(%{volume: %{name: "new_volume", size_gb: "1"}})
|
||||
|
||||
assert render_async(view) =~ "name: new_volume"
|
||||
|
||||
# The volume is automatically selected
|
||||
assert view
|
||||
|> element(~s/select[name="volume_id"] option[value="vol_1"][selected]/)
|
||||
|> has_element?()
|
||||
|
||||
# Delete the volume
|
||||
|
||||
Livebook.FlyAPI.stub(fn conn
|
||||
when conn.method == "DELETE" and
|
||||
conn.path_info == [
|
||||
"v1",
|
||||
"apps",
|
||||
"new-app",
|
||||
"volumes",
|
||||
"vol_1"
|
||||
] ->
|
||||
Req.Test.json(conn, %{})
|
||||
end)
|
||||
|
||||
view
|
||||
|> element(~s/button[phx-click="delete_volume"]/)
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element(~s/button[phx-click="confirm_delete_volume"]/)
|
||||
|> render_click()
|
||||
|
||||
refute render_async(view) =~ "name: new_volume"
|
||||
|
||||
assert view
|
||||
|> element(~s/select[name="volume_id"] option[value=""][selected]/)
|
||||
|> has_element?()
|
||||
|
||||
# We do not actually connect the runtime. We test connecting
|
||||
# againast the real API separately
|
||||
end
|
||||
|
||||
test "populates fly runtime config form existing runtime", %{conn: conn, session: session} do
|
||||
runtime =
|
||||
Runtime.Fly.new(%{
|
||||
token: "my-token",
|
||||
app_name: "my-app",
|
||||
region: "ams",
|
||||
cpu_kind: "performance",
|
||||
cpus: 1,
|
||||
memory_gb: 1,
|
||||
gpu_kind: nil,
|
||||
gpus: nil,
|
||||
volume_id: "vol_1",
|
||||
docker_tag: "edge"
|
||||
})
|
||||
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
|
||||
Livebook.FlyAPI.stub(fn
|
||||
conn when conn.method == "POST" ->
|
||||
Req.Test.json(conn, %{
|
||||
"data" => %{
|
||||
"organizations" => %{
|
||||
"nodes" => [
|
||||
%{
|
||||
"id" => "1",
|
||||
"name" => "Grumpy Cat",
|
||||
"rawSlug" => "grumpy-cat",
|
||||
"slug" => "personal"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platform" => %{
|
||||
"regions" => [
|
||||
%{"code" => "ams", "name" => "Amsterdam, Netherlands"},
|
||||
%{"code" => "fra", "name" => "Frankfurt, Germany"}
|
||||
],
|
||||
"requestRegion" => "fra"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
conn
|
||||
when conn.method == "GET" and
|
||||
conn.path_info == ["v1", "apps", "my-app", "volumes"] ->
|
||||
Req.Test.json(conn, [
|
||||
%{
|
||||
"id" => "vol_1",
|
||||
"name" => "new_volume",
|
||||
"region" => "ams",
|
||||
"size_gb" => 1,
|
||||
"state" => "created"
|
||||
}
|
||||
])
|
||||
end)
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/settings/runtime")
|
||||
|
||||
assert render_async(view) =~ "Grumpy Cat"
|
||||
|
||||
assert view
|
||||
|> element(~s/select[name="region"] option[value="ams"][selected]/)
|
||||
|> has_element?()
|
||||
|
||||
assert view
|
||||
|> element(~s/select[name="volume_id"] option[value="vol_1"][selected]/)
|
||||
|> has_element?()
|
||||
|
||||
assert view
|
||||
|> element(~s/select[name="specs[cpu_kind]"] option[value="performance"][selected]/)
|
||||
|> has_element?()
|
||||
end
|
||||
end
|
||||
|
||||
describe "persistence settings" do
|
||||
|
@ -1057,8 +1267,8 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
section_id = insert_section(session.pid)
|
||||
cell_id = insert_text_cell(session.pid, section_id, :code, "Process.sleep(10)")
|
||||
|
||||
{:ok, runtime} = Runtime.Embedded.new() |> Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
connect_and_await_runtime(session.pid)
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
|
@ -1750,7 +1960,7 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
end
|
||||
|
||||
describe "environment variables" do
|
||||
test "outputs persisted env var from ets", %{conn: conn, session: session} do
|
||||
test "outputs persisted env var from settings", %{conn: conn, session: session} do
|
||||
Session.subscribe(session.id)
|
||||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
|
@ -1802,9 +2012,8 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
@tag :tmp_dir
|
||||
test "outputs persisted PATH delimited with os PATH env var",
|
||||
%{conn: conn, session: session, tmp_dir: tmp_dir} do
|
||||
# Start the standalone runtime, to encapsulate env var changes
|
||||
{:ok, runtime} = Runtime.ElixirStandalone.new() |> Runtime.connect()
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
# Use the standalone runtime, to encapsulate env var changes
|
||||
Session.set_runtime(session.pid, Runtime.Standalone.new())
|
||||
|
||||
separator =
|
||||
case :os.type() do
|
||||
|
|
|
@ -6,7 +6,7 @@ defmodule LivebookWeb.ProxyPlugTest do
|
|||
require Phoenix.LiveViewTest
|
||||
import Livebook.AppHelpers
|
||||
|
||||
alias Livebook.{Notebook, Runtime, Session, Sessions}
|
||||
alias Livebook.{Notebook, Session, Sessions}
|
||||
|
||||
describe "session" do
|
||||
test "returns error when session doesn't exist", %{conn: conn} do
|
||||
|
@ -28,9 +28,7 @@ defmodule LivebookWeb.ProxyPlugTest do
|
|||
test "returns the proxied response defined in notebook", %{conn: conn} do
|
||||
%{sections: [%{cells: [%{id: cell_id}]}]} = notebook = proxy_notebook()
|
||||
{:ok, session} = Sessions.create_session(notebook: notebook)
|
||||
{:ok, runtime} = Runtime.Embedded.new() |> Runtime.connect()
|
||||
|
||||
Session.set_runtime(session.pid, runtime)
|
||||
Session.subscribe(session.id)
|
||||
Session.queue_cell_evaluation(session.pid, cell_id)
|
||||
|
||||
|
|
|
@ -2,10 +2,10 @@ defmodule Livebook.Runtime.NoopRuntime do
|
|||
# A runtime that doesn't do any actual evaluation,
|
||||
# thus not requiring any underlying resources.
|
||||
|
||||
defstruct [:started, :trace_to]
|
||||
defstruct [:trace_to]
|
||||
|
||||
def new(trace_to \\ nil) do
|
||||
%__MODULE__{started: false, trace_to: trace_to}
|
||||
%__MODULE__{trace_to: trace_to}
|
||||
end
|
||||
|
||||
defimpl Livebook.Runtime do
|
||||
|
@ -13,11 +13,17 @@ defmodule Livebook.Runtime.NoopRuntime do
|
|||
[{"Type", "Noop"}]
|
||||
end
|
||||
|
||||
def connect(runtime), do: {:ok, %{runtime | started: true}}
|
||||
def connected?(runtime), do: runtime.started
|
||||
def connect(runtime) do
|
||||
caller = self()
|
||||
|
||||
spawn(fn ->
|
||||
send(caller, {:runtime_connect_done, self(), {:ok, runtime}})
|
||||
end)
|
||||
end
|
||||
|
||||
def take_ownership(_, _), do: make_ref()
|
||||
def disconnect(runtime), do: {:ok, %{runtime | started: false}}
|
||||
def duplicate(_), do: Livebook.Runtime.NoopRuntime.new()
|
||||
def disconnect(_), do: :ok
|
||||
def duplicate(runtime), do: Livebook.Runtime.NoopRuntime.new(runtime.trace_to)
|
||||
|
||||
def evaluate_code(_, _, _, _, _, _ \\ []), do: :ok
|
||||
def forget_evaluation(_, _), do: :ok
|
||||
|
@ -61,8 +67,6 @@ defmodule Livebook.Runtime.NoopRuntime do
|
|||
|
||||
def search_packages(_, _, _), do: make_ref()
|
||||
|
||||
def disable_dependencies_cache(_), do: :ok
|
||||
|
||||
def put_system_envs(_, _), do: :ok
|
||||
def delete_system_envs(_, _), do: :ok
|
||||
|
||||
|
|
|
@ -13,6 +13,11 @@ defmodule Livebook.SessionHelpers do
|
|||
:ok
|
||||
end
|
||||
|
||||
def connect_and_await_runtime(session_pid) do
|
||||
Session.connect_runtime(session_pid)
|
||||
assert_receive {:operation, {:runtime_connected, _, _}}
|
||||
end
|
||||
|
||||
def evaluate_setup(session_pid) do
|
||||
Session.queue_cell_evaluation(session_pid, "setup")
|
||||
assert_receive {:operation, {:add_cell_evaluation_response, _, "setup", _, _}}
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
# Start manager on the current node and configure it not to
|
||||
# terminate automatically, so there is no race condition
|
||||
# when starting/stopping Embedded runtimes in parallel
|
||||
# Start manager on the current node and configure it not to terminate
|
||||
# automatically, so that we can use it to start runtime servers
|
||||
# explicitly
|
||||
Livebook.Runtime.ErlDist.NodeManager.start(
|
||||
auto_termination: false,
|
||||
unload_modules_on_termination: false
|
||||
)
|
||||
|
||||
# Use the embedded runtime in tests by default, so they are
|
||||
# cheaper to run. Other runtimes can be tested by starting
|
||||
# and setting them explicitly
|
||||
# Use the embedded runtime in tests by default, so they are cheaper
|
||||
# to run. Other runtimes can be tested by setting them explicitly
|
||||
Application.put_env(:livebook, :default_runtime, Livebook.Runtime.Embedded.new())
|
||||
Application.put_env(:livebook, :default_app_runtime, Livebook.Runtime.Embedded.new())
|
||||
|
||||
Application.put_env(:livebook, :runtime_modules, [
|
||||
Livebook.Runtime.ElixirStandalone,
|
||||
Livebook.Runtime.Standalone,
|
||||
Livebook.Runtime.Attached,
|
||||
Livebook.Runtime.Embedded
|
||||
Livebook.Runtime.Embedded,
|
||||
Livebook.Runtime.Fly
|
||||
])
|
||||
|
||||
defmodule Livebook.Runtime.Embedded.Packages do
|
||||
|
@ -71,15 +71,9 @@ teams_exclude =
|
|||
[:teams_integration]
|
||||
end
|
||||
|
||||
# ELIXIR_ERL_OPTIONS="-epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0" LIVEBOOK_EPMDLESS=true mix test
|
||||
epmd_exclude =
|
||||
if Livebook.Config.epmdless?() do
|
||||
[:with_epmd, :teams_integration]
|
||||
else
|
||||
[:without_epmd]
|
||||
end
|
||||
fly_exclude = if System.get_env("TEST_FLY_API_TOKEN"), do: [], else: [:fly]
|
||||
|
||||
ExUnit.start(
|
||||
assert_receive_timeout: if(windows?, do: 2_500, else: 1_500),
|
||||
exclude: erl_docs_exclude ++ windows_exclude ++ teams_exclude ++ epmd_exclude
|
||||
exclude: erl_docs_exclude ++ windows_exclude ++ teams_exclude ++ fly_exclude
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue