Implement global Environment Variables from Settings page (#1387)

This commit is contained in:
Alexandre de Souza 2022-09-12 11:34:39 -03:00 committed by GitHub
parent a1c996be1e
commit 0b9f53a122
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 566 additions and 6 deletions

View file

@ -3,7 +3,16 @@ defmodule Livebook.Settings do
# Keeps all Livebook settings that are backed by storage.
import Ecto.Changeset, only: [apply_action: 2]
alias Livebook.FileSystem
alias Livebook.Settings.EnvVar
defmodule NotFoundError do
@moduledoc false
defexception [:message, plug_status: 404]
end
@typedoc """
An id that is used for filesystem's manipulation, either insertion or removal.
@ -117,4 +126,113 @@ defmodule Livebook.Settings do
def set_update_check_enabled(enabled) do
storage().insert(:settings, "global", update_check_enabled: enabled)
end
@doc """
Gets a list of environment variables from storage.
"""
@spec fetch_env_vars() :: list(EnvVar.t())
def fetch_env_vars do
for fields <- storage().all(:env_vars) do
struct!(EnvVar, Map.delete(fields, :id))
end
end
@doc """
Gets one environment variable from storage.
Raises `RuntimeError` if the environment variable does not exist.
"""
@spec fetch_env_var!(String.t()) :: EnvVar.t()
def fetch_env_var!(id) do
case storage().fetch(:env_vars, id) do
:error ->
raise NotFoundError,
message: "could not find an environment variable matching #{inspect(id)}"
{:ok, fields} ->
struct!(EnvVar, Map.delete(fields, :id))
end
end
@doc """
Checks if environment variable already exists.
"""
@spec env_var_exists?(String.t()) :: boolean()
def env_var_exists?(id) do
storage().fetch(:env_vars, id) != :error
end
@doc """
Persists the given environment variable.
With success, notifies interested processes about environment variables
data change. Otherwise, it will return an error tuple with changeset.
"""
@spec set_env_var(EnvVar.t(), map()) ::
{:ok, EnvVar.t()} | {:error, Ecto.Changeset.t()}
def set_env_var(%EnvVar{} = env_var \\ %EnvVar{}, attrs) do
changeset = EnvVar.changeset(env_var, attrs)
with {:ok, env_var} <- apply_action(changeset, :insert) do
save_env_var(env_var)
end
end
defp save_env_var(env_var) do
attributes = env_var |> Map.from_struct() |> Map.to_list()
with :ok <- storage().insert(:env_vars, env_var.key, attributes),
:ok <- broadcast_env_vars_change() do
{:ok, env_var}
end
end
@doc """
Deletes an environment variable from given id.
Also, it notifies interested processes about environment variables data change.
"""
@spec delete_env_var(String.t()) :: :ok
def delete_env_var(id) do
storage().delete(:env_vars, id)
broadcast_env_vars_change()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking environment variable changes.
"""
@spec change_env_var(EnvVar.t(), map()) :: Ecto.Changeset.t()
def change_env_var(%EnvVar{} = env_var, attrs \\ %{}) do
env_var
|> EnvVar.changeset(attrs)
|> Map.put(:action, :validate)
end
@doc """
Subscribes to updates in settings information.
## Messages
* `{:env_vars_changed, env_vars}`
"""
@spec subscribe() :: :ok | {:error, term()}
def subscribe do
Phoenix.PubSub.subscribe(Livebook.PubSub, "settings")
end
@doc """
Unsubscribes from `subscribe/0`.
"""
@spec unsubscribe() :: :ok
def unsubscribe do
Phoenix.PubSub.unsubscribe(Livebook.PubSub, "settings")
end
# Notifies interested processes about environment variables data change.
#
# Broadcasts `{:env_vars_changed, env_vars}` message under the `"settings"` topic.
defp broadcast_env_vars_change do
Phoenix.PubSub.broadcast(Livebook.PubSub, "settings", {:env_vars_changed, fetch_env_vars()})
end
end

View file

@ -0,0 +1,23 @@
defmodule Livebook.Settings.EnvVar do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
key: String.t(),
value: String.t()
}
@primary_key {:key, :string, autogenerate: false}
embedded_schema do
field :value, :string
end
def changeset(env_var, attrs \\ %{}) do
env_var
|> cast(attrs, [:key, :value])
|> update_change(:key, &String.upcase/1)
|> validate_format(:key, ~r/^(?!LB_)\w+$/, message: "cannot start with the LB_ prefix")
|> validate_required([:key, :value])
end
end

View file

@ -19,6 +19,7 @@ defmodule LivebookWeb.LiveHelpers do
|> assign_new(:patch, fn -> nil end)
|> assign_new(:navigate, fn -> nil end)
|> assign_new(:class, fn -> "" end)
|> assign_new(:on_close, fn -> %JS{} end)
|> assign(:attrs, assigns_to_attributes(assigns, [:id, :show, :patch, :navigate, :class]))
~H"""
@ -39,8 +40,8 @@ defmodule LivebookWeb.LiveHelpers do
aria-modal="true"
tabindex="0"
autofocus
phx-window-keydown={hide_modal(@id)}
phx-click-away={hide_modal(@id)}
phx-window-keydown={hide_modal(@on_close, @id)}
phx-click-away={hide_modal(@on_close, @id)}
phx-key="escape"
>
<%= if @patch do %>
@ -53,7 +54,7 @@ defmodule LivebookWeb.LiveHelpers do
type="button"
class="absolute top-6 right-6 text-gray-400 flex space-x-1 items-center"
aria_label="close modal"
phx-click={hide_modal(@id)}
phx-click={hide_modal(@on_close, @id)}
>
<span class="text-sm">(esc)</span>
<.remix_icon icon="close-line" class="text-2xl" />

View file

@ -7,9 +7,15 @@ defmodule LivebookWeb.SettingsLive do
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Livebook.Settings.subscribe()
end
{:ok,
assign(socket,
file_systems: Livebook.Settings.file_systems(),
env_vars: Livebook.Settings.fetch_env_vars(),
env_var: nil,
autosave_path_state: %{
file: autosave_dir(),
dialog_opened?: false
@ -110,6 +116,23 @@ defmodule LivebookWeb.SettingsLive do
socket={@socket}
/>
</div>
<!-- Environment variables configuration -->
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-semibold pb-2 border-b border-gray-200">
Environment variables
</h2>
<p class="mt-4 text-gray-700">
Environment variables are used to store global values and secrets.
The global environment variables can be used on the entire Livebook
application and is accessible only to the current machine.
</p>
<.live_component
module={LivebookWeb.SettingsLive.EnvVarsComponent}
id="env-vars"
env_vars={@env_vars}
return_to={Routes.settings_path(@socket, :page)}
/>
</div>
</div>
<!-- User settings section -->
<div class="flex flex-col space-y-10">
@ -172,9 +195,28 @@ defmodule LivebookWeb.SettingsLive do
/>
</.modal>
<% end %>
<%= if @live_action in [:add_env_var, :edit_env_var] do %>
<.modal
id="env-var-modal"
show
class="w-full max-w-3xl"
on_close={JS.push("clear_env_var")}
patch={Routes.settings_path(@socket, :page)}
>
<.live_component
module={LivebookWeb.SettingsLive.EnvVarComponent}
id="env-var"
env_var={@env_var}
return_to={Routes.settings_path(@socket, :page)}
/>
</.modal>
<% end %>
"""
end
defp autosave_path_select(%{state: %{file: nil}} = assigns), do: ~H""
defp autosave_path_select(%{state: %{dialog_opened?: true}} = assigns) do
~H"""
<div class="w-full h-52">
@ -218,6 +260,11 @@ defmodule LivebookWeb.SettingsLive do
end
@impl true
def handle_params(%{"env_var_id" => key}, _url, socket) do
env_var = Livebook.Settings.fetch_env_var!(key)
{:noreply, assign(socket, env_var: env_var)}
end
def handle_params(%{"file_system_id" => file_system_id}, _url, socket) do
{:noreply, assign(socket, file_system_id: file_system_id)}
end
@ -273,6 +320,10 @@ defmodule LivebookWeb.SettingsLive do
{:noreply, assign(socket, :update_check_enabled, enabled)}
end
def handle_event("clear_env_var", _, socket) do
{:noreply, assign(socket, env_var: nil)}
end
@impl true
def handle_info({:file_systems_updated, file_systems}, socket) do
{:noreply, assign(socket, file_systems: file_systems)}
@ -286,12 +337,18 @@ defmodule LivebookWeb.SettingsLive do
handle_event("set_autosave_path", %{}, socket)
end
def handle_info({:env_vars_changed, env_vars}, socket) do
{:noreply, assign(socket, env_vars: env_vars)}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp autosave_dir() do
Livebook.Settings.autosave_path()
|> Livebook.FileSystem.Utils.ensure_dir_path()
|> Livebook.FileSystem.File.local()
if path = Livebook.Settings.autosave_path() do
path
|> Livebook.FileSystem.Utils.ensure_dir_path()
|> Livebook.FileSystem.File.local()
end
end
defp default_autosave_dir() do

View file

@ -0,0 +1,97 @@
defmodule LivebookWeb.SettingsLive.EnvVarComponent do
use LivebookWeb, :live_component
alias Livebook.Settings
alias Livebook.Settings.EnvVar
@impl true
def update(assigns, socket) do
{env_var, operation} =
if assigns.env_var,
do: {assigns.env_var, :edit},
else: {%EnvVar{}, :new}
changeset = Settings.change_env_var(env_var)
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset, env_var: env_var, operation: operation)}
end
@impl true
def render(assigns) do
~H"""
<div class="p-6 flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800">
<%= if @operation == :new, do: "Add environment variable", else: "Edit environment variable" %>
</h3>
<p class="text-gray-700">
Configure your application global environment variables.
</p>
<.form
id={"#{@id}-form"}
let={f}
for={@changeset}
phx-target={@myself}
phx-submit="save"
phx-change="validate"
autocomplete="off"
spellcheck="false"
>
<div class="flex flex-col space-y-4">
<.input_wrapper form={f} field={:key} class="flex flex-col space-y-1">
<div class="input-label">
Key <span class="text-xs text-gray-500">(alphanumeric and underscore)</span>
</div>
<%= text_input(f, :key, class: "input", autofocus: @operation == :new) %>
</.input_wrapper>
<.input_wrapper form={f} field={:value} class="flex flex-col space-y-1">
<div class="input-label">Value</div>
<%= text_input(f, :value, class: "input", autofocus: @operation == :edit) %>
</.input_wrapper>
<div class="flex space-x-2">
<%= submit("Save",
class: "button-base button-blue",
disabled: not @changeset.valid?,
phx_disabled_with: "Adding..."
) %>
<button
type="button"
phx-click="cancel"
phx-target={@myself}
class="button-base button-outlined-gray"
>
Cancel
</button>
</div>
</div>
</.form>
</div>
"""
end
@impl true
def handle_event("validate", %{"env_var" => attrs}, socket) do
{:noreply, assign(socket, changeset: Settings.change_env_var(socket.assigns.env_var, attrs))}
end
def handle_event("cancel", _, socket) do
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
def handle_event("save", %{"env_var" => attrs}, socket) do
if socket.assigns.changeset.valid? do
case Settings.set_env_var(socket.assigns.env_var, attrs) do
{:ok, _} ->
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
else
{:noreply, socket}
end
end
end

View file

@ -0,0 +1,90 @@
defmodule LivebookWeb.SettingsLive.EnvVarsComponent do
use LivebookWeb, :live_component
alias Livebook.Settings
@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">
<%= for env_var <- @env_vars do %>
<div class="flex items-center justify-between border border-gray-200 rounded-lg p-4">
<.env_var_info socket={@socket} env_var={env_var} myself={@myself} />
</div>
<% end %>
</div>
<div class="flex">
<%= live_patch("Add environment variable",
to: Routes.settings_path(@socket, :add_env_var),
class: "button-base button-blue"
) %>
</div>
</div>
"""
end
defp env_var_info(assigns) do
~H"""
<div class="grid grid-cols-1 md:grid-cols-2 w-full">
<div class="place-content-start">
<.labeled_text label="Key">
<%= @env_var.key %>
</.labeled_text>
</div>
<div class="flex items-center place-content-end">
<.menu id={"env-var-#{@env_var.key}-menu"}>
<:toggle>
<button class="icon-button" aria-label="open session menu" type="button">
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
</:toggle>
<:content>
<button
id={"env-var-#{@env_var.key}-edit"}
type="button"
phx-click={JS.push("edit_env_var", value: %{env_var: @env_var.key})}
phx-target={@myself}
role="menuitem"
class="menu-item text-gray-600"
>
<.remix_icon icon="file-edit-line" />
<span class="font-medium">Edit</span>
</button>
<button
id={"env-var-#{@env_var.key}-delete"}
type="button"
phx-click={
with_confirm(
JS.push("delete_env_var", value: %{env_var: @env_var.key}),
title: "Delete #{@env_var.key}",
description: "Are you sure you want to delete environment variable?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)
}
phx-target={@myself}
role="menuitem"
class="menu-item text-red-600"
>
<.remix_icon icon="delete-bin-line" />
<span class="font-medium">Delete</span>
</button>
</:content>
</.menu>
</div>
</div>
"""
end
@impl true
def handle_event("edit_env_var", %{"env_var" => key}, socket) do
{:noreply, push_patch(socket, to: Routes.settings_path(socket, :edit_env_var, key))}
end
def handle_event("delete_env_var", %{"env_var" => key}, socket) do
Settings.delete_env_var(key)
{:noreply, socket}
end
end

