From b0ab0564993ef546036ab70d60f9536d393c3439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Wed, 18 Sep 2024 18:20:41 +0200 Subject: [PATCH] Share code between Fly and K8s runtimes (#2788) --- lib/livebook/k8s/pod.ex | 14 +- lib/livebook/runtime/fly.ex | 83 +----- lib/livebook/runtime/k8s.ex | 184 ++++-------- lib/livebook/runtime/remote_utils.ex | 113 +++++++ .../components/core_components.ex | 28 ++ .../live/file_select_component.ex | 14 +- .../session_live/fly_runtime_component.ex | 238 ++------------- .../session_live/k8s_runtime_component.ex | 279 +++--------------- .../save_runtime_config_component.ex | 249 ++++++++++++++++ test/livebook/runtime/k8s_test.exs | 52 ++-- test/livebook_web/live/home_live_test.exs | 6 +- test/livebook_web/live/session_live_test.exs | 25 +- 12 files changed, 585 insertions(+), 700 deletions(-) create mode 100644 lib/livebook/runtime/remote_utils.ex create mode 100644 lib/livebook_web/live/session_live/save_runtime_config_component.ex diff --git a/lib/livebook/k8s/pod.ex b/lib/livebook/k8s/pod.ex index 0337416e6..74d28e311 100644 --- a/lib/livebook/k8s/pod.ex +++ b/lib/livebook/k8s/pod.ex @@ -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 diff --git a/lib/livebook/runtime/fly.ex b/lib/livebook/runtime/fly.ex index 9ea467806..bbaeffc20 100644 --- a/lib/livebook/runtime/fly.ex +++ b/lib/livebook/runtime/fly.ex @@ -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 diff --git a/lib/livebook/runtime/k8s.ex b/lib/livebook/runtime/k8s.ex index 65765af64..bcacf7bd1 100644 --- a/lib/livebook/runtime/k8s.ex +++ b/lib/livebook/runtime/k8s.ex @@ -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 diff --git a/lib/livebook/runtime/remote_utils.ex b/lib/livebook/runtime/remote_utils.ex new file mode 100644 index 000000000..297bee84d --- /dev/null +++ b/lib/livebook/runtime/remote_utils.ex @@ -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 diff --git a/lib/livebook_web/components/core_components.ex b/lib/livebook_web/components/core_components.ex index d260e9b8b..b9957931e 100644 --- a/lib/livebook_web/components/core_components.ex +++ b/lib/livebook_web/components/core_components.ex @@ -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 """ diff --git a/lib/livebook_web/live/file_select_component.ex b/lib/livebook_web/live/file_select_component.ex index 0d90b527c..96191665e 100644 --- a/lib/livebook_web/live/file_select_component.ex +++ b/lib/livebook_web/live/file_select_component.ex @@ -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 diff --git a/lib/livebook_web/live/session_live/fly_runtime_component.ex b/lib/livebook_web/live/session_live/fly_runtime_component.ex index 5693fa987..29844a153 100644 --- a/lib/livebook_web/live/session_live/fly_runtime_component.ex +++ b/lib/livebook_web/live/session_live/fly_runtime_component.ex @@ -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.

- <.save_config_form :if={@save_config} save_config={@save_config} hub={@hub} myself={@myself} /> - -
- <.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_" + /> +
-
- Save config -
-
- Store the config in a secret in the <.workspace hub={@hub} /> workspace to reuse it later. -
-
- <.message_box kind={:error} message={error} /> -
-
- <.text_field field={f[:name]} label="Secret name" class="uppercase" autofocus /> -
-
- <.button type="submit" disabled={not @save_config.changeset.valid? or @save_config.inflight}> - <%= if(@save_config.inflight, do: "Saving...", else: "Save") %> - - <.button - color="gray" - outlined - type="button" - phx-click="cancel_save_config" - phx-target={@myself} - > - Cancel - -
- - """ - end - - defp workspace(assigns) do - ~H""" - - <%= @hub.hub_emoji %> - <%= @hub.hub_name %> - - """ - end - - defp config_actions(assigns) do - ~H""" -
- <.button - color="gray" - outlined - small - type="button" - phx-click="open_save_config" - phx-target={@myself} - > - Save config - - <.menu id="config-secret-menu"> - <:toggle> - <.button color="gray" outlined small type="button"> - Load config - <.remix_icon icon="arrow-down-s-line" class="text-base leading-none" /> - - -
- No configs saved yet -
- <.menu_item :for={name <- config_secret_names(@hub_secrets)}> - - - -
- """ - end - defp loader(assigns) do ~H"""
@@ -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) diff --git a/lib/livebook_web/live/session_live/k8s_runtime_component.ex b/lib/livebook_web/live/session_live/k8s_runtime_component.ex index 2f9c3ec5b..97b3d9660 100644 --- a/lib/livebook_web/live/session_live/k8s_runtime_component.ex +++ b/lib/livebook_web/live/session_live/k8s_runtime_component.ex @@ -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.

