mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-06 19:46:00 +08:00
Notebook secrets (#1457)
* Store secrets in the notebook * Automatically grants access to secrets * Put notebook secrets on session * Shows secrets as a list * Grant access message box * Grant access confirmation on select * Applying suggestions - grant access confirmation * Handles unavailable secrets * Toggle secrets * Session only secrets * Sync secrets * Fix delete runtime secret * Clean up * Component helpers * Does not store secrets in notebooks * Store Livebook secrets * Fix sync secrets * Tests for secrets * Doesn't refetch livebook_secrets * Removes unused function * More test for secrets * Livebook secrets as maps * Fix secret tests * Applying suggestions * All secrets as a map * Shows grant access for missing secret errors * Unifies grant access * Fix set_runtime_secrets * Applying suggestions * Updates sync secrets tests * Fix active secret * Unifies prefill secret name
This commit is contained in:
parent
8d7a077fe5
commit
4c83317453
8 changed files with 539 additions and 62 deletions
113
lib/livebook/secrets.ex
Normal file
113
lib/livebook/secrets.ex
Normal file
|
@ -0,0 +1,113 @@
|
|||
defmodule Livebook.Secrets do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Changeset, only: [apply_action: 2]
|
||||
|
||||
alias Livebook.Secrets.Secret
|
||||
|
||||
defmodule NotFoundError do
|
||||
@moduledoc false
|
||||
|
||||
defexception [:message, plug_status: 404]
|
||||
end
|
||||
|
||||
defp storage() do
|
||||
Livebook.Storage.current()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the secrets list from storage.
|
||||
"""
|
||||
@spec fetch_secrets() :: list(Secret.t())
|
||||
def fetch_secrets() do
|
||||
for fields <- storage().all(:secrets) do
|
||||
struct!(Secret, Map.delete(fields, :id))
|
||||
end
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a secret from storage.
|
||||
Raises `RuntimeError` if the secret doesn't exist.
|
||||
"""
|
||||
@spec fetch_secret!(String.t()) :: Secret.t()
|
||||
def fetch_secret!(id) do
|
||||
case storage().fetch(:secrets, id) do
|
||||
:error ->
|
||||
raise NotFoundError, "could not find the secret matching #{inspect(id)}"
|
||||
|
||||
{:ok, fields} ->
|
||||
struct!(Secret, Map.delete(fields, :id))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the secret already exists.
|
||||
"""
|
||||
@spec secret_exists?(String.t()) :: boolean()
|
||||
def secret_exists?(id) do
|
||||
storage().fetch(:secrets, id) != :error
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the given secret.
|
||||
"""
|
||||
@spec set_secret(Secret.t() | %Secret{}, map()) ::
|
||||
{:ok, Secret.t()} | {:error, Ecto.Changeset.t()}
|
||||
def set_secret(%Secret{} = secret \\ %Secret{}, attrs) do
|
||||
changeset = Secret.changeset(secret, attrs)
|
||||
|
||||
with {:ok, secret} <- apply_action(changeset, :insert) do
|
||||
save_secret(secret)
|
||||
end
|
||||
end
|
||||
|
||||
defp save_secret(secret) do
|
||||
attributes = secret |> Map.from_struct() |> Map.to_list()
|
||||
|
||||
with :ok <- storage().insert(:secrets, secret.name, attributes),
|
||||
:ok <- broadcast_secrets_change({:set_secret, secret}) do
|
||||
{:ok, secret}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unset secret from given id.
|
||||
"""
|
||||
@spec unset_secret(String.t()) :: :ok
|
||||
def unset_secret(id) do
|
||||
if secret_exists?(id) do
|
||||
secret = fetch_secret!(id)
|
||||
storage().delete(:secrets, id)
|
||||
broadcast_secrets_change({:unset_secret, secret})
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Subscribe to secrets updates.
|
||||
|
||||
## Messages
|
||||
|
||||
* `{:set_secret, secret}`
|
||||
* `{:unset_secret, secret}`
|
||||
|
||||
"""
|
||||
@spec subscribe() :: :ok | {:error, term()}
|
||||
def subscribe do
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "secrets")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unsubscribes from `subscribe/0`.
|
||||
"""
|
||||
@spec unsubscribe() :: :ok
|
||||
def unsubscribe do
|
||||
Phoenix.PubSub.unsubscribe(Livebook.PubSub, "secrets")
|
||||
end
|
||||
|
||||
defp broadcast_secrets_change(message) do
|
||||
Phoenix.PubSub.broadcast(Livebook.PubSub, "secrets", message)
|
||||
end
|
||||
end
|
25
lib/livebook/secrets/secret.ex
Normal file
25
lib/livebook/secrets/secret.ex
Normal file
|
@ -0,0 +1,25 @@
|
|||
defmodule Livebook.Secrets.Secret do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
name: String.t(),
|
||||
value: String.t()
|
||||
}
|
||||
|
||||
@primary_key {:name, :string, autogenerate: false}
|
||||
embedded_schema do
|
||||
field :value, :string
|
||||
end
|
||||
|
||||
def changeset(secret, attrs \\ %{}) do
|
||||
secret
|
||||
|> cast(attrs, [:name, :value])
|
||||
|> update_change(:name, &String.upcase/1)
|
||||
|> validate_format(:name, ~r/^\w+$/,
|
||||
message: "should contain only alphanumeric and underscore"
|
||||
)
|
||||
|> validate_required([:name, :value])
|
||||
end
|
||||
end
|
|
@ -510,6 +510,14 @@ defmodule Livebook.Session do
|
|||
GenServer.cast(pid, {:put_secret, self(), secret})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends a secret deletion request to the server.
|
||||
"""
|
||||
@spec delete_secret(pid(), map()) :: :ok
|
||||
def delete_secret(pid, secret_name) do
|
||||
GenServer.cast(pid, {:delete_secret, self(), secret_name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends save request to the server.
|
||||
|
||||
|
@ -978,6 +986,12 @@ defmodule Livebook.Session do
|
|||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
def handle_cast({:delete_secret, client_pid, secret_name}, state) do
|
||||
client_id = client_id(state, client_pid)
|
||||
operation = {:delete_secret, client_id, secret_name}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
def handle_cast(:save, state) do
|
||||
{:noreply, maybe_save_notebook_async(state)}
|
||||
end
|
||||
|
@ -1438,7 +1452,12 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
defp after_operation(state, _prev_state, {:put_secret, _client_id, secret}) do
|
||||
if Runtime.connected?(state.data.runtime), do: set_runtime_secrets(state, [secret])
|
||||
if Runtime.connected?(state.data.runtime), do: set_runtime_secret(state, secret)
|
||||
state
|
||||
end
|
||||
|
||||
defp after_operation(state, _prev_state, {:delete_secret, _client_id, secret_name}) do
|
||||
if Runtime.connected?(state.data.runtime), do: delete_runtime_secrets(state, [secret_name])
|
||||
state
|
||||
end
|
||||
|
||||
|
@ -1570,11 +1589,21 @@ defmodule Livebook.Session do
|
|||
put_in(state.memory_usage, %{runtime: runtime, system: Livebook.SystemResources.memory()})
|
||||
end
|
||||
|
||||
defp set_runtime_secret(state, secret) do
|
||||
secret = {"LB_#{secret.name}", secret.value}
|
||||
Runtime.put_system_envs(state.data.runtime, [secret])
|
||||
end
|
||||
|
||||
defp set_runtime_secrets(state, secrets) do
|
||||
secrets = Enum.map(secrets, &{"LB_#{&1.name}", &1.value})
|
||||
secrets = Enum.map(secrets, fn {name, value} -> {"LB_#{name}", value} end)
|
||||
Runtime.put_system_envs(state.data.runtime, secrets)
|
||||
end
|
||||
|
||||
defp delete_runtime_secrets(state, secret_names) do
|
||||
secret_names = Enum.map(secret_names, &"LB_#{&1}")
|
||||
Runtime.delete_system_envs(state.data.runtime, secret_names)
|
||||
end
|
||||
|
||||
defp set_runtime_env_vars(state) do
|
||||
env_vars = Enum.map(Livebook.Settings.fetch_env_vars(), &{&1.name, &1.value})
|
||||
Runtime.put_system_envs(state.data.runtime, env_vars)
|
||||
|
|
|
@ -122,7 +122,7 @@ defmodule Livebook.Session.Data do
|
|||
|
||||
@type index :: non_neg_integer()
|
||||
|
||||
@type secret :: %{label: String.t(), value: String.t()}
|
||||
@type secret :: %{name: String.t(), value: String.t()}
|
||||
|
||||
# Snapshot holds information about the cell evaluation dependencies,
|
||||
# for example what is the previous cell, the number of times the
|
||||
|
@ -194,6 +194,7 @@ defmodule Livebook.Session.Data do
|
|||
| {:set_autosave_interval, client_id(), non_neg_integer() | nil}
|
||||
| {:mark_as_not_dirty, client_id()}
|
||||
| {:put_secret, client_id(), secret()}
|
||||
| {:delete_secret, client_id(), String.t()}
|
||||
|
||||
@type action ::
|
||||
:connect_runtime
|
||||
|
@ -222,7 +223,7 @@ defmodule Livebook.Session.Data do
|
|||
smart_cell_definitions: [],
|
||||
clients_map: %{},
|
||||
users_map: %{},
|
||||
secrets: []
|
||||
secrets: %{}
|
||||
}
|
||||
|
||||
data
|
||||
|
@ -770,6 +771,13 @@ defmodule Livebook.Session.Data do
|
|||
|> wrap_ok()
|
||||
end
|
||||
|
||||
def apply_operation(data, {:delete_secret, _client_id, secret_name}) do
|
||||
data
|
||||
|> with_actions()
|
||||
|> delete_secret(secret_name)
|
||||
|> wrap_ok()
|
||||
end
|
||||
|
||||
# ===
|
||||
|
||||
defp with_actions(data, actions \\ []), do: {data, actions}
|
||||
|
@ -1508,16 +1516,12 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
|
||||
defp put_secret({data, _} = data_actions, secret) do
|
||||
idx = Enum.find_index(data.secrets, &(&1.name == secret.name))
|
||||
|
||||
secrets =
|
||||
if idx do
|
||||
put_in(data.secrets, [Access.at(idx), :value], secret.value)
|
||||
else
|
||||
data.secrets ++ [secret]
|
||||
end
|
||||
|> Enum.sort()
|
||||
secrets = Map.put(data.secrets, secret.name, secret.value)
|
||||
set!(data_actions, secrets: secrets)
|
||||
end
|
||||
|
||||
defp delete_secret({data, _} = data_actions, secret_name) do
|
||||
secrets = Map.delete(data.secrets, secret_name)
|
||||
set!(data_actions, secrets: secrets)
|
||||
end
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
import LivebookWeb.SessionHelpers
|
||||
import Livebook.Utils, only: [format_bytes: 1]
|
||||
|
||||
alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown}
|
||||
alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown, Secrets}
|
||||
alias Livebook.Notebook.{Cell, ContentLoader}
|
||||
alias Livebook.JSInterop
|
||||
|
||||
|
@ -21,6 +21,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
Session.register_client(session_pid, self(), socket.assigns.current_user)
|
||||
|
||||
Session.subscribe(session_id)
|
||||
Secrets.subscribe()
|
||||
|
||||
{data, client_id}
|
||||
else
|
||||
|
@ -55,7 +56,8 @@ defmodule LivebookWeb.SessionLive do
|
|||
platform: platform,
|
||||
data_view: data_to_view(data),
|
||||
autofocus_cell_id: autofocus_cell_id(data.notebook),
|
||||
page_title: get_page_title(data.notebook.name)
|
||||
page_title: get_page_title(data.notebook.name),
|
||||
livebook_secrets: Secrets.fetch_secrets() |> Map.new(&{&1.name, &1.value})
|
||||
)
|
||||
|> assign_private(data: data)
|
||||
|> prune_outputs()
|
||||
|
@ -172,7 +174,12 @@ defmodule LivebookWeb.SessionLive do
|
|||
<.clients_list data_view={@data_view} client_id={@client_id} />
|
||||
</div>
|
||||
<div data-el-secrets-list>
|
||||
<.secrets_list data_view={@data_view} session={@session} socket={@socket} />
|
||||
<.secrets_list
|
||||
data_view={@data_view}
|
||||
livebook_secrets={@livebook_secrets}
|
||||
session={@session}
|
||||
socket={@socket}
|
||||
/>
|
||||
</div>
|
||||
<div data-el-runtime-info>
|
||||
<.runtime_info data_view={@data_view} session={@session} socket={@socket} />
|
||||
|
@ -405,9 +412,9 @@ defmodule LivebookWeb.SessionLive do
|
|||
id="secrets"
|
||||
session={@session}
|
||||
secrets={@data_view.secrets}
|
||||
livebook_secrets={@livebook_secrets}
|
||||
prefill_secret_name={@prefill_secret_name}
|
||||
select_secret_ref={@select_secret_ref}
|
||||
preselect_name={@preselect_name}
|
||||
select_secret_options={@select_secret_options}
|
||||
return_to={@self_path}
|
||||
/>
|
||||
|
@ -553,17 +560,37 @@ defmodule LivebookWeb.SessionLive do
|
|||
<h3 class="uppercase text-sm font-semibold text-gray-500">
|
||||
Secrets
|
||||
</h3>
|
||||
<span class="mt-4 text-sm font-semibold text-gray-500">Available to this notebook</span>
|
||||
<div class="flex flex-col mt-4 space-y-4">
|
||||
<%= for secret <- @data_view.secrets do %>
|
||||
<%= for {secret_name, _} <- session_only_secrets(@data_view.secrets, @livebook_secrets) do %>
|
||||
<div class="flex justify-between items-center text-gray-500">
|
||||
<span class="break-all">
|
||||
<%= secret.name %>
|
||||
<span class="text-sm break-all">
|
||||
<%= secret_name %>
|
||||
</span>
|
||||
<span class="rounded-full bg-gray-200 px-2 text-xs text-gray-600">
|
||||
Session
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="w-full border-t border-gray-300 py-1"></div>
|
||||
<div class="flex justify-between mt-4">
|
||||
<span class="text-sm font-semibold text-gray-500">Stored in your Livebook</span>
|
||||
<span class="text-sm font-light text-gray-500">On session</span>
|
||||
</div>
|
||||
<%= for {secret_name, secret_value} = secret <- Enum.sort(@livebook_secrets) do %>
|
||||
<div class="flex justify-between items-center text-gray-500">
|
||||
<span class="text-sm break-all">
|
||||
<%= secret_name %>
|
||||
</span>
|
||||
<.switch_checkbox
|
||||
name="toggle_secret"
|
||||
checked={is_secret_on_session?(secret, @data_view.secrets)}
|
||||
phx-click="toggle_secret"
|
||||
phx-value-secret_name={secret_name}
|
||||
phx-value-secret_value={secret_value}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= live_patch to: Routes.session_path(@socket, :secrets, @session.id),
|
||||
class: "inline-flex items-center justify-center p-8 py-1 mt-8 space-x-2 text-sm font-medium text-gray-500 border border-gray-400 border-dashed rounded-xl hover:bg-gray-100",
|
||||
|
@ -758,8 +785,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
when socket.assigns.live_action == :secrets do
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
prefill_secret_name: params["secret_name"],
|
||||
preselect_name: params["preselect_name"],
|
||||
prefill_secret_name: params["secret_name"] || params["preselect_name"],
|
||||
select_secret_ref: if(params["preselect_name"], do: socket.assigns.select_secret_ref),
|
||||
select_secret_options:
|
||||
if(params["preselect_name"], do: socket.assigns.select_secret_options)
|
||||
|
@ -1147,6 +1173,21 @@ defmodule LivebookWeb.SessionLive do
|
|||
)}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"toggle_secret",
|
||||
%{"secret-name" => secret_name, "secret-value" => secret_value, "value" => "true"},
|
||||
socket
|
||||
) do
|
||||
secret = %{name: secret_name, value: secret_value}
|
||||
Livebook.Session.put_secret(socket.assigns.session.pid, secret)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("toggle_secret", %{"secret-name" => secret_name}, socket) do
|
||||
Livebook.Session.delete_secret(socket.assigns.session.pid, secret_name)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:operation, operation}, socket) do
|
||||
{:noreply, handle_operation(socket, operation)}
|
||||
|
@ -1223,6 +1264,12 @@ defmodule LivebookWeb.SessionLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:set_secret, secret}, socket) do
|
||||
livebook_secrets = Map.put(socket.assigns.livebook_secrets, secret.name, secret.value)
|
||||
|
||||
{:noreply, assign(socket, livebook_secrets: livebook_secrets)}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
|
||||
defp handle_relative_path(socket, path, requested_url) do
|
||||
|
@ -1951,4 +1998,12 @@ defmodule LivebookWeb.SessionLive do
|
|||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp session_only_secrets(secrets, livebook_secrets) do
|
||||
Enum.reject(secrets, &(&1 in livebook_secrets)) |> Enum.sort()
|
||||
end
|
||||
|
||||
defp is_secret_on_session?(secret, secrets) do
|
||||
secret in secrets
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,14 +4,17 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
|
|||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket = assign(socket, assigns)
|
||||
prefill_form = prefill_secret_name(socket)
|
||||
|
||||
socket =
|
||||
if socket.assigns[:data] do
|
||||
socket
|
||||
else
|
||||
assign(socket,
|
||||
data: %{"name" => prefill_secret_name(socket), "value" => ""},
|
||||
title: title(socket)
|
||||
data: %{"name" => prefill_form, "value" => "", "store" => "session"},
|
||||
title: title(socket),
|
||||
grant_access: must_grant_access(socket),
|
||||
has_prefill: prefill_form != ""
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -25,30 +28,36 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
|
|||
<h3 class="text-2xl font-semibold text-gray-800">
|
||||
<%= @title %>
|
||||
</h3>
|
||||
<%= if @grant_access do %>
|
||||
<.grant_access_message grant_access={@grant_access} target={@myself} />
|
||||
<% end %>
|
||||
<div class="flex flex-columns gap-4">
|
||||
<%= if @select_secret_ref do %>
|
||||
<div class="basis-1/2 grow-0 pr-4 border-r">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<p class="text-gray-700">
|
||||
<p class="text-gray-800">
|
||||
Choose a secret
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<%= for secret <- @secrets do %>
|
||||
<.choice_button
|
||||
active={secret.name == @preselect_name}
|
||||
value={secret.name}
|
||||
phx-target={@myself}
|
||||
phx-click="select_secret"
|
||||
class={
|
||||
if secret.name == @preselect_name,
|
||||
do: "text-xs rounded-full",
|
||||
else: "text-xs rounded-full text-gray-700 hover:bg-gray-200"
|
||||
}
|
||||
>
|
||||
<%= secret.name %>
|
||||
</.choice_button>
|
||||
<div class="flex flex-wrap">
|
||||
<%= for {secret_name, _} <- Enum.sort(@secrets) do %>
|
||||
<.secret_with_badge
|
||||
secret_name={secret_name}
|
||||
stored="Session"
|
||||
action="select_secret"
|
||||
active={secret_name == @prefill_secret_name}
|
||||
target={@myself}
|
||||
/>
|
||||
<% end %>
|
||||
<%= if @secrets == [] do %>
|
||||
<%= for {secret_name, _} <- livebook_only_secrets(@secrets, @livebook_secrets) do %>
|
||||
<.secret_with_badge
|
||||
secret_name={secret_name}
|
||||
stored="livebook"
|
||||
action="select_livebook_secret"
|
||||
active={false}
|
||||
target={@myself}
|
||||
/>
|
||||
<% end %>
|
||||
<%= if @secrets == %{} and @livebook_secrets == %{} do %>
|
||||
<div class="w-full text-center text-gray-400 border rounded-lg p-8">
|
||||
<.remix_icon icon="folder-lock-line" class="align-middle text-2xl" />
|
||||
<span class="mt-1 block text-sm text-gray-700">
|
||||
|
@ -83,7 +92,7 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
|
|||
<%= text_input(f, :name,
|
||||
value: @data["name"],
|
||||
class: "input",
|
||||
autofocus: !@prefill_secret_name,
|
||||
autofocus: !@has_prefill,
|
||||
spellcheck: "false"
|
||||
) %>
|
||||
</.input_wrapper>
|
||||
|
@ -92,10 +101,21 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
|
|||
<%= text_input(f, :value,
|
||||
value: @data["value"],
|
||||
class: "input",
|
||||
autofocus: !!@prefill_secret_name || unavailable_secret?(@preselect_name, @secrets),
|
||||
autofocus: @has_prefill,
|
||||
spellcheck: "false"
|
||||
) %>
|
||||
</.input_wrapper>
|
||||
<div>
|
||||
<span class="text-base font-medium text-gray-900">Store</span>
|
||||
<div class="mt-2 space-y-1">
|
||||
<%= label class: "flex items-center gap-2 text-gray-600" do %>
|
||||
<%= radio_button(f, :store, "session", checked: @data["store"] == "session") %> Session
|
||||
<% end %>
|
||||
<%= label class: "flex items-center gap-2 text-gray-600" do %>
|
||||
<%= radio_button(f, :store, "livebook", checked: @data["store"] == "livebook") %> Notebook
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button class="button-base button-blue" type="submit" disabled={f.errors != []}>
|
||||
<.remix_icon icon="add-line" class="align-middle" />
|
||||
|
@ -110,13 +130,85 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp secret_with_badge(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
role="button"
|
||||
class={
|
||||
if @active do
|
||||
"flex justify-between w-full bg-blue-100 text-sm text-blue-700 p-2 border-b cursor-pointer"
|
||||
else
|
||||
"flex justify-between w-full text-sm text-gray-700 p-2 border-b cursor-pointer hover:bg-gray-100"
|
||||
end
|
||||
}
|
||||
phx-value-secret_name={@secret_name}
|
||||
phx-target={@target}
|
||||
phx-click={@action}
|
||||
>
|
||||
<%= @secret_name %>
|
||||
<span class={
|
||||
if @active do
|
||||
"inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
|
||||
else
|
||||
"inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
|
||||
end
|
||||
}>
|
||||
<%= if @active do %>
|
||||
<svg class="-ml-0.5 mr-1.5 h-2 w-2 text-blue-400" fill="currentColor" viewBox="0 0 8 8">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
<% end %>
|
||||
<%= @stored %>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp grant_access_message(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="mx-auto">
|
||||
<div class="rounded-lg bg-blue-600 p-2 shadow-sm">
|
||||
<div class="flex flex-wrap items-center justify-between">
|
||||
<div class="flex w-0 flex-1 items-center">
|
||||
<.remix_icon
|
||||
icon="error-warning-fill"
|
||||
class="align-middle text-2xl flex text-gray-100 rounded-lg py-2"
|
||||
/>
|
||||
<span class="ml-2 text-sm font-normal text-gray-100">
|
||||
The secret <span class="font-semibold text-white"><%= @grant_access %></span>
|
||||
needs to be made available to the session
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="button-base button-gray"
|
||||
phx-click="grant_access"
|
||||
phx-value-secret_name={@grant_access}
|
||||
phx-target={@target}
|
||||
>
|
||||
Grant access
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"data" => data}, socket) do
|
||||
secret_name = String.upcase(data["name"])
|
||||
|
||||
if data_errors(data) == [] do
|
||||
secret_name = String.upcase(data["name"])
|
||||
secret = %{name: secret_name, value: data["value"]}
|
||||
Livebook.Session.put_secret(socket.assigns.session.pid, secret)
|
||||
store = data["store"]
|
||||
|
||||
put_secret(socket.assigns.session.pid, secret, store)
|
||||
|
||||
if store == "livebook" &&
|
||||
(socket.assigns.select_secret_ref ||
|
||||
{secret.name, socket.assigns.livebook_secrets[secret.name]} in socket.assigns.secrets) do
|
||||
put_secret(socket.assigns.session.pid, secret, "session")
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_name)}
|
||||
|
@ -125,7 +217,14 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
|
|||
end
|
||||
end
|
||||
|
||||
def handle_event("select_secret", %{"value" => secret_name}, socket) do
|
||||
def handle_event("select_secret", %{"secret_name" => secret_name}, socket) do
|
||||
{:noreply,
|
||||
socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_name)}
|
||||
end
|
||||
|
||||
def handle_event("select_livebook_secret", %{"secret_name" => secret_name}, socket) do
|
||||
grant_access(secret_name, socket)
|
||||
|
||||
{:noreply,
|
||||
socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_name)}
|
||||
end
|
||||
|
@ -134,6 +233,13 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
|
|||
{:noreply, assign(socket, data: data)}
|
||||
end
|
||||
|
||||
def handle_event("grant_access", %{"secret_name" => secret_name}, socket) do
|
||||
grant_access(secret_name, socket)
|
||||
|
||||
{:noreply,
|
||||
socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_name)}
|
||||
end
|
||||
|
||||
defp data_errors(data) do
|
||||
Enum.flat_map(data, fn {key, value} ->
|
||||
if error = data_error(key, value) do
|
||||
|
@ -162,25 +268,44 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
|
|||
end
|
||||
|
||||
defp prefill_secret_name(socket) do
|
||||
case socket.assigns.prefill_secret_name do
|
||||
nil ->
|
||||
if unavailable_secret?(socket.assigns.preselect_name, socket.assigns.secrets),
|
||||
do: socket.assigns.preselect_name,
|
||||
else: ""
|
||||
|
||||
prefill ->
|
||||
prefill
|
||||
end
|
||||
if unavailable_secret?(
|
||||
socket.assigns.prefill_secret_name,
|
||||
socket.assigns.secrets,
|
||||
socket.assigns.livebook_secrets
|
||||
),
|
||||
do: socket.assigns.prefill_secret_name,
|
||||
else: ""
|
||||
end
|
||||
|
||||
defp unavailable_secret?(nil, _), do: false
|
||||
defp unavailable_secret?("", _), do: false
|
||||
defp unavailable_secret?(nil, _, _), do: false
|
||||
defp unavailable_secret?("", _, _), do: false
|
||||
|
||||
defp unavailable_secret?(preselect_name, secrets) do
|
||||
preselect_name not in Enum.map(secrets, & &1.name)
|
||||
defp unavailable_secret?(preselect_name, secrets, livebook_secrets) do
|
||||
not Map.has_key?(secrets, preselect_name) and
|
||||
not Map.has_key?(livebook_secrets, preselect_name)
|
||||
end
|
||||
|
||||
defp title(%{assigns: %{select_secret_ref: nil}}), do: "Add secret"
|
||||
defp title(%{assigns: %{select_secret_options: %{"title" => title}}}), do: title
|
||||
defp title(_), do: "Select secret"
|
||||
|
||||
defp put_secret(pid, secret, "session"), do: Livebook.Session.put_secret(pid, secret)
|
||||
defp put_secret(_pid, secret, "livebook"), do: Livebook.Secrets.set_secret(secret)
|
||||
|
||||
defp grant_access(secret_name, socket) do
|
||||
secret_value = socket.assigns.livebook_secrets[secret_name]
|
||||
secret = %{name: secret_name, value: secret_value}
|
||||
put_secret(socket.assigns.session.pid, secret, "session")
|
||||
end
|
||||
|
||||
defp livebook_only_secrets(secrets, livebook_secrets) do
|
||||
Enum.reject(livebook_secrets, &(&1 in secrets)) |> Enum.sort()
|
||||
end
|
||||
|
||||
defp must_grant_access(%{assigns: %{prefill_secret_name: prefill_secret_name}} = socket) do
|
||||
if not Map.has_key?(socket.assigns.secrets, prefill_secret_name) and
|
||||
Map.has_key?(socket.assigns.livebook_secrets, prefill_secret_name) do
|
||||
prefill_secret_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
70
test/livebook/secrets_test.exs
Normal file
70
test/livebook/secrets_test.exs
Normal file
|
@ -0,0 +1,70 @@
|
|||
defmodule Livebook.SecretsTest do
|
||||
use ExUnit.Case
|
||||
use Livebook.DataCase
|
||||
|
||||
alias Livebook.Secrets
|
||||
alias Livebook.Secrets.Secret
|
||||
|
||||
test "fetch secrets" do
|
||||
Secrets.set_secret(%{name: "FOO", value: "111"})
|
||||
assert %Secret{name: "FOO", value: "111"} in Secrets.fetch_secrets()
|
||||
|
||||
Secrets.unset_secret("FOO")
|
||||
refute %Secret{name: "FOO", value: "111"} in Secrets.fetch_secrets()
|
||||
end
|
||||
|
||||
test "fetch an specif secret" do
|
||||
secret = %{name: "FOO", value: "111"}
|
||||
Secrets.set_secret(secret)
|
||||
|
||||
assert_raise Secrets.NotFoundError,
|
||||
~s(could not find the secret matching "NOT_HERE"),
|
||||
fn ->
|
||||
Secrets.fetch_secret!("NOT_HERE")
|
||||
end
|
||||
|
||||
assert Secrets.fetch_secret!(secret.name) == %Secret{name: "FOO", value: "111"}
|
||||
Secrets.unset_secret(secret.name)
|
||||
end
|
||||
|
||||
test "secret_exists?/1" do
|
||||
Secrets.unset_secret("FOO")
|
||||
refute Secrets.secret_exists?("FOO")
|
||||
Secrets.set_secret(%{name: "FOO", value: "111"})
|
||||
assert Secrets.secret_exists?("FOO")
|
||||
Secrets.unset_secret("FOO")
|
||||
end
|
||||
|
||||
describe "set_secret/1" do
|
||||
test "creates and stores a secret" do
|
||||
attrs = %{name: "FOO", value: "111"}
|
||||
assert {:ok, secret} = Secrets.set_secret(attrs)
|
||||
|
||||
assert attrs.name == secret.name
|
||||
assert attrs.value == secret.value
|
||||
|
||||
Secrets.unset_secret(secret.name)
|
||||
end
|
||||
|
||||
test "updates an stored secret" do
|
||||
secret = %Secret{name: "FOO", value: "111"}
|
||||
attrs = %{value: "222"}
|
||||
assert {:ok, updated_secret} = Secrets.set_secret(secret, attrs)
|
||||
|
||||
assert secret.name == updated_secret.name
|
||||
assert updated_secret.value == attrs.value
|
||||
|
||||
Secrets.unset_secret(secret.name)
|
||||
end
|
||||
|
||||
test "returns changeset error" do
|
||||
attrs = %{value: "111"}
|
||||
assert {:error, changeset} = Secrets.set_secret(attrs)
|
||||
assert "can't be blank" in errors_on(changeset).name
|
||||
attrs = %{name: "@inavalid", value: "111"}
|
||||
|
||||
assert {:error, changeset} = Secrets.set_secret(attrs)
|
||||
assert "should contain only alphanumeric and underscore" in errors_on(changeset).name
|
||||
end
|
||||
end
|
||||
end
|
|
@ -936,9 +936,65 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
|
||||
view
|
||||
|> element(~s{form[phx-submit="save"]})
|
||||
|> render_submit(%{data: %{name: "foo", value: "123"}})
|
||||
|> render_submit(%{data: %{name: "foo", value: "123", store: "session"}})
|
||||
|
||||
assert %{secrets: [%{name: "FOO", value: "123"}]} = Session.get_data(session.pid)
|
||||
assert %{secrets: %{"FOO" => "123"}} = Session.get_data(session.pid)
|
||||
end
|
||||
|
||||
test "adds a livebook secret from form", %{conn: conn, session: session} do
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}/secrets")
|
||||
|
||||
view
|
||||
|> element(~s{form[phx-submit="save"]})
|
||||
|> render_submit(%{data: %{name: "bar", value: "456", store: "livebook"}})
|
||||
|
||||
assert %Livebook.Secrets.Secret{name: "BAR", value: "456"} in Livebook.Secrets.fetch_secrets()
|
||||
end
|
||||
|
||||
test "sync secrets when they're equal", %{conn: conn, session: session} do
|
||||
Livebook.Secrets.set_secret(%{name: "FOO", value: "123"})
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}/secrets")
|
||||
Session.put_secret(session.pid, %{name: "FOO", value: "123"})
|
||||
|
||||
view
|
||||
|> element(~s{form[phx-submit="save"]})
|
||||
|> render_submit(%{data: %{name: "FOO", value: "456", store: "livebook"}})
|
||||
|
||||
assert %{secrets: %{"FOO" => "456"}} = Session.get_data(session.pid)
|
||||
|
||||
assert %Livebook.Secrets.Secret{name: "FOO", value: "456"} in Livebook.Secrets.fetch_secrets()
|
||||
end
|
||||
|
||||
test "doesn't sync secrets when they are not the same", %{conn: conn, session: session} do
|
||||
Livebook.Secrets.set_secret(%{name: "FOO_BAR", value: "456"})
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}/secrets")
|
||||
Session.put_secret(session.pid, %{name: "FOO_BAR", value: "123"})
|
||||
|
||||
view
|
||||
|> element(~s{form[phx-submit="save"]})
|
||||
|> render_submit(%{data: %{name: "FOO_BAR", value: "999", store: "livebook"}})
|
||||
|
||||
assert %{secrets: %{"FOO_BAR" => "123"}} = Session.get_data(session.pid)
|
||||
|
||||
assert %Livebook.Secrets.Secret{name: "FOO_BAR", value: "999"} in Livebook.Secrets.fetch_secrets()
|
||||
|
||||
refute %Livebook.Secrets.Secret{name: "FOO_BAR", value: "456"} in Livebook.Secrets.fetch_secrets()
|
||||
end
|
||||
|
||||
test "never sync secrets when updating from session", %{conn: conn, session: session} do
|
||||
Livebook.Secrets.set_secret(%{name: "FOO", value: "123"})
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}/secrets")
|
||||
Session.put_secret(session.pid, %{name: "FOO", value: "123"})
|
||||
|
||||
view
|
||||
|> element(~s{form[phx-submit="save"]})
|
||||
|> render_submit(%{data: %{name: "FOO", value: "456", store: "session"}})
|
||||
|
||||
assert %{secrets: %{"FOO" => "456"}} = Session.get_data(session.pid)
|
||||
|
||||
refute %Livebook.Secrets.Secret{name: "FOO", value: "456"} in Livebook.Secrets.fetch_secrets()
|
||||
|
||||
assert %Livebook.Secrets.Secret{name: "FOO", value: "123"} in Livebook.Secrets.fetch_secrets()
|
||||
end
|
||||
|
||||
test "shows the 'Add secret' button for unavailable secrets", %{conn: conn, session: session} do
|
||||
|
|
Loading…
Add table
Reference in a new issue