defmodule Livebook.Teams.Requests do alias Livebook.FileSystem alias Livebook.FileSystems alias Livebook.Hubs.Team alias Livebook.Secrets.Secret alias Livebook.Teams @error_message "Something went wrong, try again later or please file a bug if it persists" @doc """ Send a request to Livebook Team API to create a new org. """ @spec create_org(Teams.Org.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def create_org(org) do post("/api/v1/org-request", %{name: org.name, key_hash: Teams.Org.key_hash(org)}) end @doc """ Send a request to Livebook Team API to join an org. """ @spec join_org(Teams.Org.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def join_org(org) do post("/api/v1/org-request/join", %{name: org.name, key_hash: Teams.Org.key_hash(org)}) end @doc """ Send a request to Livebook Team API to get an org request. """ @spec get_org_request_completion_data(pos_integer(), binary) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def get_org_request_completion_data(id, device_code) do get("/api/v1/org-request/#{id}?device_code=#{device_code}") end @doc """ Send a request to Livebook Team API to sign the given payload. """ @spec org_sign(Team.t(), String.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def org_sign(team, payload) do post("/api/v1/org/sign", %{payload: payload}, team) end @doc """ Send a request to Livebook Team API to create a secret. """ @spec create_secret(Team.t(), Secret.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def create_secret(team, %{deployment_group_id: nil} = secret) do secret_key = Teams.derive_key(team.teams_key) secret_value = Teams.encrypt(secret.value, secret_key) post("/api/v1/org/secrets", %{name: secret.name, value: secret_value}, team) end def create_secret(team, secret) do secret_key = Teams.derive_key(team.teams_key) secret_value = Teams.encrypt(secret.value, secret_key) params = %{ name: secret.name, value: secret_value, deployment_group_id: secret.deployment_group_id } post("/api/v1/org/deployment-groups/secrets", params, team) end @doc """ Send a request to Livebook Team API to update a secret. """ @spec update_secret(Team.t(), Secret.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def update_secret(team, %{deployment_group_id: nil} = secret) do secret_key = Teams.derive_key(team.teams_key) secret_value = Teams.encrypt(secret.value, secret_key) put("/api/v1/org/secrets", %{name: secret.name, value: secret_value}, team) end def update_secret(team, secret) do secret_key = Teams.derive_key(team.teams_key) secret_value = Teams.encrypt(secret.value, secret_key) params = %{ name: secret.name, value: secret_value, deployment_group_id: secret.deployment_group_id } put("/api/v1/org/deployment-groups/secrets", params, team) end @doc """ Send a request to Livebook Team API to delete a secret. """ @spec delete_secret(Team.t(), Secret.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def delete_secret(team, %{deployment_group_id: nil} = secret) do delete("/api/v1/org/secrets", %{name: secret.name}, team) end def delete_secret(team, secret) do params = %{name: secret.name, deployment_group_id: secret.deployment_group_id} delete("/api/v1/org/deployment-groups/secrets", params, team) end @doc """ Send a request to Livebook Team API to create a file system. """ @spec create_file_system(Team.t(), FileSystem.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def create_file_system(team, file_system) do secret_key = Teams.derive_key(team.teams_key) type = FileSystems.type(file_system) %{name: name} = FileSystem.external_metadata(file_system) attrs = FileSystem.dump(file_system) json = JSON.encode!(attrs) params = %{ name: name, type: to_string(type), value: Teams.encrypt(json, secret_key) } post("/api/v1/org/file-systems", params, team) end @doc """ Send a request to Livebook Team API to update a file system. """ @spec update_file_system(Team.t(), FileSystem.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def update_file_system(team, file_system) do secret_key = Teams.derive_key(team.teams_key) type = FileSystems.type(file_system) %{name: name} = FileSystem.external_metadata(file_system) attrs = FileSystem.dump(file_system) json = JSON.encode!(attrs) params = %{ id: file_system.external_id, name: name, type: to_string(type), value: Teams.encrypt(json, secret_key) } put("/api/v1/org/file-systems", params, team) end @doc """ Send a request to Livebook Team API to delete a file system. """ @spec delete_file_system(Team.t(), FileSystem.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def delete_file_system(team, file_system) do delete("/api/v1/org/file-systems", %{id: file_system.external_id}, team) end @doc """ Send a request to Livebook Team API to create a deployment group. """ @spec create_deployment_group(Team.t(), Teams.DeploymentGroup.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def create_deployment_group(team, deployment_group) do params = %{ name: deployment_group.name, mode: deployment_group.mode, clustering: deployment_group.clustering, url: deployment_group.url } post("/api/v1/org/deployment-groups", params, team) end @doc """ Send a request to Livebook Team API to deploy an app. """ @spec deploy_app(Team.t(), Teams.AppDeployment.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def deploy_app(team, app_deployment) do secret_key = Teams.derive_key(team.teams_key) params = %{ title: app_deployment.title, slug: app_deployment.slug, multi_session: app_deployment.multi_session, access_type: app_deployment.access_type, deployment_group_id: app_deployment.deployment_group_id, sha: app_deployment.sha } encrypted_content = Teams.encrypt(app_deployment.file, secret_key) upload("/api/v1/org/apps", encrypted_content, params, team) end @doc """ Send a request to Livebook Team API to download an app revision. """ @spec download_revision(Team.t(), Teams.AppDeployment.t()) :: {:ok, binary()} | {:error, map() | String.t()} | {:transport_error, String.t()} def download_revision(team, app_deployment) do params = %{id: app_deployment.id, deployment_group_id: app_deployment.deployment_group_id} get("/api/v1/org/apps", params, team) end @doc """ Send a request to Livebook Team API to create a new auth request. """ @spec create_auth_request(Team.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def create_auth_request(team) do post("/api/v1/org/identity", %{}, team) end @doc """ Send a request to Livebook Team API to get the access token from given auth request code. """ @spec retrieve_access_token(Team.t(), String.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def retrieve_access_token(team, code) do post("/api/v1/org/identity/token", %{code: code}, team) end @doc """ Send a request to Livebook Team API to get the user information from given access token. """ @spec get_user_info(Team.t(), String.t()) :: {:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()} def get_user_info(team, access_token) do get("/api/v1/org/identity", %{access_token: access_token}, team) end @doc """ Normalizes errors map into errors for the given schema. """ @spec to_error_list(module(), %{String.t() => list(String.t())}) :: list({atom(), list(String.t())}) def to_error_list(struct, errors_map) do fields = struct.__schema__(:fields) |> MapSet.new() for {key, errors} <- errors_map, field = String.to_atom(key), field in fields, do: {field, errors} end @doc false def error_message(), do: @error_message defp post(path, json, team \\ nil) do build_req() |> add_team_auth(team) |> request(method: :post, url: path, json: json) |> dispatch_messages(team) end defp put(path, json, team) do build_req() |> add_team_auth(team) |> request(method: :put, url: path, json: json) |> dispatch_messages(team) end defp delete(path, json, team) do build_req() |> add_team_auth(team) |> request(method: :delete, url: path, json: json) |> dispatch_messages(team) end defp get(path, params \\ %{}, team \\ nil) do build_req() |> add_team_auth(team) |> request(method: :get, url: path, params: params) end defp upload(path, content, params, team) do build_req() |> add_team_auth(team) |> Req.Request.put_header("content-length", "#{byte_size(content)}") |> request(method: :post, url: path, params: params, body: content) |> dispatch_messages(team) end defp build_req() do base_url = URI.new!(Livebook.Config.teams_url()) options = if userinfo = base_url.userinfo do [ base_url: %{base_url | userinfo: nil}, auth: {:basic, userinfo} ] else [ base_url: base_url ] end Req.new([headers: [{"x-lb-version", Livebook.Config.app_version()}]] ++ options) |> Livebook.Utils.req_attach_defaults() end defp add_team_auth(req, nil), do: req defp add_team_auth(req, team) do if team.offline do Req.Request.append_request_steps(req, unauthorized: fn req -> {req, Req.Response.new(status: 401)} end ) else token = if team.user_id do "#{team.user_id}:#{team.org_id}:#{team.org_key_id}:#{team.session_token}" else "#{team.session_token}:#{Livebook.Config.agent_name()}:#{team.org_id}:#{team.org_key_id}" end Req.Request.merge_options(req, auth: {:bearer, token}) end end defp request(req, opts) do case Req.request(req, opts) do {:ok, %{status: 204, body: body}} -> {:ok, body} {:ok, %{status: status} = response} when status in 200..299 -> {:ok, response.body} {:ok, %{status: status} = response} when status in [410, 422] -> if json?(response), do: {:error, response.body}, else: {:transport_error, response.body} {:ok, %{status: 401}} -> {:transport_error, "You are not authorized to perform this action, make sure you have the access and you are not in a Livebook App Server/Offline instance"} _otherwise -> {:transport_error, @error_message} end end defp dispatch_messages({:ok, %{"messages" => _} = body}, %Livebook.Hubs.Team{} = team) do {messages, body} = Map.pop!(body, "messages") for message <- messages do %{type: event} = message |> Base.url_decode64!(padding: false) |> LivebookProto.Event.decode() Livebook.Hubs.TeamClient.handle_event(team.id, event) end {:ok, body} end defp dispatch_messages(result, _), do: result defp json?(response) do "application/json; charset=utf-8" in Req.Response.get_header(response, "content-type") end end