diff --git a/lib/livebook/secrets.ex b/lib/livebook/secrets.ex
new file mode 100644
index 000000000..027dedec5
--- /dev/null
+++ b/lib/livebook/secrets.ex
@@ -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
diff --git a/lib/livebook/secrets/secret.ex b/lib/livebook/secrets/secret.ex
new file mode 100644
index 000000000..57e18a8fd
--- /dev/null
+++ b/lib/livebook/secrets/secret.ex
@@ -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
diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex
index edd26dd7c..1f9b89808 100644
--- a/lib/livebook/session.ex
+++ b/lib/livebook/session.ex
@@ -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)
diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex
index 1439d23d2..83d9dab95 100644
--- a/lib/livebook/session/data.ex
+++ b/lib/livebook/session/data.ex
@@ -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
diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex
index e927514ac..bf2de1635 100644
--- a/lib/livebook_web/live/session_live.ex
+++ b/lib/livebook_web/live/session_live.ex
@@ -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} />
- <.secrets_list data_view={@data_view} session={@session} socket={@socket} />
+ <.secrets_list
+ data_view={@data_view}
+ livebook_secrets={@livebook_secrets}
+ session={@session}
+ socket={@socket}
+ />
<.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
Secrets
+
Available to this notebook
- <%= for secret <- @data_view.secrets do %>
+ <%= for {secret_name, _} <- session_only_secrets(@data_view.secrets, @livebook_secrets) do %>
-
- <%= secret.name %>
+
+ <%= secret_name %>
Session
<% end %>
+
+
+ Stored in your Livebook
+ On session
+
+ <%= for {secret_name, secret_value} = secret <- Enum.sort(@livebook_secrets) do %>
+
+
+ <%= secret_name %>
+
+ <.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}
+ />
+
+ <% end %>
<%= 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
diff --git a/lib/livebook_web/live/session_live/secrets_component.ex b/lib/livebook_web/live/session_live/secrets_component.ex
index 577a1a186..8d0a8bc32 100644
--- a/lib/livebook_web/live/session_live/secrets_component.ex
+++ b/lib/livebook_web/live/session_live/secrets_component.ex
@@ -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
<%= @title %>
+ <%= if @grant_access do %>
+ <.grant_access_message grant_access={@grant_access} target={@myself} />
+ <% end %>
<%= if @select_secret_ref do %>
-
+
Choose a secret
-
- <%= 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 %>
-
+
+ <%= 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 %>
<.remix_icon icon="folder-lock-line" class="align-middle text-2xl" />
@@ -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"
) %>
@@ -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"
) %>
+
+
Store
+
+ <%= 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 %>
+
+