Skip to content

Commit 4997e07

Browse files
authored
Deploy notebooks from CLI (#3034)
1 parent 6806ef8 commit 4997e07

19 files changed

Lines changed: 948 additions & 146 deletions

File tree

lib/livebook.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ defmodule Livebook do
157157
config :livebook, teams_url: url, warn_on_live_teams_server: false
158158
end
159159

160+
if System.get_env("LIVEBOOK_TEAMS_AUTH") do
161+
config :livebook, :persist_storage, false
162+
end
163+
160164
if Livebook.Config.boolean!("LIVEBOOK_SHUTDOWN_ENABLED", false) do
161165
config :livebook, :shutdown_callback, {System, :stop, []}
162166
end

lib/livebook/hubs.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ defmodule Livebook.Hubs do
311311
@spec get_app_specs() :: list(Livebook.Apps.AppSpec.t())
312312
def get_app_specs() do
313313
for hub <- get_hubs(),
314+
Provider.connection_spec(hub),
314315
app_spec <- Provider.get_app_specs(hub),
315316
do: app_spec
316317
end

lib/livebook/hubs/team.ex

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -94,22 +94,17 @@ defmodule Livebook.Hubs.Team do
9494
changeset
9595
end
9696
end
97-
98-
@doc """
99-
Returns the public key prefix
100-
"""
101-
@spec public_key_prefix() :: String.t()
102-
def public_key_prefix(), do: "lb_opk_"
10397
end
10498

10599
defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
106100
alias Livebook.Hubs.{Team, TeamClient}
107-
alias Livebook.Teams.Requests
101+
alias Livebook.Teams
108102
alias Livebook.FileSystem
109103
alias Livebook.Secrets.Secret
110104

111-
@teams_key_prefix Livebook.Teams.Org.teams_key_prefix()
112-
@public_key_prefix Team.public_key_prefix()
105+
@teams_key_prefix Teams.Constants.teams_key_prefix()
106+
@public_key_prefix Teams.Constants.public_key_prefix()
107+
@deploy_key_prefix Teams.Constants.deploy_key_prefix()
113108

114109
def load(team, fields) do
115110
{offline?, fields} = Map.pop(fields, :offline?, false)
@@ -137,6 +132,7 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
137132

138133
def type(_team), do: "team"
139134

135+
def connection_spec(%{session_token: @deploy_key_prefix <> _}), do: nil
140136
def connection_spec(team), do: {TeamClient, team}
141137

142138
def disconnect(team), do: TeamClient.stop(team.id)
@@ -160,7 +156,7 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
160156
@teams_key_prefix <> teams_key = team.teams_key
161157
token = Livebook.Stamping.chapoly_encrypt(metadata, notebook_source, teams_key)
162158

163-
case Requests.org_sign(team, token) do
159+
case Teams.Requests.org_sign(team, token) do
164160
{:ok, %{"signature" => token_signature}} ->
165161
stamp = %{"version" => 1, "token" => token, "token_signature" => token_signature}
166162
{:ok, stamp}
@@ -201,23 +197,23 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
201197
def get_secrets(team), do: TeamClient.get_secrets(team.id)
202198

203199
def create_secret(%Team{} = team, %Secret{} = secret) do
204-
case Requests.create_secret(team, secret) do
200+
case Teams.Requests.create_secret(team, secret) do
205201
{:ok, %{"id" => _}} -> :ok
206202
{:error, %{"errors" => errors}} -> {:error, parse_secret_errors(errors)}
207203
any -> any
208204
end
209205
end
210206

211207
def update_secret(%Team{} = team, %Secret{} = secret) do
212-
case Requests.update_secret(team, secret) do
208+
case Teams.Requests.update_secret(team, secret) do
213209
{:ok, %{"id" => _}} -> :ok
214210
{:error, %{"errors" => errors}} -> {:error, parse_secret_errors(errors)}
215211
any -> any
216212
end
217213
end
218214

219215
def delete_secret(%Team{} = team, %Secret{} = secret) do
220-
case Requests.delete_secret(team, secret) do
216+
case Teams.Requests.delete_secret(team, secret) do
221217
{:ok, _} -> :ok
222218
{:error, %{"errors" => errors}} -> {:error, parse_secret_errors(errors)}
223219
any -> any
@@ -227,23 +223,23 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
227223
def get_file_systems(team), do: TeamClient.get_file_systems(team.id)
228224

229225
def create_file_system(%Team{} = team, file_system) do
230-
case Requests.create_file_system(team, file_system) do
226+
case Teams.Requests.create_file_system(team, file_system) do
231227
{:ok, %{"id" => _}} -> :ok
232228
{:error, %{"errors" => errors}} -> {:error, parse_file_system_errors(file_system, errors)}
233229
any -> any
234230
end
235231
end
236232

237233
def update_file_system(%Team{} = team, file_system) do
238-
case Requests.update_file_system(team, file_system) do
234+
case Teams.Requests.update_file_system(team, file_system) do
239235
{:ok, %{"id" => _}} -> :ok
240236
{:error, %{"errors" => errors}} -> {:error, parse_file_system_errors(file_system, errors)}
241237
any -> any
242238
end
243239
end
244240

245241
def delete_file_system(%Team{} = team, file_system) do
246-
case Requests.delete_file_system(team, file_system) do
242+
case Teams.Requests.delete_file_system(team, file_system) do
247243
{:ok, _} -> :ok
248244
{:error, %{"errors" => errors}} -> {:error, parse_file_system_errors(file_system, errors)}
249245
any -> any
@@ -264,12 +260,12 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
264260
end
265261

266262
defp parse_secret_errors(errors_map) do
267-
Requests.to_error_list(Secret, errors_map)
263+
Teams.Requests.to_error_list(Secret, errors_map)
268264
end
269265

270266
defp parse_file_system_errors(%struct{} = file_system, errors_map) do
271267
%{error_field: field} = FileSystem.external_metadata(file_system)
272268
errors_map = Map.new(errors_map, fn {_key, values} -> {field, values} end)
273-
Requests.to_error_list(struct, errors_map)
269+
Teams.Requests.to_error_list(struct, errors_map)
274270
end
275271
end

lib/livebook/storage.ex

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,11 @@ defmodule Livebook.Storage do
186186
# in case it is persisting to disk. terminate/2 is still a no-op.
187187
Process.flag(:trap_exit, true)
188188

189-
table = load_or_create_table()
189+
persist_storage? = Application.get_env(:livebook, :persist_storage, true)
190+
table = load_or_create_table(persist_storage?)
190191
:persistent_term.put(__MODULE__, table)
191192

192-
{:ok, %{table: table}}
193+
{:ok, %{table: table, persist?: persist_storage?}}
193194
end
194195

195196
@impl true
@@ -220,6 +221,10 @@ defmodule Livebook.Storage do
220221
end
221222

222223
@impl true
224+
def handle_continue(:save_to_file, %{persist?: false} = state) do
225+
{:noreply, state}
226+
end
227+
223228
def handle_continue(:save_to_file, %{table: table} = state) do
224229
file_path = String.to_charlist(config_file_path())
225230
:ok = :ets.tab2file(table, file_path)
@@ -228,7 +233,11 @@ defmodule Livebook.Storage do
228233

229234
defp table_name(), do: :persistent_term.get(__MODULE__)
230235

231-
defp load_or_create_table() do
236+
defp load_or_create_table(false) do
237+
:ets.new(__MODULE__, [:protected, :duplicate_bag])
238+
end
239+
240+
defp load_or_create_table(true) do
232241
tab =
233242
if path = config_file_path_for_restore() do
234243
path

lib/livebook/teams.ex

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule Livebook.Teams do
1111
import Ecto.Changeset,
1212
only: [add_error: 3, apply_action: 2, apply_action!: 2, get_field: 2]
1313

14-
@prefix Org.teams_key_prefix()
14+
@teams_key_prefix Teams.Constants.teams_key_prefix()
1515

1616
@doc """
1717
Creates an Org.
@@ -148,7 +148,7 @@ defmodule Livebook.Teams do
148148
Derives the secret and sign secret from given `teams_key`.
149149
"""
150150
@spec derive_key(String.t()) :: bitstring()
151-
def derive_key(@prefix <> teams_key) do
151+
def derive_key(@teams_key_prefix <> teams_key) do
152152
binary_key = Base.url_decode64!(teams_key, padding: false)
153153
Plug.Crypto.KeyGenerator.generate(binary_key, "notebook secret", cache: Plug.Crypto.Keys)
154154
end
@@ -235,6 +235,45 @@ defmodule Livebook.Teams do
235235
TeamClient.get_environment_variables(team.id)
236236
end
237237

238+
@doc """
239+
Fetches the CLI session using a deploy key.
240+
"""
241+
@spec fetch_cli_session(map()) ::
242+
{:ok, Team.t()} | {:error, String.t()} | {:transport_error, String.t()}
243+
def fetch_cli_session(%{session_token: _, teams_key: _} = config) do
244+
with {:ok, %{"name" => name} = attrs} <- Requests.fetch_cli_session(config) do
245+
id = "team-#{name}"
246+
247+
hub =
248+
Hubs.save_hub(%Team{
249+
id: id,
250+
hub_name: name,
251+
hub_emoji: "🚀",
252+
user_id: nil,
253+
org_id: attrs["org_id"],
254+
org_key_id: attrs["org_key_id"],
255+
session_token: config.session_token,
256+
teams_key: config.teams_key,
257+
org_public_key: attrs["public_key"]
258+
})
259+
260+
{:ok, hub}
261+
end
262+
end
263+
264+
@doc """
265+
Deploys the given app deployment to given deployment group using a deploy key.
266+
"""
267+
@spec deploy_app_from_cli(Team.t(), Teams.AppDeployment.t(), String.t()) ::
268+
{:ok, String.t()} | {:error, map()} | {:transport_error, String.t()}
269+
def deploy_app_from_cli(%Team{} = team, %Teams.AppDeployment{} = app_deployment, name) do
270+
case Requests.deploy_app_from_cli(team, app_deployment, name) do
271+
{:ok, %{"url" => url}} -> {:ok, url}
272+
{:error, %{"errors" => errors}} -> {:error, errors}
273+
any -> any
274+
end
275+
end
276+
238277
defp map_teams_field_to_livebook_field(map, teams_field, livebook_field) do
239278
if value = map[teams_field] do
240279
Map.put_new(map, livebook_field, value)

lib/livebook/teams/constants.ex

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
defmodule Livebook.Teams.Constants do
2+
@doc """
3+
Returns the public key prefix
4+
"""
5+
@spec public_key_prefix() :: String.t()
6+
def public_key_prefix(), do: "lb_opk_"
7+
8+
@doc """
9+
Returns the Agent Key prefix
10+
"""
11+
@spec agent_key_prefix() :: String.t()
12+
def agent_key_prefix, do: "lb_ak_"
13+
14+
@doc """
15+
Returns the Deploy Key prefix
16+
"""
17+
@spec deploy_key_prefix() :: String.t()
18+
def deploy_key_prefix, do: "lb_dk_"
19+
20+
@doc """
21+
Returns the Teams Key prefix
22+
"""
23+
@spec teams_key_prefix() :: String.t()
24+
def teams_key_prefix(), do: "lb_tk_"
25+
end

lib/livebook/teams/org.ex

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ defmodule Livebook.Teams.Org do
22
use Ecto.Schema
33
import Ecto.Changeset
44

5-
@prefix "lb_tk_"
6-
75
@type t :: %__MODULE__{
86
id: pos_integer() | nil,
97
emoji: String.t() | nil,
@@ -31,7 +29,7 @@ defmodule Livebook.Teams.Org do
3129
@spec teams_key() :: String.t()
3230
def teams_key() do
3331
key = :crypto.strong_rand_bytes(@secret_key_size)
34-
@prefix <> Base.url_encode64(key, padding: false)
32+
Livebook.Teams.Constants.teams_key_prefix() <> Base.url_encode64(key, padding: false)
3533
end
3634

3735
@doc """
@@ -50,10 +48,4 @@ defmodule Livebook.Teams.Org do
5048
message: "should only contain lowercase alphanumeric characters and dashes"
5149
)
5250
end
53-
54-
@doc """
55-
Returns the teams key prefix
56-
"""
57-
@spec teams_key_prefix() :: String.t()
58-
def teams_key_prefix(), do: @prefix
5951
end

lib/livebook/teams/requests.ex

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule Livebook.Teams.Requests do
55
alias Livebook.Secrets.Secret
66
alias Livebook.Teams
77

8+
@deploy_key_prefix Teams.Constants.deploy_key_prefix()
89
@error_message "Something went wrong, try again later or please file a bug if it persists"
910
@unauthorized_error_message "You are not authorized to perform this action, make sure you have the access and you are not in a Livebook App Server/Offline instance"
1011

@@ -227,6 +228,34 @@ defmodule Livebook.Teams.Requests do
227228
get("/api/v1/org/identity", %{access_token: access_token}, team)
228229
end
229230

231+
@doc """
232+
Send a request to Livebook Team API to return a session using a deploy key.
233+
"""
234+
@spec fetch_cli_session(map()) :: api_result()
235+
def fetch_cli_session(config) do
236+
post("/api/v1/cli/auth", %{}, config)
237+
end
238+
239+
@doc """
240+
Send a request to Livebook Team API to deploy an app using a deploy key.
241+
"""
242+
@spec deploy_app_from_cli(Team.t(), Teams.AppDeployment.t(), String.t()) :: api_result()
243+
def deploy_app_from_cli(team, app_deployment, deployment_group_name) do
244+
secret_key = Teams.derive_key(team.teams_key)
245+
246+
params = %{
247+
title: app_deployment.title,
248+
slug: app_deployment.slug,
249+
multi_session: app_deployment.multi_session,
250+
access_type: app_deployment.access_type,
251+
deployment_group_name: deployment_group_name,
252+
sha: app_deployment.sha
253+
}
254+
255+
encrypted_content = Teams.encrypt(app_deployment.file, secret_key)
256+
upload("/api/v1/cli/org/apps", encrypted_content, params, team)
257+
end
258+
230259
@doc """
231260
Normalizes errors map into errors for the given schema.
232261
"""
@@ -271,7 +300,6 @@ defmodule Livebook.Teams.Requests do
271300
defp upload(path, content, params, team) do
272301
build_req(team)
273302
|> Req.Request.put_header("content-length", "#{byte_size(content)}")
274-
|> Req.Request.put_private(:deploy, true)
275303
|> Req.post(url: path, params: params, body: content)
276304
|> handle_response()
277305
|> dispatch_messages(team)
@@ -280,7 +308,6 @@ defmodule Livebook.Teams.Requests do
280308
defp build_req(team) do
281309
Req.new(base_url: Livebook.Config.teams_url())
282310
|> Req.Request.put_new_header("x-lb-version", Livebook.Config.app_version())
283-
|> Req.Request.append_response_steps(transform_teams_response: &transform_response/1)
284311
|> Livebook.Utils.req_attach_defaults()
285312
|> add_team_auth(team)
286313
end
@@ -291,6 +318,11 @@ defmodule Livebook.Teams.Requests do
291318
Req.Request.append_request_steps(req, unauthorized: &{&1, Req.Response.new(status: 401)})
292319
end
293320

321+
defp add_team_auth(req, %{session_token: @deploy_key_prefix <> _} = team) do
322+
token = "#{team.session_token}:#{Teams.Org.key_hash(%Teams.Org{teams_key: team.teams_key})}"
323+
Req.Request.merge_options(req, auth: {:bearer, token})
324+
end
325+
294326
defp add_team_auth(req, %{user_id: nil} = team) do
295327
agent_name = Livebook.Config.agent_name()
296328
token = "#{team.session_token}:#{agent_name}:#{team.org_id}:#{team.org_key_id}"
@@ -303,17 +335,6 @@ defmodule Livebook.Teams.Requests do
303335
Req.Request.merge_options(req, auth: {:bearer, token})
304336
end
305337

306-
defp transform_response({request, response}) do
307-
case {request, response} do
308-
{request, %{status: 400, body: %{"errors" => %{"detail" => error}}}}
309-
when request.private.deploy ->
310-
{request, %{response | status: 422, body: %{"errors" => %{"file" => [error]}}}}
311-
312-
_otherwise ->
313-
{request, response}
314-
end
315-
end
316-
317338
defp handle_response(response) do
318339
case response do
319340
{:ok, %{status: status} = response} when status in 200..299 -> {:ok, response.body}

0 commit comments

Comments
 (0)