View file

@ -51,6 +51,8 @@ defmodule LivebookWeb.Router do
live "/settings", SettingsLive, :page
live "/settings/add-file-system", SettingsLive, :add_file_system
live "/settings/env-var/new", SettingsLive, :add_env_var
live "/settings/env-var/edit/:env_var_id", SettingsLive, :edit_env_var
live "/explore", ExploreLive, :page
live "/explore/notebooks/:slug", ExploreLive, :notebook

View file

@ -0,0 +1,66 @@
defmodule Livebook.SettingsTest do
use Livebook.DataCase
alias Livebook.Settings
test "fetch_env_vars/0 returns a list of persisted environment variables" do
env_var = insert_env_var(:env_var)
assert env_var in Settings.fetch_env_vars()
Settings.delete_env_var(env_var.key)
refute env_var in Settings.fetch_env_vars()
end
test "fetch_env_var!/1 returns one persisted fly" do
assert_raise Settings.NotFoundError,
~s/could not find an environment variable matching "123456"/,
fn ->
Settings.fetch_env_var!("123456")
end
env_var = insert_env_var(:env_var, key: "123456")
assert Settings.fetch_env_var!("123456") == env_var
Settings.delete_env_var("123456")
end
test "env_var_exists?/1" do
refute Settings.env_var_exists?("FOO")
insert_env_var(:env_var, key: "FOO")
assert Settings.env_var_exists?("FOO")
Settings.delete_env_var("FOO")
end
describe "set_env_var/1" do
test "creates an environment variable" do
attrs = params_for(:env_var, key: "FOO_BAR_BAZ")
assert {:ok, env_var} = Settings.set_env_var(attrs)
assert attrs.key == env_var.key
assert attrs.value == env_var.value
Settings.delete_env_var(env_var.key)
end
test "updates an environment variable" do
env_var = insert_env_var(:env_var)
attrs = %{value: "FOO"}
assert {:ok, updated_env_var} = Settings.set_env_var(env_var, attrs)
assert env_var.key == updated_env_var.key
assert updated_env_var.value == attrs.value
Settings.delete_env_var(env_var.key)
end
test "returns changeset error" do
attrs = params_for(:env_var, key: nil)
assert {:error, changeset} = Settings.set_env_var(attrs)
assert "can't be blank" in errors_on(changeset).key
assert {:error, changeset} = Settings.set_env_var(%{attrs | key: "LB_FOO"})
assert "cannot start with the LB_ prefix" in errors_on(changeset).key
end
end
end

