Manage deployment groups (#2330)

This commit is contained in:
Cristine Guadelupe 2023-11-14 17:20:46 -03:00 committed by GitHub
parent 22d8e40ab6
commit f633748b37
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1026 additions and 4 deletions

View file

@ -112,7 +112,7 @@ defmodule Livebook.Hubs.Personal do
@doc """
Unset secret from given id.
"""
@spec set_secret(String.t()) :: :ok
@spec unset_secret(String.t()) :: :ok
def unset_secret(id) do
Storage.delete(@secrets_namespace, id)
:ok

View file

@ -7,6 +7,7 @@ defmodule Livebook.Hubs.TeamClient do
alias Livebook.Hubs
alias Livebook.Secrets
alias Livebook.Teams
alias Livebook.Teams.DeploymentGroup
@registry Livebook.HubsRegistry
@supervisor Livebook.HubsSupervisor
@ -17,7 +18,8 @@ defmodule Livebook.Hubs.TeamClient do
:derived_key,
connected?: false,
secrets: [],
file_systems: []
file_systems: [],
deployment_groups: []
]
@type registry_name :: {:via, Registry, {Livebook.HubsRegistry, String.t()}}
@ -68,6 +70,14 @@ defmodule Livebook.Hubs.TeamClient do
:exit, _ -> "connection refused"
end
@doc """
Returns a list of cached deployment groups.
"""
@spec get_deployment_groups(String.t()) :: list(DeploymentGroup.t())
def get_deployment_groups(id) do
GenServer.call(registry_name(id), :get_deployment_groups)
end
@doc """
Returns if the Team client is connected.
"""
@ -125,6 +135,10 @@ defmodule Livebook.Hubs.TeamClient do
{:reply, state.file_systems, state}
end
def handle_call(:get_deployment_groups, _caller, state) do
{:reply, state.deployment_groups, state}
end
@impl true
def handle_info(:connected, state) do
Hubs.Broadcasts.hub_connected(state.hub.id)
@ -198,6 +212,22 @@ defmodule Livebook.Hubs.TeamClient do
FileSystems.load(file_system.type, dumped_data)
end
defp put_deployment_group(state, deployment_group) do
state = remove_deployment_group(state, deployment_group)
%{state | deployment_groups: [deployment_group | state.deployment_groups]}
end
defp remove_deployment_group(state, deployment_group) do
%{
state
| deployment_groups: Enum.reject(state.deployment_groups, &(&1.id == deployment_group.id))
}
end
defp build_deployment_group(state, %{id: id, name: name, mode: mode}) do
%DeploymentGroup{id: id, name: name, mode: mode, hub_id: state.hub.id}
end
defp handle_event(:secret_created, %Secrets.Secret{} = secret, state) do
Hubs.Broadcasts.secret_created(secret)
@ -261,10 +291,49 @@ defmodule Livebook.Hubs.TeamClient do
end
end
defp handle_event(:deployment_group_created, %DeploymentGroup{} = deployment_group, state) do
Teams.Broadcasts.deployment_group_created(deployment_group)
put_deployment_group(state, deployment_group)
end
defp handle_event(:deployment_group_created, deployment_group_created, state) do
handle_event(
:deployment_group_created,
build_deployment_group(state, deployment_group_created),
state
)
end
defp handle_event(:deployment_group_updated, %DeploymentGroup{} = deployment_group, state) do
Teams.Broadcasts.deployment_group_updated(deployment_group)
put_deployment_group(state, deployment_group)
end
defp handle_event(:deployment_group_updated, deployment_group_updated, state) do
handle_event(
:deployment_group_updated,
build_deployment_group(state, deployment_group_updated),
state
)
end
defp handle_event(:deployment_group_deleted, deployment_group_deleted, state) do
if deployment_group =
Enum.find(state.deployment_groups, &(&1.id == deployment_group_deleted.id)) do
Teams.Broadcasts.deployment_group_deleted(deployment_group)
remove_deployment_group(state, deployment_group)
else
state
end
end
defp handle_event(:user_connected, user_connected, state) do
state
|> dispatch_secrets(user_connected)
|> dispatch_file_systems(user_connected)
|> dispatch_deployment_groups(user_connected)
end
defp dispatch_secrets(state, %{secrets: secrets}) do
@ -305,6 +374,19 @@ defmodule Livebook.Hubs.TeamClient do
defp dispatch_file_systems(state, _), do: state
defp dispatch_deployment_groups(state, %{deployment_groups: deployment_groups}) do
decrypted_deployment_groups = Enum.map(deployment_groups, &build_deployment_group(state, &1))
{created, deleted, updated} =
diff(state.deployment_groups, decrypted_deployment_groups, &(&1.id == &2.id))
dispatch_events(state,
deployment_group_deleted: deleted,
deployment_group_created: created,
deployment_group_updated: updated
)
end
defp diff(old_list, new_list, fun, deleted_fun \\ nil, updated_fun \\ nil) do
deleted_fun = unless deleted_fun, do: fun, else: deleted_fun
updated_fun = unless updated_fun, do: fun, else: updated_fun

