- Store the config in a secret in the <.workspace hub={@hub} /> workspace to reuse it later.
-
-
- <.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"""
-
@@ -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 00000000000..5a31cf92476
--- /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 f65552571a5..1b6139506b8 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 7b9e4d1f16d..9e4428b7c98 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 0a737b30e6a..b175f9d89ce 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