Improve Add Hub and Edit Hub pages (#1929)

This commit is contained in:
Alexandre de Souza 2023-05-26 15:40:45 -03:00 committed by GitHub
parent 683cd8a0d1
commit 76015e3009
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 264 additions and 2129 deletions

View file

@ -49,8 +49,13 @@ export function registerGlobalEventHandlers() {
window.addEventListener("lb:clipcopy", (event) => {
if ("clipboard" in navigator) {
const text = event.target.textContent;
navigator.clipboard.writeText(text);
const tag = event.target.tagName;
if (tag === "INPUT") {
navigator.clipboard.writeText(event.target.value);
} else {
navigator.clipboard.writeText(event.target.tagName);
}
} else {
alert(
"Sorry, your browser does not support clipboard copy.\nThis generally requires a secure origin — either HTTPS or localhost."

View file

@ -58,7 +58,7 @@ config :livebook, LivebookWeb.Endpoint,
live_reload: [
patterns: [
~r"tmp/static_dev/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"lib/livebook_web/(live|views)/.*(ex)$",
~r"lib/livebook_web/(live|views|components)/.*(ex)$",
~r"lib/livebook_web/templates/.*(eex)$"
]
]

View file

@ -2,7 +2,7 @@ defmodule Livebook.Hubs do
@moduledoc false
alias Livebook.Storage
alias Livebook.Hubs.{Broadcasts, Enterprise, Fly, Metadata, Personal, Provider, Team}
alias Livebook.Hubs.{Broadcasts, Metadata, Personal, Provider, Team}
alias Livebook.Secrets.Secret
@namespace :hubs
@ -157,14 +157,6 @@ defmodule Livebook.Hubs do
Phoenix.PubSub.unsubscribe(Livebook.PubSub, "hubs:#{topic}")
end
defp to_struct(%{id: "fly-" <> _} = fields) do
Provider.load(%Fly{}, fields)
end
defp to_struct(%{id: "enterprise-" <> _} = fields) do
Provider.load(%Enterprise{}, fields)
end
defp to_struct(%{id: "personal-" <> _} = fields) do
Provider.load(%Personal{}, fields)
end

View file

@ -1,203 +0,0 @@
defmodule Livebook.Hubs.Enterprise do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Livebook.Hubs
@type t :: %__MODULE__{
id: String.t() | nil,
org_id: pos_integer() | nil,
user_id: pos_integer() | nil,
org_key_id: pos_integer() | nil,
teams_key: String.t() | nil,
session_token: String.t() | nil,
hub_name: String.t() | nil,
hub_emoji: String.t() | nil
}
embedded_schema do
field :org_id, :integer
field :user_id, :integer
field :org_key_id, :integer
field :teams_key, :string
field :session_token, :string
field :hub_name, :string
field :hub_emoji, :string
end
@fields ~w(
org_id
user_id
org_key_id
teams_key
session_token
hub_name
hub_emoji
)a
@doc """
Returns an `%Ecto.Changeset{}` for tracking hub changes.
"""
@spec change_hub(t(), map()) :: Ecto.Changeset.t()
def change_hub(%__MODULE__{} = enterprise, attrs \\ %{}) do
changeset(enterprise, attrs)
end
@doc """
Returns changeset with applied validations.
"""
@spec validate_hub(t(), map()) :: Ecto.Changeset.t()
def validate_hub(%__MODULE__{} = enterprise, attrs \\ %{}) do
enterprise
|> changeset(attrs)
|> Map.put(:action, :validate)
end
@doc """
Creates a Hub.
With success, notifies interested processes about hub metadatas data change.
Otherwise, it will return an error tuple with changeset.
"""
@spec create_hub(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def create_hub(%__MODULE__{} = enterprise, attrs) do
changeset = changeset(enterprise, attrs)
id = get_field(changeset, :id)
if Hubs.hub_exists?(id) do
{:error,
changeset
|> add_error(:hub_name, "already exists")
|> Map.replace!(:action, :validate)}
else
with {:ok, struct} <- apply_action(changeset, :insert) do
Hubs.save_hub(struct)
{:ok, struct}
end
end
end
@doc """
Updates a Hub.
With success, notifies interested processes about hub metadatas data change.
Otherwise, it will return an error tuple with changeset.
"""
@spec update_hub(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def update_hub(%__MODULE__{} = enterprise, attrs) do
changeset = changeset(enterprise, attrs)
id = get_field(changeset, :id)
if Hubs.hub_exists?(id) do
with {:ok, struct} <- apply_action(changeset, :update) do
Hubs.save_hub(struct)
{:ok, struct}
end
else
{:error,
changeset
|> add_error(:hub_name, "does not exists")
|> Map.replace!(:action, :validate)}
end
end
defp changeset(enterprise, attrs) do
enterprise
|> cast(attrs, @fields)
|> validate_required(@fields)
|> add_id()
end
defp add_id(changeset) do
case get_field(changeset, :hub_name) do
nil -> changeset
hub_name -> put_change(changeset, :id, "enterprise-#{hub_name}")
end
end
end
defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Enterprise do
alias Livebook.Hubs.EnterpriseClient
def load(enterprise, fields) do
%{
enterprise
| id: fields.id,
session_token: fields.session_token,
teams_key: fields.teams_key,
org_id: fields.org_id,
user_id: fields.user_id,
org_key_id: fields.org_key_id,
hub_name: fields.hub_name,
hub_emoji: fields.hub_emoji
}
end
def to_metadata(enterprise) do
%Livebook.Hubs.Metadata{
id: enterprise.id,
name: enterprise.hub_name,
provider: enterprise,
emoji: enterprise.hub_emoji,
connected?: EnterpriseClient.connected?(enterprise.id)
}
end
def type(_enterprise), do: "enterprise"
def connection_spec(enterprise), do: {EnterpriseClient, enterprise}
def disconnect(enterprise) do
EnterpriseClient.stop(enterprise.id)
end
def capabilities(_enterprise), do: ~w(connect list_secrets create_secret)a
def get_secrets(enterprise) do
EnterpriseClient.get_secrets(enterprise.id)
end
def create_secret(enterprise, secret) do
data = LivebookProto.build_create_secret_request(name: secret.name, value: secret.value)
case EnterpriseClient.send_request(enterprise.id, data) do
{:create_secret, _} ->
:ok
{:changeset_error, errors} ->
changeset =
for {field, messages} <- errors,
message <- messages,
reduce: secret do
acc ->
Livebook.Secrets.add_secret_error(acc, field, message)
end
{:error, changeset}
{:transport_error, reason} ->
message = "#{enterprise.hub_emoji} #{enterprise.hub_name}: #{reason}"
changeset = Livebook.Secrets.add_secret_error(secret, :hub_id, message)
{:error, changeset}
end
end
def update_secret(_enterprise, _secret), do: raise("not implemented")
def delete_secret(_enterprise, _secret), do: raise("not implemented")
def connection_error(enterprise) do
reason = EnterpriseClient.get_connection_error(enterprise.id)
"Cannot connect to Hub: #{reason}. Will attempt to reconnect automatically..."
end
# TODO: implement signing through the enterprise server
def notebook_stamp(_hub, _notebook_source, _metadata) do
:skip
end
def verify_notebook_stamp(_hub, _notebook_source, _stamp), do: raise("not implemented")
end

View file

@ -1,225 +0,0 @@
defmodule Livebook.Hubs.EnterpriseClient do
@moduledoc false
use GenServer
require Logger
alias Livebook.Hubs.Broadcasts
alias Livebook.Hubs.Enterprise
alias Livebook.Secrets.Secret
alias Livebook.WebSocket.ClientConnection
@registry Livebook.HubsRegistry
@supervisor Livebook.HubsSupervisor
defstruct [:server, :hub, :connection_error, connected?: false, secrets: []]
@type registry_name :: {:via, Registry, {Livebook.HubsRegistry, String.t()}}
@doc """
Connects the Enterprise client with WebSocket server.
"""
@spec start_link(Enterprise.t()) :: GenServer.on_start()
def start_link(%Enterprise{} = enterprise) do
GenServer.start_link(__MODULE__, enterprise, name: registry_name(enterprise.id))
end
@doc """
Stops the WebSocket server.
"""
@spec stop(String.t()) :: :ok
def stop(id) do
if pid = GenServer.whereis(registry_name(id)) do
DynamicSupervisor.terminate_child(@supervisor, pid)
end
:ok
end
@doc """
Sends a request to the WebSocket server.
"""
@spec send_request(String.t() | registry_name() | pid(), WebSocket.proto()) :: {atom(), term()}
def send_request(id, %_struct{} = data) when is_binary(id) do
send_request(registry_name(id), data)
end
def send_request(pid, %_struct{} = data) do
with {:ok, server} <- GenServer.call(pid, :fetch_server) do
ClientConnection.send_request(server, data)
end
end
@doc """
Returns a list of cached secrets.
"""
@spec get_secrets(String.t()) :: list(Secret.t())
def get_secrets(id) do
GenServer.call(registry_name(id), :get_secrets)
catch
:exit, _ -> []
end
@doc """
Returns the latest error from connection.
"""
@spec get_connection_error(String.t()) :: String.t() | nil
def get_connection_error(id) do
GenServer.call(registry_name(id), :get_connection_error)
catch
:exit, _ -> "connection refused"
end
@doc """
Returns if the given enterprise is connected.
"""
@spec connected?(String.t()) :: boolean()
def connected?(id) do
GenServer.call(registry_name(id), :connected?)
catch
:exit, _ -> false
end
## GenServer callbacks
@impl true
def init(%Enterprise{} = enterprise) do
# TODO: Make it work with new struct and `Livebook.Teams`
{:ok, pid} = ClientConnection.start_link(self(), Livebook.Config.teams_url())
{:ok, %__MODULE__{hub: enterprise, server: pid}}
end
@impl true
def handle_continue(:synchronize_user, state) do
data = LivebookProto.build_handshake_request(app_version: Livebook.Config.app_version())
{:handshake, _} = ClientConnection.send_request(state.server, data)
{:noreply, state}
end
@impl true
def handle_call(:fetch_server, _caller, state) do
if state.connected? do
{:reply, {:ok, state.server}, state}
else
{:reply, {:transport_error, state.connection_error}, state}
end
end
def handle_call(:get_secrets, _caller, state) do
{:reply, state.secrets, state}
end
def handle_call(:get_connection_error, _caller, state) do
{:reply, state.connection_error, state}
end
def handle_call(:connected?, _caller, state) do
{:reply, state.connected?, state}
end
@impl true
def handle_info({:connect, :ok, _}, state) do
Broadcasts.hub_connected()
{:noreply, %{state | connected?: true, connection_error: nil}, {:continue, :synchronize_user}}
end
def handle_info({:connect, :error, reason}, state) do
Broadcasts.hub_connection_failed(reason)
{:noreply, %{state | connected?: false, connection_error: reason}}
end
def handle_info({:disconnect, :ok, :disconnected}, state) do
Broadcasts.hub_disconnected()
{:stop, :normal, state}
end
def handle_info({:event, topic, data}, state) do
Logger.debug("Received event #{topic} with data: #{inspect(data)}")
{:noreply, handle_event(topic, data, state)}
end
# Private
defp registry_name(id) do
{:via, Registry, {@registry, id}}
end
defp put_secret(state, secret) do
state = remove_secret(state, secret)
%{state | secrets: [secret | state.secrets]}
end
defp remove_secret(state, secret) do
%{state | secrets: Enum.reject(state.secrets, &(&1.name == secret.name))}
end
defp build_secret(state, %{name: name, value: value}),
do: %Secret{name: name, value: value, hub_id: state.hub.id, readonly: true}
defp update_hub(state, name) do
case Enterprise.update_hub(state.hub, %{hub_name: name}) do
{:ok, hub} -> %{state | hub: hub}
{:error, _} -> state
end
end
defp handle_event(:secret_created, secret_created, state) do
secret = build_secret(state, secret_created)
Broadcasts.secret_created(secret)
put_secret(state, secret)
end
defp handle_event(:secret_updated, secret_updated, state) do
secret = build_secret(state, secret_updated)
Broadcasts.secret_updated(secret)
put_secret(state, secret)
end
defp handle_event(:secret_deleted, secret_deleted, state) do
secret = build_secret(state, secret_deleted)
Broadcasts.secret_deleted(secret)
remove_secret(state, secret)
end
defp handle_event(:user_synchronized, user_synchronized, %{secrets: []} = state) do
state = update_hub(state, user_synchronized.name)
secrets = for secret <- user_synchronized.secrets, do: build_secret(state, secret)
%{state | secrets: secrets}
end
defp handle_event(:user_synchronized, user_synchronized, state) do
state = update_hub(state, user_synchronized.name)
secrets = for secret <- user_synchronized.secrets, do: build_secret(state, secret)
created_secrets =
Enum.reject(secrets, fn secret ->
Enum.find(state.secrets, &(&1.name == secret.name and &1.value == secret.value))
end)
deleted_secrets =
Enum.reject(state.secrets, fn secret ->
Enum.find(secrets, &(&1.name == secret.name))
end)
updated_secrets =
Enum.filter(secrets, fn secret ->
Enum.find(state.secrets, &(&1.name == secret.name and &1.value != secret.value))
end)
events_by_type = [
secret_deleted: deleted_secrets,
secret_created: created_secrets,
secret_updated: updated_secrets
]
for {type, events} <- events_by_type, event <- events, reduce: state do
state -> handle_event(type, event, state)
end
end
end

View file

@ -1,171 +0,0 @@
defmodule Livebook.Hubs.Fly do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Livebook.Hubs
@type t :: %__MODULE__{
id: String.t() | nil,
access_token: String.t() | nil,
hub_name: String.t() | nil,
hub_emoji: String.t() | nil,
organization_id: String.t() | nil,
organization_type: String.t() | nil,
organization_name: String.t() | nil,
application_id: String.t() | nil
}
embedded_schema do
field :access_token, :string
field :hub_name, :string
field :hub_emoji, :string
field :organization_id, :string
field :organization_type, :string
field :organization_name, :string
field :application_id, :string
end
@fields ~w(
access_token
hub_name
hub_emoji
organization_id
organization_name
organization_type
application_id
)a
@doc """
Returns an `%Ecto.Changeset{}` for tracking hub changes.
"""
@spec change_hub(t(), map()) :: Ecto.Changeset.t()
def change_hub(%__MODULE__{} = fly, attrs \\ %{}) do
changeset(fly, attrs)
end
@doc """
Returns changeset with applied validations.
"""
@spec validate_hub(t(), map()) :: Ecto.Changeset.t()
def validate_hub(%__MODULE__{} = fly, attrs \\ %{}) do
fly
|> changeset(attrs)
|> Map.put(:action, :validate)
end
@doc """
Creates a Hub.
With success, notifies interested processes about hub metadatas data change.
Otherwise, it will return an error tuple with changeset.
"""
@spec create_hub(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def create_hub(%__MODULE__{} = fly, attrs) do
changeset = changeset(fly, attrs)
if Hubs.hub_exists?(fly.id) do
{:error,
changeset
|> add_error(:application_id, "already exists")
|> Map.replace!(:action, :validate)}
else
with {:ok, struct} <- apply_action(changeset, :insert) do
Hubs.save_hub(struct)
{:ok, struct}
end
end
end
@doc """
Updates a Hub.
With success, notifies interested processes about hub metadatas data change.
Otherwise, it will return an error tuple with changeset.
"""
@spec update_hub(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def update_hub(%__MODULE__{} = fly, attrs) do
changeset = changeset(fly, attrs)
if Hubs.hub_exists?(fly.id) do
with {:ok, struct} <- apply_action(changeset, :update) do
Hubs.save_hub(struct)
{:ok, struct}
end
else
{:error,
changeset
|> add_error(:application_id, "does not exists")
|> Map.replace!(:action, :validate)}
end
end
defp changeset(fly, attrs) do
fly
|> cast(attrs, @fields)
|> validate_required(@fields)
|> add_id()
end
defp add_id(changeset) do
if application_id = get_field(changeset, :application_id) do
change(changeset, %{id: "fly-#{application_id}"})
else
changeset
end
end
end
defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Fly do
def load(fly, fields) do
%{
fly
| id: fields.id,
access_token: fields.access_token,
hub_name: fields.hub_name,
hub_emoji: fields.hub_emoji,
organization_id: fields.organization_id,
organization_type: fields.organization_type,
organization_name: fields.organization_name,
application_id: fields.application_id
}
end
def to_metadata(fly) do
%Livebook.Hubs.Metadata{
id: fly.id,
name: fly.hub_name,
provider: fly,
emoji: fly.hub_emoji,
connected?: false
}
end
def type(_fly), do: "fly"
def connection_spec(_fly), do: nil
def disconnect(_fly), do: raise("not implemented")
def capabilities(_fly), do: []
def get_secrets(_fly), do: []
# TODO: Implement the FlyClient.set_secrets/2
def create_secret(_fly, _secret), do: :ok
# TODO: Implement the FlyClient.set_secrets/2
def update_secret(_fly, _secret), do: :ok
# TODO: Implement the FlyClient.unset_secrets/2
def delete_secret(_fly, _secret), do: :ok
def connection_error(_fly), do: raise("not implemented")
def notebook_stamp(_hub, _notebook_source, _metadata) do
:skip
end
def verify_notebook_stamp(_hub, _notebook_source, _stamp), do: raise("not implemented")
end

View file

@ -1,139 +0,0 @@
defmodule Livebook.Hubs.FlyClient do
@moduledoc false
alias Livebook.Hubs.Fly
alias Livebook.Utils.HTTP
def fetch_apps(access_token) do
query = """
query {
apps {
nodes {
id
organization {
id
name
type
}
}
}
}
"""
with {:ok, %{"apps" => %{"nodes" => nodes}}} <- graphql(access_token, query) do
apps =
for node <- nodes do
%Fly{
id: "fly-" <> node["id"],
access_token: access_token,
organization_id: node["organization"]["id"],
organization_type: node["organization"]["type"],
organization_name: node["organization"]["name"],
application_id: node["id"]
}
end
{:ok, apps}
end
end
def fetch_app(%Fly{application_id: app_id, access_token: access_token}) do
query = """
query($appId: String!) {
app(id: $appId) {
id
name
hostname
platformVersion
deployed
status
secrets {
id
name
digest
createdAt
}
}
}
"""
with {:ok, %{"app" => app}} <- graphql(access_token, query, %{appId: app_id}) do
{:ok, app}
end
end
def set_secrets(%Fly{access_token: access_token, application_id: application_id}, secrets) do
mutation = """
mutation($input: SetSecretsInput!) {
setSecrets(input: $input) {
app {
secrets {
id
name
digest
createdAt
}
}
}
}
"""
input = %{input: %{appId: application_id, secrets: secrets}}
with {:ok, %{"setSecrets" => %{"app" => app}}} <- graphql(access_token, mutation, input) do
{:ok, app["secrets"]}
end
end
def unset_secrets(%Fly{access_token: access_token, application_id: application_id}, keys) do
mutation = """
mutation($input: UnsetSecretsInput!) {
unsetSecrets(input: $input) {
app {
secrets {
id
name
digest
createdAt
}
}
}
}
"""
input = %{input: %{appId: application_id, keys: keys}}
with {:ok, %{"unsetSecrets" => %{"app" => app}}} <- graphql(access_token, mutation, input) do
{:ok, app["secrets"]}
end
end
defp graphql(access_token, query, input \\ %{}) do
headers = [{"Authorization", "Bearer #{access_token}"}]
body = {"application/json", Jason.encode!(%{query: query, variables: input})}
case HTTP.request(:post, graphql_endpoint(), headers: headers, body: body) do
{:ok, 200, _, body} ->
case Jason.decode!(body) do
%{"errors" => [%{"extensions" => %{"code" => code}}]} ->
{:error, "request failed with code: #{code}"}
%{"errors" => [%{"message" => message}]} ->
{:error, message}
%{"data" => data} ->
{:ok, data}
end
{:ok, _, _, body} ->
{:error, body}
{:error, _} = error ->
error
end
end
defp graphql_endpoint do
Application.get_env(:livebook, :fly_graphql_endpoint, "https://api.fly.io/graphql")
end
end

View file

@ -1,23 +1,32 @@
defmodule LivebookWeb.Hub.Edit.PersonalComponent do
use LivebookWeb, :live_component
alias Livebook.Hubs
alias Livebook.Hubs.Personal
alias LivebookWeb.LayoutHelpers
@impl true
def update(assigns, socket) do
socket = assign(socket, assigns)
changeset = Personal.change_hub(assigns.hub)
secrets = Hubs.get_secrets(assigns.hub)
secret_name = assigns.params["secret_name"]
secret_value =
if assigns.live_action == :edit_secret do
secret = Enum.find(assigns.secrets, &(&1.name == assigns.secret_name))
secret.value
secrets
|> Enum.find(&(&1.name == secret_name))
|> Map.get(:value)
end
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset, stamp_changeset: changeset, secret_value: secret_value)}
assign(socket,
secrets: secrets,
changeset: changeset,
stamp_changeset: changeset,
secret_name: secret_name,
secret_value: secret_value
)}
end
@impl true

View file

@ -7,52 +7,141 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
@impl true
def update(assigns, socket) do
socket = assign(socket, assigns)
changeset = Team.change_hub(assigns.hub)
show_key? = assigns.params["show-key"] == "true"
{:ok,
socket
|> assign(assigns)
|> assign(show_key: show_key?)
|> assign_form(changeset)}
end
@impl true
def render(assigns) do
~H"""
<div id={"#{@id}-component"} class="space-y-8">
<div class="flex relative">
<LayoutHelpers.title text={"#{@hub.hub_emoji} #{@hub.hub_name}"} />
<div id={"#{@id}-component"}>
<.modal
:if={@show_key}
id="show-key-modal"
width={:medium}
show={true}
patch={~p"/hub/#{@hub.id}"}
>
<div class="p-6 flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800">
Teams Key
</h3>
<div class="justify-center">
This is your <strong>Teams Key</strong>. If you want to join or invite others
to your organization, you will need to share your Teams Key with them. We
recommend storing it somewhere safe:
</div>
<div class=" w-full">
<div id="teams-key-toggle" class="relative flex">
<input
type="password"
id="teams-key"
readonly
value={@hub.teams_key}
class="input font-mono w-full border-neutral-200 bg-neutral-100 py-2 border-2 pr-8"
/>
<button
phx-click={JS.push("delete_hub", value: %{id: @hub.id})}
class="absolute right-0 button-base button-red"
>
Delete hub
</button>
</div>
<div class="flex items-center absolute inset-y-0 right-1">
<button
class="icon-button"
data-copy
data-tooltip="Copied to clipboard"
type="button"
aria-label="copy to clipboard"
phx-click={
JS.dispatch("lb:clipcopy", to: "#teams-key")
|> JS.add_class(
"tooltip top",
to: "#teams-key-toggle [data-copy]",
transition: {"ease-out duration-200", "opacity-0", "opacity-100"}
)
|> JS.remove_class(
"tooltip top",
to: "#teams-key-toggle [data-copy]",
transition: {"ease-out duration-200", "opacity-0", "opacity-100"},
time: 2000
)
}
>
<.remix_icon icon="clipboard-line" class="text-xl" />
</button>
<div class="flex flex-col space-y-10">
<div class="flex flex-col space-y-2">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
General
</h2>
<.form
:let={f}
id={@id}
class="flex flex-col mt-4 space-y-4"
for={@form}
phx-submit="save"
phx-change="validate"
phx-target={@myself}
>
<div class="grid grid-cols-1 md:grid-cols-1 gap-3">
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
<button
class="icon-button"
data-show
type="button"
aria-label="show password"
phx-click={
JS.remove_attribute("type", to: "#teams-key-toggle input")
|> JS.set_attribute({"type", "text"}, to: "#teams-key-toggle input")
|> JS.add_class("hidden", to: "#teams-key-toggle [data-show]")
|> JS.remove_class("hidden", to: "#teams-key-toggle [data-hide]")
}
>
<.remix_icon icon="eye-line" class="text-xl" />
</button>
<button
class="icon-button hidden"
data-hide
type="button"
aria-label="hide password"
phx-click={
JS.remove_attribute("type", to: "#teams-key-toggle input")
|> JS.set_attribute({"type", "password"}, to: "#teams-key-toggle input")
|> JS.remove_class("hidden", to: "#teams-key-toggle [data-show]")
|> JS.add_class("hidden", to: "#teams-key-toggle [data-hide]")
}
>
<.remix_icon icon="eye-off-line" class="text-xl" />
</button>
</div>
</div>
</div>
</div>
</.modal>
<button class="button-base button-blue" type="submit" phx-disable-with="Updating...">
Update Hub
</button>
</.form>
<div class="space-y-8">
<div class="flex relative">
<LayoutHelpers.title text={"#{@hub.hub_emoji} #{@hub.hub_name}"} />
<button
phx-click={JS.push("delete_hub", value: %{id: @hub.id})}
class="absolute right-0 button-base button-red"
>
Delete hub
</button>
</div>
<div class="flex flex-col space-y-10">
<div class="flex flex-col space-y-2">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
General
</h2>
<.form
:let={f}
id={@id}
class="flex flex-col mt-4 space-y-4"
for={@form}
phx-submit="save"
phx-change="validate"
phx-target={@myself}
>
<div class="grid grid-cols-1 md:grid-cols-1 gap-3">
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
</div>
<button class="button-base button-blue" type="submit" phx-disable-with="Updating...">
Update Hub
</button>
</.form>
</div>
</div>
</div>
</div>

View file

@ -9,32 +9,16 @@ defmodule LivebookWeb.Hub.EditLive do
@impl true
def mount(_params, _session, socket) do
{:ok,
assign(socket,
hub: nil,
secrets: [],
type: nil,
page_title: "Hub - Livebook",
env_var_id: nil,
secret_name: nil
)}
{:ok, assign(socket, hub: nil, type: nil, page_title: "Hub - Livebook", params: %{})}
end
@impl true
def handle_params(params, _url, socket) do
Livebook.Hubs.subscribe([:secrets])
Hubs.subscribe([:secrets])
hub = Hubs.fetch_hub!(params["id"])
type = Provider.type(hub)
{:noreply,
assign(socket,
hub: hub,
type: type,
secrets: Hubs.get_secrets(hub),
params: params,
env_var_id: params["env_var_id"],
secret_name: params["secret_name"]
)}
{:noreply, assign(socket, hub: hub, type: type, params: params, counter: 0)}
end
@impl true
@ -50,8 +34,8 @@ defmodule LivebookWeb.Hub.EditLive do
type={@type}
hub={@hub}
live_action={@live_action}
secrets={@secrets}
secret_name={@secret_name}
params={@params}
counter={@counter}
/>
</div>
</LayoutHelpers.layout>
@ -63,16 +47,23 @@ defmodule LivebookWeb.Hub.EditLive do
<.live_component
module={LivebookWeb.Hub.Edit.PersonalComponent}
hub={@hub}
secrets={@secrets}
params={@params}
live_action={@live_action}
secret_name={@secret_name}
counter={@counter}
id="personal-form"
/>
"""
end
defp hub_component(%{type: "team"} = assigns) do
~H(<.live_component module={LivebookWeb.Hub.Edit.TeamComponent} hub={@hub} id="team-form" />)
~H"""
<.live_component
module={LivebookWeb.Hub.Edit.TeamComponent}
hub={@hub}
params={@params}
id="team-form"
/>
"""
end
@impl true
@ -98,21 +89,21 @@ defmodule LivebookWeb.Hub.EditLive do
def handle_info({:secret_created, %{hub_id: id}}, %{assigns: %{hub: %{id: id}}} = socket) do
{:noreply,
socket
|> refresh_secrets()
|> increment_counter()
|> put_flash(:success, "Secret created successfully")}
end
def handle_info({:secret_updated, %{hub_id: id}}, %{assigns: %{hub: %{id: id}}} = socket) do
{:noreply,
socket
|> refresh_secrets()
|> increment_counter()
|> put_flash(:success, "Secret updated successfully")}
end
def handle_info({:secret_deleted, %{hub_id: id}}, %{assigns: %{hub: %{id: id}}} = socket) do
{:noreply,
socket
|> refresh_secrets()
|> increment_counter()
|> put_flash(:success, "Secret deleted successfully")}
end
@ -120,7 +111,5 @@ defmodule LivebookWeb.Hub.EditLive do
{:noreply, socket}
end
defp refresh_secrets(socket) do
assign(socket, secrets: Livebook.Hubs.get_secrets(socket.assigns.hub))
end
defp increment_counter(socket), do: assign(socket, counter: socket.assigns.counter + 1)
end

View file

@ -132,7 +132,7 @@ defmodule LivebookWeb.Hub.NewLive do
</div>
<.password_field
readonly={@selected_option == "new-org"}
:if={@selected_option == "join-org"}
field={f[:teams_key]}
label="Livebook Teams Key"
/>
@ -281,7 +281,7 @@ defmodule LivebookWeb.Hub.NewLive do
{:noreply,
socket
|> put_flash(:success, "Hub added successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")}
|> push_navigate(to: ~p"/hub/#{hub.id}?show-key=true")}
{:error, :expired} ->
changeset = Teams.change_org(org, %{user_code: nil})
@ -293,6 +293,12 @@ defmodule LivebookWeb.Hub.NewLive do
|> assign_form(changeset)}
{:transport_error, message} ->
Process.send_after(
self(),
{:check_completion_data, device_code},
@check_completion_data_interval
)
{:noreply, put_flash(socket, :error, message)}
end
end

View file

@ -1,99 +0,0 @@
defmodule Livebook.Hubs.EnterpriseClientTest do
use Livebook.EnterpriseIntegrationCase, async: true
@moduletag :capture_log
alias Livebook.Hubs.EnterpriseClient
alias Livebook.Secrets.Secret
setup do
Livebook.Hubs.subscribe([:connection, :secrets])
:ok
end
describe "start_link/1" do
test "successfully authenticates the web socket connection", %{url: url, token: token} do
enterprise = build(:enterprise, url: url, token: token)
EnterpriseClient.start_link(enterprise)
assert_receive :hub_connected
end
test "rejects the websocket with invalid address", %{token: token} do
enterprise = build(:enterprise, url: "http://localhost:9999", token: token)
EnterpriseClient.start_link(enterprise)
assert_receive {:hub_connection_failed, "connection refused"}
end
test "rejects the web socket connection with invalid credentials", %{url: url} do
enterprise = build(:enterprise, url: url, token: "foo")
EnterpriseClient.start_link(enterprise)
assert_receive {:hub_connection_failed, reason}
assert reason =~ "the given token is invalid"
end
end
describe "handle events" do
setup %{test: test, url: url, token: token} do
node = EnterpriseServer.get_node()
hub_id = "enterprise-#{test}"
insert_hub(:enterprise,
id: hub_id,
external_id: to_string(test),
url: url,
token: token
)
assert_receive :hub_connected
{:ok, node: node, hub_id: hub_id}
end
test "receives a secret_created event", %{node: node, hub_id: id} do
name = "API_TOKEN_ID"
value = Livebook.Utils.random_id()
:erpc.call(node, Enterprise.Integration, :create_secret, [name, value])
secret = %Secret{name: name, value: value, hub_id: id, readonly: true}
assert_receive {:secret_created, ^secret}
assert secret in EnterpriseClient.get_secrets(id)
end
test "receives a secret_updated event", %{node: node, hub_id: id} do
name = "SUPER_SUDO_USER"
value = "JakePeralta"
new_value = "ChonkyCat"
enterprise_secret = :erpc.call(node, Enterprise.Integration, :create_secret, [name, value])
secret = %Secret{name: name, value: value, hub_id: id, readonly: true}
updated_secret = %Secret{name: name, value: new_value, hub_id: id, readonly: true}
assert_receive {:secret_created, ^secret}
assert secret in EnterpriseClient.get_secrets(id)
refute updated_secret in EnterpriseClient.get_secrets(id)
:erpc.call(node, Enterprise.Integration, :update_secret, [enterprise_secret, new_value])
assert_receive {:secret_updated, ^updated_secret}
assert updated_secret in EnterpriseClient.get_secrets(id)
refute secret in EnterpriseClient.get_secrets(id)
end
test "receives a secret_deleted event", %{node: node, hub_id: id} do
name = "SUPER_DELETE"
value = "JakePeralta"
enteprise_secret = :erpc.call(node, Enterprise.Integration, :create_secret, [name, value])
secret = %Secret{name: name, value: value, hub_id: id, readonly: true}
assert_receive {:secret_created, ^secret}
assert secret in EnterpriseClient.get_secrets(id)
:erpc.call(node, Enterprise.Integration, :delete_secret, [enteprise_secret])
assert_receive {:secret_deleted, ^secret}
refute secret in EnterpriseClient.get_secrets(id)
end
end
end

View file

@ -1,225 +0,0 @@
defmodule Livebook.Hubs.FlyClientTest do
use Livebook.DataCase
alias Livebook.Hubs.{Fly, FlyClient}
setup do
bypass = Bypass.open()
Application.put_env(
:livebook,
:fly_graphql_endpoint,
"http://localhost:#{bypass.port}"
)
on_exit(fn ->
Application.delete_env(:livebook, :fly_graphql_endpoint)
end)
{:ok, bypass: bypass, url: "http://localhost:#{bypass.port}"}
end
describe "fetch_apps/1" do
test "fetches an empty list of apps", %{bypass: bypass} do
response = %{"data" => %{"apps" => %{"nodes" => []}}}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
assert {:ok, []} = FlyClient.fetch_apps("some valid token")
end
test "fetches a list of apps", %{bypass: bypass} do
app = %{
"id" => "foo-app",
"organization" => %{
"id" => "l3soyvjmvtmwtl6l2drnbfuvltipprge",
"name" => "Foo Bar",
"type" => "PERSONAL"
}
}
app_id = app["id"]
response = %{"data" => %{"apps" => %{"nodes" => [app]}}}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
assert {:ok, [%Fly{application_id: ^app_id}]} = FlyClient.fetch_apps("some valid token")
end
test "returns unauthorized when token is invalid", %{bypass: bypass} do
error = %{"extensions" => %{"code" => "UNAUTHORIZED"}}
response = %{"data" => nil, "errors" => [error]}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
assert {:error, "request failed with code: UNAUTHORIZED"} = FlyClient.fetch_apps("foo")
end
end
describe "fetch_app/1" do
test "fetches an application", %{bypass: bypass} do
app = %{
"id" => "foo-app",
"name" => "foo-app",
"hostname" => "foo-app.fly.dev",
"platformVersion" => "nomad",
"deployed" => true,
"status" => "running",
"secrets" => [
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => Livebook.Utils.random_short_id(),
"name" => "FOO"
}
]
}
response = %{"data" => %{"app" => app}}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
hub = build(:fly)
assert {:ok, ^app} = FlyClient.fetch_app(hub)
end
test "returns unauthorized when token is invalid", %{bypass: bypass} do
error = %{"extensions" => %{"code" => "UNAUTHORIZED"}}
response = %{"data" => nil, "errors" => [error]}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
hub = build(:fly)
assert {:error, "request failed with code: UNAUTHORIZED"} = FlyClient.fetch_app(hub)
end
end
describe "set_secrets/2" do
test "puts a list of secrets inside application", %{bypass: bypass} do
secrets = [
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => Livebook.Utils.random_short_id(),
"name" => "FOO"
}
]
response = %{"data" => %{"setSecrets" => %{"app" => %{"secrets" => secrets}}}}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
hub = build(:fly)
assert {:ok, ^secrets} = FlyClient.set_secrets(hub, [%{key: "FOO", value: "BAR"}])
end
test "returns error when input is invalid", %{bypass: bypass} do
message =
"Variable $input of type SetSecretsInput! was provided invalid value for secrets.0.Value (Field is not defined on SecretInput), secrets.0.value (Expected value to not be null)"
error = %{
"extensions" => %{
"problems" => [
%{
"explanation" => "Field is not defined on SecretInput",
"path" => ["secrets", 0, "Value"]
},
%{
"explanation" => "Expected value to not be null",
"path" => ["secrets", 0, "value"]
}
],
"value" => %{
"appId" => "myfoo-test-livebook",
"secrets" => [%{"Value" => "BAR", "key" => "FOO"}]
}
},
"locations" => [%{"column" => 10, "line" => 1}],
"message" => message
}
response = %{"data" => nil, "errors" => [error]}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
hub = build(:fly)
assert {:error, ^message} = FlyClient.set_secrets(hub, [%{key: "FOO", Value: "BAR"}])
end
test "returns unauthorized when token is invalid", %{bypass: bypass} do
error = %{"extensions" => %{"code" => "UNAUTHORIZED"}}
response = %{"data" => nil, "errors" => [error]}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
hub = build(:fly)
assert {:error, "request failed with code: UNAUTHORIZED"} =
FlyClient.set_secrets(hub, [%{key: "FOO", value: "BAR"}])
end
end
describe "unset_secrets/2" do
test "deletes a list of secrets inside application", %{bypass: bypass} do
response = %{"data" => %{"unsetSecrets" => %{"app" => %{"secrets" => []}}}}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
hub = build(:fly)
assert {:ok, []} = FlyClient.unset_secrets(hub, ["FOO"])
end
test "returns unauthorized when token is invalid", %{bypass: bypass} do
error = %{"extensions" => %{"code" => "UNAUTHORIZED"}}
response = %{"data" => nil, "errors" => [error]}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
hub = build(:fly)
assert {:error, "request failed with code: UNAUTHORIZED"} =
FlyClient.unset_secrets(hub, ["FOO"])
end
end
end

View file

@ -4,53 +4,53 @@ defmodule Livebook.HubsTest do
alias Livebook.Hubs
test "get_hubs/0 returns a list of persisted hubs" do
fly = insert_hub(:fly, id: "fly-baz")
assert fly in Hubs.get_hubs()
team = insert_hub(:team, id: "team-baz")
assert team in Hubs.get_hubs()
Hubs.delete_hub("fly-baz")
refute fly in Hubs.get_hubs()
Hubs.delete_hub("team-baz")
refute team in Hubs.get_hubs()
end
test "get_metadata/0 returns a list of persisted hubs normalized" do
fly = insert_hub(:fly, id: "fly-livebook")
metadata = Hubs.Provider.to_metadata(fly)
team = insert_hub(:team, id: "team-livebook")
metadata = Hubs.Provider.to_metadata(team)
assert metadata in Hubs.get_metadatas()
Hubs.delete_hub("fly-livebook")
Hubs.delete_hub("team-livebook")
refute metadata in Hubs.get_metadatas()
end
test "fetch_hub!/1 returns one persisted fly" do
test "fetch_hub!/1 returns one persisted team" do
assert_raise Livebook.Storage.NotFoundError,
~s/could not find entry in \"hubs\" with ID "fly-exception-foo"/,
~s/could not find entry in \"hubs\" with ID "team-exception-foo"/,
fn ->
Hubs.fetch_hub!("fly-exception-foo")
Hubs.fetch_hub!("team-exception-foo")
end
fly = insert_hub(:fly, id: "fly-exception-foo")
team = insert_hub(:team, id: "team-exception-foo")
assert Hubs.fetch_hub!("fly-exception-foo") == fly
assert Hubs.fetch_hub!("team-exception-foo") == team
end
test "hub_exists?/1" do
refute Hubs.hub_exists?("fly-bar")
insert_hub(:fly, id: "fly-bar")
assert Hubs.hub_exists?("fly-bar")
refute Hubs.hub_exists?("team-bar")
insert_hub(:team, id: "team-bar")
assert Hubs.hub_exists?("team-bar")
end
test "save_hub/1 persists hub" do
fly = build(:fly, id: "fly-foo")
Hubs.save_hub(fly)
team = build(:team, id: "team-foo")
Hubs.save_hub(team)
assert Hubs.fetch_hub!("fly-foo") == fly
assert Hubs.fetch_hub!("team-foo") == team
end
test "save_hub/1 updates hub" do
fly = insert_hub(:fly, id: "fly-foo2")
Hubs.save_hub(%{fly | hub_emoji: "🐈"})
team = insert_hub(:team, id: "team-foo2")
Hubs.save_hub(%{team | hub_emoji: "🐈"})
refute Hubs.fetch_hub!("fly-foo2") == fly
assert Hubs.fetch_hub!("fly-foo2").hub_emoji == "🐈"
refute Hubs.fetch_hub!("team-foo2") == team
assert Hubs.fetch_hub!("team-foo2").hub_emoji == "🐈"
end
end

View file

@ -1122,16 +1122,16 @@ defmodule Livebook.LiveMarkdown.ExportTest do
end
test "persists hub id when not default" do
Livebook.Factory.insert_hub(:fly, id: "fly-persisted-id")
Livebook.Factory.insert_hub(:team, id: "team-persisted-id")
notebook = %{
Notebook.new()
| name: "My Notebook",
hub_id: "fly-persisted-id"
hub_id: "team-persisted-id"
}
expected_document = """
<!-- livebook:{"hub_id":"fly-persisted-id"} -->
<!-- livebook:{"hub_id":"team-persisted-id"} -->
# My Notebook
"""

View file

@ -721,17 +721,17 @@ defmodule Livebook.LiveMarkdown.ImportTest do
end
test "imports notebook hub id when exists" do
Livebook.Factory.insert_hub(:fly, id: "fly-persisted-id")
Livebook.Factory.insert_hub(:team, id: "team-persisted-id")
markdown = """
<!-- livebook:{"hub_id":"fly-persisted-id"} -->
<!-- livebook:{"hub_id":"team-persisted-id"} -->
# My Notebook
"""
{notebook, []} = Import.notebook_from_livemd(markdown)
assert %Notebook{name: "My Notebook", hub_id: "fly-persisted-id"} = notebook
assert %Notebook{name: "My Notebook", hub_id: "team-persisted-id"} = notebook
end
test "imports ignores hub id when does not exist" do

View file

@ -78,12 +78,14 @@ defmodule Livebook.TeamsTest do
)
%{
org: %{id: id, name: name, keys: [%{id: org_key_id}]},
user: %{id: user_id},
sessions: [%{token: token}]
} = org_request.user_org
token: token,
user_org: %{
org: %{id: id, name: name, keys: [%{id: org_key_id}]},
user: %{id: user_id}
}
} = org_request.user_org_session
assert Teams.get_org_request_completion_data(org) ==
assert Teams.get_org_request_completion_data(org, org_request.device_code) ==
{:ok,
%{
"id" => id,
@ -108,12 +110,13 @@ defmodule Livebook.TeamsTest do
user_code: org_request.user_code
)
assert Teams.get_org_request_completion_data(org) == {:ok, :awaiting_confirmation}
assert Teams.get_org_request_completion_data(org, org_request.device_code) ==
{:ok, :awaiting_confirmation}
end
test "returns error when org request doesn't exist" do
org = build(:org, id: 0)
assert {:transport_error, _embarrassing} = Teams.get_org_request_completion_data(org)
assert {:transport_error, _embarrassing} = Teams.get_org_request_completion_data(org, "")
end
test "returns error when org request expired", %{node: node} do
@ -135,7 +138,8 @@ defmodule Livebook.TeamsTest do
user_code: org_request.user_code
)
assert Teams.get_org_request_completion_data(org) == {:error, :expired}
assert Teams.get_org_request_completion_data(org, org_request.device_code) ==
{:error, :expired}
end
end
end

View file

@ -1,168 +0,0 @@
defmodule Livebook.WebSocket.ClientConnectionTest do
use Livebook.EnterpriseIntegrationCase, async: true
@moduletag :capture_log
alias Livebook.WebSocket.ClientConnection
describe "connect" do
test "successfully authenticates the websocket connection", %{url: url, token: token} do
headers = [{"X-Auth-Token", token}]
assert {:ok, _conn} = ClientConnection.start_link(self(), url, headers)
assert_receive {:connect, :ok, :connected}
end
test "rejects the websocket with invalid address", %{token: token} do
headers = [{"X-Auth-Token", token}]
assert {:ok, _conn} = ClientConnection.start_link(self(), "http://localhost:9999", headers)
assert_receive {:connect, :error, "connection refused"}
end
test "rejects the websocket connection with invalid credentials", %{url: url} do
headers = [{"X-Auth-Token", "foo"}]
assert {:ok, _conn} = ClientConnection.start_link(self(), url, headers)
assert_receive {:connect, :error, reason}
assert reason =~ "the given token is invalid"
assert {:ok, _conn} = ClientConnection.start_link(self(), url)
assert_receive {:connect, :error, reason}
assert reason =~ "could not get the token from the connection"
end
end
describe "send_request/2" do
setup %{url: url, token: token} do
headers = [{"X-Auth-Token", token}]
{:ok, conn} = ClientConnection.start_link(self(), url, headers)
assert_receive {:connect, :ok, :connected}
{:ok, conn: conn}
end
test "successfully sends a session request", %{conn: conn, user: %{id: id, email: email}} do
data = LivebookProto.build_handshake_request(app_version: Livebook.Config.app_version())
assert {:handshake, handshake_response} = ClientConnection.send_request(conn, data)
assert %{id: _, user: %{id: ^id, email: ^email}} = handshake_response
end
test "successfully sends a create secret message", %{conn: conn} do
data = LivebookProto.build_create_secret_request(name: "MY_USERNAME", value: "Jake Peralta")
assert {:create_secret, _} = ClientConnection.send_request(conn, data)
end
test "sends a create secret message, but receive a changeset error", %{conn: conn} do
data = LivebookProto.build_create_secret_request(name: "MY_USERNAME", value: "")
assert {:changeset_error, errors} = ClientConnection.send_request(conn, data)
assert "can't be blank" in errors.value
end
end
describe "reconnect event" do
setup %{test: name} do
start_new_instance(name)
url = EnterpriseServer.url(name)
token = EnterpriseServer.token(name)
headers = [{"X-Auth-Token", token}]
assert {:ok, conn} = ClientConnection.start_link(self(), url, headers)
assert_receive {:connect, :ok, :connected}
on_exit(fn -> stop_new_instance(name) end)
{:ok, conn: conn}
end
test "receives the disconnect message from websocket server", %{conn: conn, test: name} do
EnterpriseServer.disconnect(name)
assert_receive {:connect, :error, "socket closed"}
assert_receive {:connect, :error, "connection refused"}
assert Process.alive?(conn)
end
test "reconnects after websocket server is up", %{test: name} do
EnterpriseServer.disconnect(name)
assert_receive {:connect, :error, "socket closed"}
assert_receive {:connect, :error, "connection refused"}
Process.sleep(1000)
# Wait until the server is up again
assert EnterpriseServer.reconnect(name) == :ok
assert_receive {:connect, :ok, :connected}, 5000
end
end
describe "handle events from server" do
setup %{url: url, token: token} do
headers = [{"X-Auth-Token", token}]
{:ok, conn} = ClientConnection.start_link(self(), url, headers)
assert_receive {:connect, :ok, :connected}
{:ok, conn: conn}
end
test "receives a secret_created event", %{node: node} do
name = "MY_SECRET_ID"
value = Livebook.Utils.random_id()
:erpc.call(node, Enterprise.Integration, :create_secret, [name, value])
assert_receive {:event, :secret_created, %{name: ^name, value: ^value}}
end
test "receives a secret_updated event", %{node: node} do
name = "API_USERNAME"
value = "JakePeralta"
secret = :erpc.call(node, Enterprise.Integration, :create_secret, [name, value])
assert_receive {:event, :secret_created, %{name: ^name, value: ^value}}
new_value = "ChonkyCat"
:erpc.call(node, Enterprise.Integration, :update_secret, [secret, new_value])
assert_receive {:event, :secret_updated, %{name: ^name, value: ^new_value}}
end
test "receives a secret_deleted event", %{node: node} do
name = "DELETE_ME"
value = "JakePeralta"
secret = :erpc.call(node, Enterprise.Integration, :create_secret, [name, value])
assert_receive {:event, :secret_created, %{name: ^name, value: ^value}}
:erpc.call(node, Enterprise.Integration, :delete_secret, [secret])
assert_receive {:event, :secret_deleted, %{name: ^name, value: ^value}}
end
test "receives a user_synchronized event", %{conn: conn, node: node} do
data = LivebookProto.build_handshake_request(app_version: Livebook.Config.app_version())
assert {:handshake, _} = ClientConnection.send_request(conn, data)
id = :erpc.call(node, Enterprise.Integration, :fetch_env!, ["ENTERPRISE_ID"])
name = :erpc.call(node, Enterprise.Integration, :fetch_env!, ["ENTERPRISE_NAME"])
assert_receive {:event, :user_synchronized, %{id: ^id, name: ^name, secrets: []}}
secret = :erpc.call(node, Enterprise.Integration, :create_secret, ["SESSION", "123"])
assert_receive {:event, :secret_created, %{name: "SESSION", value: "123"}}
assert {:handshake, _} = ClientConnection.send_request(conn, data)
assert_receive {:event, :user_synchronized, %{id: ^id, name: ^name, secrets: secrets}}
assert LivebookProto.Secret.new!(name: secret.name, value: secret.value) in secrets
end
end
end

View file

@ -206,13 +206,13 @@ defmodule LivebookWeb.HomeLiveTest do
end
test "render persisted hubs", %{conn: conn} do
fly = insert_hub(:fly, id: "fly-foo-bar-id")
team = insert_hub(:team, id: "team-foo-bar-id")
{:ok, _view, html} = live(conn, ~p"/")
assert html =~ "HUBS"
assert html =~ fly.hub_name
assert html =~ team.hub_name
Livebook.Hubs.delete_hub("fly-foo-bar-id")
Livebook.Hubs.delete_hub("team-foo-bar-id")
end
end

View file

@ -71,15 +71,17 @@ defmodule LivebookWeb.Hub.EditLiveTest do
|> render_submit(attrs)
assert_receive {:secret_created, ^secret}
assert_patch(view, "/hub/#{hub.id}")
assert render(view) =~ "Secret created successfully"
assert render(view) =~ secret.name
assert render(element(view, "#hub-secrets-list")) =~ secret.name
assert secret in Livebook.Hubs.get_secrets(hub)
end
test "updates secret", %{conn: conn, hub: hub} do
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
secret = insert_secret(name: "PERSONAL_EDIT_SECRET", value: "GetTheBonk")
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
attrs = %{
secret: %{
name: secret.name,
@ -112,15 +114,17 @@ defmodule LivebookWeb.Hub.EditLiveTest do
updated_secret = %{secret | value: new_value}
assert_receive {:secret_updated, ^updated_secret}
assert_patch(view, "/hub/#{hub.id}")
assert render(view) =~ "Secret updated successfully"
assert render(view) =~ secret.name
assert render(element(view, "#hub-secrets-list")) =~ secret.name
assert updated_secret in Livebook.Hubs.get_secrets(hub)
end
test "deletes secret", %{conn: conn, hub: hub} do
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
secret = insert_secret(name: "PERSONAL_DELETE_SECRET", value: "GetTheBonk")
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
refute view
|> element("#secrets-form button[disabled]")
|> has_element?()

View file

@ -1,168 +0,0 @@
defmodule LivebookWeb.Hub.New.EnterpriseComponentTest do
use Livebook.EnterpriseIntegrationCase, async: true
@moduletag :capture_log
import Phoenix.LiveViewTest
alias Livebook.Hubs
describe "enterprise" do
test "persists new hub", %{conn: conn, url: url, token: token} do
node = EnterpriseServer.get_node()
id = :erpc.call(node, Enterprise.Integration, :fetch_env!, ["ENTERPRISE_ID"])
Livebook.Hubs.delete_hub("enterprise-#{id}")
{:ok, view, _html} = live(conn, ~p"/hub")
assert view
|> element("#enterprise")
|> render_click() =~ "2. Configure your Hub"
view
|> element("#enterprise-form")
|> render_change(%{
"enterprise" => %{
"url" => url,
"token" => token
}
})
view
|> element("#connect")
|> render_click()
assert render(view) =~ to_string(id)
attrs = %{
"url" => url,
"token" => token,
"hub_name" => "Enterprise",
"hub_emoji" => "🐈"
}
view
|> element("#enterprise-form")
|> render_change(%{"enterprise" => attrs})
refute view
|> element("#enterprise-form .invalid-feedback")
|> has_element?()
result =
view
|> element("#enterprise-form")
|> render_submit(%{"enterprise" => attrs})
assert {:ok, view, _html} = follow_redirect(result, conn)
assert render(view) =~ "Hub added successfully"
hubs_html = view |> element("#hubs") |> render()
assert hubs_html =~ "🐈"
assert hubs_html =~ "/hub/enterprise-#{id}"
assert hubs_html =~ "Enterprise"
end
test "fails with invalid token", %{test: name, conn: conn} do
start_new_instance(name)
url = EnterpriseServer.url(name)
{:ok, view, _html} = live(conn, ~p"/hub")
token = "foo bar baz"
assert view
|> element("#enterprise")
|> render_click() =~ "2. Configure your Hub"
view
|> element("#enterprise-form")
|> render_change(%{
"enterprise" => %{
"url" => url,
"token" => token
}
})
view
|> element("#connect")
|> render_click()
assert render(view) =~ "the given token is invalid"
refute render(view) =~ "enterprise[hub_name]"
after
stop_new_instance(name)
end
test "fails to create existing hub", %{test: name, conn: conn} do
start_new_instance(name)
node = EnterpriseServer.get_node(name)
url = EnterpriseServer.url(name)
token = EnterpriseServer.token(name)
id = :erpc.call(node, Enterprise.Integration, :fetch_env!, ["ENTERPRISE_ID"])
user = :erpc.call(node, Enterprise.Integration, :create_user, [])
another_token =
:erpc.call(node, Enterprise.Integration, :generate_user_session_token!, [user])
hub =
insert_hub(:enterprise,
id: "enterprise-#{id}",
external_id: id,
url: url,
token: another_token
)
{:ok, view, _html} = live(conn, ~p"/hub")
assert view
|> element("#enterprise")
|> render_click() =~ "2. Configure your Hub"
view
|> element("#enterprise-form")
|> render_change(%{
"enterprise" => %{
"url" => url,
"token" => token
}
})
view
|> element("#connect")
|> render_click()
assert render(view) =~ to_string(id)
attrs = %{
"url" => url,
"token" => token,
"hub_name" => "Enterprise",
"hub_emoji" => "🐈"
}
view
|> element("#enterprise-form")
|> render_change(%{"enterprise" => attrs})
refute view
|> element("#enterprise-form .invalid-feedback")
|> has_element?()
assert view
|> element("#enterprise-form")
|> render_submit(%{"enterprise" => attrs}) =~ "already exists"
hubs_html = view |> element("#hubs") |> render()
assert hubs_html =~ hub.hub_emoji
assert hubs_html =~ ~p"/hub/#{hub.id}"
assert hubs_html =~ hub.hub_name
assert Hubs.fetch_hub!(hub.id) == hub
after
stop_new_instance(name)
end
end
end

View file

@ -4,7 +4,6 @@ defmodule LivebookWeb.Hub.NewLiveTest do
alias Livebook.Teams.Org
import Phoenix.LiveViewTest
@check_completion_data_interval 5000
test "render hub selection cards", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/hub")
@ -17,9 +16,6 @@ defmodule LivebookWeb.Hub.NewLiveTest do
describe "new-org" do
test "persist a new hub", %{conn: conn, node: node, user: user} do
name = "new-org-test"
teams_key = Livebook.Teams.Org.teams_key()
key_hash = Org.key_hash(build(:org, teams_key: teams_key))
path = ~p"/hub/team-#{name}"
{:ok, view, _html} = live(conn, ~p"/hub")
@ -29,7 +25,7 @@ defmodule LivebookWeb.Hub.NewLiveTest do
|> render_click()
# builds the form data
attrs = %{"org" => %{"name" => name, "teams_key" => teams_key, "emoji" => "🐈"}}
attrs = %{"org" => %{"name" => name, "emoji" => "🐈"}}
# finds the form and change data
form = element(view, "#new-org-form")
@ -38,11 +34,8 @@ defmodule LivebookWeb.Hub.NewLiveTest do
# submits the form
render_submit(form, attrs)
# gets the org request by name and key hash
org_request =
:erpc.call(node, Hub.Integration, :get_org_request_by!, [
[name: name, key_hash: key_hash]
])
# gets the org request by name
org_request = :erpc.call(node, Hub.Integration, :get_org_request_by!, [[name: name]])
# check if the form has the url to confirm
link_element = element(view, "#new-org-form a")
@ -55,14 +48,18 @@ defmodule LivebookWeb.Hub.NewLiveTest do
# check if the page redirected to edit hub page
# and check the flash message
%{"success" => "Hub added successfully"} =
assert_redirect(view, path, @check_completion_data_interval)
assert_redirect(view, "/hub/team-#{name}?show-key=true", check_completion_data_interval())
# access the page and shows the teams key modal
{:ok, view, _html} = live(conn, "/hub/team-#{name}?show-key=true")
assert has_element?(view, "#show-key-modal")
# access the page when closes the modal
assert {:ok, view, _html} = live(conn, "/hub/team-#{name}")
refute has_element?(view, "#show-key-modal")
# checks if the hub is in the sidebar
{:ok, view, _html} = live(conn, path)
hubs_html = view |> element("#hubs") |> render()
assert hubs_html =~ "🐈"
assert hubs_html =~ path
assert hubs_html =~ name
assert_hub(view, "/hub/team-#{name}", name)
end
end
@ -71,7 +68,6 @@ defmodule LivebookWeb.Hub.NewLiveTest do
name = "join-org-test"
teams_key = Livebook.Teams.Org.teams_key()
key_hash = Org.key_hash(build(:org, teams_key: teams_key))
path = ~p"/hub/team-#{name}"
{:ok, view, _html} = live(conn, ~p"/hub")
@ -112,14 +108,28 @@ defmodule LivebookWeb.Hub.NewLiveTest do
# check if the page redirected to edit hub page
# and check the flash message
%{"success" => "Hub added successfully"} =
assert_redirect(view, path, @check_completion_data_interval)
assert_redirect(view, "/hub/team-#{name}?show-key=true", check_completion_data_interval())
# access the page and shows the teams key modal
{:ok, view, _html} = live(conn, "/hub/team-#{name}?show-key=true")
assert has_element?(view, "#show-key-modal")
# access the page when closes the modal
assert {:ok, view, _html} = live(conn, "/hub/team-#{name}")
refute has_element?(view, "#show-key-modal")
# checks if the hub is in the sidebar
{:ok, view, _html} = live(conn, path)
hubs_html = view |> element("#hubs") |> render()
assert hubs_html =~ "🐈"
assert hubs_html =~ path
assert hubs_html =~ name
assert_hub(view, "/hub/team-#{name}", name)
end
end
defp check_completion_data_interval(), do: 2000
defp assert_hub(view, path, name, emoji \\ "🐈") do
hubs_html = view |> element("#hubs") |> render()
assert hubs_html =~ emoji
assert hubs_html =~ path
assert hubs_html =~ name
end
end

View file

@ -1,200 +0,0 @@
defmodule LivebookWeb.SessionLive.SecretsComponentTest do
use Livebook.EnterpriseIntegrationCase, async: true
import Livebook.SessionHelpers
import Phoenix.LiveViewTest
alias Livebook.Session
alias Livebook.Sessions
describe "enterprise" do
setup %{test: name} do
start_new_instance(name)
node = EnterpriseServer.get_node(name)
url = EnterpriseServer.url(name)
token = EnterpriseServer.token(name)
id = :erpc.call(node, Enterprise.Integration, :fetch_env!, ["ENTERPRISE_ID"])
hub_id = "enterprise-#{id}"
Livebook.Hubs.subscribe([:connection, :secrets])
Livebook.Hubs.delete_hub(hub_id)
enterprise =
insert_hub(:enterprise,
id: hub_id,
external_id: id,
url: url,
token: token
)
{:ok, session} = Sessions.create_session(notebook: Livebook.Notebook.new())
Session.set_notebook_hub(session.pid, hub_id)
on_exit(fn ->
Livebook.Hubs.delete_hub(hub_id)
Session.close(session.pid)
stop_new_instance(name)
end)
{:ok, enterprise: enterprise, session: session, node: node}
end
test "creates a secret on Enterprise hub",
%{conn: conn, session: session, enterprise: enterprise} do
id = enterprise.id
secret =
build(:secret, name: "BIG_IMPORTANT_SECRET", value: "123", hub_id: id, readonly: true)
{:ok, view, _html} = live(conn, ~p"/sessions/#{session.id}/secrets")
attrs = %{
secret: %{
name: secret.name,
value: secret.value,
hub_id: enterprise.id
}
}
form = element(view, ~s{form[phx-submit="save"]})
render_change(form, attrs)
render_submit(form, attrs)
assert_receive {:secret_created, ^secret}
assert has_element?(view, "#hub-#{enterprise.id}-secret-#{secret.name}")
end
test "toggle a secret from Enterprise hub",
%{conn: conn, session: session, enterprise: enterprise, node: node} do
secret =
build(:secret,
name: "POSTGRES_PASSWORD",
value: "postgres",
hub_id: enterprise.id,
readonly: true
)
{:ok, view, _html} = live(conn, ~p"/sessions/#{session.id}")
:erpc.call(node, Enterprise.Integration, :create_secret, [secret.name, secret.value])
assert_receive {:secret_created, ^secret}
Session.set_secret(session.pid, secret)
assert_session_secret(view, session.pid, secret)
end
test "adding a missing secret using 'Add secret' button",
%{conn: conn, session: session, enterprise: enterprise} do
secret =
build(:secret,
name: "PGPASS",
value: "postgres",
hub_id: enterprise.id,
readonly: true
)
# Subscribe and executes the code to trigger
# the `System.EnvError` exception and outputs the 'Add secret' button
Session.subscribe(session.id)
section_id = insert_section(session.pid)
code = ~s{System.fetch_env!("LB_#{secret.name}")}
cell_id = insert_text_cell(session.pid, section_id, :code, code)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation, {:add_cell_evaluation_response, _, ^cell_id, _, _}}
# Enters the session to check if the button exists
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
expected_url = ~p"/sessions/#{session.id}/secrets?secret_name=#{secret.name}"
add_secret_button = element(view, "a[href='#{expected_url}']")
assert has_element?(add_secret_button)
# Clicks the button and fills the form to create a new secret
# that prefilled the name with the received from exception.
render_click(add_secret_button)
secrets_component = with_target(view, "#secrets-modal")
form_element = element(secrets_component, "form[phx-submit='save']")
assert has_element?(form_element)
attrs = %{value: secret.value, hub_id: enterprise.id}
render_submit(form_element, %{secret: attrs})
# Checks we received the secret created event from Enterprise
assert_receive {:secret_created, ^secret}
# Checks if the secret is persisted
assert secret in Livebook.Hubs.get_secrets()
# Checks if the secret exists and is inside the session,
# then executes the code cell again and checks if the
# secret value is what we expected.
assert_session_secret(view, session.pid, secret)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, output}, _}}
assert output == "\e[32m\"#{secret.value}\"\e[0m"
end
test "granting access for missing secret using 'Add secret' button",
%{conn: conn, session: session, enterprise: enterprise, node: node} do
secret =
build(:secret,
name: "MYSQL_PASS",
value: "admin",
hub_id: enterprise.id,
readonly: true
)
# Subscribe and executes the code to trigger
# the `System.EnvError` exception and outputs the 'Add secret' button
Session.subscribe(session.id)
section_id = insert_section(session.pid)
code = ~s{System.fetch_env!("LB_#{secret.name}")}
cell_id = insert_text_cell(session.pid, section_id, :code, code)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation, {:add_cell_evaluation_response, _, ^cell_id, _, _}}
# Enters the session to check if the button exists
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
expected_url = ~p"/sessions/#{session.id}/secrets?secret_name=#{secret.name}"
add_secret_button = element(view, "a[href='#{expected_url}']")
assert has_element?(add_secret_button)
# Persist the secret from the Enterprise
:erpc.call(node, Enterprise.Integration, :create_secret, [secret.name, secret.value])
# Grant we receive the event, even with eventually delay
assert_receive {:secret_created, ^secret}, 10_000
# Checks if the secret is persisted
assert secret in Livebook.Hubs.get_secrets()
# Clicks the button and checks if the 'Grant access' banner
# is being shown, so clicks it's button to set the app secret
# to the session, allowing the user to fetches the secret.
render_click(add_secret_button)
secrets_component = with_target(view, "#secrets-modal")
assert render(secrets_component) =~
"in #{hub_label(enterprise)}. Allow this session to access it?"
grant_access_button = element(secrets_component, "button", "Grant access")
render_click(grant_access_button)
# Checks if the secret exists and is inside the session,
# then executes the code cell again and checks if the
# secret value is what we expected.
assert_session_secret(view, session.pid, secret)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, output}, _}}
assert output == "\e[32m\"#{secret.value}\"\e[0m"
end
end
end

View file

@ -1479,7 +1479,7 @@ defmodule LivebookWeb.SessionLiveTest do
describe "hubs" do
test "selects the notebook hub", %{conn: conn, session: session} do
hub = insert_hub(:fly)
hub = insert_hub(:team)
id = hub.id
personal_id = Livebook.Hubs.Personal.id()

View file

@ -1,50 +0,0 @@
defmodule Livebook.EnterpriseIntegrationCase do
use ExUnit.CaseTemplate
alias Livebook.EnterpriseServer
using do
quote do
use LivebookWeb.ConnCase
@moduletag :enterprise_integration
import Livebook.EnterpriseIntegrationCase,
only: [start_new_instance: 1, stop_new_instance: 1]
alias Livebook.EnterpriseServer
end
end
setup_all do
case EnterpriseServer.start() do
{:ok, _} -> :ok
{:error, {:already_started, _}} -> :ok
end
{:ok,
url: EnterpriseServer.url(),
token: EnterpriseServer.token(),
user: EnterpriseServer.user(),
node: EnterpriseServer.get_node()}
end
def start_new_instance(name) do
suffix = Ecto.UUID.generate() |> :erlang.phash2() |> to_string()
app_port = Enum.random(1000..9000) |> to_string()
{:ok, _} =
EnterpriseServer.start(name,
env: %{
"DATABASE_URL" =>
"postgres://postgres:postgres@localhost:5432/enterprise_integration_#{suffix}"
},
app_port: app_port
)
end
def stop_new_instance(name) do
EnterpriseServer.disconnect(name)
EnterpriseServer.drop_database(name)
end
end