View file

@ -0,0 +1,87 @@
defmodule LivebookWeb.SettingsLiveTest do
use LivebookWeb.ConnCase, async: true
@moduletag :tmp_dir
import Phoenix.LiveViewTest
alias Livebook.Settings
describe "environment variables configuration" do
test "list persisted environment variables", %{conn: conn} do
insert_env_var(:env_var, key: "MY_ENVIRONMENT_VAR")
{:ok, _view, html} = live(conn, Routes.settings_path(conn, :page))
assert html =~ "MY_ENVIRONMENT_VAR"
end
test "adds an environment variable", %{conn: conn} do
attrs = params_for(:env_var, key: "JAKE_PERALTA_ENV_VAR")
{:ok, view, html} = live(conn, Routes.settings_path(conn, :add_env_var))
assert html =~ "Add environment variable"
refute html =~ attrs.key
view
|> element("#env-var-form")
|> render_change(%{"env_var" => attrs})
refute view
|> element("#env-var-form .invalid-feedback")
|> has_element?()
view
|> element("#env-var-form")
|> render_submit(%{"env_var" => attrs})
assert_patch(view, Routes.settings_path(conn, :page))
assert render(view) =~ attrs.key
end
test "updates an environment variable", %{conn: conn} do
env_var = insert_env_var(:env_var, key: "UPDATE_ME")
{:ok, view, html} = live(conn, Routes.settings_path(conn, :page))
assert html =~ env_var.key
view
|> with_target("#env-vars")
|> render_click("edit_env_var", %{"env_var" => env_var.key})
assert_patch(view, Routes.settings_path(conn, :edit_env_var, env_var.key))
assert render(view) =~ "Edit environment variable"
form = element(view, "#env-var-form")
assert render(form) =~ env_var.value
render_change(form, %{"env_var" => %{"value" => "123456"}})
refute view
|> element(".invalid-feedback")
|> has_element?()
render_submit(form, %{"env_var" => %{"value" => "123456"}})
assert_patch(view, Routes.settings_path(conn, :page))
updated_env_var = Settings.fetch_env_var!(env_var.key)
assert updated_env_var.key == env_var.key
refute updated_env_var.value == env_var.value
end
test "deletes an environment variable", %{conn: conn} do
env_var = insert_env_var(:env_var)
{:ok, view, html} = live(conn, Routes.settings_path(conn, :page))
assert html =~ env_var.key
view
|> with_target("#env-vars")
|> render_click("delete_env_var", %{"env_var" => env_var.key})
refute render(view) =~ env_var.key
end
end
end

View file

@ -31,13 +31,32 @@ defmodule Livebook.Factory do
}
end
def build(:env_var) do
%Livebook.Settings.EnvVar{
key: "BAR",
value: "foo"
}
end
def build(factory_name, attrs \\ %{}) do
factory_name |> build() |> struct!(attrs)
end
def params_for(factory_name, attrs \\ %{}) do
factory_name |> build() |> struct!(attrs) |> Map.from_struct()
end
def insert_hub(factory_name, attrs \\ %{}) do
factory_name
|> build(attrs)
|> Livebook.Hubs.save_hub()
end
def insert_env_var(factory_name, attrs \\ %{}) do
env_var = build(factory_name, attrs)
attributes = env_var |> Map.from_struct() |> Map.to_list()
Livebook.Storage.current().insert(:env_vars, env_var.key, attributes)
env_var
end
end