View file

@ -3,7 +3,8 @@ defmodule Livebook.Teams do
alias Livebook.Hubs
alias Livebook.Hubs.Team
alias Livebook.Teams.{Requests, Org}
alias Livebook.Hubs.TeamClient
alias Livebook.Teams.{Requests, Org, DeploymentGroup}
import Ecto.Changeset,
only: [add_error: 3, apply_action: 2, apply_action!: 2, get_field: 2]
@ -158,4 +159,79 @@ defmodule Livebook.Teams do
defp add_org_errors(%Ecto.Changeset{} = changeset, errors_map) do
Requests.add_errors(changeset, Org.__schema__(:fields), errors_map)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking deployment group changes.
"""
@spec change_deployment_group(DeploymentGroup.t(), map()) :: Ecto.Changeset.t()
def change_deployment_group(%DeploymentGroup{} = deployment_group, attrs \\ %{}) do
DeploymentGroup.changeset(deployment_group, attrs)
end
@doc """
Updates a deployment group with the given changes.
"""
@spec update_deployment_group(Team.t(), DeploymentGroup.t()) ::
{:ok, pos_integer()}
| {:error, Ecto.Changeset.t()}
| {:transport_error, String.t()}
def update_deployment_group(%Team{} = team, deployment_group) do
case Requests.update_deployment_group(team, deployment_group) do
{:ok, %{"id" => id}} ->
{:ok, id}
{:error, %{"errors" => errors}} ->
{:error, Requests.add_errors(deployment_group, errors)}
any ->
any
end
end
@doc """
Creates a Deployment Group.
"""
@spec create_deployment_group(Team.t(), DeploymentGroup.t()) ::
{:ok, pos_integer()}
| {:error, Ecto.Changeset.t()}
| {:transport_error, String.t()}
def create_deployment_group(%Team{} = team, deployment_group) do
case Requests.create_deployment_group(team, deployment_group) do
{:ok, %{"id" => id}} ->
{:ok, id}
{:error, %{"errors" => errors}} ->
{:error, Requests.add_errors(deployment_group, errors)}
any ->
any
end
end
@doc """
Deletes a Deployment Group.
"""
@spec delete_deployment_group(Team.t(), DeploymentGroup.t()) ::
:ok
| {:error, Ecto.Changeset.t()}
| {:transport_error, String.t()}
def delete_deployment_group(%Team{} = team, deployment_group) do
case Requests.delete_deployment_group(team, deployment_group) do
{:ok, _} ->
:ok
{:error, %{"errors" => errors}} ->
{:error, Requests.add_errors(deployment_group, errors)}
any ->
any
end
end
@doc """
Gets a list of deployment groups for a given Hub.
"""
def get_deployment_groups(team) do
TeamClient.get_deployment_groups(team.id)
end
end

View file

@ -0,0 +1,68 @@
defmodule Livebook.Teams.Broadcasts do
alias Livebook.Teams.DeploymentGroup
@type broadcast :: :ok | {:error, term()}
@deployment_groups_topic "teams:deployment_groups"
@doc """
Subscribes to one or more subtopics in `"teams"`.
## Messages
Topic `teams:deployment_groups`:
* `{:deployment_group_created, DeploymentGroup.t()}`
* `{:deployment_group_updated, DeploymentGroup.t()}`
* `{:deployment_group_deleted, DeploymentGroup.t()}`
"""
@spec subscribe(atom() | list(atom())) :: :ok | {:error, term()}
def subscribe(topics) when is_list(topics) do
for topic <- topics, do: subscribe(topic)
:ok
end
def subscribe(topic) do
Phoenix.PubSub.subscribe(Livebook.PubSub, "teams:#{topic}")
end
@doc """
Unsubscribes from `subscribe/0`.
"""
@spec unsubscribe(atom() | list(atom())) :: :ok
def unsubscribe(topics) when is_list(topics) do
for topic <- topics, do: unsubscribe(topic)
:ok
end
def unsubscribe(topic) do
Phoenix.PubSub.unsubscribe(Livebook.PubSub, "teams:#{topic}")
end
@doc """
Broadcasts under `#{@deployment_groups_topic}` topic when hub received a new deployment group.
"""
@spec deployment_group_created(DeploymentGroup.t()) :: broadcast()
def deployment_group_created(%DeploymentGroup{} = deployment_group) do
broadcast(@deployment_groups_topic, {:deployment_group_created, deployment_group})
end
@doc """
Broadcasts under `#{@deployment_groups_topic}` topic when hub received an updated deployment group.
"""
@spec deployment_group_updated(DeploymentGroup.t()) :: broadcast()
def deployment_group_updated(%DeploymentGroup{} = deployment_group) do
broadcast(@deployment_groups_topic, {:deployment_group_updated, deployment_group})
end
@doc """
Broadcasts under `#{@deployment_groups_topic}` topic when hub received a deleted deployment group.
"""
@spec deployment_group_deleted(DeploymentGroup.t()) :: broadcast()
def deployment_group_deleted(%DeploymentGroup{} = deployment_group) do
broadcast(@deployment_groups_topic, {:deployment_group_deleted, deployment_group})
end
defp broadcast(topic, message) do
Phoenix.PubSub.broadcast(Livebook.PubSub, topic, message)
end
end

View file

@ -0,0 +1,24 @@
defmodule Livebook.Teams.DeploymentGroup do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: pos_integer() | nil,
name: String.t() | nil,
mode: :online | :offline,
hub_id: String.t() | nil
}
@primary_key {:id, :id, autogenerate: false}
embedded_schema do
field :name, :string
field :mode, Ecto.Enum, values: [:online, :offline]
field :hub_id, :string
end
def changeset(deployment_group, attrs \\ %{}) do
deployment_group
|> cast(attrs, [:id, :name, :mode, :hub_id])
|> validate_required([:name, :mode])
end
end

View file

@ -6,6 +6,7 @@ defmodule Livebook.Teams.Requests do
alias Livebook.Teams
alias Livebook.Teams.Org
alias Livebook.Utils.HTTP
alias Livebook.Teams.DeploymentGroup
@doc """
Send a request to Livebook Team API to create a new org.
@ -145,6 +146,42 @@ defmodule Livebook.Teams.Requests do
delete("/api/v1/org/file-systems", params, headers)
end
@doc """
Send a request to Livebook Team API to create a deployment group.
"""
@spec create_deployment_group(Team.t(), DeploymentGroup.t()) ::
{:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def create_deployment_group(team, deployment_group) do
headers = auth_headers(team)
params = %{name: deployment_group.name, mode: deployment_group.mode}
post("/api/v1/org/deployment-groups", params, headers)
end
@doc """
Send a request to Livebook Team API to update a deployment group.
"""
@spec update_deployment_group(Team.t(), DeploymentGroup.t()) ::
{:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def update_deployment_group(team, deployment_group) do
headers = auth_headers(team)
params = %{id: deployment_group.id, name: deployment_group.name, mode: deployment_group.mode}
put("/api/v1/org/deployment-groups", params, headers)
end
@doc """
Send a request to Livebook Team API to delete a deployment group.
"""
@spec delete_deployment_group(Team.t(), DeploymentGroup.t()) ::
{:ok, String.t()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def delete_deployment_group(team, deployment_group) do
headers = auth_headers(team)
params = %{id: deployment_group.id}
delete("/api/v1/org/deployment-groups", params, headers)
end
@doc """
Add requests errors to a `changeset` for the given `fields`.
"""

View file

@ -14,8 +14,10 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
show_key = assigns.params["show-key"]
secrets = Hubs.get_secrets(assigns.hub)
file_systems = Hubs.get_file_systems(assigns.hub, hub_only: true)
deployment_groups = Teams.get_deployment_groups(assigns.hub)
secret_name = assigns.params["secret_name"]
file_system_id = assigns.params["file_system_id"]
deployment_group_id = assigns.params["deployment_group_id"]
default? = default_hub?(assigns.hub)
secret_value =
@ -30,6 +32,15 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
raise(NotFoundError, "could not find file system matching #{inspect(file_system_id)}")
end
deployment_group =
if assigns.live_action == :edit_deployment_group do
Enum.find_value(deployment_groups, &(&1.id == deployment_group_id && &1)) ||
raise(
NotFoundError,
"could not find deployment group matching #{inspect(deployment_group_id)}"
)
end
{:ok,
socket
|> assign(
@ -37,6 +48,9 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
file_system: file_system,
file_system_id: file_system_id,
file_systems: file_systems,
deployment_group_id: deployment_group_id,
deployment_group: deployment_group,
deployment_groups: deployment_groups,
show_key: show_key,
secret_name: secret_name,
secret_value: secret_value,
@ -193,6 +207,24 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
/>
</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">
Deployment groups
</h2>
<p class="text-gray-700">
Deployment groups.
</p>
<.live_component
module={LivebookWeb.Hub.Teams.DeploymentGroupListComponent}
id="hub-deployment-groups-list"
hub_id={@hub.id}
deployment_groups={@deployment_groups}
target={@myself}
/>
</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
@ -292,6 +324,23 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
return_to={~p"/hub/#{@hub.id}"}
/>
</.modal>
<.modal
:if={@live_action in [:new_deployment_group, :edit_deployment_group]}
id="deployment-groups-modal"
show
width={:medium}
patch={~p"/hub/#{@hub.id}"}
>
<.live_component
module={LivebookWeb.Hub.Teams.DeploymentGroupFormComponent}
id="deployment-groups"
hub={@hub}
deployment_group_id={@deployment_group_id}
deployment_group={@deployment_group}
return_to={~p"/hub/#{@hub.id}"}
/>
</.modal>
</div>
</div>
</div>

View file

@ -0,0 +1,135 @@
defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
use LivebookWeb, :live_component
alias Livebook.Teams.DeploymentGroup
alias Livebook.Teams
@impl true
def update(assigns, socket) do
deployment_group = assigns.deployment_group
deployment_group = deployment_group || %DeploymentGroup{hub_id: assigns.hub.id}
changeset = Teams.change_deployment_group(deployment_group)
socket = assign(socket, assigns)
{:ok,
assign(socket,
deployment_group: deployment_group,
changeset: changeset,
mode: mode(deployment_group),
title: title(deployment_group),
button: button(deployment_group),
error_message: nil
)}
end
@impl true
def render(assigns) do
~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800">
<%= @title %>
</h3>
<div class="flex flex-columns gap-4">
<.form
:let={f}
id={"#{@id}-form"}
for={to_form(@changeset, as: :deployment_group)}
phx-target={@myself}
phx-change="validate"
phx-submit="save"
autocomplete="off"
class="basis-1/2 grow"
>
<div class="flex flex-col space-y-4">
<.text_field
field={f[:name]}
label="Name"
autofocus="true"
spellcheck="false"
autocomplete="off"
phx-debounce
/>
<.select_field
label="Mode"
help={
~S'''
Deployment group mode.
'''
}
field={f[:mode]}
options={[
{"Offline", "offline"},
{"Online", "online"}
]}
/>
<div class="flex space-x-2">
<button class="button-base button-blue" type="submit" disabled={not @changeset.valid?}>
<.remix_icon icon={@button.icon} class="align-middle mr-1" />
<span class="font-normal"><%= @button.label %></span>
</button>
<.link patch={@return_to} class="button-base button-outlined-gray">
Cancel
</.link>
</div>
</div>
</.form>
</div>
</div>
"""
end
@impl true
def handle_event("save", %{"deployment_group" => attrs}, socket) do
changeset = Teams.change_deployment_group(socket.assigns.deployment_group, attrs)
with {:ok, deployment_group} <- Ecto.Changeset.apply_action(changeset, :update),
{:ok, _id} <- save_deployment_group(deployment_group, socket) do
message =
case socket.assigns.mode do
:new -> "Deployment group #{deployment_group.name} added successfully"
:edit -> "Deployment group #{deployment_group.name} updated successfully"
end
{:noreply,
socket
|> put_flash(:success, message)
|> push_redirect(to: socket.assigns.return_to)}
else
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
{:transport_error, message} ->
{:noreply, assign(socket, error_message: message)}
{:error, message} ->
{:noreply, assign(socket, error_message: message)}
end
end
def handle_event("validate", %{"deployment_group" => attrs}, socket) do
changeset =
%DeploymentGroup{}
|> DeploymentGroup.changeset(attrs)
|> Map.replace!(:action, :validate)
{:noreply, assign(socket, changeset: changeset)}
end
defp save_deployment_group(deployment_group, socket) do
case socket.assigns.mode do
:new -> Teams.create_deployment_group(socket.assigns.hub, deployment_group)
:edit -> Teams.update_deployment_group(socket.assigns.hub, deployment_group)
end
end
defp mode(%DeploymentGroup{name: nil}), do: :new
defp mode(_), do: :edit
defp title(%DeploymentGroup{name: nil}), do: "Add deployment group"
defp title(_), do: "Edit deployment group"
defp button(%DeploymentGroup{name: nil}), do: %{icon: "add-line", label: "Add"}
defp button(_), do: %{icon: "save-line", label: "Save"}
end

View file

@ -0,0 +1,100 @@
defmodule LivebookWeb.Hub.Teams.DeploymentGroupListComponent do
use LivebookWeb, :live_component
alias Livebook.Teams
@impl true
def render(assigns) do
~H"""
<div id={@id} class="flex flex-col space-y-4">
<div class="flex flex-col space-y-4">
<.no_entries :if={@deployment_groups == []}>
No deployment groups in this Hub yet.
</.no_entries>
<div
:for={deployment_group <- @deployment_groups}
class="flex items-center justify-between border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center space-x-12">
<.labeled_text label="Name"><%= deployment_group.name %></.labeled_text>
<.labeled_text label="Mode"><%= deployment_group.mode %></.labeled_text>
</div>
<div class="flex items-center space-x-2">
<.menu id={"hub-deployment-group-#{deployment_group.id}-menu"}>
<:toggle>
<button class="icon-button" aria-label="open deployment group menu" type="button">
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
</:toggle>
<.menu_item>
<.link
id={"hub-deployment-group-#{deployment_group.id}-edit"}
patch={~p"/hub/#{@hub_id}/deployment-groups/edit/#{deployment_group.id}"}
type="button"
role="menuitem"
>
<.remix_icon icon="file-edit-line" />
<span>Edit</span>
</.link>
</.menu_item>
<.menu_item variant={:danger}>
<button
id={"hub-deployment-group-#{deployment_group.id}-delete"}
type="button"
role="menuitem"
class="text-red-600"
phx-click={
JS.push("delete_deployment_group",
value: %{id: deployment_group.id, name: deployment_group.name}
)
}
phx-target={@myself}
>
<.remix_icon icon="delete-bin-line" />
<span>Delete</span>
</button>
</.menu_item>
</.menu>
</div>
</div>
</div>
<div class="flex">
<.link
patch={~p"/hub/#{@hub_id}/deployment-groups/new"}
class="button-base button-blue"
id="add-deployment-group"
>
Add deployment group
</.link>
</div>
</div>
"""
end
@impl true
def handle_event("delete_deployment_group", %{"id" => id, "name" => name}, socket) do
on_confirm = fn socket ->
hub = Livebook.Hubs.fetch_hub!(socket.assigns.hub.id)
deployment_groups = Teams.get_deployment_groups(hub)
deployment_group = Enum.find(deployment_groups, &(&1.id == id))
case Teams.delete_deployment_group(hub, deployment_group) do
:ok ->
socket
|> put_flash(:success, "Deployment group #{deployment_group.name} deleted successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")
{:transport_error, reason} ->
put_flash(socket, :error, reason)
end
end
{:noreply,
confirm(socket, on_confirm,
title: "Delete hub deployment group",
description: "Are you sure you want to delete #{name}?",
confirm_text: "delete",
confirm_icon: "delete-bin-6-line"
)}
end
end

View file

@ -83,6 +83,12 @@ defmodule LivebookWeb.Router do
live "/hub/:id/secrets/edit/:secret_name", Hub.EditLive, :edit_secret, as: :hub
live "/hub/:id/file-systems/new", Hub.EditLive, :new_file_system, as: :hub
live "/hub/:id/file-systems/edit/:file_system_id", Hub.EditLive, :edit_file_system, as: :hub
live "/hub/:id/deployment-groups/new", Hub.EditLive, :new_deployment_group, as: :hub
live "/hub/:id/deployment-groups/edit/:deployment_group_id",
Hub.EditLive,
:edit_deployment_group,
as: :hub
live "/sessions/:id", SessionLive, :page
live "/sessions/:id/shortcuts", SessionLive, :shortcuts

View file

@ -7,6 +7,9 @@ defmodule LivebookProto do
SecretCreated,
SecretDeleted,
SecretUpdated,
DeploymentGroupCreated,
DeploymentGroupDeleted,
DeploymentGroupUpdated,
UserSynchronized
}
@ -22,6 +25,9 @@ defmodule LivebookProto do
| SecretCreated.t()
| SecretDeleted.t()
| SecretUpdated.t()
| DeploymentGroupCreated.t()
| DeploymentGroupDeleted.t()
| DeploymentGroupUpdated.t()
| UserSynchronized.t()
@doc """

View file

@ -0,0 +1,7 @@
defmodule LivebookProto.DeploymentGroup do
use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.12.0"
field :id, 1, type: :string
field :name, 2, type: :string
field :mode, 3, type: :string
end

View file

@ -0,0 +1,7 @@
defmodule LivebookProto.DeploymentGroupCreated do
use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.12.0"
field :id, 1, type: :string
field :name, 2, type: :string
field :mode, 3, type: :string
end

View file

@ -0,0 +1,5 @@
defmodule LivebookProto.DeploymentGroupDeleted do
use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.12.0"
field :id, 1, type: :string
end

View file

@ -0,0 +1,7 @@
defmodule LivebookProto.DeploymentGroupUpdated do
use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.12.0"
field :id, 1, type: :string
field :name, 2, type: :string
field :mode, 3, type: :string
end

View file

@ -1,7 +1,7 @@
defmodule LivebookProto.Event do
use Protobuf, syntax: :proto3, protoc_gen_elixir_version: "0.12.0"
oneof :type, 0
oneof(:type, 0)
field :secret_created, 1,
type: LivebookProto.SecretCreated,
@ -37,4 +37,19 @@ defmodule LivebookProto.Event do
type: LivebookProto.FileSystemDeleted,
json_name: "fileSystemDeleted",
oneof: 0
field :deployment_group_created, 8,
type: LivebookProto.DeploymentGroupCreated,
json_name: "deploymentGroupCreated",
oneof: 0
field :deployment_group_updated, 9,
type: LivebookProto.DeploymentGroupUpdated,
json_name: "deploymentGroupUpdated",
oneof: 0
field :deployment_group_deleted, 10,
type: LivebookProto.DeploymentGroupDeleted,
json_name: "deploymentGroupDeleted",
oneof: 0
end

View file

@ -4,4 +4,9 @@ defmodule LivebookProto.UserConnected do
field :name, 1, type: :string
field :secrets, 2, repeated: true, type: LivebookProto.Secret
field :file_systems, 3, repeated: true, type: LivebookProto.FileSystem, json_name: "fileSystems"
field :deployment_groups, 4,
repeated: true,
type: LivebookProto.DeploymentGroup,
json_name: "deploymentGroups"
end

View file

@ -48,6 +48,28 @@ message FileSystemDeleted {
string id = 1;
}
message DeploymentGroup {
string id = 1;
string name = 2;
string mode = 3;
}
message DeploymentGroupCreated {
string id = 1;
string name = 2;
string mode = 3;
}
message DeploymentGroupUpdated {
string id = 1;
string name = 2;
string mode = 3;
}
message DeploymentGroupDeleted {
string id = 1;
}
message UserConnected {
string name = 1;
repeated Secret secrets = 2;
@ -63,5 +85,8 @@ message Event {
FileSystemCreated file_system_created = 5;
FileSystemUpdated file_system_updated = 6;
FileSystemDeleted file_system_deleted = 7;
DeploymentGroupCreated deployment_group_created = 8;
DeploymentGroupUpdated deployment_group_updated = 9;
DeploymentGroupDeleted deployment_group_deleted = 10;
}
}

View file

@ -7,6 +7,7 @@ defmodule Livebook.Hubs.TeamClientTest do
setup do
Livebook.Hubs.Broadcasts.subscribe([:connection, :file_systems, :secrets])
Livebook.Teams.Broadcasts.subscribe([:deployment_groups])
:ok
end
@ -178,6 +179,83 @@ defmodule Livebook.Hubs.TeamClientTest do
# receives `{:file_system_deleted, file_system_deleted}` event
assert_receive {:file_system_deleted, %{external_id: ^id, bucket_url: ^bucket_url}}
end
test "receives the deployment_group_created event", %{user: user, node: node} do
team = create_team_hub(user, node)
id = team.id
assert_receive {:hub_connected, ^id}
deployment_group =
build(:deployment_group, name: "DEPLOYMENT_GROUP_CREATED_FOO", mode: "online")
assert {:ok, _id} =
Livebook.Teams.create_deployment_group(team, deployment_group)
%{name: name, mode: mode} = deployment_group
# receives `{:event, :deployment_group_created, deployment_group_created}` event
assert_receive {:deployment_group_created, %{name: ^name, mode: ^mode}}
end
test "receives the deployment_group_updated event", %{user: user, node: node} do
team = create_team_hub(user, node)
id = team.id
assert_receive {:hub_connected, ^id}
deployment_group =
build(:deployment_group, name: "DEPLOYMENT_GROUP_UPDATED_FOO", mode: "offline")
assert {:ok, id} =
Livebook.Teams.create_deployment_group(team, deployment_group)
%{name: name, mode: mode} = deployment_group
# receives `{:deployment_group_created, deployment_group_created}` event
assert_receive {:deployment_group_created, %{name: ^name, mode: ^mode}}
# updates the deployment group
update_deployment_group = %{deployment_group | id: id, mode: "online"}
assert {:ok, ^id} =
Livebook.Teams.update_deployment_group(
team,
update_deployment_group
)
new_mode = update_deployment_group.mode
# receives `{:deployment_group_updated, deployment_group_updated}` event
assert_receive {:deployment_group_updated, %{name: ^name, mode: ^new_mode}}
end
test "receives the deployment_group_deleted event", %{user: user, node: node} do
team = create_team_hub(user, node)
id = team.id
assert_receive {:hub_connected, ^id}
deployment_group =
build(:deployment_group, name: "DEPLOYMENT_GROUP_DELETED_FOO", mode: "online")
assert {:ok, id} =
Livebook.Teams.create_deployment_group(team, deployment_group)
name = deployment_group.name
mode = deployment_group.mode
# receives `{:deployment_group_created, deployment_group_created}` event
assert_receive {:deployment_group_created, %{name: ^name, mode: ^mode}}
# deletes the deployment group
assert Livebook.Teams.delete_deployment_group(team, %{
deployment_group
| id: id
}) == :ok
# receives `{:deployment_group_deleted, deployment_group_deleted}` event
assert_receive {:deployment_group_deleted, %{name: ^name, mode: ^mode}}
end
end
describe "user connected event" do

View file

@ -72,5 +72,23 @@ defmodule Livebook.Teams.ConnectionTest do
refute file_system_created.value == FileSystem.dump(file_system)
assert is_binary(file_system_created.value)
end
test "receives the deployment_group_created event", %{user: user, node: node} do
{hub, headers} = build_team_headers(user, node)
assert {:ok, _conn} = Connection.start_link(self(), headers)
assert_receive :connected
# creates a new deployment_group
deployment_group = build(:deployment_group, name: "FOO", mode: "offline")
assert {:ok, _id} =
Livebook.Teams.create_deployment_group(hub, deployment_group)
# deployment_group name and mode are not encrypted
assert_receive {:event, :deployment_group_created, deployment_group_created}
assert deployment_group_created.name == deployment_group.name
assert deployment_group_created.mode == deployment_group.mode
end
end
end

View file

@ -148,4 +148,91 @@ defmodule Livebook.TeamsTest do
{:error, :expired}
end
end
describe "create_deployment_group/2" do
test "creates a new deployment group when the data is valid", %{user: user, node: node} do
team = create_team_hub(user, node)
deployment_group = build(:deployment_group)
assert {:ok, _id} = Teams.create_deployment_group(team, deployment_group)
# Guarantee uniqueness
assert {:error, changeset} = Teams.create_deployment_group(team, deployment_group)
assert "has already been taken" in errors_on(changeset).name
end
test "returns changeset errors when the name is invalid", %{user: user, node: node} do
team = create_team_hub(user, node)
deployment_group = %{build(:deployment_group) | name: ""}
assert {:error, changeset} = Teams.create_deployment_group(team, deployment_group)
assert "can't be blank" in errors_on(changeset).name
end
test "returns changeset errors when the mode is blank", %{user: user, node: node} do
team = create_team_hub(user, node)
deployment_group = %{build(:deployment_group) | mode: ""}
assert {:error, changeset} = Teams.create_deployment_group(team, deployment_group)
assert "can't be blank" in errors_on(changeset).mode
end
test "returns changeset errors when the mode is invalid", %{user: user, node: node} do
team = create_team_hub(user, node)
deployment_group = %{build(:deployment_group) | mode: "invalid"}
assert {:error, changeset} = Teams.create_deployment_group(team, deployment_group)
assert "is invalid" in errors_on(changeset).mode
end
end
describe "update_deployment_group/2" do
test "updates a deployment group", %{user: user, node: node} do
team = create_team_hub(user, node)
deployment_group = build(:deployment_group, name: "BAR", mode: "online")
assert {:ok, id} = Teams.create_deployment_group(team, deployment_group)
update_deployment_group = %{deployment_group | id: id, name: "BAZ"}
assert {:ok, ^id} = Teams.update_deployment_group(team, update_deployment_group)
end
test "returns changeset errors when the new name is invalid", %{user: user, node: node} do
team = create_team_hub(user, node)
deployment_group = build(:deployment_group, name: "BAR", mode: "online")
assert {:ok, id} = Teams.create_deployment_group(team, deployment_group)
update_deployment_group = %{deployment_group | id: id, name: ""}
assert {:error, changeset} = Teams.update_deployment_group(team, update_deployment_group)
assert "can't be blank" in errors_on(changeset).name
end
test "returns changeset errors when the new mode is invalid", %{user: user, node: node} do
team = create_team_hub(user, node)
deployment_group = build(:deployment_group, name: "BAR", mode: "online")
assert {:ok, id} = Teams.create_deployment_group(team, deployment_group)
update_deployment_group = %{deployment_group | id: id, mode: ""}
assert {:error, changeset} = Teams.update_deployment_group(team, update_deployment_group)
assert "can't be blank" in errors_on(changeset).mode
update_deployment_group = %{deployment_group | id: id, mode: "invalid"}
assert {:error, changeset} = Teams.update_deployment_group(team, update_deployment_group)
assert "is invalid" in errors_on(changeset).mode
end
end
describe "delete_deployment_group/2" do
test "deletes a deployment group", %{user: user, node: node} do
team = create_team_hub(user, node)
deployment_group = build(:deployment_group, name: "BAR", mode: "online")
assert {:ok, id} = Teams.create_deployment_group(team, deployment_group)
delete_deployment_group = %{deployment_group | id: id}
assert Teams.delete_deployment_group(team, delete_deployment_group) == :ok
end
end
end

View file

@ -1,4 +1,5 @@
defmodule LivebookWeb.Integration.Hub.EditLiveTest do
alias Livebook.Teams.DeploymentGroup
use Livebook.TeamsIntegrationCase, async: true
import Phoenix.LiveViewTest
@ -8,6 +9,7 @@ defmodule LivebookWeb.Integration.Hub.EditLiveTest do
setup %{user: user, node: node} do
Livebook.Hubs.Broadcasts.subscribe([:crud, :connection, :secrets, :file_systems])
Livebook.Teams.Broadcasts.subscribe([:deployment_groups])
hub = create_team_hub(user, node)
id = hub.id
@ -304,6 +306,151 @@ defmodule LivebookWeb.Integration.Hub.EditLiveTest do
refute render(element(view, "#hub-file-systems-list")) =~ file_system.bucket_url
refute file_system in Livebook.Hubs.get_file_systems(hub)
end
test "creates a deployment group", %{conn: conn, hub: hub} do
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
deployment_group =
build(:deployment_group,
name: "TEAM_ADD_DEPLOYMENT_GROUP",
mode: "offline",
hub_id: hub.id
)
attrs = %{
deployment_group: %{
name: deployment_group.name,
value: deployment_group.mode,
hub_id: deployment_group.hub_id
}
}
refute render(view) =~ deployment_group.name
view
|> element("#add-deployment-group")
|> render_click(%{})
assert_patch(view, ~p"/hub/#{hub.id}/deployment-groups/new")
assert render(view) =~ "Add deployment group"
view
|> element("#deployment-groups-form")
|> render_change(attrs)
refute view
|> element("#deployment-groups-form button[disabled]")
|> has_element?()
view
|> element("#deployment-groups-form")
|> render_submit(attrs)
assert_receive {:deployment_group_created,
%DeploymentGroup{name: "TEAM_ADD_DEPLOYMENT_GROUP"} = deployment_group}
%{"success" => "Deployment group TEAM_ADD_DEPLOYMENT_GROUP added successfully"} =
assert_redirect(view, "/hub/#{hub.id}")
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
assert render(element(view, "#hub-deployment-groups-list")) =~ deployment_group.name
assert deployment_group in Livebook.Teams.get_deployment_groups(hub)
end
test "updates existing deployment group", %{conn: conn, hub: hub} do
insert_deployment_group(
name: "TEAM_EDIT_DEPLOYMENT_GROUP",
mode: "online",
hub_id: hub.id
)
assert_receive {:deployment_group_created,
%DeploymentGroup{name: "TEAM_EDIT_DEPLOYMENT_GROUP"} = deployment_group}
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
attrs = %{
deployment_group: %{
id: deployment_group.id,
name: deployment_group.name,
mode: deployment_group.mode,
hub_id: deployment_group.hub_id
}
}
new_mode = "offline"
view
|> element("#hub-deployment-group-#{deployment_group.id}-edit")
|> render_click(%{"deployment_group_name" => deployment_group.id})
assert_patch(view, ~p"/hub/#{hub.id}/deployment-groups/edit/#{deployment_group.id}")
assert render(view) =~ "Edit deployment group"
view
|> element("#deployment-groups-form")
|> render_change(attrs)
refute view
|> element("#deployment-groups-form button[disabled]")
|> has_element?()
view
|> element("#deployment-groups-form")
|> render_submit(put_in(attrs.deployment_group.mode, new_mode))
updated_deployment_group = %{deployment_group | mode: new_mode}
assert_receive {:deployment_group_updated, ^updated_deployment_group}
%{"success" => "Deployment group TEAM_EDIT_DEPLOYMENT_GROUP updated successfully"} =
assert_redirect(view, "/hub/#{hub.id}")
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
assert render(element(view, "#hub-deployment-groups-list")) =~ deployment_group.name
assert updated_deployment_group in Livebook.Teams.get_deployment_groups(hub)
end
test "deletes existing deployment group", %{conn: conn, hub: hub} do
insert_deployment_group(
name: "TEAM_DELETE_DEPLOYMENT_GROUP",
mode: "online",
hub_id: hub.id
)
assert_receive {:deployment_group_created,
%DeploymentGroup{name: "TEAM_DELETE_DEPLOYMENT_GROUP"} = deployment_group}
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
refute view
|> element("#deployment-groups-form button[disabled]")
|> has_element?()
view
|> element("#hub-deployment-group-#{deployment_group.id}-delete", "Delete")
|> render_click()
render_confirm(view)
assert_receive {:deployment_group_deleted,
%DeploymentGroup{name: "TEAM_DELETE_DEPLOYMENT_GROUP"}}
%{"success" => "Deployment group TEAM_DELETE_DEPLOYMENT_GROUP deleted successfully"} =
assert_redirect(view, "/hub/#{hub.id}")
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
refute render(element(view, "#hub-deployment-groups-list")) =~ deployment_group.name
refute deployment_group in Livebook.Teams.get_deployment_groups(hub)
end
test "raises an error if the deployment group does not exist", %{conn: conn, hub: hub} do
assert_raise LivebookWeb.NotFoundError, fn ->
live(conn, ~p"/hub/#{hub.id}/deployment-groups/edit/9999999")
end
end
end
defp expect_s3_listing(bypass) do

View file

@ -55,6 +55,13 @@ defmodule Livebook.Factory do
}
end
def build(:deployment_group) do
%Livebook.Teams.DeploymentGroup{
name: "FOO",
mode: "offline"
}
end
def build(:org) do
%Livebook.Teams.Org{
id: nil,
@ -96,6 +103,13 @@ defmodule Livebook.Factory do
secret
end
def insert_deployment_group(attrs \\ %{}) do
deployment_group = build(:deployment_group, attrs)
hub = Livebook.Hubs.fetch_hub!(deployment_group.hub_id)
{:ok, _id} = Livebook.Teams.create_deployment_group(hub, deployment_group)
deployment_group
end
def insert_env_var(factory_name, attrs \\ %{}) do
env_var = build(factory_name, attrs)
attributes = env_var |> Map.from_struct() |> Map.to_list()

View file

@ -132,6 +132,30 @@ defmodule Livebook.HubHelpers do
send(pid, {:event, :secret_deleted, secret_deleted})
end
def put_offline_hub_deployment_group(deployment_group) do
hub = offline_hub()
{:ok, pid} = hub_pid(hub)
deployment_group_created =
LivebookProto.DeploymentGroupCreated.new(
id: deployment_group.id,
name: deployment_group.name,
mode: deployment_group.mode
)
send(pid, {:event, :deployment_group_created, deployment_group_created})
end
def remove_offline_hub_deployment_group(deployment_group) do
hub = offline_hub()
{:ok, pid} = hub_pid(hub)
deployment_group_deleted =
LivebookProto.DeploymentGroupDeleted.new(id: deployment_group.id)
send(pid, {:event, :deployment_group_deleted, deployment_group_deleted})
end
def put_offline_hub_file_system(file_system) do
hub = offline_hub()
{:ok, pid} = hub_pid(hub)