withruntime 0.1.0 → 0.2.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.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "stringio"
5
+ require "tmpdir"
4
6
  require "zlib"
5
7
 
6
8
  module WithRuntime
@@ -65,41 +67,224 @@ module WithRuntime
65
67
  gzip(tar << ("\0".b * 1024))
66
68
  end
67
69
 
68
- # Unpacks a gzipped tar into target, refusing any entry that would land outside it, and links.
70
+ # A folder that did not arrive whole: tar in the sandbox stopped part way
71
+ # (a file it may not read, one that changed as it was read), which ends
72
+ # the gzip stream short, or the connection was lost.
73
+ def cut_short
74
+ Error.new("The folder's archive arrived cut short: tar in the sandbox stopped part way, or the " \
75
+ "connection was lost. Nothing was written.",
76
+ code: "download_incomplete",
77
+ hint: "Try again. If it fails the same way, a file in the folder cannot be read by the " \
78
+ "sandbox user or changes as it is read.")
79
+ end
80
+
81
+ # Unpacks a whole gzipped tar held in memory into target, by Unpacker's rules.
69
82
  def unpack(archive, target)
70
- data = Zlib::GzipReader.new(StringIO.new(archive)).read.b
71
- root = File.expand_path(target)
72
- FileUtils.mkdir_p(root)
73
- field = ->(start, length) { data[start, length].split("\0", 2).first.to_s.force_encoding("UTF-8") }
74
- offset = 0
75
- long_name = nil
76
- while offset + 512 <= data.bytesize
77
- break if data[offset, 512].count("\0") == 512
78
-
79
- size = field.call(offset + 124, 12).strip.then { |text| text.empty? ? 0 : text.to_i(8) }
80
- type = data[offset + 156]
81
- prefix = field.call(offset + 345, 155)
82
- name = long_name || (prefix.empty? ? field.call(offset, 100) : "#{prefix}/#{field.call(offset, 100)}")
83
- long_name = nil
84
- mode = field.call(offset + 100, 8).strip.then { |text| text.empty? ? 0o644 : text.to_i(8) }
85
- body = data[offset + 512, size]
86
- offset += 512 + (((size + 511) / 512) * 512)
87
- if type == "L"
88
- long_name = body.split("\0", 2).first.force_encoding("UTF-8")
89
- next
83
+ unpacker = Unpacker.new(target)
84
+ begin
85
+ unpacker.feed(archive)
86
+ unpacker.finish
87
+ ensure
88
+ unpacker.discard
89
+ end
90
+ end
91
+
92
+ # Unpacks a gzipped tar fed to it a chunk at a time into a target folder,
93
+ # holding no more than a chunk. Entries land only inside the target, and
94
+ # symbolic links are not made; a hard link lands as the file it names. It unpacks into a folder beside the target and moves
95
+ # it into place only once the whole archive arrived (+finish+), so an
96
+ # archive cut short leaves nothing behind that could pass for the folder.
97
+ # A target that exists is merged into, files of the same name replaced,
98
+ # never through a link in it. +discard+ removes what was unpacked if
99
+ # +finish+ was never reached.
100
+ class Unpacker
101
+ def initialize(target)
102
+ @final = File.expand_path(target)
103
+ FileUtils.mkdir_p(File.dirname(@final))
104
+ @staging = Dir.mktmpdir("#{File.basename(@final)}.runtime-partial-", File.dirname(@final))
105
+ @root = File.realpath(@staging)
106
+ @inflate = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
107
+ @held = "".b
108
+ @entry = nil
109
+ @left = 0
110
+ @padding = 0
111
+ @long = nil
112
+ @long_link = nil
113
+ @ended = false
114
+ end
115
+
116
+ def feed(data)
117
+ begin
118
+ @held << @inflate.inflate(data)
119
+ rescue Zlib::Error
120
+ raise Tar.cut_short
121
+ end
122
+ nil while step
123
+ end
124
+
125
+ # Checks the archive arrived whole, then puts it in place.
126
+ def finish
127
+ raise Tar.cut_short unless @inflate.finished? && @ended
128
+
129
+ if File.exist?(@final) || File.symlink?(@final)
130
+ final = File.realpath(@final)
131
+ merge(@root, final, final)
132
+ else
133
+ File.rename(@staging, @final)
90
134
  end
91
- name = name.delete_prefix("./")
92
- next if name.empty? || name == "." || name == "./"
93
-
94
- destination = File.expand_path(name, root)
95
- raise IOError, "Refusing an archive entry outside the target: #{name}" unless destination.start_with?("#{root}/")
96
-
97
- case type
98
- when "5" then FileUtils.mkdir_p(destination)
99
- when "0", "\0", "7"
100
- FileUtils.mkdir_p(File.dirname(destination))
101
- File.binwrite(destination, body)
102
- File.chmod(mode & 0o777, destination)
135
+ end
136
+
137
+ def discard
138
+ @entry[:file]&.close if @entry.is_a?(Hash)
139
+ FileUtils.rm_rf(@staging)
140
+ end
141
+
142
+ private
143
+
144
+ def step
145
+ if @ended
146
+ @held.clear
147
+ return false
148
+ end
149
+ if @entry.nil?
150
+ return false if @held.bytesize < 512
151
+
152
+ header = @held.slice!(0, 512)
153
+ if header.count("\0") == 512
154
+ @ended = true
155
+ else
156
+ begin_entry(header)
157
+ end
158
+ return true
159
+ end
160
+ if @left.positive?
161
+ return false if @held.empty?
162
+
163
+ part = @held.slice!(0, @left)
164
+ @left -= part.bytesize
165
+ @entry[:file]&.write(part)
166
+ @entry[:long]&.<<(part)
167
+ return false if @left.positive?
168
+
169
+ end_body
170
+ return true
171
+ end
172
+ if @padding.positive?
173
+ return false if @held.empty?
174
+
175
+ @padding -= @held.slice!(0, @padding).bytesize
176
+ return false if @padding.positive?
177
+ end
178
+ @entry = nil
179
+ true
180
+ end
181
+
182
+ def field(header, start, length) = header[start, length].split("\0", 2).first.to_s.force_encoding("UTF-8")
183
+
184
+ def begin_entry(header)
185
+ size = if header.getbyte(124) & 0x80 != 0
186
+ header[125, 11].bytes.inject(0) { |sum, byte| (sum << 8) | byte }
187
+ else
188
+ field(header, 124, 12).strip.then { |text| text.empty? ? 0 : text.to_i(8) }
189
+ end
190
+ type = header[156]
191
+ prefix = field(header, 345, 155)
192
+ name = @long || (prefix.empty? ? field(header, 0, 100) : "#{prefix}/#{field(header, 0, 100)}")
193
+ link = @long_link || field(header, 157, 100)
194
+ @long = nil
195
+ @long_link = nil
196
+ @long_link = nil
197
+ mode = field(header, 100, 8).strip.then { |text| text.empty? ? 0o644 : text.to_i(8) }
198
+ @left = size
199
+ @padding = -size % 512
200
+ @entry = {}
201
+ if %w[L K x].include?(type)
202
+ @entry = { long: "".b, type: type }
203
+ else
204
+ name = name.delete_prefix("./")
205
+ unless name.empty? || name == "." || name == "./"
206
+ destination = File.expand_path(name, @root)
207
+ raise IOError, "Refusing an archive entry outside the target: #{name}" unless destination.start_with?("#{@root}/")
208
+
209
+ case type
210
+ when "5" then FileUtils.mkdir_p(destination)
211
+ when "1" then hard_link(destination, link, name)
212
+ when "0", "\0", "7"
213
+ FileUtils.mkdir_p(File.dirname(destination))
214
+ # A name already unpacked may share its file with a hard link;
215
+ # writing through it would change the link's copy too.
216
+ File.unlink(destination) if File.exist?(destination) || File.symlink?(destination)
217
+ @entry = { file: File.open(destination, "wb"), path: destination, mode: mode }
218
+ end
219
+ end
220
+ end
221
+ end_body if @left.zero?
222
+ end
223
+
224
+ def end_body
225
+ if @entry[:file]
226
+ @entry[:file].close
227
+ File.chmod(@entry[:mode] & 0o777, @entry[:path])
228
+ elsif @entry[:long]
229
+ body = @entry[:long]
230
+ case @entry[:type]
231
+ when "L" then @long = body.split("\0", 2).first.force_encoding("UTF-8")
232
+ when "K" then @long_link = body.split("\0", 2).first.force_encoding("UTF-8")
233
+ else @long = pax_path(body)
234
+ end
235
+ end
236
+ @entry = {}
237
+ end
238
+
239
+ # A second name for a file the archive already carried: tar writes the
240
+ # first name as a file and every other as a hard link to it. The link
241
+ # names a plain file unpacked earlier in this archive, or the unpack
242
+ # fails; it never reaches outside or ahead.
243
+ def hard_link(destination, link, name)
244
+ source = File.expand_path(link.delete_prefix("./"), @root)
245
+ unless source.start_with?("#{@root}/") && !link.start_with?("/")
246
+ raise IOError, "Refusing an archive hard link that leads outside the target: #{name} -> #{link}"
247
+ end
248
+ unless File.file?(source) && !File.symlink?(source)
249
+ raise IOError, "Refusing an archive hard link to a file it does not carry: #{name} -> #{link}"
250
+ end
251
+ return if source == destination
252
+
253
+ FileUtils.mkdir_p(File.dirname(destination))
254
+ File.unlink(destination) if File.exist?(destination) || File.symlink?(destination)
255
+ begin
256
+ File.link(source, destination)
257
+ rescue SystemCallError
258
+ FileUtils.cp(source, destination, preserve: true)
259
+ end
260
+ end
261
+
262
+ # A pax header's path record, if it has one.
263
+ def pax_path(body)
264
+ until body.empty?
265
+ length = body[/\A\d+/].to_i
266
+ key, value = body[0, length].split(" ", 2).last.chomp.split("=", 2)
267
+ return value.force_encoding("UTF-8") if key == "path"
268
+
269
+ body = body[length..]
270
+ end
271
+ nil
272
+ end
273
+
274
+ def merge(from, to, root)
275
+ Dir.children(from).each do |name|
276
+ source = File.join(from, name)
277
+ destination = File.join(to, name)
278
+ through = destination.delete_prefix("#{root}/").split("/").each_with_object([root]) { |part, way| way << File.join(way.last, part) }
279
+ if through.drop(1).any? { |path| File.symlink?(path) }
280
+ raise IOError, "Refusing an archive entry that passes through a link: #{destination.delete_prefix("#{root}/")}"
281
+ end
282
+
283
+ if File.directory?(source) && File.directory?(destination)
284
+ merge(source, destination, root)
285
+ else
286
+ File.rename(source, destination)
287
+ end
103
288
  end
104
289
  end
105
290
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "json"
4
5
  require "net/http"
5
6
  require "securerandom"
@@ -10,7 +11,7 @@ module WithRuntime
10
11
  # product: the same rules as the JavaScript, Python, Go and Java SDKs. Writes
11
12
  # carry an idempotency key, made per call and kept across the client's own
12
13
  # retries; transport failures, 429, 502, 503 and 504 are retried with backoff
13
- # and jitter; a create waits out a full trial, quota or region.
14
+ # and jitter; a create waits out full slots without credit, a full quota or a full region.
14
15
  class Transport
15
16
  class OpeningCancelled < IOError; end
16
17
  class Cancellation
@@ -41,6 +42,21 @@ module WithRuntime
41
42
  action&.call
42
43
  end
43
44
  end
45
+ # How long one attempt waits for its answer to start. The API starts every
46
+ # answer within 120 s: Caddy answers 503 itself past that, and a longer call
47
+ # gets its 200 at 90 s and then its JSON (runtime-late-answer). An attempt
48
+ # with no answer by then is talking to a server that is gone, such as a
49
+ # controller that died with the call in flight while its address moved to
50
+ # the new leader: a client that only waits sends nothing the new leader
51
+ # could refuse (the leader drill on vin-5, 4 October 2026). It is sent
52
+ # again under the same key.
53
+ ANSWER_START = 125.0
54
+ class << self
55
+ attr_writer :answer_start
56
+
57
+ def answer_start = @answer_start || ANSWER_START
58
+ end
59
+
44
60
  NETWORK_ERRORS = [IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError,
45
61
  Net::HTTPBadResponse, Net::ProtocolError, EOFError].freeze
