defmodule Livebook.Runtime.Fly do # A runtime backed by a Fly.io machine managed by Livebook. # # This runtime uses a Livebook-managed Elixir node, similarly to # the Standalone runtime, however it runs on a temporary Fly.io # machine. The machine is configured to automatically shutdown # as soon as the runtime is disconnected. # # Note: this runtime requires `flyctl` executable to be available # in the system. # # ## Communication # # The machine runs the Livebook Docker image and we configure it to # invoke the start_runtime.exs script, by setting LIVEBOOK_RUNTIME. # This environment variable also includes encoded information passed # from the parent. Once the Elixir node starts on the machine, it # waits for the parent to connect and finish the initialization. # # Now, we want to establish a distribution connection from the local # Livebook node to the node on the Fly.io machine. We could reach # the node directly, by requiring the user to set up WireGuard. # However, that would require the user to install WireGuard and go # through a few configuration steps. Instead, we use flyctl proxy # feature and only require flyctl to be installed. # # With flyctl proxy, we proxy a local port to the the distribution # port of the Fly.io node. Then, in our EPMD module (`Livebook.EPMD`) # we special case those nodes in two ways: (1) we infer the # distribution port from the node name; (2) we resolve the node # address to loopback, ignoring its hostname. # # ### Distribution protocol # # Usually, nodes need to be configured to use the same distribution # protocol (`-proto_dist`). We configure the Fly.io node to use IPv6 # distribution (`-proto_dist inet6_tcp`). However, depending whether # the local node runs IPv4 or IPv6 distribution, we configure the # flyctl proxy to bind to a IPv4 or IPv6 loopback respectively. The # proxy always communicates with the Fly.io machine over IPv6, as # is the case with all internal networking. Consequently, regardless # of the protocol used by the local node, the remote node perceives # it as IPv6. # # Sidenote, a node using IPv6 distribution may accept connections # from a node using IPv4, depending on the `:kernel` application # configuration `inet_dist_listen_options` -> `ipv6_v6only`, which # has OS-specific value. However, we don't rely on this here. defstruct [:config, :node, :server_pid, :machine_id, :previous_machine_id] use GenServer, restart: :temporary alias Livebook.Config alias Livebook.Runtime.RemoteUtils @type t :: %__MODULE__{ config: config(), node: node() | nil, server_pid: pid() | nil, machine_id: String.t() | nil, previous_machine_id: String.t() | nil } @type config :: %{ token: String.t(), app_name: String.t(), region: String.t(), cpu_kind: String.t(), cpus: pos_integer(), memory_gb: pos_integer(), gpu_kind: String.t() | nil, gpus: pos_integer() | nil, volume_id: String.t() | nil, docker_tag: String.t() } @doc """ Returns a new runtime instance. """ @spec new(config()) :: t() def new(config) do %__MODULE__{config: config} end def __connect__(runtime) do {:ok, pid} = DynamicSupervisor.start_child(Livebook.RuntimeSupervisor, {__MODULE__, {runtime, self()}}) pid end @doc false def start_link({runtime, caller}) do GenServer.start_link(__MODULE__, {runtime, caller}) end @impl true def init({runtime, caller}) do state = %{primary_ref: nil, proxy_port: nil} {:ok, state, {:continue, {:init, runtime, caller}}} end @impl true def handle_continue({:init, runtime, caller}, state) do config = runtime.config local_port = RemoteUtils.get_free_port!() node_base = "remote_runtime_#{local_port}" runtime_data = RemoteUtils.encode_runtime_data(node_base) parent = self() {:ok, watcher_pid} = DynamicSupervisor.start_child( Livebook.RuntimeSupervisor, {Task, fn -> watcher(parent, config) end} ) with :ok <- (if config.volume_id && runtime.previous_machine_id do with_log(caller, "await resources", fn -> await_previous_machine_destroyed(config, runtime.previous_machine_id) end) else :ok end), {:ok, machine_id, machine_ip} <- with_log(caller, "create machine", fn -> create_machine(config, runtime_data) end), _ <- send(watcher_pid, {:machine_created, machine_id}), 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, config.token) end), :ok <- with_log(caller, "machine starting", fn -> await_machine_started(config, machine_id) end), :ok <- with_log(caller, "connect to node", fn -> RemoteUtils.connect(child_node) end), %{pid: primary_pid} <- RemoteUtils.fetch_runtime_info(child_node) do primary_ref = Process.monitor(primary_pid) server_pid = with_log(caller, "initialize node", fn -> RemoteUtils.initialize_node(child_node) end) send(primary_pid, :node_initialized) send(watcher_pid, :done) runtime = %{runtime | node: child_node, server_pid: server_pid, machine_id: machine_id} send(caller, {:runtime_connect_done, self(), {:ok, runtime}}) {:noreply, %{state | primary_ref: primary_ref, proxy_port: proxy_port}} else {:error, error} -> send(caller, {:runtime_connect_done, self(), {:error, error}}) {:stop, :shutdown, state} end end @impl true def handle_info({:DOWN, ref, :process, _pid, _reason}, state) when ref == state.primary_ref do {:stop, :shutdown, state} end def handle_info({port, _message}, state) when state.proxy_port == port do {:noreply, state} end defp watcher(parent, config) do ref = Process.monitor(parent) watcher_loop(%{ref: ref, config: config, machine_id: nil}) end defp watcher_loop(state) do receive do {:DOWN, ref, :process, _pid, _reason} when ref == state.ref -> # If the parent process is killed, we try to eagerly free the # created resources if machine_id = state.machine_id do config = state.config _ = Livebook.FlyAPI.delete_machine(config.token, config.app_name, machine_id) end {:machine_created, machine_id} -> watcher_loop(%{state | machine_id: machine_id}) :done -> :ok end end defp create_machine(config, runtime_data) do base_image = Enum.find(Livebook.Config.docker_images(), &(&1.tag == config.docker_tag)) image_registry_url = Config.image_registry_url() image = "#{image_registry_url}:#{base_image.tag}" env = Map.merge( Map.new(base_image.env), %{ "LIVEBOOK_RUNTIME" => runtime_data, "ERL_AFLAGS" => "-proto_dist inet6_tcp", # Make the token automatically available for FLAME "FLY_API_TOKEN" => config.token } ) name = "#{config.app_name}-livebook-runtime-#{Livebook.Utils.random_id()}" machine_config = %{ image: image, guest: %{ cpu_kind: config.cpu_kind, cpus: config.cpus, memory_mb: config.memory_gb * 1024, gpu_kind: config.gpu_kind, gpus: config.gpus }, mounts: config.volume_id && [%{volume: config.volume_id, path: "/home/livebook"}], auto_destroy: true, restart: %{policy: "no"}, env: env, metadata: %{livebook_runtime: "true"} } case Livebook.FlyAPI.create_machine( config.token, config.app_name, name, config.region, machine_config ) do {:ok, %{id: machine_id, private_ip: machine_ip}} -> {:ok, machine_id, machine_ip} {:error, %{message: message}} -> {:error, "could not create machine, reason: #{message}"} end end defp await_previous_machine_destroyed(config, machine_id) do # We wait only to ensure the volume is detached. If waiting fails, # we ignore the error and try to create the machine anyway, if the # volume is attached, creation will fail _ = Livebook.FlyAPI.await_machine_destroyed(config.token, config.app_name, machine_id, 5) :ok end defp await_machine_started(config, machine_id) do case Livebook.FlyAPI.await_machine_started(config.token, config.app_name, machine_id) do :ok -> :ok {:error, %{message: message}} -> {:error, "failed while waiting for the machine to start, reason: #{message}." <> " See the app logs in the Fly.io dashboard to determine the reason"} end end defp start_fly_proxy(app_name, host, local_port, token) do with {:ok, flyctl_path} <- find_fly_executable() do ports = "#{local_port}:#{RemoteUtils.remote_port()}" # We want the proxy to accept the same protocol that we are # going to use for distribution bind_addr = if Livebook.Utils.proto_dist() == :inet6_tcp do "[::1]" else "127.0.0.1" end args = [ "proxy", ports, host, "--app", app_name, "--bind-addr", bind_addr, "--access-token", token, "--watch-stdin" ] env = [{~c"FLY_NO_UPDATE_CHECK", ~c"1"}] port = Port.open( {:spawn_executable, flyctl_path}, [:binary, :hide, :stderr_to_stdout, args: args, env: env] ) port_ref = Port.monitor(port) result = receive do {^port, {:data, "Proxying " <> _}} -> {:ok, port} {^port, {:data, "Error: unknown flag: --watch-stdin\n"}} -> {:error, "failed to open fly proxy, because the current version " <> "is missing a required feature. Please update flyctl"} {^port, {:data, "Error: " <> error}} -> {:error, "failed to open fly proxy. Error: #{String.trim(error)}"} {:DOWN, ^port_ref, :port, _object, reason} -> {:error, "failed to open fly proxy. Process terminated, reason: #{inspect(reason)}"} after 30_000 -> {:error, "failed to open fly proxy. Timed out after 30s"} end Port.demonitor(port_ref, [:flush]) result end end defp find_fly_executable() do if path = System.find_executable("flyctl") || default_flyctl_path() do {:ok, path} else {:error, "no flyctl executable found in PATH. For installation instructions" <> " refer to https://fly.io/docs/flyctl/install"} end end defp default_flyctl_path() do # Checks the default locations where flyctl gets installed using # the official instructions home = System.user_home() paths = [ "/opt/homebrew/bin/flyctl", home && Path.join(home, ".fly/bin/flyctl") ] Enum.find(paths, fn path -> path && File.regular?(path) end) end defp with_log(caller, name, fun) do send(caller, {:runtime_connect_info, self(), name}) RemoteUtils.with_log("[fly runtime] #{name}", fun) end end defimpl Livebook.Runtime, for: Livebook.Runtime.Fly do alias Livebook.Runtime.ErlDist.RuntimeServer def describe(%{config: config} = runtime) do specs = [ "#{config.cpus} #{config.cpu_kind} CPU", "#{config.memory_gb} GB RAM", config.gpu_kind && "#{config.gpus} #{config.gpu_kind} GPU" ] |> Enum.reject(&is_nil/1) |> Enum.join(", ") [ {"Type", "Fly.io machine"}, {"App", config.app_name}, {"Specs", specs} ] ++ if runtime.node do [{"Node name", Atom.to_string(runtime.node)}] else [] end end def connect(runtime) do Livebook.Runtime.Fly.__connect__(runtime) end def take_ownership(runtime, opts \\ []) do RuntimeServer.attach(runtime.server_pid, self(), opts) Process.monitor(runtime.server_pid) end def disconnect(runtime) do :ok = RuntimeServer.stop(runtime.server_pid) end def duplicate(runtime) do %Livebook.Runtime.Fly{ config: runtime.config, previous_machine_id: runtime.machine_id } end def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ []) do RuntimeServer.evaluate_code( runtime.server_pid, language, code, locator, parent_locators, opts ) end def forget_evaluation(runtime, locator) do RuntimeServer.forget_evaluation(runtime.server_pid, locator) end def drop_container(runtime, container_ref) do RuntimeServer.drop_container(runtime.server_pid, container_ref) end def handle_intellisense(runtime, send_to, language, request, parent_locators, node) do RuntimeServer.handle_intellisense( runtime.server_pid, send_to, language, request, parent_locators, node ) end def read_file(runtime, path) do RuntimeServer.read_file(runtime.server_pid, path) end def transfer_file(runtime, path, file_id, callback) do RuntimeServer.transfer_file(runtime.server_pid, path, file_id, callback) end def relabel_file(runtime, file_id, new_file_id) do RuntimeServer.relabel_file(runtime.server_pid, file_id, new_file_id) end def revoke_file(runtime, file_id) do RuntimeServer.revoke_file(runtime.server_pid, file_id) end def start_smart_cell(runtime, kind, ref, attrs, parent_locators) do RuntimeServer.start_smart_cell(runtime.server_pid, kind, ref, attrs, parent_locators) end def set_smart_cell_parent_locators(runtime, ref, parent_locators) do RuntimeServer.set_smart_cell_parent_locators(runtime.server_pid, ref, parent_locators) end def stop_smart_cell(runtime, ref) do RuntimeServer.stop_smart_cell(runtime.server_pid, ref) end def fixed_dependencies?(_runtime), do: false def add_dependencies(_runtime, code, dependencies) do Livebook.Runtime.Dependencies.add_dependencies(code, dependencies) end def has_dependencies?(runtime, dependencies) do RuntimeServer.has_dependencies?(runtime.server_pid, dependencies) end def snippet_definitions(_runtime) do Livebook.Runtime.Definitions.snippet_definitions() end def search_packages(_runtime, send_to, search) do Livebook.Runtime.Dependencies.search_packages_on_hex(send_to, search) end def put_system_envs(runtime, envs) do RuntimeServer.put_system_envs(runtime.server_pid, envs) end def delete_system_envs(runtime, names) do RuntimeServer.delete_system_envs(runtime.server_pid, names) end def restore_transient_state(runtime, transient_state) do RuntimeServer.restore_transient_state(runtime.server_pid, transient_state) end def register_clients(runtime, clients) do RuntimeServer.register_clients(runtime.server_pid, clients) end def unregister_clients(runtime, client_ids) do RuntimeServer.unregister_clients(runtime.server_pid, client_ids) end def fetch_proxy_handler_spec(runtime) do RuntimeServer.fetch_proxy_handler_spec(runtime.server_pid) end def disconnect_node(runtime, node) do RuntimeServer.disconnect_node(runtime.server_pid, node) end end