- <.save_config_form :if={@save_config} save_config={@save_config} hub={@hub} myself={@myself} /> - -
- <.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_" + /> +
<.message_box :if={@kubeconfig.current_cluster == nil} kind={:error}> In order to use the Kubernetes context, you need to set the KUBECONFIG environment variable to a path pointing to a
- + <.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]} />
- <.text_field value={@home_pvc} name="home_pvc" label="Persistent Volume Claim" /> + <.text_field value={@pvc_name} name="pvc_name" label="Persistent Volume Claim" />
Authenticated user has no permission to list PVCs. But you can enter a name of an existing PVC to be attached.
@@ -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" /> @@ -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" >

- Are you sure you want to irreversibly delete Persistent Volume Claim <%= @home_pvc %>? + Are you sure you want to irreversibly delete Persistent Volume Claim <%= @pvc_name %>?

- - -
- """ - end - defp loader(assigns) do ~H"""
@@ -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 } diff --git a/lib/livebook_web/live/session_live/save_runtime_config_component.ex b/lib/livebook_web/live/session_live/save_runtime_config_component.ex new file mode 100644 index 000000000..5a31cf924 --- /dev/null +++ b/lib/livebook_web/live/session_live/save_runtime_config_component.ex @@ -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""" +
+ <%= 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 %> +
+ """ + end + + defp config_actions(assigns) do + ~H""" +
+ <.button + color="gray" + outlined + small + type="button" + phx-click="open_save_config" + phx-target={@myself} + > + Save config + + <.menu id="config-secret-menu"> + <:toggle> + <.button color="gray" outlined small type="button"> + Load config + <.remix_icon icon="arrow-down-s-line" class="text-base leading-none" /> + + +
+ No configs saved yet +
+ <.menu_item :for={name <- config_secret_names(@hub_secrets, @secret_prefix)}> + + + +
+ """ + 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" + > +
+ Save config +
+
+ Store the config in a secret in the <.workspace hub={@hub} /> workspace to reuse it later. +
+
+ <.message_box kind={:error} message={error} /> +
+
+ <.text_field field={f[:name]} label="Secret name" class="uppercase" autofocus /> +
+
+ <.button type="submit" disabled={not @save_config.changeset.valid? or @save_config.inflight}> + <%= if(@save_config.inflight, do: "Saving...", else: "Save") %> + + <.button + color="gray" + outlined + type="button" + phx-click="cancel_save_config" + phx-target={@myself} + > + Cancel + +
+ + """ + end + + defp workspace(assigns) do + ~H""" + + <%= @hub.hub_emoji %> + <%= @hub.hub_name %> + + """ + 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 diff --git a/test/livebook/runtime/k8s_test.exs b/test/livebook/runtime/k8s_test.exs index f65552571..1b6139506 100644 --- a/test/livebook/runtime/k8s_test.exs +++ b/test/livebook/runtime/k8s_test.exs @@ -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) diff --git a/test/livebook_web/live/home_live_test.exs b/test/livebook_web/live/home_live_test.exs index 7b9e4d1f1..9e4428b7c 100644 --- a/test/livebook_web/live/home_live_test.exs +++ b/test/livebook_web/live/home_live_test.exs @@ -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 diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index 0a737b30e..b175f9d89 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -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