Deploy apps to Livebook Agent (#2511)

This commit is contained in:
Alexandre de Souza 2024-03-20 10:16:30 -03:00 committed by GitHub
parent 40a4fda509
commit ddc2ad0a85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 971 additions and 55 deletions

View file

@ -279,6 +279,21 @@ defmodule Livebook.Hubs.TeamClient do
}
end
defp put_app_deployment(deployment_group, app_deployment) do
deployment_group = remove_app_deployment(deployment_group, app_deployment)
app_deployments = [app_deployment | deployment_group.app_deployments]
%{deployment_group | app_deployments: Enum.sort_by(app_deployments, & &1.slug)}
end
defp remove_app_deployment(deployment_group, app_deployment) do
%{
deployment_group
| app_deployments:
Enum.reject(deployment_group.app_deployments, &(&1.slug == app_deployment.slug))
}
end
defp build_agent_key(agent_key) do
%Teams.AgentKey{
id: agent_key.id,
@ -290,6 +305,7 @@ defmodule Livebook.Hubs.TeamClient do
defp build_deployment_group(state, deployment_group) do
secrets = Enum.map(deployment_group.secrets, &build_secret(state, &1))
agent_keys = Enum.map(deployment_group.agent_keys, &build_agent_key/1)
app_deployments = Enum.map(deployment_group.deployed_apps, &build_app_deployment/1)
%Teams.DeploymentGroup{
id: deployment_group.id,
@ -298,12 +314,29 @@ defmodule Livebook.Hubs.TeamClient do
hub_id: state.hub.id,
secrets: secrets,
agent_keys: agent_keys,
app_deployments: app_deployments,
clustering: nullify(deployment_group.clustering),
zta_provider: atomize(deployment_group.zta_provider),
zta_key: nullify(deployment_group.zta_key)
}
end
defp build_app_deployment(app_deployment) do
path = URI.parse(app_deployment.archive_url).path
%Teams.AppDeployment{
id: app_deployment.id,
filename: Path.basename(path),
slug: app_deployment.slug,
sha: app_deployment.sha,
title: app_deployment.title,
deployment_group_id: app_deployment.deployment_group_id,
file: {:url, app_deployment.archive_url},
deployed_by: app_deployment.deployed_by,
deployed_at: NaiveDateTime.from_iso8601!(app_deployment.deployed_at)
}
end
defp handle_event(:secret_created, %Secrets.Secret{} = secret, state) do
Hubs.Broadcasts.secret_created(secret)
@ -382,6 +415,7 @@ defmodule Livebook.Hubs.TeamClient do
end
defp handle_event(:deployment_group_updated, %Teams.DeploymentGroup{} = deployment_group, state) do
deployment_group = deploy_apps(state.deployment_group_id, deployment_group, state.derived_key)
Teams.Broadcasts.deployment_group_updated(deployment_group)
put_deployment_group(state, deployment_group)
@ -397,6 +431,7 @@ defmodule Livebook.Hubs.TeamClient do
defp handle_event(:deployment_group_deleted, deployment_group_deleted, state) do
with {:ok, deployment_group} <- fetch_deployment_group(deployment_group_deleted.id, state) do
undeploy_apps(state.deployment_group_id, deployment_group)
Teams.Broadcasts.deployment_group_deleted(deployment_group)
remove_deployment_group(state, deployment_group)
@ -436,6 +471,24 @@ defmodule Livebook.Hubs.TeamClient do
end
end
defp handle_event(:app_deployment_created, app_deployment_created, state) do
app_deployment = build_app_deployment(app_deployment_created)
deployment_group_id = app_deployment.deployment_group_id
with {:ok, deployment_group} <- fetch_deployment_group(deployment_group_id, state) do
app_deployment =
if deployment_group_id == state.deployment_group_id do
{:ok, app_deployment} = download_and_deploy(app_deployment, state.derived_key)
app_deployment
else
app_deployment
end
deployment_group = put_app_deployment(deployment_group, app_deployment)
handle_event(:deployment_group_updated, deployment_group, state)
end
end
defp dispatch_secrets(state, %{secrets: secrets}) do
decrypted_secrets = Enum.map(secrets, &build_secret(state, &1))
@ -534,4 +587,59 @@ defmodule Livebook.Hubs.TeamClient do
defp nullify(""), do: nil
defp nullify(value), do: value
defp deploy_apps(id, %{id: id} = deployment_group, derived_key) do
app_deployments =
for app_deployment <- deployment_group.app_deployments do
{:ok, app_deployment} = download_and_deploy(app_deployment, derived_key)
app_deployment
end
%{deployment_group | app_deployments: app_deployments}
end
defp deploy_apps(_, deployment_group, _), do: deployment_group
defp undeploy_apps(id, %{id: id} = deployment_group) do
for %{slug: slug} <- deployment_group.app_deployments do
:ok = undeploy_app(slug)
end
end
defp undeploy_apps(_, _), do: :noop
defp download_and_deploy(%{file: nil} = app_deployment, _) do
app_deployment
end
defp download_and_deploy(%{file: {:url, archive_url}} = app_deployment, derived_key) do
destination_path = app_deployment_path(app_deployment.slug)
with {:ok, %{status: 200} = response} <- Req.get(archive_url),
:ok <- undeploy_app(app_deployment.slug),
{:ok, decrypted_content} <- Teams.decrypt(response.body, derived_key),
:ok <- unzip_app(decrypted_content, destination_path),
:ok <- Livebook.Apps.deploy_apps_in_dir(destination_path) do
{:ok, %{app_deployment | file: nil}}
end
end
defp undeploy_app(slug) do
with {:ok, app} <- Livebook.Apps.fetch_app(slug) do
Livebook.App.close(app.pid)
end
:ok
end
defp app_deployment_path(slug) do
Path.join([Livebook.Config.tmp_path(), "apps", slug <> Livebook.Utils.random_short_id()])
end
defp unzip_app(content, destination_path) do
case :zip.extract(content, cwd: to_charlist(destination_path)) do
{:ok, _} -> :ok
{:error, error} -> FileSystem.Utils.posix_error(error)
end
end
end

View file

@ -4,7 +4,7 @@ defmodule Livebook.Teams do
alias Livebook.Hubs
alias Livebook.Hubs.Team
alias Livebook.Hubs.TeamClient
alias Livebook.Teams.{AgentKey, DeploymentGroup, Org, Requests}
alias Livebook.Teams.{AgentKey, AppDeployment, DeploymentGroup, Org, Requests}
import Ecto.Changeset,
only: [add_error: 3, apply_action: 2, apply_action!: 2, get_field: 2]
@ -249,4 +249,27 @@ defmodule Livebook.Teams do
[]
end
end
@doc """
Creates a new app deployment.
"""
@spec deploy_app(Team.t(), AppDeployment.t()) ::
:ok
| {:error, Ecto.Changeset.t()}
| {:transport_error, String.t()}
def deploy_app(%Team{} = team, %AppDeployment{} = app_deployment) do
case Requests.deploy_app(team, app_deployment) do
{:ok, %{"id" => _id}} ->
:ok
{:error, %{"errors" => %{"detail" => error}}} ->
{:error, Requests.add_errors(app_deployment, %{"file" => [error]})}
{:error, %{"errors" => errors}} ->
{:error, Requests.add_errors(app_deployment, errors)}
any ->
any
end
end
end

View file

@ -0,0 +1,88 @@
defmodule Livebook.Teams.AppDeployment do
use Ecto.Schema
alias Livebook.FileSystem
@file_extension ".zip"
@max_size 20 * 1024 * 1024
@type t :: %__MODULE__{
id: String.t() | nil,
filename: String.t() | nil,
slug: String.t() | nil,
sha: String.t() | nil,
title: String.t() | nil,
deployment_group_id: String.t() | nil,
file: {:url, String.t()} | {:content, binary()} | nil,
deployed_by: String.t() | nil,
deployed_at: NaiveDateTime.t() | nil
}
@primary_key {:id, :string, autogenerate: false}
embedded_schema do
field :filename, :string
field :slug, :string
field :sha, :string
field :title, :string
field :deployment_group_id, :string
field :file, :string
field :deployed_by, :string
timestamps(updated_at: nil, inserted_at: :deployed_at)
end
@doc """
Creates a new app deployment from notebook.
"""
@spec new(Livebook.Notebook.t(), Livebook.FileSystem.File.t()) ::
{:ok, t()} | {:warning, list(String.t())} | {:error, FileSystem.error()}
def new(notebook, files_dir) do
with {:ok, source} <- fetch_notebook_source(notebook),
{:ok, files} <- build_and_check_file_entries(notebook, source, files_dir),
{:ok, {_, zip_content}} <- :zip.create(~c"app_deployment.zip", files, [:memory]),
:ok <- validate_size(zip_content) do
md5_hash = :crypto.hash(:md5, zip_content)
shasum = Base.encode16(md5_hash, case: :lower)
{:ok,
%__MODULE__{
filename: shasum <> @file_extension,
slug: notebook.app_settings.slug,
sha: shasum,
title: notebook.name,
deployment_group_id: notebook.deployment_group_id,
file: {:content, zip_content}
}}
end
end
defp fetch_notebook_source(notebook) do
case Livebook.LiveMarkdown.notebook_to_livemd(notebook) do
{source, []} -> {:ok, source}
{_, warnings} -> {:warning, warnings}
end
end
defp build_and_check_file_entries(notebook, source, files_dir) do
notebook.file_entries
|> Enum.filter(&(&1.type == :attachment))
|> Enum.reduce_while({:ok, [{~c"notebook.livemd", source}]}, fn %{name: name}, {:ok, acc} ->
file = Livebook.FileSystem.File.resolve(files_dir, name)
with {:ok, true} <- Livebook.FileSystem.File.exists?(file),
{:ok, content} <- Livebook.FileSystem.File.read(file) do
{:cont, {:ok, [{to_charlist("files/" <> name), content} | acc]}}
else
{:ok, false} -> {:halt, {:error, "files/#{name}: doesn't exist"}}
{:error, reason} -> {:halt, {:error, "files/#{name}: #{reason}"}}
end
end)
end
defp validate_size(data) do
if byte_size(data) <= @max_size do
:ok
else
{:error, "the notebook and its attachments have exceeded the maximum size of 20MB"}
end
end
end

View file

@ -3,7 +3,7 @@ defmodule Livebook.Teams.DeploymentGroup do
import Ecto.Changeset
alias Livebook.Secrets.Secret
alias Livebook.Teams.AgentKey
alias Livebook.Teams.{AgentKey, AppDeployment}
# If this list is updated, it must also be mirrored on Livebook Teams Server.
@zta_providers ~w(cloudflare google_iap tailscale teleport)a
@ -17,7 +17,8 @@ defmodule Livebook.Teams.DeploymentGroup do
zta_provider: :cloudflare | :google_iap | :tailscale | :teleport,
zta_key: String.t(),
secrets: [Secret.t()],
agent_keys: [AgentKey.t()]
agent_keys: [AgentKey.t()],
app_deployments: [AppDeployment.t()]
}
@primary_key {:id, :string, autogenerate: false}
@ -31,6 +32,7 @@ defmodule Livebook.Teams.DeploymentGroup do
has_many :secrets, Secret
has_many :agent_keys, AgentKey
has_many :app_deployments, AppDeployment
end
def changeset(deployment_group, attrs \\ %{}) do

View file

@ -4,7 +4,9 @@ defmodule Livebook.Teams.Requests do
alias Livebook.Hubs.Team
alias Livebook.Secrets.Secret
alias Livebook.Teams
alias Livebook.Teams.{AgentKey, DeploymentGroup, Org}
alias Livebook.Teams.{AgentKey, AppDeployment, DeploymentGroup, Org}
@error_message "Something went wrong, try again later or please file a bug if it persists"
@doc """
Send a request to Livebook Team API to create a new org.
@ -216,6 +218,26 @@ defmodule Livebook.Teams.Requests do
delete("/api/v1/org/deployment-groups/agent-keys", params, team)
end
@doc """
Send a request to Livebook Team API to deploy an app.
"""
@spec deploy_app(Team.t(), AppDeployment.t()) ::
{:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def deploy_app(team, %{file: {:content, content}} = app_deployment) do
secret_key = Teams.derive_key(team.teams_key)
params = %{
filename: app_deployment.filename <> ".encrypted",
title: app_deployment.title,
slug: app_deployment.slug,
deployment_group_id: app_deployment.deployment_group_id,
sha: app_deployment.sha
}
encrypted_content = Teams.encrypt(content, secret_key)
upload("/api/v1/org/apps", encrypted_content, params, team)
end
@doc """
Add requests errors to a `changeset` for the given `fields`.
"""
@ -235,6 +257,9 @@ defmodule Livebook.Teams.Requests do
value |> Ecto.Changeset.change() |> add_errors(struct.__schema__(:fields), errors_map)
end
@doc false
def error_message(), do: @error_message
defp post(path, json, team \\ nil) do
build_req()
|> add_team_auth(team)
@ -261,6 +286,14 @@ defmodule Livebook.Teams.Requests do
|> request(method: :get, url: path, params: params)
end
defp upload(path, content, params, team) do
build_req()
|> add_team_auth(team)
|> Req.Request.put_header("content-length", "#{byte_size(content)}")
|> request(method: :post, url: path, params: params, body: content)
|> dispatch_messages(team)
end
defp build_req() do
Req.new(
base_url: Livebook.Config.teams_url(),
@ -301,8 +334,7 @@ defmodule Livebook.Teams.Requests do
"You are not authorized to perform this action, make sure you have the access or you are not in a Livebook Agent instance"}
_otherwise ->
{:transport_error,
"Something went wrong, try again later or please file a bug if it persists"}
{:transport_error, @error_message}
end
end

View file

@ -37,6 +37,11 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupLive do
do: deployment_group.agent_keys,
else: []
app_deployments =
if socket.assigns.live_action != :new_deployment_group,
do: deployment_group.app_deployments,
else: []
secret_value =
if socket.assigns.live_action == :edit_secret do
Enum.find_value(secrets, &(&1.name == secret_name and &1.value)) ||
@ -55,7 +60,8 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupLive do
secret_value: secret_value,
default?: default?,
secrets: secrets,
agent_keys: agent_keys
agent_keys: agent_keys,
app_deployments: app_deployments
)
|> assign_new(:config_changeset, fn ->
Hubs.Dockerfile.config_changeset(Hubs.Dockerfile.config_new())
@ -188,6 +194,36 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupLive do
</div>
</div>
<div :if={@deployment_group.mode == :online} class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
Deployed Apps
</h2>
<p class="text-gray-700">
Apps currently deployed in this group.
</p>
<div id="deployed-apps-list" class="flex flex-col space-y-4">
<div class="flex flex-col space-y-4">
<.no_entries :if={@app_deployments == []}>
No deployed apps in this deployment group yet.
</.no_entries>
<div :if={@app_deployments != []}>
<.table id="hub-deployed-apps-table" rows={@app_deployments}>
<:col :let={app_deployment} label="ID"><%= app_deployment.id %></:col>
<:col :let={app_deployment} label="Slug"><%= app_deployment.slug %></:col>
<:col :let={app_deployment} label="Deployed By">
<%= app_deployment.deployed_by %>
</:col>
<:col :let={app_deployment} label="Deployed At">
<%= app_deployment.deployed_at %>
</:col>
</.table>
</div>
</div>
</div>
</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">
Airgapped deployment

View file

@ -17,6 +17,11 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
socket = assign(socket, assigns)
deployment_groups = Provider.deployment_groups(assigns.hub)
deployment_group =
if assigns.deployment_group_id do
Enum.find(deployment_groups, &(&1.id == assigns.deployment_group_id))
end
socket =
socket
|> assign(settings_valid?: Livebook.Notebook.AppSettings.valid?(socket.assigns.settings))
@ -24,14 +29,18 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
hub_secrets: Hubs.get_secrets(assigns.hub),
hub_file_systems: Hubs.get_file_systems(assigns.hub, hub_only: true),
deployment_groups: deployment_groups,
deployment_group: deployment_group,
deployment_group_form: %{"deployment_group_id" => assigns.deployment_group_id},
deployment_group_id: assigns.deployment_group_id
)
|> assign_new(:save_result, fn -> nil end)
|> assign_new(:messages, fn -> [] end)
socket =
if deployment_group_changed? do
assign(socket, :changeset, Hubs.Dockerfile.config_changeset(base_config(socket)))
assign(socket,
changeset: Hubs.Dockerfile.config_changeset(base_config(socket)),
deployment_type: :dockerfile
)
else
socket
end
@ -40,9 +49,7 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
end
defp base_config(socket) do
if id = socket.assigns.deployment_group_id do
deployment_group = Enum.find(socket.assigns.deployment_groups, &(&1.id == id))
if deployment_group = socket.assigns.deployment_group do
%{
Hubs.Dockerfile.config_new()
| clustering: deployment_group.clustering,
@ -65,14 +72,14 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
file={@file}
settings_valid?={@settings_valid?}
hub={@hub}
deployment_group={@deployment_group}
deployment_groups={@deployment_groups}
deployment_group_form={@deployment_group_form}
deployment_group_id={@deployment_group_id}
changeset={@changeset}
session={@session}
dockerfile={@dockerfile}
warnings={@warnings}
save_result={@save_result}
messages={@messages}
myself={@myself}
/>
</div>
@ -120,6 +127,7 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
You can deploy this app in the cloud using Docker. To do that, configure
the deployment and then use the generated Dockerfile.
</p>
<div class="flex gap-12">
<p class="text-gray-700">
<.label>Hub</.label>
@ -155,11 +163,13 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
<% end %>
<% end %>
</div>
<div :if={@warnings != []} class="flex flex-col gap-2">
<.message_box :for={warning <- @warnings} kind={:warning}>
<%= raw(warning) %>
<div :if={@messages != []} class="flex flex-col gap-2">
<.message_box :for={{kind, message} <- @messages} kind={kind}>
<%= raw(message) %>
</.message_box>
</div>
<.form :let={f} for={@changeset} as={:data} phx-change="validate" phx-target={@myself}>
<div class="flex flex-col space-y-4">
<AppComponents.deployment_group_form_content
@ -170,7 +180,7 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
<AppComponents.docker_config_form_content hub={@hub} form={f} />
</div>
</.form>
<.save_result :if={@save_result} save_result={@save_result} />
<AppComponents.docker_instructions
hub={@hub}
dockerfile={@dockerfile}
@ -194,22 +204,6 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
"""
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 =
@ -223,14 +217,12 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
def handle_event("save_dockerfile", %{}, socket) do
dockerfile_file = FileSystem.File.resolve(socket.assigns.file, "./Dockerfile")
file_path_message = "File saved at #{dockerfile_file.path}"
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)}
case FileSystem.File.write(dockerfile_file, socket.assigns.dockerfile) do
:ok -> {:noreply, assign(socket, messages: [{:info, file_path_message}])}
{:error, message} -> {:noreply, assign(socket, messages: [{:error, message}])}
end
end
def handle_event("select_deployment_group", %{"deployment_group_id" => id}, socket) do
@ -241,7 +233,7 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
end
defp update_dockerfile(socket) when socket.assigns.file == nil do
assign(socket, dockerfile: nil, warnings: [])
assign(socket, dockerfile: nil, messages: [])
end
defp update_dockerfile(socket) do
@ -291,7 +283,9 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
secrets
)
assign(socket, dockerfile: dockerfile, warnings: warnings)
messages = Enum.map(warnings, &{:warning, &1})
assign(socket, dockerfile: dockerfile, messages: messages)
end
defp deployment_group_options(deployment_groups) do

