Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add docker deployment instructions to app panel #2276

Merged
merged 6 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions lib/livebook/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
284 changes: 284 additions & 0 deletions lib/livebook/hubs/dockerfile.ex
Original file line number Diff line number Diff line change
@@ -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
21 changes: 18 additions & 3 deletions lib/livebook_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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={[
Expand All @@ -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
Expand Down Expand Up @@ -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>
Expand Down
Loading
Loading