withruntime 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,22 +13,34 @@ module WithRuntime
13
13
  end
14
14
 
15
15
  # Creates a sandbox and, unless +wait: false+, waits until it is running.
16
- # Every field is optional: with none you get the free trial while it lasts,
16
+ # Every field is optional: with none you get the free usage while it lasts,
17
17
  # the default region and a 2 vCPU / 4 GiB machine for up to 30 minutes.
18
- # When every trial slot or the account's quota is taken, it waits for one to
18
+ # When every slot without credit or the account's quota is taken, it waits for one to
19
19
  # free, up to +wait_for_capacity+ seconds (the client's, two minutes by default).
20
20
  #
21
- # runtime.sandboxes.create(funding: "trial", labels: { team: "search" }, timeout_seconds: 900)
21
+ # runtime.sandboxes.create(labels: { team: "search" }, timeout_seconds: 900)
22
+ #
23
+ # +spread_from: [id, ...]+ (one to 16 ids of your own sandboxes or snapshots)
24
+ # starts it on a server none of them is on. With no other server that has
25
+ # room, the create raises +no_capacity+ and nothing is made or charged.
26
+ # +sbx.info.placement+ is an opaque tag for its server: the same tag means
27
+ # the same server.
22
28
  def create(wait: true, idempotency_key: nil, wait_for_capacity: nil, **fields)
23
29
  room = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
24
30
  info = @t.json("POST", "/v1/sandboxes", body: Fields.body(fields), wait: wait ? 60 : 0,
25
31
  key: idempotency_key, room: room)
26
32
  sandbox = Sandbox.new(@t, info)
27
33
  if wait && sandbox.state != "running"
28
- sandbox.wait_for("running", timeout: 60)
34
+ sandbox.wait_for("running", timeout: ApiDefaults::WAIT_FOR_TIMEOUT_SECONDS)
29
35
  unless sandbox.state == "running"
30
- raise Error.new("Sandbox #{sandbox.id} is #{sandbox.state}, not running.", code: "start_failed",
31
- hint: "Read it with runtime.sandboxes.get(id); stop_reason says why.")
36
+ message = if info["replayed"]
37
+ "Sandbox #{sandbox.id} was made by an earlier call with this idempotency key and is #{sandbox.state} now. " \
38
+ "It did not fail to start; create again with a new key."
39
+ else
40
+ "Sandbox #{sandbox.id} is #{sandbox.state}, not running."
41
+ end
42
+ raise Error.new(message, code: "start_failed",
43
+ hint: "Read it with runtime.sandboxes.get(id); stop_reason says why.")
32
44
  end
33
45
  end
34
46
  sandbox
@@ -94,7 +106,7 @@ module WithRuntime
94
106
 
95
107
  # Waits, on the server with no polling, until the sandbox reaches +state+
96
108
  # (running, paused or stopped) or +timeout+ seconds pass.
97
- def wait_for(state, timeout: 60)
109
+ def wait_for(state, timeout: ApiDefaults::WAIT_FOR_TIMEOUT_SECONDS)
98
110
  seconds = [1, timeout.to_i].max
99
111
  @info = Record.new(@t.json("GET", path, query: { "waitFor" => state, "timeoutSeconds" => seconds },
100
112
  timeout: seconds + 60))
@@ -110,19 +122,28 @@ module WithRuntime
110
122
  # Saves the sandbox's memory and files; compute billing stops.
111
123
  def pause(wait: true, idempotency_key: nil) = lifecycle("pause", {}, wait, idempotency_key)
112
124
 
113
- # Carries on a paused sandbox, with its memory and processes; +timeout_seconds+ is its new lease.
114
- def wake(wait: true, timeout_seconds: nil, idempotency_key: nil)
115
- lifecycle("wake", timeout_seconds ? { "timeoutSeconds" => timeout_seconds } : {}, wait, idempotency_key)
125
+ # Carries on a paused sandbox, with its memory and processes;
126
+ # +timeout_seconds+ is its new time limit, for a sandbox that has one.
127
+ # When its server has no room (+no_capacity+) it waits for room, as +create+
128
+ # does, up to +wait_for_capacity+ seconds (the client's, two minutes by
129
+ # default; 0 fails at once), sending the same idempotency key each time.
130
+ def wake(wait: true, timeout_seconds: nil, idempotency_key: nil, wait_for_capacity: nil)
131
+ lifecycle("wake", timeout_seconds ? { "timeoutSeconds" => timeout_seconds } : {}, wait, idempotency_key,
132
+ room: room_for(wait_for_capacity))
116
133
  end
117
134
 
118
- # More time before the lease ends, at most an hour ahead of now.
135
+ # Moves a sandbox's time limit on, at most an hour ahead of now. A sandbox
136
+ # with no time limit answers at once and nothing changes.
119
137
  def extend_lease(seconds, idempotency_key: nil) = lifecycle("extend", { "seconds" => seconds }, false, idempotency_key)
120
138
 
121
139
  # Days (1 to 365) a paused sandbox is kept before it is deleted.
122
140
  def set_retention(days, idempotency_key: nil) = lifecycle("retention", { "days" => days }, false, idempotency_key)
123
141
 
124
142
  # Starts a stopped persistent sandbox again from its disk. Memory is not kept.
125
- def restart(wait: true, idempotency_key: nil) = lifecycle("restart", {}, wait, idempotency_key)
143
+ # Waits for room like +wake+ when its server is full.
144
+ def restart(wait: true, idempotency_key: nil, wait_for_capacity: nil)
145
+ lifecycle("restart", {}, wait, idempotency_key, room: room_for(wait_for_capacity))
146
+ end
126
147
 
127
148
  # Changes name, labels, auto_wake, idle_pause_seconds, persistent or
128
149
  # max_total_cost_micros; fields left out stay as they are, and
@@ -133,12 +154,13 @@ module WithRuntime
133
154
  lifecycle("update", body, false, idempotency_key)
134
155
  end
135
156
 
136
- # Keeps a running sandbox's lease ahead of now on a background thread, until
137
- # +stop+ or +stop_keep_alive+: every +every+ seconds it extends the lease so
138
- # that +margin+ seconds remain, never more than the hour ahead the API
139
- # allows. Running time is billed as it is used. A paused sandbox is left
140
- # paused; a stopped one ends the loop.
141
- def keep_alive(every: 60, margin: 600, &on_error)
157
+ # Keeps a sandbox with a time limit running past it on a background thread,
158
+ # until +stop+ or +stop_keep_alive+: every +every+ seconds it extends the
159
+ # limit so that +margin+ seconds remain, never more than the hour ahead the
160
+ # API allows. A sandbox with no time limit needs none and is only watched.
161
+ # Running time is billed as it is used. A paused sandbox is left paused; a
162
+ # stopped one ends the loop.
163
+ def keep_alive(every: 60, margin: ApiDefaults::KEEP_ALIVE_MARGIN_SECONDS, &on_error)
142
164
  stop_keep_alive
143
165
  every = [10, every].max
144
166
  margin = margin.clamp(60, 3600)
@@ -148,7 +170,7 @@ module WithRuntime
148
170
  refresh
149
171
  break if %w[stopped stopping].include?(state)
150
172
 
151
- expires = @info["expiresAt"]
173
+ expires = Sandbox.limit_end(@info)
152
174
  if state == "running" && expires
153
175
  need = (margin - (Time.parse(expires) - Time.now)).ceil
154
176
  extend_lease([3600, need].min) if need >= 1
@@ -163,6 +185,17 @@ module WithRuntime
163
185
  self
164
186
  end
165
187
 
188
+ # Where a sandbox's time limit ends, or nil when it has none: it renews
189
+ # itself (timeoutSeconds 0, or persistent) unless endsAt says its renewal
190
+ # stopped. An older server sends no endsAt and a timeout of at least a
191
+ # minute.
192
+ def self.limit_end(info)
193
+ return info["endsAt"] if info["endsAt"]
194
+ return nil if info["timeoutSeconds"].to_i.zero? || info["persistent"]
195
+
196
+ info["expiresAt"]
197
+ end
198
+
166
199
  # Ends a keep-alive, if one runs.
167
200
  def stop_keep_alive
168
201
  thread = @keep_alive
@@ -174,16 +207,19 @@ module WithRuntime
174
207
  # processes), each its own sandbox, on the same server, answered once they
175
208
  # run. One copy without +count+; an array with it. If a copy fails, the
176
209
  # error's +details["startedSandboxIds"]+ names the copies that did start.
210
+ # +funding+ is accepted and ignored: copies run on free usage first, then credit.
177
211
  def fork(count: nil, name: nil, labels: nil, keep_snapshot: nil, funding: nil, idempotency_key: nil)
178
- body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot, funding: funding)
212
+ body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot)
179
213
  reply = @t.json("POST", path(":fork"), body: body, wait: 60, key: idempotency_key)
180
214
  copies = reply["sandboxes"].map { |info| Sandbox.new(@t, info) }
181
215
  count.nil? ? copies.first : copies
182
216
  end
183
217
 
184
218
  # Keeps this sandbox's whole machine as a snapshot to start new sandboxes
185
- # from. A running sandbox is paused for the moment it takes, then woken; a
186
- # paused one stays paused. A sandbox with volumes cannot be snapshotted.
219
+ # from. A running sandbox is paused by the server for the moment it takes
220
+ # and woken once the capture is over (one still capturing when the call
221
+ # answers stays paused until it is ready); a paused one stays paused. A
222
+ # sandbox with volumes cannot be snapshotted.
187
223
  def snapshot(name: nil, labels: nil, retention_days: nil, idempotency_key: nil)
188
224
  refresh
189
225
  # Straight after a fork or a wake the sandbox is still resuming, and
@@ -192,15 +228,11 @@ module WithRuntime
192
228
  if %w[resuming starting].include?(state) then wait_for("running")
193
229
  elsif state == "pausing" then wait_for("paused")
194
230
  end
195
- running = state == "running"
196
- pause if running
197
- begin
198
- Record.new(@t.json("POST", path(":snapshot"),
199
- body: Fields.body(name: name, labels: labels, retention_days: retention_days),
200
- wait: 10, key: idempotency_key))
201
- ensure
202
- wake if running
203
- end
231
+ # The server pauses a running sandbox itself: a client pause and wake
232
+ # would be two more round trips.
233
+ Record.new(@t.json("POST", path(":snapshot"),
234
+ body: Fields.body(name: name, labels: labels, retention_days: retention_days),
235
+ wait: 10, key: idempotency_key))
204
236
  end
205
237
 
206
238
  # CPU and memory over a range: 15m, 1h, 6h, 24h, 7d or 30d.
@@ -239,43 +271,89 @@ module WithRuntime
239
271
  idempotency_key: idempotency_key)
240
272
  end
241
273
 
242
- body = command_body(command, cwd, env, stdin, timeout || 86_400).merge("stream" => true)
274
+ longest = ApiDefaults::STREAMED_EXEC_TIMEOUT_SECONDS
275
+ body = command_body(command, cwd, env, stdin, timeout || longest).merge("stream" => true)
243
276
  handed_over = nil
244
- @t.events("POST", path(":exec"), body: body, key: idempotency_key,
245
- timeout: timeout ? timeout + 60 : 86_400) do |event|
246
- case event["type"]
247
- when "continue" then handed_over = event
248
- when "error" then raise stream_error(event)
249
- else yield event
277
+ process_id = nil
278
+ cursor = 0
279
+ exited = false
280
+ begin
281
+ @t.events("POST", path(":exec"), body: body, key: idempotency_key,
282
+ timeout: timeout ? timeout + 60 : longest) do |event|
283
+ case event["type"]
284
+ when "continue" then handed_over = event
285
+ when "error" then raise stream_error(event)
286
+ else
287
+ process_id = event["processId"] if event["type"] == "start"
288
+ cursor = event["offset"] + event["data"].bytesize if %w[stdout stderr].include?(event["type"])
289
+ exited = true if event["type"] == "exit"
290
+ yield event
291
+ end
292
+ break if handed_over || exited
250
293
  end
251
- break if handed_over
294
+ rescue ConnectionError => e
295
+ raise if e.code != "connection_error" || process_id.nil?
296
+ end
297
+ return follow(handed_over["processId"], handed_over["cursor"], &block) if handed_over
298
+ return if exited
299
+
300
+ # The stream closed before the command's exit. Its output is kept on the
301
+ # sandbox, so follow it from where this stream stopped; an end with no
302
+ # exit is never a result.
303
+ if process_id.nil?
304
+ raise ConnectionError.new("The exec stream closed before the command started.",
305
+ code: "connection_error", idempotency_key: idempotency_key,
306
+ hint: "Run it again; to be sure it runs once, pass the same idempotency_key.")
252
307
  end
253
- follow(handed_over["processId"], handed_over["cursor"], &block) if handed_over
308
+ follow(process_id, cursor, &block)
254
309
  end
255
310
 
256
- # A process's output events from +cursor+ until it exits, across the server's stream slices.
311
+ # A process's output events from +cursor+ until it exits, across the
312
+ # server's stream slices. A stream that closes or is cut before the exit
313
+ # is followed again from where it stopped; four in a row with nothing new
314
+ # are an error, never an end.
257
315
  def follow(process_id, cursor = 0)
258
316
  return enum_for(:follow, process_id, cursor) unless block_given?
259
317
 
318
+ idle = 0
260
319
  loop do
261
320
  resume = nil
262
321
  exited = false
263
- @t.events("GET", path("/processes/#{Transport.segment(process_id)}/output"),
264
- query: { "cursor" => cursor, "follow" => "true" }, timeout: 180) do |event|
265
- case event["type"]
266
- when "continue"
267
- resume = event["cursor"]
268
- break
269
- when "stdout", "stderr" then cursor = event["offset"] + event["data"].bytesize
270
- when "error" then raise stream_error(event)
271
- when "exit" then exited = true
322
+ before = cursor
323
+ cut = false
324
+ begin
325
+ @t.events("GET", path("/processes/#{Transport.segment(process_id)}/output"),
326
+ query: { "cursor" => cursor, "follow" => "true" }, timeout: 180) do |event|
327
+ case event["type"]
328
+ when "continue"
329
+ resume = event["cursor"]
330
+ break
331
+ when "stdout", "stderr" then cursor = event["offset"] + event["data"].bytesize
332
+ when "error" then raise stream_error(event)
333
+ when "exit" then exited = true
334
+ end
335
+ yield event
336
+ break if exited
272
337
  end
273
- yield event
274
- break if exited
338
+ rescue ConnectionError => e
339
+ raise if e.code != "connection_error"
340
+
341
+ cut = true
275
342
  end
276
- break if exited || resume.nil?
343
+ break if exited
277
344
 
278
- cursor = resume
345
+ cursor = [cursor, resume].max if resume
346
+ if resume || cursor > before
347
+ idle = 0
348
+ next
349
+ end
350
+ idle += 1
351
+ if idle > 3
352
+ raise ConnectionError.new("The output stream of process #{process_id} keeps closing before it ends.",
353
+ code: "connection_error",
354
+ hint: "Read what it printed so far: runtime sandboxes logs #{id} #{process_id}")
355
+ end
356
+ sleep([0.05 * (2**idle), 2].min) if cut
279
357
  end
280
358
  end
281
359
 
@@ -300,11 +378,13 @@ module WithRuntime
300
378
 
301
379
  private
302
380
 
303
- def lifecycle(verb, body, wait, key)
304
- @info = Record.new(@t.json("POST", path(":#{verb}"), body: body, wait: wait ? 60 : 0, key: key))
381
+ def lifecycle(verb, body, wait, key, room: 0)
382
+ @info = Record.new(@t.json("POST", path(":#{verb}"), body: body, wait: wait ? 60 : 0, key: key, room: room))
305
383
  self
306
384
  end
307
385
 
386
+ def room_for(wait_for_capacity) = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
387
+
308
388
  def command_body(command, cwd, env, stdin, timeout)
309
389
  body = command.is_a?(Array) ? { "argv" => command.map(&:to_s) } : { "command" => command.to_s }
310
390
  body["cwd"] = cwd if cwd
@@ -421,7 +501,7 @@ module WithRuntime
421
501
  end
422
502
 
423
503
  # A file's bytes (a binary String), any size.
424
- def read(path) = t.bytes("GET", @sandbox.path("/files/content"), query: { "path" => path }, accept: "application/octet-stream")
504
+ def read(path) = t.file_bytes(@sandbox.path("/files/content"), query: { "path" => path })
425
505
 
426
506
  def read_text(path) = read(path).force_encoding(Encoding::UTF_8)
427
507
 
@@ -536,20 +616,38 @@ module WithRuntime
536
616
  nil
537
617
  end
538
618
 
539
- # Copies a local file or directory in. A directory travels as one gzipped tar.
619
+ # Copies a local file or directory in. A directory travels as one gzipped
620
+ # tar to the API's folder routes, unpacked by the sandbox's own tar as it
621
+ # arrives.
540
622
  def upload(local, remote)
541
623
  return write(remote, File.binread(local)) unless File.directory?(local)
542
624
 
