defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do use LivebookWeb, :live_component import Ecto.Changeset @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_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) socket = cond do is_map_key(socket.assigns, :config_defaults) -> socket is_struct(assigns.runtime, Livebook.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.

<.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_" />
<.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)} <.button :if={@runtime_status == :connecting} color="red" outlined phx-click="disconnect" phx-target={@myself} > Disconnect
<.message_box kind="info">
<.spinner /> Step: {@runtime_connect_info}
""" 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.status == :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.status == :inflight} > {if(@volume_action.status == :inflight, do: "Creating...", else: "Create")} <.button type="button" color="gray" outlined phx-click="cancel_new_volume" phx-target={@myself} disabled={@volume_action.status == :inflight} > Cancel
<.message_box kind="error" message={"Error: " <> @volume_action.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, status: :initial, 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(), status: :initial, error: nil} {: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 = Livebook.Runtime.Fly.new(config) Livebook.Session.set_runtime(socket.assigns.session.pid, runtime) Livebook.Session.connect_runtime(socket.assigns.session.pid) {:noreply, socket} end def handle_event("disconnect", %{}, socket) do Livebook.Session.disconnect_runtime(socket.assigns.session.pid) {:noreply, socket} 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, status: :error) 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, status: :error) 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?(%Livebook.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 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 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, status: :inflight) 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, status: :inflight) 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