View file

@ -0,0 +1,223 @@
defmodule LivebookWeb.SessionLive.AppTeamsComponent do
use LivebookWeb, :live_component
alias Livebook.Hubs.Provider
@impl true
def update(assigns, socket) do
socket = assign(socket, assigns)
deployment_groups = Provider.deployment_groups(assigns.hub)
deployment_group =
if assigns.deployment_group_id do
Enum.find(deployment_groups, &(&1.id == assigns.deployment_group_id))
end
app_deployment =
if deployment_group do
Enum.find(deployment_group.app_deployments, &(&1.slug == assigns.app_settings.slug))
end
socket =
socket
|> assign(
settings_valid?: Livebook.Notebook.AppSettings.valid?(socket.assigns.app_settings),
app_deployment: app_deployment,
deployment_groups: deployment_groups,
deployment_group: deployment_group,
deployment_group_form: %{"deployment_group_id" => assigns.deployment_group_id},
deployment_group_id: assigns.deployment_group_id
)
|> assign_new(:messages, fn -> [] end)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="p-6 flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800">
App deployment with Livebook Teams
</h3>
<.content
file={@file}
hub={@hub}
app_deployment={@app_deployment}
deployment_group={@deployment_group}
deployment_groups={@deployment_groups}
deployment_group_form={@deployment_group_form}
deployment_group_id={@deployment_group_id}
session={@session}
messages={@messages}
myself={@myself}
/>
</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?context=app-teams"}
>
<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 to your own cloud using Livebook Teams. To do that, select
which deployment group you want to send this app and then click the button to Deploy.
</p>
<div class="flex gap-12">
<p class="text-gray-700">
<.label>Hub</.label>
<span>
<span class="text-lg"><%= @hub.hub_emoji %></span>
<span><%= @hub.hub_name %></span>
</span>
</p>
<%= if @deployment_groups do %>
<%= if @deployment_groups != [] do %>
<.form
for={@deployment_group_form}
phx-change="select_deployment_group"
phx-target={@myself}
id="select_deployment_group_form"
>
<.select_field
help={deployment_group_help()}
field={@deployment_group_form[:deployment_group_id]}
options={deployment_group_options(@deployment_groups)}
label="Deployment Group"
name="deployment_group_id"
value={@deployment_group_id}
/>
</.form>
<% else %>
<p class="text-gray-700">
<.label help={deployment_group_help()}>
Deployment Group
</.label>
<span>No deployment groups available</span>
</p>
<% end %>
<% end %>
</div>
<div :if={@messages != []} class="flex flex-col gap-2">
<.message_box :for={{kind, message} <- @messages} kind={kind}>
<%= raw(message) %>
</.message_box>
</div>
<div :if={@app_deployment} class="space-y-3 pb-4">
<p class="text-gray-700">Current deployed version:</p>
<ul class="text-gray-700 space-y-3">
<li class="flex gap-2">
<div class="font-bold">Title:</div>
<span><%= @app_deployment.title %></span>
</li>
<li class="flex gap-2">
<div class="font-bold">Deployed by:</div>
<span><%= @app_deployment.deployed_by %></span>
</li>
<li class="flex gap-2">
<div class="font-bold">Deployed at:</div>
<span><%= @app_deployment.deployed_at %></span>
</li>
</ul>
</div>
<.button
id="deploy-livebook-agent-button"
color="blue"
phx-click="deploy_app"
phx-target={@myself}
>
<.remix_icon icon="rocket-line" /> Deploy to Livebook Agent
</.button>
</div>
"""
end
@impl true
def handle_event("select_deployment_group", %{"deployment_group_id" => id}, socket) do
id = if(id != "", do: id)
Livebook.Session.set_notebook_deployment_group(socket.assigns.session.pid, id)
{:noreply, socket}
end
def handle_event("deploy_app", _, socket) do
with {:ok, app_deployment} <- pack_app(socket),
:ok <- deploy_app(socket, app_deployment) do
message =
"App deployment for #{app_deployment.slug} with title #{app_deployment.title} created successfully"
{:noreply, assign(socket, messages: [{:info, message}])}
end
end
defp pack_app(socket) do
notebook = Livebook.Session.get_notebook(socket.assigns.session.pid)
files_dir = socket.assigns.session.files_dir
case Livebook.Teams.AppDeployment.new(notebook, files_dir) do
{:ok, app_deployment} ->
{:ok, app_deployment}
{:warning, warnings} ->
messages = Enum.map(warnings, &{:error, &1})
{:noreply, assign(socket, messages: messages)}
{:error, error} ->
error = "Failed to pack files: #{error}"
{:noreply, assign(socket, messages: [{:error, error}])}
end
end
defp deploy_app(socket, app_deployment) do
app_deployment = Map.replace!(app_deployment, :slug, "@bc")
case Livebook.Teams.deploy_app(socket.assigns.hub, app_deployment) do
:ok ->
:ok
{:error, %{errors: errors}} ->
errors = Enum.map(errors, fn {key, error} -> "#{key}: #{normalize_error(error)}" end)
{:noreply, assign(socket, messages: errors)}
{:transport_error, error} ->
{:noreply, assign(socket, messages: [{:error, error}])}
end
end
defp normalize_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end
defp deployment_group_options(deployment_groups) do
for deployment_group <- [%{name: "none", id: nil}] ++ deployment_groups,
do: {deployment_group.name, deployment_group.id}
end
defp deployment_group_help() do
"Share deployment credentials, secrets, and configuration with deployment groups."
end
end

View file

@ -116,6 +116,24 @@ defmodule LivebookWeb.SessionLive.Render do
/>
</.modal>
<.modal
:if={@live_action == :app_teams}
id="app-teams-modal"
show
width={:medium}
patch={@self_path}
>
<.live_component
module={LivebookWeb.SessionLive.AppTeamsComponent}
id="app-teams"
session={@session}
hub={@data_view.hub}
file={@data_view.file}
app_settings={@data_view.app_settings}
deployment_group_id={@data_view.deployment_group_id}
/>
</.modal>
<.modal
:if={@live_action == :add_file_entry}
id="add-file-entry-modal"

View file

@ -109,6 +109,7 @@ defmodule LivebookWeb.Router do
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/app-teams", SessionLive, :app_teams
live "/sessions/:id/add-file/:tab", SessionLive, :add_file_entry
live "/sessions/:id/rename-file/:name", SessionLive, :rename_file_entry
live "/sessions/:id/bin", SessionLive, :bin

View file

@ -151,6 +151,43 @@ defmodule Livebook.Hubs.TeamClientTest do
# we dispatch the `{:event, :deployment_group_updated, :deployment_group}` event
assert_receive {:deployment_group_updated, %{id: ^id, agent_keys: [^built_in_agent_key]}}
end
@tag :tmp_dir
test "receives the app events", %{team: team, tmp_dir: tmp_dir} do
deployment_group = build(:deployment_group, name: team.id, mode: :online)
assert {:ok, id} = Livebook.Teams.create_deployment_group(team, deployment_group)
id = to_string(id)
# receives `{:event, :deployment_group_created, :deployment_group}` event
assert_receive {:deployment_group_created, %{id: ^id, app_deployments: []}}
# creates the app deployment
slug = Livebook.Utils.random_short_id()
title = "MyNotebook-#{slug}"
notebook = %{
Livebook.Notebook.new()
| app_settings: %{Livebook.Notebook.AppSettings.new() | slug: slug},
name: title,
hub_id: team.id,
deployment_group_id: id
}
files_dir = Livebook.FileSystem.File.local(tmp_dir)
{:ok, app_deployment} = Livebook.Teams.AppDeployment.new(notebook, files_dir)
:ok = Livebook.Teams.deploy_app(team, app_deployment)
# since the `app_deployment` belongs to a deployment group,
# we dispatch the `{:event, :deployment_group_updated, :deployment_group}` event
assert_receive {:deployment_group_updated,
%{
id: ^id,
app_deployments: [
%Livebook.Teams.AppDeployment{title: ^title, slug: ^slug}
]
}}
end
end
describe "handle user_connected event" do
@ -273,8 +310,7 @@ defmodule Livebook.Hubs.TeamClientTest do
id: "1",
name: "sleepy-cat-#{System.unique_integer([:positive])}",
mode: :offline,
hub_id: team.id,
secrets: []
hub_id: team.id
)
livebook_proto_deployment_group =
@ -282,7 +318,9 @@ defmodule Livebook.Hubs.TeamClientTest do
id: to_string(deployment_group.id),
name: deployment_group.name,
mode: to_string(deployment_group.mode),
secrets: []
secrets: [],
agent_keys: [],
deployed_apps: []
}
# creates the deployment group
@ -317,6 +355,73 @@ defmodule Livebook.Hubs.TeamClientTest do
assert_receive {:deployment_group_deleted, ^updated_deployment_group}
refute updated_deployment_group in TeamClient.get_deployment_groups(team.id)
end
test "dispatches the app deployments list", %{team: team, user_connected: user_connected} do
deployment_group =
build(:deployment_group,
id: "1",
name: "sleepy-cat-#{System.unique_integer([:positive])}",
mode: :online,
hub_id: team.id
)
livebook_proto_deployment_group =
%LivebookProto.DeploymentGroup{
id: to_string(deployment_group.id),
name: deployment_group.name,
mode: to_string(deployment_group.mode),
secrets: [],
agent_keys: [],
deployed_apps: []
}
user_connected = %{user_connected | deployment_groups: [livebook_proto_deployment_group]}
pid = connect_to_teams(team)
refute_receive {:deployment_group_created, ^deployment_group}
send(pid, {:event, :user_connected, user_connected})
assert_receive {:deployment_group_created, ^deployment_group}
assert deployment_group in TeamClient.get_deployment_groups(team.id)
url = "http://localhost/123456.zip"
app_deployment =
build(:app_deployment,
filename: "123456.zip",
file: {:url, url},
deployment_group_id: deployment_group.id
)
# updates the deployment group with an app deployment
updated_deployment_group = %{deployment_group | app_deployments: [app_deployment]}
livebook_proto_app_deployment =
%LivebookProto.DeployedApp{
id: app_deployment.id,
title: app_deployment.title,
slug: app_deployment.slug,
sha: app_deployment.sha,
archive_url: url,
deployed_by: app_deployment.deployed_by,
deployed_at: to_string(app_deployment.deployed_at),
app_id: "1",
deployment_group_id: app_deployment.deployment_group_id
}
updated_livebook_proto_deployment_group = %{
livebook_proto_deployment_group
| deployed_apps: [livebook_proto_app_deployment]
}
user_connected = %{
user_connected
| deployment_groups: [updated_livebook_proto_deployment_group]
}
send(pid, {:event, :user_connected, user_connected})
assert_receive {:deployment_group_updated, ^updated_deployment_group}
refute deployment_group in TeamClient.get_deployment_groups(team.id)
assert updated_deployment_group in TeamClient.get_deployment_groups(team.id)
end
end
describe "handle agent_connected event" do
@ -335,7 +440,11 @@ defmodule Livebook.Hubs.TeamClientTest do
deployment_groups: []
}
{:ok, team: team, deployment_group: deployment_group, agent_connected: agent_connected}
{:ok,
team: team,
deployment_group: deployment_group,
agent_connected: agent_connected,
agent_key: agent_key}
end
test "dispatches the secrets list", %{team: team, agent_connected: agent_connected} do
@ -548,6 +657,151 @@ defmodule Livebook.Hubs.TeamClientTest do
refute secret in TeamClient.get_secrets(team.id)
assert override_secret in TeamClient.get_secrets(team.id)
end
@tag :tmp_dir
test "dispatches the app deployments list",
%{
team: team,
deployment_group: teams_deployment_group,
agent_key: teams_agent_key,
agent_connected: agent_connected,
tmp_dir: tmp_dir
} do
agent_key =
build(:agent_key,
id: to_string(teams_agent_key.id),
key: teams_agent_key.key,
deployment_group_id: to_string(teams_agent_key.deployment_group_id)
)
deployment_group =
build(:deployment_group,
id: to_string(teams_deployment_group.id),
name: teams_deployment_group.name,
mode: teams_deployment_group.mode,
hub_id: team.id,
agent_keys: [agent_key]
)
livebook_proto_agent_key =
%LivebookProto.AgentKey{
id: agent_key.id,
key: agent_key.key,
deployment_group_id: agent_key.deployment_group_id
}
livebook_proto_deployment_group =
%LivebookProto.DeploymentGroup{
id: to_string(deployment_group.id),
name: deployment_group.name,
mode: to_string(deployment_group.mode),
secrets: [],
agent_keys: [livebook_proto_agent_key],
deployed_apps: []
}
agent_connected = %{agent_connected | deployment_groups: [livebook_proto_deployment_group]}
pid = connect_to_teams(team)
# Since we're connecting as Agent, we should receive the
# `:deployment_group_created` event from `:agent_connected` event
assert_receive {:deployment_group_created, ^deployment_group}
assert deployment_group in TeamClient.get_deployment_groups(team.id)
# creates a new app deployment
deployment_group_id = to_string(deployment_group.id)
slug = Livebook.Utils.random_short_id()
title = "MyNotebook2-#{slug}"
notebook = %{
Livebook.Notebook.new()
| app_settings: %{Livebook.Notebook.AppSettings.new() | slug: slug},
name: title,
hub_id: team.id,
deployment_group_id: deployment_group_id
}
files_dir = Livebook.FileSystem.File.local(tmp_dir)
{:ok, %{file: {:content, zip_content}} = app_deployment} =
Livebook.Teams.AppDeployment.new(notebook, files_dir)
secret_key = Livebook.Teams.derive_key(team.teams_key)
encrypted_content = Livebook.Teams.encrypt(zip_content, secret_key)
# Since the app deployment struct generation is from Livebook side,
# we don't have yet the information about who deployed the app,
# so we need to add it ourselves
app_deployment = %{
app_deployment
| id: "1",
filename: app_deployment.filename <> ".encrypted",
deployed_by: "Jake Peralta",
deployed_at: NaiveDateTime.utc_now()
}
bypass = Bypass.open()
Bypass.expect_once(bypass, "GET", "/#{app_deployment.filename}", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/octet-stream")
|> Plug.Conn.resp(200, encrypted_content)
end)
# updates the deployment group with an app deployment
# and after we deploy, the `:file` key turns to `nil` value
updated_deployment_group = %{
deployment_group
| app_deployments: [%{app_deployment | file: nil}]
}
livebook_proto_app_deployment =
%LivebookProto.DeployedApp{
id: app_deployment.id,
title: app_deployment.title,
slug: app_deployment.slug,
sha: app_deployment.sha,
archive_url: "http://localhost:#{bypass.port}/#{app_deployment.filename}",
deployed_by: app_deployment.deployed_by,
deployed_at: to_string(app_deployment.deployed_at),
app_id: "1",
deployment_group_id: app_deployment.deployment_group_id
}
updated_livebook_proto_deployment_group = %{
livebook_proto_deployment_group
| deployed_apps: [livebook_proto_app_deployment]
}
agent_connected = %{
agent_connected
| deployment_groups: [updated_livebook_proto_deployment_group]
}
apps_path = Path.join(tmp_dir, "apps")
app_path = Path.join(apps_path, slug)
Application.put_env(:livebook, :apps_path, apps_path)
# To avoid having extracting to the same folder from the original notebook,
# we need to create the ./apps/{slug} folder before sending the event
File.mkdir_p!(app_path)
Livebook.Apps.subscribe()
send(pid, {:event, :agent_connected, agent_connected})
assert_receive {:deployment_group_updated, ^updated_deployment_group}
assert_receive {:app_created, %{pid: app_pid, slug: ^slug}}
assert_receive {:app_updated,
%{slug: ^slug, sessions: [%{app_status: %{execution: :executed}}]}}
refute deployment_group in TeamClient.get_deployment_groups(team.id)
assert updated_deployment_group in TeamClient.get_deployment_groups(team.id)
Livebook.App.close(app_pid)
Application.put_env(:livebook, :apps_path, nil)
end
end
defp connect_to_teams(%{id: id} = team) do

View file

@ -1,7 +1,7 @@
defmodule Livebook.TeamsTest do
use Livebook.TeamsIntegrationCase, async: true
alias Livebook.Teams
alias Livebook.{Notebook, Teams, Utils}
alias Livebook.Teams.Org
describe "create_org/1" do
@ -221,4 +221,27 @@ defmodule Livebook.TeamsTest do
assert "can't be blank" in errors_on(changeset).name
end
end
describe "deploy_app/2" do
@tag :tmp_dir
test "deploys app to Teams from a notebook", %{user: user, node: node, tmp_dir: tmp_dir} do
team = create_team_hub(user, node)
deployment_group = build(:deployment_group, name: "BAZ", mode: :online)
{:ok, id} = Teams.create_deployment_group(team, deployment_group)
notebook = %{
Notebook.new()
| app_settings: %{Notebook.AppSettings.new() | slug: Utils.random_short_id()},
name: "MyNotebook",
hub_id: team.id,
deployment_group_id: to_string(id)
}
files_dir = Livebook.FileSystem.File.local(tmp_dir)
assert {:ok, app_deployment} = Teams.AppDeployment.new(notebook, files_dir)
assert Teams.deploy_app(team, app_deployment) == :ok
end
end
end

View file

@ -4,7 +4,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
import Phoenix.LiveViewTest
import Livebook.SessionHelpers
alias Livebook.{Sessions, Session}
alias Livebook.{FileSystem, Sessions, Session}
setup do
{:ok, session} = Sessions.create_session(notebook: Livebook.Notebook.new())
@ -291,7 +291,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
team_file_system =
build(:fs_s3,
id: Livebook.FileSystem.S3.id(team_id, bucket_url),
id: FileSystem.S3.id(team_id, bucket_url),
bucket_url: bucket_url,
hub_id: team_id
)
@ -334,7 +334,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
file_system =
build(:fs_s3,
id: Livebook.FileSystem.S3.id(hub_id, bucket_url),
id: FileSystem.S3.id(hub_id, bucket_url),
bucket_url: bucket_url,
hub_id: hub_id,
external_id: "123"
@ -378,7 +378,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
assert_receive {:operation, {:set_notebook_hub, _client, ^team_id}}
notebook_path = Path.join(tmp_dir, "notebook.livemd")
file = Livebook.FileSystem.File.local(notebook_path)
file = FileSystem.File.local(notebook_path)
Session.set_file(session.pid, file)
slug = Livebook.Utils.random_short_id()
@ -415,7 +415,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
assert_receive {:operation, {:set_notebook_hub, _client, ^team_id}}
notebook_path = Path.join(tmp_dir, "notebook.livemd")
file = Livebook.FileSystem.File.local(notebook_path)
file = FileSystem.File.local(notebook_path)
Session.set_file(session.pid, file)
slug = Livebook.Utils.random_short_id()
@ -447,7 +447,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
assert_receive {:operation, {:set_notebook_hub, _client, ^team_id}}
notebook_path = Path.join(tmp_dir, "notebook.livemd")
file = Livebook.FileSystem.File.local(notebook_path)
file = FileSystem.File.local(notebook_path)
Session.set_file(session.pid, file)
slug = Livebook.Utils.random_short_id()
@ -460,5 +460,87 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
assert render(view) =~ "No deployment groups available"
refute has_element?(view, "#select_deployment_group_form")
end
@tag :tmp_dir
test "deploys the app to livebook teams api",
%{conn: conn, user: user, node: node, session: session, tmp_dir: tmp_dir} do
team = create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id)
notebook_path = Path.join(tmp_dir, "notebook.livemd")
file = 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)
deployment_group = insert_deployment_group(mode: :online, hub_id: team.id)
Session.set_notebook_deployment_group(session.pid, deployment_group.id)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-teams")
assert render(view) =~ "Deploy to Livebook Agent"
view
|> element("#deploy-livebook-agent-button")
|> render_click()
assert render(view) =~
"App deployment for #{slug} with title Untitled notebook created successfully"
end
test "deploys the app to livebook teams api without saving the file",
%{conn: conn, user: user, node: node, session: session} do
team = create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id)
slug = Livebook.Utils.random_short_id()
app_settings = %{Livebook.Notebook.AppSettings.new() | slug: slug}
Session.set_app_settings(session.pid, app_settings)
deployment_group = insert_deployment_group(mode: :online, hub_id: team.id)
Session.set_notebook_deployment_group(session.pid, deployment_group.id)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-teams")
assert render(view) =~ "Deploy to Livebook Agent"
view
|> element("#deploy-livebook-agent-button")
|> render_click()
assert render(view) =~
"App deployment for #{slug} with title Untitled notebook created successfully"
end
test "returns error when the deployment size is higher than the maximum size of 20MB",
%{conn: conn, user: user, node: node, session: session} do
team = create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id)
slug = Livebook.Utils.random_short_id()
app_settings = %{Livebook.Notebook.AppSettings.new() | slug: slug}
Session.set_app_settings(session.pid, app_settings)
deployment_group = insert_deployment_group(mode: :online, hub_id: team.id)
Session.set_notebook_deployment_group(session.pid, deployment_group.id)
%{files_dir: files_dir} = session
image_file = FileSystem.File.resolve(files_dir, "image.jpg")
:ok = FileSystem.File.write(image_file, :crypto.strong_rand_bytes(20 * 1024 * 1024))
Session.add_file_entries(session.pid, [%{type: :attachment, name: "image.jpg"}])
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-teams")
assert render(view) =~ "Deploy to Livebook Agent"
view
|> element("#deploy-livebook-agent-button")
|> render_click()
assert render(view) =~
"Failed to pack files: the notebook and its attachments have exceeded the maximum size of 20MB"
end
end
end

View file

@ -61,7 +61,8 @@ defmodule Livebook.Factory do
name: "FOO",
mode: :offline,
agent_keys: [],
secrets: []
secrets: [],
app_deployments: []
}
end
@ -91,6 +92,37 @@ defmodule Livebook.Factory do
}
end
def build(:agent_key) do
%Livebook.Teams.AgentKey{
id: "1",
key: "lb_ak_zj9tWM1rEVeweYR7DbH_2VK5_aKtWfptcL07dBncqg",
deployment_group_id: "1"
}
end
def build(:app_deployment) do
slug = Livebook.Utils.random_short_id()
path = Plug.Upload.random_file!(slug)
local = Livebook.FileSystem.Local.new()
file = Livebook.FileSystem.File.new(local, path)
{:ok, content} = Livebook.FileSystem.File.read(file)
md5_hash = :crypto.hash(:md5, content)
shasum = Base.encode16(md5_hash, case: :lower)
%Livebook.Teams.AppDeployment{
id: "1",
title: "MyNotebook",
sha: shasum,
slug: slug,
file: file,
filename: Path.basename(file.path),
deployment_group_id: "1",
deployed_by: "Ada Lovelace",
deployed_at: NaiveDateTime.utc_now()
}
end
def build(factory_name, attrs) do
factory_name |> build() |> struct!(attrs)
end