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.
- checksums.yaml +7 -0
- data/GUIDE.md +406 -0
- data/LICENSE +55 -0
- data/lib/withruntime/client.rb +79 -0
- data/lib/withruntime/credentials.rb +85 -0
- data/lib/withruntime/errors.rb +114 -0
- data/lib/withruntime/fields.rb +40 -0
- data/lib/withruntime/file_watch.rb +73 -0
- data/lib/withruntime/images.rb +258 -0
- data/lib/withruntime/network_products.rb +49 -0
- data/lib/withruntime/parity_products.rb +51 -0
- data/lib/withruntime/products.rb +373 -0
- data/lib/withruntime/record.rb +85 -0
- data/lib/withruntime/sandbox.rb +618 -0
- data/lib/withruntime/tar.rb +107 -0
- data/lib/withruntime/transport.rb +362 -0
- data/lib/withruntime/tunnel.rb +275 -0
- data/lib/withruntime/version.rb +5 -0
- data/lib/withruntime/websocket.rb +249 -0
- data/lib/withruntime.rb +32 -0
- metadata +64 -0
|
@@ -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
|