Add docker deployment instructions to app panel (#2276)

This commit is contained in:
Jonatan Kłosko 2023-10-17 15:04:47 +02:00 committed by GitHub
parent 9bd51e3af1
commit 797844223a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1062 additions and 275 deletions

View file

@ -45,10 +45,10 @@ defmodule Livebook.Config do
@identity_provider_read_only Enum.filter(@identity_providers, & &1.read_only)
@doc """
Returns docker tags to be used when generating sample Dockerfiles.
Returns docker images to be used when generating sample Dockerfiles.
"""
@spec docker_tags() :: list(%{tag: String.t(), name: String.t(), env: keyword()})
def docker_tags do
@spec docker_images() :: list(%{tag: String.t(), name: String.t(), env: keyword()})
def docker_images() do
version = app_version()
base = if version =~ "dev", do: "latest", else: version

View file

@ -0,0 +1,284 @@
defmodule Livebook.Hubs.Dockerfile do
# This module is responsible for building Dockerfile to deploy apps.
import Ecto.Changeset
alias Livebook.Hubs
@type config :: %{
deploy_all: boolean(),
docker_tag: String.t(),
zta_provider: atom() | nil,
zta_key: String.t() | nil
}
@doc """
Builds a changeset for app Dockerfile configuration.
"""
@spec config_changeset(map()) :: Ecto.Changeset.t()
def config_changeset(attrs \\ %{}) do
default_image = Livebook.Config.docker_images() |> hd()
data = %{deploy_all: false, docker_tag: default_image.tag, zta_provider: nil, zta_key: nil}
zta_types =
for provider <- Livebook.Config.identity_providers(),
not provider.read_only,
do: provider.type
types = %{
deploy_all: :boolean,
docker_tag: :string,
zta_provider: Ecto.ParameterizedType.init(Ecto.Enum, values: zta_types),
zta_key: :string
}
cast({data, types}, attrs, [:deploy_all, :docker_tag, :zta_provider, :zta_key])
|> validate_required([:deploy_all, :docker_tag])
end
@doc """
Builds Dockerfile definition for app deployment.
"""
@spec build_dockerfile(
config(),
Hubs.Provider.t(),
list(Livebook.Secrets.Secret.t()),
list(Livebook.FileSystem.t()),
Livebook.FileSystem.File.t() | nil,
list(Livebook.Notebook.file_entry()),
list(Livebook.Session.Data.secrets())
) :: String.t()
def build_dockerfile(config, hub, hub_secrets, hub_file_systems, file, file_entries, secrets) do
base_image = Enum.find(Livebook.Config.docker_images(), &(&1.tag == config.docker_tag))
image = """
FROM ghcr.io/livebook-dev/livebook:#{base_image.tag}
"""
image_envs = format_envs(base_image.env)
hub_type = Hubs.Provider.type(hub)
used_secrets = used_secrets(config, hub, secrets, hub_secrets) |> Enum.sort_by(& &1.name)
hub_config = format_hub_config(hub_type, config, hub, hub_file_systems, used_secrets)
apps_config = """
# Apps configuration
ENV LIVEBOOK_APPS_PATH "/apps"
ENV LIVEBOOK_APPS_PATH_WARMUP "manual"
ENV LIVEBOOK_APPS_PATH_HUB_ID "#{hub.id}"
"""
notebook =
if config.deploy_all do
"""
# Notebooks and files
COPY . /apps
"""
else
notebook_file_name = Livebook.FileSystem.File.name(file)
notebook =
"""
# Notebook
COPY #{notebook_file_name} /apps/
"""
attachments =
file_entries
|> Enum.filter(&(&1.type == :attachment))
|> Enum.sort_by(& &1.name)
if attachments == [] do
notebook
else
list = Enum.map_join(attachments, " ", &"files/#{&1.name}")
"""
# Files
COPY #{list} /apps/files/
#{notebook}\
"""
end
end
apps_warmup = """
# Cache apps setup at build time
RUN /app/bin/warmup_apps.sh
"""
[
image,
image_envs,
hub_config,
apps_config,
notebook,
apps_warmup
]
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
end
defp format_hub_config("team", config, hub, hub_file_systems, used_secrets) do
base_env =
"""
ARG TEAMS_KEY="#{hub.teams_key}"
# Teams Hub configuration for airgapped deployment
ENV LIVEBOOK_TEAMS_KEY ${TEAMS_KEY}
ENV LIVEBOOK_TEAMS_NAME "#{hub.hub_name}"
ENV LIVEBOOK_TEAMS_OFFLINE_KEY "#{hub.org_public_key}"
"""
secrets =
if used_secrets != [] do
"""
ENV LIVEBOOK_TEAMS_SECRETS "#{encrypt_secrets_to_dockerfile(used_secrets, hub)}"
"""
end
file_systems =
if hub_file_systems != [] do
"""
ENV LIVEBOOK_TEAMS_FS "#{encrypt_file_systems_to_dockerfile(hub_file_systems, hub)}"
"""
end
zta =
if zta_configured?(config) do
"""
ENV LIVEBOOK_IDENTITY_PROVIDER "#{config.zta_provider}:#{config.zta_key}"
"""
end
[base_env, secrets, file_systems, zta]
|> Enum.reject(&is_nil/1)
|> Enum.join()
end
defp format_hub_config("personal", _config, _hub, _hub_file_systems, used_secrets) do
if used_secrets != [] do
envs = used_secrets |> Enum.map(&{"LB_" <> &1.name, &1.value}) |> format_envs()
"""
# Personal Hub secrets
#{envs}\
"""
end
end
defp format_envs([]), do: nil
defp format_envs(list) do
Enum.map_join(list, fn {key, value} -> ~s/ENV #{key} "#{value}"\n/ end)
end
defp encrypt_secrets_to_dockerfile(secrets, hub) do
secrets_map =
for %{name: name, value: value} <- secrets,
into: %{},
do: {name, value}
encrypt_to_dockerfile(hub, secrets_map)
end
defp encrypt_file_systems_to_dockerfile(file_systems, hub) do
file_systems =
for file_system <- file_systems do
file_system
|> Livebook.FileSystem.dump()
|> Map.put_new(:type, Livebook.FileSystems.type(file_system))
end
encrypt_to_dockerfile(hub, file_systems)
end
defp encrypt_to_dockerfile(hub, data) do
secret_key = Livebook.Teams.derive_key(hub.teams_key)
data
|> Jason.encode!()
|> Livebook.Teams.encrypt(secret_key)
end
defp used_secrets(config, hub, secrets, hub_secrets) do
if config.deploy_all do
hub_secrets
else
for {_, secret} <- secrets, secret.hub_id == hub.id, do: secret
end
end
defp zta_configured?(config) do
config.zta_provider != nil and config.zta_key != nil
end
@doc """
Returns a list of Dockerfile-related warnings.
The returned messages may include HTML.
"""
@spec warnings(
config(),
Hubs.Provider.t(),
list(Livebook.Secrets.Secret.t()),
Livebook.Notebook.AppSettings.t(),
list(Livebook.Notebook.file_entry()),
list(Livebook.Session.Data.secrets())
) :: list(String.t())
def warnings(config, hub, hub_secrets, app_settings, file_entries, secrets) do
common_warnings =
[
if Livebook.Session.Data.session_secrets(secrets, hub.id) != [] do
"The notebook uses session secrets, but those are not available to deployed apps." <>
" Convert them to Hub secrets instead."
end
]
hub_warnings =
case Hubs.Provider.type(hub) do
"personal" ->
[
if used_secrets(config, hub, secrets, hub_secrets) != [] do
"You are deploying an app with secrets and the secrets are included in the Dockerfile" <>
" as environment variables. If someone else deploys this app, they must also set the" <>
" same secrets. Use Livebook Teams to automatically encrypt and synchronize secrets" <>
" across your team and deployments."
end,
if module = find_hub_file_system(file_entries) do
name = LivebookWeb.FileSystemHelpers.file_system_name(module)
"The #{name} file storage, defined in your personal hub, will not be available in the Docker image." <>
" You must either download all references as attachments or use Livebook Teams to automatically" <>
" encrypt and synchronize file storages across your team and deployments."
end,
if app_settings.access_type == :public do
teams_link =
~s{<a class="font-medium underline text-gray-900 hover:no-underline" href="https://livebook.dev/teams?ref=LivebookApp" target="_blank">Livebook Teams</a>}
"This app has no password configuration and anyone with access to the server will be able" <>
" to use it. You may either configure a password or use #{teams_link} to add Zero Trust Authentication" <>
" to your deployed notebooks."
end
]
"team" ->
[
if app_settings.access_type == :public and not zta_configured?(config) do
"This app has no password configuration and anyone with access to the server will be able" <>
" to use it. You may either configure a password or configure Zero Trust Authentication."
end
]
end
Enum.reject(common_warnings ++ hub_warnings, &is_nil/1)
end
defp find_hub_file_system(file_entries) do
Enum.find_value(file_entries, fn entry ->
entry.type == :file && entry.file.file_system_module != FileSystem.Local &&
entry.file.file_system_module
end)
end
end

View file

@ -94,11 +94,17 @@ defmodule LivebookWeb.CoreComponents do
<.message_box kind={:info} message="🦊 in a 📦" />
<.message_box kind={:info}>
<span>🦊</span> in a <span>📦</span>
</.message_box>
"""
attr :message, :string, required: true
attr :message, :string, default: nil
attr :kind, :atom, values: [:info, :success, :warning, :error]
slot :inner_block
def message_box(assigns) do
~H"""
<div class={[
@ -108,7 +114,14 @@ defmodule LivebookWeb.CoreComponents do
@kind == :warning && "border-yellow-300",
@kind == :error && "border-red-500"
]}>
<div class="whitespace-pre-wrap pr-2 max-h-52 overflow-y-auto tiny-scrollbar" phx-no-format><%= @message %></div>
<div
:if={@message}
class="whitespace-pre-wrap pr-2 max-h-52 overflow-y-auto tiny-scrollbar"
phx-no-format
><%= @message %></div>
<div :if={@inner_block}>
<%= render_slot(@inner_block) %>
</div>
</div>
"""
end
@ -478,11 +491,13 @@ defmodule LivebookWeb.CoreComponents do
default: false,
doc: "whether to force the text into a single scrollable line"
attr :class, :string, default: nil
slot :inner_block, required: true
def labeled_text(assigns) do
~H"""
<div class="flex flex-col space-y-1">
<div class={["flex flex-col space-y-1", @class]}>
<span class="text-sm text-gray-500">
<%= @label %>
</span>

View file

@ -1,6 +1,8 @@
defmodule LivebookWeb.AppHelpers do
use LivebookWeb, :html
alias Livebook.Hubs
@doc """
Renders page placeholder on unauthenticated dead render.
"""
@ -79,4 +81,148 @@ defmodule LivebookWeb.AppHelpers do
confirm_icon: "delete-bin-6-line"
)
end
@doc """
Renders form fields for Dockerfile configuration.
"""
attr :form, Phoenix.HTML.Form, required: true
attr :hub, :map, required: true
attr :show_deploy_all, :boolean, default: true
def docker_config_form_content(assigns) do
~H"""
<div class="flex flex-col space-y-4">
<.radio_field
:if={@show_deploy_all}
label="Deploy"
field={@form[:deploy_all]}
options={[
{"false", "Only this notebook"},
{"true", "All notebooks in the current directory"}
]}
/>
<.radio_field label="Base image" field={@form[:docker_tag]} options={docker_tag_options()} />
<%= if Hubs.Provider.type(@hub) == "team" do %>
<div class="flex flex-col">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.select_field
label="Zero Trust Authentication provider"
field={@form[: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 = zta_metadata(@form[:zta_provider].value)}
field={@form[:zta_key]}
label={zta_metadata.value}
phx-debounce
/>
</div>
<div :if={zta_metadata = zta_metadata(@form[:zta_provider].value)} class="text-sm mt-1">
See the
<a
class="text-blue-800 hover:text-blue-600"
href={"https://hexdocs.pm/livebook/#{zta_metadata.type}"}
>
Authentication with <%= zta_metadata.name %> docs
</a>
for more information.
</div>
</div>
<% end %>
</div>
"""
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 docker_tag_options() do
for image <- Livebook.Config.docker_images(), do: {image.tag, image.name}
end
@doc """
Renders Docker deployment instruction for an app.
"""
attr :hub, :map, required: true
attr :dockerfile, :string, required: true
slot :dockerfile_actions, default: nil
def docker_instructions(assigns) do
~H"""
<div class="flex flex-col gap-4">
<div>
<div class="flex items-end mb-1 gap-1">
<span class="text-sm text-gray-700 font-semibold">Dockerfile</span>
<div class="grow" />
<%= render_slot(@dockerfile_actions) %>
<button
class="button-base button-gray whitespace-nowrap py-1 px-2"
data-tooltip="Copied to clipboard"
type="button"
aria-label="copy to clipboard"
phx-click={
JS.dispatch("lb:clipcopy", to: "#dockerfile-source")
|> JS.add_class("", transition: {"tooltip top", "", ""}, time: 2000)
}
>
<.remix_icon icon="clipboard-line" class="align-middle mr-1 text-xs" />
<span class="font-normal text-xs">Copy source</span>
</button>
</div>
<.code_preview source_id="dockerfile-source" source={@dockerfile} language="dockerfile" />
</div>
<div class="text-gray-700">
To test the deployment locally, go the the notebook directory, save the Dockerfile, then run:
</div>
<.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"
/>
<p class="text-gray-700 py-2">
You may additionally perform the following optional steps:
</p>
<ul class="text-gray-700 space-y-3">
<li :if={Hubs.Provider.type(@hub) == "team"} class="flex gap-2">
<div><.remix_icon icon="arrow-right-line" class="text-gray-900" /></div>
<span>
you may remove the default value for <code>TEAMS_KEY</code>
from your Dockerfile and set it as a build argument in your deployment
platform
</span>
</li>
<li class="flex gap-2">
<div><.remix_icon icon="arrow-right-line" class="text-gray-900" /></div>
<span>
if you want to debug your deployed notebooks in production, you may
set the <code>LIVEBOOK_PASSWORD</code> environment variable with a
value of at least 12 characters of your choice
</span>
</li>
</ul>
</div>
"""
end
defp zta_metadata(nil), do: nil
defp zta_metadata(zta_provider) do
Enum.find(Livebook.Config.identity_providers(), &(&1.type == zta_provider))
end
end

View file

@ -12,7 +12,7 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent 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)
secrets = 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"]
@ -30,9 +30,6 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
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(
@ -44,13 +41,10 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
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
is_default: is_default?
)
|> assign_dockerfile()
|> assign_new(:config_changeset, fn -> Hubs.Dockerfile.config_changeset() end)
|> update_dockerfile()
|> assign_form(changeset)}
end
@ -184,7 +178,7 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
File Storages
File storages
</h2>
<p class="text-gray-700">
@ -202,129 +196,36 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
Airgapped Deployment
Airgapped deployment
</h2>
<p class="text-gray-700">
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:
deployment, without connecting back to Livebook Teams server. Configure the
deployment below and use the generated Dockerfile in a directory with notebooks
that belong to your Organization.
</p>
<div class="grid grid-cols-1">
<form phx-change="base_image" phx-target={@myself} phx-nosubmit>
<.radio_field
name="base_image"
label="Base image"
value={@base_image}
options={for tag <- @docker_tags, do: {tag.tag, tag.name}}
/>
</form>
</div>
<.form :let={f} class="py-2" for={@zta} phx-change="change_zta" phx-target={@myself}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.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
/>
</div>
<div class="text-sm mt-2">
<span :if={@zta_metadata}>
See the
<a
class="text-blue-800 hover:text-blue-600"
href={"https://hexdocs.pm/livebook/#{@zta_metadata.type}"}
>
Authentication with <%= @zta_metadata.name %> docs
</a>
for more information.
</span>
</div>
<.form
:let={f}
for={@config_changeset}
as={:data}
phx-change="validate_dockerfile"
phx-target={@myself}
>
<LivebookWeb.AppHelpers.docker_config_form_content
hub={@hub}
form={f}
show_deploy_all={false}
/>
</.form>
<p class="text-gray-700">
Then save the Dockerfile below in a repository with the Livebook notebooks
that belong to your Organization. <strong>You must change</strong>
the value of the <code>APPS_PATH</code>
argument in the template below to point to a directory with all <code>.livemd</code>
files you want to deploy.
</p>
<div id="env-code" class="py-2">
<div class="flex justify-between items-end mb-1">
<span class="text-sm text-gray-700 font-semibold">Dockerfile</span>
<button
class="button-base button-gray whitespace-nowrap py-1 px-2"
data-copy
data-tooltip="Copied to clipboard"
type="button"
aria-label="copy to clipboard"
phx-click={
JS.dispatch("lb:clipcopy", to: "#offline-deployment-#{@hub.id}-source")
|> JS.add_class(
"tooltip top",
to: "#env-code [data-copy]",
transition: {"ease-out duration-200", "opacity-0", "opacity-100"}
)
|> JS.remove_class(
"tooltip top",
to: "#env-code [data-copy]",
transition: {"ease-out duration-200", "opacity-0", "opacity-100"},
time: 2000
)
}
>
<.remix_icon icon="clipboard-line" class="align-middle mr-1 text-xs" />
<span class="font-normal text-xs">Copy source</span>
</button>
</div>
<.code_preview
source_id={"offline-deployment-#{@hub.id}-source"}
source={@dockerfile}
language="dockerfile"
/>
</div>
<p class="text-gray-700 py-2">
You may additionally perform the following optional steps:
</p>
<ul class="text-gray-700 space-y-3">
<li class="flex gap-2">
<div><.remix_icon icon="arrow-right-line" class="text-gray-900" /></div>
<span>
you may remove the default value for <code>TEAMS_KEY</code>
from your Dockerfile and set it as a build argument in your deployment
platform
</span>
</li>
<li class="flex gap-2">
<div><.remix_icon icon="arrow-right-line" class="text-gray-900" /></div>
<span>
if you want to debug your deployed notebooks in production, you may
set the <code>LIVEBOOK_PASSWORD</code> environment variable with a
value of at least 12 characters of your choice
</span>
</li>
</ul>
<LivebookWeb.AppHelpers.docker_instructions hub={@hub} dockerfile={@dockerfile} />
</div>
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
Danger Zone
Danger zone
</h2>
<div class="flex items-center justify-between gap-4 text-gray-700">
@ -420,23 +321,12 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
<div class="flex items-center absolute inset-y-0 right-1">
<button
class="icon-button"
data-copy
data-tooltip="Copied to clipboard"
type="button"
aria-label="copy to clipboard"
phx-click={
JS.dispatch("lb:clipcopy", to: "#teams-key")
|> JS.add_class(
"tooltip top",
to: "#teams-key-toggle [data-copy]",
transition: {"ease-out duration-200", "opacity-0", "opacity-100"}
)
|> JS.remove_class(
"tooltip top",
to: "#teams-key-toggle [data-copy]",
transition: {"ease-out duration-200", "opacity-0", "opacity-100"},
time: 2000
)
|> JS.add_class("", transition: {"tooltip top", "", ""}, time: 2000)
}
>
<.remix_icon icon="clipboard-line" class="text-xl" />
@ -521,15 +411,16 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
)}
end
def handle_event("change_zta", %{"provider" => provider} = params, socket) do
zta = %{"provider" => provider, "key" => params["key"]}
def handle_event("validate_dockerfile", %{"data" => data}, socket) do
changeset =
data
|> Hubs.Dockerfile.config_changeset()
|> Map.replace!(:action, :validate)
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()}
{:noreply,
socket
|> assign(config_changeset: changeset)
|> update_dockerfile()}
end
def handle_event("mark_as_default", _, socket) do
@ -542,10 +433,6 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
{: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
@ -554,110 +441,17 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent 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))
defp update_dockerfile(socket) do
config =
socket.assigns.config_changeset
|> Ecto.Changeset.apply_changes()
|> Map.replace!(:deploy_all, true)
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)
%{hub: hub, secrets: hub_secrets, file_systems: hub_file_systems} = socket.assigns
dockerfile =
[image, base_args, image_base_env, base_env, secrets, file_systems, zta, apps]
|> Enum.reject(&is_nil/1)
|> Enum.join()
Hubs.Dockerfile.build_dockerfile(config, hub, hub_secrets, hub_file_systems, nil, [], %{})
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

