mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-01-19 21:38:13 +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)
|
@identity_provider_read_only Enum.filter(@identity_providers, & &1.read_only)
|
||||||
|
|
||||||
@doc """
|
@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()})
|
@spec docker_images() :: list(%{tag: String.t(), name: String.t(), env: keyword()})
|
||||||
def docker_tags do
|
def docker_images() do
|
||||||
version = app_version()
|
version = app_version()
|
||||||
base = if version =~ "dev", do: "latest", else: 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} 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]
|
attr :kind, :atom, values: [:info, :success, :warning, :error]
|
||||||
|
|
||||||
|
slot :inner_block
|
||||||
|
|
||||||
def message_box(assigns) do
|
def message_box(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class={[
|
<div class={[
|
||||||
|
@ -108,7 +114,14 @@ defmodule LivebookWeb.CoreComponents do
|
||||||
@kind == :warning && "border-yellow-300",
|
@kind == :warning && "border-yellow-300",
|
||||||
@kind == :error && "border-red-500"
|
@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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
@ -478,11 +491,13 @@ defmodule LivebookWeb.CoreComponents do
|
||||||
default: false,
|
default: false,
|
||||||
doc: "whether to force the text into a single scrollable line"
|
doc: "whether to force the text into a single scrollable line"
|
||||||
|
|
||||||
|
attr :class, :string, default: nil
|
||||||
|
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def labeled_text(assigns) do
|
def labeled_text(assigns) do
|
||||||
~H"""
|
~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">
|
<span class="text-sm text-gray-500">
|
||||||
<%= @label %>
|
<%= @label %>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule LivebookWeb.AppHelpers do
|
defmodule LivebookWeb.AppHelpers do
|
||||||
use LivebookWeb, :html
|
use LivebookWeb, :html
|
||||||
|
|
||||||
|
alias Livebook.Hubs
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders page placeholder on unauthenticated dead render.
|
Renders page placeholder on unauthenticated dead render.
|
||||||
"""
|
"""
|
||||||
|
@ -79,4 +81,148 @@ defmodule LivebookWeb.AppHelpers do
|
||||||
confirm_icon: "delete-bin-6-line"
|
confirm_icon: "delete-bin-6-line"
|
||||||
)
|
)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -12,7 +12,7 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
|
||||||
socket = assign(socket, assigns)
|
socket = assign(socket, assigns)
|
||||||
changeset = Team.change_hub(assigns.hub)
|
changeset = Team.change_hub(assigns.hub)
|
||||||
show_key? = assigns.params["show-key"] == "true"
|
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)
|
file_systems = Hubs.get_file_systems(assigns.hub, hub_only: true)
|
||||||
secret_name = assigns.params["secret_name"]
|
secret_name = assigns.params["secret_name"]
|
||||||
file_system_id = assigns.params["file_system_id"]
|
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)}")
|
raise(NotFoundError, "could not find file system matching #{inspect(file_system_id)}")
|
||||||
end
|
end
|
||||||
|
|
||||||
docker_tags = Livebook.Config.docker_tags()
|
|
||||||
[%{tag: default_base_image} | _] = docker_tags
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(
|
||||||
|
@ -44,13 +41,10 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
|
||||||
secret_name: secret_name,
|
secret_name: secret_name,
|
||||||
secret_value: secret_value,
|
secret_value: secret_value,
|
||||||
hub_metadata: Provider.to_metadata(assigns.hub),
|
hub_metadata: Provider.to_metadata(assigns.hub),
|
||||||
is_default: is_default?,
|
is_default: is_default?
|
||||||
zta: %{"provider" => "", "key" => ""},
|
|
||||||
zta_metadata: nil,
|
|
||||||
base_image: default_base_image,
|
|
||||||
docker_tags: docker_tags
|
|
||||||
)
|
)
|
||||||
|> assign_dockerfile()
|
|> assign_new(:config_changeset, fn -> Hubs.Dockerfile.config_changeset() end)
|
||||||
|
|> update_dockerfile()
|
||||||
|> assign_form(changeset)}
|
|> assign_form(changeset)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -184,7 +178,7 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
|
||||||
|
|
||||||
<div class="flex flex-col space-y-4">
|
<div class="flex flex-col space-y-4">
|
||||||
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
||||||
File Storages
|
File storages
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-gray-700">
|
<p class="text-gray-700">
|
||||||
|
@ -202,129 +196,36 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
|
||||||
|
|
||||||
<div class="flex flex-col space-y-4">
|
<div class="flex flex-col space-y-4">
|
||||||
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
||||||
Airgapped Deployment
|
Airgapped deployment
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-gray-700">
|
<p class="text-gray-700">
|
||||||
It is possible to deploy notebooks that belong to this Hub in an airgapped
|
It is possible to deploy notebooks that belong to this Hub in an airgapped
|
||||||
deployment, without connecting back to Livebook Teams server, by following
|
deployment, without connecting back to Livebook Teams server. Configure the
|
||||||
the steps below. First, configure your deployment:
|
deployment below and use the generated Dockerfile in a directory with notebooks
|
||||||
|
that belong to your Organization.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid grid-cols-1">
|
<.form
|
||||||
<form phx-change="base_image" phx-target={@myself} phx-nosubmit>
|
:let={f}
|
||||||
<.radio_field
|
for={@config_changeset}
|
||||||
name="base_image"
|
as={:data}
|
||||||
label="Base image"
|
phx-change="validate_dockerfile"
|
||||||
value={@base_image}
|
phx-target={@myself}
|
||||||
options={for tag <- @docker_tags, do: {tag.tag, tag.name}}
|
>
|
||||||
/>
|
<LivebookWeb.AppHelpers.docker_config_form_content
|
||||||
</form>
|
hub={@hub}
|
||||||
</div>
|
form={f}
|
||||||
|
show_deploy_all={false}
|
||||||
<.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>
|
</.form>
|
||||||
|
|
||||||
<p class="text-gray-700">
|
<LivebookWeb.AppHelpers.docker_instructions hub={@hub} dockerfile={@dockerfile} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col space-y-4">
|
<div class="flex flex-col space-y-4">
|
||||||
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
|
||||||
Danger Zone
|
Danger zone
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 text-gray-700">
|
<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">
|
<div class="flex items-center absolute inset-y-0 right-1">
|
||||||
<button
|
<button
|
||||||
class="icon-button"
|
class="icon-button"
|
||||||
data-copy
|
|
||||||
data-tooltip="Copied to clipboard"
|
data-tooltip="Copied to clipboard"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="copy to clipboard"
|
aria-label="copy to clipboard"
|
||||||
phx-click={
|
phx-click={
|
||||||
JS.dispatch("lb:clipcopy", to: "#teams-key")
|
JS.dispatch("lb:clipcopy", to: "#teams-key")
|
||||||
|> JS.add_class(
|
|> JS.add_class("", transition: {"tooltip top", "", ""}, time: 2000)
|
||||||
"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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<.remix_icon icon="clipboard-line" class="text-xl" />
|
<.remix_icon icon="clipboard-line" class="text-xl" />
|
||||||
|
@ -521,15 +411,16 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("change_zta", %{"provider" => provider} = params, socket) do
|
def handle_event("validate_dockerfile", %{"data" => data}, socket) do
|
||||||
zta = %{"provider" => provider, "key" => params["key"]}
|
changeset =
|
||||||
|
data
|
||||||
|
|> Hubs.Dockerfile.config_changeset()
|
||||||
|
|> Map.replace!(:action, :validate)
|
||||||
|
|
||||||
meta =
|
{:noreply,
|
||||||
Enum.find(Livebook.Config.identity_providers(), fn meta ->
|
socket
|
||||||
Atom.to_string(meta.type) == provider
|
|> assign(config_changeset: changeset)
|
||||||
end)
|
|> update_dockerfile()}
|
||||||
|
|
||||||
{:noreply, assign(socket, zta: zta, zta_metadata: meta) |> assign_dockerfile()}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("mark_as_default", _, socket) do
|
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}")}
|
{:noreply, push_navigate(socket, to: ~p"/hub/#{socket.assigns.hub.id}")}
|
||||||
end
|
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
|
defp is_default?(hub) do
|
||||||
Hubs.get_default_hub().id == hub.id
|
Hubs.get_default_hub().id == hub.id
|
||||||
end
|
end
|
||||||
|
@ -554,110 +441,17 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
|
||||||
assign(socket, form: to_form(changeset))
|
assign(socket, form: to_form(changeset))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assign_dockerfile(socket) do
|
defp update_dockerfile(socket) do
|
||||||
base_image = Enum.find(socket.assigns.docker_tags, &(&1.tag == socket.assigns.base_image))
|
config =
|
||||||
|
socket.assigns.config_changeset
|
||||||
|
|> Ecto.Changeset.apply_changes()
|
||||||
|
|> Map.replace!(:deploy_all, true)
|
||||||
|
|
||||||
image = """
|
%{hub: hub, secrets: hub_secrets, file_systems: hub_file_systems} = socket.assigns
|
||||||
FROM ghcr.io/livebook-dev/livebook:#{base_image.tag}
|
|
||||||
"""
|
|
||||||
|
|
||||||
image_base_env = base_env(base_image.env)
|
|
||||||
|
|
||||||
base_args = """
|
|
||||||
ARG APPS_PATH=/path/to/my/notebooks
|
|
||||||
ARG TEAMS_KEY="#{socket.assigns.hub.teams_key}"
|
|
||||||
"""
|
|
||||||
|
|
||||||
base_env =
|
|
||||||
"""
|
|
||||||
|
|
||||||
ENV LIVEBOOK_TEAMS_KEY ${TEAMS_KEY}
|
|
||||||
ENV LIVEBOOK_TEAMS_NAME "#{socket.assigns.hub.hub_name}"
|
|
||||||
ENV LIVEBOOK_TEAMS_OFFLINE_KEY "#{socket.assigns.hub.org_public_key}"
|
|
||||||
"""
|
|
||||||
|
|
||||||
secrets = secrets_env(socket)
|
|
||||||
file_systems = file_systems_env(socket)
|
|
||||||
|
|
||||||
apps =
|
|
||||||
"""
|
|
||||||
|
|
||||||
ENV LIVEBOOK_APPS_PATH "/apps"
|
|
||||||
ENV LIVEBOOK_APPS_PATH_WARMUP "manual"
|
|
||||||
ENV LIVEBOOK_APPS_PATH_HUB_ID "#{socket.assigns.hub.id}"
|
|
||||||
COPY ${APPS_PATH} /apps
|
|
||||||
RUN /app/bin/warmup_apps.sh\
|
|
||||||
"""
|
|
||||||
|
|
||||||
zta = zta_env(socket.assigns.zta)
|
|
||||||
|
|
||||||
dockerfile =
|
dockerfile =
|
||||||
[image, base_args, image_base_env, base_env, secrets, file_systems, zta, apps]
|
Hubs.Dockerfile.build_dockerfile(config, hub, hub_secrets, hub_file_systems, nil, [], %{})
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|> Enum.join()
|
|
||||||
|
|
||||||
assign(socket, :dockerfile, dockerfile)
|
assign(socket, :dockerfile, dockerfile)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -482,6 +482,26 @@ defmodule LivebookWeb.SessionLive do
|
||||||
/>
|
/>
|
||||||
</.modal>
|
</.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
|
<.modal
|
||||||
:if={@live_action == :add_file_entry}
|
:if={@live_action == :add_file_entry}
|
||||||
id="add-file-entry-modal"
|
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}
|
kind={:warning}
|
||||||
message="The notebook uses session secrets, but those are not available to deployed apps. Convert them to Hub secrets instead."
|
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">
|
<div class="flex flex-col gap-3">
|
||||||
<button
|
<div class="flex gap-2">
|
||||||
class="button-base button-blue"
|
<button
|
||||||
phx-click="deploy_app"
|
class="button-base button-blue"
|
||||||
disabled={not Livebook.Notebook.AppSettings.valid?(@settings)}
|
phx-click="deploy_app"
|
||||||
>
|
disabled={not Livebook.Notebook.AppSettings.valid?(@settings)}
|
||||||
<.remix_icon icon="rocket-line" class="align-middle mr-1" />
|
>
|
||||||
<span>Deploy</span>
|
<.remix_icon icon="rocket-line" class="align-middle mr-1" />
|
||||||
</button>
|
<span>Deploy</span>
|
||||||
|
</button>
|
||||||
|
<.link
|
||||||
|
patch={~p"/sessions/#{@session.id}/settings/app"}
|
||||||
|
class="button-base button-outlined-gray bg-transparent"
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
<.link
|
<.link
|
||||||
patch={~p"/sessions/#{@session.id}/settings/app"}
|
class="text-sm text-gray-700 hover:text-blue-600"
|
||||||
class="button-base button-outlined-gray bg-transparent"
|
patch={~p"/sessions/#{@session.id}/app-docker"}
|
||||||
>
|
>
|
||||||
Configure
|
<.remix_icon icon="arrow-right-line" />
|
||||||
|
<span>Deploy with Docker</span>
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -55,18 +64,20 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
||||||
Latest deployment
|
Latest deployment
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 border border-gray-200 rounded-lg">
|
<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>
|
<.labeled_text label="URL" one_line>
|
||||||
<a href={~p"/apps/#{@app.slug}"}>
|
<a href={~p"/apps/#{@app.slug}"}>
|
||||||
<%= ~p"/apps/#{@app.slug}" %>
|
<%= ~p"/apps/#{@app.slug}" %>
|
||||||
</a>
|
</a>
|
||||||
</.labeled_text>
|
</.labeled_text>
|
||||||
<.labeled_text label="Version" one_line>
|
<div class="flex gap-3">
|
||||||
v<%= @app.version %>
|
<.labeled_text label="Session type" one_line class="grow">
|
||||||
</.labeled_text>
|
<%= if(@app.multi_session, do: "Multi", else: "Single") %>
|
||||||
<.labeled_text label="Session type" one_line>
|
</.labeled_text>
|
||||||
<%= if(@app.multi_session, do: "Multi", else: "Single") %>
|
<.labeled_text label="Version" one_line class="grow">
|
||||||
</.labeled_text>
|
v<%= @app.version %>
|
||||||
|
</.labeled_text>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-gray-200 px-3 py-2 flex space-x-2">
|
<div class="border-t border-gray-200 px-3 py-2 flex space-x-2">
|
||||||
<div class="grow" />
|
<div class="grow" />
|
||||||
|
@ -85,9 +96,9 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
||||||
Running sessions
|
Running sessions
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 flex flex-col space-y-4">
|
<div class="mt-2 flex flex-col space-y-4">
|
||||||
<div :for={app_session <- @app.sessions} class="border border-gray-200 rounded-lg">
|
<div :for={app_session <- @app.sessions} class="w-full border border-gray-200 rounded-lg">
|
||||||
<div class="p-4 flex flex-col space-y-3">
|
<div class="p-4 flex gap-3">
|
||||||
<.labeled_text label="Status">
|
<.labeled_text label="Status" class="grow">
|
||||||
<a
|
<a
|
||||||
class="inline-block"
|
class="inline-block"
|
||||||
aria-label="debug app"
|
aria-label="debug app"
|
||||||
|
@ -97,7 +108,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
||||||
<.app_status status={app_session.app_status} />
|
<.app_status status={app_session.app_status} />
|
||||||
</a>
|
</a>
|
||||||
</.labeled_text>
|
</.labeled_text>
|
||||||
<.labeled_text label="Version">
|
<.labeled_text label="Version" class="grow">
|
||||||
v<%= app_session.version %>
|
v<%= app_session.version %>
|
||||||
</.labeled_text>
|
</.labeled_text>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -88,6 +88,7 @@ defmodule LivebookWeb.Router do
|
||||||
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings
|
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings
|
||||||
live "/sessions/:id/settings/file", SessionLive, :file_settings
|
live "/sessions/:id/settings/file", SessionLive, :file_settings
|
||||||
live "/sessions/:id/settings/app", SessionLive, :app_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/add-file/:tab", SessionLive, :add_file_entry
|
||||||
live "/sessions/:id/rename-file/:name", SessionLive, :rename_file_entry
|
live "/sessions/:id/rename-file/:name", SessionLive, :rename_file_entry
|
||||||
live "/sessions/:id/bin", SessionLive, :bin
|
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."
|
"The notebook uses session secrets, but those are not available to deployed apps. Convert them to Hub secrets instead."
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
Loading…
Reference in a new issue