mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-01-01 12:41:43 +08:00
Add docker deployment instructions to app panel (#2276)
This commit is contained in:
parent
9bd51e3af1
commit
797844223a
11 changed files with 1062 additions and 275 deletions
|
@ -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
|
||||
|
||||
|
|
284
lib/livebook/hubs/dockerfile.ex
Normal file
284
lib/livebook/hubs/dockerfile.ex
Normal 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
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
188
lib/livebook_web/live/session_live/app_docker_component.ex
Normal file
188
lib/livebook_web/live/session_live/app_docker_component.ex
Normal 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
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
281
test/livebook/hubs/dockerfile_test.exs
Normal file
281
test/livebook/hubs/dockerfile_test.exs
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue