mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
Add Enterprise Hub (#1449)
This commit is contained in:
parent
5c053a9573
commit
19773a0e36
|
@ -2,7 +2,7 @@ defmodule Livebook.Hubs do
|
|||
@moduledoc false
|
||||
|
||||
alias Livebook.Storage
|
||||
alias Livebook.Hubs.{Fly, Local, Metadata, Provider}
|
||||
alias Livebook.Hubs.{Enterprise, Fly, Local, Metadata, Provider}
|
||||
|
||||
@namespace :hubs
|
||||
|
||||
|
@ -105,6 +105,10 @@ defmodule Livebook.Hubs do
|
|||
Provider.load(%Fly{}, fields)
|
||||
end
|
||||
|
||||
defp to_struct(%{id: "enterprise-" <> _} = fields) do
|
||||
Provider.load(%Enterprise{}, fields)
|
||||
end
|
||||
|
||||
defp to_struct(%{id: "local-" <> _} = fields) do
|
||||
Provider.load(%Local{}, fields)
|
||||
end
|
||||
|
|
130
lib/livebook/hubs/enterprise.ex
Normal file
130
lib/livebook/hubs/enterprise.ex
Normal file
|
@ -0,0 +1,130 @@
|
|||
defmodule Livebook.Hubs.Enterprise do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Livebook.Hubs
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: Livebook.Utils.id(),
|
||||
url: String.t(),
|
||||
token: String.t(),
|
||||
external_id: String.t(),
|
||||
hub_name: String.t(),
|
||||
hub_color: String.t()
|
||||
}
|
||||
|
||||
embedded_schema do
|
||||
field :url, :string
|
||||
field :token, :string
|
||||
field :external_id, :string
|
||||
field :hub_name, :string
|
||||
field :hub_color, Livebook.EctoTypes.HexColor
|
||||
end
|
||||
|
||||
@fields ~w(
|
||||
url
|
||||
token
|
||||
external_id
|
||||
hub_name
|
||||
hub_color
|
||||
)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
|
||||
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(:external_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__{} = 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(:external_id, "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, :external_id) do
|
||||
nil -> changeset
|
||||
external_id -> put_change(changeset, :id, "enterprise-#{external_id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Enterprise do
|
||||
def load(%Livebook.Hubs.Enterprise{} = enterprise, fields) do
|
||||
%{
|
||||
enterprise
|
||||
| id: fields.id,
|
||||
url: fields.url,
|
||||
token: fields.token,
|
||||
external_id: fields.external_id,
|
||||
hub_name: fields.hub_name,
|
||||
hub_color: fields.hub_color
|
||||
}
|
||||
end
|
||||
|
||||
def normalize(%Livebook.Hubs.Enterprise{} = enterprise) do
|
||||
%Livebook.Hubs.Metadata{
|
||||
id: enterprise.id,
|
||||
name: enterprise.hub_name,
|
||||
provider: enterprise,
|
||||
color: enterprise.hub_color
|
||||
}
|
||||
end
|
||||
|
||||
def type(_), do: "enterprise"
|
||||
end
|
62
lib/livebook/hubs/enterprise_client.ex
Normal file
62
lib/livebook/hubs/enterprise_client.ex
Normal file
|
@ -0,0 +1,62 @@
|
|||
defmodule Livebook.Hubs.EnterpriseClient do
|
||||
@moduledoc false
|
||||
|
||||
alias Livebook.Utils.HTTP
|
||||
|
||||
@path "/api/v1"
|
||||
|
||||
def fetch_info(url, token) do
|
||||
query = """
|
||||
query {
|
||||
info {
|
||||
id
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
with {:ok, %{"info" => info}} <- graphql(url, token, query) do
|
||||
{:ok, info}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_me(url, token) do
|
||||
query = """
|
||||
query {
|
||||
me {
|
||||
id
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
with {:ok, %{"me" => me}} <- graphql(url, token, query) do
|
||||
{:ok, me}
|
||||
end
|
||||
end
|
||||
|
||||
defp graphql(url, token, query, input \\ %{}) do
|
||||
headers = [{"Authorization", "Bearer #{token}"}]
|
||||
body = {"application/json", Jason.encode!(%{query: query, variables: input})}
|
||||
|
||||
case HTTP.request(:post, graphql_endpoint(url), headers: headers, body: body) do
|
||||
{:ok, 200, _, body} ->
|
||||
case Jason.decode!(body) do
|
||||
%{"errors" => [%{"message" => "invalid_token"}]} ->
|
||||
{:error, "request failed with invalid token", :invalid_token}
|
||||
|
||||
%{"errors" => [%{"message" => "unauthorized"}]} ->
|
||||
{:error, "request failed with unauthorized", :unauthorized}
|
||||
|
||||
%{"errors" => [%{"message" => message}]} ->
|
||||
{:error, "request failed with message: #{message}", :other}
|
||||
|
||||
%{"data" => data} ->
|
||||
{:ok, data}
|
||||
end
|
||||
|
||||
{:error, {:failed_connect, _}} ->
|
||||
{:error, "request failed to connect", :invalid_url}
|
||||
end
|
||||
end
|
||||
|
||||
defp graphql_endpoint(url), do: url <> @path
|
||||
end
|
81
lib/livebook_web/live/hub/edit/enterprise_component.ex
Normal file
81
lib/livebook_web/live/hub/edit/enterprise_component.ex
Normal file
|
@ -0,0 +1,81 @@
|
|||
defmodule LivebookWeb.Hub.Edit.EnterpriseComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
alias Livebook.EctoTypes.HexColor
|
||||
alias Livebook.Hubs.Enterprise
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
changeset = Enterprise.change_hub(assigns.hub)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(changeset: changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={"#{@id}-component"}>
|
||||
<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={@changeset}
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
phx-target={@myself}
|
||||
phx-debounce="blur"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-1 gap-3">
|
||||
<.input_wrapper form={f} field={:hub_color} class="flex flex-col space-y-1">
|
||||
<div class="input-label">Color</div>
|
||||
<.hex_color_input
|
||||
form={f}
|
||||
field={:hub_color}
|
||||
randomize={JS.push("randomize_color", target: @myself)}
|
||||
/>
|
||||
</.input_wrapper>
|
||||
</div>
|
||||
|
||||
<%= submit("Update Hub",
|
||||
class: "button-base button-blue",
|
||||
phx_disable_with: "Updating...",
|
||||
disabled: not @changeset.valid?
|
||||
) %>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("randomize_color", _, socket) do
|
||||
handle_event("validate", %{"enterprise" => %{"hub_color" => HexColor.random()}}, socket)
|
||||
end
|
||||
|
||||
def handle_event("save", %{"enterprise" => params}, socket) do
|
||||
case Enterprise.update_hub(socket.assigns.hub, params) do
|
||||
{:ok, hub} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:success, "Hub updated successfully")
|
||||
|> push_redirect(to: Routes.hub_path(socket, :edit, hub.id))}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"enterprise" => attrs}, socket) do
|
||||
{:noreply, assign(socket, changeset: Enterprise.change_hub(socket.assigns.hub, attrs))}
|
||||
end
|
||||
end
|
|
@ -50,6 +50,14 @@ defmodule LivebookWeb.Hub.EditLive do
|
|||
env_var_id={@env_var_id}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<%= if @type == "enterprise" do %>
|
||||
<.live_component
|
||||
module={LivebookWeb.Hub.Edit.EnterpriseComponent}
|
||||
hub={@hub}
|
||||
id="enterprise-form"
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
</LayoutHelpers.layout>
|
||||
"""
|
||||
|
|
157
lib/livebook_web/live/hub/new/enterprise_component.ex
Normal file
157
lib/livebook_web/live/hub/new/enterprise_component.ex
Normal file
|
@ -0,0 +1,157 @@
|
|||
defmodule LivebookWeb.Hub.New.EnterpriseComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
import Ecto.Changeset, only: [get_field: 2]
|
||||
|
||||
alias Livebook.EctoTypes.HexColor
|
||||
alias Livebook.Hubs.{Enterprise, EnterpriseClient}
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(
|
||||
base: %Enterprise{},
|
||||
changeset: Enterprise.change_hub(%Enterprise{}),
|
||||
connected: false
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.form
|
||||
:let={f}
|
||||
id={@id}
|
||||
class="flex flex-col space-y-4"
|
||||
for={@changeset}
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
phx-target={@myself}
|
||||
phx-debounce="blur"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<.input_wrapper form={f} field={:url} class="flex flex-col space-y-1">
|
||||
<div class="input-label">URL</div>
|
||||
<%= text_input(f, :url,
|
||||
class: "input w-full phx-form-error:border-red-300",
|
||||
autofocus: true,
|
||||
spellcheck: "false",
|
||||
autocomplete: "off"
|
||||
) %>
|
||||
</.input_wrapper>
|
||||
|
||||
<.input_wrapper form={f} field={:token} class="flex flex-col space-y-1">
|
||||
<div class="input-label">Token</div>
|
||||
<%= password_input(f, :token,
|
||||
value: get_field(@changeset, :token),
|
||||
class: "input w-full phx-form-error:border-red-300",
|
||||
spellcheck: "false",
|
||||
autocomplete: "off"
|
||||
) %>
|
||||
</.input_wrapper>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="connect"
|
||||
type="button"
|
||||
phx-click="connect"
|
||||
phx-target={@myself}
|
||||
class="button-base button-blue"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
|
||||
<%= if @connected do %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-1">
|
||||
<.input_wrapper form={f} field={:external_id} class="flex flex-col space-y-1">
|
||||
<div class="input-label">ID</div>
|
||||
<%= text_input(f, :external_id, class: "input", disabled: true) %>
|
||||
</.input_wrapper>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<.input_wrapper form={f} field={:hub_name} class="flex flex-col space-y-1">
|
||||
<div class="input-label">Name</div>
|
||||
<%= text_input(f, :hub_name, class: "input", readonly: true) %>
|
||||
</.input_wrapper>
|
||||
|
||||
<.input_wrapper form={f} field={:hub_color} class="flex flex-col space-y-1">
|
||||
<div class="input-label">Color</div>
|
||||
<.hex_color_input
|
||||
form={f}
|
||||
field={:hub_color}
|
||||
randomize={JS.push("randomize_color", target: @myself)}
|
||||
/>
|
||||
</.input_wrapper>
|
||||
</div>
|
||||
|
||||
<%= submit("Add Hub",
|
||||
class: "button-base button-blue",
|
||||
phx_disable_with: "Add...",
|
||||
disabled: not @changeset.valid?
|
||||
) %>
|
||||
<% end %>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("connect", _params, socket) do
|
||||
url = get_field(socket.assigns.changeset, :url)
|
||||
token = get_field(socket.assigns.changeset, :token)
|
||||
|
||||
with {:ok, _info} <- EnterpriseClient.fetch_info(url, token),
|
||||
{:ok, %{"id" => id}} <- EnterpriseClient.fetch_me(url, token) do
|
||||
base = %Enterprise{
|
||||
token: token,
|
||||
url: url,
|
||||
external_id: id,
|
||||
hub_name: "Enterprise",
|
||||
hub_color: HexColor.random()
|
||||
}
|
||||
|
||||
changeset = Enterprise.change_hub(base)
|
||||
|
||||
{:noreply, assign(socket, changeset: changeset, base: base, connected: true)}
|
||||
else
|
||||
{:error, _message, reason} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, message_from_reason(reason))
|
||||
|> push_patch(to: Routes.hub_path(socket, :new))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("randomize_color", _, socket) do
|
||||
handle_event("validate", %{"enterprise" => %{"hub_color" => HexColor.random()}}, socket)
|
||||
end
|
||||
|
||||
def handle_event("save", %{"enterprise" => params}, socket) do
|
||||
if socket.assigns.changeset.valid? do
|
||||
case Enterprise.create_hub(socket.assigns.base, params) do
|
||||
{:ok, hub} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:success, "Hub added successfully")
|
||||
|> push_redirect(to: Routes.hub_path(socket, :edit, hub.id))}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"enterprise" => attrs}, socket) do
|
||||
{:noreply, assign(socket, changeset: Enterprise.change_hub(socket.assigns.base, attrs))}
|
||||
end
|
||||
|
||||
defp message_from_reason(:invalid_url), do: "Failed to connect with given URL"
|
||||
defp message_from_reason(:unauthorized), do: "Failed to authorize with given token"
|
||||
defp message_from_reason(:invalid_token), do: "Failed to authenticate with given token"
|
||||
end
|
|
@ -11,6 +11,9 @@ defmodule LivebookWeb.Hub.NewLive do
|
|||
{:ok, assign(socket, selected_type: nil, page_title: "Livebook - Hub")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
@ -69,13 +72,7 @@ defmodule LivebookWeb.Hub.NewLive do
|
|||
<% end %>
|
||||
|
||||
<%= if @selected_type == "enterprise" do %>
|
||||
<div>
|
||||
Livebook Enterprise is currently in closed beta. If you want to learn more, <a
|
||||
href="https://livebook.dev/#livebook-plans"
|
||||
class="pointer-events-auto text-blue-600"
|
||||
target="_blank"
|
||||
>click here</a>.
|
||||
</div>
|
||||
<.live_component module={LivebookWeb.Hub.New.EnterpriseComponent} id="enterprise-form" />
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
25
test/livebook/hubs/enterprise_client_test.exs
Normal file
25
test/livebook/hubs/enterprise_client_test.exs
Normal file
|
@ -0,0 +1,25 @@
|
|||
defmodule Livebook.Hubs.EnterpriseClientTest do
|
||||
use Livebook.EnterpriseIntegrationCase, async: true
|
||||
|
||||
alias Livebook.Hubs.EnterpriseClient
|
||||
|
||||
describe "fetch_info/1" do
|
||||
test "fetches the token info", %{url: url, token: token} do
|
||||
assert {:ok, %{"id" => _}} = EnterpriseClient.fetch_info(url, token)
|
||||
end
|
||||
|
||||
test "returns invalid_token when token is invalid", %{url: url} do
|
||||
assert {:error, _, :invalid_token} = EnterpriseClient.fetch_info(url, "foo")
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_me/1" do
|
||||
test "fetches the current user id", %{url: url, token: token} do
|
||||
assert {:ok, %{"id" => _}} = EnterpriseClient.fetch_me(url, token)
|
||||
end
|
||||
|
||||
test "returns unauthorized when token is invalid", %{url: url} do
|
||||
assert {:error, _, :unauthorized} = EnterpriseClient.fetch_me(url, "foo")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,13 +10,17 @@ defmodule LivebookWeb.Hub.EditLiveTest do
|
|||
Hubs.clean_hubs()
|
||||
end)
|
||||
|
||||
bypass = Bypass.open()
|
||||
Application.put_env(:livebook, :fly_graphql_endpoint, "http://localhost:#{bypass.port}")
|
||||
|
||||
{:ok, bypass: bypass}
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "fly" do
|
||||
setup do
|
||||
bypass = Bypass.open()
|
||||
Application.put_env(:livebook, :fly_graphql_endpoint, "http://localhost:#{bypass.port}")
|
||||
|
||||
{:ok, bypass: bypass}
|
||||
end
|
||||
|
||||
test "updates hub", %{conn: conn, bypass: bypass} do
|
||||
{:ok, pid} = Agent.start(fn -> %{fun: &fetch_app_response/2, type: :mount} end)
|
||||
|
||||
|
@ -55,18 +59,7 @@ defmodule LivebookWeb.Hub.EditLiveTest do
|
|||
|
||||
assert render(view) =~ "Hub updated successfully"
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ ~s/style="color: #FF00FF"/
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ Routes.hub_path(conn, :edit, hub.id)
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ "Personal Hub"
|
||||
|
||||
assert_hub(view, conn, %{hub | hub_color: attrs["hub_color"], hub_name: attrs["hub_name"]})
|
||||
refute Hubs.fetch_hub!(hub.id) == hub
|
||||
end
|
||||
|
||||
|
@ -197,6 +190,42 @@ defmodule LivebookWeb.Hub.EditLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "enterprise" do
|
||||
test "updates hub", %{conn: conn} do
|
||||
hub = insert_hub(:enterprise)
|
||||
{:ok, view, _html} = live(conn, Routes.hub_path(conn, :edit, hub.id))
|
||||
|
||||
attrs = %{"hub_color" => "#FF00FF"}
|
||||
|
||||
view
|
||||
|> element("#enterprise-form")
|
||||
|> render_change(%{"enterprise" => attrs})
|
||||
|
||||
refute view
|
||||
|> element("#enterprise-form .invalid-feedback")
|
||||
|> has_element?()
|
||||
|
||||
assert {:ok, view, _html} =
|
||||
view
|
||||
|> element("#enterprise-form")
|
||||
|> render_submit(%{"enterprise" => attrs})
|
||||
|> follow_redirect(conn)
|
||||
|
||||
assert render(view) =~ "Hub updated successfully"
|
||||
|
||||
assert_hub(view, conn, %{hub | hub_color: attrs["hub_color"]})
|
||||
refute Hubs.fetch_hub!(hub.id) == hub
|
||||
end
|
||||
end
|
||||
|
||||
defp assert_hub(view, conn, hub) do
|
||||
hubs_html = view |> element("#hubs") |> render()
|
||||
|
||||
assert hubs_html =~ ~s/style="color: #{hub.hub_color}"/
|
||||
assert hubs_html =~ Routes.hub_path(conn, :edit, hub.id)
|
||||
assert hubs_html =~ hub.hub_name
|
||||
end
|
||||
|
||||
defp fly_bypass(bypass, app_id, agent_pid) do
|
||||
Bypass.expect(bypass, "POST", "/", fn conn ->
|
||||
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
||||
|
|
114
test/livebook_web/live/hub/new/enterprise_component_test.exs
Normal file
114
test/livebook_web/live/hub/new/enterprise_component_test.exs
Normal file
|
@ -0,0 +1,114 @@
|
|||
defmodule LivebookWeb.Hub.New.EnterpriseComponentTest do
|
||||
use Livebook.EnterpriseIntegrationCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Livebook.Hubs
|
||||
|
||||
describe "enterprise" do
|
||||
test "persists new hub", %{conn: conn, url: url, token: token} do
|
||||
{:ok, view, _html} = live(conn, Routes.hub_path(conn, :new))
|
||||
|
||||
assert view
|
||||
|> element("#enterprise")
|
||||
|> render_click() =~ "2. Configure your Hub"
|
||||
|
||||
view
|
||||
|> element("#enterprise-form")
|
||||
|> render_change(%{
|
||||
"enterprise" => %{
|
||||
"url" => url,
|
||||
"token" => token
|
||||
}
|
||||
})
|
||||
|
||||
assert view
|
||||
|> element("#connect")
|
||||
|> render_click() =~ "Add Hub"
|
||||
|
||||
attrs = %{
|
||||
"url" => url,
|
||||
"token" => token,
|
||||
"hub_name" => "Enterprise",
|
||||
"hub_color" => "#FF00FF"
|
||||
}
|
||||
|
||||
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 =~ ~s/style="color: #FF00FF"/
|
||||
assert hubs_html =~ "/hub/enterprise-bf1587a3-4501-4729-9f53-43679381e28b"
|
||||
assert hubs_html =~ "Enterprise"
|
||||
end
|
||||
|
||||
test "fails to create existing hub", %{conn: conn, url: url, token: token} do
|
||||
hub =
|
||||
insert_hub(:enterprise,
|
||||
id: "enterprise-bf1587a3-4501-4729-9f53-43679381e28b",
|
||||
external_id: "bf1587a3-4501-4729-9f53-43679381e28b",
|
||||
url: url,
|
||||
token: token
|
||||
)
|
||||
|
||||
{:ok, view, _html} = live(conn, Routes.hub_path(conn, :new))
|
||||
|
||||
assert view
|
||||
|> element("#enterprise")
|
||||
|> render_click() =~ "2. Configure your Hub"
|
||||
|
||||
view
|
||||
|> element("#enterprise-form")
|
||||
|> render_change(%{
|
||||
"enterprise" => %{
|
||||
"url" => url,
|
||||
"token" => token
|
||||
}
|
||||
})
|
||||
|
||||
assert view
|
||||
|> element("#connect")
|
||||
|> render_click() =~ "Add Hub"
|
||||
|
||||
attrs = %{
|
||||
"url" => url,
|
||||
"token" => token,
|
||||
"hub_name" => "Enterprise",
|
||||
"hub_color" => "#FFFFFF"
|
||||
}
|
||||
|
||||
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 =~ ~s/style="color: #{hub.hub_color}"/
|
||||
assert hubs_html =~ Routes.hub_path(conn, :edit, hub.id)
|
||||
assert hubs_html =~ hub.hub_name
|
||||
|
||||
assert Hubs.fetch_hub!(hub.id) == hub
|
||||
end
|
||||
end
|
||||
end
|
|
@ -18,7 +18,7 @@ defmodule LivebookWeb.Hub.NewLiveTest do
|
|||
end
|
||||
|
||||
describe "fly" do
|
||||
test "persists fly", %{conn: conn} do
|
||||
test "persists new hub", %{conn: conn} do
|
||||
fly_bypass("123456789")
|
||||
|
||||
{:ok, view, _html} = live(conn, Routes.hub_path(conn, :new))
|
||||
|
@ -118,6 +118,124 @@ defmodule LivebookWeb.Hub.NewLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "enterprise" do
|
||||
test "persists new hub", %{conn: conn} do
|
||||
id = Livebook.Utils.random_short_id()
|
||||
bypass = enterprise_bypass(id)
|
||||
|
||||
{:ok, view, _html} = live(conn, Routes.hub_path(conn, :new))
|
||||
|
||||
assert view
|
||||
|> element("#enterprise")
|
||||
|> render_click() =~ "2. Configure your Hub"
|
||||
|
||||
view
|
||||
|> element("#enterprise-form")
|
||||
|> render_change(%{
|
||||
"enterprise" => %{
|
||||
"url" => "http://localhost:#{bypass.port}",
|
||||
"token" => "dummy access token"
|
||||
}
|
||||
})
|
||||
|
||||
assert view
|
||||
|> element("#connect")
|
||||
|> render_click() =~ "Add Hub"
|
||||
|
||||
attrs = %{
|
||||
"url" => "http://localhost:#{bypass.port}",
|
||||
"token" => "dummy access token",
|
||||
"hub_name" => "Enterprise",
|
||||
"hub_color" => "#FF00FF"
|
||||
}
|
||||
|
||||
view
|
||||
|> element("#enterprise-form")
|
||||
|> render_change(%{"enterprise" => attrs})
|
||||
|
||||
refute view
|
||||
|> element("#enterprise-form .invalid-feedback")
|
||||
|> has_element?()
|
||||
|
||||
assert {:ok, view, _html} =
|
||||
view
|
||||
|> element("#enterprise-form")
|
||||
|> render_submit(%{"enterprise" => attrs})
|
||||
|> follow_redirect(conn)
|
||||
|
||||
assert render(view) =~ "Hub added successfully"
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ ~s/style="color: #FF00FF"/
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ "/hub/enterprise-#{id}"
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ "Enterprise"
|
||||
end
|
||||
|
||||
test "fails to create existing hub", %{conn: conn} do
|
||||
hub = insert_hub(:enterprise, id: "enterprise-foo", external_id: "foo")
|
||||
bypass = enterprise_bypass(hub.external_id)
|
||||
|
||||
{:ok, view, _html} = live(conn, Routes.hub_path(conn, :new))
|
||||
|
||||
assert view
|
||||
|> element("#enterprise")
|
||||
|> render_click() =~ "2. Configure your Hub"
|
||||
|
||||
view
|
||||
|> element("#enterprise-form")
|
||||
|> render_change(%{
|
||||
"enterprise" => %{
|
||||
"url" => "http://localhost:#{bypass.port}",
|
||||
"token" => "dummy access token"
|
||||
}
|
||||
})
|
||||
|
||||
assert view
|
||||
|> element("#connect")
|
||||
|> render_click() =~ "Add Hub"
|
||||
|
||||
attrs = %{
|
||||
"url" => "http://localhost:#{bypass.port}",
|
||||
"token" => "dummy access token",
|
||||
"hub_name" => "Enterprise",
|
||||
"hub_color" => "#FF00FF"
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ ~s/style="color: #{hub.hub_color}"/
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ Routes.hub_path(conn, :edit, hub.id)
|
||||
|
||||
assert view
|
||||
|> element("#hubs")
|
||||
|> render() =~ hub.hub_name
|
||||
|
||||
assert Hubs.fetch_hub!(hub.id) == hub
|
||||
end
|
||||
end
|
||||
|
||||
defp fly_bypass(app_id) do
|
||||
bypass = Bypass.open()
|
||||
Application.put_env(:livebook, :fly_graphql_endpoint, "http://localhost:#{bypass.port}")
|
||||
|
@ -138,6 +256,37 @@ defmodule LivebookWeb.Hub.NewLiveTest do
|
|||
end)
|
||||
end
|
||||
|
||||
defp enterprise_bypass(id) do
|
||||
bypass = Bypass.open()
|
||||
|
||||
Bypass.expect(bypass, "POST", "/api/v1", fn conn ->
|
||||
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
||||
body = Jason.decode!(body)
|
||||
|
||||
response =
|
||||
cond do
|
||||
body["query"] =~ "info" ->
|
||||
%{
|
||||
"data" => %{
|
||||
"info" => %{
|
||||
"id" => Livebook.Utils.random_short_id(),
|
||||
"expire_at" => to_string(DateTime.utc_now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body["query"] =~ "me" ->
|
||||
%{"data" => %{"me" => %{"id" => id}}}
|
||||
end
|
||||
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.resp(200, Jason.encode!(response))
|
||||
end)
|
||||
|
||||
bypass
|
||||
end
|
||||
|
||||
defp fetch_apps_response(app_id) do
|
||||
app = %{
|
||||
"id" => app_id,
|
||||
|
|
21
test/support/enterprise_integration_case.ex
Normal file
21
test/support/enterprise_integration_case.ex
Normal file
|
@ -0,0 +1,21 @@
|
|||
defmodule Livebook.EnterpriseIntegrationCase do
|
||||
use ExUnit.CaseTemplate
|
||||
|
||||
alias Livebook.EnterpriseServer
|
||||
|
||||
using do
|
||||
quote do
|
||||
use LivebookWeb.ConnCase
|
||||
|
||||
@moduletag :enterprise_integration
|
||||
|
||||
alias Livebook.EnterpriseServer
|
||||
end
|
||||
end
|
||||
|
||||
setup_all do
|
||||
EnterpriseServer.start()
|
||||
|
||||
{:ok, url: EnterpriseServer.url(), token: EnterpriseServer.token()}
|
||||
end
|
||||
end
|
|
@ -31,6 +31,30 @@ defmodule Livebook.Factory do
|
|||
}
|
||||
end
|
||||
|
||||
def build(:enterprise_metadata) do
|
||||
id = Livebook.Utils.random_short_id()
|
||||
|
||||
%Livebook.Hubs.Metadata{
|
||||
id: "enterprise-#{id}",
|
||||
name: "Enterprise",
|
||||
color: "#FF00FF",
|
||||
provider: build(:enterprise)
|
||||
}
|
||||
end
|
||||
|
||||
def build(:enterprise) do
|
||||
id = Livebook.Utils.random_short_id()
|
||||
|
||||
%Livebook.Hubs.Enterprise{
|
||||
id: "enterprise-#{id}",
|
||||
hub_name: "Enterprise",
|
||||
hub_color: "#FF0000",
|
||||
external_id: id,
|
||||
token: Livebook.Utils.random_cookie(),
|
||||
url: "http://localhost"
|
||||
}
|
||||
end
|
||||
|
||||
def build(:env_var) do
|
||||
%Livebook.Settings.EnvVar{
|
||||
name: "BAR",
|
||||
|
|
189
test/support/integration/enterprise_server.ex
Normal file
189
test/support/integration/enterprise_server.ex
Normal file
|
@ -0,0 +1,189 @@
|
|||
defmodule Livebook.EnterpriseServer do
|
||||
@moduledoc false
|
||||
use GenServer
|
||||
|
||||
@name __MODULE__
|
||||
|
||||
def start do
|
||||
GenServer.start(__MODULE__, [], name: @name)
|
||||
end
|
||||
|
||||
def url do
|
||||
"http://localhost:#{app_port()}"
|
||||
end
|
||||
|
||||
def token do
|
||||
GenServer.call(@name, :fetch_token)
|
||||
end
|
||||
|
||||
# GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
{:ok, %{token: nil, node: enterprise_node(), port: nil}, {:continue, :start_enterprise}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_continue(:start_enterprise, state) do
|
||||
{:noreply, %{state | port: start_enterprise(state)}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:fetch_token, _from, state) do
|
||||
token = state.token || fetch_token_from_enterprise(state)
|
||||
|
||||
{:reply, token, %{state | token: token}}
|
||||
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 fetch_token_from_enterprise(state) do
|
||||
:erpc.call(state.node, Enterprise.Integration, :fetch_token, [])
|
||||
end
|
||||
|
||||
defp start_enterprise(state) do
|
||||
ensure_app_dir!()
|
||||
prepare_database()
|
||||
|
||||
env = [
|
||||
{~c"MIX_ENV", ~c"livebook"},
|
||||
{~c"LIVEBOOK_ENTERPRISE_PORT", String.to_charlist(app_port())}
|
||||
]
|
||||
|
||||
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(port)
|
||||
end
|
||||
|
||||
defp prepare_database do
|
||||
mix(["ecto.drop", "--quiet"])
|
||||
mix(["ecto.create", "--quiet"])
|
||||
mix(["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", "../enterprise")
|
||||
end
|
||||
|
||||
defp app_port do
|
||||
System.get_env("ENTERPRISE_PORT", "4043")
|
||||
end
|
||||
|
||||
defp wait_on_start(port) do
|
||||
case :httpc.request(:get, {~c"#{url()}/public/health", []}, [], []) do
|
||||
{:ok, _} ->
|
||||
port
|
||||
|
||||
{:error, _} ->
|
||||
Process.sleep(10)
|
||||
wait_on_start(port)
|
||||
end
|
||||
end
|
||||
|
||||
defp mix(args, opts \\ []) do
|
||||
env = [
|
||||
{"MIX_ENV", "livebook"},
|
||||
{"LIVEBOOK_ENTERPRISE_PORT", app_port()}
|
||||
]
|
||||
|
||||
cmd_opts = [stderr_to_stdout: true, env: env, cd: app_dir()]
|
||||
args = ["--erl", "-elixir ansi_enabled true", "-S", "mix" | args]
|
||||
|
||||
cmd_opts =
|
||||
if opts[:with_return],
|
||||
do: cmd_opts,
|
||||
else: Keyword.put(cmd_opts, :into, IO.stream(:stdio, :line))
|
||||
|
||||
if opts[:with_return] do
|
||||
case System.cmd(elixir_executable(), args, cmd_opts) do
|
||||
{result, 0} ->
|
||||
result
|
||||
|
||||
{message, status} ->
|
||||
error("""
|
||||
|
||||
#{message}\
|
||||
""")
|
||||
|
||||
System.halt(status)
|
||||
end
|
||||
else
|
||||
{_, 0} = System.cmd(elixir_executable(), args, cmd_opts)
|
||||
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
|
|
@ -41,7 +41,10 @@ Livebook.Storage.insert(:settings, "global", autosave_path: nil)
|
|||
|
||||
erl_docs_available? = Code.fetch_docs(:gen_server) != {:error, :chunk_not_found}
|
||||
|
||||
exclude = []
|
||||
exclude = if erl_docs_available?, do: exclude, else: Keyword.put(exclude, :erl_docs, true)
|
||||
enterprise_path = System.get_env("ENTERPRISE_PATH", "../enterprise")
|
||||
enterprise_available? = File.exists?(enterprise_path)
|
||||
|
||||
ExUnit.start(assert_receive_timeout: 1_500, exclude: exclude)
|
||||
ExUnit.start(
|
||||
assert_receive_timeout: 1_500,
|
||||
exclude: [erl_docs: erl_docs_available?, enterprise_integration: !enterprise_available?]
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue