Deployment group secrets (#2374)

Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com>
This commit is contained in:
Cristine Guadelupe 2023-12-06 18:53:49 -03:00 committed by GitHub
parent 839c326ab0
commit 8923e700d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 303 additions and 52 deletions

View file

@ -196,13 +196,14 @@ defmodule Livebook.Hubs.TeamClient do
%{state | secrets: Enum.reject(state.secrets, &(&1.name == secret.name))}
end
defp build_secret(state, %{name: name, value: value}) do
defp build_secret(state, %{name: name, value: value} = attrs) do
{:ok, decrypted_value} = Teams.decrypt(value, state.derived_key)
%Secrets.Secret{
name: name,
value: decrypted_value,
hub_id: state.hub.id
hub_id: state.hub.id,
deployment_group_id: Map.get(attrs, :deployment_group_id)
}
end
@ -243,8 +244,9 @@ defmodule Livebook.Hubs.TeamClient do
}
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}
defp build_deployment_group(state, %{id: id, name: name, mode: mode, secrets: secrets}) do
secrets = Enum.map(secrets, &build_secret(state, &1))
%DeploymentGroup{id: id, name: name, mode: mode, hub_id: state.hub.id, secrets: secrets}
end
defp handle_event(:secret_created, %Secrets.Secret{} = secret, state) do

View file

