- <.specs_config specs_changeset={@specs_changeset} myself={@myself} />
+
- <.storage_config
+ <.app_config
+ :if={@token_check.status == :ok}
+ org_name={@org.name}
+ regions={@regions}
+ app_name={@app_name}
+ app_check={@app_check}
volumes={@volumes}
- volume_id={@volume_id}
region={@region}
- volume_action={@volume_action}
myself={@myself}
/>
-
- <.button
- phx-click="init"
- phx-target={@myself}
- disabled={
- @runtime_status == :connecting or not @specs_changeset.valid? or
- volume_errors(@volume_id, @volumes, @region) != []
- }
- >
- <%= label(@app_name, @runtime, @runtime_status) %>
-
-
@@ -131,6 +151,99 @@ 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"
+ >
+
+ 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"""
@@ -351,6 +464,12 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
<.text_field field={f[:name]} placeholder="Name" />
<.text_field field={f[:size_gb]} placeholder="Size (GB)" type="number" min="1" />
+ <.button
+ type="submit"
+ disabled={not @volume_action.changeset.valid? or @volume_action.inflight}
+ >
+ <%= if(@volume_action.inflight, do: "Creating...", else: "Create") %>
+
<.button
type="button"
color="gray"
@@ -360,12 +479,6 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
>
Cancel
- <.button
- type="submit"
- disabled={not @volume_action.changeset.valid? or @volume_action.inflight}
- >
- <%= if(@volume_action.inflight, do: "Creating...", else: "Create") %>
-
<.message_box kind={:error} message={error} />
@@ -442,38 +555,64 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
def handle_event("validate_specs", %{"specs" => specs}, socket) do
changeset =
- socket.assigns.specs_changeset.data
- |> specs_changeset(specs)
+ specs
+ |> specs_changeset()
|> Map.replace!(:action, :validate)
{:noreply, assign(socket, specs_changeset: changeset)}
end
def handle_event("init", %{}, socket) do
- socket.assigns.specs_changeset
- |> apply_action(:insert)
- |> case do
- {:ok, specs} ->
- config = %{
- token: socket.assigns.token,
- app_name: socket.assigns.app_name,
- region: socket.assigns.region,
- cpu_kind: specs.cpu_kind,
- cpus: specs.cpus,
- memory_gb: specs.memory_gb,
- gpu_kind: specs.gpu_kind,
- gpus: specs.gpus,
- volume_id: socket.assigns.volume_id,
- docker_tag: specs.docker_tag
- }
+ config = build_config(socket)
+ runtime = Runtime.Fly.new(config)
+ Session.set_runtime(socket.assigns.session.pid, runtime)
+ Session.connect_runtime(socket.assigns.session.pid)
+ {:noreply, socket}
+ end
- runtime = Runtime.Fly.new(config)
- Session.set_runtime(socket.assigns.session.pid, runtime)
- Session.connect_runtime(socket.assigns.session.pid)
- {:noreply, socket}
+ 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(socket, specs_changeset: 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
@@ -482,11 +621,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
socket =
case result do
{:ok, %{orgs: [org]} = data} ->
- region =
- case socket.assigns.runtime do
- %Runtime.Fly{config: config} -> config.region
- _ -> data.closest_region
- end
+ region = socket.assigns.config_defaults["region"] || data.closest_region
socket
|> assign(org: org, regions: data.regions, region: region)
@@ -510,13 +645,9 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
case result do
{:ok, volumes} ->
volume_id =
- case socket.assigns.runtime do
- %Runtime.Fly{config: %{volume_id: volume_id}} ->
- # Ignore the volume if it no longer exists
- if Enum.any?(volumes, &(&1.id == volume_id)), do: volume_id
-
- _ ->
- nil
+ if volume_id = socket.assigns.config_defaults["volume_id"] do
+ # Ignore the volume if it no longer exists
+ if Enum.any?(volumes, &(&1.id == volume_id)), do: volume_id
end
socket
@@ -575,6 +706,22 @@ 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)
@@ -610,18 +757,40 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
}
end
- defp specs_changeset(config, attrs \\ %{}) do
- defaults = %{
+ 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(
+ token: config_defaults["token"],
+ app_name: config_defaults["app_name"],
+ specs_changeset: specs_changeset(config_defaults)
+ )
+ |> load_org_and_regions()
+ |> load_app()
+ end
+
+ defp specs_changeset(attrs \\ %{}) do
+ docker_tags = Enum.map(Livebook.Config.docker_images(), & &1.tag)
+
+ data = %{
cpu_kind: "shared",
cpus: 1,
memory_gb: 1,
gpu_kind: nil,
gpus: nil,
- docker_tag: Livebook.Config.docker_images() |> hd() |> Map.fetch!(:tag)
+ docker_tag: hd(docker_tags)
}
- data = for {key, default} <- defaults, into: %{}, do: {key, Map.get(config, key, default)}
-
types = %{
cpu_kind: :string,
cpus: :integer,
@@ -634,6 +803,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
changeset =
cast({data, types}, attrs, Map.keys(types))
|> validate_required([:cpu_kind, :cpus, :memory_gb, :docker_tag])
+ |> validate_inclusion(:docker_tag, docker_tags)
if get_field(changeset, :gpu_kind) do
changeset
@@ -656,6 +826,18 @@ 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
@@ -737,12 +919,52 @@ 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)
+
+ %{
+ token: socket.assigns.token,
+ app_name: socket.assigns.app_name,
+ region: socket.assigns.region,
+ cpu_kind: specs.cpu_kind,
+ cpus: specs.cpus,
+ memory_gb: specs.memory_gb,
+ gpu_kind: specs.gpu_kind,
+ gpus: specs.gpus,
+ volume_id: socket.assigns.volume_id,
+ docker_tag: specs.docker_tag
+ }
+ end
+
defp nullify(""), do: nil
defp nullify(value), do: value
end
diff --git a/lib/livebook_web/live/session_live/render.ex b/lib/livebook_web/live/session_live/render.ex
index 4f322f9fa..b954023b9 100644
--- a/lib/livebook_web/live/session_live/render.ex
+++ b/lib/livebook_web/live/session_live/render.ex
@@ -63,6 +63,8 @@ defmodule LivebookWeb.SessionLive.Render do
runtime={@data_view.runtime}
runtime_status={@data_view.runtime_status}
runtime_connect_info={@data_view.runtime_connect_info}
+ hub={@data_view.hub}
+ hub_secrets={@data_view.hub_secrets}
/>
diff --git a/lib/livebook_web/live/session_live/runtime_component.ex b/lib/livebook_web/live/session_live/runtime_component.ex
index f6985d9c9..a236b18cd 100644
--- a/lib/livebook_web/live/session_live/runtime_component.ex
+++ b/lib/livebook_web/live/session_live/runtime_component.ex
@@ -82,6 +82,8 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
runtime={@runtime}
runtime_status={@runtime_status}
runtime_connect_info={@runtime_connect_info}
+ hub={@hub}
+ hub_secrets={@hub_secrets}
/>
diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs
index d88448f0a..794f2afc3 100644
--- a/test/livebook_web/live/session_live_test.exs
+++ b/test/livebook_web/live/session_live_test.exs
@@ -1166,6 +1166,109 @@ defmodule LivebookWeb.SessionLiveTest do
|> element(~s/select[name="specs[cpu_kind]"] option[value="performance"][selected]/)
|> has_element?()
end
+
+ test "saving and loading config from secret", %{conn: conn, session: session} do
+ runtime =
+ Runtime.Fly.new(%{
+ token: "my-token",
+ app_name: "my-app",
+ region: "ams",
+ cpu_kind: "performance",
+ cpus: 1,
+ memory_gb: 1,
+ gpu_kind: nil,
+ gpus: nil,
+ volume_id: "vol_1",
+ docker_tag: "edge"
+ })
+
+ Session.set_runtime(session.pid, runtime)
+
+ Livebook.FlyAPI.stub(fn
+ conn when conn.method == "POST" ->
+ Req.Test.json(conn, %{
+ "data" => %{
+ "organizations" => %{
+ "nodes" => [
+ %{
+ "id" => "1",
+ "name" => "Grumpy Cat",
+ "rawSlug" => "grumpy-cat",
+ "slug" => "personal"
+ }
+ ]
+ },
+ "platform" => %{
+ "regions" => [
+ %{"code" => "ams", "name" => "Amsterdam, Netherlands"},
+ %{"code" => "fra", "name" => "Frankfurt, Germany"}
+ ],
+ "requestRegion" => "fra"
+ }
+ }
+ })
+
+ conn
+ when conn.method == "GET" and
+ conn.path_info == ["v1", "apps", "my-app", "volumes"] ->
+ Req.Test.json(conn, [
+ %{
+ "id" => "vol_1",
+ "name" => "new_volume",
+ "region" => "ams",
+ "size_gb" => 1,
+ "state" => "created"
+ }
+ ])
+ end)
+
+ {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/settings/runtime")
+
+ # The form is already filled with the runtime configuration, we
+ # just save it in a secret
+ view
+ |> element("button", "Save config")
+ |> render_click()
+
+ secret_name = "FLY_RUNTIME_#{System.unique_integer([:positive])}"
+
+ view
+ |> element(~s/form[phx-submit="save_config"]/)
+ |> render_submit(%{secret: %{name: secret_name}})
+
+ assert render_async(view) =~ "Load config"
+
+ # Set a different runtime, so there are no defaults
+ Session.set_runtime(session.pid, Runtime.Standalone.new())
+
+ # Open new runtime configuratino
+ {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/settings/runtime")
+
+ view
+ |> element("#runtime-settings-modal button", "Fly.io machine")
+ |> render_click()
+
+ refute render(view) =~ "CPU kind"
+
+ # Load the configuration from secret
+ view
+ |> element("#config-secret-menu-content button", secret_name)
+ |> render_click()
+
+ assert render_async(view) =~ "Grumpy Cat"
+
+ assert view
+ |> element(~s/select[name="region"] option[value="ams"][selected]/)
+ |> has_element?()
+
+ assert view
+ |> element(~s/select[name="volume_id"] option[value="vol_1"][selected]/)
+ |> has_element?()
+
+ assert view
+ |> element(~s/select[name="specs[cpu_kind]"] option[value="performance"][selected]/)
+ |> has_element?()
+ end
end
describe "persistence settings" do