46
62
 
@@ -68,8 +84,8 @@ module WithRuntime
68
84
 
69
85
  # The value of a JSON call: a Hash, an Array or nil.
70
86
  def json(method, path, query: nil, body: nil, **options)
71
- with_connection(options) do |http|
72
- response = send_call(http, method, path, query: query, body: body, **options)
87
+ with_connection(options, method) do |http, admitted|
88
+ response = send_call(http, method, path, query: query, body: body, **admitted)
73
89
  text = response.body.to_s
74
90
  text.strip.empty? ? nil : JSON.parse(text)
75
91
  rescue JSON::ParserError => e
@@ -77,14 +93,79 @@ module WithRuntime
77
93
  end
78
94
  end
79
95
 
96
+ # A GET's body to the block a chunk at a time as it arrives. A connection
97
+ # lost part way is raised, never retried, so no chunk is given twice.
98
+ def chunks(path, query: nil, accept: "application/octet-stream", &block)
99
+ with_connection({}, "GET") do |http, admitted|
100
+ send_call(http, "GET", path, query: query, accept: accept, no_retry: true, **admitted, &block)
101
+ end
102
+ nil
103
+ end
104
+
80
105
  def bytes(method, path, query: nil, body: nil, **options)
81
- with_connection(options) do |http|
82
- send_call(http, method, path, query: query, body: body, **options).body.to_s.b
106
+ with_connection(options, method) do |http, admitted|
107
+ send_call(http, method, path, query: query, body: body, **admitted).body.to_s.b
108
+ end
109
+ end
110
+
111
+ # A file's bytes, checked against the length (x-content-length) and
112
+ # SHA-256 (x-content-sha256) the API sent with them: a stream that broke
113
+ # can arrive as one that ended, short. A short or different body is read
114
+ # again twice, then download_incomplete.
115
+ def file_bytes(path, query: nil, accept: "application/octet-stream", **options)
116
+ attempt = 0
117
+ loop do
118
+ response = with_connection(options, "GET") do |http, admitted|
119
+ send_call(http, "GET", path, query: query, accept: accept, **admitted)
120
+ end
121
+ body = response.body.to_s.b
122
+ problem = Transport.check_body(response, body)
123
+ return body unless problem
124
+ raise problem if attempt >= 2
125
+
126
+ sleep(backoff(attempt))
127
+ attempt += 1
128
+ end
129
+ end
130
+
131
+ # Why a whole file body cannot be trusted, or nil when it can.
132
+ def self.check_body(response, body)
133
+ expected = response["x-content-length"]
134
+ if expected&.match?(/\A\d+\z/) && body.bytesize != expected.to_i
135
+ return Error.new("The download ended at #{body.bytesize} of #{expected} bytes.",
136
+ code: "download_incomplete",
137
+ hint: "Read the file again; nothing was written for this read.",
138
+ details: { "received" => body.bytesize, "expected" => expected.to_i })
139
+ end
140
+ sha256 = response["x-content-sha256"]
141
+ return nil if sha256.nil? || sha256.empty? || Digest::SHA256.hexdigest(body).casecmp?(sha256)
142
+
143
+ Error.new("The file's bytes did not match the SHA-256 the API sent with them.",
144
+ code: "download_incomplete", hint: "Read the file again.")
145
+ end
146
+
147
+ # The status a late answer's body failed with, or 200 when it is an
148
+ # answer. An API error is a body of `error` alone, with a code; an answer
149
+ # may carry an `error` of its own (a code cell's exception) beside other
150
+ # fields. A body that is not JSON was cut or garbled: 502.
151
+ def self.late_failure(text)
152
+ body = begin
153
+ JSON.parse(text)
154
+ rescue JSON::ParserError
155
+ return 502
83
156
  end
157
+ return 200 unless body.is_a?(Hash) && body.size == 1
158
+ error = body["error"]
159
+ return 200 unless error.is_a?(Hash) && error["code"].is_a?(String)
160
+
161
+ status = error["status"]
162
+ status.is_a?(Numeric) && status >= 400 ? status.to_i : 500
84
163
  end
85
164
 
