withruntime 0.1.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.
@@ -0,0 +1,618 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module WithRuntime
9
+ # +runtime.sandboxes+: create, find and list sandboxes.
10
+ class Sandboxes
11
+ def initialize(transport)
12
+ @t = transport
13
+ end
14
+
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,
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
19
+ # free, up to +wait_for_capacity+ seconds (the client's, two minutes by default).
20
+ #
21
+ # runtime.sandboxes.create(funding: "trial", labels: { team: "search" }, timeout_seconds: 900)
22
+ def create(wait: true, idempotency_key: nil, wait_for_capacity: nil, **fields)
23
+ room = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
24
+ info = @t.json("POST", "/v1/sandboxes", body: Fields.body(fields), wait: wait ? 60 : 0,
25
+ key: idempotency_key, room: room)
26
+ sandbox = Sandbox.new(@t, info)
27
+ if wait && sandbox.state != "running"
28
+ sandbox.wait_for("running", timeout: 60)
29
+ 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.")
32
+ end
33
+ end
34
+ sandbox
35
+ end
36
+
37
+ # The sandbox named +name+, ready to use: running as it is, woken if paused,
38
+ # restarted if stopped and persistent, or created with the other fields when
39
+ # no sandbox has the name. +sbx.info.reused+ says which.
40
+ def get_or_create(name, **fields) = create(**fields, name: name, get_or_create: true)
41
+
42
+ # Reconnects to a sandbox by id.
43
+ def get(id) = Sandbox.new(@t, @t.json("GET", "/v1/sandboxes/#{Transport.segment(id)}"))
44
+
45
+ # The first page of live sandboxes, oldest first; +each+ walks every page.
46
+ def list(state: nil, include_stopped: false, labels: nil, name: nil, limit: nil)
47
+ query = { "state" => state, "includeStopped" => (include_stopped ? "true" : nil),
48
+ "label" => labels&.map { |key, value| "#{key}:#{value}" }, "name" => name, "limit" => limit }
49
+ page(query, nil)
50
+ end
51
+
52
+ private
53
+
54
+ def page(query, cursor)
55
+ body = @t.json("GET", "/v1/sandboxes", query: query.merge("cursor" => cursor))
56
+ Page.new(body["data"].map { |info| Sandbox.new(@t, info) }, body["nextCursor"]) { |after| page(query, after) }
57
+ end
58
+ end
59
+
60
+ # A running (or stopped) sandbox. Its methods are safe to call from many threads.
61
+ class Sandbox
62
+ attr_reader :files, :previews, :network, :interpreter, :desktop, :info
63
+
64
+ def initialize(transport, info)
65
+ @t = transport
66
+ @info = Record.new(info)
67
+ @files = Files.new(self)
68
+ @previews = Previews.new(self)
69
+ @network = Network.new(self)
70
+ @interpreter = Interpreter.new(self)
71
+ @desktop = Desktop.new(self)
72
+ @keep_alive = nil
73
+ end
74
+
75
+ # :nodoc:
76
+ def transport = @t
77
+ def mounts = Mounts.new(self)
78
+ def mcp = SandboxMCP.new(self)
79
+ def terminal(**options) = Terminal.new(self, **options)
80
+ def open_tunnel = Tunnel.new(self)
81
+ def port_forward(port, host: "127.0.0.1", local_port: 0) = PortForward.new(self, port, host: host, local_port: local_port)
82
+
83
+ def id = @info["id"]
84
+ def state = @info["state"]
85
+
86
+ # :nodoc:
87
+ def path(suffix = "") = "/v1/sandboxes/#{Transport.segment(id)}#{suffix}"
88
+
89
+ # Reads the sandbox again.
90
+ def refresh
91
+ @info = Record.new(@t.json("GET", path))
92
+ self
93
+ end
94
+
95
+ # Waits, on the server with no polling, until the sandbox reaches +state+
96
+ # (running, paused or stopped) or +timeout+ seconds pass.
97
+ def wait_for(state, timeout: 60)
98
+ seconds = [1, timeout.to_i].max
99
+ @info = Record.new(@t.json("GET", path, query: { "waitFor" => state, "timeoutSeconds" => seconds },
100
+ timeout: seconds + 60))
101
+ self
102
+ end
103
+
104
+ # Stops the sandbox and ends its charges. Also ends a keep-alive.
105
+ def stop(wait: true, idempotency_key: nil)
106
+ stop_keep_alive
107
+ lifecycle("stop", {}, wait, idempotency_key)
108
+ end
109
+
110
+ # Saves the sandbox's memory and files; compute billing stops.
111
+ def pause(wait: true, idempotency_key: nil) = lifecycle("pause", {}, wait, idempotency_key)
112
+
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)
116
+ end
117
+
118
+ # More time before the lease ends, at most an hour ahead of now.
119
+ def extend_lease(seconds, idempotency_key: nil) = lifecycle("extend", { "seconds" => seconds }, false, idempotency_key)
120
+
121
+ # Days (1 to 365) a paused sandbox is kept before it is deleted.
122
+ def set_retention(days, idempotency_key: nil) = lifecycle("retention", { "days" => days }, false, idempotency_key)
123
+
124
+ # 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)
126
+
127
+ # Changes name, labels, auto_wake, idle_pause_seconds, persistent or
128
+ # max_total_cost_micros; fields left out stay as they are, and
129
+ # +max_total_cost_micros: :remove+ removes the cap.
130
+ def update(idempotency_key: nil, **settings)
131
+ body = Fields.body(settings.reject { |_key, value| value == :remove })
132
+ body["maxTotalCostMicros"] = nil if settings[:max_total_cost_micros] == :remove
133
+ lifecycle("update", body, false, idempotency_key)
134
+ end
135
+
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)
142
+ stop_keep_alive
143
+ every = [10, every].max
144
+ margin = margin.clamp(60, 3600)
145
+ @keep_alive = Thread.new do
146
+ loop do
147
+ begin
148
+ refresh
149
+ break if %w[stopped stopping].include?(state)
150
+
151
+ expires = @info["expiresAt"]
152
+ if state == "running" && expires
153
+ need = (margin - (Time.parse(expires) - Time.now)).ceil
154
+ extend_lease([3600, need].min) if need >= 1
155
+ end
156
+ rescue Error => e
157
+ on_error&.call(e)
158
+ end
159
+ sleep(every)
160
+ end
161
+ end
162
+ @keep_alive.report_on_exception = false
163
+ self
164
+ end
165
+
166
+ # Ends a keep-alive, if one runs.
167
+ def stop_keep_alive
168
+ thread = @keep_alive
169
+ @keep_alive = nil
170
+ thread&.kill
171
+ end
172
+
173
+ # Starts copies of this sandbox as it is now (files, memory, running
174
+ # processes), each its own sandbox, on the same server, answered once they
175
+ # run. One copy without +count+; an array with it. If a copy fails, the
176
+ # error's +details["startedSandboxIds"]+ names the copies that did start.
177
+ 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)
179
+ reply = @t.json("POST", path(":fork"), body: body, wait: 60, key: idempotency_key)
180
+ copies = reply["sandboxes"].map { |info| Sandbox.new(@t, info) }
181
+ count.nil? ? copies.first : copies
182
+ end
183
+
184
+ # 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.
187
+ def snapshot(name: nil, labels: nil, retention_days: nil, idempotency_key: nil)
188
+ refresh
189
+ # Straight after a fork or a wake the sandbox is still resuming, and
190
+ # after a pause still pausing: wait for where it is going, or a
191
+ # snapshot of it is refused as not paused.
192
+ if %w[resuming starting].include?(state) then wait_for("running")
193
+ elsif state == "pausing" then wait_for("paused")
194
+ 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
204
+ end
205
+
206
+ # CPU and memory over a range: 15m, 1h, 6h, 24h, 7d or 30d.
207
+ def metrics(range: nil) = Record.new(@t.json("GET", path("/metrics"), query: { "range" => range }))
208
+
209
+ # Runs a command and returns its exit code and output. A String runs under
210
+ # +bash -c+; an Array runs the program directly, with no shell.
211
+ #
212
+ # +cwd+ (default /workspace), +env+ (merged over the sandbox's; put secrets
213
+ # here), +stdin+, +timeout+ (seconds, default 60, up to a day; a timeout is
214
+ # a result, not an error), +on_stdout+ and +on_stderr+ (stream the command;
215
+ # the result keeps everything), and +check+ (raise CommandError on a
216
+ # non-zero exit or a timeout).
217
+ def exec(command, cwd: nil, env: nil, stdin: nil, timeout: nil, on_stdout: nil, on_stderr: nil,
218
+ check: false, idempotency_key: nil)
219
+ streamed = on_stdout || on_stderr || (timeout && timeout > 60)
220
+ result = if streamed
221
+ collect(exec_stream(command, cwd: cwd, env: env, stdin: stdin, timeout: timeout,
222
+ idempotency_key: idempotency_key), on_stdout, on_stderr)
223
+ else
224
+ body = command_body(command, cwd, env, stdin, timeout)
225
+ CommandResult.new(@t.json("POST", path(":exec"), body: body, key: idempotency_key,
226
+ timeout: timeout && (timeout + 60)))
227
+ end
228
+ raise CommandError, result if check && !result.ok?
229
+
230
+ result
231
+ end
232
+
233
+ # Runs a command and yields its events as they happen: start, stdout,
234
+ # stderr, exit (each a Hash). Resumes by itself when the server ends a long
235
+ # stream, so it never drops output. Without a block, an Enumerator.
236
+ def exec_stream(command, cwd: nil, env: nil, stdin: nil, timeout: nil, idempotency_key: nil, &block)
237
+ unless block
238
+ return enum_for(:exec_stream, command, cwd: cwd, env: env, stdin: stdin, timeout: timeout,
239
+ idempotency_key: idempotency_key)
240
+ end
241
+
242
+ body = command_body(command, cwd, env, stdin, timeout || 86_400).merge("stream" => true)
243
+ 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
250
+ end
251
+ break if handed_over
252
+ end
253
+ follow(handed_over["processId"], handed_over["cursor"], &block) if handed_over
254
+ end
255
+
256
+ # A process's output events from +cursor+ until it exits, across the server's stream slices.
257
+ def follow(process_id, cursor = 0)
258
+ return enum_for(:follow, process_id, cursor) unless block_given?
259
+
260
+ loop do
261
+ resume = nil
262
+ 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
272
+ end
273
+ yield event
274
+ break if exited
275
+ end
276
+ break if exited || resume.nil?
277
+
278
+ cursor = resume
279
+ end
280
+ end
281
+
282
+ # Starts a background process (a server, a watcher, a REPL) and returns at
283
+ # once. It outlives your connection; +process(id)+ gets it back.
284
+ # +pipe_stdin: true+ keeps input open for +write+; +pty: { cols:, rows: }+
285
+ # gives it a terminal.
286
+ def spawn(command, cwd: nil, env: nil, timeout: nil, stdin: nil, pipe_stdin: false, pty: nil, idempotency_key: nil)
287
+ body = command_body(command, cwd, env, pipe_stdin ? nil : stdin, timeout)
288
+ body["stdinMode"] = "pipe" if pipe_stdin
289
+ body["pty"] = Fields.body(pty) if pty
290
+ SandboxProcess.new(self, @t.json("POST", path("/processes"), body: body, key: idempotency_key))
291
+ end
292
+
293
+ # The sandbox's background processes.
294
+ def processes = @t.json("GET", path("/processes"))["data"].map { |info| Record.new(info) }
295
+
296
+ # Gets a background process back by id.
297
+ def process(id) = SandboxProcess.new(self, @t.json("GET", path("/processes/#{Transport.segment(id)}")))
298
+
299
+ def inspect = "#<WithRuntime::Sandbox #{id} #{state}>"
300
+
301
+ private
302
+
303
+ def lifecycle(verb, body, wait, key)
304
+ @info = Record.new(@t.json("POST", path(":#{verb}"), body: body, wait: wait ? 60 : 0, key: key))
305
+ self
306
+ end
307
+
308
+ def command_body(command, cwd, env, stdin, timeout)
309
+ body = command.is_a?(Array) ? { "argv" => command.map(&:to_s) } : { "command" => command.to_s }
310
+ body["cwd"] = cwd if cwd
311
+ body["env"] = env.to_h { |key, value| [key.to_s, value.to_s] } if env
312
+ body["stdinBase64"] = [stdin.to_s.b].pack("m0") unless stdin.nil?
313
+ body["timeoutMs"] = (timeout * 1000).round if timeout
314
+ body
315
+ end
316
+
317
+ def collect(events, on_stdout, on_stderr)
318
+ stdout = +""
319
+ stderr = +""
320
+ fields = {}
321
+ dropped = false
322
+ events.each do |event|
323
+ case event["type"]
324
+ when "start" then fields["processId"] = event["processId"]
325
+ when "stdout"
326
+ stdout << event["data"]
327
+ on_stdout&.call(event["data"])
328
+ when "stderr"
329
+ stderr << event["data"]
330
+ on_stderr&.call(event["data"])
331
+ when "truncated" then dropped = true
332
+ when "exit" then fields.merge!(event.slice("exitCode", "timedOut", "durationMs"))
333
+ end
334
+ end
335
+ # A truncated event does not say which stream lost bytes, so both flags carry it.
336
+ CommandResult.new(fields.merge("stdout" => stdout, "stderr" => stderr,
337
+ "stdoutTruncated" => dropped, "stderrTruncated" => dropped))
338
+ end
339
+
340
+ def stream_error(event)
341
+ error = event["error"].is_a?(Hash) ? event["error"] : {}
342
+ Error.new(error["message"] || "The output stream failed.", code: error["code"] || "stream_failed",
343
+ request_id: error["requestId"])
344
+ end
345
+ end
346
+
347
+ # A background process in a sandbox: its output, its input, its end. Use one
348
+ # from one thread at a time.
349
+ class SandboxProcess
350
+ attr_reader :info
351
+
352
+ def initialize(sandbox, info)
353
+ @sandbox = sandbox
354
+ @info = Record.new(info)
355
+ @input_offset = info["stdinOffset"].to_i
356
+ end
357
+
358
+ def id = @info["id"]
359
+
360
+ # Every output event from +cursor+ (0 for the start) until the process exits.
361
+ def output(cursor = 0, &block) = @sandbox.follow(id, cursor, &block)
362
+
363
+ # Waits for the process to end and returns its result.
364
+ def wait
365
+ stdout = +""
366
+ stderr = +""
367
+ fields = { "processId" => id }
368
+ dropped = false
369
+ output(0) do |event|
370
+ case event["type"]
371
+ when "stdout" then stdout << event["data"]
372
+ when "stderr" then stderr << event["data"]
373
+ when "truncated" then dropped = true
374
+ when "exit" then fields.merge!(event.slice("exitCode", "timedOut", "durationMs"))
375
+ end
376
+ end
377
+ CommandResult.new(fields.merge("stdout" => stdout, "stderr" => stderr,
378
+ "stdoutTruncated" => dropped, "stderrTruncated" => dropped))
379
+ end
380
+
381
+ # Sends input. Offsets are tracked for you, so a retried write is never
382
+ # typed twice. +eof: true+ closes the input after it.
383
+ def write(data, eof: false)
384
+ data = data.to_s.b
385
+ sent = 0
386
+ loop do
387
+ body = { "base64" => [data.byteslice(sent..)].pack("m0"), "offset" => @input_offset }
388
+ body["eof"] = true if eof
389
+ reply = @sandbox.transport.json("POST", path(":write"), body: body)
390
+ progress = reply["offset"] - @input_offset
391
+ sent += progress
392
+ @input_offset = reply["offset"]
393
+ return self if sent >= data.bytesize
394
+ raise Error.new("The process took none of the input.", code: "write_stalled") if progress <= 0
395
+ end
396
+ end
397
+
398
+ # Sends a signal: SIGTERM, SIGKILL, SIGINT, SIGHUP, SIGQUIT, SIGUSR1 or SIGUSR2.
399
+ def kill(signal = "SIGTERM") = @sandbox.transport.json("POST", path(":signal"), body: { "signal" => signal })
400
+
401
+ # Changes a PTY process's terminal size.
402
+ def resize(cols, rows) = @sandbox.transport.json("POST", path(":resize"), body: { "cols" => cols, "rows" => rows })
403
+
404
+ # Reads the process again.
405
+ def refresh
406
+ @info = Record.new(@sandbox.transport.json("GET", path))
407
+ self
408
+ end
409
+
410
+ private
411
+
412
+ def path(suffix = "") = @sandbox.path("/processes/#{Transport.segment(id)}#{suffix}")
413
+ end
414
+
415
+ # +sbx.files+: files in a sandbox. Paths are absolute; any path the sandbox user may use.
416
+ class Files
417
+ CHUNK = 1 << 20
418
+
419
+ def initialize(sandbox)
420
+ @sandbox = sandbox
421
+ end
422
+
423
+ # 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")
425
+
426
+ def read_text(path) = read(path).force_encoding(Encoding::UTF_8)
427
+
428
+ # Writes a file of any size, atomically, making parent directories. A file
429
+ # over 1 MiB goes in parallel 1 MiB chunks checked against its SHA-256.
430
+ def write(path, data, mode: nil, idempotency_key: nil)
431
+ data = data.to_s.b
432
+ mode = mode_string(mode) unless mode.nil?
433
+ if data.bytesize <= CHUNK
434
+ t.bytes("PUT", @sandbox.path("/files/content"), query: { "path" => path, "mode" => mode }.compact, raw: data, key: idempotency_key)
435
+ return nil
436
+ end
437
+ root = idempotency_key || SecureRandom.uuid
438
+ phase = ->(name) { Digest::SHA256.hexdigest("files.write:#{root}:#{name}") }
439
+ body = { "path" => path, "size" => data.bytesize, "sha256" => Digest::SHA256.hexdigest(data), "mode" => mode }.compact
440
+ begin
441
+ upload = t.json("POST", @sandbox.path("/uploads"), body: body, key: phase.call("begin"))
442
+ rescue Error => error
443
+ raise unless mode && error.code == "guest_upgrade_required"
444
+ upload = t.json("POST", @sandbox.path("/uploads"), body: body.reject { |key, _| key == "mode" }, key: phase.call("legacy-begin"))
445
+ end
446
+ chunk = upload["chunkBytes"].to_i
447
+ raise Error.new("The upload omitted its ID or chunk size.", code: "unexpected_answer") unless chunk.positive? && upload["uploadId"]
448
+ base = @sandbox.path("/uploads/#{Transport.segment(upload["uploadId"])}")
449
+ commit = -> { t.json("POST", "#{base}:commit", body: {}, key: phase.call("commit")) }
450
+ committed = false
451
+ if upload["replayed"]
452
+ begin
453
+ commit.call
454
+ committed = true
455
+ rescue Error => error
456
+ raise unless error.code == "upload_incomplete"
457
+ end
458
+ end
459
+ unless committed
460
+ offsets = Queue.new
461
+ (0...data.bytesize).step(chunk) { |offset| offsets << offset }
462
+ offsets.close
463
+ failure = nil
464
+ lock = Mutex.new
465
+ workers = Array.new(4) do
466
+ Thread.new do
467
+ while !lock.synchronize { failure } && (offset = offsets.pop)
468
+ begin
469
+ t.bytes("PUT", base, query: { "offset" => offset }, raw: data.byteslice(offset, chunk))
470
+ rescue StandardError => error
471
+ lock.synchronize { failure ||= error }
472
+ end
473
+ end
474
+ end
475
+ end
476
+ begin
477
+ workers.each(&:join)
478
+ raise failure if failure
479
+ commit.call
480
+ rescue StandardError
481
+ begin
482
+ t.json("POST", "#{base}:abort", body: {}, key: phase.call("abort"), timeout: 10)
483
+ rescue Error
484
+ nil
485
+ end
486
+ raise
487
+ end
488
+ end
489
+ t.json("POST", @sandbox.path("/files:chmod"), body: { "path" => path, "mode" => mode }, key: phase.call("chmod")) if mode && upload["mode"] != mode
490
+ nil
491
+ end
492
+
493
+ def chmod(path, mode)
494
+ t.json("POST", @sandbox.path("/files:chmod"), body: { "path" => path, "mode" => mode_string(mode) })
495
+ nil
496
+ end
497
+ def watches = WatchService.new(@sandbox)
498
+ def watch(path, **options) = watches.start(path, **options)
499
+ def mode_string(mode)
500
+ raise ArgumentError, "mode must be an integer from 000 to 777" unless mode.is_a?(Integer) && mode.between?(0, 0o777)
501
+ format("%03o", mode)
502
+ end
503
+ private :mode_string
504
+
505
+ # A directory's entries (default /workspace). +depth:+ goes deeper; +glob:+
506
+ # filters, e.g. "**/*.py".
507
+ def list(directory = "/workspace", depth: nil, glob: nil, hidden: nil, limit: nil)
508
+ query = { "path" => directory, "depth" => depth, "glob" => glob, "hidden" => hidden, "limit" => limit }
509
+ t.json("GET", @sandbox.path("/files/list"), query: query)["data"].map { |entry| Record.new(entry) }
510
+ end
511
+
512
+ def glob(pattern, root = "/workspace") = list(root, glob: pattern)
513
+
514
+ # A file's entry, or nil when it does not exist.
515
+ def stat(path)
516
+ answer = t.json("GET", @sandbox.path("/files/stat"), query: { "path" => path })
517
+ answer["exists"] ? Record.new(answer) : nil
518
+ end
519
+
520
+ def exist?(path) = !stat(path).nil?
521
+
522
+ def mkdir(path, parents: true)
523
+ t.json("POST", @sandbox.path("/files:mkdir"), body: { "path" => path, "parents" => (parents || nil) }.compact)
524
+ nil
525
+ end
526
+
527
+ # Removes a file, or a directory with +recursive: true+. Says whether anything was there.
528
+ def remove(path, recursive: false)
529
+ body = { "path" => path, "recursive" => (recursive || nil) }.compact
530
+ t.json("POST", @sandbox.path("/files:remove"), body: body)["removed"] == true
531
+ end
532
+
533
+ def rename(from, to, overwrite: false)
534
+ body = { "from" => from, "to" => to, "overwrite" => (overwrite || nil) }.compact
535
+ t.json("POST", @sandbox.path("/files:rename"), body: body)
536
+ nil
537
+ end
538
+
539
+ # Copies a local file or directory in. A directory travels as one gzipped tar.
540
+ def upload(local, remote)
541
+ return write(remote, File.binread(local)) unless File.directory?(local)
542
+
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
+
549
+ nil
550
+ end
551
+
552
+ # Copies a file or directory out.
553
+ def download(remote, local)
554
+ entry = stat(remote)
555
+ raise NotFoundError.new("#{remote} does not exist.", code: "file_not_found", status: 404) unless entry
556
+
557
+ if entry["type"] != "directory"
558
+ FileUtils.mkdir_p(File.dirname(File.expand_path(local)))
559
+ File.binwrite(local, read(remote))
560
+ return nil
561
+ 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
+
566
+ begin
567
+ Tar.unpack(read(staging), local)
568
+ ensure
569
+ begin
570
+ remove(staging)
571
+ rescue Error
572
+ nil # /tmp is cleared with the sandbox.
573
+ end
574
+ end
575
+ nil
576
+ end
577
+
578
+ private
579
+
580
+ def t = @sandbox.transport
581
+ end
582
+
583
+ # +sbx.previews+: share ports of the sandbox at public HTTPS addresses under
584
+ # runtimehost.com. WebSockets work; the server must listen on 0.0.0.0 or localhost.
585
+ class Previews
586
+ def initialize(sandbox)
587
+ @sandbox = sandbox
588
+ end
589
+
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
595
+ Record.new(t.json("POST", path, body: body))
596
+ end
597
+
598
+ # Every shared port, each private one with a fresh token.
599
+ def list = t.json("GET", path)["data"].map { |preview| Record.new(preview) }
600
+
601
+ # One preview, with a fresh token of +ttl_seconds+ if it is private.
602
+ def get(port, ttl_seconds: nil) = Record.new(t.json("GET", path("/#{port.to_i}"), query: { "ttlSeconds" => ttl_seconds }))
603
+
604
+ # Refuses every token issued for this port so far and returns a new one.
605
+ def rotate(port) = Record.new(t.json("POST", path("/#{port.to_i}:rotate")))
606
+
607
+ # Stops sharing +port+. Open connections close within seconds.
608
+ def delete(port)
609
+ t.json("DELETE", path("/#{port.to_i}"))
610
+ nil
611
+ end
612
+
613
+ private
614
+
615
+ def t = @sandbox.transport
616
+ def path(suffix = "") = @sandbox.path("/previews#{suffix}")
617
+ end
618
+ end