mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 01:55:56 +08:00
Share code between Fly and K8s runtimes (#2788)
This commit is contained in:
parent
2e45f8aca0
commit
b0ab056499
|
@ -1,6 +1,6 @@
|
|||
defmodule Livebook.K8s.Pod do
|
||||
@main_container_name "livebook-runtime"
|
||||
@home_pvc_volume_name "livebook-home"
|
||||
@pvc_name_volume_name "livebook-home"
|
||||
|
||||
@default_pod_template """
|
||||
apiVersion: v1
|
||||
|
@ -35,15 +35,15 @@ defmodule Livebook.K8s.Pod do
|
|||
|
||||
@doc """
|
||||
Adds "volume" and "volumeMount" configurations to `manifest` in order
|
||||
to mount `home_pvc` under /home/livebook on the pod.
|
||||
to mount `pvc_name` under /home/livebook on the pod.
|
||||
"""
|
||||
@spec set_home_pvc(map(), String.t()) :: map()
|
||||
def set_home_pvc(manifest, home_pvc) do
|
||||
@spec set_pvc_name(map(), String.t()) :: map()
|
||||
def set_pvc_name(manifest, pvc_name) do
|
||||
manifest
|
||||
|> update_in(["spec", Access.key("volumes", [])], fn volumes ->
|
||||
volume = %{
|
||||
"name" => @home_pvc_volume_name,
|
||||
"persistentVolumeClaim" => %{"claimName" => home_pvc}
|
||||
"name" => @pvc_name_volume_name,
|
||||
"persistentVolumeClaim" => %{"claimName" => pvc_name}
|
||||
}
|
||||
|
||||
[volume | volumes]
|
||||
|
@ -51,7 +51,7 @@ defmodule Livebook.K8s.Pod do
|
|||
|> update_in(
|
||||
["spec", "containers", access_main_container(), Access.key("volumeMounts", [])],
|
||||
fn volume_mounts ->
|
||||
[%{"name" => @home_pvc_volume_name, "mountPath" => "/home/livebook"} | volume_mounts]
|
||||
[%{"name" => @pvc_name_volume_name, "mountPath" => "/home/livebook"} | volume_mounts]
|
||||
end
|
||||
)
|
||||
end
|
||||
|
|
|
@ -51,7 +51,7 @@ defmodule Livebook.Runtime.Fly do
|
|||
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
require Logger
|
||||
alias Livebook.Runtime.RemoteUtils
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
config: config(),
|
||||
|
@ -103,18 +103,10 @@ defmodule Livebook.Runtime.Fly do
|
|||
@impl true
|
||||
def handle_continue({:init, runtime, caller}, state) do
|
||||
config = runtime.config
|
||||
local_port = get_free_port!()
|
||||
remote_port = 44444
|
||||
local_port = RemoteUtils.get_free_port!()
|
||||
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()
|
||||
runtime_data = RemoteUtils.encode_runtime_data(node_base)
|
||||
|
||||
parent = self()
|
||||
|
||||
|
@ -140,7 +132,7 @@ defmodule Livebook.Runtime.Fly do
|
|||
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)
|
||||
start_fly_proxy(config.app_name, machine_ip, local_port, config.token)
|
||||
end),
|
||||
:ok <-
|
||||
with_log(caller, "machine starting", fn ->
|
||||
|
@ -148,14 +140,14 @@ defmodule Livebook.Runtime.Fly do
|
|||
end),
|
||||
:ok <-
|
||||
with_log(caller, "connect to node", fn ->
|
||||
connect_loop(child_node, 40, 250)
|
||||
RemoteUtils.connect(child_node)
|
||||
end),
|
||||
{:ok, primary_pid} <- fetch_runtime_info(child_node) do
|
||||
%{pid: primary_pid} <- RemoteUtils.fetch_runtime_info(child_node) do
|
||||
primary_ref = Process.monitor(primary_pid)
|
||||
|
||||
server_pid =
|
||||
with_log(caller, "initialize node", fn ->
|
||||
initialize_node(child_node)
|
||||
RemoteUtils.initialize_node(child_node)
|
||||
end)
|
||||
|
||||
send(primary_pid, :node_initialized)
|
||||
|
@ -274,29 +266,9 @@ defmodule Livebook.Runtime.Fly do
|
|||
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
|
||||
defp start_fly_proxy(app_name, host, local_port, token) do
|
||||
with {:ok, flyctl_path} <- find_fly_executable() do
|
||||
ports = "#{local_port}:#{remote_port}"
|
||||
ports = "#{local_port}:#{RemoteUtils.remote_port()}"
|
||||
|
||||
# We want the proxy to accept the same protocol that we are
|
||||
# going to use for distribution
|
||||
|
@ -380,44 +352,9 @@ defmodule Livebook.Runtime.Fly do
|
|||
Enum.find(paths, fn path -> path && File.regular?(path) 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
|
||||
#
|
||||
# Also, the remote node already has all the runtime modules in
|
||||
# the code path, compiled for its Elixir version, so we don't
|
||||
# need to check for matching Elixir version.
|
||||
|
||||
%{pid: pid} = :erpc.call(child_node, :persistent_term, :get, [:livebook_runtime_info])
|
||||
|
||||
{:ok, pid}
|
||||
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
|
||||
RemoteUtils.with_log("[fly runtime] #{name}", fun)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -8,13 +8,12 @@ defmodule Livebook.Runtime.K8s do
|
|||
|
||||
defstruct [:config, :node, :req, :server_pid, :lv_pid, :pod_name]
|
||||
|
||||
@type config :: %{
|
||||
context: String.t(),
|
||||
namespace: String.t(),
|
||||
home_pvc: String.t() | nil,
|
||||
docker_tag: String.t(),
|
||||
pod_template: String.t()
|
||||
}
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
require Logger
|
||||
|
||||
alias Livebook.Runtime.RemoteUtils
|
||||
alias Livebook.K8s.Pod
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
node: node() | nil,
|
||||
|
@ -24,18 +23,20 @@ defmodule Livebook.Runtime.K8s do
|
|||
pod_name: String.t() | nil
|
||||
}
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
require Logger
|
||||
|
||||
alias Livebook.K8s.Pod
|
||||
@type config :: %{
|
||||
context: String.t(),
|
||||
namespace: String.t(),
|
||||
docker_tag: String.t(),
|
||||
pod_template: String.t(),
|
||||
pvc_name: String.t() | nil
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns a new runtime instance.
|
||||
"""
|
||||
@spec new(config :: map(), req :: Req.Request.t()) :: t()
|
||||
def new(config, req) do
|
||||
%__MODULE__{config: config, req: req, lv_pid: self()}
|
||||
@spec new(map()) :: t()
|
||||
def new(config) do
|
||||
%__MODULE__{config: config, lv_pid: self()}
|
||||
end
|
||||
|
||||
def __connect__(runtime) do
|
||||
|
@ -60,25 +61,26 @@ defmodule Livebook.Runtime.K8s do
|
|||
def handle_continue({:init, runtime, caller}, state) do
|
||||
config = runtime.config
|
||||
%{namespace: namespace, context: context} = config
|
||||
req = runtime.req
|
||||
|
||||
kubeconfig =
|
||||
if System.get_env("KUBERNETES_SERVICE_HOST") do
|
||||
nil
|
||||
within_kubernetes? = System.get_env("KUBERNETES_SERVICE_HOST") != nil
|
||||
|
||||
{node_base, local_port} =
|
||||
if within_kubernetes? do
|
||||
# When already running within Kubernetes we don't need the
|
||||
# proxy, the node is reachable directly
|
||||
{"k8s_runtime", nil}
|
||||
else
|
||||
System.get_env("KUBECONFIG") || Path.join(System.user_home!(), ".kube/config")
|
||||
local_port = RemoteUtils.get_free_port!()
|
||||
{"remote_runtime_#{local_port}", local_port}
|
||||
end
|
||||
|
||||
cluster_data = get_cluster_data(kubeconfig)
|
||||
req =
|
||||
Kubereq.Kubeconfig.Default
|
||||
|> Kubereq.Kubeconfig.load()
|
||||
|> Kubereq.Kubeconfig.set_current_context(context)
|
||||
|> Kubereq.new("api/v1/namespaces/:namespace/pods/:name")
|
||||
|
||||
runtime_data =
|
||||
%{
|
||||
node_base: cluster_data.node_base,
|
||||
cookie: Node.get_cookie(),
|
||||
dist_port: cluster_data.remote_port
|
||||
}
|
||||
|> :erlang.term_to_binary()
|
||||
|> Base.encode64()
|
||||
runtime_data = RemoteUtils.encode_runtime_data(node_base)
|
||||
|
||||
parent = self()
|
||||
|
||||
|
@ -90,28 +92,32 @@ defmodule Livebook.Runtime.K8s do
|
|||
|
||||
with {:ok, pod_name} <-
|
||||
with_log(caller, "create pod", fn ->
|
||||
create_pod(req, config, runtime_data, cluster_data.remote_port)
|
||||
create_pod(req, config, runtime_data)
|
||||
end),
|
||||
_ <- send(watcher_pid, {:pod_created, pod_name}),
|
||||
{:ok, pod_ip} <-
|
||||
with_pod_events(caller, "waiting for pod", req, namespace, pod_name, fn ->
|
||||
await_pod_ready(req, namespace, pod_name)
|
||||
end),
|
||||
child_node <- :"#{cluster_data.node_base}@#{pod_ip}",
|
||||
child_node <- :"#{node_base}@#{pod_ip}",
|
||||
:ok <-
|
||||
(if within_kubernetes? do
|
||||
:ok
|
||||
else
|
||||
with_log(caller, "start proxy", fn ->
|
||||
k8s_forward_port(kubeconfig, context, cluster_data, pod_name, namespace)
|
||||
k8s_forward_port(context, local_port, pod_name, namespace)
|
||||
end)
|
||||
end),
|
||||
:ok <-
|
||||
with_log(caller, "connect to node", fn ->
|
||||
connect_loop(child_node, 40, 250)
|
||||
RemoteUtils.connect(child_node)
|
||||
end),
|
||||
{:ok, primary_pid} <- fetch_runtime_info(child_node) do
|
||||
%{pid: primary_pid} <- RemoteUtils.fetch_runtime_info(child_node) do
|
||||
primary_ref = Process.monitor(primary_pid)
|
||||
|
||||
server_pid =
|
||||
with_log(caller, "initialize node", fn ->
|
||||
initialize_node(child_node)
|
||||
RemoteUtils.initialize_node(child_node)
|
||||
end)
|
||||
|
||||
send(primary_pid, :node_initialized)
|
||||
|
@ -139,30 +145,6 @@ defmodule Livebook.Runtime.K8s do
|
|||
{:noreply, state}
|
||||
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 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("[K8s runtime] #{name} FAILED in #{milliseconds}ms, error: #{error}")
|
||||
|
||||
_ ->
|
||||
Logger.debug("[K8s runtime] #{name} finished in #{milliseconds}ms")
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
defp with_pod_events(caller, name, req, namespace, pod_name, fun) do
|
||||
with_log(caller, name, fn ->
|
||||
runtime_pid = self()
|
||||
|
@ -186,8 +168,8 @@ defmodule Livebook.Runtime.K8s do
|
|||
{:ok, stream} ->
|
||||
Enum.each(stream, fn event ->
|
||||
message = Livebook.Utils.downcase_first(event["object"]["message"])
|
||||
Logger.debug(~s'[K8s runtime] Pod event: "#{message}"')
|
||||
send(caller, {:runtime_connect_info, runtime_pid, message})
|
||||
Logger.debug(~s/[k8s runtime] Pod event: "#{message}"/)
|
||||
end)
|
||||
|
||||
_error ->
|
||||
|
@ -224,11 +206,11 @@ defmodule Livebook.Runtime.K8s do
|
|||
end
|
||||
end
|
||||
|
||||
defp create_pod(req, config, runtime_data, remote_port) do
|
||||
defp create_pod(req, config, runtime_data) do
|
||||
%{
|
||||
pod_template: pod_template,
|
||||
docker_tag: docker_tag,
|
||||
home_pvc: home_pvc,
|
||||
pvc_name: pvc_name,
|
||||
namespace: namespace
|
||||
} = config
|
||||
|
||||
|
@ -254,11 +236,11 @@ defmodule Livebook.Runtime.K8s do
|
|||
])
|
||||
|> Pod.set_docker_tag(docker_tag)
|
||||
|> Pod.set_namespace(namespace)
|
||||
|> Pod.add_container_port(remote_port)
|
||||
|> Pod.add_container_port(RemoteUtils.remote_port())
|
||||
|
||||
manifest =
|
||||
if home_pvc do
|
||||
Pod.set_home_pvc(manifest, home_pvc)
|
||||
if pvc_name do
|
||||
Pod.set_pvc_name(manifest, pvc_name)
|
||||
else
|
||||
manifest
|
||||
end
|
||||
|
@ -275,24 +257,9 @@ defmodule Livebook.Runtime.K8s do
|
|||
end
|
||||
end
|
||||
|
||||
defp get_cluster_data(_kubeconfig = nil) do
|
||||
# When already running within Kubernetes we don't need the proxy,
|
||||
# the node is reachable directly
|
||||
%{node_base: "k8s_runtime", remote_port: 44444}
|
||||
end
|
||||
|
||||
defp get_cluster_data(_kubeconfig) do
|
||||
local_port = get_free_port!()
|
||||
%{node_base: "remote_runtime_#{local_port}", remote_port: 44444, local_port: local_port}
|
||||
end
|
||||
|
||||
defp k8s_forward_port(_kubeconfig = nil, _, _, _, _), do: :ok
|
||||
|
||||
defp k8s_forward_port(kubeconfig, context, cluster_data, pod_name, namespace) do
|
||||
%{local_port: local_port, remote_port: remote_port} = cluster_data
|
||||
|
||||
defp k8s_forward_port(context, local_port, pod_name, namespace) do
|
||||
with {:ok, kubectl_path} <- find_kubectl_executable() do
|
||||
ports = "#{local_port}:#{remote_port}"
|
||||
ports = "#{local_port}:#{RemoteUtils.remote_port()}"
|
||||
|
||||
# We want the proxy to accept the same protocol that we are
|
||||
# going to use for distribution
|
||||
|
@ -303,6 +270,8 @@ defmodule Livebook.Runtime.K8s do
|
|||
"127.0.0.1"
|
||||
end
|
||||
|
||||
kubeconfig = System.get_env("KUBECONFIG") || Path.join(System.user_home!(), ".kube/config")
|
||||
|
||||
args =
|
||||
[
|
||||
"port-forward",
|
||||
|
@ -351,7 +320,7 @@ defmodule Livebook.Runtime.K8s do
|
|||
if path = System.find_executable("kubectl") do
|
||||
{:ok, path}
|
||||
else
|
||||
{:error, "no kubectl executable found in PATH."}
|
||||
{:error, "no kubectl executable found in PATH"}
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -363,7 +332,7 @@ defmodule Livebook.Runtime.K8s do
|
|||
pod_name,
|
||||
fn
|
||||
:deleted ->
|
||||
{:error, "The Pod was deleted before it started running."}
|
||||
{:error, "the Pod was deleted before it started running"}
|
||||
|
||||
pod ->
|
||||
get_in(pod, [
|
||||
|
@ -380,50 +349,19 @@ defmodule Livebook.Runtime.K8s do
|
|||
{:ok, pod["status"]["podIP"]}
|
||||
else
|
||||
{:error, :watch_timeout} ->
|
||||
{:error, "Timed out waiting for Pod to start up."}
|
||||
{:error, "timed out waiting for Pod to start up"}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
|
||||
_other ->
|
||||
{:error, "Failed getting the Pod's IP address."}
|
||||
{:error, "tailed getting the Pod's IP address"}
|
||||
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 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
|
||||
#
|
||||
# Also, the remote node already has all the runtime modules in
|
||||
# the code path, compiled for its Elixir version, so we don't
|
||||
# need to check for matching Elixir version.
|
||||
|
||||
%{pid: pid} = :erpc.call(child_node, :persistent_term, :get, [:livebook_runtime_info])
|
||||
|
||||
{:ok, pid}
|
||||
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)
|
||||
defp with_log(caller, name, fun) do
|
||||
send(caller, {:runtime_connect_info, self(), name})
|
||||
RemoteUtils.with_log("[k8s runtime] #{name}", fun)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -453,7 +391,7 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.K8s do
|
|||
end
|
||||
|
||||
def duplicate(runtime) do
|
||||
Livebook.Runtime.K8s.new(runtime.config, runtime.req)
|
||||
Livebook.Runtime.K8s.new(runtime.config)
|
||||
end
|
||||
|
||||
def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ []) do
|
||||
|
|
113
lib/livebook/runtime/remote_utils.ex
Normal file
113
lib/livebook/runtime/remote_utils.ex
Normal file
|
@ -0,0 +1,113 @@
|
|||
defmodule Livebook.Runtime.RemoteUtils do
|
||||
# Shared code for runtimes using a remote node.
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
The port that the remote runtime node uses for distribution.
|
||||
"""
|
||||
@spec remote_port() :: pos_integer()
|
||||
def remote_port(), do: 44444
|
||||
|
||||
@doc """
|
||||
Encodes information for the remote node.
|
||||
|
||||
The returned value should be passed when starting the remote node
|
||||
via the LIVEBOOK_RUNTIME environment variable.
|
||||
"""
|
||||
@spec encode_runtime_data(String.t()) :: String.t()
|
||||
def encode_runtime_data(node_base) do
|
||||
%{
|
||||
node_base: node_base,
|
||||
cookie: Node.get_cookie(),
|
||||
dist_port: remote_port()
|
||||
}
|
||||
|> :erlang.term_to_binary()
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Discovers a free TCP port.
|
||||
"""
|
||||
@spec get_free_port!() :: pos_integer()
|
||||
def 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
|
||||
|
||||
@doc """
|
||||
Fetches information from the remote runtime node.
|
||||
"""
|
||||
@spec fetch_runtime_info(node()) :: %{pid: pid()}
|
||||
def 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
|
||||
#
|
||||
# Also, the remote node already has all the runtime modules in
|
||||
# the code path, compiled for its Elixir version, so we don't
|
||||
# need to check for matching Elixir version.
|
||||
|
||||
:erpc.call(child_node, :persistent_term, :get, [:livebook_runtime_info])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts connecting to the given node.
|
||||
|
||||
Makes several connect attempts over a few seconds.
|
||||
"""
|
||||
@spec connect(node()) :: :ok | {:error, String.t()}
|
||||
def connect(node) do
|
||||
connect_loop(node, 40, 250)
|
||||
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
|
||||
|
||||
@doc """
|
||||
Starts a runtime server on the remote node.
|
||||
"""
|
||||
@spec initialize_node(node()) :: pid()
|
||||
def 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
|
||||
|
||||
@doc """
|
||||
Wraps a potentially long operation.
|
||||
|
||||
Logs operation duration after completion. On failure, also logs the
|
||||
error.
|
||||
"""
|
||||
@spec with_log(String.t(), (-> term())) :: term()
|
||||
def with_log(name, fun) do
|
||||
{microseconds, result} = :timer.tc(fun)
|
||||
milliseconds = div(microseconds, 1000)
|
||||
|
||||
case result do
|
||||
{:error, error} ->
|
||||
Logger.debug("#{name} FAILED in #{milliseconds}ms, error: #{error}")
|
||||
|
||||
_ ->
|
||||
Logger.debug("#{name} finished in #{milliseconds}ms")
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
|
@ -1017,6 +1017,34 @@ defmodule LivebookWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates keys in a map assign.
|
||||
"""
|
||||
def 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
|
||||
|
||||
@doc """
|
||||
Sends an event to the given target.
|
||||
|
||||
Given:
|
||||
|
||||
* a LV pid, sends the event as a regular message to the process
|
||||
|
||||
* a component `{module, id}` tuple, the event is sent as an update
|
||||
with `:event` assign
|
||||
|
||||
"""
|
||||
def send_event(target, event) when is_pid(target) do
|
||||
send(target, event)
|
||||
end
|
||||
|
||||
def send_event({module, id}, event) when is_atom(module) and is_binary(id) do
|
||||
Phoenix.LiveView.send_update(module, id: id, event: event)
|
||||
end
|
||||
|
||||
# JS commands
|
||||
|
||||
@doc """
|
||||
|
|
|
@ -490,7 +490,7 @@ defmodule LivebookWeb.FileSelectComponent do
|
|||
|
||||
file = FileSystem.File.new(file_system)
|
||||
|
||||
send_event(socket, {:set_file, file, %{exists: true}})
|
||||
send_event(socket.assigns.target, {:set_file, file, %{exists: true}})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
@ -512,7 +512,7 @@ defmodule LivebookWeb.FileSelectComponent do
|
|||
_info -> %{exists: true}
|
||||
end
|
||||
|
||||
send_event(socket, {:set_file, file, info})
|
||||
send_event(socket.assigns.target, {:set_file, file, info})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
@ -759,14 +759,4 @@ defmodule LivebookWeb.FileSelectComponent do
|
|||
new_file = FileSystem.File.resolve(parent_dir, new_name)
|
||||
FileSystem.File.rename(file, new_file)
|
||||
end
|
||||
|
||||
defp send_event(socket, event) do
|
||||
case socket.assigns.target do
|
||||
{module, id} ->
|
||||
send_update(module, id: id, event: event)
|
||||
|
||||
pid when is_pid(pid) ->
|
||||
send(pid, event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,8 +5,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
|
||||
alias Livebook.{Session, Runtime}
|
||||
|
||||
@config_secret_prefix "FLY_RUNTIME_"
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
unless Livebook.Config.runtime_enabled?(Livebook.Runtime.Fly) do
|
||||
|
@ -26,11 +24,26 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
specs_changeset: specs_changeset(),
|
||||
volume_id: nil,
|
||||
volume_action: nil,
|
||||
save_config: nil
|
||||
save_config_payload: nil
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{event: :open_save_config}, socket) do
|
||||
{:ok, assign(socket, save_config_payload: build_config(socket))}
|
||||
end
|
||||
|
||||
def update(%{event: :close_save_config}, socket) do
|
||||
{:ok, assign(socket, save_config_payload: nil)}
|
||||
end
|
||||
|
||||
def update(%{event: {:load_config, config_defaults}}, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(config_defaults: config_defaults)
|
||||
|> load_config_defaults()}
|
||||
end
|
||||
|
||||
def update(assigns, socket) do
|
||||
socket = assign(socket, assigns)
|
||||
|
||||
|
@ -67,11 +80,17 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
The machine is automatically destroyed, once you disconnect the runtime.
|
||||
</p>
|
||||
|
||||
<.save_config_form :if={@save_config} save_config={@save_config} hub={@hub} myself={@myself} />
|
||||
|
||||
<div :if={@save_config == nil}>
|
||||
<.config_actions hub_secrets={@hub_secrets} myself={@myself} />
|
||||
<.live_component
|
||||
module={LivebookWeb.SessionLive.SaveRuntimeConfigComponent}
|
||||
id="save-runtime-config"
|
||||
hub={@hub}
|
||||
hub_secrets={@hub_secrets}
|
||||
target={{__MODULE__, @id}}
|
||||
save_config_payload={@save_config_payload}
|
||||
secret_prefix="FLY_RUNTIME_"
|
||||
/>
|
||||
|
||||
<div :if={@save_config_payload == nil}>
|
||||
<form
|
||||
class="mt-1 flex flex-col gap-4"
|
||||
phx-change="set_token"
|
||||
|
@ -162,99 +181,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp save_config_form(assigns) do
|
||||
~H"""
|
||||
<.form
|
||||
:let={f}
|
||||
for={@save_config.changeset}
|
||||
as={:secret}
|
||||
class="mt-4 flex flex-col"
|
||||
phx-change="validate_save_config"
|
||||
phx-submit="save_config"
|
||||
phx-target={@myself}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<div class="text-lg text-gray-800 font-semibold">
|
||||
Save config
|
||||
</div>
|
||||
<div class="mt-1 text-gray-700">
|
||||
Store the config in a secret in the <.workspace hub={@hub} /> workspace to reuse it later.
|
||||
</div>
|
||||
<div :if={error = @save_config.error} class="mt-4">
|
||||
<.message_box kind={:error} message={error} />
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3">
|
||||
<.text_field field={f[:name]} label="Secret name" class="uppercase" autofocus />
|
||||
</div>
|
||||
<div class="mt-6 flex gap-2">
|
||||
<.button type="submit" disabled={not @save_config.changeset.valid? or @save_config.inflight}>
|
||||
<%= if(@save_config.inflight, do: "Saving...", else: "Save") %>
|
||||
</.button>
|
||||
<.button
|
||||
color="gray"
|
||||
outlined
|
||||
type="button"
|
||||
phx-click="cancel_save_config"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Cancel
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
defp workspace(assigns) do
|
||||
~H"""
|
||||
<span class="font-medium">
|
||||
<span class="text-lg"><%= @hub.hub_emoji %></span>
|
||||
<span><%= @hub.hub_name %></span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp config_actions(assigns) do
|
||||
~H"""
|
||||
<div class="mt-1 flex justify-end gap-1">
|
||||
<.button
|
||||
color="gray"
|
||||
outlined
|
||||
small
|
||||
type="button"
|
||||
phx-click="open_save_config"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Save config
|
||||
</.button>
|
||||
<.menu id="config-secret-menu">
|
||||
<:toggle>
|
||||
<.button color="gray" outlined small type="button">
|
||||
<span>Load config</span>
|
||||
<.remix_icon icon="arrow-down-s-line" class="text-base leading-none" />
|
||||
</.button>
|
||||
</:toggle>
|
||||
<div
|
||||
:if={config_secret_names(@hub_secrets) == []}
|
||||
class="px-3 py-1 whitespace-nowrap text-gray-600 text-sm"
|
||||
>
|
||||
No configs saved yet
|
||||
</div>
|
||||
<.menu_item :for={name <- config_secret_names(@hub_secrets)}>
|
||||
<button
|
||||
class="text-gray-500 text-sm"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
phx-click={JS.push("load_config", value: %{name: name}, target: @myself)}
|
||||
>
|
||||
<%= name %>
|
||||
</button>
|
||||
</.menu_item>
|
||||
</.menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp loader(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center gap-2">
|
||||
|
@ -598,52 +524,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("open_save_config", %{}, socket) do
|
||||
changeset = config_secret_changeset(socket, %{name: @config_secret_prefix})
|
||||
save_config = %{changeset: changeset, inflight: false, error: false}
|
||||
{:noreply, assign(socket, save_config: save_config)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_save_config", %{}, socket) do
|
||||
{:noreply, assign(socket, save_config: nil)}
|
||||
end
|
||||
|
||||
def handle_event("validate_save_config", %{"secret" => secret}, socket) do
|
||||
changeset =
|
||||
socket
|
||||
|> config_secret_changeset(secret)
|
||||
|> Map.replace!(:action, :validate)
|
||||
|
||||
{:noreply, assign_nested(socket, :save_config, changeset: changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save_config", %{"secret" => secret}, socket) do
|
||||
changeset = config_secret_changeset(socket, secret)
|
||||
|
||||
case Ecto.Changeset.apply_action(changeset, :insert) do
|
||||
{:ok, secret} ->
|
||||
{:noreply, save_config_secret(socket, secret, changeset)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign_nested(socket, :save_config, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("load_config", %{"name" => name}, socket) do
|
||||
secret = Enum.find(socket.assigns.hub_secrets, &(&1.name == name))
|
||||
|
||||
case Jason.decode(secret.value) do
|
||||
{:ok, config_defaults} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(config_defaults: config_defaults)
|
||||
|> load_config_defaults()}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_async(:load_org_and_regions, {:ok, result}, socket) do
|
||||
socket =
|
||||
|
@ -734,22 +614,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_async(:save_config, {:ok, result}, socket) do
|
||||
socket =
|
||||
case result do
|
||||
:ok ->
|
||||
assign(socket, save_config: nil)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
assign_nested(socket, :save_config, changeset: changeset, inflight: false)
|
||||
|
||||
{:transport_error, error} ->
|
||||
assign_nested(socket, :save_config, error: error, inflight: false)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp label(app_name, runtime, runtime_status) do
|
||||
reconnecting? = reconnecting?(app_name, runtime)
|
||||
|
||||
|
@ -785,15 +649,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
}
|
||||
end
|
||||
|
||||
defp config_secret_names(hub_secrets) do
|
||||
names =
|
||||
for %{name: name} <- hub_secrets,
|
||||
String.starts_with?(name, @config_secret_prefix),
|
||||
do: name
|
||||
|
||||
Enum.sort(names)
|
||||
end
|
||||
|
||||
defp load_config_defaults(socket) do
|
||||
config_defaults = socket.assigns.config_defaults
|
||||
|
||||
|
@ -854,18 +709,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
|> validate_required([:name, :size_gb])
|
||||
end
|
||||
|
||||
defp config_secret_changeset(socket, attrs) do
|
||||
hub = socket.assigns.hub
|
||||
value = socket |> build_config() |> Jason.encode!()
|
||||
secret = %Livebook.Secrets.Secret{hub_id: hub.id, name: nil, value: value}
|
||||
|
||||
secret
|
||||
|> Livebook.Secrets.change_secret(attrs)
|
||||
|> validate_format(:name, ~r/^#{@config_secret_prefix}\w+$/,
|
||||
message: "must be in the format #{@config_secret_prefix}*"
|
||||
)
|
||||
end
|
||||
|
||||
defp volume_errors(nil, _volumes, _region), do: []
|
||||
|
||||
defp volume_errors(volume_id, volumes, region) do
|
||||
|
@ -947,35 +790,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
|||
|> assign_nested(:volume_action, inflight: true)
|
||||
end
|
||||
|
||||
defp save_config_secret(socket, secret, changeset) do
|
||||
hub = socket.assigns.hub
|
||||
exists? = Enum.any?(socket.assigns.hub_secrets, &(&1.name == secret.name))
|
||||
|
||||
socket
|
||||
|> start_async(:save_config, fn ->
|
||||
result =
|
||||
if exists? do
|
||||
Livebook.Hubs.update_secret(hub, secret)
|
||||
else
|
||||
Livebook.Hubs.create_secret(hub, secret)
|
||||
end
|
||||
|
||||
with {:error, errors} <- result do
|
||||
{:error,
|
||||
changeset
|
||||
|> Livebook.Utils.put_changeset_errors(errors)
|
||||
|> Map.replace!(:action, :validate)}
|
||||
end
|
||||
end)
|
||||
|> assign_nested(:save_config, 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 build_config(socket) do
|
||||
specs = apply_changes(socket.assigns.specs_changeset)
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
alias Livebook.{Session, Runtime}
|
||||
alias Livebook.K8s.{Auth, Pod, PVC}
|
||||
|
||||
@config_secret_prefix "K8S_RUNTIME_"
|
||||
@kubeconfig_pipeline Application.compile_env(:livebook, :k8s_kubeconfig_pipeline)
|
||||
|
||||
@impl true
|
||||
|
@ -29,15 +28,31 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
namespace: nil,
|
||||
namespace_options: nil,
|
||||
rbac: %{status: :inflight, errors: [], permissions: []},
|
||||
save_config: nil,
|
||||
pvcs: nil,
|
||||
pvc_action: nil,
|
||||
home_pvc: nil,
|
||||
pvc_name: nil,
|
||||
docker_tag: hd(Livebook.Config.docker_images()).tag,
|
||||
pod_template: %{template: Pod.default_pod_template(), status: :valid, message: nil}
|
||||
pod_template: %{template: Pod.default_pod_template(), status: :valid, message: nil},
|
||||
save_config_payload: nil
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{event: :open_save_config}, socket) do
|
||||
{:ok, assign(socket, save_config_payload: build_config(socket))}
|
||||
end
|
||||
|
||||
def update(%{event: :close_save_config}, socket) do
|
||||
{:ok, assign(socket, save_config_payload: nil)}
|
||||
end
|
||||
|
||||
def update(%{event: {:load_config, config_defaults}}, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(config_defaults: config_defaults)
|
||||
|> load_config_defaults()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@spec update(maybe_improper_list() | map(), any()) :: {:ok, any()}
|
||||
def update(assigns, socket) do
|
||||
|
@ -78,11 +93,17 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
The Pod is automatically deleted, once you disconnect the runtime.
|
||||
</p>
|
||||
|
||||
<.save_config_form :if={@save_config} save_config={@save_config} hub={@hub} myself={@myself} />
|
||||
|
||||
<div :if={@save_config == nil}>
|
||||
<.config_actions hub_secrets={@hub_secrets} myself={@myself} />
|
||||
<.live_component
|
||||
module={LivebookWeb.SessionLive.SaveRuntimeConfigComponent}
|
||||
id="save-runtime-config"
|
||||
hub={@hub}
|
||||
hub_secrets={@hub_secrets}
|
||||
target={{__MODULE__, @id}}
|
||||
save_config_payload={@save_config_payload}
|
||||
secret_prefix="K8S_RUNTIME_"
|
||||
/>
|
||||
|
||||
<div :if={@save_config_payload == nil}>
|
||||
<.message_box :if={@kubeconfig.current_cluster == nil} kind={:error}>
|
||||
In order to use the Kubernetes context, you need to set the <code>KUBECONFIG</code>
|
||||
environment variable to a path pointing to a <a
|
||||
|
@ -183,7 +204,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
<.storage_config
|
||||
:if={@rbac.status == :ok}
|
||||
myself={@myself}
|
||||
home_pvc={@home_pvc}
|
||||
pvc_name={@pvc_name}
|
||||
pvcs={@pvcs}
|
||||
pvc_action={@pvc_action}
|
||||
rbac={@rbac}
|
||||
|
@ -239,16 +260,16 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
|
||||
<div class="mt-4 flex flex-col">
|
||||
<div class="flex items-start gap-1">
|
||||
<form phx-change="set_home_pvc" phx-target={@myself} class="grow">
|
||||
<form phx-change="set_pvc_name" phx-target={@myself} class="grow">
|
||||
<.select_field
|
||||
:if={@rbac.permissions.list_pvc}
|
||||
value={@home_pvc}
|
||||
name="home_pvc"
|
||||
value={@pvc_name}
|
||||
name="pvc_name"
|
||||
label="Persistent Volume Claim"
|
||||
options={[{"None", nil} | @pvcs]}
|
||||
/>
|
||||
<div :if={!@rbac.permissions.list_pvc}>
|
||||
<.text_field value={@home_pvc} name="home_pvc" label="Persistent Volume Claim" />
|
||||
<.text_field value={@pvc_name} name="pvc_name" label="Persistent Volume Claim" />
|
||||
<div class="text-sm text-amber-600">
|
||||
Authenticated user has no permission to list PVCs. But you can enter a name of an existing PVC to be attached.
|
||||
</div>
|
||||
|
@ -264,7 +285,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
<.icon_button
|
||||
phx-click="delete_pvc"
|
||||
phx-target={@myself}
|
||||
disabled={@home_pvc == nil or @pvc_action != nil}
|
||||
disabled={@pvc_name == nil or @pvc_action != nil}
|
||||
>
|
||||
<.remix_icon icon="delete-bin-6-line" />
|
||||
</.icon_button>
|
||||
|
@ -285,7 +306,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
class="px-4 py-3 mt-4 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 Persistent Volume Claim <span class="font-semibold"><%= @home_pvc %></span>?
|
||||
Are you sure you want to irreversibly delete Persistent Volume Claim <span class="font-semibold"><%= @pvc_name %></span>?
|
||||
</p>
|
||||
<div class="flex space-x-4">
|
||||
<button
|
||||
|
@ -357,99 +378,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp save_config_form(assigns) do
|
||||
~H"""
|
||||
<.form
|
||||
:let={f}
|
||||
for={@save_config.changeset}
|
||||
as={:secret}
|
||||
class="mt-4 flex flex-col"
|
||||
phx-change="validate_save_config"
|
||||
phx-submit="save_config"
|
||||
phx-target={@myself}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<div class="text-lg text-gray-800 font-semibold">
|
||||
Save config
|
||||
</div>
|
||||
<div class="mt-1 text-gray-700">
|
||||
Store the config in a secret in the <.workspace hub={@hub} /> workspace to reuse it later.
|
||||
</div>
|
||||
<div :if={error = @save_config.error} class="mt-4">
|
||||
<.message_box kind={:error} message={error} />
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3">
|
||||
<.text_field field={f[:name]} label="Secret name" class="uppercase" autofocus />
|
||||
</div>
|
||||
<div class="mt-6 flex gap-2">
|
||||
<.button type="submit" disabled={not @save_config.changeset.valid? or @save_config.inflight}>
|
||||
<%= if(@save_config.inflight, do: "Saving...", else: "Save") %>
|
||||
</.button>
|
||||
<.button
|
||||
color="gray"
|
||||
outlined
|
||||
type="button"
|
||||
phx-click="cancel_save_config"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Cancel
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
defp workspace(assigns) do
|
||||
~H"""
|
||||
<span class="font-medium">
|
||||
<span class="text-lg"><%= @hub.hub_emoji %></span>
|
||||
<span><%= @hub.hub_name %></span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp config_actions(assigns) do
|
||||
~H"""
|
||||
<div class="mt-1 flex justify-end gap-1">
|
||||
<.button
|
||||
color="gray"
|
||||
outlined
|
||||
small
|
||||
type="button"
|
||||
phx-click="open_save_config"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Save config
|
||||
</.button>
|
||||
<.menu id="config-secret-menu">
|
||||
<:toggle>
|
||||
<.button color="gray" outlined small type="button">
|
||||
<span>Load config</span>
|
||||
<.remix_icon icon="arrow-down-s-line" class="text-base leading-none" />
|
||||
</.button>
|
||||
</:toggle>
|
||||
<div
|
||||
:if={config_secret_names(@hub_secrets) == []}
|
||||
class="px-3 py-1 whitespace-nowrap text-gray-600 text-sm"
|
||||
>
|
||||
No configs saved yet
|
||||
</div>
|
||||
<.menu_item :for={name <- config_secret_names(@hub_secrets)}>
|
||||
<button
|
||||
class="text-gray-500 text-sm"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
phx-click={JS.push("load_config", value: %{name: name}, target: @myself)}
|
||||
>
|
||||
<%= name %>
|
||||
</button>
|
||||
</.menu_item>
|
||||
</.menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp loader(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center gap-2">
|
||||
|
@ -530,13 +458,8 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
{:noreply, set_pod_template(socket, pod_template)}
|
||||
end
|
||||
|
||||
def handle_event("set_home_pvc", %{"home_pvc" => home_pvc}, socket) do
|
||||
{:noreply, assign(socket, :home_pvc, home_pvc)}
|
||||
end
|
||||
|
||||
def handle_event("disconnect", %{}, socket) do
|
||||
Session.disconnect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
def handle_event("set_pvc_name", %{"pvc_name" => pvc_name}, socket) do
|
||||
{:noreply, assign(socket, :pvc_name, pvc_name)}
|
||||
end
|
||||
|
||||
def handle_event("new_pvc", %{}, socket) do
|
||||
|
@ -583,7 +506,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
end
|
||||
|
||||
def handle_event("confirm_delete_pvc", %{}, socket) do
|
||||
%{namespace: namespace, home_pvc: name} = socket.assigns
|
||||
%{namespace: namespace, pvc_name: name} = socket.assigns
|
||||
req = socket.assigns.reqs.pvc
|
||||
|
||||
socket =
|
||||
|
@ -600,57 +523,16 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
|
||||
def handle_event("init", %{}, socket) do
|
||||
config = build_config(socket)
|
||||
runtime = Runtime.K8s.new(config, socket.assigns.reqs.pod)
|
||||
runtime = Runtime.K8s.new(config)
|
||||
Session.set_runtime(socket.assigns.session.pid, runtime)
|
||||
Session.connect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("open_save_config", %{}, socket) do
|
||||
changeset = config_secret_changeset(socket, %{name: @config_secret_prefix})
|
||||
save_config = %{changeset: changeset, inflight: false, error: false}
|
||||
{:noreply, assign(socket, save_config: save_config)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_save_config", %{}, socket) do
|
||||
{:noreply, assign(socket, save_config: nil)}
|
||||
end
|
||||
|
||||
def handle_event("validate_save_config", %{"secret" => secret}, socket) do
|
||||
changeset =
|
||||
socket
|
||||
|> config_secret_changeset(secret)
|
||||
|> Map.replace!(:action, :validate)
|
||||
|
||||
{:noreply, assign_nested(socket, :save_config, changeset: changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save_config", %{"secret" => secret}, socket) do
|
||||
changeset = config_secret_changeset(socket, secret)
|
||||
|
||||
case Ecto.Changeset.apply_action(changeset, :insert) do
|
||||
{:ok, secret} ->
|
||||
{:noreply, save_config_secret(socket, secret, changeset)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign_nested(socket, :save_config, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("load_config", %{"name" => name}, socket) do
|
||||
secret = Enum.find(socket.assigns.hub_secrets, &(&1.name == name))
|
||||
|
||||
case Jason.decode(secret.value) do
|
||||
{:ok, config_defaults} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(config_defaults: config_defaults)
|
||||
|> load_config_defaults()}
|
||||
|
||||
{:error, _} ->
|
||||
def handle_event("disconnect", %{}, socket) do
|
||||
Session.disconnect_runtime(socket.assigns.session.pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_async(:rbac_check, {:ok, %{errors: errors, permissions: permissions}}, socket) do
|
||||
|
@ -684,7 +566,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
case result do
|
||||
{:ok, %{status: 200}} ->
|
||||
socket
|
||||
|> assign(home_pvc: nil, pvc_action: nil)
|
||||
|> assign(pvc_name: nil, pvc_action: nil)
|
||||
|> pvc_options()
|
||||
|
||||
{:ok, %{body: %{"message" => message}}} ->
|
||||
|
@ -699,7 +581,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
case result do
|
||||
{:ok, %{status: 201, body: created_pvc}} ->
|
||||
socket
|
||||
|> assign(home_pvc: created_pvc["metadata"]["name"], pvc_action: nil)
|
||||
|> assign(pvc_name: created_pvc["metadata"]["name"], pvc_action: nil)
|
||||
|> pvc_options()
|
||||
|
||||
{:ok, %{body: body}} ->
|
||||
|
@ -731,22 +613,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_async(:save_config, {:ok, result}, socket) do
|
||||
socket =
|
||||
case result do
|
||||
:ok ->
|
||||
assign(socket, save_config: nil)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
assign_nested(socket, :save_config, changeset: changeset, inflight: false)
|
||||
|
||||
{:transport_error, error} ->
|
||||
assign_nested(socket, :save_config, error: error, inflight: false)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp label(namespace, runtime, runtime_status) do
|
||||
reconnecting? = reconnecting?(namespace, runtime)
|
||||
|
||||
|
@ -780,7 +646,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
access_reviews:
|
||||
Kubereq.new(kubeconfig, "apis/authorization.k8s.io/v1/selfsubjectaccessreviews"),
|
||||
namespaces: Kubereq.new(kubeconfig, "api/v1/namespaces/:name"),
|
||||
pod: Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/pods/:name"),
|
||||
pvc: Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/persistentvolumeclaims/:name"),
|
||||
sc: Kubereq.new(kubeconfig, "apis/storage.k8s.io/v1/storageclasses/:name")
|
||||
}
|
||||
|
@ -917,21 +782,12 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
end
|
||||
end
|
||||
|
||||
defp config_secret_names(hub_secrets) do
|
||||
names =
|
||||
for %{name: name} <- hub_secrets,
|
||||
String.starts_with?(name, @config_secret_prefix),
|
||||
do: name
|
||||
|
||||
Enum.sort(names)
|
||||
end
|
||||
|
||||
defp load_config_defaults(socket) do
|
||||
config_defaults = socket.assigns.config_defaults
|
||||
|
||||
socket
|
||||
|> assign(
|
||||
home_pvc: config_defaults["home_pvc"],
|
||||
pvc_name: config_defaults["pvc_name"],
|
||||
docker_tag: config_defaults["docker_tag"]
|
||||
)
|
||||
|> set_context(config_defaults["context"])
|
||||
|
@ -939,52 +795,11 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
|||
|> set_pod_template(config_defaults["pod_template"])
|
||||
end
|
||||
|
||||
defp config_secret_changeset(socket, attrs) do
|
||||
hub = socket.assigns.hub
|
||||
value = socket |> build_config() |> Jason.encode!()
|
||||
secret = %Livebook.Secrets.Secret{hub_id: hub.id, name: nil, value: value}
|
||||
|
||||
secret
|
||||
|> Livebook.Secrets.change_secret(attrs)
|
||||
|> validate_format(:name, ~r/^#{@config_secret_prefix}\w+$/,
|
||||
message: "must be in the format #{@config_secret_prefix}*"
|
||||
)
|
||||
end
|
||||
|
||||
defp save_config_secret(socket, secret, changeset) do
|
||||
hub = socket.assigns.hub
|
||||
exists? = Enum.any?(socket.assigns.hub_secrets, &(&1.name == secret.name))
|
||||
|
||||
socket
|
||||
|> start_async(:save_config, fn ->
|
||||
result =
|
||||
if exists? do
|
||||
Livebook.Hubs.update_secret(hub, secret)
|
||||
else
|
||||
Livebook.Hubs.create_secret(hub, secret)
|
||||
end
|
||||
|
||||
with {:error, errors} <- result do
|
||||
{:error,
|
||||
changeset
|
||||
|> Livebook.Utils.put_changeset_errors(errors)
|
||||
|> Map.replace!(:action, :validate)}
|
||||
end
|
||||
end)
|
||||
|> assign_nested(:save_config, 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 build_config(socket) do
|
||||
%{
|
||||
context: socket.assigns.context,
|
||||
namespace: socket.assigns.namespace,
|
||||
home_pvc: socket.assigns.home_pvc,
|
||||
pvc_name: socket.assigns.pvc_name,
|
||||
docker_tag: socket.assigns.docker_tag,
|
||||
pod_template: socket.assigns.pod_template.template
|
||||
}
|
||||
|
|
|
@ -0,0 +1,249 @@
|
|||
defmodule LivebookWeb.SessionLive.SaveRuntimeConfigComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, save_config: nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket = assign(socket, assigns)
|
||||
|
||||
socket =
|
||||
case {socket.assigns.save_config_payload, socket.assigns.save_config} do
|
||||
{nil, nil} ->
|
||||
socket
|
||||
|
||||
{_, nil} ->
|
||||
deafult_name = socket.assigns.secret_prefix
|
||||
changeset = config_secret_changeset(socket, %{name: deafult_name})
|
||||
save_config = %{changeset: changeset, inflight: false, error: false}
|
||||
assign(socket, save_config: save_config)
|
||||
|
||||
{nil, _} ->
|
||||
assign(socket, save_config: nil)
|
||||
|
||||
{_, _} ->
|
||||
socket
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<%= if @save_config do %>
|
||||
<.save_config_form save_config={@save_config} hub={@hub} myself={@myself} />
|
||||
<% else %>
|
||||
<.config_actions secret_prefix={@secret_prefix} hub_secrets={@hub_secrets} myself={@myself} />
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp config_actions(assigns) do
|
||||
~H"""
|
||||
<div class="mt-1 flex justify-end gap-1">
|
||||
<.button
|
||||
color="gray"
|
||||
outlined
|
||||
small
|
||||
type="button"
|
||||
phx-click="open_save_config"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Save config
|
||||
</.button>
|
||||
<.menu id="config-secret-menu">
|
||||
<:toggle>
|
||||
<.button color="gray" outlined small type="button">
|
||||
<span>Load config</span>
|
||||
<.remix_icon icon="arrow-down-s-line" class="text-base leading-none" />
|
||||
</.button>
|
||||
</:toggle>
|
||||
<div
|
||||
:if={config_secret_names(@hub_secrets, @secret_prefix) == []}
|
||||
class="px-3 py-1 whitespace-nowrap text-gray-600 text-sm"
|
||||
>
|
||||
No configs saved yet
|
||||
</div>
|
||||
<.menu_item :for={name <- config_secret_names(@hub_secrets, @secret_prefix)}>
|
||||
<button
|
||||
class="text-gray-500 text-sm"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
phx-click={JS.push("load_config", value: %{name: name}, target: @myself)}
|
||||
>
|
||||
<%= name %>
|
||||
</button>
|
||||
</.menu_item>
|
||||
</.menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp save_config_form(assigns) do
|
||||
~H"""
|
||||
<.form
|
||||
:let={f}
|
||||
for={@save_config.changeset}
|
||||
as={:secret}
|
||||
class="mt-4 flex flex-col"
|
||||
phx-change="validate_save_config"
|
||||
phx-submit="save_config"
|
||||
phx-target={@myself}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<div class="text-lg text-gray-800 font-semibold">
|
||||
Save config
|
||||
</div>
|
||||
<div class="mt-1 text-gray-700">
|
||||
Store the config in a secret in the <.workspace hub={@hub} /> workspace to reuse it later.
|
||||
</div>
|
||||
<div :if={error = @save_config.error} class="mt-4">
|
||||
<.message_box kind={:error} message={error} />
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3">
|
||||
<.text_field field={f[:name]} label="Secret name" class="uppercase" autofocus />
|
||||
</div>
|
||||
<div class="mt-6 flex gap-2">
|
||||
<.button type="submit" disabled={not @save_config.changeset.valid? or @save_config.inflight}>
|
||||
<%= if(@save_config.inflight, do: "Saving...", else: "Save") %>
|
||||
</.button>
|
||||
<.button
|
||||
color="gray"
|
||||
outlined
|
||||
type="button"
|
||||
phx-click="cancel_save_config"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Cancel
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
defp workspace(assigns) do
|
||||
~H"""
|
||||
<span class="font-medium">
|
||||
<span class="text-lg"><%= @hub.hub_emoji %></span>
|
||||
<span><%= @hub.hub_name %></span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_save_config", %{}, socket) do
|
||||
send_event(socket.assigns.target, :open_save_config)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("cancel_save_config", %{}, socket) do
|
||||
send_event(socket.assigns.target, :close_save_config)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate_save_config", %{"secret" => secret}, socket) do
|
||||
changeset =
|
||||
socket
|
||||
|> config_secret_changeset(secret)
|
||||
|> Map.replace!(:action, :validate)
|
||||
|
||||
{:noreply, assign_nested(socket, :save_config, changeset: changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save_config", %{"secret" => secret}, socket) do
|
||||
changeset = config_secret_changeset(socket, secret)
|
||||
|
||||
case Ecto.Changeset.apply_action(changeset, :insert) do
|
||||
{:ok, secret} ->
|
||||
{:noreply, save_config_secret(socket, secret, changeset)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign_nested(socket, :save_config, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("load_config", %{"name" => name}, socket) do
|
||||
secret = Enum.find(socket.assigns.hub_secrets, &(&1.name == name))
|
||||
|
||||
case Jason.decode(secret.value) do
|
||||
{:ok, config_defaults} ->
|
||||
send_event(socket.assigns.target, {:load_config, config_defaults})
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_async(:save_config, {:ok, result}, socket) do
|
||||
socket =
|
||||
case result do
|
||||
:ok ->
|
||||
send_event(socket.assigns.target, :close_save_config)
|
||||
assign_nested(socket, :save_config, inflight: false)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
assign_nested(socket, :save_config, changeset: changeset, inflight: false)
|
||||
|
||||
{:transport_error, error} ->
|
||||
assign_nested(socket, :save_config, error: error, inflight: false)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp config_secret_names(hub_secrets, secret_prefix) do
|
||||
names =
|
||||
for %{name: name} <- hub_secrets,
|
||||
String.starts_with?(name, secret_prefix),
|
||||
do: name
|
||||
|
||||
Enum.sort(names)
|
||||
end
|
||||
|
||||
defp config_secret_changeset(socket, attrs) do
|
||||
secret_prefix = socket.assigns.secret_prefix
|
||||
hub = socket.assigns.hub
|
||||
value = Jason.encode!(socket.assigns.save_config_payload)
|
||||
secret = %Livebook.Secrets.Secret{hub_id: hub.id, name: nil, value: value}
|
||||
|
||||
secret
|
||||
|> Livebook.Secrets.change_secret(attrs)
|
||||
|> validate_format(:name, ~r/^#{secret_prefix}\w+$/,
|
||||
message: "must be in the format #{secret_prefix}*"
|
||||
)
|
||||
end
|
||||
|
||||
defp save_config_secret(socket, secret, changeset) do
|
||||
hub = socket.assigns.hub
|
||||
exists? = Enum.any?(socket.assigns.hub_secrets, &(&1.name == secret.name))
|
||||
|
||||
socket
|
||||
|> start_async(:save_config, fn ->
|
||||
result =
|
||||
if exists? do
|
||||
Livebook.Hubs.update_secret(hub, secret)
|
||||
else
|
||||
Livebook.Hubs.create_secret(hub, secret)
|
||||
end
|
||||
|
||||
with {:error, errors} <- result do
|
||||
{:error,
|
||||
changeset
|
||||
|> Livebook.Utils.put_changeset_errors(errors)
|
||||
|> Map.replace!(:action, :validate)}
|
||||
end
|
||||
end)
|
||||
|> assign_nested(:save_config, inflight: true)
|
||||
end
|
||||
end
|
|
@ -1,30 +1,17 @@
|
|||
defmodule Livebook.Runtime.K8sTest do
|
||||
alias Livebook.Runtime
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
# To run these tests, install [Kind](https://kind.sigs.k8s.io/) on your machine.
|
||||
alias Livebook.Runtime
|
||||
|
||||
# To run these tests, install [Kind](https://kind.sigs.k8s.io/) on
|
||||
# your machine. You can also set TEST_K8S_BUILD_IMAGE=1 to build
|
||||
# a container image, in case you make changes to start_runtime.exs.
|
||||
@moduletag :k8s
|
||||
|
||||
@assert_receive_timeout 10_000
|
||||
@cluster_name "livebook-runtime-test"
|
||||
@kubeconfig_path "tmp/k8s_runtime/kubeconfig.yaml"
|
||||
|
||||
@default_pod_template """
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
generateName: livebook-runtime-
|
||||
labels:
|
||||
livebook.dev/runtime: integration-test
|
||||
spec:
|
||||
containers:
|
||||
- image: ghcr.io/livebook-dev/livebook:nightly
|
||||
name: livebook-runtime
|
||||
env:
|
||||
- name: TEST_VAR
|
||||
value: present
|
||||
|
||||
"""
|
||||
setup_all do
|
||||
unless System.find_executable("kind") do
|
||||
raise "kind is not installed"
|
||||
|
@ -39,8 +26,6 @@ defmodule Livebook.Runtime.K8sTest do
|
|||
# Export kubeconfig file
|
||||
cmd!(~w(kind export kubeconfig --name #{@cluster_name} --kubeconfig #{@kubeconfig_path}))
|
||||
|
||||
# In most cases we can use the existing image, but when making
|
||||
# changes to the remote runtime code, we need to build a new image
|
||||
if System.get_env("TEST_K8S_BUILD_IMAGE") in ~w(true 1) do
|
||||
{_, versions} = Code.eval_file("versions")
|
||||
|
||||
|
@ -55,6 +40,8 @@ defmodule Livebook.Runtime.K8sTest do
|
|||
# Load container image into Kind cluster
|
||||
cmd!(~w(kind load docker-image --name #{@cluster_name} ghcr.io/livebook-dev/livebook:nightly))
|
||||
|
||||
System.put_env("KUBECONFIG", @kubeconfig_path)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
|
@ -64,7 +51,7 @@ defmodule Livebook.Runtime.K8sTest do
|
|||
|
||||
assert [] = list_pods(req)
|
||||
|
||||
pid = Runtime.K8s.new(config, req) |> Runtime.connect()
|
||||
pid = Runtime.K8s.new(config) |> Runtime.connect()
|
||||
|
||||
assert_receive {:runtime_connect_info, ^pid, "create pod"}, @assert_receive_timeout
|
||||
|
||||
|
@ -86,9 +73,9 @@ defmodule Livebook.Runtime.K8sTest do
|
|||
assert [_] = list_pods(req)
|
||||
|
||||
# Verify that we can actually evaluate code on the Kubernetes Pod
|
||||
Runtime.evaluate_code(runtime, :elixir, ~s/System.fetch_env!("TEST_VAR")/, {:c1, :e1}, [])
|
||||
Runtime.evaluate_code(runtime, :elixir, ~s/System.fetch_env!("POD_NAME")/, {:c1, :e1}, [])
|
||||
assert_receive {:runtime_evaluation_response, :e1, %{type: :terminal_text, text: text}, _meta}
|
||||
assert text =~ "present"
|
||||
assert text =~ runtime.pod_name
|
||||
|
||||
Runtime.disconnect(runtime)
|
||||
|
||||
|
@ -106,18 +93,31 @@ defmodule Livebook.Runtime.K8sTest do
|
|||
end
|
||||
|
||||
defp req() do
|
||||
[Kubereq.Kubeconfig.ENV, {Kubereq.Kubeconfig.File, path: @kubeconfig_path}]
|
||||
Kubereq.Kubeconfig.Default
|
||||
|> Kubereq.Kubeconfig.load()
|
||||
|> Kubereq.Kubeconfig.set_current_context("kind-#{@cluster_name}")
|
||||
|> Kubereq.new("api/v1/namespaces/:namespace/pods/:name")
|
||||
end
|
||||
|
||||
defp config(attrs \\ %{}) do
|
||||
pod_template = """
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
generateName: livebook-runtime-
|
||||
labels:
|
||||
livebook.dev/runtime: integration-test
|
||||
spec:
|
||||
containers:
|
||||
- name: livebook-runtime\
|
||||
"""
|
||||
|
||||
defaults = %{
|
||||
context: "kind-#{@cluster_name}",
|
||||
namespace: "default",
|
||||
home_pvc: nil,
|
||||
pvc_name: nil,
|
||||
docker_tag: "nightly",
|
||||
pod_template: @default_pod_template
|
||||
pod_template: pod_template
|
||||
}
|
||||
|
||||
Map.merge(defaults, attrs)
|
||||
|
|
|
@ -99,7 +99,7 @@ defmodule LivebookWeb.HomeLiveTest do
|
|||
end
|
||||
|
||||
test "allows closing session after confirmation", %{conn: conn} do
|
||||
{:ok, session} = Sessions.create_session()
|
||||
{:ok, %{id: id} = session} = Sessions.create_session()
|
||||
|
||||
{:ok, view, _} = live(conn, ~p"/")
|
||||
|
||||
|
@ -109,8 +109,12 @@ defmodule LivebookWeb.HomeLiveTest do
|
|||
|> element(~s{[data-test-session-id="#{session.id}"] button}, "Close")
|
||||
|> render_click()
|
||||
|
||||
Sessions.subscribe()
|
||||
|
||||
render_confirm(view)
|
||||
|
||||
assert_receive {:session_closed, %{id: ^id}}
|
||||
|
||||
refute render(view) =~ session.id
|
||||
end
|
||||
|
||||
|
|
|
@ -1206,11 +1206,11 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
|> render_change(%{namespace: "default"})
|
||||
|
||||
assert view
|
||||
|> element(~s{select[name="home_pvc"] option[value="foo-pvc"]})
|
||||
|> element(~s{select[name="pvc_name"] option[value="foo-pvc"]})
|
||||
|> has_element?()
|
||||
|
||||
assert view
|
||||
|> element(~s{select[name="home_pvc"] option[value="new-pvc"]})
|
||||
|> element(~s{select[name="pvc_name"] option[value="new-pvc"]})
|
||||
|> has_element?()
|
||||
|
||||
assert render_async(view) =~ "You can fully customize"
|
||||
|
@ -1334,16 +1334,13 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
"""
|
||||
|
||||
runtime =
|
||||
Runtime.K8s.new(
|
||||
%{
|
||||
Runtime.K8s.new(%{
|
||||
context: "default",
|
||||
namespace: "default",
|
||||
home_pvc: "foo-pvc",
|
||||
pvc_name: "foo-pvc",
|
||||
docker_tag: "nightly",
|
||||
pod_template: pod_template
|
||||
},
|
||||
nil
|
||||
)
|
||||
})
|
||||
|
||||
Req.Test.stub(:k8s_cluster, Livebook.K8sClusterStub)
|
||||
|
||||
|
@ -1354,11 +1351,11 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
assert render_async(view) =~ "You can fully customize"
|
||||
|
||||
assert view
|
||||
|> element(~s{select[name="home_pvc"] option[value="foo-pvc"][selected]})
|
||||
|> element(~s{select[name="pvc_name"] option[value="foo-pvc"][selected]})
|
||||
|> has_element?()
|
||||
|
||||
assert view
|
||||
|> element(~s{select[name="home_pvc"] option[value="new-pvc"]})
|
||||
|> element(~s{select[name="pvc_name"] option[value="new-pvc"]})
|
||||
|> has_element?()
|
||||
|
||||
assert view
|
||||
|
|
Loading…
Reference in a new issue