Share code between Fly and K8s runtimes (#2788)

This commit is contained in:
Jonatan Kłosko 2024-09-18 18:20:41 +02:00 committed by GitHub
parent 2e45f8aca0
commit b0ab056499
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 585 additions and 700 deletions

View file

@ -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

View file

@ -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

View file

@ -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 <-
with_log(caller, "start proxy", fn ->
k8s_forward_port(kubeconfig, context, cluster_data, pod_name, namespace)
end),
(if within_kubernetes? do
:ok
else
with_log(caller, "start proxy", fn ->
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

View 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

View file

@ -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 """

View file

@ -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

View file

@ -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)

View file

@ -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,56 +523,15 @@ 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, _} ->
{:noreply, socket}
end
def handle_event("disconnect", %{}, socket) do
Session.disconnect_runtime(socket.assigns.session.pid)
{:noreply, socket}
end
@impl true
@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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(
%{
context: "default",
namespace: "default",
home_pvc: "foo-pvc",
docker_tag: "nightly",
pod_template: pod_template
},
nil
)
Runtime.K8s.new(%{
context: "default",
namespace: "default",
pvc_name: "foo-pvc",
docker_tag: "nightly",
pod_template: pod_template
})
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