defmodule Livebook.Teams.AppDeployment do use Ecto.Schema alias Livebook.FileSystem @max_size 20 * 1024 * 1024 @type t :: %__MODULE__{ id: String.t() | nil, slug: String.t() | nil, version: String.t() | nil, sha: String.t() | nil, title: String.t() | nil, multi_session: boolean(), access_type: Livebook.Notebook.AppSettings.access_type(), hub_id: String.t() | nil, deployment_group_id: String.t() | nil, app_folder_id: String.t() | nil, file: binary() | nil, deployed_by: String.t() | nil, deployed_at: DateTime.t() | nil, authorization_groups: Ecto.Schema.embeds_many(Livebook.Teams.AuthorizationGroup.t()) } @access_types Livebook.Notebook.AppSettings.access_types() @primary_key {:id, :string, autogenerate: false} embedded_schema do field :slug, :string field :version, :string field :sha, :string field :title, :string field :multi_session, :boolean field :access_type, Ecto.Enum, values: @access_types field :hub_id, :string field :deployment_group_id, :string field :app_folder_id, :string field :file, :string field :deployed_by, :string field :deployed_at, :utc_datetime embeds_many :authorization_groups, Livebook.Teams.AuthorizationGroup end @doc """ Creates a new app deployment from notebook. """ @spec new(Livebook.Notebook.t() | String.t(), Livebook.FileSystem.File.t()) :: {:ok, t()} | {:warning, list(String.t())} | {:error, FileSystem.error()} def new(%Livebook.Notebook{} = notebook, files_dir) do case Livebook.LiveMarkdown.notebook_to_livemd(notebook) do {source, []} -> new(notebook, source, files_dir) {_, warnings} -> {:warning, warnings} end end @stamp_error "notebook does not have a stamp, disabling access to secrets and remote files" def new(source, files_dir) when is_binary(source) do case Livebook.LiveMarkdown.notebook_from_livemd(source) do {notebook, %{warnings: [], stamp_verified?: true}} -> new(notebook, source, files_dir) {_, %{warnings: [], stamp_verified?: false}} -> {:warning, [@stamp_error]} {_, %{warnings: warnings}} -> {:warning, warnings} end end defp new(%Livebook.Notebook{} = notebook, source, files_dir) do with {:ok, files} <- build_and_check_file_entries(notebook, source, files_dir), {:ok, {_, zip_content}} <- :zip.create(~c"app_deployment.zip", files, [:memory]), :ok <- validate_size(zip_content) do md5_hash = :crypto.hash( :md5, files |> Enum.sort() |> Enum.map(fn {file, contents} -> ["__FILE__", file, contents] end) ) shasum = Base.encode16(md5_hash, case: :lower) {:ok, %__MODULE__{ slug: notebook.app_settings.slug, sha: shasum, title: notebook.name, multi_session: notebook.app_settings.multi_session, access_type: notebook.app_settings.access_type, app_folder_id: notebook.app_settings.app_folder_id, hub_id: notebook.hub_id, deployment_group_id: notebook.deployment_group_id, file: zip_content }} end end defp build_and_check_file_entries(notebook, source, files_dir) do notebook.file_entries |> Enum.filter(&(&1.type == :attachment)) |> Enum.reduce_while({:ok, [{~c"notebook.livemd", source}]}, fn %{name: name}, {:ok, acc} -> file = Livebook.FileSystem.File.resolve(files_dir, name) with {:ok, true} <- Livebook.FileSystem.File.exists?(file), {:ok, content} <- Livebook.FileSystem.File.read(file) do {:cont, {:ok, [{to_charlist("files/" <> name), content} | acc]}} else {:ok, false} -> {:halt, {:error, "files/#{name}: doesn't exist"}} {:error, reason} -> {:halt, {:error, "files/#{name}: #{reason}"}} end end) end defp validate_size(data) do if byte_size(data) <= @max_size do :ok else {:error, "the notebook and its attachments have exceeded the maximum size of 20MB"} end end end