mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-10 21:46:46 +08:00
Support Zero Trust authentication (#1938)
Co-authored-by: José Valim <jose.valim@gmail.com> Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com>
This commit is contained in:
parent
5d2a1f4a55
commit
efb28fbdf0
15 changed files with 341 additions and 33 deletions
|
@ -242,6 +242,12 @@ The following environment variables can be used to configure Livebook on boot:
|
|||
standard schemes by default (such as http and https). Set it to a comma-separated
|
||||
list of schemes.
|
||||
|
||||
* LIVEBOOK_IDENTITY_PROVIDER - controls whether Zero Trust Identity is enabled.
|
||||
Set it to your provider and the correspondent key to enable it.
|
||||
Currently supported providers are Cloudflare and GoogleIap.
|
||||
The respective keys are the team name (domain) for CloudFlare and the audience (aud) for GoogleIAP.
|
||||
E.g. `"cloudflare:<your-team-name>"`, `"googleiap:<your-audience>`
|
||||
|
||||
<!-- Environment variables -->
|
||||
|
||||
When running Livebook Desktop, Livebook will invoke on boot a file named
|
||||
|
|
|
@ -194,6 +194,11 @@ defmodule Livebook do
|
|||
if allowed_uri_schemes = Livebook.Config.allowed_uri_schemes!("LIVEBOOK_ALLOW_URI_SCHEMES") do
|
||||
config :livebook, :allowed_uri_schemes, allowed_uri_schemes
|
||||
end
|
||||
|
||||
config :livebook,
|
||||
:identity_provider,
|
||||
Livebook.Config.identity_provider!("LIVEBOOK_IDENTITY_PROVIDER") ||
|
||||
{LivebookWeb.Cookies, :unused}
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
@ -44,6 +44,7 @@ defmodule Livebook.Application do
|
|||
{DynamicSupervisor, name: Livebook.HubsSupervisor, strategy: :one_for_one}
|
||||
] ++
|
||||
iframe_server_specs() ++
|
||||
identity_provider() ++
|
||||
[
|
||||
# Start the Endpoint (http/https)
|
||||
# We skip the access url as we do our own logging below
|
||||
|
@ -267,4 +268,9 @@ defmodule Livebook.Application do
|
|||
"Failed to start Livebook iframe server because port #{port} is already in use"
|
||||
)
|
||||
end
|
||||
|
||||
defp identity_provider() do
|
||||
{module, key} = Livebook.Config.identity_provider()
|
||||
[{module, name: LivebookWeb.ZTA, identity: [key: key]}]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -168,6 +168,22 @@ defmodule Livebook.Config do
|
|||
Application.fetch_env!(:livebook, :shutdown_callback)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the identity provider.
|
||||
"""
|
||||
@spec identity_provider() :: tuple()
|
||||
def identity_provider() do
|
||||
Application.fetch_env!(:livebook, :identity_provider)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns if the identity data is readonly.
|
||||
"""
|
||||
@spec identity_readonly?() :: boolean()
|
||||
def identity_readonly?() do
|
||||
not match?({LivebookWeb.Cookies, _}, Livebook.Config.identity_provider())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether the application is running inside an iframe.
|
||||
"""
|
||||
|
@ -493,4 +509,23 @@ defmodule Livebook.Config do
|
|||
IO.puts("\nERROR!!! [Livebook] " <> message)
|
||||
System.halt(1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses zero trust identity provider from env.
|
||||
"""
|
||||
def identity_provider!(env) do
|
||||
case System.get_env(env) do
|
||||
"googleiap:" <> rest ->
|
||||
{Livebook.ZTA.GoogleIAP, rest}
|
||||
|
||||
"cloudflare:" <> rest ->
|
||||
{Livebook.ZTA.Cloudflare, rest}
|
||||
|
||||
nil ->
|
||||
nil
|
||||
|
||||
_ ->
|
||||
abort!("invalid configuration for identity provider")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
95
lib/livebook/zta/cloudflare.ex
Normal file
95
lib/livebook/zta/cloudflare.ex
Normal file
|
@ -0,0 +1,95 @@
|
|||
defmodule Livebook.ZTA.Cloudflare do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
import Plug.Conn
|
||||
|
||||
@assertion "cf-access-jwt-assertion"
|
||||
@renew_afer 24 * 60 * 60 * 1000
|
||||
|
||||
defstruct [:name, :req_options, :identity]
|
||||
|
||||
def start_link(opts) do
|
||||
identity = identity(opts[:identity][:key])
|
||||
options = [req_options: [url: identity.certs], identity: identity]
|
||||
GenServer.start_link(__MODULE__, options, name: opts[:name])
|
||||
end
|
||||
|
||||
def authenticate(name, conn) do
|
||||
token = get_req_header(conn, @assertion)
|
||||
GenServer.call(name, {:authenticate, token})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(options) do
|
||||
:ets.new(options[:name], [:public, :named_table])
|
||||
{:ok, struct!(__MODULE__, options)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_keys, _from, state) do
|
||||
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
|
||||
{:reply, keys, state}
|
||||
end
|
||||
|
||||
def handle_call({:authenticate, token}, _from, state) do
|
||||
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
|
||||
user = authenticate(token, state.identity, keys)
|
||||
{:reply, user, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:request, state) do
|
||||
request_and_store_in_ets(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp request_and_store_in_ets(state) do
|
||||
Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}")
|
||||
keys = Req.request!(state.req_options).body["keys"]
|
||||
:ets.insert(state.name, keys: keys)
|
||||
Process.send_after(self(), :request, @renew_afer)
|
||||
keys
|
||||
end
|
||||
|
||||
defp get_from_ets(name) do
|
||||
case :ets.lookup(name, :keys) do
|
||||
[keys: keys] -> keys
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp authenticate(token, identity, keys) do
|
||||
with [token] <- token,
|
||||
{:ok, token} <- verify_token(token, keys),
|
||||
:ok <- verify_iss(token, identity.iss) do
|
||||
%{name: token.fields["email"]}
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_token(token, keys) do
|
||||
Enum.find_value(keys, fn key ->
|
||||
case JOSE.JWT.verify(key, token) do
|
||||
{true, token, _s} -> {:ok, token}
|
||||
{_, _t, _s} -> nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok
|
||||
defp verify_iss(_, _), do: nil
|
||||
|
||||
defp identity(key) do
|
||||
%{
|
||||
key: key,
|
||||
key_type: "domain",
|
||||
iss: "https://#{key}.cloudflareaccess.com",
|
||||
certs: "https://#{key}.cloudflareaccess.com/cdn-cgi/access/certs",
|
||||
assertion: "cf-access-jwt-assertion",
|
||||
email: "cf-access-authenticated-user-email"
|
||||
}
|
||||
end
|
||||
end
|
95
lib/livebook/zta/googleiap.ex
Normal file
95
lib/livebook/zta/googleiap.ex
Normal file
|
@ -0,0 +1,95 @@
|
|||
defmodule Livebook.ZTA.GoogleIAP do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
import Plug.Conn
|
||||
|
||||
@assertion "cf-access-jwt-assertion"
|
||||
@renew_afer 24 * 60 * 60 * 1000
|
||||
|
||||
defstruct [:name, :req_options, :identity]
|
||||
|
||||
def start_link(opts) do
|
||||
identity = identity(opts[:identity][:key])
|
||||
options = [req_options: [url: identity.certs], identity: identity]
|
||||
GenServer.start_link(__MODULE__, options, name: opts[:name])
|
||||
end
|
||||
|
||||
def authenticate(name, conn) do
|
||||
token = get_req_header(conn, @assertion)
|
||||
GenServer.call(name, {:authenticate, token})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(options) do
|
||||
:ets.new(options[:name], [:public, :named_table])
|
||||
{:ok, struct!(__MODULE__, options)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_keys, _from, state) do
|
||||
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
|
||||
{:reply, keys, state}
|
||||
end
|
||||
|
||||
def handle_call({:authenticate, token}, _from, state) do
|
||||
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
|
||||
user = authenticate(token, state.identity, keys)
|
||||
{:reply, user, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:request, state) do
|
||||
request_and_store_in_ets(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp request_and_store_in_ets(state) do
|
||||
Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}")
|
||||
keys = Req.request!(state.req_options).body["keys"]
|
||||
:ets.insert(state.name, keys: keys)
|
||||
Process.send_after(self(), :request, @renew_afer)
|
||||
keys
|
||||
end
|
||||
|
||||
defp get_from_ets(name) do
|
||||
case :ets.lookup(name, :keys) do
|
||||
[keys: keys] -> keys
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp authenticate(token, identity, keys) do
|
||||
with [token] <- token,
|
||||
{:ok, token} <- verify_token(token, keys),
|
||||
:ok <- verify_iss(token, identity.iss) do
|
||||
%{name: token.fields["email"]}
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_token(token, keys) do
|
||||
Enum.find_value(keys, fn key ->
|
||||
case JOSE.JWT.verify(key, token) do
|
||||
{true, token, _s} -> {:ok, token}
|
||||
{_, _t, _s} -> nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok
|
||||
defp verify_iss(_, _), do: nil
|
||||
|
||||
defp identity(key) do
|
||||
%{
|
||||
key: key,
|
||||
key_type: "aud",
|
||||
iss: "https://cloud.google.com/iap",
|
||||
certs: "https://www.gstatic.com/iap/verify/public_key",
|
||||
assertion: "x-goog-iap-jwt-assertion",
|
||||
email: "x-goog-authenticated-user-email"
|
||||
}
|
||||
end
|
||||
end
|
24
lib/livebook_web/controllers/error_html/403.html.heex
Normal file
24
lib/livebook_web/controllers/error_html/403.html.heex
Normal file
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href={~p"/favicon.svg"} />
|
||||
<link rel="alternate icon" type="image/png" href={~p"/favicon.png"} />
|
||||
<title><%= @status %> - Livebook</title>
|
||||
<link rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
</head>
|
||||
<body>
|
||||
<div class="h-screen flex items-center justify-center bg-gray-900">
|
||||
<div class="flex flex-col space-y-4 items-center">
|
||||
<a href={~p"/"}>
|
||||
<img src={~p"/images/logo.png"} height="128" width="128" alt="livebook" />
|
||||
</a>
|
||||
<div class="text-2xl text-gray-50">
|
||||
No Numbats allowed here!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
12
lib/livebook_web/cookies.ex
Normal file
12
lib/livebook_web/cookies.ex
Normal file
|
@ -0,0 +1,12 @@
|
|||
defmodule LivebookWeb.Cookies do
|
||||
# This module implements the ZTA contract specific to Livebook cookies
|
||||
@moduledoc false
|
||||
|
||||
def authenticate(_, _conn) do
|
||||
%{}
|
||||
end
|
||||
|
||||
def child_spec(_opts) do
|
||||
%{id: __MODULE__, start: {Function, :identity, [:ignore]}}
|
||||
end
|
||||
end
|
|
@ -35,9 +35,11 @@ defmodule LivebookWeb.UserHook do
|
|||
defp build_current_user(session, socket) do
|
||||
%{"current_user_id" => current_user_id} = session
|
||||
user = %{User.new() | id: current_user_id}
|
||||
identity_data = Map.new(session["identity_data"], fn {k, v} -> {Atom.to_string(k), v} end)
|
||||
|
||||
connect_params = get_connect_params(socket) || %{}
|
||||
attrs = connect_params["user_data"] || session["user_data"] || %{}
|
||||
attrs = Map.merge(attrs, identity_data)
|
||||
|
||||
case Livebook.Users.update_user(user, attrs) do
|
||||
{:ok, user} -> user
|
||||
|
|
|
@ -1732,8 +1732,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
end
|
||||
|
||||
defp after_operation(socket, _prev_socket, {:insert_cell, client_id, _, _, _, cell_id, _attrs}) do
|
||||
{:ok, cell, _section} =
|
||||
Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
|
||||
{:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
|
||||
|
||||
socket = push_cell_editor_payloads(socket, socket.private.data, [cell])
|
||||
|
||||
|
@ -1764,8 +1763,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
end
|
||||
|
||||
defp after_operation(socket, _prev_socket, {:restore_cell, client_id, cell_id}) do
|
||||
{:ok, cell, _section} =
|
||||
Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
|
||||
{:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
|
||||
|
||||
socket = push_cell_editor_payloads(socket, socket.private.data, [cell])
|
||||
|
||||
|
|
|
@ -35,7 +35,12 @@ defmodule LivebookWeb.UserComponent do
|
|||
phx-hook="UserForm"
|
||||
>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<.text_field field={f[:name]} label="Display name" spellcheck="false" />
|
||||
<.text_field
|
||||
field={f[:name]}
|
||||
label="Display name"
|
||||
spellcheck="false"
|
||||
disabled={Livebook.Config.identity_readonly?()}
|
||||
/>
|
||||
<.hex_color_field
|
||||
field={f[:hex_color]}
|
||||
label="Cursor color"
|
||||
|
|
|
@ -16,6 +16,7 @@ defmodule LivebookWeb.UserPlug do
|
|||
@behaviour Plug
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias Livebook.Users.User
|
||||
|
||||
|
@ -26,6 +27,7 @@ defmodule LivebookWeb.UserPlug do
|
|||
def call(conn, _opts) do
|
||||
conn
|
||||
|> ensure_current_user_id()
|
||||
|> ensure_user_identity()
|
||||
|> ensure_user_data()
|
||||
|> mirror_user_data_in_session()
|
||||
end
|
||||
|
@ -39,11 +41,27 @@ defmodule LivebookWeb.UserPlug do
|
|||
end
|
||||
end
|
||||
|
||||
defp ensure_user_identity(conn) do
|
||||
{module, _} = Livebook.Config.identity_provider()
|
||||
identity_data = module.authenticate(LivebookWeb.ZTA, conn)
|
||||
|
||||
if identity_data do
|
||||
put_session(conn, :identity_data, identity_data)
|
||||
else
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> put_view(LivebookWeb.ErrorHTML)
|
||||
|> render("403.html")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_user_data(conn) do
|
||||
if Map.has_key?(conn.req_cookies, "lb:user_data") do
|
||||
conn
|
||||
else
|
||||
user_data = user_data(User.new())
|
||||
identity_data = get_session(conn, :identity_data)
|
||||
user_data = User.new() |> user_data() |> Map.merge(identity_data)
|
||||
encoded = user_data |> Jason.encode!() |> Base.encode64()
|
||||
|
||||
# We disable HttpOnly, so that it can be accessed on the client
|
||||
|
|
5
mix.exs
5
mix.exs
|
@ -107,7 +107,10 @@ defmodule Livebook.MixProject do
|
|||
{:protobuf, "~> 0.8.0"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:floki, ">= 0.27.0", only: :test},
|
||||
{:bypass, "~> 2.1", only: :test}
|
||||
{:bypass, "~> 2.1", only: :test},
|
||||
# ZTA deps
|
||||
{:jose, "~> 1.11.5"},
|
||||
{:req, "~> 0.3.8"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
5
mix.lock
5
mix.lock
|
@ -9,12 +9,16 @@
|
|||
"earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"},
|
||||
"ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"},
|
||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||
"finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"},
|
||||
"floki": {:hex, :floki, "0.34.3", "5e2dcaec5d7c228ce5b1d3501502e308b2d79eb655e4191751a1fe491c37feac", [:mix], [], "hexpm", "9577440eea5b97924b4bf3c7ea55f7b8b6dce589f9b28b096cc294a8dc342341"},
|
||||
"hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"},
|
||||
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
|
||||
"jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"},
|
||||
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
|
||||
"mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"},
|
||||
"mint_web_socket": {:hex, :mint_web_socket, "1.0.3", "aab42fff792a74649916236d0b01f560a0b3f03ca5dea693c230d1c44736b50e", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "ca3810ca44cc8532e3dce499cc17f958596695d226bb578b2fbb88c09b5954b0"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
|
||||
"phoenix": {:hex, :phoenix, "1.7.5", "3234bc87185e6a2103a15a3b1399f19775b093a6923c4064436e49cdab8ce5d2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.1", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "5abad1789f06a3572ee5e5d5151993ed35b9e2711537904cc457a40229587979"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.2", "b21bd01fdeffcfe2fab49e4942aa938b6d3e89e93a480d4aee58085560a0bc0d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "70242edd4601d50b69273b057ecf7b684644c19ee750989fd555625ae4ce8f5d"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"},
|
||||
|
@ -28,6 +32,7 @@
|
|||
"plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
|
||||
"protobuf": {:hex, :protobuf, "0.8.0", "61b27d6fd50e7b1b2eb0ee17c1f639906121f4ef965ae0994644eb4c68d4647d", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3644ed846fd6f5e3b5c2cd617aa8344641e230edf812a45365fee7622bccd25a"},
|
||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||
"req": {:hex, :req, "0.3.8", "e254074435c970b1d7699777f1a8466acbacab5e6ba4a264d35053bf52c03467", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a17244d18a7fbf3e9892c38c10628224f6f7974fd364392ca0d85f91e3cc8251"},
|
||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
|
||||
|
|
|
@ -135,32 +135,31 @@ defmodule Livebook.Notebook.Export.ElixirTest do
|
|||
end
|
||||
|
||||
test "comments out non-elixir code cells" do
|
||||
notebook =
|
||||
%{
|
||||
Notebook.new()
|
||||
| name: "My Notebook",
|
||||
sections: [
|
||||
%{
|
||||
Notebook.Section.new()
|
||||
| name: "Section 1",
|
||||
cells: [
|
||||
%{
|
||||
Notebook.Cell.new(:code)
|
||||
| source: """
|
||||
Enum.to_list(1..10)\
|
||||
"""
|
||||
},
|
||||
%{
|
||||
Notebook.Cell.new(:code)
|
||||
| language: :erlang,
|
||||
source: """
|
||||
lists:seq(1, 10).\
|
||||
"""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
notebook = %{
|
||||
Notebook.new()
|
||||
| name: "My Notebook",
|
||||
sections: [
|
||||
%{
|
||||
Notebook.Section.new()
|
||||
| name: "Section 1",
|
||||
cells: [
|
||||
%{
|
||||
Notebook.Cell.new(:code)
|
||||
| source: """
|
||||
Enum.to_list(1..10)\
|
||||
"""
|
||||
},
|
||||
%{
|
||||
Notebook.Cell.new(:code)
|
||||
| language: :erlang,
|
||||
source: """
|
||||
lists:seq(1, 10).\
|
||||
"""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expected_document = """
|
||||
# Run as: iex --dot-iex path/to/notebook.exs
|
||||
|
|
Loading…
Add table
Reference in a new issue