@ -5,18 +5,20 @@ defmodule Livebook.Secrets.Secret do
@type t :: %__MODULE__{
name: String.t(),
value: String.t(),
hub_id: String.t() | nil
hub_id: String.t() | nil,
deployment_group_id: String.t() | nil
}
@primary_key {:name, :string, autogenerate: false}
embedded_schema do
field :value, :string
field :hub_id, :string
field :deployment_group_id, :string
end
def changeset(secret, attrs \\ %{}) do
secret
|> cast(attrs, [:name, :value, :hub_id])
|> cast(attrs, [:name, :value, :hub_id, :deployment_group_id])
|> update_change(:name, &String.upcase/1)
|> validate_format(:name, ~r/^\w+$/,
message: "should contain only alphanumeric characters and underscore"

View file

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

View file

@ -49,34 +49,66 @@ defmodule Livebook.Teams.Requests do
"""
@spec create_secret(Team.t(), Secret.t()) ::
{:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def create_secret(team, secret) do
def create_secret(team, %{deployment_group_id: nil} = secret) do
secret_key = Teams.derive_key(team.teams_key)
secret_value = Teams.encrypt(secret.value, secret_key)
post("/api/v1/org/secrets", %{name: secret.name, value: secret_value}, team)
end
def create_secret(team, secret) do
secret_key = Teams.derive_key(team.teams_key)
secret_value = Teams.encrypt(secret.value, secret_key)
params = %{
name: secret.name,
value: secret_value,
deployment_group_id: secret.deployment_group_id
}
post("/api/v1/org/deployment-groups/secrets", params, team)
end
@doc """
Send a request to Livebook Team API to update a secret.
"""
@spec update_secret(Team.t(), Secret.t()) ::
{:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def update_secret(team, secret) do
def update_secret(team, %{deployment_group_id: nil} = secret) do
secret_key = Teams.derive_key(team.teams_key)
secret_value = Teams.encrypt(secret.value, secret_key)
put("/api/v1/org/secrets", %{name: secret.name, value: secret_value}, team)
end
def update_secret(team, secret) do
secret_key = Teams.derive_key(team.teams_key)
secret_value = Teams.encrypt(secret.value, secret_key)
params = %{
name: secret.name,
value: secret_value,
deployment_group_id: secret.deployment_group_id
}
put("/api/v1/org/deployment-groups/secrets", params, team)
end
@doc """
Send a request to Livebook Team API to delete a secret.
"""
@spec delete_secret(Team.t(), Secret.t()) ::
{:ok, String.t()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def delete_secret(team, secret) do
def delete_secret(team, %{deployment_group_id: nil} = secret) do
delete("/api/v1/org/secrets", %{name: secret.name}, team)
end
def delete_secret(team, secret) do
params = %{name: secret.name, deployment_group_id: secret.deployment_group_id}
delete("/api/v1/org/deployment-groups/secrets", params, team)
end
@doc """
Send a request to Livebook Team API to create a file system.
"""

View file

@ -101,6 +101,9 @@ defmodule LivebookWeb.Hub.Edit.PersonalComponent do
id="hub-secrets-list"
hub={@hub}
secrets={@secrets}
add_path={~p"/hub/#{@hub.id}/secrets/new"}
edit_path={"hub/#{@hub.id}/secrets/edit"}
return_to={~p"/hub/#{@hub.id}"}
/>
</div>

View file

@ -185,6 +185,9 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
id="hub-secrets-list"
hub={@hub}
secrets={@secrets}
add_path={~p"/hub/#{@hub.id}/secrets/new"}
edit_path={"hub/#{@hub.id}/secrets/edit"}
return_to={~p"/hub/#{@hub.id}"}
target={@myself}
/>
</div>
@ -324,23 +327,6 @@ 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

@ -20,6 +20,7 @@ defmodule LivebookWeb.Hub.SecretFormComponent do
title: title(socket),
button: button(socket),
changeset: changeset,
deployment_group_id: assigns[:deployment_group_id],
error_message: nil
)}
end
@ -66,6 +67,7 @@ defmodule LivebookWeb.Hub.SecretFormComponent do
phx-debounce
/>
<.hidden_field field={f[:hub_id]} value={@hub.id} />
<.hidden_field field={f[:deployment_group_id]} value={@deployment_group_id} />
<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" />

View file

@ -16,7 +16,7 @@ defmodule LivebookWeb.Hub.SecretListComponent do
<div id={@id} class="flex flex-col space-y-4">
<div class="flex flex-col space-y-4">
<.no_entries :if={@secrets == []}>
No secrets in this Hub yet.
No secrets here... yet!
</.no_entries>
<div
:for={secret <- @secrets}
@ -43,7 +43,7 @@ defmodule LivebookWeb.Hub.SecretListComponent do
<.menu_item>
<.link
id={"hub-secret-#{secret.name}-edit"}
patch={~p"/hub/#{secret.hub_id}/secrets/edit/#{secret.name}"}
patch={"/#{@edit_path}/#{secret.name}"}
type="button"
role="menuitem"
>
@ -60,7 +60,9 @@ defmodule LivebookWeb.Hub.SecretListComponent do
value: %{
name: secret.name,
value: secret.value,
hub_id: secret.hub_id
hub_id: secret.hub_id,
deployment_group_id: secret.deployment_group_id,
return_to: @return_to
}
)
}
@ -77,7 +79,7 @@ defmodule LivebookWeb.Hub.SecretListComponent do
</div>
</div>
<div class="flex">
<.link patch={~p"/hub/#{@hub.id}/secrets/new"} class="button-base button-blue" id="add-secret">
<.link patch={@add_path} class="button-base button-blue" id="add-secret">
Add secret
</.link>
</div>
@ -95,7 +97,7 @@ defmodule LivebookWeb.Hub.SecretListComponent do
:ok ->
socket
|> put_flash(:success, "Secret #{secret.name} deleted successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")
|> push_navigate(to: attrs["return_to"])
{:transport_error, reason} ->
put_flash(socket, :error, reason)

View file

@ -7,6 +7,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
@impl true
def update(assigns, socket) do
deployment_group = assigns.deployment_group
hub = assigns.hub
deployment_group = deployment_group || %DeploymentGroup{hub_id: assigns.hub.id}
changeset = Teams.change_deployment_group(deployment_group)
@ -20,6 +21,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
mode: mode(deployment_group),
title: title(deployment_group),
button: button(deployment_group),
subtitle: subtitle(deployment_group, hub.hub_name),
error_message: nil
)}
end
@ -27,10 +29,14 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
@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">
<div class="max-w-4xl flex flex-col space-y-5">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
<%= @title %>
</h3>
</h2>
<p class="text-gray-700">
<%= @subtitle %>
</p>
<div class="flex flex-columns gap-4">
<.form
:let={f}
@ -69,9 +75,11 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
<.remix_icon icon={@button.icon} class="align-middle mr-1" />
<span class="font-normal"><%= @button.label %></span>
</button>
<%= if @mode == :new do %>
<.link patch={@return_to} class="button-base button-outlined-gray">
Cancel
</.link>
<% end %>
</div>
</div>
</.form>
@ -85,7 +93,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent 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
{:ok, id} <- save_deployment_group(deployment_group, socket) do
message =
case socket.assigns.mode do
:new -> "Deployment group #{deployment_group.name} added successfully"
@ -95,7 +103,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
{:noreply,
socket
|> put_flash(:success, message)
|> push_redirect(to: socket.assigns.return_to)}
|> push_redirect(to: ~p"/hub/#{socket.assigns.hub.id}/deployment-groups/edit/#{id}")}
else
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
@ -130,6 +138,12 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
defp title(%DeploymentGroup{name: nil}), do: "Add deployment group"
defp title(_), do: "Edit deployment group"
defp subtitle(%DeploymentGroup{name: nil}, hub_name),
do: "Add a new deployment group to #{hub_name}"
defp subtitle(%DeploymentGroup{name: deployment_group}, _),
do: "Manage the #{deployment_group} 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,168 @@
defmodule LivebookWeb.Hub.Teams.DeploymentGroupLive do
use LivebookWeb, :live_view
alias LivebookWeb.LayoutHelpers
alias Livebook.Hubs
alias Livebook.Teams
alias Livebook.Hubs.Provider
alias LivebookWeb.NotFoundError
on_mount LivebookWeb.SidebarHook
@impl true
def handle_params(%{"id" => id} = params, _url, socket) do
hub = Hubs.fetch_hub!(id)
deployment_group_id = params["deployment_group_id"]
secret_name = params["secret_name"]
deployment_groups = Teams.get_deployment_groups(hub)
default? = default_hub?(hub)
deployment_group =
if socket.assigns.live_action != :new_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
secrets =
if socket.assigns.live_action != :new_deployment_group,
do: deployment_group.secrets,
else: []
secret_value =
if socket.assigns.live_action == :edit_secret do
Enum.find_value(secrets, &(&1.name == secret_name and &1.value)) ||
raise(NotFoundError, "could not find secret matching #{inspect(secret_name)}")
end
{:noreply,
socket
|> assign(
hub: hub,
deployment_groups: deployment_groups,
deployment_group_id: deployment_group_id,
deployment_group: deployment_group,
hub_metadata: Provider.to_metadata(hub),
secret_name: secret_name,
secret_value: secret_value,
default?: default?,
secrets: secrets
)}
end
@impl true
def render(assigns) do
~H"""
<LayoutHelpers.layout
current_page={~p"/hub/#{@hub.id}"}
current_user={@current_user}
saved_hubs={@saved_hubs}
>
<div>
<LayoutHelpers.topbar
:if={not @hub_metadata.connected? && Provider.connection_error(@hub)}
variant={:warning}
>
<%= Provider.connection_error(@hub) %>
</LayoutHelpers.topbar>
<div class="p-4 md:px-12 md:py-7 max-w-screen-md mx-auto">
<div id={"#{@hub.id}-component"}>
<div class="mb-8 flex flex-col space-y-10">
<div class="flex flex-col space-y-2">
<LayoutHelpers.title>
<div class="flex gap-2 items-center">
<div class="flex justify-center">
<span class="relative">
<%= @hub.hub_emoji %>
<div class={[
"absolute w-[10px] h-[10px] border-white border-2 rounded-full right-0 bottom-1",
if(@hub_metadata.connected?, do: "bg-green-400", else: "bg-red-400")
]} />
</span>
</div>
<%= @hub.hub_name %>
<span class="bg-green-100 text-green-800 text-xs px-2.5 py-0.5 rounded cursor-default">
Livebook Teams
</span>
<%= if @default? do %>
<span class="bg-blue-100 text-blue-800 text-xs px-2.5 py-0.5 rounded cursor-default">
Default
</span>
<% end %>
</div>
</LayoutHelpers.title>
<p class="text-sm flex flex-row space-x-6 text-gray-700">
<.link patch={~p"/hub/#{@hub.id}"} class="hover:text-blue-600 cursor-pointer">
<.remix_icon icon="arrow-left-line" /> Back to Hub
</.link>
</p>
</div>
<div class="flex flex-col space-y-4">
<.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}"}
/>
</div>
<%= if @deployment_group_id do %>
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
Secrets
</h2>
<p class="text-gray-700">
Deployment group secrets overrides Hub secrets
</p>
<.live_component
module={LivebookWeb.Hub.SecretListComponent}
id="hub-secrets-list"
hub={@hub}
secrets={@secrets}
deployment_group={@deployment_group}
add_path={
~p"/hub/#{@hub.id}/deployment-groups/edit/#{@deployment_group.id}/secrets/new"
}
edit_path={"hub/#{@hub.id}/deployment-groups/edit/#{@deployment_group.id}/secrets/edit"}
return_to={~p"/hub/#{@hub.id}/deployment-groups/edit/#{@deployment_group.id}"}
/>
</div>
<% end %>
</div>
</div>
</div>
</div>
<.modal
:if={@live_action in [:new_secret, :edit_secret]}
id="secrets-modal"
show
width={:medium}
patch={~p"/hub/#{@hub.id}/deployment-groups/edit/#{@deployment_group.id}"}
>
<.live_component
module={LivebookWeb.Hub.SecretFormComponent}
id="secrets"
hub={@hub}
deployment_group_id={@deployment_group.id}
secret_name={@secret_name}
secret_value={@secret_value}
return_to={~p"/hub/#{@hub.id}/deployment-groups/edit/#{@deployment_group.id}"}
/>
</.modal>
</LayoutHelpers.layout>
"""
end
defp default_hub?(hub) do
Hubs.get_default_hub().id == hub.id
end
end

View file

@ -83,13 +83,25 @@ 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/new", Hub.Teams.DeploymentGroupLive, :new_deployment_group,
as: :hub
live "/hub/:id/deployment-groups/edit/:deployment_group_id",
Hub.EditLive,
Hub.Teams.DeploymentGroupLive,
:edit_deployment_group,
as: :hub
live "/hub/:id/deployment-groups/edit/:deployment_group_id/secrets/new",
Hub.Teams.DeploymentGroupLive,
:new_secret,
as: :hub
live "/hub/:id/deployment-groups/edit/:deployment_group_id/secrets/edit/:secret_name",
Hub.Teams.DeploymentGroupLive,
:edit_secret,
as: :hub
live "/sessions/:id", SessionLive, :page
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
live "/sessions/:id/secrets", SessionLive, :secrets

View file

@ -4,4 +4,5 @@ defmodule LivebookProto.DeploymentGroup do
field :id, 1, type: :string
field :name, 2, type: :string
field :mode, 3, type: :string
field :secrets, 4, repeated: true, type: LivebookProto.DeploymentGroupSecret
end

View file

@ -4,4 +4,5 @@ defmodule LivebookProto.DeploymentGroupCreated do
field :id, 1, type: :string
field :name, 2, type: :string
field :mode, 3, type: :string
field :secrets, 4, repeated: true, type: LivebookProto.DeploymentGroupSecret
end

View file

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

View file

@ -4,4 +4,5 @@ defmodule LivebookProto.DeploymentGroupUpdated do
field :id, 1, type: :string
field :name, 2, type: :string
field :mode, 3, type: :string
field :secrets, 4, repeated: true, type: LivebookProto.DeploymentGroupSecret
end

View file

@ -48,22 +48,31 @@ message FileSystemDeleted {
string id = 1;
}
message DeploymentGroupSecret {
string name = 1;
string value = 2;
string deployment_group_id = 3;
}
message DeploymentGroup {
string id = 1;
string name = 2;
string mode = 3;
repeated DeploymentGroupSecret secrets = 4;
}
message DeploymentGroupCreated {
string id = 1;
string name = 2;
string mode = 3;
repeated DeploymentGroupSecret secrets = 4;
}
message DeploymentGroupUpdated {
string id = 1;
string name = 2;
string mode = 3;
repeated DeploymentGroupSecret secrets = 4;
}
message DeploymentGroupDeleted {

View file

@ -325,14 +325,14 @@ defmodule LivebookWeb.Integration.Hub.EditLiveTest do
}
}
refute render(view) =~ deployment_group.name
view
|> element("#add-deployment-group")
|> render_click(%{})
|> render_click()
assert_patch(view, ~p"/hub/#{hub.id}/deployment-groups/new")
assert render(view) =~ "Add deployment group"
{:ok, view, html} = live(conn, ~p"/hub/#{hub.id}/deployment-groups/new")
assert html =~ "Add a new deployment group to"
view
|> element("#deployment-groups-form")
@ -350,7 +350,7 @@ defmodule LivebookWeb.Integration.Hub.EditLiveTest do
%DeploymentGroup{name: "TEAM_ADD_DEPLOYMENT_GROUP"} = deployment_group}
%{"success" => "Deployment group TEAM_ADD_DEPLOYMENT_GROUP added successfully"} =
assert_redirect(view, "/hub/#{hub.id}")
assert_redirect(view, "/hub/#{hub.id}/deployment-groups/edit/#{deployment_group.id}")
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
@ -386,7 +386,12 @@ defmodule LivebookWeb.Integration.Hub.EditLiveTest do
|> 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"
{:ok, view, html} =
live(conn, ~p"/hub/#{hub.id}/deployment-groups/edit/#{deployment_group.id}")
assert html =~ "Edit deployment group"
assert html =~ "Manage the #{deployment_group.name} deployment group"
view
|> element("#deployment-groups-form")
@ -405,7 +410,7 @@ defmodule LivebookWeb.Integration.Hub.EditLiveTest do
assert_receive {:deployment_group_updated, ^updated_deployment_group}
%{"success" => "Deployment group TEAM_EDIT_DEPLOYMENT_GROUP updated successfully"} =
assert_redirect(view, "/hub/#{hub.id}")
assert_redirect(view, "/hub/#{hub.id}/deployment-groups/edit/#{deployment_group.id}")
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
assert render(element(view, "#hub-deployment-groups-list")) =~ deployment_group.name

View file

@ -51,7 +51,8 @@ defmodule Livebook.Factory do
%Livebook.Secrets.Secret{
name: "FOO",
value: "123",
hub_id: Livebook.Hubs.Personal.id()
hub_id: Livebook.Hubs.Personal.id(),
deployment_group_id: nil
}
end