Persist app settings and support deployment from directory (#1741)

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2023-02-28 21:37:43 +01:00 committed by GitHub
parent a90e9193f4
commit dd5e1af23b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 308 additions and 3 deletions

View file

@ -164,6 +164,14 @@ The following environment variables configure Livebook:
* LIVEBOOK_APP_SERVICE_URL - sets the application url to manage this * LIVEBOOK_APP_SERVICE_URL - sets the application url to manage this
Livebook instance within the cloud provider platform. Livebook instance within the cloud provider platform.
* LIVEBOOK_APPS_PATH - the directory with app notebooks. When set, the apps
are deployed on Livebook startup with the persisted settings.
Password-protected notebooks will receive a random password,
unless LIVEBOOK_APPS_PATH_PASSWORD is set.
* LIVEBOOK_APPS_PATH_PASSWORD - the password to use for all protected apps
deployed from LIVEBOOK_APPS_PATH.
* LIVEBOOK_BASE_URL_PATH - sets the base url path the web application is served on. * LIVEBOOK_BASE_URL_PATH - sets the base url path the web application is served on.
Useful when deploying behind a reverse proxy. Useful when deploying behind a reverse proxy.
@ -194,7 +202,7 @@ The following environment variables configure Livebook:
* LIVEBOOK_IFRAME_URL - sets the URL that Livebook loads iframes from. * LIVEBOOK_IFRAME_URL - sets the URL that Livebook loads iframes from.
By default iframes are loaded from local LIVEBOOK_IFRAME_PORT when accessing By default iframes are loaded from local LIVEBOOK_IFRAME_PORT when accessing
Livebook over http:// and from https://livebook.space when accessing over `https://`. Livebook over http:// and from https://livebook.space when accessing over https://.
* LIVEBOOK_IP - sets the ip address to start the web application on. * LIVEBOOK_IP - sets the ip address to start the web application on.
Must be a valid IPv4 or IPv6 address. Must be a valid IPv4 or IPv6 address.
@ -219,7 +227,7 @@ The following environment variables configure Livebook:
in the homepage. Set it to "true" to enable it. in the homepage. Set it to "true" to enable it.
* LIVEBOOK_TOKEN_ENABLED - controls whether token authentication is enabled. * LIVEBOOK_TOKEN_ENABLED - controls whether token authentication is enabled.
Enabled by default unless `LIVEBOOK_PASSWORD` is set. Set it to "false" to Enabled by default unless LIVEBOOK_PASSWORD is set. Set it to "false" to
disable it. disable it.
* LIVEBOOK_UPDATE_INSTRUCTIONS_URL - sets the URL to direct the user to for * LIVEBOOK_UPDATE_INSTRUCTIONS_URL - sets the URL to direct the user to for

View file

@ -152,6 +152,14 @@ defmodule Livebook do
config :livebook, :data_path, data_path config :livebook, :data_path, data_path
end end
if apps_path = Livebook.Config.readable_dir!("LIVEBOOK_APPS_PATH") do
config :livebook, :apps_path, apps_path
end
if apps_path_password = Livebook.Config.password!("LIVEBOOK_APPS_PATH_PASSWORD") do
config :livebook, :apps_path_password, apps_path_password
end
if force_ssl_host = Livebook.Config.force_ssl_host!("LIVEBOOK_FORCE_SSL_HOST") do if force_ssl_host = Livebook.Config.force_ssl_host!("LIVEBOOK_FORCE_SSL_HOST") do
config :livebook, :force_ssl_host, force_ssl_host config :livebook, :force_ssl_host, force_ssl_host
end end

View file

@ -56,6 +56,7 @@ defmodule Livebook.Application do
insert_personal_hub() insert_personal_hub()
Livebook.Hubs.connect_hubs() Livebook.Hubs.connect_hubs()
update_app_secrets_origin() update_app_secrets_origin()
deploy_apps()
result result
{:error, error} -> {:error, error} ->
@ -222,6 +223,12 @@ defmodule Livebook.Application do
end end
end end
defp deploy_apps() do
if apps_path = Livebook.Config.apps_path() do
Livebook.Apps.deploy_apps_in_dir(apps_path, password: Livebook.Config.apps_path_password())
end
end
defp iframe_server_specs() do defp iframe_server_specs() do
server? = Phoenix.Endpoint.server?(:livebook, LivebookWeb.Endpoint) server? = Phoenix.Endpoint.server?(:livebook, LivebookWeb.Endpoint)
port = Livebook.Config.iframe_port() port = Livebook.Config.iframe_port()

View file

@ -1,6 +1,8 @@
defmodule Livebook.Apps do defmodule Livebook.Apps do
@moduledoc false @moduledoc false
require Logger
alias Livebook.Session alias Livebook.Session
@doc """ @doc """
@ -89,4 +91,49 @@ defmodule Livebook.Apps do
end end
defp name(slug), do: {:app, slug} defp name(slug), do: {:app, slug}
@doc """
Deploys an app for each notebook in the given directory.
## Options
* `:password` - a password to set for every loaded app
"""
@spec deploy_apps_in_dir(String.t(), keyword()) :: :ok
def deploy_apps_in_dir(path, opts \\ []) do
opts = Keyword.validate!(opts, [:password])
pattern = Path.join([path, "**", "*.livemd"])
paths = Path.wildcard(pattern)
for path <- paths do
markdown = File.read!(path)
{notebook, warnings} = Livebook.LiveMarkdown.notebook_from_livemd(markdown)
if warnings != [] do
items = Enum.map(warnings, &("- " <> &1))
Logger.warning(
"Found warnings while importing app notebook at #{path}:\n\n" <> Enum.join(items, "\n")
)
end
notebook =
if password = opts[:password] do
put_in(notebook.app_settings.password, password)
else
notebook
end
if Livebook.Notebook.AppSettings.valid?(notebook.app_settings) do
{:ok, session} = Livebook.Sessions.create_session(notebook: notebook, mode: :app)
Livebook.Session.app_build(session.pid)
else
Logger.warning("Skipping app deployment at #{path} due to invalid settings")
end
end
:ok
end
end end

View file

@ -84,6 +84,22 @@ defmodule Livebook.Config do
Application.get_env(:livebook, :data_path) || :filename.basedir(:user_data, "livebook") Application.get_env(:livebook, :data_path) || :filename.basedir(:user_data, "livebook")
end end
@doc """
Returns the apps path.
"""
@spec apps_path() :: String.t() | nil
def apps_path() do
Application.get_env(:livebook, :apps_path)
end
@doc """
Returns the password configured for all apps deployed rom `app_path`.
"""
@spec apps_path_password() :: String.t() | nil
def apps_path_password() do
Application.get_env(:livebook, :apps_path_password)
end
@doc """ @doc """
Returns the configured port for the Livebook endpoint. Returns the configured port for the Livebook endpoint.
@ -246,6 +262,33 @@ defmodule Livebook.Config do
end end
end end
@doc """
Parses and validates dir from env.
"""
def readable_dir!(env) do
if dir = System.get_env(env) do
readable_dir!(env, dir)
end
end
@doc """
Validates `dir` within context.
"""
def readable_dir!(context, dir) do
if readable_dir?(dir) do
Path.expand(dir)
else
abort!("expected #{context} to be a readable directory: #{dir}")
end
end
defp readable_dir?(path) do
case File.stat(path) do
{:ok, %{type: :directory, access: access}} when access in [:read_write, :read] -> true
_ -> false
end
end
@doc """ @doc """
Parses and validates the secret from env. Parses and validates the secret from env.
""" """

View file

@ -69,7 +69,25 @@ defmodule Livebook.LiveMarkdown.Export do
defp notebook_metadata(notebook) do defp notebook_metadata(notebook) do
keys = [:persist_outputs, :autosave_interval_s] keys = [:persist_outputs, :autosave_interval_s]
put_unless_default(%{}, Map.take(notebook, keys), Map.take(Notebook.new(), keys)) metadata = put_unless_default(%{}, Map.take(notebook, keys), Map.take(Notebook.new(), keys))
app_settings_metadata = app_settings_metadata(notebook.app_settings)
if app_settings_metadata == %{} do
metadata
else
Map.put(metadata, :app_settings, app_settings_metadata)
end
end
defp app_settings_metadata(app_settings) do
keys = [:slug, :access_type]
put_unless_default(
%{},
Map.take(app_settings, keys),
Map.take(Notebook.AppSettings.new(), keys)
)
end end
defp render_section(section, notebook, ctx) do defp render_section(section, notebook, ctx) do

View file

@ -368,6 +368,28 @@ defmodule Livebook.LiveMarkdown.Import do
{"autosave_interval_s", autosave_interval_s}, attrs -> {"autosave_interval_s", autosave_interval_s}, attrs ->
Map.put(attrs, :autosave_interval_s, autosave_interval_s) Map.put(attrs, :autosave_interval_s, autosave_interval_s)
{"app_settings", app_settings_metadata}, attrs ->
app_settings =
Map.merge(
Notebook.AppSettings.new(),
app_settings_metadata_to_attrs(app_settings_metadata)
)
Map.put(attrs, :app_settings, app_settings)
_entry, attrs ->
attrs
end)
end
defp app_settings_metadata_to_attrs(metadata) do
Enum.reduce(metadata, %{}, fn
{"slug", slug}, attrs ->
Map.put(attrs, :slug, slug)
{"access_type", access_type}, attrs when access_type in ["public", "protected"] ->
Map.put(attrs, :access_type, String.to_atom(access_type))
_entry, attrs -> _entry, attrs ->
attrs attrs
end) end)

