Add task to deploy notebook from CLI

This commit is contained in:
Alexandre de Souza 2025-07-17 12:40:18 -03:00
parent ccc07af9d9
commit 01a2977114
No known key found for this signature in database
GPG key ID: E39228FFBA346545
5 changed files with 465 additions and 1 deletions

View file

@ -24,6 +24,7 @@ config :mime, :types, %{
config :livebook,
agent_name: "default",
mode: :app,
allowed_uri_schemes: [],
app_service_name: nil,
app_service_url: nil,

View file

@ -326,7 +326,7 @@ defmodule Livebook.Application do
Application.put_env(:livebook, :apps_path_hub_id, hub_id)
fun
teams_key || auth ->
Application.get_env(:livebook, :mode) == :app and (teams_key || auth) ->
Livebook.Config.abort!(
"You must specify both LIVEBOOK_TEAMS_KEY and LIVEBOOK_TEAMS_AUTH."
)

View file

@ -32,6 +32,7 @@ defmodule LivebookCLI do
Available commands:
livebook server Starts the Livebook web application
livebook deploy Deploys a notebook to Livebook Teams
The --help and --version options can be given instead of a command for usage and versioning information.\
""")

227
lib/livebook_cli/deploy.ex Normal file
View file

@ -0,0 +1,227 @@
defmodule LivebookCLI.Deploy do
import LivebookCLI.Utils
alias Livebook.Teams
@behaviour LivebookCLI.Task
@deploy_key_prefix Teams.Requests.deploy_key_prefix()
@teams_key_prefix Teams.Org.teams_key_prefix()
@impl true
def usage() do
"""
Usage: livebook deploy [options] filename|directory
## Available options
--deploy-key Sets the deploy key to authenticate with Livebook Teams
--teams-key Sets the Teams key to authenticate with Livebook Teams and encrypt the Livebook app
--deployment-group The deployment group name which you want to deploy
The --help option can be given to print this notice.
## Examples
Deploys a single notebook:
livebook deploy --deploy-key="lb_dk_..." --teams-key="lb_tk_..." -deployment-group "online" path/to/file.livemd
Deploys a folder:
livebook deploy --deploy-key="lb_dk_..." --teams-key="lb_tk_..." -deployment-group "online" path/to\
"""
end
@switches [
deploy_key: :string,
teams_key: :string,
deployment_group: :string
]
@impl true
def call(args) do
Application.put_env(:livebook, :mode, :cli)
Application.put_env(:livebook, LivebookWeb.Endpoint, server: false)
{:ok, _} = Application.ensure_all_started(:livebook)
config = config_from_args(args)
ensure_config!(config)
team = authenticate_cli!(config)
deploy_to_teams(team, config)
end
defp config_from_args(args) do
{opts, filename_or_directory} = OptionParser.parse!(args, strict: @switches)
filename_or_directory = Path.expand(filename_or_directory)
%{
path: filename_or_directory,
session_token: opts[:deploy_key],
teams_key: opts[:teams_key],
deployment_group: opts[:deployment_group]
}
end
defp ensure_config!(config) do
log_debug("Validating config from options...")
errors =
Enum.reduce(config, %{}, fn
{:session_token, value}, acc when value in ["", nil] ->
add_error(acc, "Deploy Key", "can't be blank")
{:session_token, value}, acc ->
if not String.starts_with?(value, @deploy_key_prefix) do
add_error(acc, "Deploy Key", "must be a Livebook Teams Deploy Key")
else
acc
end
{:teams_key, value}, acc when value in ["", nil] ->
add_error(acc, "Teams Key", "can't be blank")
{:teams_key, value}, acc ->
if not String.starts_with?(value, @teams_key_prefix) do
add_error(acc, "Teams Key", "must be a Livebook Teams Key")
else
acc
end
{:deployment_group, value}, acc when value in ["", nil] ->
add_error(acc, "Deployment Group", "can't be blank")
{:path, value}, acc when value in ["", nil] ->
add_error(acc, "Path", "can't be blank")
{:path, value}, acc ->
if not File.exists?(value) do
add_error(acc, "Path", "must be a valid path")
else
acc
end
_otherwise, acc ->
acc
end)
if Map.keys(errors) == [] do
:ok
else
raise """
You configuration is invalid, make sure you are using the correct options for this task.
#{format_errors(errors)}\
"""
end
end
defp authenticate_cli!(config) do
log_debug("Authenticating CLI...")
case Teams.fetch_cli_session(config) do
{:ok, team} -> team
{:error, error} -> raise error
{:transport_error, error} -> raise error
end
end
defp deploy_to_teams(team, config) do
for path <- list_notebooks!(config.path) do
log_debug("Deploying notebook: #{path}")
files_dir = Livebook.FileSystem.File.local(path)
with {:ok, content} <- File.read(path),
{:ok, app_deployment} <- prepare_app_deployment(path, content, files_dir) do
case Livebook.Teams.deploy_app_from_cli(team, app_deployment, config.deployment_group) do
{:ok, url} -> print_deployment(app_deployment, url)
{:error, errors} -> raise format_errors(errors)
{:transport_error, reason} -> raise reason
end
end
end
:ok
end
defp list_notebooks!(path) do
log_debug("Listing notebooks from: #{path}")
files =
if File.dir?(path) do
path
|> File.ls!()
|> Enum.map(&Path.join(path, &1))
|> Enum.reject(&File.dir?/1)
|> Enum.filter(&String.ends_with?(&1, ".livemd"))
else
[path]
end
if files == [] do
raise "There's no notebook available to deploy"
else
if length(files) == 1 do
log_debug("Found 1 notebook")
else
log_debug("Found #{length(files)} notebooks")
end
files
end
end
defp add_error(errors, key, message) do
Map.update(errors, key, [message], &[message | &1])
end
defp format_errors(%{} = errors_map) do
errors_map
|> Enum.map(fn {key, errors} ->
"""
* #{key}
#{format_list(errors)}\
"""
end)
|> Enum.join("\n")
end
defp format_list(errors) when is_list(errors) do
errors |> Enum.map(&" * #{&1}") |> Enum.join("\n")
end
defp prepare_app_deployment(path, content, files_dir) do
case Livebook.Teams.AppDeployment.new(content, files_dir) do
{:ok, app_deployment} ->
{:ok, app_deployment}
{:warning, warnings} ->
raise """
Deployment for notebook #{Path.basename(path)} failed because the notebook has some warnings:
#{format_list(warnings)}
"""
{:error, reason} ->
raise "Failed to handle I/O operations: #{reason}"
end
end
defp print_deployment(app_deployment, url) do
print_text([
:green,
"App deployment created successfully.\n\n",
:magenta,
:bright,
"Slug: ",
:reset,
:white,
"#{app_deployment.slug} (#{url})\n",
:magenta,
:bright,
"Title: ",
:reset,
:white,
app_deployment.title
])
end
end

View file

@ -0,0 +1,235 @@
defmodule LivebookCLI.Integration.DeployTest do
use Livebook.TeamsIntegrationCase, async: true
import Livebook.AppHelpers
alias Livebook.Utils
alias LivebookCLI.Deploy
@url "http://localhost:4200"
@moduletag teams_for: :user
setup :teams
@moduletag subscribe_to_hubs_topics: [:connection]
@moduletag subscribe_to_teams_topics: [:clients, :deployment_groups, :app_deployments]
@moduletag :tmp_dir
@moduletag :capture_io
describe "CLI deploy integration" do
test "successfully deploys a notebook via CLI",
%{team: team, node: node, org: org, tmp_dir: tmp_dir} do
title = "Test CLI Deploy App"
slug = Utils.random_short_id()
app_path = Path.join(tmp_dir, "#{slug}.livemd")
{key, _} = TeamsRPC.create_deploy_key(node, org: org)
deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url)
hub_id = team.id
deployment_group_id = to_string(deployment_group.id)
stamp_notebook(app_path, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{hub_id}"} -->
# #{title}
## Test Section
```elixir
IO.puts("Hello from CLI deployed app!")
```
""")
output =
ExUnit.CaptureIO.capture_io(fn ->
assert Deploy.call([
"--deploy-key",
key,
"--teams-key",
team.teams_key,
"--deployment-group",
deployment_group.name,
app_path
]) == :ok
end)
assert output =~ "App deployment created successfully."
assert output =~ "#{slug} (#{@url}/apps/#{slug})"
assert output =~ title
assert_receive {:app_deployment_started,
%{
title: ^title,
slug: ^slug,
deployment_group_id: ^deployment_group_id,
hub_id: ^hub_id,
deployed_by: "CLI"
}}
end
test "successfully deploys multiple notebooks from directory",
%{team: team, node: node, org: org, tmp_dir: tmp_dir} do
{key, _} = TeamsRPC.create_deploy_key(node, org: org)
deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url)
hub_id = team.id
deployment_group_id = to_string(deployment_group.id)
apps =
for i <- 1..3 do
title = "Test App #{i}"
slug = "app-#{i}-#{Utils.random_short_id()}"
app_path = Path.join(tmp_dir, "app_#{i}.livemd")
stamp_notebook(app_path, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{hub_id}"} -->
# #{title}
```elixir
IO.puts("Hello from app #{i}!")
```
""")
{slug, title}
end
output =
ExUnit.CaptureIO.capture_io(fn ->
assert Deploy.call([
"--deploy-key",
key,
"--teams-key",
team.teams_key,
"--deployment-group",
deployment_group.name,
tmp_dir
]) == :ok
end)
for {slug, title} <- apps do
assert output =~ "App deployment created successfully."
assert output =~ "#{slug} (#{@url}/apps/#{slug})"
assert output =~ title
assert_receive {:app_deployment_started,
%{
title: ^title,
slug: ^slug,
deployment_group_id: ^deployment_group_id,
hub_id: ^hub_id,
deployed_by: "CLI"
}}
end
end
test "fails with invalid deploy key", %{team: team, node: node, org: org, tmp_dir: tmp_dir} do
slug = Utils.random_short_id()
app_path = Path.join(tmp_dir, "#{slug}.livemd")
deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url)
stamp_notebook(app_path, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{team.id}"} -->
# Test App
""")
assert_raise RuntimeError, ~r/Deploy Key.*must be a Livebook Teams Deploy Key/s, fn ->
Deploy.call([
"--deploy-key",
"invalid_key",
"--teams-key",
team.teams_key,
"--deployment-group",
deployment_group.name,
app_path
])
end
end
test "fails with invalid teams key", %{team: team, node: node, org: org, tmp_dir: tmp_dir} do
slug = Utils.random_short_id()
app_path = Path.join(tmp_dir, "#{slug}.livemd")
{key, _} = TeamsRPC.create_deploy_key(node, org: org)
deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url)
stamp_notebook(app_path, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{team.id}"} -->
# Test App
""")
assert_raise RuntimeError, ~r/Teams Key.*must be a Livebook Teams Key/s, fn ->
Deploy.call([
"--deploy-key",
key,
"--teams-key",
"invalid-key",
"--deployment-group",
deployment_group.name,
app_path
])
end
end
test "fails with missing deployment group",
%{team: team, node: node, org: org, tmp_dir: tmp_dir} do
slug = Utils.random_short_id()
app_path = Path.join(tmp_dir, "#{slug}.livemd")
{key, _} = TeamsRPC.create_deploy_key(node, org: org)
stamp_notebook(app_path, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{team.id}"} -->
# Test App
""")
assert_raise RuntimeError, ~r/Deployment Group.*can't be blank/s, fn ->
Deploy.call([
"--deploy-key",
key,
"--teams-key",
team.teams_key,
app_path
])
end
end
test "fails with non-existent file", %{team: team, node: node, org: org, tmp_dir: tmp_dir} do
{key, _} = TeamsRPC.create_deploy_key(node, org: org)
deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url)
assert_raise RuntimeError, ~r/Path.*must be a valid path/s, fn ->
Deploy.call([
"--deploy-key",
key,
"--teams-key",
team.teams_key,
"--deployment-group",
deployment_group.name,
Path.join(tmp_dir, "app.livemd")
])
end
end
test "fails when directory contains no notebooks",
%{team: team, node: node, org: org, tmp_dir: tmp_dir} do
{key, _} = TeamsRPC.create_deploy_key(node, org: org)
deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url)
File.write!(Path.join(tmp_dir, "readme.txt"), "No notebooks here")
File.write!(Path.join(tmp_dir, "config.json"), "{}")
assert_raise RuntimeError, ~r/There's no notebook available to deploy/, fn ->
Deploy.call([
"--deploy-key",
key,
"--teams-key",
team.teams_key,
"--deployment-group",
deployment_group.name,
tmp_dir
])
end
end
end
end