86
165
  # Newline-delimited JSON events as they arrive, one Hash per event, on a
87
- # connection of their own.
166
+ # connection of their own. A connection lost after the first event is
167
+ # raised, never retried, so no event is given twice; the caller resumes
168
+ # from its cursor.
88
169
  def events(method, path, query: nil, body: nil, cancel: nil, **options, &block)
89
170
  return enum_for(:events, method, path, query: query, body: body, cancel: cancel, **options) unless block
90
171
 
@@ -205,7 +286,12 @@ module WithRuntime
205
286
  end
206
287
  end
207
288
 
208
- def with_connection(_options)
289
+ # A connection from the pool, waited for within the call's own deadline;
290
+ # the wait is taken off it, so a queued call expires as an unqueued one
291
+ # would, without sending anything.
292
+ def with_connection(options, method)
293
+ limit = options[:timeout] || @timeout
294
+ deadline = now + limit
209
295
  http = @lock.synchronize do
210
296
  loop do
211
297
  break @idle.pop unless @idle.empty?
@@ -213,12 +299,18 @@ module WithRuntime
213
299
  @open += 1
214
300
  break connect
215
301
  end
216
- @freed.wait(@lock)
302
+ left = deadline - now
303
+ raise connection_error(true, method != "GET", options[:key]) if left <= 0
304
+
305
+ @freed.wait(@lock, left)
217
306
  end
218
307
  end
219
308
  healthy = false
220
309
  begin
221
- result = yield http
310
+ left = deadline - now
311
+ raise connection_error(true, method != "GET", options[:key]) if left <= 0
312
+
313
+ result = yield http, options.merge(timeout: left)
222
314
  healthy = true
223
315
  result
224
316
  ensure
@@ -234,6 +326,35 @@ module WithRuntime
234
326
  end
235
327
  end
236
328
 
329
+ def deadline_watchdog(http, seconds)
330
+ Thread.new do
331
+ Thread.current.report_on_exception = false
332
+ sleep(seconds)
333
+ begin
334
+ http.instance_variable_get(:@socket)&.io&.close
335
+ rescue IOError, SystemCallError
336
+ nil
337
+ end
338
+ end
339
+ end
340
+
341
+ # A connection that never opened: no byte of the request left this machine.
342
+ NEVER_OPENED = [Errno::ECONNREFUSED, Errno::ENOENT, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
343
+ Errno::EADDRNOTAVAIL].freeze
344
+
345
+ # Whether a failure, or one of its causes, is a connection that never
346
+ # opened. A timeout or a reset never counts.
347
+ def self.never_sent?(error)
348
+ at = error
349
+ 5.times do
350
+ return false if at.nil?
351
+ return true if NEVER_OPENED.any? { |kind| at.is_a?(kind) }
352
+
353
+ at = at.cause
354
+ end
355
+ false
356
+ end
357
+
237
358
  def backoff(attempt) = [8.0, 0.25 * (2**[attempt, 10].min)].min * (0.5 + rand)
238
359
 
239
360
  # 0.5 s, 1 s, 2 s, 4 s, then every 8 s, jittered, so a queue of CI jobs spreads out.
@@ -262,6 +383,8 @@ module WithRuntime
262
383
  room_attempt = 0
263
384
  payload = raw || (body.nil? ? nil : JSON.generate(body))
264
385
  attempt = 0
386
+ # The last try never left, and was sent again at once.
387
+ resent_at_once = false
265
388
  loop do
266
389
  raise connection_error(true, write, key) if cancel&.cancelled?
267
390
  left = deadline - now
@@ -278,9 +401,22 @@ module WithRuntime
278
401
  request["Content-Type"] = raw ? "application/octet-stream" : "application/json"
279
402
  request.body = payload
280
403
  end
281
- http.read_timeout = left
404
+ # Until its answer starts, a read waits at most answer_start; then the
405
+ # call's whole deadline again.
406
+ bounded = Transport.answer_start < left
407
+ answered = false
408
+ http.read_timeout = bounded ? Transport.answer_start : left
282
409
  http.write_timeout = left if http.respond_to?(:write_timeout=)
