defmodule LivebookWeb.Hub.Edit.TeamComponent do use LivebookWeb, :live_component alias Livebook.Hubs alias Livebook.Hubs.{Provider, Team} alias Livebook.Teams alias LivebookWeb.LayoutHelpers alias LivebookWeb.NotFoundError @impl true def update(assigns, socket) do socket = assign(socket, assigns) changeset = Team.change_hub(assigns.hub) show_key? = assigns.params["show-key"] == "true" secrets = Livebook.Hubs.get_secrets(assigns.hub) file_systems = Hubs.get_file_systems(assigns.hub, hub_only: true) secret_name = assigns.params["secret_name"] file_system_id = assigns.params["file_system_id"] is_default? = is_default?(assigns.hub) secret_value = if assigns.live_action == :edit_secret do Enum.find_value(secrets, &(&1.name == secret_name and &1.value)) || raise(NotFoundError, "could not find secret matching #{inspect(secret_name)}") end file_system = if assigns.live_action == :edit_file_system do Enum.find_value(file_systems, &(&1.id == file_system_id && &1)) || raise(NotFoundError, "could not find file system matching #{inspect(file_system_id)}") end docker_tags = Livebook.Config.docker_tags() [%{tag: default_base_image} | _] = docker_tags {:ok, socket |> assign( secrets: secrets, file_system: file_system, file_system_id: file_system_id, file_systems: file_systems, show_key: show_key?, secret_name: secret_name, secret_value: secret_value, hub_metadata: Provider.to_metadata(assigns.hub), is_default: is_default?, zta: %{"provider" => "", "key" => ""}, zta_metadata: nil, base_image: default_base_image, docker_tags: docker_tags ) |> assign_dockerfile() |> assign_form(changeset)} end @impl true def render(assigns) do ~H"""
<%= Provider.connection_error(@hub) %>

General

<.form :let={f} id={@id} class="flex flex-col mt-4 space-y-4" for={@form} phx-submit="save" phx-change="validate" phx-target={@myself} >
<.text_field field={f[:hub_name]} label="Name" disabled help="Name cannot be changed" class="bg-gray-200/50 border-200/80 cursor-not-allowed" /> <.emoji_field field={f[:hub_emoji]} label="Emoji" />

Secrets

Secrets are a safe way to share credentials and tokens with notebooks. They are often used by Smart cells and can be read as environment variables using the LB_ prefix.

<.live_component module={LivebookWeb.Hub.SecretListComponent} id="hub-secrets-list" hub={@hub} secrets={@secrets} target={@myself} />

File Storages

File storages are used to store notebooks and their files.

<.live_component module={LivebookWeb.Hub.FileSystemListComponent} id="hub-file-systems-list" hub_id={@hub.id} file_systems={@file_systems} target={@myself} />

Airgapped Deployment

It is possible to deploy notebooks that belong to this Hub in an airgapped deployment, without connecting back to Livebook Teams server, by following the steps below. First, configure your deployment:

<.radio_field name="base_image" label="Base image" value={@base_image} options={for tag <- @docker_tags, do: {tag.tag, tag.name}} />
<.form :let={f} class="py-2" for={@zta} phx-change="change_zta" phx-target={@myself}>
<.select_field name="provider" label="Zero Trust Authentication provider" value={@zta["provider"]} help="Enable this option if you want to deploy your notebooks behind an authentication proxy" prompt="None" options={zta_options()} /> <.text_field :if={@zta_metadata} field={f[:key]} label={@zta_metadata.value} phx-debounce />

Then save the Dockerfile below in a repository with the Livebook notebooks that belong to your Organization. You must change the value of the APPS_PATH argument in the template below to point to a directory with all .livemd files you want to deploy.

Dockerfile
<.code_preview source_id={"offline-deployment-#{@hub.id}-source"} source={@dockerfile} language="dockerfile" />

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" />
    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

Danger Zone

Delete this hub

This only removes the hub from this machine. You must rejoin to access its features once again.

<.modal show={@show_key} id="show-key-modal" width={:medium} patch={~p"/hub/#{@hub.id}"}> <.teams_key_modal teams_key={@hub.teams_key} /> <.modal :if={@live_action in [:new_secret, :edit_secret]} id="secrets-modal" show width={:medium} patch={~p"/hub/#{@hub.id}"} > <.live_component module={LivebookWeb.Hub.SecretFormComponent} id="secrets" hub={@hub} secret_name={@secret_name} secret_value={@secret_value} return_to={~p"/hub/#{@hub.id}"} /> <.modal :if={@live_action in [:new_file_system, :edit_file_system]} id="file-systems-modal" show width={:medium} patch={~p"/hub/#{@hub.id}"} > <.live_component module={LivebookWeb.Hub.FileSystemFormComponent} id="file-systems" hub={@hub} file_system={@file_system} file_system_id={@file_system_id} return_to={~p"/hub/#{@hub.id}"} />
""" end defp org_url(hub, path) do Livebook.Config.teams_url() <> "/orgs/#{hub.org_id}" <> path end defp teams_key_modal(assigns) do ~H"""

Teams key

This is your Teams key. This key encrypts your data before it is sent to Livebook Teams servers. This key is required for you and invited users to join this organization. We recommend storing it somewhere safe:
""" end @impl true def handle_event("save", %{"team" => params}, socket) do case Teams.update_hub(socket.assigns.hub, params) do {:ok, hub} -> {:noreply, socket |> put_flash(:success, "Hub updated successfully") |> push_navigate(to: ~p"/hub/#{hub.id}")} {:error, changeset} -> {:noreply, assign_form(socket, changeset)} end end def handle_event("validate", %{"team" => attrs}, socket) do changeset = socket.assigns.hub |> Team.change_hub(attrs) |> Map.replace!(:action, :validate) {:noreply, assign_form(socket, changeset)} end def handle_event("delete_hub_secret", attrs, socket) do %{hub: hub} = socket.assigns on_confirm = fn socket -> {:ok, secret} = Livebook.Secrets.update_secret(%Livebook.Secrets.Secret{}, attrs) case Livebook.Hubs.delete_secret(hub, secret) do :ok -> socket {:transport_error, reason} -> put_flash(socket, :error, reason) end end {:noreply, confirm(socket, on_confirm, title: "Delete hub secret - #{attrs["name"]}", description: "Are you sure you want to delete this hub secret?", confirm_text: "Delete", confirm_icon: "delete-bin-6-line" )} end def handle_event("change_zta", %{"provider" => provider} = params, socket) do zta = %{"provider" => provider, "key" => params["key"]} meta = Enum.find(Livebook.Config.identity_providers(), fn meta -> Atom.to_string(meta.type) == provider end) {:noreply, assign(socket, zta: zta, zta_metadata: meta) |> assign_dockerfile()} end def handle_event("mark_as_default", _, socket) do Hubs.set_default_hub(socket.assigns.hub.id) {:noreply, push_navigate(socket, to: ~p"/hub/#{socket.assigns.hub.id}")} end def handle_event("remove_as_default", _, socket) do Hubs.unset_default_hub(socket.assigns.hub.id) {:noreply, push_navigate(socket, to: ~p"/hub/#{socket.assigns.hub.id}")} end def handle_event("base_image", %{"base_image" => base_image}, socket) do {:noreply, assign(socket, base_image: base_image) |> assign_dockerfile()} end defp is_default?(hub) do Hubs.get_default_hub().id == hub.id end defp assign_form(socket, %Ecto.Changeset{} = changeset) do assign(socket, form: to_form(changeset)) end defp assign_dockerfile(socket) do base_image = Enum.find(socket.assigns.docker_tags, &(&1.tag == socket.assigns.base_image)) image = """ FROM ghcr.io/livebook-dev/livebook:#{base_image.tag} """ image_base_env = base_env(base_image.env) base_args = """ ARG APPS_PATH=/path/to/my/notebooks ARG TEAMS_KEY="#{socket.assigns.hub.teams_key}" """ base_env = """ ENV LIVEBOOK_TEAMS_KEY ${TEAMS_KEY} ENV LIVEBOOK_TEAMS_NAME "#{socket.assigns.hub.hub_name}" ENV LIVEBOOK_TEAMS_OFFLINE_KEY "#{socket.assigns.hub.org_public_key}" """ secrets = secrets_env(socket) file_systems = file_systems_env(socket) apps = """ ENV LIVEBOOK_APPS_PATH "/apps" ENV LIVEBOOK_APPS_PATH_WARMUP "manual" ENV LIVEBOOK_APPS_PATH_HUB_ID "#{socket.assigns.hub.id}" COPY ${APPS_PATH} /apps RUN /app/bin/warmup_apps.sh\ """ zta = zta_env(socket.assigns.zta) dockerfile = [image, base_args, image_base_env, base_env, secrets, file_systems, zta, apps] |> Enum.reject(&is_nil/1) |> Enum.join() assign(socket, :dockerfile, dockerfile) end defp encrypt_secrets_to_dockerfile(socket) do secrets_map = for %{name: name, value: value} <- socket.assigns.secrets, into: %{}, do: {name, value} encrypt_to_dockerfile(socket, secrets_map) end defp encrypt_file_systems_to_dockerfile(socket) do file_systems = for file_system <- socket.assigns.file_systems do file_system |> Livebook.FileSystem.dump() |> Map.put_new(:type, Livebook.FileSystems.type(file_system)) end encrypt_to_dockerfile(socket, file_systems) end defp encrypt_to_dockerfile(socket, data) do secret_key = Livebook.Teams.derive_key(socket.assigns.hub.teams_key) data |> Jason.encode!() |> Livebook.Teams.encrypt(secret_key) end @zta_options for provider <- Livebook.Config.identity_providers(), not provider.read_only, do: {provider.name, provider.type} defp zta_options, do: @zta_options defp zta_env(%{"provider" => ""}), do: nil defp zta_env(%{"key" => ""}), do: nil defp zta_env(%{"provider" => provider, "key" => key}) do """ ENV LIVEBOOK_IDENTITY_PROVIDER "#{provider}:#{key}" """ end defp secrets_env(%{assigns: %{secrets: []}}), do: nil defp secrets_env(socket) do """ ENV LIVEBOOK_TEAMS_SECRETS "#{encrypt_secrets_to_dockerfile(socket)}" """ end defp file_systems_env(%{assigns: %{file_systems: []}}), do: nil defp file_systems_env(socket) do """ ENV LIVEBOOK_TEAMS_FS "#{encrypt_file_systems_to_dockerfile(socket)}" """ end defp base_env([]), do: nil defp base_env(list), do: Enum.map_join(list, fn {k, v} -> ~s/ENV #{k} "#{v}"\n/ end) end