defmodule Livebook.Apps.Deployer do # Deploys apps on the given node. # # Each node runs a single deployer, which deploys apps sequentially. # This design makes sure that only one app warmup is going to run # at a time, rather than concurrently, which reduces memory usage # and prevents race conditions with `Mix.install/2`. use GenServer require Logger alias Livebook.App alias Livebook.Apps @doc """ Starts a new deployer process. """ @spec start_link(keyword()) :: GenServer.on_start() def start_link(_opts) do GenServer.start_link(__MODULE__, {}) end @doc """ Asynchronously requests an app deployment. If there is no app process under the requested slug, it is started. Otherwise the notebook is deployed as a new version into the existing app. This function automatically starts monitoring the deployer process and returns the monitor reference. When the deployment finished the caller receives a message of the form `{:deploy_result, ref, result}`, with the same monitor reference and the result of `Apps.deploy/2`. The caller should then demonitor the reference. ## Options * `:start_only` - when `true`, deploys only if the app does not exist already. If the app does exist, the deployment finishes with an error. Defaults to `false` * `:permanent` - whether the app is deployed as permanent. Defaults to `false` """ @spec deploy_monitor(pid(), Apps.AppSpec.t(), keyword()) :: reference() def deploy_monitor(pid, app_spec, opts \\ []) do opts = Keyword.validate!(opts, start_only: false, permanent: false) ref = Process.monitor(pid) GenServer.cast(pid, {:deploy, app_spec, opts[:start_only], opts[:permanent], self(), ref}) ref end @doc """ Returns a list of all deployers present in the cluster. """ @spec list_deployers() :: list(pid()) def list_deployers() do :pg.get_members(Apps.Deployer.PG, Apps.Deployer) end @doc """ Returns a node-local deployer. """ @spec local_deployer(node()) :: pid() def local_deployer(node \\ node()) do list_deployers() |> Enum.find(&(node(&1) == node)) || raise "no local deployer running" end @impl true def init({}) do :pg.join(Apps.Deployer.PG, __MODULE__, self()) {:ok, %{}} end @impl true def handle_cast({:deploy, app_spec, start_only, permanent, from, ref}, state) do Logger.info("[app=#{app_spec.slug}] Deploying app") files_tmp_path = Apps.generate_files_tmp_path(app_spec.slug) result = with {:ok, %{notebook: notebook, warnings: warnings}} <- Apps.AppSpec.load(app_spec, files_tmp_path) do if Apps.AppSpec.should_warmup?(app_spec) do with {:error, message} <- Apps.warmup_app(notebook, files_tmp_path) do Logger.warning("[app=#{app_spec.slug}] App warmup failed, #{message}") end end deployment_bundle = %{ notebook: notebook, files_tmp_path: files_tmp_path, app_spec: app_spec, permanent: permanent, warnings: warnings } name = Apps.global_name(app_spec.slug) opts = [start_only: start_only] with {:error, error} <- start_or_redeploy(name, deployment_bundle, opts) do File.rm_rf(files_tmp_path) {:error, error} end end send(from, {:deploy_result, ref, result}) {:noreply, state} end defp start_or_redeploy(name, deployment_bundle, opts) do case :global.whereis_name(name) do :undefined -> opts = [deployment_bundle: deployment_bundle] case DynamicSupervisor.start_child(Livebook.AppSupervisor, {App, opts}) do {:ok, pid} -> {:ok, pid} {:error, :already_started} -> # We could use a global transaction to prevent the unlikely # case of multiple nodes starting the app simultaneously. # However, the global registration can still fail if the # node joins the cluster while the app process is starting. # So we handle both cases the same way in this branch. start_or_redeploy(name, deployment_bundle, opts) {:error, reason} -> {:error, "app process failed to start, reason: #{inspect(reason)}"} end pid -> redeploy_app(pid, deployment_bundle, opts) end end defp redeploy_app(pid, deployment_bundle, opts) do cond do opts[:start_only] -> {:error, :already_started} node(pid) != node() -> # This is relevant specifically when :files_source points to # a local directory, but we generally expect redeployments to # happen within the same node, so it's good to enforce this {:error, "cannot deploy a new notebook to an app running on a different node"} true -> App.deploy(pid, deployment_bundle) {:ok, pid} end end end