mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
Deploy apps to Livebook Agent (#2511)
This commit is contained in:
parent
40a4fda509
commit
ddc2ad0a85
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
88
lib/livebook/teams/app_deployment.ex
Normal file
88
lib/livebook/teams/app_deployment.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
223
lib/livebook_web/live/session_live/app_teams_component.ex
Normal file
223
lib/livebook_web/live/session_live/app_teams_component.ex
Normal 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
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue