defmodule LivebookWeb.SessionLive.AppDockerComponent do use LivebookWeb, :live_component import Ecto.Changeset alias Livebook.Hubs alias Livebook.FileSystem alias LivebookWeb.AppComponents alias Livebook.Hubs.Provider @impl true def update(assigns, socket) do deployment_group_changed? = not Map.has_key?(socket.assigns, :deployment_group_id) or socket.assigns.deployment_group_id != assigns.deployment_group_id socket = assign(socket, assigns) deployment_groups = Provider.deployment_groups(assigns.hub) deployment_group = if assigns.deployment_group_id do Enum.find(deployment_groups, &(&1.id == assigns.deployment_group_id)) end socket = socket |> assign(settings_valid?: Livebook.Notebook.AppSettings.valid?(socket.assigns.settings)) |> assign( hub_secrets: Hubs.get_secrets(assigns.hub), hub_file_systems: Hubs.get_file_systems(assigns.hub, hub_only: true), deployment_groups: deployment_groups, deployment_group: deployment_group, deployment_group_id: assigns.deployment_group_id ) |> assign_new(:messages, fn -> [] end) socket = if deployment_group_changed? do assign(socket, changeset: Hubs.Dockerfile.config_changeset(base_config(socket)), deployment_type: :dockerfile ) else socket end {:ok, update_dockerfile(socket)} end defp base_config(socket) do if deployment_group = socket.assigns.deployment_group do Hubs.Dockerfile.from_deployment_group(deployment_group) else Hubs.Dockerfile.config_new() end end @impl true def render(assigns) do ~H"""

App deployment with Docker

<.content file={@file} settings_valid?={@settings_valid?} hub={@hub} deployment_group={@deployment_group} deployment_groups={@deployment_groups} deployment_group_id={@deployment_group_id} changeset={@changeset} session={@session} dockerfile={@dockerfile} messages={@messages} myself={@myself} />
""" end defp content(%{file: nil} = assigns) do ~H"""

To deploy this app, make sure to save the notebook first.

<.link class="text-blue-600 font-medium" patch={~p"/sessions/#{@session.id}/settings/file?context=app-docker"} > Save <.remix_icon icon="arrow-right-line" />
""" end defp content(%{settings_valid?: false} = assigns) do ~H"""

To deploy this app, make sure to specify valid settings.

<.link class="text-blue-600 font-medium" patch={~p"/sessions/#{@session.id}/settings/app?context=app-docker"} > Configure <.remix_icon icon="arrow-right-line" />
""" end defp content(assigns) do ~H"""

Choose your deployment settings and then deploy your notebook using the generated Dockerfile.

<.label>Workspace {@hub.hub_emoji} {@hub.hub_name}

<%= if @deployment_groups do %> <%= if @deployment_groups != [] do %> <.form :let={f} for={%{"id" => @deployment_group_id}} as={:deployment_group} phx-change="select_deployment_group" phx-target={@myself} id="select_deployment_group_form" > <.select_field help={deployment_group_help()} field={f[:id]} options={deployment_group_options(@deployment_groups)} label="Deployment Group" /> <% else %>

<.label help={deployment_group_help()}> Deployment Group None configured <.link navigate={~p"/hub/#{@hub.id}/groups/new"} target="_blank" class="pl-3 text-blue-600 font-semibold" > + add new

<% end %> <% end %>
<.message_box :for={{kind, message} <- @messages} kind={kind}> {raw(message)}
<.form :let={f} for={@changeset} as={:data} phx-change="validate" phx-target={@myself}>
<.radio_field label="Deploy" field={f[:deploy_all]} options={[ {"false", "Only this notebook"}, {"true", "All notebooks in the current directory"} ]} /> <.radio_field label="Base image" field={f[:docker_tag]} options={AppComponents.docker_tag_options()} />
Dockerfile
<.button color="gray" small type="button" aria-label="save dockerfile alongside the notebook" phx-click="save_dockerfile" phx-target={@myself} > <.remix_icon icon="save-line" /> Save alongside notebook <.button color="gray" small data-tooltip="Copied to clipboard" type="button" aria-label="copy to clipboard" phx-click={ JS.dispatch("lb:clipcopy", to: "#dockerfile-source") |> JS.transition("tooltip top", time: 2000) } > <.remix_icon icon="clipboard-line" /> Copy source
<.code_preview source_id="dockerfile-source" source={@dockerfile} language="dockerfile" />
To test the deployment locally, go the the notebook directory, save the Dockerfile, then run:
<.code_preview source_id="dockerfile-cmd" source={ ~s''' docker build -t my-app . docker run --rm -p 8080:8080 -p 8081:8081 my-app ''' } language="text" />

You may additionally perform the following optional steps:

  • <.remix_icon icon="arrow-right-line" class="text-gray-900" />
    you may remove the default value for TEAMS_KEY from your Dockerfile and set it as a build argument in your deployment platform
  • <.remix_icon icon="arrow-right-line" class="text-gray-900" />
    you may set LIVEBOOK_SECRET_KEY_BASE and LIVEBOOK_COOKIE as runtime environment secrets in your deployment platform, to ensure their values stay the same across deployments. If you do that, you can remove the defaults from your Dockerfile
  • <.remix_icon icon="arrow-right-line" class="text-gray-900" />
    if you want to debug your deployed notebooks in production, you may set the LIVEBOOK_PASSWORD environment variable with a value of at least 12 characters of your choice
""" end @impl true def handle_event("validate", %{"data" => data}, socket) do changeset = socket |> base_config() |> Hubs.Dockerfile.config_changeset(data) |> Map.replace!(:action, :validate) {:noreply, assign(socket, changeset: changeset) |> update_dockerfile()} end def handle_event("save_dockerfile", %{}, socket) do dockerfile_file = FileSystem.File.resolve(socket.assigns.file, "./Dockerfile") file_path_message = "File saved at #{dockerfile_file.path}" case FileSystem.File.write(dockerfile_file, socket.assigns.dockerfile) do :ok -> {:noreply, assign(socket, messages: [{"info", file_path_message}])} {:error, message} -> {:noreply, assign(socket, messages: [{"error", message}])} end end def handle_event("select_deployment_group", %{"deployment_group" => %{"id" => id}}, socket) do id = if(id != "", do: id) Livebook.Session.set_notebook_deployment_group(socket.assigns.session.pid, id) {:noreply, socket} end defp update_dockerfile(socket) when socket.assigns.file == nil do assign(socket, dockerfile: nil, messages: []) end defp update_dockerfile(socket) do config = apply_changes(socket.assigns.changeset) %{ hub: hub, hub_secrets: hub_secrets, hub_file_systems: hub_file_systems, file: file, file_entries: file_entries, secrets: secrets, app_settings: app_settings, deployment_groups: deployment_groups, deployment_group_id: deployment_group_id } = socket.assigns deployment_group = if deployment_group_id, do: Enum.find(deployment_groups, &(&1.id == deployment_group_id)) hub_secrets = if deployment_group do Enum.uniq_by(deployment_group.secrets ++ hub_secrets, & &1.name) else hub_secrets end dockerfile = Hubs.Dockerfile.airgapped_dockerfile( config, hub, hub_secrets, hub_file_systems, file, file_entries, secrets ) warnings = Hubs.Dockerfile.airgapped_warnings( config, hub, hub_secrets, hub_file_systems, app_settings, file_entries, secrets ) messages = Enum.map(warnings, &{"warning", &1}) assign(socket, dockerfile: dockerfile, messages: messages) end defp deployment_group_options(deployment_groups) do [{"none", nil}] ++ for deployment_group <- deployment_groups do {"#{deployment_group.name} (#{mode(deployment_group.mode)})", deployment_group.id} end end defp mode(:online), do: "online" defp mode(:offline), do: "airgapped" defp deployment_group_help() do "Share deployment credentials, secrets, and configuration with deployment groups." end end