543
- staging = "/tmp/.runtime-upload-#{SecureRandom.uuid}.tar.gz"
544
- write(staging, Tar.pack_directory(local))
545
- result = @sandbox.exec(["sh", "-c", 'mkdir -p "$1" && tar -xzf "$2" -C "$1"; code=$?; rm -f "$2"; exit $code',
546
- "sh", remote, staging])
547
- raise CommandError, result unless result.ok?
548
-
625
+ archive = Tar.pack_directory(local)
626
+ if archive.bytesize <= CHUNK
627
+ t.bytes("PUT", @sandbox.path("/files/archive"), query: { "path" => remote }, raw: archive)
628
+ return nil
629
+ end
630
+ # Larger: parts in order; a part resent after a lost answer is not written twice.
631
+ upload = t.json("POST", @sandbox.path("/files/archive/uploads"), body: { "path" => remote, "gzip" => true })
632
+ base = @sandbox.path("/files/archive/uploads/#{Transport.segment(upload["uploadId"])}")
633
+ begin
634
+ (0...archive.bytesize).step(upload["chunkBytes"].to_i) do |offset|
635
+ t.bytes("PUT", base, query: { "offset" => offset }, raw: archive.byteslice(offset, upload["chunkBytes"].to_i))
636
+ end
637
+ t.json("POST", "#{base}:commit", body: {})
638
+ rescue StandardError
639
+ begin
640
+ t.json("POST", "#{base}:abort", body: {}, timeout: 10)
641
+ rescue Error
642
+ nil
643
+ end
644
+ raise
645
+ end
549
646
  nil
550
647
  end
551
648
 
552
- # Copies a file or directory out.
649
+ # Copies a file or directory out. A directory comes as one tar from the
650
+ # API's folder route.
553
651
  def download(remote, local)
554
652
  entry = stat(remote)
555
653
  raise NotFoundError.new("#{remote} does not exist.", code: "file_not_found", status: 404) unless entry
@@ -559,18 +657,14 @@ module WithRuntime
559
657
  File.binwrite(local, read(remote))
560
658
  return nil
561
659
  end
562
- staging = "/tmp/.runtime-download-#{SecureRandom.uuid}.tar.gz"
563
- packed = @sandbox.exec(["tar", "-czf", staging, "-C", remote, "."])
564
- raise CommandError, packed unless packed.ok?
565
-
660
+ # The sandbox's own tar packs it as it streams, and it is unpacked as it
661
+ # arrives; one cut short is refused and nothing is put in place.
662
+ unpacker = Tar::Unpacker.new(local)
566
663
  begin
567
- Tar.unpack(read(staging), local)
664
+ t.chunks(@sandbox.path("/files/archive"), query: { "path" => remote, "gzip" => true }) { |chunk| unpacker.feed(chunk) }
665
+ unpacker.finish
568
666
  ensure
569
- begin
570
- remove(staging)
571
- rescue Error
572
- nil # /tmp is cleared with the sandbox.
573
- end
667
+ unpacker.discard
574
668
  end
575
669
  nil
576
670
  end
@@ -587,11 +681,16 @@ module WithRuntime
587
681
  @sandbox = sandbox
588
682
  end
589
683
 
590
- # Shares +port+, or changes its visibility if it is shared already.
591
- # +visibility+ is "private" (the default: a token is needed) or "public";
592
- # +ttl_seconds+ is how long the returned token lasts (60 s to 7 days).
593
- def create(port, visibility: nil, ttl_seconds: nil)
594
- body = { "port" => port, "visibility" => visibility, "ttlSeconds" => ttl_seconds }.compact
684
+ # Shares +port+, or changes its visibility and embed origins if it is
685
+ # shared already. +visibility+ is "private" (the default: a token is
686
+ # needed) or "public"; +ttl_seconds+ is how long the returned token lasts
687
+ # (60 s to 7 days). +embed_origins+ names the sites that may show it in an
688
+ # iframe (["https://app.example.com", "https://*.example.com"]); any other
689
+ # site's iframe is refused. Omitted, a shared port keeps its list; [] is
690
+ # any site, the default.
691
+ def create(port, visibility: nil, ttl_seconds: nil, embed_origins: nil)
692
+ body = { "port" => port, "visibility" => visibility, "ttlSeconds" => ttl_seconds,
693
+ "embedOrigins" => embed_origins }.compact
595
694
  Record.new(t.json("POST", path, body: body))
596
695
  end
597
696