mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +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
|
defmodule Livebook.K8s.Pod do
|
||||||
@main_container_name "livebook-runtime"
|
@main_container_name "livebook-runtime"
|
||||||
@home_pvc_volume_name "livebook-home"
|
@pvc_name_volume_name "livebook-home"
|
||||||
|
|
||||||
@default_pod_template """
|
@default_pod_template """
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
@ -35,15 +35,15 @@ defmodule Livebook.K8s.Pod do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Adds "volume" and "volumeMount" configurations to `manifest` in order
|
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()
|
@spec set_pvc_name(map(), String.t()) :: map()
|
||||||
def set_home_pvc(manifest, home_pvc) do
|
def set_pvc_name(manifest, pvc_name) do
|
||||||
manifest
|
manifest
|
||||||
|> update_in(["spec", Access.key("volumes", [])], fn volumes ->
|
|> update_in(["spec", Access.key("volumes", [])], fn volumes ->
|
||||||
volume = %{
|
volume = %{
|
||||||
"name" => @home_pvc_volume_name,
|
"name" => @pvc_name_volume_name,
|
||||||
"persistentVolumeClaim" => %{"claimName" => home_pvc}
|
"persistentVolumeClaim" => %{"claimName" => pvc_name}
|
||||||
}
|
}
|
||||||
|
|
||||||
[volume | volumes]
|
[volume | volumes]
|
||||||
|
@ -51,7 +51,7 @@ defmodule Livebook.K8s.Pod do
|
||||||
|> update_in(
|
|> update_in(
|
||||||
["spec", "containers", access_main_container(), Access.key("volumeMounts", [])],
|
["spec", "containers", access_main_container(), Access.key("volumeMounts", [])],
|
||||||
fn volume_mounts ->
|
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
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -51,7 +51,7 @@ defmodule Livebook.Runtime.Fly do
|
||||||
|
|
||||||
use GenServer, restart: :temporary
|
use GenServer, restart: :temporary
|
||||||
|
|
||||||
require Logger
|
alias Livebook.Runtime.RemoteUtils
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
config: config(),
|
config: config(),
|
||||||
|
@ -103,18 +103,10 @@ defmodule Livebook.Runtime.Fly do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_continue({:init, runtime, caller}, state) do
|
def handle_continue({:init, runtime, caller}, state) do
|
||||||
config = runtime.config
|
config = runtime.config
|
||||||
local_port = get_free_port!()
|
local_port = RemoteUtils.get_free_port!()
|
||||||
remote_port = 44444
|
|
||||||
node_base = "remote_runtime_#{local_port}"
|
node_base = "remote_runtime_#{local_port}"
|
||||||
|
|
||||||
runtime_data =
|
runtime_data = RemoteUtils.encode_runtime_data(node_base)
|
||||||
%{
|
|
||||||
node_base: node_base,
|
|
||||||
cookie: Node.get_cookie(),
|
|
||||||
dist_port: remote_port
|
|
||||||
}
|
|
||||||
|> :erlang.term_to_binary()
|
|
||||||
|> Base.encode64()
|
|
||||||
|
|
||||||
parent = self()
|
parent = self()
|
||||||
|
|
||||||
|
@ -140,7 +132,7 @@ defmodule Livebook.Runtime.Fly do
|
||||||
child_node <- :"#{node_base}@#{machine_id}.vm.#{config.app_name}.internal",
|
child_node <- :"#{node_base}@#{machine_id}.vm.#{config.app_name}.internal",
|
||||||
{:ok, proxy_port} <-
|
{:ok, proxy_port} <-
|
||||||
with_log(caller, "start proxy", fn ->
|
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),
|
end),
|
||||||
:ok <-
|
:ok <-
|
||||||
with_log(caller, "machine starting", fn ->
|
with_log(caller, "machine starting", fn ->
|
||||||
|
@ -148,14 +140,14 @@ defmodule Livebook.Runtime.Fly do
|
||||||
end),
|
end),
|
||||||
:ok <-
|
:ok <-
|
||||||
with_log(caller, "connect to node", fn ->
|
with_log(caller, "connect to node", fn ->
|
||||||
connect_loop(child_node, 40, 250)
|
RemoteUtils.connect(child_node)
|
||||||
end),
|
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)
|
primary_ref = Process.monitor(primary_pid)
|
||||||
|
|
||||||
server_pid =
|
server_pid =
|
||||||
with_log(caller, "initialize node", fn ->
|
with_log(caller, "initialize node", fn ->
|
||||||
initialize_node(child_node)
|
RemoteUtils.initialize_node(child_node)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
send(primary_pid, :node_initialized)
|
send(primary_pid, :node_initialized)
|
||||||
|
@ -274,29 +266,9 @@ defmodule Livebook.Runtime.Fly do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp connect_loop(_node, 0, _interval) do
|
defp start_fly_proxy(app_name, host, local_port, token) 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
|
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
|
# We want the proxy to accept the same protocol that we are
|
||||||
# going to use for distribution
|
# going to use for distribution
|
||||||
|
@ -380,44 +352,9 @@ defmodule Livebook.Runtime.Fly do
|
||||||
Enum.find(paths, fn path -> path && File.regular?(path) end)
|
Enum.find(paths, fn path -> path && File.regular?(path) end)
|
||||||
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
|
defp with_log(caller, name, fun) do
|
||||||
send(caller, {:runtime_connect_info, self(), name})
|
send(caller, {:runtime_connect_info, self(), name})
|
||||||
|
RemoteUtils.with_log("[fly runtime] #{name}", fun)
|
||||||
{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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -8,13 +8,12 @@ defmodule Livebook.Runtime.K8s do
|
||||||
|
|
||||||
defstruct [:config, :node, :req, :server_pid, :lv_pid, :pod_name]
|
defstruct [:config, :node, :req, :server_pid, :lv_pid, :pod_name]
|
||||||
|
|
||||||
@type config :: %{
|
use GenServer, restart: :temporary
|
||||||
context: String.t(),
|
|
||||||
namespace: String.t(),
|
require Logger
|
||||||
home_pvc: String.t() | nil,
|
|
||||||
docker_tag: String.t(),
|
alias Livebook.Runtime.RemoteUtils
|
||||||
pod_template: String.t()
|
alias Livebook.K8s.Pod
|
||||||
}
|
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
node: node() | nil,
|
node: node() | nil,
|
||||||
|
@ -24,18 +23,20 @@ defmodule Livebook.Runtime.K8s do
|
||||||
pod_name: String.t() | nil
|
pod_name: String.t() | nil
|
||||||
}
|
}
|
||||||
|
|
||||||
use GenServer, restart: :temporary
|
@type config :: %{
|
||||||
|
context: String.t(),
|
||||||
require Logger
|
namespace: String.t(),
|
||||||
|
docker_tag: String.t(),
|
||||||
alias Livebook.K8s.Pod
|
pod_template: String.t(),
|
||||||
|
pvc_name: String.t() | nil
|
||||||
|
}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a new runtime instance.
|
Returns a new runtime instance.
|
||||||
"""
|
"""
|
||||||
@spec new(config :: map(), req :: Req.Request.t()) :: t()
|
@spec new(map()) :: t()
|
||||||
def new(config, req) do
|
def new(config) do
|
||||||
%__MODULE__{config: config, req: req, lv_pid: self()}
|
%__MODULE__{config: config, lv_pid: self()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def __connect__(runtime) do
|
def __connect__(runtime) do
|
||||||
|
@ -60,25 +61,26 @@ defmodule Livebook.Runtime.K8s do
|
||||||
def handle_continue({:init, runtime, caller}, state) do
|
def handle_continue({:init, runtime, caller}, state) do
|
||||||
config = runtime.config
|
config = runtime.config
|
||||||
%{namespace: namespace, context: context} = config
|
%{namespace: namespace, context: context} = config
|
||||||
req = runtime.req
|
|
||||||
|
|
||||||
kubeconfig =
|
within_kubernetes? = System.get_env("KUBERNETES_SERVICE_HOST") != nil
|
||||||
if System.get_env("KUBERNETES_SERVICE_HOST") do
|
|
||||||
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
|
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
|
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 =
|
runtime_data = RemoteUtils.encode_runtime_data(node_base)
|
||||||
%{
|
|
||||||
node_base: cluster_data.node_base,
|
|
||||||
cookie: Node.get_cookie(),
|
|
||||||
dist_port: cluster_data.remote_port
|
|
||||||
}
|
|
||||||
|> :erlang.term_to_binary()
|
|
||||||
|> Base.encode64()
|
|
||||||
|
|
||||||
parent = self()
|
parent = self()
|
||||||
|
|
||||||
|
@ -90,28 +92,32 @@ defmodule Livebook.Runtime.K8s do
|
||||||
|
|
||||||
with {:ok, pod_name} <-
|
with {:ok, pod_name} <-
|
||||||
with_log(caller, "create pod", fn ->
|
with_log(caller, "create pod", fn ->
|
||||||
create_pod(req, config, runtime_data, cluster_data.remote_port)
|
create_pod(req, config, runtime_data)
|
||||||
end),
|
end),
|
||||||
_ <- send(watcher_pid, {:pod_created, pod_name}),
|
_ <- send(watcher_pid, {:pod_created, pod_name}),
|
||||||
{:ok, pod_ip} <-
|
{:ok, pod_ip} <-
|
||||||
with_pod_events(caller, "waiting for pod", req, namespace, pod_name, fn ->
|
with_pod_events(caller, "waiting for pod", req, namespace, pod_name, fn ->
|
||||||
await_pod_ready(req, namespace, pod_name)
|
await_pod_ready(req, namespace, pod_name)
|
||||||
end),
|
end),
|
||||||
child_node <- :"#{cluster_data.node_base}@#{pod_ip}",
|
child_node <- :"#{node_base}@#{pod_ip}",
|
||||||
:ok <-
|
:ok <-
|
||||||
|
(if within_kubernetes? do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
with_log(caller, "start proxy", fn ->
|
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),
|
end),
|
||||||
:ok <-
|
:ok <-
|
||||||
with_log(caller, "connect to node", fn ->
|
with_log(caller, "connect to node", fn ->
|
||||||
connect_loop(child_node, 40, 250)
|
RemoteUtils.connect(child_node)
|
||||||
end),
|
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)
|
primary_ref = Process.monitor(primary_pid)
|
||||||
|
|
||||||
server_pid =
|
server_pid =
|
||||||
with_log(caller, "initialize node", fn ->
|
with_log(caller, "initialize node", fn ->
|
||||||
initialize_node(child_node)
|
RemoteUtils.initialize_node(child_node)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
send(primary_pid, :node_initialized)
|
send(primary_pid, :node_initialized)
|
||||||
|
@ -139,30 +145,6 @@ defmodule Livebook.Runtime.K8s do
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
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 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
|
defp with_pod_events(caller, name, req, namespace, pod_name, fun) do
|
||||||
with_log(caller, name, fn ->
|
with_log(caller, name, fn ->
|
||||||
runtime_pid = self()
|
runtime_pid = self()
|
||||||
|
@ -186,8 +168,8 @@ defmodule Livebook.Runtime.K8s do
|
||||||
{:ok, stream} ->
|
{:ok, stream} ->
|
||||||
Enum.each(stream, fn event ->
|
Enum.each(stream, fn event ->
|
||||||
message = Livebook.Utils.downcase_first(event["object"]["message"])
|
message = Livebook.Utils.downcase_first(event["object"]["message"])
|
||||||
Logger.debug(~s'[K8s runtime] Pod event: "#{message}"')
|
|
||||||
send(caller, {:runtime_connect_info, runtime_pid, message})
|
send(caller, {:runtime_connect_info, runtime_pid, message})
|
||||||
|
Logger.debug(~s/[k8s runtime] Pod event: "#{message}"/)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
_error ->
|
_error ->
|
||||||
|
@ -224,11 +206,11 @@ defmodule Livebook.Runtime.K8s do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_pod(req, config, runtime_data, remote_port) do
|
defp create_pod(req, config, runtime_data) do
|
||||||
%{
|
%{
|
||||||
pod_template: pod_template,
|
pod_template: pod_template,
|
||||||
docker_tag: docker_tag,
|
docker_tag: docker_tag,
|
||||||
home_pvc: home_pvc,
|
pvc_name: pvc_name,
|
||||||
namespace: namespace
|
namespace: namespace
|
||||||
} = config
|
} = config
|
||||||
|
|
||||||
|
@ -254,11 +236,11 @@ defmodule Livebook.Runtime.K8s do
|
||||||
])
|
])
|
||||||
|> Pod.set_docker_tag(docker_tag)
|
|> Pod.set_docker_tag(docker_tag)
|
||||||
|> Pod.set_namespace(namespace)
|
|> Pod.set_namespace(namespace)
|
||||||
|> Pod.add_container_port(remote_port)
|
|> Pod.add_container_port(RemoteUtils.remote_port())
|
||||||
|
|
||||||
manifest =
|
manifest =
|
||||||
if home_pvc do
|
if pvc_name do
|
||||||
Pod.set_home_pvc(manifest, home_pvc)
|
Pod.set_pvc_name(manifest, pvc_name)
|
||||||
else
|
else
|
||||||
manifest
|
manifest
|
||||||
end
|
end
|
||||||
|
@ -275,24 +257,9 @@ defmodule Livebook.Runtime.K8s do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_cluster_data(_kubeconfig = nil) do
|
defp k8s_forward_port(context, local_port, pod_name, namespace) 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
|
|
||||||
|
|
||||||
with {:ok, kubectl_path} <- find_kubectl_executable() 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
|
# We want the proxy to accept the same protocol that we are
|
||||||
# going to use for distribution
|
# going to use for distribution
|
||||||
|
@ -303,6 +270,8 @@ defmodule Livebook.Runtime.K8s do
|
||||||
"127.0.0.1"
|
"127.0.0.1"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
kubeconfig = System.get_env("KUBECONFIG") || Path.join(System.user_home!(), ".kube/config")
|
||||||
|
|
||||||
args =
|
args =
|
||||||
[
|
[
|
||||||
"port-forward",
|
"port-forward",
|
||||||
|
@ -351,7 +320,7 @@ defmodule Livebook.Runtime.K8s do
|
||||||
if path = System.find_executable("kubectl") do
|
if path = System.find_executable("kubectl") do
|
||||||
{:ok, path}
|
{:ok, path}
|
||||||
else
|
else
|
||||||
{:error, "no kubectl executable found in PATH."}
|
{:error, "no kubectl executable found in PATH"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -363,7 +332,7 @@ defmodule Livebook.Runtime.K8s do
|
||||||
pod_name,
|
pod_name,
|
||||||
fn
|
fn
|
||||||
:deleted ->
|
:deleted ->
|
||||||
{:error, "The Pod was deleted before it started running."}
|
{:error, "the Pod was deleted before it started running"}
|
||||||
|
|
||||||
pod ->
|
pod ->
|
||||||
get_in(pod, [
|
get_in(pod, [
|
||||||
|
@ -380,50 +349,19 @@ defmodule Livebook.Runtime.K8s do
|
||||||
{:ok, pod["status"]["podIP"]}
|
{:ok, pod["status"]["podIP"]}
|
||||||
else
|
else
|
||||||
{:error, :watch_timeout} ->
|
{: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} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
{:error, "Failed getting the Pod's IP address."}
|
{:error, "tailed getting the Pod's IP address"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp connect_loop(_node, 0, _interval) do
|
defp with_log(caller, name, fun) do
|
||||||
{:error, "could not establish connection with the node"}
|
send(caller, {:runtime_connect_info, self(), name})
|
||||||
end
|
RemoteUtils.with_log("[k8s runtime] #{name}", fun)
|
||||||
|
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -453,7 +391,7 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.K8s do
|
||||||
end
|
end
|
||||||
|
|
||||||
def duplicate(runtime) do
|
def duplicate(runtime) do
|
||||||
Livebook.Runtime.K8s.new(runtime.config, runtime.req)
|
Livebook.Runtime.K8s.new(runtime.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ []) do
|
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
|
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
|
# JS commands
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
@ -490,7 +490,7 @@ defmodule LivebookWeb.FileSelectComponent do
|
||||||
|
|
||||||
file = FileSystem.File.new(file_system)
|
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}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
@ -512,7 +512,7 @@ defmodule LivebookWeb.FileSelectComponent do
|
||||||
_info -> %{exists: true}
|
_info -> %{exists: true}
|
||||||
end
|
end
|
||||||
|
|
||||||
send_event(socket, {:set_file, file, info})
|
send_event(socket.assigns.target, {:set_file, file, info})
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
@ -759,14 +759,4 @@ defmodule LivebookWeb.FileSelectComponent do
|
||||||
new_file = FileSystem.File.resolve(parent_dir, new_name)
|
new_file = FileSystem.File.resolve(parent_dir, new_name)
|
||||||
FileSystem.File.rename(file, new_file)
|
FileSystem.File.rename(file, new_file)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -5,8 +5,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
|
|
||||||
alias Livebook.{Session, Runtime}
|
alias Livebook.{Session, Runtime}
|
||||||
|
|
||||||
@config_secret_prefix "FLY_RUNTIME_"
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(socket) do
|
def mount(socket) do
|
||||||
unless Livebook.Config.runtime_enabled?(Livebook.Runtime.Fly) do
|
unless Livebook.Config.runtime_enabled?(Livebook.Runtime.Fly) do
|
||||||
|
@ -26,11 +24,26 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
specs_changeset: specs_changeset(),
|
specs_changeset: specs_changeset(),
|
||||||
volume_id: nil,
|
volume_id: nil,
|
||||||
volume_action: nil,
|
volume_action: nil,
|
||||||
save_config: nil
|
save_config_payload: nil
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
def update(assigns, socket) do
|
||||||
socket = assign(socket, assigns)
|
socket = assign(socket, assigns)
|
||||||
|
|
||||||
|
@ -67,11 +80,17 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
The machine is automatically destroyed, once you disconnect the runtime.
|
The machine is automatically destroyed, once you disconnect the runtime.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<.save_config_form :if={@save_config} save_config={@save_config} hub={@hub} myself={@myself} />
|
<.live_component
|
||||||
|
module={LivebookWeb.SessionLive.SaveRuntimeConfigComponent}
|
||||||
<div :if={@save_config == nil}>
|
id="save-runtime-config"
|
||||||
<.config_actions hub_secrets={@hub_secrets} myself={@myself} />
|
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
|
<form
|
||||||
class="mt-1 flex flex-col gap-4"
|
class="mt-1 flex flex-col gap-4"
|
||||||
phx-change="set_token"
|
phx-change="set_token"
|
||||||
|
@ -162,99 +181,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
"""
|
"""
|
||||||
end
|
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
|
defp loader(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
@ -598,52 +524,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_async(:load_org_and_regions, {:ok, result}, socket) do
|
def handle_async(:load_org_and_regions, {:ok, result}, socket) do
|
||||||
socket =
|
socket =
|
||||||
|
@ -734,22 +614,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
defp label(app_name, runtime, runtime_status) do
|
||||||
reconnecting? = reconnecting?(app_name, runtime)
|
reconnecting? = reconnecting?(app_name, runtime)
|
||||||
|
|
||||||
|
@ -785,15 +649,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent 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
|
defp load_config_defaults(socket) do
|
||||||
config_defaults = socket.assigns.config_defaults
|
config_defaults = socket.assigns.config_defaults
|
||||||
|
|
||||||
|
@ -854,18 +709,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
|> validate_required([:name, :size_gb])
|
|> validate_required([:name, :size_gb])
|
||||||
end
|
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(nil, _volumes, _region), do: []
|
||||||
|
|
||||||
defp volume_errors(volume_id, 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)
|
|> assign_nested(:volume_action, inflight: true)
|
||||||
end
|
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
|
defp build_config(socket) do
|
||||||
specs = apply_changes(socket.assigns.specs_changeset)
|
specs = apply_changes(socket.assigns.specs_changeset)
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
alias Livebook.{Session, Runtime}
|
alias Livebook.{Session, Runtime}
|
||||||
alias Livebook.K8s.{Auth, Pod, PVC}
|
alias Livebook.K8s.{Auth, Pod, PVC}
|
||||||
|
|
||||||
@config_secret_prefix "K8S_RUNTIME_"
|
|
||||||
@kubeconfig_pipeline Application.compile_env(:livebook, :k8s_kubeconfig_pipeline)
|
@kubeconfig_pipeline Application.compile_env(:livebook, :k8s_kubeconfig_pipeline)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
@ -29,15 +28,31 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
namespace: nil,
|
namespace: nil,
|
||||||
namespace_options: nil,
|
namespace_options: nil,
|
||||||
rbac: %{status: :inflight, errors: [], permissions: []},
|
rbac: %{status: :inflight, errors: [], permissions: []},
|
||||||
save_config: nil,
|
|
||||||
pvcs: nil,
|
pvcs: nil,
|
||||||
pvc_action: nil,
|
pvc_action: nil,
|
||||||
home_pvc: nil,
|
pvc_name: nil,
|
||||||
docker_tag: hd(Livebook.Config.docker_images()).tag,
|
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
|
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
|
@impl true
|
||||||
@spec update(maybe_improper_list() | map(), any()) :: {:ok, any()}
|
@spec update(maybe_improper_list() | map(), any()) :: {:ok, any()}
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
|
@ -78,11 +93,17 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
The Pod is automatically deleted, once you disconnect the runtime.
|
The Pod is automatically deleted, once you disconnect the runtime.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<.save_config_form :if={@save_config} save_config={@save_config} hub={@hub} myself={@myself} />
|
<.live_component
|
||||||
|
module={LivebookWeb.SessionLive.SaveRuntimeConfigComponent}
|
||||||
<div :if={@save_config == nil}>
|
id="save-runtime-config"
|
||||||
<.config_actions hub_secrets={@hub_secrets} myself={@myself} />
|
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}>
|
<.message_box :if={@kubeconfig.current_cluster == nil} kind={:error}>
|
||||||
In order to use the Kubernetes context, you need to set the <code>KUBECONFIG</code>
|
In order to use the Kubernetes context, you need to set the <code>KUBECONFIG</code>
|
||||||
environment variable to a path pointing to a <a
|
environment variable to a path pointing to a <a
|
||||||
|
@ -183,7 +204,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
<.storage_config
|
<.storage_config
|
||||||
:if={@rbac.status == :ok}
|
:if={@rbac.status == :ok}
|
||||||
myself={@myself}
|
myself={@myself}
|
||||||
home_pvc={@home_pvc}
|
pvc_name={@pvc_name}
|
||||||
pvcs={@pvcs}
|
pvcs={@pvcs}
|
||||||
pvc_action={@pvc_action}
|
pvc_action={@pvc_action}
|
||||||
rbac={@rbac}
|
rbac={@rbac}
|
||||||
|
@ -239,16 +260,16 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col">
|
<div class="mt-4 flex flex-col">
|
||||||
<div class="flex items-start gap-1">
|
<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
|
<.select_field
|
||||||
:if={@rbac.permissions.list_pvc}
|
:if={@rbac.permissions.list_pvc}
|
||||||
value={@home_pvc}
|
value={@pvc_name}
|
||||||
name="home_pvc"
|
name="pvc_name"
|
||||||
label="Persistent Volume Claim"
|
label="Persistent Volume Claim"
|
||||||
options={[{"None", nil} | @pvcs]}
|
options={[{"None", nil} | @pvcs]}
|
||||||
/>
|
/>
|
||||||
<div :if={!@rbac.permissions.list_pvc}>
|
<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">
|
<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.
|
Authenticated user has no permission to list PVCs. But you can enter a name of an existing PVC to be attached.
|
||||||
</div>
|
</div>
|
||||||
|
@ -264,7 +285,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
<.icon_button
|
<.icon_button
|
||||||
phx-click="delete_pvc"
|
phx-click="delete_pvc"
|
||||||
phx-target={@myself}
|
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" />
|
<.remix_icon icon="delete-bin-6-line" />
|
||||||
</.icon_button>
|
</.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"
|
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">
|
<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>
|
</p>
|
||||||
<div class="flex space-x-4">
|
<div class="flex space-x-4">
|
||||||
<button
|
<button
|
||||||
|
@ -357,99 +378,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
"""
|
"""
|
||||||
end
|
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
|
defp loader(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
@ -530,13 +458,8 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
{:noreply, set_pod_template(socket, pod_template)}
|
{:noreply, set_pod_template(socket, pod_template)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("set_home_pvc", %{"home_pvc" => home_pvc}, socket) do
|
def handle_event("set_pvc_name", %{"pvc_name" => pvc_name}, socket) do
|
||||||
{:noreply, assign(socket, :home_pvc, home_pvc)}
|
{:noreply, assign(socket, :pvc_name, pvc_name)}
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("disconnect", %{}, socket) do
|
|
||||||
Session.disconnect_runtime(socket.assigns.session.pid)
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("new_pvc", %{}, socket) do
|
def handle_event("new_pvc", %{}, socket) do
|
||||||
|
@ -583,7 +506,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("confirm_delete_pvc", %{}, socket) do
|
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
|
req = socket.assigns.reqs.pvc
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
|
@ -600,57 +523,16 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
|
|
||||||
def handle_event("init", %{}, socket) do
|
def handle_event("init", %{}, socket) do
|
||||||
config = build_config(socket)
|
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.set_runtime(socket.assigns.session.pid, runtime)
|
||||||
Session.connect_runtime(socket.assigns.session.pid)
|
Session.connect_runtime(socket.assigns.session.pid)
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("open_save_config", %{}, socket) do
|
def handle_event("disconnect", %{}, socket) do
|
||||||
changeset = config_secret_changeset(socket, %{name: @config_secret_prefix})
|
Session.disconnect_runtime(socket.assigns.session.pid)
|
||||||
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}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_async(:rbac_check, {:ok, %{errors: errors, permissions: permissions}}, socket) do
|
def handle_async(:rbac_check, {:ok, %{errors: errors, permissions: permissions}}, socket) do
|
||||||
|
@ -684,7 +566,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
case result do
|
case result do
|
||||||
{:ok, %{status: 200}} ->
|
{:ok, %{status: 200}} ->
|
||||||
socket
|
socket
|
||||||
|> assign(home_pvc: nil, pvc_action: nil)
|
|> assign(pvc_name: nil, pvc_action: nil)
|
||||||
|> pvc_options()
|
|> pvc_options()
|
||||||
|
|
||||||
{:ok, %{body: %{"message" => message}}} ->
|
{:ok, %{body: %{"message" => message}}} ->
|
||||||
|
@ -699,7 +581,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
case result do
|
case result do
|
||||||
{:ok, %{status: 201, body: created_pvc}} ->
|
{:ok, %{status: 201, body: created_pvc}} ->
|
||||||
socket
|
socket
|
||||||
|> assign(home_pvc: created_pvc["metadata"]["name"], pvc_action: nil)
|
|> assign(pvc_name: created_pvc["metadata"]["name"], pvc_action: nil)
|
||||||
|> pvc_options()
|
|> pvc_options()
|
||||||
|
|
||||||
{:ok, %{body: body}} ->
|
{:ok, %{body: body}} ->
|
||||||
|
@ -731,22 +613,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
defp label(namespace, runtime, runtime_status) do
|
||||||
reconnecting? = reconnecting?(namespace, runtime)
|
reconnecting? = reconnecting?(namespace, runtime)
|
||||||
|
|
||||||
|
@ -780,7 +646,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
access_reviews:
|
access_reviews:
|
||||||
Kubereq.new(kubeconfig, "apis/authorization.k8s.io/v1/selfsubjectaccessreviews"),
|
Kubereq.new(kubeconfig, "apis/authorization.k8s.io/v1/selfsubjectaccessreviews"),
|
||||||
namespaces: Kubereq.new(kubeconfig, "api/v1/namespaces/:name"),
|
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"),
|
pvc: Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/persistentvolumeclaims/:name"),
|
||||||
sc: Kubereq.new(kubeconfig, "apis/storage.k8s.io/v1/storageclasses/:name")
|
sc: Kubereq.new(kubeconfig, "apis/storage.k8s.io/v1/storageclasses/:name")
|
||||||
}
|
}
|
||||||
|
@ -917,21 +782,12 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
end
|
end
|
||||||
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
|
defp load_config_defaults(socket) do
|
||||||
config_defaults = socket.assigns.config_defaults
|
config_defaults = socket.assigns.config_defaults
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(
|
||||||
home_pvc: config_defaults["home_pvc"],
|
pvc_name: config_defaults["pvc_name"],
|
||||||
docker_tag: config_defaults["docker_tag"]
|
docker_tag: config_defaults["docker_tag"]
|
||||||
)
|
)
|
||||||
|> set_context(config_defaults["context"])
|
|> set_context(config_defaults["context"])
|
||||||
|
@ -939,52 +795,11 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
|> set_pod_template(config_defaults["pod_template"])
|
|> set_pod_template(config_defaults["pod_template"])
|
||||||
end
|
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
|
defp build_config(socket) do
|
||||||
%{
|
%{
|
||||||
context: socket.assigns.context,
|
context: socket.assigns.context,
|
||||||
namespace: socket.assigns.namespace,
|
namespace: socket.assigns.namespace,
|
||||||
home_pvc: socket.assigns.home_pvc,
|
pvc_name: socket.assigns.pvc_name,
|
||||||
docker_tag: socket.assigns.docker_tag,
|
docker_tag: socket.assigns.docker_tag,
|
||||||
pod_template: socket.assigns.pod_template.template
|
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
|
defmodule Livebook.Runtime.K8sTest do
|
||||||
alias Livebook.Runtime
|
|
||||||
use ExUnit.Case, async: true
|
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
|
@moduletag :k8s
|
||||||
|
|
||||||
@assert_receive_timeout 10_000
|
@assert_receive_timeout 10_000
|
||||||
@cluster_name "livebook-runtime-test"
|
@cluster_name "livebook-runtime-test"
|
||||||
@kubeconfig_path "tmp/k8s_runtime/kubeconfig.yaml"
|
@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
|
setup_all do
|
||||||
unless System.find_executable("kind") do
|
unless System.find_executable("kind") do
|
||||||
raise "kind is not installed"
|
raise "kind is not installed"
|
||||||
|
@ -39,8 +26,6 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
# Export kubeconfig file
|
# Export kubeconfig file
|
||||||
cmd!(~w(kind export kubeconfig --name #{@cluster_name} --kubeconfig #{@kubeconfig_path}))
|
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
|
if System.get_env("TEST_K8S_BUILD_IMAGE") in ~w(true 1) do
|
||||||
{_, versions} = Code.eval_file("versions")
|
{_, versions} = Code.eval_file("versions")
|
||||||
|
|
||||||
|
@ -55,6 +40,8 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
# Load container image into Kind cluster
|
# Load container image into Kind cluster
|
||||||
cmd!(~w(kind load docker-image --name #{@cluster_name} ghcr.io/livebook-dev/livebook:nightly))
|
cmd!(~w(kind load docker-image --name #{@cluster_name} ghcr.io/livebook-dev/livebook:nightly))
|
||||||
|
|
||||||
|
System.put_env("KUBECONFIG", @kubeconfig_path)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -64,7 +51,7 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
|
|
||||||
assert [] = list_pods(req)
|
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
|
assert_receive {:runtime_connect_info, ^pid, "create pod"}, @assert_receive_timeout
|
||||||
|
|
||||||
|
@ -86,9 +73,9 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
assert [_] = list_pods(req)
|
assert [_] = list_pods(req)
|
||||||
|
|
||||||
# Verify that we can actually evaluate code on the Kubernetes Pod
|
# 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_receive {:runtime_evaluation_response, :e1, %{type: :terminal_text, text: text}, _meta}
|
||||||
assert text =~ "present"
|
assert text =~ runtime.pod_name
|
||||||
|
|
||||||
Runtime.disconnect(runtime)
|
Runtime.disconnect(runtime)
|
||||||
|
|
||||||
|
@ -106,18 +93,31 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp req() do
|
defp req() do
|
||||||
[Kubereq.Kubeconfig.ENV, {Kubereq.Kubeconfig.File, path: @kubeconfig_path}]
|
Kubereq.Kubeconfig.Default
|
||||||
|> Kubereq.Kubeconfig.load()
|
|> Kubereq.Kubeconfig.load()
|
||||||
|
|> Kubereq.Kubeconfig.set_current_context("kind-#{@cluster_name}")
|
||||||
|> Kubereq.new("api/v1/namespaces/:namespace/pods/:name")
|
|> Kubereq.new("api/v1/namespaces/:namespace/pods/:name")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp config(attrs \\ %{}) do
|
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 = %{
|
defaults = %{
|
||||||
context: "kind-#{@cluster_name}",
|
context: "kind-#{@cluster_name}",
|
||||||
namespace: "default",
|
namespace: "default",
|
||||||
home_pvc: nil,
|
pvc_name: nil,
|
||||||
docker_tag: "nightly",
|
docker_tag: "nightly",
|
||||||
pod_template: @default_pod_template
|
pod_template: pod_template
|
||||||
}
|
}
|
||||||
|
|
||||||
Map.merge(defaults, attrs)
|
Map.merge(defaults, attrs)
|
||||||
|
|
|
@ -99,7 +99,7 @@ defmodule LivebookWeb.HomeLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "allows closing session after confirmation", %{conn: conn} do
|
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"/")
|
{:ok, view, _} = live(conn, ~p"/")
|
||||||
|
|
||||||
|
@ -109,8 +109,12 @@ defmodule LivebookWeb.HomeLiveTest do
|
||||||
|> element(~s{[data-test-session-id="#{session.id}"] button}, "Close")
|
|> element(~s{[data-test-session-id="#{session.id}"] button}, "Close")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
|
Sessions.subscribe()
|
||||||
|
|
||||||
render_confirm(view)
|
render_confirm(view)
|
||||||
|
|
||||||
|
assert_receive {:session_closed, %{id: ^id}}
|
||||||
|
|
||||||
refute render(view) =~ session.id
|
refute render(view) =~ session.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1206,11 +1206,11 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
|> render_change(%{namespace: "default"})
|
|> render_change(%{namespace: "default"})
|
||||||
|
|
||||||
assert view
|
assert view
|
||||||
|> element(~s{select[name="home_pvc"] option[value="foo-pvc"]})
|
|> element(~s{select[name="pvc_name"] option[value="foo-pvc"]})
|
||||||
|> has_element?()
|
|> has_element?()
|
||||||
|
|
||||||
assert view
|
assert view
|
||||||
|> element(~s{select[name="home_pvc"] option[value="new-pvc"]})
|
|> element(~s{select[name="pvc_name"] option[value="new-pvc"]})
|
||||||
|> has_element?()
|
|> has_element?()
|
||||||
|
|
||||||
assert render_async(view) =~ "You can fully customize"
|
assert render_async(view) =~ "You can fully customize"
|
||||||
|
@ -1334,16 +1334,13 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
runtime =
|
runtime =
|
||||||
Runtime.K8s.new(
|
Runtime.K8s.new(%{
|
||||||
%{
|
|
||||||
context: "default",
|
context: "default",
|
||||||
namespace: "default",
|
namespace: "default",
|
||||||
home_pvc: "foo-pvc",
|
pvc_name: "foo-pvc",
|
||||||
docker_tag: "nightly",
|
docker_tag: "nightly",
|
||||||
pod_template: pod_template
|
pod_template: pod_template
|
||||||
},
|
})
|
||||||
nil
|
|
||||||
)
|
|
||||||
|
|
||||||
Req.Test.stub(:k8s_cluster, Livebook.K8sClusterStub)
|
Req.Test.stub(:k8s_cluster, Livebook.K8sClusterStub)
|
||||||
|
|
||||||
|
@ -1354,11 +1351,11 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
assert render_async(view) =~ "You can fully customize"
|
assert render_async(view) =~ "You can fully customize"
|
||||||
|
|
||||||
assert view
|
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?()
|
|> has_element?()
|
||||||
|
|
||||||
assert view
|
assert view
|
||||||
|> element(~s{select[name="home_pvc"] option[value="new-pvc"]})
|
|> element(~s{select[name="pvc_name"] option[value="new-pvc"]})
|
||||||
|> has_element?()
|
|> has_element?()
|
||||||
|
|
||||||
assert view
|
assert view
|
||||||
|
|
Loading…
Reference in a new issue