410
+ http.open_timeout = [left, 30].min
411
+ # Per-read limits do not bound a body that keeps trickling in: at the
412
+ # deadline the socket is closed, and the read fails as a timeout.
413
+ watchdog = deadline_watchdog(http, left)
283
414
  response = nil
415
+ # A stream that gave its consumer anything is never sent again: that
416
+ # would give the same events twice. The consumer's own errors are its
417
+ # own, never a reason to retry.
418
+ streamed = false
419
+ consumer_error = nil
284
420
  begin
285
421
  open_connection(http, cancel)
286
422
  cancel&.register do
@@ -296,11 +432,27 @@ module WithRuntime
296
432
  raise connection_error(true, write, key) if cancel&.cancelled?
297
433
  if stream
298
434
  http.request(request) do |answer|
435
+ answered = true
436
+ http.read_timeout = [deadline - now, 0.001].max
299
437
  response = answer
300
- answer.read_body(&stream) if answer.code.to_i < 300
438
+ # A late answer is read whole first: it may be a failure.
439
+ if answer.code.to_i < 300 && answer["runtime-late-answer"] != "true"
440
+ answer.read_body do |chunk|
441
+ streamed = true
442
+ begin
443
+ stream.call(chunk)
444
+ rescue Exception => e
445
+ consumer_error = e
446
+ raise
447
+ end
448
+ end
449
+ end
301
450
  end
302
451
  else
303
- response = http.request(request)
452
+ response = http.request(request) do
453
+ answered = true
454
+ http.read_timeout = [deadline - now, 0.001].max
455
+ end
304
456
  end
305
457
  status = response.code.to_i
306
458
  if status >= 300 && status < 400
@@ -308,14 +460,35 @@ module WithRuntime
308
460
  end
309
461
  rescue *NETWORK_ERRORS => e
310
462
  http.finish if http.started?
311
- late = e.is_a?(Timeout::Error) || now >= deadline || cancel&.cancelled?
312
- raise connection_error(late, write, key, e) if late || no_retry || attempt >= max_retries
463
+ raise if e.equal?(consumer_error)
313
464
 
314
- pause(backoff(attempt), deadline, write, key, nil, cancel)
465
+ unanswered = bounded && !answered && e.is_a?(Net::ReadTimeout) && now < deadline
466
+ late = (e.is_a?(Timeout::Error) && !unanswered) || now >= deadline || cancel&.cancelled?
467
+ raise connection_error(late, write, key, e) if late || no_retry || streamed || attempt >= max_retries
468
+
469
+ # A connection that never opened sent no byte of the request, so the
470
+ # next try, which may reach another server, goes at once; once in a
471
+ # row, so an API that is down is not hammered. A reset or a timeout
472
+ # may have reached a server and keeps the backoff and its key's rules.
473
+ at_once = Transport.never_sent?(e) && !resent_at_once
474
+ resent_at_once = at_once
475
+ pause(at_once ? rand * 0.05 : backoff(attempt), deadline, write, key, nil, cancel)
315
476
  attempt += 1
316
477
  next
478
+ ensure
479
+ watchdog.kill
480
+ end
481
+ # An answer that took longer than the API holds its headers (90 s)
482
+ # comes as a 200 sent early, and then its JSON: the answer, or
483
+ # {"error": ...} when the work failed after the status went
484
+ # (runtime-late-answer). A failure is raised as the error it is,
485
+ # never returned as a success.
486
+ late = status < 300 && response["runtime-late-answer"] == "true"
487
+ status = Transport.late_failure(response.body.to_s) if late
488
+ if status < 300
489
+ stream&.call(response.body.to_s) if late
490
+ return response
317
491
  end
318
- return response if status < 300
319
492
 
320
493
  text = response.body.to_s