View file

@ -839,6 +839,7 @@ defmodule Livebook.Session.Data do
data data
|> with_actions() |> with_actions()
|> set_app_settings(settings) |> set_app_settings(settings)
|> set_dirty()
|> wrap_ok() |> wrap_ok()
end end

View file

@ -0,0 +1,76 @@
defmodule Livebook.AppsTest do
use ExUnit.Case, async: true
import ExUnit.CaptureLog
describe "deploy_apps_in_dir/1" do
@tag :tmp_dir
test "deploys apps", %{tmp_dir: tmp_dir} do
app1_path = Path.join(tmp_dir, "app1.livemd")
app2_path = Path.join(tmp_dir, "app2.livemd")
File.write!(app1_path, """
<!-- livebook:{"app_settings":{"slug":"app1"}} -->
# App 1
""")
File.write!(app2_path, """
<!-- livebook:{"app_settings":{"slug":"app2"}} -->
# App 2
""")
Livebook.Sessions.subscribe()
Livebook.Apps.deploy_apps_in_dir(tmp_dir)
assert_receive {:session_created, %{app_info: %{slug: "app1"}}}
assert_receive {:session_updated,
%{app_info: %{slug: "app1", status: :running, registered: true}} =
app1_session}
assert_receive {:session_created, %{app_info: %{slug: "app2"}}}
assert_receive {:session_updated,
%{app_info: %{slug: "app2", status: :running, registered: true}} =
app2_session}
Livebook.Session.close([app1_session.pid, app2_session.pid])
end
@tag :tmp_dir
test "skips apps with incomplete config and warns", %{tmp_dir: tmp_dir} do
app_path = Path.join(tmp_dir, "app.livemd")
File.write!(app_path, """
# App
""")
assert capture_log(fn ->
Livebook.Apps.deploy_apps_in_dir(tmp_dir)
end) =~ "Skipping app deployment at #{app_path} due to invalid settings"
end
@tag :tmp_dir
test "overrides apps password when :password is set", %{tmp_dir: tmp_dir} do
app_path = Path.join(tmp_dir, "app.livemd")
File.write!(app_path, """
<!-- livebook:{"app_settings":{"slug":"app"}} -->
# App
""")
Livebook.Sessions.subscribe()
Livebook.Apps.deploy_apps_in_dir(tmp_dir, password: "verylongpass")
assert_receive {:session_created, %{app_info: %{slug: "app"}} = session}
%{access_type: :protected, password: "verylongpass"} =
Livebook.Session.get_app_settings(session.pid)
end
end
end

