|
| 1 | +defmodule LivebookCLI.Deploy do |
| 2 | + import LivebookCLI.Utils |
| 3 | + alias Livebook.Teams |
| 4 | + |
| 5 | + @behaviour LivebookCLI.Task |
| 6 | + |
| 7 | + @deploy_key_prefix Teams.Requests.deploy_key_prefix() |
| 8 | + @teams_key_prefix Teams.Org.teams_key_prefix() |
| 9 | + |
| 10 | + @impl true |
| 11 | + def usage() do |
| 12 | + """ |
| 13 | + Usage: livebook deploy [options] filename|directory |
| 14 | +
|
| 15 | + ## Available options |
| 16 | +
|
| 17 | + --deploy-key Sets the deploy key to authenticate with Livebook Teams |
| 18 | + --teams-key Sets the Teams key to authenticate with Livebook Teams and encrypt the Livebook app |
| 19 | + --deployment-group The deployment group name which you want to deploy |
| 20 | +
|
| 21 | + The --help option can be given to print this notice. |
| 22 | +
|
| 23 | + ## Examples |
| 24 | +
|
| 25 | + Deploys a single notebook: |
| 26 | +
|
| 27 | + livebook deploy --deploy-key="lb_dk_..." --teams-key="lb_tk_..." -deployment-group "online" path/to/file.livemd |
| 28 | +
|
| 29 | + Deploys a folder: |
| 30 | +
|
| 31 | + livebook deploy --deploy-key="lb_dk_..." --teams-key="lb_tk_..." -deployment-group "online" path/to\ |
| 32 | + """ |
| 33 | + end |
| 34 | + |
| 35 | + @switches [ |
| 36 | + deploy_key: :string, |
| 37 | + teams_key: :string, |
| 38 | + deployment_group: :string |
| 39 | + ] |
| 40 | + |
| 41 | + @impl true |
| 42 | + def call(args) do |
| 43 | + Application.put_env(:livebook, :mode, :cli) |
| 44 | + Application.put_env(:livebook, LivebookWeb.Endpoint, server: false) |
| 45 | + |
| 46 | + {:ok, _} = Application.ensure_all_started(:livebook) |
| 47 | + config = config_from_args(args) |
| 48 | + ensure_config!(config) |
| 49 | + |
| 50 | + team = authenticate_cli!(config) |
| 51 | + deploy_to_teams(team, config) |
| 52 | + end |
| 53 | + |
| 54 | + defp config_from_args(args) do |
| 55 | + {opts, filename_or_directory} = OptionParser.parse!(args, strict: @switches) |
| 56 | + filename_or_directory = Path.expand(filename_or_directory) |
| 57 | + |
| 58 | + %{ |
| 59 | + path: filename_or_directory, |
| 60 | + session_token: opts[:deploy_key], |
| 61 | + teams_key: opts[:teams_key], |
| 62 | + deployment_group: opts[:deployment_group] |
| 63 | + } |
| 64 | + end |
| 65 | + |
| 66 | + defp ensure_config!(config) do |
| 67 | + log_debug("Validating config from options...") |
| 68 | + |
| 69 | + errors = |
| 70 | + Enum.reduce(config, %{}, fn |
| 71 | + {:session_token, value}, acc when value in ["", nil] -> |
| 72 | + add_error(acc, "Deploy Key", "can't be blank") |
| 73 | + |
| 74 | + {:session_token, value}, acc -> |
| 75 | + if not String.starts_with?(value, @deploy_key_prefix) do |
| 76 | + add_error(acc, "Deploy Key", "must be a Livebook Teams Deploy Key") |
| 77 | + else |
| 78 | + acc |
| 79 | + end |
| 80 | + |
| 81 | + {:teams_key, value}, acc when value in ["", nil] -> |
| 82 | + add_error(acc, "Teams Key", "can't be blank") |
| 83 | + |
| 84 | + {:teams_key, value}, acc -> |
| 85 | + if not String.starts_with?(value, @teams_key_prefix) do |
| 86 | + add_error(acc, "Teams Key", "must be a Livebook Teams Key") |
| 87 | + else |
| 88 | + acc |
| 89 | + end |
| 90 | + |
| 91 | + {:deployment_group, value}, acc when value in ["", nil] -> |
| 92 | + add_error(acc, "Deployment Group", "can't be blank") |
| 93 | + |
| 94 | + {:path, value}, acc when value in ["", nil] -> |
| 95 | + add_error(acc, "Path", "can't be blank") |
| 96 | + |
| 97 | + {:path, value}, acc -> |
| 98 | + if not File.exists?(value) do |
| 99 | + add_error(acc, "Path", "must be a valid path") |
| 100 | + else |
| 101 | + acc |
| 102 | + end |
| 103 | + |
| 104 | + _otherwise, acc -> |
| 105 | + acc |
| 106 | + end) |
| 107 | + |
| 108 | + if Map.keys(errors) == [] do |
| 109 | + :ok |
| 110 | + else |
| 111 | + raise """ |
| 112 | + You configuration is invalid, make sure you are using the correct options for this task. |
| 113 | +
|
| 114 | + #{format_errors(errors)}\ |
| 115 | + """ |
| 116 | + end |
| 117 | + end |
| 118 | + |
| 119 | + defp authenticate_cli!(config) do |
| 120 | + log_debug("Authenticating CLI...") |
| 121 | + |
| 122 | + case Teams.fetch_cli_session(config) do |
| 123 | + {:ok, team} -> team |
| 124 | + {:error, error} -> raise error |
| 125 | + {:transport_error, error} -> raise error |
| 126 | + end |
| 127 | + end |
| 128 | + |
| 129 | + defp deploy_to_teams(team, config) do |
| 130 | + for path <- list_notebooks!(config.path) do |
| 131 | + log_debug("Deploying notebook: #{path}") |
| 132 | + files_dir = Livebook.FileSystem.File.local(path) |
| 133 | + |
| 134 | + with {:ok, content} <- File.read(path), |
| 135 | + {:ok, app_deployment} <- prepare_app_deployment(path, content, files_dir) do |
| 136 | + case Livebook.Teams.deploy_app_from_cli(team, app_deployment, config.deployment_group) do |
| 137 | + {:ok, url} -> print_deployment(app_deployment, url) |
| 138 | + {:error, errors} -> raise format_errors(errors) |
| 139 | + {:transport_error, reason} -> raise reason |
| 140 | + end |
| 141 | + end |
| 142 | + end |
| 143 | + |
| 144 | + :ok |
| 145 | + end |
| 146 | + |
| 147 | + defp list_notebooks!(path) do |
| 148 | + log_debug("Listing notebooks from: #{path}") |
| 149 | + |
| 150 | + files = |
| 151 | + if File.dir?(path) do |
| 152 | + path |
| 153 | + |> File.ls!() |
| 154 | + |> Enum.map(&Path.join(path, &1)) |
| 155 | + |> Enum.reject(&File.dir?/1) |
| 156 | + |> Enum.filter(&String.ends_with?(&1, ".livemd")) |
| 157 | + else |
| 158 | + [path] |
| 159 | + end |
| 160 | + |
| 161 | + if files == [] do |
| 162 | + raise "There's no notebook available to deploy" |
| 163 | + else |
| 164 | + if length(files) == 1 do |
| 165 | + log_debug("Found 1 notebook") |
| 166 | + else |
| 167 | + log_debug("Found #{length(files)} notebooks") |
| 168 | + end |
| 169 | + |
| 170 | + files |
| 171 | + end |
| 172 | + end |
| 173 | + |
| 174 | + defp add_error(errors, key, message) do |
| 175 | + Map.update(errors, key, [message], &[message | &1]) |
| 176 | + end |
| 177 | + |
| 178 | + defp format_errors(%{} = errors_map) do |
| 179 | + errors_map |
| 180 | + |> Enum.map(fn {key, errors} -> |
| 181 | + """ |
| 182 | + * #{key} |
| 183 | + #{format_list(errors)}\ |
| 184 | + """ |
| 185 | + end) |
| 186 | + |> Enum.join("\n") |
| 187 | + end |
| 188 | + |
| 189 | + defp format_list(errors) when is_list(errors) do |
| 190 | + errors |> Enum.map(&" * #{&1}") |> Enum.join("\n") |
| 191 | + end |
| 192 | + |
| 193 | + defp prepare_app_deployment(path, content, files_dir) do |
| 194 | + case Livebook.Teams.AppDeployment.new(content, files_dir) do |
| 195 | + {:ok, app_deployment} -> |
| 196 | + {:ok, app_deployment} |
| 197 | + |
| 198 | + {:warning, warnings} -> |
| 199 | + raise """ |
| 200 | + Deployment for notebook #{Path.basename(path)} failed because the notebook has some warnings: |
| 201 | + #{format_list(warnings)} |
| 202 | + """ |
| 203 | + |
| 204 | + {:error, reason} -> |
| 205 | + raise "Failed to handle I/O operations: #{reason}" |
| 206 | + end |
| 207 | + end |
| 208 | + |
| 209 | + defp print_deployment(app_deployment, url) do |
| 210 | + print_text([ |
| 211 | + :green, |
| 212 | + "App deployment created successfully.\n\n", |
| 213 | + :magenta, |
| 214 | + :bright, |
| 215 | + "Slug: ", |
| 216 | + :reset, |
| 217 | + :white, |
| 218 | + "#{app_deployment.slug} (#{url})\n", |
| 219 | + :magenta, |
| 220 | + :bright, |
| 221 | + "Title: ", |
| 222 | + :reset, |
| 223 | + :white, |
| 224 | + app_deployment.title |
| 225 | + ]) |
| 226 | + end |
| 227 | +end |
0 commit comments