321
494
  parsed = begin
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithRuntime
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module WithRuntime
8
+ # An account opened with a wallet alone: no key, no sign-in, no browser.
9
+ #
10
+ # opened = WithRuntime::Wallet.open_account(20, accept_terms: true)
11
+ # # send exactly opened["amount"] to one of opened["networks"], then, once paid:
12
+ # key = WithRuntime::Wallet.claim_account(opened["claimCode"])["apiKey"]
13
+ #
14
+ # Opening one means agreeing to https://withruntime.com/legal/terms, so ask the
15
+ # person you work for first. The claim code is shown once and is the only way
16
+ # to the account's key: keep it secret. These two calls go to the website;
17
+ # every call after the key goes to the API as usual.
18
+ module Wallet
19
+ module_function
20
+
21
+ # Opens an account and a stablecoin top-up of +usd+ dollars ($10 to
22
+ # $500: it is a new account's first week). Returns "claimCode", "amount" (the exact token amount, to six
23
+ # decimals), "networks" (an "address" on each and its "tokens") and "payBy".
24
+ def open_account(usd, accept_terms:, auth_url: nil, timeout: 30)
25
+ post(auth_url, "/api/wallet/topups", { "usd" => usd, "acceptTerms" => accept_terms }, timeout)
26
+ end
27
+
28
+ # Once the top-up is paid, {"status" => "paid", "apiKey" => ...}, once.
29
+ # Before that, the top-up's "status"; call again.
30
+ def claim_account(claim_code, auth_url: nil, timeout: 30)
31
+ post(auth_url, "/api/wallet/claim", { "claimCode" => claim_code }, timeout)
32
+ end
33
+
34
+ def origin(auth_url)
35
+ value = auth_url.to_s.empty? ? ENV["RUNTIME_AUTH_URL"].to_s : auth_url
36
+ (value.empty? ? "https://withruntime.com" : value).chomp("/")
37
+ end
38
+
39
+ # One POST, never redirected, with no Authorization header.
40
+ def post(auth_url, path, body, timeout)
41
+ base = origin(auth_url)
42
+ uri = URI("#{base}#{path}")
43
+ http = Net::HTTP.new(uri.host, uri.port, nil)
44
+ http.use_ssl = uri.scheme == "https"
45
+ http.open_timeout = http.read_timeout = http.write_timeout = timeout
46
+ request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
47
+ request.body = JSON.generate(body)
48
+ answer = begin
49
+ http.start { |connection| connection.request(request) }
50
+ rescue *Transport::NETWORK_ERRORS
51
+ raise Error.new("No answer from #{base}.", code: "network", hint: "Retry in a minute.")
52
+ end
53
+ said = begin
54
+ JSON.parse(answer.body.to_s)
55
+ rescue JSON::ParserError
56
+ nil
57
+ end
58
+ said = {} unless said.is_a?(Hash)
59
+ status = answer.code.to_i
60
+ return said if status.between?(200, 299)
61
+
62
+ raise Error.new(said["error"].is_a?(String) ? said["error"] : "The request failed (HTTP #{status}).",
63
+ code: said["code"].is_a?(String) ? said["code"] : (status == 429 ? "rate_limited" : "request_failed"),
64
+ status: status)
65
+ end
66
+ private_class_method :origin, :post
67
+ end
68
+ end
data/lib/withruntime.rb CHANGED
@@ -6,8 +6,9 @@
6
6
  # require "withruntime"
7
7
  #
8
8
  # runtime = WithRuntime::Client.new # RUNTIME_API_KEY, or this machine's saved connection
9
- # sbx = runtime.sandboxes.create(funding: "trial")
10
- # puts sbx.exec("python3 -c 'print(6 * 7)'", check: true).stdout
9
+ # sbx = runtime.sandboxes.create
10
+ # sbx.files.write("/workspace/invoice.py", "print(sum([125, 250, 375]))\n")
11
+ # puts sbx.exec("python3 /workspace/invoice.py", check: true).stdout
11
12
  # sbx.stop
12
13
  #
13
14
  # The guide is at https://withruntime.com/docs/ruby.
@@ -16,10 +17,12 @@ require "openssl"
16
17
 
17
18
  require_relative "withruntime/version"
18
19
  require_relative "withruntime/errors"
20
+ require_relative "withruntime/api_defaults"
19
21
  require_relative "withruntime/record"
20
22
  require_relative "withruntime/fields"
21
23
  require_relative "withruntime/transport"
22
24
  require_relative "withruntime/credentials"
25
+ require_relative "withruntime/wallet"
23
26
  require_relative "withruntime/tar"
24
27
  require_relative "withruntime/sandbox"
25
28
  require_relative "withruntime/products"