Current section

Files

Jump to
livebook lib livebook hubs dockerfile.ex
Raw

lib/livebook/hubs/dockerfile.ex

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(),
clustering: nil | :auto | :dns,
environment_variables: list({String.t(), String.t()})
}
@types %{
deploy_all: :boolean,
docker_tag: :string,
clustering: Ecto.ParameterizedType.init(Ecto.Enum, values: [:auto, :dns])
}
@doc """
Builds the default Dockerfile configuration.
"""
@spec config_new() :: config()
def config_new() do
default_image = Livebook.Config.docker_images() |> hd()
%{
deploy_all: false,
docker_tag: default_image.tag,
clustering: nil,
environment_variables: []
}
end
@doc """
Builds Dockerfile configuration with defaults from deployment group.
"""
@spec from_deployment_group(Livebook.Teams.DeploymentGroup.t()) :: config()
def from_deployment_group(deployment_group) do
environment_variables =
for environment_variable <- deployment_group.environment_variables,
do: {environment_variable.name, environment_variable.value}
%{
config_new()
| clustering: deployment_group.clustering,
environment_variables: environment_variables
}
end
@doc """
Builds a changeset for app Dockerfile configuration.
"""
@spec config_changeset(config(), map()) :: Ecto.Changeset.t()
def config_changeset(config, attrs \\ %{}) do
{config, @types}
|> cast(attrs, [:deploy_all, :docker_tag, :clustering])
|> validate_required([:deploy_all, :docker_tag])
end
@doc """
Builds Dockerfile definition for app deployment.
"""
@spec airgapped_dockerfile(
config(),
Hubs.Provider.t(),
list(Livebook.Secrets.Secret.t()),
list(Livebook.FileSystem.t()),
Livebook.FileSystem.File.t() | nil,
list(Livebook.Notebook.file_entry()),
Livebook.Session.Data.secrets()
) :: String.t()
def airgapped_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_hub_secrets(config, hub_secrets, secrets) |> Enum.sort_by(& &1.name)
hub_config = format_hub_config(hub_type, config, hub, hub_file_systems, used_secrets)
environment_variables =
if config.environment_variables != [] do
envs = config.environment_variables |> Enum.sort() |> format_envs()
"""
# Deployment group environment variables
#{envs}\
"""
end
secret_key =
case hub_type do
"team" -> hub.teams_key
"personal" -> hub.secret_key
end
{secret_key_base, cookie} = deterministic_skb_and_cookie(secret_key)
clustering_config =
case to_string(config.clustering) do
"auto" ->
"""
# Clustering configuration (note that we need the same Livebook secrets across all nodes)
ENV LIVEBOOK_CLUSTER="auto"
ENV LIVEBOOK_SECRET_KEY_BASE="#{secret_key_base}"
ENV LIVEBOOK_COOKIE="#{cookie}"
"""
"dns" ->
"""
# Clustering configuration (note that we need the same Livebook secrets across all nodes)
ENV LIVEBOOK_NODE="livebook_server@MACHINE_IP"
ENV LIVEBOOK_CLUSTER="dns:QUERY"
ENV LIVEBOOK_SECRET_KEY_BASE="#{secret_key_base}"
ENV LIVEBOOK_COOKIE="#{cookie}"
"""
_ ->
nil
end
apps_config = """
# Apps configuration
ENV LIVEBOOK_APPS_PATH="/apps"
ENV LIVEBOOK_APPS_PATH_WARMUP="manual"
"""
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
"""
[
image,
image_envs,
hub_config,
environment_variables,
clustering_config,
apps_config,
notebook,
apps_warmup
]
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
end
defp deterministic_skb_and_cookie(secret_key) do
hash = :crypto.hash(:sha256, secret_key)
<<left::48-binary, right::39-binary>> =
Plug.Crypto.KeyGenerator.generate(hash, "dockerfile",
cache: Plug.Crypto.Keys,
length: 48 + 39
)
{Base.url_encode64(left, padding: false), "c_" <> Base.url_encode64(right, padding: false)}
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_AUTH="offline:#{hub.hub_name}:#{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
[base_env, secrets, file_systems]
|> 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}="#{String.replace(value, ~S["], ~S[\"])}"\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
|> JSON.encode!()
|> Livebook.Teams.encrypt(secret_key)
end
defp used_hub_secrets(config, hub_secrets, secrets) do
if config.deploy_all do
hub_secrets
else
Enum.filter(hub_secrets, fn hub_secret ->
if secret = secrets[hub_secret.name] do
secret.hub_id == hub_secret.hub_id
end
end)
end
end
defp used_hub_file_systems(config, hub_file_systems, file_entries) do
if config.deploy_all do
hub_file_systems
else
file_entry_file_system_ids =
for entry <- file_entries,
entry.type == :file,
do: entry.file.file_system_id,
into: MapSet.new()
Enum.filter(hub_file_systems, &(&1.id in file_entry_file_system_ids))
end
end
@doc """
Returns information for deploying Livebook Agent using Docker.
"""
@spec online_docker_info(config(), Hubs.Provider.t(), Livebook.Teams.AgentKey.t()) :: %{
image: String.t(),
env: list({String.t(), String.t()})
}
def online_docker_info(config, %Hubs.Team{} = hub, agent_key) do
base_image = Enum.find(Livebook.Config.docker_images(), &(&1.tag == config.docker_tag))
image = "ghcr.io/livebook-dev/livebook:#{base_image.tag}"
env = [
{"LIVEBOOK_AGENT_NAME", "default"},
{"LIVEBOOK_TEAMS_KEY", "#{hub.teams_key}"},
{"LIVEBOOK_TEAMS_AUTH",
"online:#{hub.hub_name}:#{hub.org_id}:#{hub.org_key_id}:#{agent_key.key}"}
]
{secret_key_base, cookie} = deterministic_skb_and_cookie(hub.teams_key)
clustering_env =
case to_string(config.clustering) do
"auto" ->
[
{"LIVEBOOK_CLUSTER", "auto"},
{"LIVEBOOK_SECRET_KEY_BASE", secret_key_base},
{"LIVEBOOK_COOKIE", cookie}
]
"dns" ->
[
{"LIVEBOOK_NODE", "livebook_server@MACHINE_IP"},
{"LIVEBOOK_CLUSTER", "dns:QUERY"},
{"LIVEBOOK_SECRET_KEY_BASE", secret_key_base},
{"LIVEBOOK_COOKIE", cookie}
]
_ ->
[]
end
deployment_group_env = Enum.sort(config.environment_variables)
%{image: image, env: base_image.env ++ env ++ deployment_group_env ++ clustering_env}
end
@doc """
Returns a list of Dockerfile-related warnings.
The returned messages may include HTML.
"""
@spec airgapped_warnings(
config(),
Hubs.Provider.t(),
list(Livebook.Secrets.Secret.t()),
list(Livebook.FileSystem.t()),
Livebook.Notebook.AppSettings.t(),
list(Livebook.Notebook.file_entry()),
Livebook.Session.Data.secrets()
) :: list(String.t())
def airgapped_warnings(
config,
hub,
hub_secrets,
hub_file_systems,
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 Workspace secrets instead."
end
] ++ config_warnings(config)
hub_warnings =
case Hubs.Provider.type(hub) do
"personal" ->
used_hub_secrets = used_hub_secrets(config, hub_secrets, secrets)
used_hub_file_systems = used_hub_file_systems(config, hub_file_systems, file_entries)
[
if used_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 used_hub_file_systems != [] do
%module{} = hd(used_hub_file_systems)
name = LivebookWeb.FileSystemComponents.file_system_name(module)
"The #{name} file storage, configured in your personal workspace, will not be available in the Docker image." <>
" You must either download all file references as file 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://hexdocs.pm/livebook/authentication.html" target="_blank">Authentication</a>}
"This app has no password configuration and anyone with access to the server will be able" <>
" to use it. See the documentation on #{teams_link} for more information."
end
]
"team" ->
[
if app_settings.access_type == :public 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 an Identity Provider."
end
]
end
Enum.reject(common_warnings ++ hub_warnings, &is_nil/1)
end
defp config_warnings(config) do
[
if config.clustering == nil do
"Clustering has not been configured for this deployment. " <>
"You must either configure it below or make sure to run only one instance of this server."
end
]
end
@doc """
Returns warnings specific to agent Docker deployment.
"""
@spec online_warnings(config()) :: list(String.t())
def online_warnings(config) do
warnings = config_warnings(config)
Enum.reject(warnings, &is_nil/1)
end
end