View file

@ -482,6 +482,26 @@ defmodule LivebookWeb.SessionLive do
/>
</.modal>
<.modal
:if={@live_action == :app_docker}
id="app-docker-modal"
show
width={:big}
patch={@self_path}
>
<.live_component
module={LivebookWeb.SessionLive.AppDockerComponent}
id="app-docker"
session={@session}
hub={@data_view.hub}
file={@data_view.file}
app_settings={@data_view.app_settings}
secrets={@data_view.secrets}
file_entries={@data_view.file_entries}
settings={@data_view.app_settings}
/>
</.modal>
<.modal
:if={@live_action == :add_file_entry}
id="add-file-entry-modal"

View file

@ -0,0 +1,188 @@
defmodule LivebookWeb.SessionLive.AppDockerComponent do
use LivebookWeb, :live_component
import Ecto.Changeset
alias Livebook.Hubs
alias Livebook.FileSystem
alias LivebookWeb.AppHelpers
@impl true
def update(assigns, socket) do
socket = assign(socket, assigns)
{:ok,
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)
)
|> assign_new(:changeset, fn -> Hubs.Dockerfile.config_changeset() end)
|> assign_new(:save_result, fn -> nil end)
|> update_dockerfile()}
end
@impl true
def render(assigns) do
~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800">
App deployment
</h3>
<.content
file={@file}
settings_valid?={@settings_valid?}
hub={@hub}
changeset={@changeset}
session={@session}
dockerfile={@dockerfile}
warnings={@warnings}
save_result={@save_result}
myself={@myself}
/>
</div>
"""
end
defp content(%{file: nil} = assigns) do
~H"""
<div class="flex justify-between">
<p class="text-gray-700">
To deploy this app, make sure to save the notebook first.
</p>
<.link class="text-blue-600 font-medium" patch={~p"/sessions/#{@session.id}/settings/file"}>
<span>Save</span>
<.remix_icon icon="arrow-right-line" />
</.link>
</div>
"""
end
defp content(%{settings_valid?: false} = assigns) do
~H"""
<div class="flex justify-between">
<p class="text-gray-700">
To deploy this app, make sure to specify valid settings.
</p>
<.link class="text-blue-600 font-medium" patch={~p"/sessions/#{@session.id}/settings/app"}>
<span>Configure</span>
<.remix_icon icon="arrow-right-line" />
</.link>
</div>
"""
end
defp content(assigns) do
~H"""
<div class="flex flex-col gap-4">
<p class="text-gray-700">
You can deploy this app in the cloud using Docker. To do that, configure
the deployment and then use the generated Dockerfile.
</p>
<p class="text-gray-700">
<.label>Hub</.label>
<span>
<span class="text-lg"><%= @hub.hub_emoji %></span>
<span><%= @hub.hub_name %></span>
</span>
</p>
<div class="flex flex-col gap-2">
<.message_box :for={warning <- @warnings} kind={:warning}>
<%= raw(warning) %>
</.message_box>
</div>
<.form :let={f} for={@changeset} as={:data} phx-change="validate" phx-target={@myself}>
<AppHelpers.docker_config_form_content hub={@hub} form={f} />
</.form>
<.save_result :if={@save_result} save_result={@save_result} />
<AppHelpers.docker_instructions hub={@hub} dockerfile={@dockerfile}>
<:dockerfile_actions>
<button
class="button-base button-gray whitespace-nowrap py-1 px-2"
type="button"
aria-label="save dockerfile alongside the notebook"
phx-click="save_dockerfile"
phx-target={@myself}
>
<.remix_icon icon="save-line" class="align-middle mr-1 text-xs" />
<span class="font-normal text-xs">Save alongside notebook</span>
</button>
</:dockerfile_actions>
</AppHelpers.docker_instructions>
</div>
"""
end
defp save_result(%{save_result: {:ok, file}}) do
assigns = %{path: file.path}
~H"""
<.message_box kind={:info} message={"File saved at #{@path}"} />
"""
end
defp save_result(%{save_result: {:error, message}}) do
assigns = %{message: message}
~H"""
<.message_box kind={:error} message={@message} />
"""
end
@impl true
def handle_event("validate", %{"data" => data}, socket) do
changeset =
data
|> Hubs.Dockerfile.config_changeset()
|> 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")
save_result =
case FileSystem.File.write(dockerfile_file, socket.assigns.dockerfile) do
:ok -> {:ok, dockerfile_file}
{:error, message} -> {:error, message}
end
{:noreply, assign(socket, save_result: save_result)}
end
defp update_dockerfile(socket) when socket.assigns.file == nil do
assign(socket, dockerfile: nil, warnings: [])
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
} = socket.assigns
dockerfile =
Hubs.Dockerfile.build_dockerfile(
config,
hub,
hub_secrets,
hub_file_systems,
file,
file_entries,
secrets
)
warnings =
Hubs.Dockerfile.warnings(config, hub, hub_secrets, app_settings, file_entries, secrets)
assign(socket, dockerfile: dockerfile, warnings: warnings)
end
end

View file

@ -33,20 +33,29 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
kind={:warning}
message="The notebook uses session secrets, but those are not available to deployed apps. Convert them to Hub secrets instead."
/>
<div class="flex space-x-2">
<button
class="button-base button-blue"
phx-click="deploy_app"
disabled={not Livebook.Notebook.AppSettings.valid?(@settings)}
>
<.remix_icon icon="rocket-line" class="align-middle mr-1" />
<span>Deploy</span>
</button>
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<button
class="button-base button-blue"
phx-click="deploy_app"
disabled={not Livebook.Notebook.AppSettings.valid?(@settings)}
>
<.remix_icon icon="rocket-line" class="align-middle mr-1" />
<span>Deploy</span>
</button>
<.link
patch={~p"/sessions/#{@session.id}/settings/app"}
class="button-base button-outlined-gray bg-transparent"
>
Configure
</.link>
</div>
<.link
patch={~p"/sessions/#{@session.id}/settings/app"}
class="button-base button-outlined-gray bg-transparent"
class="text-sm text-gray-700 hover:text-blue-600"
patch={~p"/sessions/#{@session.id}/app-docker"}
>
Configure
<.remix_icon icon="arrow-right-line" />
<span>Deploy with Docker</span>
</.link>
</div>
</div>
@ -55,18 +64,20 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
Latest deployment
</h3>
<div class="mt-2 border border-gray-200 rounded-lg">
<div class="p-4 flex flex-col space-y-3">
<div class="p-4 flex flex-col gap-3">
<.labeled_text label="URL" one_line>
<a href={~p"/apps/#{@app.slug}"}>
<%= ~p"/apps/#{@app.slug}" %>
</a>
</.labeled_text>
<.labeled_text label="Version" one_line>
v<%= @app.version %>
</.labeled_text>
<.labeled_text label="Session type" one_line>
<%= if(@app.multi_session, do: "Multi", else: "Single") %>
</.labeled_text>
<div class="flex gap-3">
<.labeled_text label="Session type" one_line class="grow">
<%= if(@app.multi_session, do: "Multi", else: "Single") %>
</.labeled_text>
<.labeled_text label="Version" one_line class="grow">
v<%= @app.version %>
</.labeled_text>
</div>
</div>
<div class="border-t border-gray-200 px-3 py-2 flex space-x-2">
<div class="grow" />
@ -85,9 +96,9 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
Running sessions
</h3>
<div class="mt-2 flex flex-col space-y-4">
<div :for={app_session <- @app.sessions} class="border border-gray-200 rounded-lg">
<div class="p-4 flex flex-col space-y-3">
<.labeled_text label="Status">
<div :for={app_session <- @app.sessions} class="w-full border border-gray-200 rounded-lg">
<div class="p-4 flex gap-3">
<.labeled_text label="Status" class="grow">
<a
class="inline-block"
aria-label="debug app"
@ -97,7 +108,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
<.app_status status={app_session.app_status} />
</a>
</.labeled_text>
<.labeled_text label="Version">
<.labeled_text label="Version" class="grow">
v<%= app_session.version %>
</.labeled_text>
</div>

View file

@ -88,6 +88,7 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings
live "/sessions/:id/settings/file", SessionLive, :file_settings
live "/sessions/:id/settings/app", SessionLive, :app_settings
live "/sessions/:id/app-docker", SessionLive, :app_docker
live "/sessions/:id/add-file/:tab", SessionLive, :add_file_entry
live "/sessions/:id/rename-file/:name", SessionLive, :rename_file_entry
live "/sessions/:id/bin", SessionLive, :bin

View file

@ -0,0 +1,281 @@
defmodule Livebook.Hubs.DockerfileTest do
use ExUnit.Case, async: true
import Livebook.TestHelpers
alias Livebook.Hubs.Dockerfile
alias Livebook.Hubs
alias Livebook.Secrets.Secret
describe "build_dockerfile/7" do
test "deploying a single notebook in personal hub" do
config = dockerfile_config()
hub = personal_hub()
file = Livebook.FileSystem.File.local(p("/notebook.livemd"))
dockerfile = Dockerfile.build_dockerfile(config, hub, [], [], file, [], %{})
assert dockerfile == """
FROM ghcr.io/livebook-dev/livebook:latest
# Apps configuration
ENV LIVEBOOK_APPS_PATH "/apps"
ENV LIVEBOOK_APPS_PATH_WARMUP "manual"
ENV LIVEBOOK_APPS_PATH_HUB_ID "personal-hub"
# Notebook
COPY notebook.livemd /apps/
# Cache apps setup at build time
RUN /app/bin/warmup_apps.sh
"""
# With secrets
secret = %Secret{name: "TEST", value: "test", hub_id: hub.id}
unused_secret = %Secret{name: "TEST2", value: "test", hub_id: hub.id}
session_secret = %Secret{name: "SESSION", value: "test", hub_id: nil}
hub_secrets = [secret, unused_secret]
secrets = %{"TEST" => secret, "SESSION" => session_secret}
dockerfile = Dockerfile.build_dockerfile(config, hub, hub_secrets, [], file, [], secrets)
assert dockerfile =~
"""
# Personal Hub secrets
ENV LB_TEST "test"
"""
refute dockerfile =~ "ENV LB_SESSION"
end
test "deploying a directory in personal hub" do
config = dockerfile_config(%{deploy_all: true})
hub = personal_hub()
file = Livebook.FileSystem.File.local(p("/notebook.livemd"))
dockerfile = Dockerfile.build_dockerfile(config, hub, [], [], file, [], %{})
assert dockerfile =~ """
# Notebooks and files
COPY . /apps
"""
# With secrets
secret = %Secret{name: "TEST", value: "test", hub_id: hub.id}
unused_secret = %Secret{name: "TEST2", value: "test", hub_id: hub.id}
session_secret = %Secret{name: "SESSION", value: "test", hub_id: nil}
hub_secrets = [secret, unused_secret]
secrets = %{"TEST" => secret, "SESSION" => session_secret}
dockerfile = Dockerfile.build_dockerfile(config, hub, hub_secrets, [], file, [], secrets)
assert dockerfile =~
"""
# Personal Hub secrets
ENV LB_TEST "test"
ENV LB_TEST2 "test"
"""
refute dockerfile =~ "ENV LB_SESSION"
end
test "deploying a single notebook in teams hub" do
config = dockerfile_config()
hub = team_hub()
file = Livebook.FileSystem.File.local(p("/notebook.livemd"))
dockerfile = Dockerfile.build_dockerfile(config, hub, [], [], file, [], %{})
assert dockerfile == """
FROM ghcr.io/livebook-dev/livebook:latest
ARG TEAMS_KEY="lb_tk_fn0pL3YLWzPoPFWuHeV3kd0o7_SFuIOoU4C_k6OWDYg"
# Teams Hub configuration for airgapped deployment
ENV LIVEBOOK_TEAMS_KEY ${TEAMS_KEY}
ENV LIVEBOOK_TEAMS_NAME "org-name-387"
ENV LIVEBOOK_TEAMS_OFFLINE_KEY "lb_opk_fpxnp3r5djwxnmirx3tu276hialoivf3"
# Apps configuration
ENV LIVEBOOK_APPS_PATH "/apps"
ENV LIVEBOOK_APPS_PATH_WARMUP "manual"
ENV LIVEBOOK_APPS_PATH_HUB_ID "team-org-name-387"
# Notebook
COPY notebook.livemd /apps/
# Cache apps setup at build time
RUN /app/bin/warmup_apps.sh
"""
# With secrets
secret = %Secret{name: "TEST", value: "test", hub_id: hub.id}
session_secret = %Secret{name: "SESSION", value: "test", hub_id: nil}
hub_secrets = [secret]
secrets = %{"TEST" => secret, "SESSION" => session_secret}
dockerfile = Dockerfile.build_dockerfile(config, hub, hub_secrets, [], file, [], secrets)
assert dockerfile =~ "ENV LIVEBOOK_TEAMS_SECRETS"
refute dockerfile =~ "ENV TEST"
refute dockerfile =~ "ENV LB_SESSION"
# With file systems
file_system = Livebook.Factory.build(:fs_s3)
file_systems = [file_system]
dockerfile = Dockerfile.build_dockerfile(config, hub, [], file_systems, file, [], %{})
assert dockerfile =~ "ENV LIVEBOOK_TEAMS_FS"
end
test "deploying with ZTA in teams hub" do
config = dockerfile_config(%{zta_provider: :cloudflare, zta_key: "cloudflare_key"})
hub = team_hub()
file = Livebook.FileSystem.File.local(p("/notebook.livemd"))
dockerfile = Dockerfile.build_dockerfile(config, hub, [], [], file, [], %{})
assert dockerfile =~ ~S/ENV LIVEBOOK_IDENTITY_PROVIDER "cloudflare:cloudflare_key"/
end
test "deploying a directory in teams hub" do
config = dockerfile_config(%{deploy_all: true})
hub = team_hub()
file = Livebook.FileSystem.File.local(p("/notebook.livemd"))
dockerfile = Dockerfile.build_dockerfile(config, hub, [], [], file, [], %{})
assert dockerfile =~ """
# Notebooks and files
COPY . /apps
"""
end
test "deploying with different base image" do
config = dockerfile_config(%{docker_tag: "latest-cuda11.8"})
hub = personal_hub()
file = Livebook.FileSystem.File.local(p("/notebook.livemd"))
dockerfile = Dockerfile.build_dockerfile(config, hub, [], [], file, [], %{})
assert dockerfile =~ """
FROM ghcr.io/livebook-dev/livebook:latest-cuda11.8
ENV XLA_TARGET "cuda118"
"""
end
test "deploying with file entries" do
config = dockerfile_config()
hub = personal_hub()
file = Livebook.FileSystem.File.local(p("/notebook.livemd"))
file_entries = [
%{type: :attachment, name: "image.jpeg"},
%{type: :attachment, name: "data.csv"}
]
dockerfile = Dockerfile.build_dockerfile(config, hub, [], [], file, file_entries, %{})
assert dockerfile =~
"""
# Files
COPY files/data.csv files/image.jpeg /apps/files/
"""
end
end
describe "warnings/6" do
test "warns when session secrets are used" do
config = dockerfile_config()
hub = personal_hub()
app_settings = Livebook.Notebook.AppSettings.new()
session_secret = %Secret{name: "SESSION", value: "test", hub_id: nil}
secrets = %{"SESSION" => session_secret}
assert [warning] = Dockerfile.warnings(config, hub, [], app_settings, [], secrets)
assert warning =~ "The notebook uses session secrets"
end
test "warns when hub secrets are used from personal hub" do
config = dockerfile_config()
hub = personal_hub()
app_settings = Livebook.Notebook.AppSettings.new()
secret = %Secret{name: "TEST", value: "test", hub_id: hub.id}
hub_secrets = [secret]
secrets = %{"TEST" => secret}
assert [warning] = Dockerfile.warnings(config, hub, hub_secrets, app_settings, [], secrets)
assert warning =~ "secrets are included in the Dockerfile"
end
test "warns when there is a reference to external file system from personal hub" do
config = dockerfile_config()
hub = personal_hub()
app_settings = Livebook.Notebook.AppSettings.new()
file_system = Livebook.Factory.build(:fs_s3)
file_entries = [
%{type: :file, file: Livebook.FileSystem.File.new(file_system, "/data.csv")}
]
assert [warning] = Dockerfile.warnings(config, hub, [], app_settings, file_entries, %{})
assert warning =~
"The S3 file storage, defined in your personal hub, will not be available in the Docker image"
end
test "warns when the app has no password in personal hub" do
config = dockerfile_config()
hub = personal_hub()
app_settings = %{Livebook.Notebook.AppSettings.new() | access_type: :public}
assert [warning] = Dockerfile.warnings(config, hub, [], app_settings, [], %{})
assert warning =~ "This app has no password configuration"
end
test "warns when the app has no password and no ZTA in teams hub" do
config = dockerfile_config()
hub = team_hub()
app_settings = %{Livebook.Notebook.AppSettings.new() | access_type: :public}
assert [warning] = Dockerfile.warnings(config, hub, [], app_settings, [], %{})
assert warning =~ "This app has no password configuration"
config = %{config | zta_provider: :cloudflare, zta_key: "key"}
assert [] = Dockerfile.warnings(config, hub, [], app_settings, [], %{})
end
end
defp dockerfile_config(attrs \\ %{}) do
attrs
|> Dockerfile.config_changeset()
|> Ecto.Changeset.apply_changes()
end
defp personal_hub() do
Hubs.fetch_hub!(Hubs.Personal.id())
end
defp team_hub() do
Livebook.Factory.build(:team,
id: "team-org-name-387",
hub_name: "org-name-387",
teams_key: "lb_tk_fn0pL3YLWzPoPFWuHeV3kd0o7_SFuIOoU4C_k6OWDYg",
org_public_key: "lb_opk_fpxnp3r5djwxnmirx3tu276hialoivf3"
)
end
end

View file

@ -2169,4 +2169,51 @@ defmodule LivebookWeb.SessionLiveTest do
"The notebook uses session secrets, but those are not available to deployed apps. Convert them to Hub secrets instead."
end
end
describe "docker deployment" do
test "instructs to choose a file when the notebook is not persisted",
%{conn: conn, session: session} do
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-docker")
assert render(view) =~ "To deploy this app, make sure to save the notebook first."
assert render(view) =~ ~p"/sessions/#{session.id}/settings/file"
end
@tag :tmp_dir
test "instructs to change app settings when invalid",
%{conn: conn, session: session, tmp_dir: tmp_dir} do
notebook_path = Path.join(tmp_dir, "notebook.livemd")
file = Livebook.FileSystem.File.local(notebook_path)
Session.set_file(session.pid, file)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-docker")
assert render(view) =~ "To deploy this app, make sure to specify valid settings."
assert render(view) =~ ~p"/sessions/#{session.id}/settings/app"
end
@tag :tmp_dir
test "shows dockerfile and allows saving it",
%{conn: conn, session: session, tmp_dir: tmp_dir} do
notebook_path = Path.join(tmp_dir, "notebook.livemd")
file = Livebook.FileSystem.File.local(notebook_path)
Session.set_file(session.pid, file)
slug = Livebook.Utils.random_short_id()
app_settings = %{Livebook.Notebook.AppSettings.new() | slug: slug}
Session.set_app_settings(session.pid, app_settings)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-docker")
assert render(view) =~ "FROM ghcr.io/livebook-dev/livebook:"
view
|> element("button", "Save alongside notebook")
|> render_click()
dockerfile_path = Path.join(tmp_dir, "Dockerfile")
assert File.read!(dockerfile_path) =~ "COPY notebook.livemd /apps"
end
end
end