Improvements

This commit is contained in:
Alexandre de Souza 2025-06-23 17:28:42 -03:00
parent 803ce3cf9c
commit bd5ff093c0
No known key found for this signature in database
GPG key ID: E39228FFBA346545
2 changed files with 250 additions and 71 deletions

View file

@ -1,6 +1,4 @@
defmodule LivebookCLI do
import Kernel, except: [raise: 1]
@switches [
help: :boolean,
version: :boolean
@ -103,32 +101,97 @@ defmodule LivebookCLI do
@doc """
Logs an error message.
"""
def error(message), do: IO.puts(:stderr, IO.ANSI.format(red(message)))
def error(message) do
message
|> put_level(:error)
|> IO.ANSI.format()
|> IO.puts()
end
@doc """
Logs an info message.
"""
def info(message), do: IO.puts(IO.ANSI.format(message))
def info(message) do
message
|> put_level(:info)
|> IO.ANSI.format()
|> IO.puts()
end
@doc """
Logs a debug message.
"""
def debug(message), do: IO.puts(IO.ANSI.format(cyan(message)))
if Mix.env() == :dev do
def debug(message) do
message
|> put_level(:debug)
|> IO.ANSI.format()
|> IO.puts()
end
else
def debug(message) do
_ = put_level(message, :debug)
:ok
end
end
@doc """
Logs a warning message.
"""
def warning(message), do: IO.warn(message, [])
def warning(message) do
message
|> put_level(:warning)
|> IO.ANSI.format()
|> IO.warn()
end
@doc """
Aborts
Logs an error message.
"""
@spec raise(String.t()) :: no_return()
def raise(message) do
error(message)
def raise(exception, command_name, stacktrace) when is_exception(exception) do
exception
|> format_exception(command_name, stacktrace)
|> IO.ANSI.format()
|> IO.puts()
System.halt(1)
end
defp red(message), do: [:red, :bright, message]
defp cyan(message), do: [:cyan, :bright, message]
defp put_level(message, :info) do
["[info] ", message]
end
defp put_level(message, :error) do
[:red, "[error] ", message]
end
defp put_level(message, :warning) do
["[warning] ", message]
end
defp put_level(message, :debug) do
[:cyan, "[debug] ", message]
end
defp format_exception(%OptionParser.ParseError{} = exception, command_name, _) do
[
:red,
:bright,
"""
#{Exception.message(exception)}
For more information try:
livebook #{command_name} --help
"""
]
end
defp format_exception(exception, _command_name, stacktrace) do
[
:red,
:bright,
Exception.format(:error, exception, stacktrace)
]
end
end

View file

@ -1,51 +1,34 @@
defmodule LivebookCLI.Deploy do
@moduledoc """
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 encrypt the Livebook app
--dg 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_..." -dg "online" path/to/file.livemd
Deploys a folder:
livebook deploy --deploy-key="lb_dk_..." --teams-key="lb_tk_..." -dg "online" path/to\
"""
use LivebookCLI.Command
@impl true
def usage() do
"""
Usage: livebook deploy [options] filename|directory
## Available options
--api-key Sets the API key to authenticate with Livebook Teams
--teams-key Sets the Teams key to encrypt the Livebook app
--dg The deployment group name which you want to deploy
The --help option can be given to print this notice.
## Environment variables
The following environment variables can be used to configure Livebook on boot:
* `LIVEBOOK_TEAMS_API_KEY` - sets the API key to authenticate with Livebook Teams
* `LIVEBOOK_TEAMS_KEY` - sets the Teams key to encrypt the Livebook app
## Examples
Deploys a single notebook:
livebook deploy --api-key="lb_ak_..." --teams-key="lb_tk_..." -dg "online" path/to/file.livemd
Deploys a folder:
livebook deploy --api-key="lb_ak_..." --teams-key="lb_tk_..." -dg "online" path/to
Deploys from environment variable:
export LIVEBOOK_TEAMS_KEY=lb_tk...
export LIVEBOOK_TEAMS_API_KEY=lb_ak_...
livebook deploy -dg "online" path/to/file.livemd
Deploys an imported notebook at the given URL:
livebook deploy --api-key="lb_ak_..." --teams-key="lb_tk_..." -dg "online" https://example.com/my-notebook.livemd\
"""
@moduledoc
end
@switches [
api_key: :string,
deploy_key: :string,
teams_key: :string,
dg: :string
]
@ -53,28 +36,40 @@ defmodule LivebookCLI.Deploy do
@impl true
def call(args) do
Application.put_env(:livebook, :mode, :cli)
{:ok, _} = Application.ensure_all_started(:livebook)
config = config_from_args(args)
with :ok <- validate_config(config),
{:ok, session_auth} <- authenticate_cli(config) do
deploy(session_auth, config.path)
{:ok, attrs} <- authenticate_cli(config) do
team = fetch_or_create_hub(attrs, config)
deploy_to_teams(team, config, attrs)
end
end
defp config_from_args(args) do
{opts, filename_or_directory} = OptionParser.parse!(args, strict: @switches)
Enum.into(opts, %{path: filename_or_directory})
filename_or_directory = Path.expand(filename_or_directory)
%{
teams_key: opts[:teams_key],
deploy_key: opts[:deploy_key],
deployment_group_name: opts[:dg],
path: filename_or_directory
}
end
defp validate_config(config) do
cond do
config[:api_key] == nil or not String.starts_with?(config.api_key, "lb_ak_") ->
{:error, "must be a Livebook Teams API Key"}
debug("Validating config from options...")
config[:teams_key] == nil or not String.starts_with?(config.teams_key, "lb_tk_") ->
cond do
config.deploy_key == nil or not String.starts_with?(config.deploy_key, "lb_dk_") ->
{:error, "must be a Livebook Teams Deploy Key"}
config.teams_key == nil or not String.starts_with?(config.teams_key, "lb_tk_") ->
{:error, "must be a Livebook Teams Key"}
config[:dg] in ["", nil] ->
config.deployment_group_name in ["", nil] ->
{:error, "must be a deployment group name"}
not File.exists?(config.path) ->
@ -86,28 +81,149 @@ defmodule LivebookCLI.Deploy do
end
defp authenticate_cli(config) do
req = Req.new(base_url: Livebook.Config.teams_url(), auth: {:bearer, config.api_key})
json = %{key_hash: Livebook.Teams.Org.key_hash(config), deployment_group_name: config.dg}
info("Authenticating CLI...")
case Req.post(req, path: "/api/v1/auth-cli", json: json) do
{:ok, %{status: 200, body: session_auth}} -> {:ok, session_auth}
case Req.post(client(config), url: "/api/v1/auth-cli") do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: 401}} -> {:error, "Invalid API credentials"}
{:ok, %{status: 403}} -> {:error, "You don't have access to this action"}
{:ok, %{status: 500}} -> {:error, "Something went wrong"}
{:ok, %{status: _, body: body}} -> {:error, error_from_body(body)}
{:error, exception} -> Kernel.raise(exception)
{:error, exception} -> raise exception
end
end
defp error_from_body(%{"error" => %{"details" => error}}) do
defp client(config) do
debug("Building Req client for: #{inspect(config, pretty: true)}")
key_hash = Livebook.Teams.Org.key_hash(config.teams_key)
token = "#{config.deploy_key}:#{key_hash}:#{config.deployment_group_name}"
Req.new(base_url: Livebook.Config.teams_url(), auth: {:bearer, token})
|> Req.Request.put_new_header("x-lb-version", Livebook.Config.app_version())
|> Livebook.Utils.req_attach_defaults()
end
defp error_from_body(%{"errors" => %{"detail" => error}}) do
"Livebook Teams API returned error: #{error}"
end
defp deploy(session_auth, path) do
for filename <- File.ls(path),
path = Path.join(path, filename),
not File.dir?(path),
defp error_from_body(error) when is_binary(error) do
"Livebook Teams API returned error: #{error}"
end
defp fetch_or_create_hub(%{"name" => name} = attrs, config) do
id = "team-#{name}"
if not Livebook.Hubs.hub_exists?(id) do
Livebook.Hubs.save_hub(%Livebook.Hubs.Team{
id: id,
hub_name: name,
hub_emoji: "🚀",
user_id: nil,
org_id: attrs["org_id"],
org_key_id: attrs["org_key_id"],
session_token: "#{config.deploy_key}:#{config.deployment_group_name}",
teams_key: config.teams_key,
org_public_key: attrs["public_key"]
})
Application.put_env(:livebook, :teams_auth, :online)
Application.put_env(:livebook, :apps_path_hub_id, id)
end
Livebook.Hubs.fetch_hub!(id)
end
defp deploy_to_teams(team, config, %{"url" => url}) do
for path <- list_notebooks(config.path),
String.ends_with?(path, ".livemd") do
{notebook, %{warnings: []}} = Livebook.LiveMarkdown.notebook_from_livemd(source)
deploy_notebook(team, path, url)
end
:ok
end
defp list_notebooks(path) do
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)
else
[path]
end
if files == [] do
raise "There's no notebook available to deploy"
else
if length(files) == 1 do
debug("Found 1 notebook")
else
debug("Found #{length(files)} notebooks")
end
files
end
end
defp deploy_notebook(team, path, url) do
info("Deploying notebook: #{path}")
files_dir = Livebook.FileSystem.File.local(path)
with {:ok, content} <- File.read(path),
{:ok, notebook} = notebook_from_livemd(content),
{:ok, app_deployment} <- prepare_app_deployment(notebook, files_dir) do
case Livebook.Teams.deploy_app(team, app_deployment) do
:ok -> print_deployment(app_deployment, url)
{:error, _changeset} -> raise "Invalid data"
{:transport_error, reason} -> raise reason
end
end
end
defp notebook_from_livemd(content) do
debug("Loading notebook from content")
case Livebook.LiveMarkdown.notebook_from_livemd(content) do
{notebook, %{warnings: [], stamp_verified?: true}} ->
debug("Notebook loaded for app: #{notebook.app_settings.slug}")
{:ok, notebook}
{_notebook, %{warnings: [], stamp_verified?: false}} ->
{:error, "Failed to validate the notebook stamp"}
{_notebook, %{warnings: warnings}} ->
{:error, Enum.join(warnings, "\n")}
end
end
defp prepare_app_deployment(notebook, files_dir) do
debug("Preparing app deployment for #{notebook.app_settings.slug}")
case Livebook.Teams.AppDeployment.new(notebook, files_dir) do
{:ok, app_deployment} -> {:ok, app_deployment}
{:warning, warnings} -> {:error, Enum.join(warnings, "\n")}
other -> other
end
end
defp print_deployment(app_deployment, url) do
slug =
if url do
"#{app_deployment.slug} (#{url}/apps/#{app_deployment.slug})"
else
app_deployment.slug
end
info("""
App deployment created successfully.
Slug: #{slug}
Title: #{app_deployment.title}
""")
end
end