View file

@ -9,38 +9,21 @@ defmodule Livebook.Factory do
}
end
def build(:fly_metadata) do
:fly |> build() |> Livebook.Hubs.Provider.to_metadata()
def build(:team_metadata) do
:team |> build() |> Livebook.Hubs.Provider.to_metadata()
end
def build(:fly) do
%Livebook.Hubs.Fly{
id: "fly-foo-bar-baz",
hub_name: "My Personal Hub",
hub_emoji: "🚀",
access_token: Livebook.Utils.random_cookie(),
organization_id: Livebook.Utils.random_id(),
organization_type: "PERSONAL",
organization_name: "Foo",
application_id: "foo-bar-baz"
}
end
def build(:team) do
org = build(:org)
def build(:enterprise_metadata) do
:enterprise |> build() |> Livebook.Hubs.Provider.to_metadata()
end
def build(:enterprise) do
name = "Enteprise #{Livebook.Utils.random_short_id()}"
%Livebook.Hubs.Enterprise{
id: "enterprise-#{name}",
hub_name: name,
%Livebook.Hubs.Team{
id: "team-#{org.name}",
hub_name: org.name,
hub_emoji: "🏭",
org_id: 1,
user_id: 1,
org_key_id: 1,
teams_key: Livebook.Utils.random_id(),
teams_key: org.teams_key,
session_token: Livebook.Utils.random_cookie()
}
end

View file

@ -1,307 +0,0 @@
defmodule Livebook.EnterpriseServer do
@moduledoc false
use GenServer
defstruct [:token, :user, :node, :port, :app_port, :url, :env]
@name __MODULE__
@timeout 10_000
@default_enterprise_dir "../enterprise"
def available?() do
System.get_env("ENTERPRISE_PATH") != nil or File.exists?(@default_enterprise_dir)
end
def start(name \\ @name, opts \\ []) do
GenServer.start(__MODULE__, opts, name: name)
end
def url(name \\ @name) do
GenServer.call(name, :fetch_url, @timeout)
end
def token(name \\ @name) do
GenServer.call(name, :fetch_token, @timeout)
end
def user(name \\ @name) do
GenServer.call(name, :fetch_user, @timeout)
end
def get_node(name \\ @name) do
GenServer.call(name, :fetch_node, @timeout)
end
def drop_database(name \\ @name) do
app_port = GenServer.call(name, :fetch_port)
state_env = GenServer.call(name, :fetch_env)
app_port |> env(state_env) |> mix(["ecto.drop", "--quiet"])
end
def reconnect(name \\ @name) do
GenServer.cast(name, :reconnect)
end
def disconnect(name \\ @name) do
GenServer.cast(name, :disconnect)
end
# GenServer Callbacks
@impl true
def init(opts) do
state = struct!(__MODULE__, opts)
{:ok, %{state | node: enterprise_node()}, {:continue, :start_enterprise}}
end
@impl true
def handle_continue(:start_enterprise, state) do
ensure_app_dir!()
prepare_database(state)
{:noreply, %{state | port: start_enterprise(state)}}
end
@impl true
def handle_call(:fetch_token, _from, state) do
state = if state.token, do: state, else: create_enterprise_token(state)
{:reply, state.token, state}
end
@impl true
def handle_call(:fetch_user, _from, state) do
state = if state.user, do: state, else: create_enterprise_user(state)
{:reply, state.user, state}
end
@impl true
def handle_call(:fetch_url, _from, state) do
state = if state.app_port, do: state, else: %{state | app_port: app_port()}
url = state.url || fetch_url(state)
{:reply, url, %{state | url: url}}
end
def handle_call(:fetch_node, _from, state) do
{:reply, state.node, state}
end
def handle_call(:fetch_port, _from, state) do
port = state.app_port || app_port()
{:reply, port, state}
end
def handle_call(:fetch_env, _from, state) do
{:reply, state.env, state}
end
@impl true
def handle_cast(:reconnect, state) do
if state.port do
{:noreply, state}
else
{:noreply, %{state | port: start_enterprise(state)}}
end
end
def handle_cast(:disconnect, state) do
if state.port do
Port.close(state.port)
end
{:noreply, %{state | port: nil}}
end
# Port Callbacks
@impl true
def handle_info({_port, {:data, message}}, state) do
info(message)
{:noreply, state}
end
def handle_info({_port, {:exit_status, status}}, _state) do
error("enterprise quit with status #{status}")
System.halt(status)
end
# Private
defp create_enterprise_token(state) do
if user = state.user do
token = call_erpc_function(state.node, :generate_user_session_token!, [user])
%{state | token: token}
else
user = call_erpc_function(state.node, :create_user)
token = call_erpc_function(state.node, :generate_user_session_token!, [user])
%{state | user: user, token: token}
end
end
defp create_enterprise_user(state) do
%{state | user: call_erpc_function(state.node, :create_user)}
end
defp call_erpc_function(node, function, args \\ []) do
:erpc.call(node, Enterprise.Integration, function, args)
end
defp start_enterprise(state) do
env =
for {key, value} <- env(state), into: [] do
{String.to_charlist(key), String.to_charlist(value)}
end
args = [
"-e",
"spawn(fn -> IO.gets([]) && System.halt(0) end)",
"--sname",
to_string(state.node),
"--cookie",
to_string(Node.get_cookie()),
"-S",
"mix",
"phx.server"
]
port =
Port.open({:spawn_executable, elixir_executable()}, [
:exit_status,
:use_stdio,
:stderr_to_stdout,
:binary,
:hide,
env: env,
cd: app_dir(),
args: args
])
wait_on_start(state, port)
end
defp fetch_url(state) do
port = state.app_port || app_port()
"http://localhost:#{port}"
end
defp prepare_database(state) do
:ok = mix(state, ["ecto.drop", "--quiet"])
:ok = mix(state, ["ecto.create", "--quiet"])
:ok = mix(state, ["ecto.migrate", "--quiet"])
end
defp ensure_app_dir! do
dir = app_dir()
unless File.exists?(dir) do
IO.puts(
"Unable to find #{dir}, make sure to clone the enterprise repository " <>
"into it to run integration tests or set ENTERPRISE_PATH to its location"
)
System.halt(1)
end
end
defp app_dir do
System.get_env("ENTERPRISE_PATH", @default_enterprise_dir)
end
defp app_port do
System.get_env("ENTERPRISE_PORT", "4043")
end
defp debug do
System.get_env("ENTERPRISE_DEBUG", "false")
end
defp proto do
System.get_env("ENTERPRISE_LIVEBOOK_PROTO_PATH")
end
defp wait_on_start(state, port) do
url = state.url || fetch_url(state)
case :httpc.request(:get, {~c"#{url}/public/health", []}, [], []) do
{:ok, _} ->
port
{:error, _} ->
Process.sleep(10)
wait_on_start(state, port)
end
end
defp mix(state, args) when is_struct(state) do
state |> env() |> mix(args)
end
defp mix(env, args) do
cmd_opts = [
stderr_to_stdout: true,
env: env,
cd: app_dir(),
into: IO.stream(:stdio, :line)
]
args = ["--erl", "-elixir ansi_enabled true", "-S", "mix" | args]
case System.cmd(elixir_executable(), args, cmd_opts) do
{_, 0} -> :ok
_ -> :error
end
end
defp env(state) do
app_port = state.app_port || app_port()
env(app_port, state.env)
end
defp env(app_port, state_env) do
env = %{
"MIX_ENV" => "livebook",
"PORT" => to_string(app_port),
"DEBUG" => debug()
}
env = if proto(), do: Map.merge(env, %{"LIVEBOOK_PROTO_PATH" => proto()}), else: env
if state_env do
Map.merge(env, state_env)
else
env
end
end
defp elixir_executable do
System.find_executable("elixir")
end
defp enterprise_node do
:"enterprise_#{Livebook.Utils.random_short_id()}@#{hostname()}"
end
defp hostname do
[nodename, hostname] =
node()
|> Atom.to_charlist()
|> :string.split(~c"@")
with {:ok, nodenames} <- :erl_epmd.names(hostname),
true <- List.keymember?(nodenames, nodename, 0) do
hostname
else
_ ->
raise "Error"
end
end
defp info(message), do: log([:blue, message <> "\n"])
defp error(message), do: log([:red, message <> "\n"])
defp log(data), do: data |> IO.ANSI.format() |> IO.write()
end

View file

@ -55,7 +55,6 @@ ExUnit.start(
assert_receive_timeout: if(windows?, do: 2_500, else: 1_500),
exclude: [
erl_docs: erl_docs_available?,
enterprise_integration: not Livebook.EnterpriseServer.available?(),
teams_integration: not Livebook.TeamsServer.available?()
]
)