diff --git a/config/config.exs b/config/config.exs index e746e2b1a..3fc0812cf 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/lib/livebook/application.ex b/lib/livebook/application.ex index add3c00e2..bb3bc86ce 100644 --- a/lib/livebook/application.ex +++ b/lib/livebook/application.ex @@ -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." ) diff --git a/lib/livebook_cli.ex b/lib/livebook_cli.ex index 7ec7f0d3c..2e7598ab3 100644 --- a/lib/livebook_cli.ex +++ b/lib/livebook_cli.ex @@ -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.\ """) diff --git a/lib/livebook_cli/deploy.ex b/lib/livebook_cli/deploy.ex new file mode 100644 index 000000000..a18ef880d --- /dev/null +++ b/lib/livebook_cli/deploy.ex @@ -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 diff --git a/test/livebook_teams/cli/deploy_test.exs b/test/livebook_teams/cli/deploy_test.exs new file mode 100644 index 000000000..139a9dd42 --- /dev/null +++ b/test/livebook_teams/cli/deploy_test.exs @@ -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, """ + + + # #{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, """ + + + # #{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, """ + + + # 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, """ + + + # 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, """ + + + # 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