defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do use LivebookWeb, :live_component import Ecto.Changeset alias Livebook.{Session, Runtime} @config_secret_prefix "FLY_RUNTIME_" @impl true def mount(socket) do unless Livebook.Config.runtime_enabled?(Livebook.Runtime.Fly) do raise "runtime module not allowed" end {:ok, assign(socket, token: nil, token_check: %{status: :initial, error: nil}, org: nil, regions: nil, app_name: nil, app_check: %{status: :initial, error: nil}, volumes: nil, region: nil, specs_changeset: specs_changeset(), volume_id: nil, volume_action: nil, save_config: nil )} end @impl true def update(assigns, socket) do socket = assign(socket, assigns) socket = cond do is_map_key(socket.assigns, :config_defaults) -> socket is_struct(assigns.runtime, Runtime.Fly) -> %{config: config} = assigns.runtime config_defaults = Map.new(config, fn {key, value} -> {Atom.to_string(key), value} end) socket |> assign(config_defaults: config_defaults) |> load_config_defaults() true -> assign(socket, config_defaults: nil) end {:ok, socket} end @impl true def render(assigns) do ~H"""

Start a temporary Fly.io machine with an Elixir node to evaluate code. 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} />
<.password_field name="token" value={@token} label="Token" /> <.message_box :if={@token == nil} kind={:info}> Go to Fly dashboard, click "Tokens" in the left sidebar and create a new token for your organization of choice. This functionality is restricted to organization admins. Alternatively, you can create an app in the organization by running fly app create and generate a deploy token in the app dashboard. <.loader :if={@token_check.status == :inflight} /> <.message_box :if={error = @token_check.error} kind={:error} message={"Error: " <> error.message} />
<.app_config :if={@token_check.status == :ok} org_name={@org.name} regions={@regions} app_name={@app_name} app_check={@app_check} volumes={@volumes} region={@region} myself={@myself} />
<.specs_config specs_changeset={@specs_changeset} myself={@myself} /> <.storage_config 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) %>
<.message_box kind={:info}>
<.spinner /> Step: <%= @runtime_connect_info %>
""" 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"""
Loading <.spinner />
""" end defp app_config(assigns) do ~H"""
<.text_field name="org" label="Organization" value={@org_name} readonly />
<.text_field name="app_name" label="App" value={@app_name} phx-debounce="500" />
<.select_field name="region" label="Region" value={@region} options={region_options(@regions)} />
<.message_box :if={@app_name == nil} kind={:info} message="Specify the app where machines should be created." /> <.loader :if={@app_check.status == :inflight} /> <.app_check_error :if={@app_check.error} error={@app_check.error} app_name={@app_name} myself={@myself} />
""" end defp app_check_error(%{error: %{status: 404}} = assigns) do ~H""" <.message_box kind={:info}>
App <%= @app_name %> does not exist yet.
<.button phx-click="create_app" phx-target={@myself}> Create
""" end defp app_check_error(%{error: %{status: 403}} = assigns) do ~H""" <.message_box kind={:error} message={ "This app name is already taken, pick a different name." <> " If this is an app you own, enter a token for the corresponding organization." } /> """ end defp app_check_error(assigns) do ~H""" <.message_box kind={:error} message={"Error: " <> @error.message} /> """ end defp specs_config(assigns) do ~H"""
Specs
For more details refer to Machine sizing and Pricing pages in the Fly.io documentation.
<.form :let={f} for={@specs_changeset} as={:specs} class="mt-4 flex flex-col gap-4" phx-change="validate_specs" phx-nosubmit phx-target={@myself} autocomplete="off" spellcheck="false" >
<.select_field field={f[:cpu_kind]} label="CPU kind" options={cpu_kind_options()} /> <.text_field field={f[:cpus]} label="CPUs" type="number" min="1" /> <.text_field field={f[:memory_gb]} label="Memory (GB)" type="number" step="1" min="1" /> <.select_field field={f[:gpu_kind]} label="GPU kind" options={gpu_kind_options()} /> <.text_field field={f[:gpus]} label="GPUs" type="number" min="1" disabled={get_field(@specs_changeset, :gpu_kind) == nil} />
GPUs are available only in certain regions, see Getting started with GPUs.
<.radio_field field={f[:docker_tag]} label="Base Docker image" options={LivebookWeb.AppComponents.docker_tag_options()} />
""" end defp storage_config(assigns) do ~H"""
Storage
Every time you connect to the runtime, a fresh machine is created. In order to persist data and caches, you can optionally mount a volume at /home/livebook. Keep in mind that volumes are billed even when not in use, so you may want to remove those no longer needed.
<.select_field name="volume_id" label="Volume" value={@volume_id} options={[{"None", ""} | volume_options(@volumes)]} errors={volume_errors(@volume_id, @volumes, @region)} />
<.icon_button phx-click="delete_volume" phx-target={@myself} disabled={@volume_id == nil or (@volume_action != nil and @volume_action.inflight)} > <.remix_icon icon="delete-bin-6-line" /> <.icon_button phx-click="new_volume" phx-target={@myself}> <.remix_icon icon="add-line" />

Are you sure you want to irreversibly delete <%= @volume_id %>?

<.form :let={f} :if={@volume_action[:type] == :new} for={@volume_action.changeset} as={:volume} phx-submit="create_volume" phx-change="validate_volume" phx-target={@myself} class="flex gap-2 items-center" autocomplete="off" spellcheck="false" >
<.remix_icon icon="corner-down-right-line" class="text-gray-400 text-lg" />
<.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" outlined phx-click="cancel_new_volume" phx-target={@myself} > Cancel
<.message_box kind={:error} message={"Error: " <> error.message} />
""" end @impl true def handle_event("set_token", %{"token" => token}, socket) do {:noreply, socket |> assign(token: nullify(token)) |> load_org_and_regions()} end def handle_event("set_app_name", %{"app_name" => app_name}, socket) do {:noreply, socket |> assign(app_name: nullify(app_name)) |> load_app()} end def handle_event("set_region", %{"region" => region}, socket) do {:noreply, assign(socket, region: region)} end def handle_event("create_app", %{}, socket) do {:noreply, create_app(socket)} end def handle_event("set_volume_id", %{"volume_id" => volume_id}, socket) do {:noreply, assign(socket, volume_id: nullify(volume_id), volume_action: nil)} end def handle_event("delete_volume", %{}, socket) do volume_action = %{type: :delete, inflight: false, error: nil} {:noreply, assign(socket, volume_action: volume_action)} end def handle_event("cancel_delete_volume", %{}, socket) do {:noreply, assign(socket, volume_action: nil)} end def handle_event("confirm_delete_volume", %{}, socket) do {:noreply, delete_volume(socket)} end def handle_event("new_volume", %{}, socket) do volume_action = %{type: :new, changeset: volume_changeset(), inflight: false, error: false} {:noreply, assign(socket, volume_action: volume_action)} end def handle_event("cancel_new_volume", %{}, socket) do {:noreply, assign(socket, volume_action: nil)} end def handle_event("validate_volume", %{"volume" => volume}, socket) do changeset = volume |> volume_changeset() |> Map.replace!(:action, :validate) {:noreply, assign_nested(socket, :volume_action, changeset: changeset)} end def handle_event("create_volume", %{"volume" => volume}, socket) do volume |> volume_changeset() |> apply_action(:insert) |> case do {:ok, %{name: name, size_gb: size_gb}} -> {:noreply, create_volume(socket, name, size_gb)} {:error, changeset} -> {:noreply, assign_nested(socket, :volume_action, changeset: changeset)} end end def handle_event("validate_specs", %{"specs" => specs}, socket) do changeset = specs |> specs_changeset() |> Map.replace!(:action, :validate) {:noreply, assign(socket, specs_changeset: changeset)} end def handle_event("init", %{}, socket) do 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 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 = case result do {:ok, %{orgs: [org]} = data} -> region = socket.assigns.config_defaults["region"] || data.closest_region socket |> assign(org: org, regions: data.regions, region: region) |> assign(:token_check, %{status: :ok, error: nil}) {:ok, %{orgs: orgs}} -> error = "expected organization-specific auth token, but the given one gives access to #{length(orgs)} organizations" assign(socket, :token_check, %{status: :error, error: error}) {:error, error} -> assign(socket, :token_check, %{status: :error, error: error}) end {:noreply, socket} end def handle_async(:load_app, {:ok, result}, socket) do socket = case result do {:ok, volumes} -> volume_id = 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 |> assign(volumes: volumes, volume_id: volume_id) |> assign(:app_check, %{status: :ok, error: nil}) {:error, error} -> assign(socket, :app_check, %{status: :error, error: error}) end {:noreply, socket} end def handle_async(:create_app, {:ok, result}, socket) do socket = case result do :ok -> socket |> assign(volumes: [], volume_id: nil) |> assign(:app_check, %{status: :ok, error: nil}) {:error, error} -> assign(socket, :app_check, %{status: :error, error: error}) end {:noreply, socket} end def handle_async(:create_volume, {:ok, result}, socket) do socket = case result do {:ok, volume} -> volumes = [volume | socket.assigns.volumes] assign(socket, volumes: volumes, volume_id: volume.id, volume_action: nil) {:error, error} -> assign_nested(socket, :volume_action, error: error, inflight: false) end {:noreply, socket} end def handle_async(:delete_volume, {:ok, result}, socket) do volume_id = socket.assigns.volume_id socket = case result do :ok -> volumes = Enum.reject(socket.assigns.volumes, &(&1.id == volume_id)) assign(socket, volumes: volumes, volume_id: nil, volume_action: nil) {:error, error} -> assign_nested(socket, :volume_action, error: error, inflight: false) end {: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) case {reconnecting?, runtime_status} do {true, :connected} -> "Reconnect" {true, :connecting} -> "Connecting..." _ -> "Connect" end end defp reconnecting?(app_name, runtime) do match?(%Runtime.Fly{config: %{app_name: ^app_name}}, runtime) end defp cpu_kind_options() do Enum.map(Livebook.FlyAPI.cpu_kinds(), &{&1, &1}) end defp gpu_kind_options() do [{"None", ""}] ++ Enum.map(Livebook.FlyAPI.gpu_kinds(), &{&1, &1}) end defp region_options(regions) do for region <- regions, do: {"#{region.name} (#{region.code})", region.code} end defp volume_options(volumes) do for volume <- Enum.sort_by(volumes, &{&1.name, &1.id}), do: { "#{volume.id} (name: #{volume.name}, region: #{volume.region}, size: #{volume.size_gb} GB)", volume.id } 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( 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: hd(docker_tags) } types = %{ cpu_kind: :string, cpus: :integer, memory_gb: :integer, gpu_kind: :string, gpus: :integer, docker_tag: :string } 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 else # We may be reverting back to the defult, so we force the change # to take precedence over form params in Phoenix.HTML.FormData force_change(changeset, :gpus, nil) end end defp volume_changeset(attrs \\ %{}) do data = %{name: nil, size_gb: nil} types = %{ name: :string, size_gb: :integer } cast({data, types}, attrs, Map.keys(types)) |> 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 volume = Enum.find(volumes, &(&1.id == volume_id)) if volume.region == region do [] else ["must be in the same region as the machine (#{region})"] end end defp load_org_and_regions(socket) when socket.assigns.token == nil do assign(socket, :token_check, %{status: :initial, error: nil}) end defp load_org_and_regions(socket) do token = socket.assigns.token socket |> start_async(:load_org_and_regions, fn -> Livebook.FlyAPI.get_orgs_and_regions(token) end) |> assign(:token_check, %{status: :inflight, error: nil}) end defp load_app(socket) when socket.assigns.app_name == nil do assign(socket, :app_check, %{status: :initial, error: nil}) end defp load_app(socket) do %{token: token, app_name: app_name} = socket.assigns socket |> start_async(:load_app, fn -> Livebook.FlyAPI.get_app_volumes(token, app_name) end) |> assign(:app_check, %{status: :inflight, error: nil}) end defp create_app(socket) do %{token: token, app_name: app_name} = socket.assigns org_slug = socket.assigns.org.slug socket |> start_async(:create_app, fn -> Livebook.FlyAPI.create_app(token, app_name, org_slug) end) |> assign(:app_check, %{status: :inflight, error: nil}) end defp delete_volume(socket) do %{token: token, app_name: app_name, volume_id: volume_id} = socket.assigns socket |> start_async(:delete_volume, fn -> Livebook.FlyAPI.delete_volume(token, app_name, volume_id) end) |> assign_nested(:volume_action, inflight: true) end defp create_volume(socket, name, size_gb) do %{token: token, app_name: app_name, region: region} = socket.assigns specs = apply_changes(socket.assigns.specs_changeset) compute = %{ cpu_kind: specs.cpu_kind, cpus: specs.cpus, memory_mb: specs.memory_gb * 1024, gpu_kind: specs.gpu_kind, gpus: specs.gpus } socket |> start_async(:create_volume, fn -> Livebook.FlyAPI.create_volume(token, app_name, name, region, size_gb, compute) end) |> 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