View file

@ -1121,6 +1121,49 @@ defmodule Livebook.LiveMarkdown.ExportTest do
assert expected_document == document assert expected_document == document
end end
describe "app settings" do
test "persists non-default app settings" do
notebook = %{
Notebook.new()
| name: "My Notebook",
app_settings: %{Notebook.AppSettings.new() | slug: "app", access_type: :public}
}
expected_document = """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"app"}} -->
# My Notebook
"""
document = Export.notebook_to_livemd(notebook)
assert expected_document == document
end
test "does not persist password" do
notebook = %{
Notebook.new()
| name: "My Notebook",
app_settings: %{
Notebook.AppSettings.new()
| slug: "app",
access_type: :protected,
password: "verylongpass"
}
}
expected_document = """
<!-- livebook:{"app_settings":{"slug":"app"}} -->
# My Notebook
"""
document = Export.notebook_to_livemd(notebook)
assert expected_document == document
end
end
describe "setup cell" do describe "setup cell" do
test "includes the leading setup cell when it has content" do test "includes the leading setup cell when it has content" do
notebook = notebook =

View file

@ -720,6 +720,38 @@ defmodule Livebook.LiveMarkdown.ImportTest do
assert %Notebook{name: "My Notebook", autosave_interval_s: 10} = notebook assert %Notebook{name: "My Notebook", autosave_interval_s: 10} = notebook
end end
describe "app settings" do
test "imports settings" do
markdown = """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"app"}} -->
# My Notebook
"""
{notebook, []} = Import.notebook_from_livemd(markdown)
assert %Notebook{
name: "My Notebook",
app_settings: %{slug: "app", access_type: :public}
} = notebook
end
test "correctly imports protected access" do
markdown = """
<!-- livebook:{"app_settings":{"slug":"app"}} -->
# My Notebook
"""
{notebook, []} = Import.notebook_from_livemd(markdown)
assert %Notebook{
name: "My Notebook",
app_settings: %{slug: "app", access_type: :protected}
} = notebook
end
end
describe "backward compatibility" do describe "backward compatibility" do
test "warns if the imported notebook includes an input" do test "warns if the imported notebook includes an input" do
markdown = """ markdown = """