diff --git a/lib/livebook/hubs.ex b/lib/livebook/hubs.ex index 234ac5047..abeedcb4a 100644 --- a/lib/livebook/hubs.ex +++ b/lib/livebook/hubs.ex @@ -2,7 +2,7 @@ defmodule Livebook.Hubs do @moduledoc false alias Livebook.Storage - alias Livebook.Hubs.{Broadcasts, Enterprise, Fly, Metadata, Personal, Provider} + alias Livebook.Hubs.{Broadcasts, Enterprise, Fly, Metadata, Personal, Provider, Team} alias Livebook.Secrets.Secret @namespace :hubs @@ -169,6 +169,10 @@ defmodule Livebook.Hubs do Provider.load(%Personal{}, fields) end + defp to_struct(%{id: "team-" <> _} = fields) do + Provider.load(%Team{}, fields) + end + @doc """ Connects to the all available and connectable hubs. diff --git a/lib/livebook/hubs/enterprise.ex b/lib/livebook/hubs/enterprise.ex index 619cd4bdc..a96bb8bb3 100644 --- a/lib/livebook/hubs/enterprise.ex +++ b/lib/livebook/hubs/enterprise.ex @@ -8,25 +8,31 @@ defmodule Livebook.Hubs.Enterprise do @type t :: %__MODULE__{ id: String.t() | nil, - url: String.t() | nil, - token: String.t() | nil, - external_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 :url, :string - field :token, :string - field :external_id, :string + 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( - url - token - external_id + org_id + user_id + org_key_id + teams_key + session_token hub_name hub_emoji )a @@ -63,7 +69,7 @@ defmodule Livebook.Hubs.Enterprise do if Hubs.hub_exists?(id) do {:error, changeset - |> add_error(:external_id, "already exists") + |> add_error(:hub_name, "already exists") |> Map.replace!(:action, :validate)} else with {:ok, struct} <- apply_action(changeset, :insert) do @@ -92,7 +98,7 @@ defmodule Livebook.Hubs.Enterprise do else {:error, changeset - |> add_error(:external_id, "does not exists") + |> add_error(:hub_name, "does not exists") |> Map.replace!(:action, :validate)} end end @@ -105,9 +111,9 @@ defmodule Livebook.Hubs.Enterprise do end defp add_id(changeset) do - case get_field(changeset, :external_id) do + case get_field(changeset, :hub_name) do nil -> changeset - external_id -> put_change(changeset, :id, "enterprise-#{external_id}") + hub_name -> put_change(changeset, :id, "enterprise-#{hub_name}") end end end @@ -119,9 +125,11 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Enterprise do %{ enterprise | id: fields.id, - url: fields.url, - token: fields.token, - external_id: fields.external_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 } diff --git a/lib/livebook/hubs/enterprise_client.ex b/lib/livebook/hubs/enterprise_client.ex index 692ae02e5..c16747a3d 100644 --- a/lib/livebook/hubs/enterprise_client.ex +++ b/lib/livebook/hubs/enterprise_client.ex @@ -82,9 +82,9 @@ defmodule Livebook.Hubs.EnterpriseClient do ## GenServer callbacks @impl true - def init(%Enterprise{url: url, token: token} = enterprise) do - headers = [{"X-Auth-Token", token}] - {:ok, pid} = ClientConnection.start_link(self(), url, headers) + 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 diff --git a/lib/livebook/hubs/team.ex b/lib/livebook/hubs/team.ex new file mode 100644 index 000000000..3fa2668ce --- /dev/null +++ b/lib/livebook/hubs/team.ex @@ -0,0 +1,110 @@ +defmodule Livebook.Hubs.Team do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @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__{} = team, attrs \\ %{}) do + changeset(team, attrs) + end + + defp changeset(team, attrs) do + team + |> cast(attrs, @fields) + |> validate_required(@fields) + |> add_id() + end + + defp add_id(changeset) do + if name = get_field(changeset, :hub_name) do + change(changeset, %{id: "team-#{name}"}) + else + changeset + end + end +end + +defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do + def load(team, fields) do + %{ + team + | 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(team) do + %Livebook.Hubs.Metadata{ + id: team.id, + name: team.hub_name, + provider: team, + emoji: team.hub_emoji, + connected?: false + } + end + + def type(_team), do: "team" + + def connection_spec(_team), do: nil + + def disconnect(_team), do: raise("not implemented") + + def capabilities(_team), do: [] + + def get_secrets(_team), do: [] + + def create_secret(_team, _secret), do: :ok + + def update_secret(_team, _secret), do: :ok + + def delete_secret(_team, _secret), do: :ok + + def connection_error(_team), 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 diff --git a/lib/livebook/teams.ex b/lib/livebook/teams.ex index 9a15b45c7..1cbb452bf 100644 --- a/lib/livebook/teams.ex +++ b/lib/livebook/teams.ex @@ -1,8 +1,12 @@ defmodule Livebook.Teams do @moduledoc false + alias Livebook.Hubs + alias Livebook.Hubs.Team alias Livebook.Teams.{Client, Org} + import Ecto.Changeset, only: [add_error: 3, apply_action: 2, apply_action!: 2, get_field: 2] + @doc """ Creates an Org. @@ -16,7 +20,7 @@ defmodule Livebook.Teams do def create_org(%Org{} = org, attrs) do changeset = Org.changeset(org, attrs) - with {:ok, %Org{} = org} <- Ecto.Changeset.apply_action(changeset, :insert), + with {:ok, %Org{} = org} <- apply_action(changeset, :insert), {:ok, response} <- Client.create_org(org) do {:ok, response} else @@ -60,12 +64,45 @@ defmodule Livebook.Teams do end end + @doc """ + Creates a Hub. + + It notifies interested processes about hub metadatas data change. + """ + @spec create_hub!(map()) :: Team.t() + def create_hub!(attrs) do + changeset = Team.change_hub(%Team{}, attrs) + team = apply_action!(changeset, :insert) + + Hubs.save_hub(team) + 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(Team.t(), map()) :: {:ok, Team.t()} | {:error, Ecto.Changeset.t()} + def update_hub(%Team{} = team, attrs) do + changeset = Team.change_hub(team, attrs) + id = get_field(changeset, :id) + + if Hubs.hub_exists?(id) do + with {:ok, struct} <- apply_action(changeset, :update) do + {:ok, Hubs.save_hub(struct)} + end + else + {:error, add_error(changeset, :hub_name, "does not exists")} + end + end + defp add_org_errors(%Ecto.Changeset{} = changeset, errors_map) do for {key, errors} <- errors_map, field <- String.to_atom(key), field in Org.__schema__(:fields), error <- errors, reduce: changeset, - do: (acc -> Ecto.Changeset.add_error(acc, field, error)) + do: (acc -> add_error(acc, field, error)) end end diff --git a/lib/livebook/teams/org.ex b/lib/livebook/teams/org.ex index 83ee98dec..e62cfa4a7 100644 --- a/lib/livebook/teams/org.ex +++ b/lib/livebook/teams/org.ex @@ -6,18 +6,22 @@ defmodule Livebook.Teams.Org do @type t :: %__MODULE__{ id: pos_integer() | nil, + emoji: String.t() | nil, name: String.t() | nil, teams_key: String.t() | nil, user_code: String.t() | nil } + @primary_key {:id, :id, autogenerate: false} embedded_schema do + field :emoji, :string field :name, :string field :teams_key, :string field :user_code, :string end - @fields ~w(id name teams_key user_code)a + @fields ~w(id emoji name teams_key user_code)a + @required_fields @fields -- ~w(id user_code)a @doc """ Generates a new teams key. @@ -30,7 +34,7 @@ defmodule Livebook.Teams.Org do org |> cast(attrs, @fields) |> generate_teams_key() - |> validate_required(@fields -- [:id]) + |> validate_required(@required_fields) end defp generate_teams_key(changeset) do diff --git a/lib/livebook_web/live/hub/edit/team_component.ex b/lib/livebook_web/live/hub/edit/team_component.ex new file mode 100644 index 000000000..149a0068c --- /dev/null +++ b/lib/livebook_web/live/hub/edit/team_component.ex @@ -0,0 +1,88 @@ +defmodule LivebookWeb.Hub.Edit.TeamComponent do + use LivebookWeb, :live_component + + alias Livebook.Hubs.Team + alias Livebook.Teams + alias LivebookWeb.LayoutHelpers + + @impl true + def update(assigns, socket) do + changeset = Team.change_hub(assigns.hub) + + {:ok, + socket + |> assign(assigns) + |> assign_form(changeset)} + end + + @impl true + def render(assigns) do + ~H""" +
<%= @title %>
@@ -151,8 +185,108 @@ defmodule LivebookWeb.Hub.NewLive do """ end + defp disabled_class(true), do: "opacity-30 pointer-events-none" + defp disabled_class(false), do: "" + + defp card_item_border_class(id, id), do: "border-gray-200" + defp card_item_border_class(_, _), do: "border-gray-100" + + defp card_item_class(id, id), do: "bg-gray-200" + defp card_item_class(_, _), do: "bg-gray-100" + @impl true - def handle_event("select_type", %{"value" => service}, socket) do - {:noreply, assign(socket, selected_type: service)} + def handle_event("select_option", %{"value" => option}, socket) do + {:noreply, + socket + |> assign(selected_option: option) + |> assign_form(option)} + end + + def handle_event("validate", %{"new_org" => attrs}, socket) do + changeset = + socket.assigns.org + |> Teams.change_org(attrs) + |> Map.replace!(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"new_org" => attrs}, socket) do + case Teams.create_org(socket.assigns.org, attrs) do + {:ok, response} -> + attrs = Map.merge(attrs, response) + changeset = Teams.change_org(socket.assigns.org, attrs) + org = Ecto.Changeset.apply_action!(changeset, :insert) + + Process.send_after(self(), :check_completion_data, @check_completion_data_internal) + + {:noreply, + socket + |> assign(requested_code: true, org: org, verification_uri: response["verification_uri"]) + |> assign_form(changeset)} + + {:error, changeset} -> + {:noreply, assign_form(socket, changeset)} + + {:transport_error, message} -> + {:noreply, put_flash(socket, :error, message)} + end + end + + @impl true + def handle_info(:check_completion_data, %{assigns: %{org: org}} = socket) do + case Teams.get_org_request_completion_data(org) do + {:ok, :awaiting_confirmation} -> + Process.send_after(self(), :check_completion_data, @check_completion_data_internal) + + {:noreply, socket} + + {:ok, %{"id" => _id, "session_token" => _session_token} = response} -> + hub = + Teams.create_hub!(%{ + org_id: response["id"], + user_id: response["user_id"], + org_key_id: response["org_key_id"], + session_token: response["session_token"], + teams_key: org.teams_key, + hub_name: org.name, + hub_emoji: org.emoji + }) + + {:noreply, + socket + |> put_flash(:success, "Hub added successfully") + |> push_navigate(to: ~p"/hub/#{hub.id}")} + + {:error, :expired} -> + changeset = Teams.change_org(org, %{user_code: nil}) + + {:noreply, + socket + |> assign(requested_code: false, org: org, verification_uri: nil) + |> put_flash( + :error, + "Oh no! Your org creation request expired, could you please try again?" + ) + |> assign_form(changeset)} + + {:transport_error, message} -> + {:noreply, put_flash(socket, :error, message)} + end + end + + def handle_info(_any, socket), do: {:noreply, socket} + + defp assign_form(socket, "new-org") do + org = %Org{emoji: "💡"} + changeset = Teams.change_org(org) + + socket + |> assign(org: org) + |> assign_form(changeset) + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, org_form: to_form(changeset, as: :new_org)) end end diff --git a/test/livebook_web/live/hub/new_live_test.exs b/test/livebook_web/live/hub/new_live_test.exs index f4ea3c8e8..c4307b6ef 100644 --- a/test/livebook_web/live/hub/new_live_test.exs +++ b/test/livebook_web/live/hub/new_live_test.exs @@ -1,166 +1,62 @@ defmodule LivebookWeb.Hub.NewLiveTest do - use LivebookWeb.ConnCase + use Livebook.TeamsIntegrationCase, async: true import Phoenix.LiveViewTest - alias Livebook.Hubs - test "render hub selection cards", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/hub") + {:ok, view, _html} = live(conn, ~p"/hub") - assert html =~ "Fly" - assert html =~ "Livebook Teams" + # shows the new options + assert has_element?(view, "#new-org") + assert has_element?(view, "#join-org") end - describe "fly" do - test "persists new hub", %{conn: conn} do - fly_bypass("123456789") + describe "new-org" do + test "persist a new hub", %{conn: conn, node: node, user: user} do + name = "New Org Test #{System.unique_integer([:positive])}" + teams_key = Livebook.Teams.Org.teams_key() {:ok, view, _html} = live(conn, ~p"/hub") + path = ~p"/hub/team-#{name}" + # select the new org option assert view - |> element("#fly") - |> render_click() =~ "2. Configure your Hub" + |> element("#new-org") + |> render_click() =~ "2. Create your Organization" - assert view - |> element(~s/input[name="fly[access_token]"]/) - |> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~ - ~s() + # builds the form data + org_attrs = %{"new_org" => %{"name" => name, "teams_key" => teams_key, "emoji" => "🐈"}} - attrs = %{ - "access_token" => "dummy access token", - "application_id" => "123456789", - "hub_name" => "My Foo Hub", - "hub_emoji" => "🐈" - } + # finds the form and change data + new_org_form = element(view, "#new-org-form") + render_change(new_org_form, org_attrs) - view - |> element("#fly-form") - |> render_change(%{"fly" => attrs}) + # submits the form + render_submit(new_org_form, org_attrs) - refute view - |> element("#fly-form .invalid-feedback") - |> has_element?() + # check if the form has the url to confirm + link_element = element(view, "#new-org-form a") + html = render(link_element) + parsed_html = Floki.parse_document!(html) + assert [url] = Floki.attribute(parsed_html, "href") + assert [_port, [org_request_id]] = Regex.scan(~r/(?<=\D|^)\d{1,4}(?=\D|$)/, url) + id = String.to_integer(org_request_id) - assert {:ok, view, _html} = - view - |> element("#fly-form") - |> render_submit(%{"fly" => attrs}) - |> follow_redirect(conn) + # force org request confirmation + org_request = :erpc.call(node, Hub.Integration, :get_org_request!, [id]) + :erpc.call(node, Hub.Integration, :confirm_org_request, [org_request, user]) - assert render(view) =~ "Hub added successfully" + # wait for the c:handle_info/2 cycle + # check if the page redirected to edit hub page + # and check the flash message + %{"success" => "Hub added successfully"} = assert_redirect(view, path, 1200) + # 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 =~ "/hub/fly-123456789" - assert hubs_html =~ "My Foo Hub" + assert hubs_html =~ path + assert hubs_html =~ name end - - test "fails to create existing hub", %{conn: conn} do - hub = insert_hub(:fly, id: "fly-foo", application_id: "foo") - fly_bypass(hub.application_id) - - {:ok, view, _html} = live(conn, ~p"/hub") - - assert view - |> element("#fly") - |> render_click() =~ "2. Configure your Hub" - - assert view - |> element(~s/input[name="fly[access_token]"]/) - |> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~ - ~s() - - attrs = %{ - "access_token" => "dummy access token", - "application_id" => "foo", - "hub_name" => "My Foo Hub", - "hub_emoji" => "🐈" - } - - view - |> element("#fly-form") - |> render_change(%{"fly" => attrs}) - - refute view - |> element("#fly-form .invalid-feedback") - |> has_element?() - - assert view - |> element("#fly-form") - |> render_submit(%{"fly" => attrs}) =~ "already exists" - - assert_hub(view, hub) - 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}") - - Bypass.expect(bypass, "POST", "/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - body = Jason.decode!(body) - - response = - cond do - body["query"] =~ "apps" -> fetch_apps_response(app_id) - body["query"] =~ "app" -> fetch_app_response(app_id) - end - - conn - |> Plug.Conn.put_resp_content_type("application/json") - |> Plug.Conn.resp(200, Jason.encode!(response)) - end) - end - - defp fetch_apps_response(app_id) do - app = %{ - "id" => app_id, - "organization" => %{ - "id" => "l3soyvjmvtmwtl6l2drnbfuvltipprge", - "name" => "Foo Bar", - "type" => "PERSONAL" - } - } - - %{"data" => %{"apps" => %{"nodes" => [app]}}} - end - - defp fetch_app_response(app_id) do - app = %{ - "id" => app_id, - "name" => app_id, - "hostname" => app_id <> ".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" => "LIVEBOOK_PASSWORD" - }, - %{ - "createdAt" => to_string(DateTime.utc_now()), - "digest" => to_string(Livebook.Utils.random_cookie()), - "id" => Livebook.Utils.random_short_id(), - "name" => "LIVEBOOK_SECRET_KEY_BASE" - } - ] - } - - %{"data" => %{"app" => app}} - end - - defp assert_hub(view, hub) do - 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 end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 12a4f0a55..e4a3d233c 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -31,15 +31,17 @@ defmodule Livebook.Factory do end def build(:enterprise) do - id = Livebook.Utils.random_id() + name = "Enteprise #{Livebook.Utils.random_short_id()}" %Livebook.Hubs.Enterprise{ - id: "enterprise-#{id}", - hub_name: "Enterprise", + id: "enterprise-#{name}", + hub_name: name, hub_emoji: "🏭", - external_id: id, - token: Livebook.Utils.random_cookie(), - url: "http://localhost" + org_id: 1, + user_id: 1, + org_key_id: 1, + teams_key: Livebook.Utils.random_id(), + session_token: Livebook.Utils.random_cookie() } end @@ -74,6 +76,7 @@ defmodule Livebook.Factory do def build(:org) do %Livebook.Teams.Org{ id: nil, + emoji: "🏭", name: "Org Name #{System.unique_integer([:positive])}", teams_key: Livebook.Teams.Org.teams_key(), user_code: "request"