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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "zlib"
5
+
6
+ module WithRuntime
7
+ # A small ustar writer and reader for directory uploads, downloads and build contexts.
8
+ module Tar
9
+ module_function
10
+
11
+ def header(name, size, mode, type, mtime = 0)
12
+ bytes = name.b
13
+ prefix = "".b
14
+ if bytes.bytesize > 100
15
+ split = name.rindex("/", 155)
16
+ raise ArgumentError, "Path too long for a tar archive: #{name}" if split.nil? || split <= 0 || name[(split + 1)..].bytesize > 100
17
+
18
+ prefix = name[0, split].b
19
+ bytes = name[(split + 1)..].b
20
+ end
21
+ block = "\0".b * 512
22
+ put = ->(offset, value, length) { block[offset, [length, value.bytesize].min] = value.b[0, length] }
23
+ octal = ->(value, length) { "#{value.to_s(8).rjust(length - 1, "0")}\0" }
24
+ put.call(0, bytes, 100)
25
+ put.call(100, octal.call(mode & 0o7777, 8), 8)
26
+ put.call(108, octal.call(0, 8), 8)
27
+ put.call(116, octal.call(0, 8), 8)
28
+ put.call(124, octal.call(size, 12), 12)
29
+ put.call(136, octal.call(mtime, 12), 12)
30
+ put.call(148, " " * 8, 8)
31
+ put.call(156, type, 1)
32
+ put.call(257, "ustar\u000000", 8)
33
+ put.call(345, prefix, 155)
34
+ put.call(148, "#{block.sum(64).to_s(8).rjust(6, "0")}\0 ", 8)
35
+ block
36
+ end
37
+
38
+ def pad(size) = "\0".b * ((512 - (size % 512)) % 512)
39
+
40
+ def gzip(data)
41
+ out = StringIO.new("".b)
42
+ writer = Zlib::GzipWriter.new(out, 6)
43
+ writer.mtime = 0
44
+ writer.write(data)
45
+ writer.close
46
+ out.string
47
+ end
48
+
49
+ # A directory as a gzipped tar: directories and regular files; links stay behind.
50
+ def pack_directory(root)
51
+ tar = "".b
52
+ base = File.expand_path(root)
53
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: base).sort.each do |relative|
54
+ next if relative.split("/").any? { |part| [".", ".."].include?(part) }
55
+
56
+ full = File.join(base, relative)
57
+ stat = File.lstat(full)
58
+ if stat.directory?
59
+ tar << header("#{relative}/", 0, stat.mode, "5", stat.mtime.to_i)
60
+ elsif stat.file?
61
+ data = File.binread(full)
62
+ tar << header(relative, data.bytesize, stat.mode, "0", stat.mtime.to_i) << data << pad(data.bytesize)
63
+ end
64
+ end
65
+ gzip(tar << ("\0".b * 1024))
66
+ end
67
+
68
+ # Unpacks a gzipped tar into target, refusing any entry that would land outside it, and links.
69
+ 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
90
+ 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)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module WithRuntime
9
+ # One connection pool, one retry policy and one error shape for every
10
+ # product: the same rules as the JavaScript, Python, Go and Java SDKs. Writes
11
+ # carry an idempotency key, made per call and kept across the client's own
12
+ # 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
+ class Transport
15
+ class OpeningCancelled < IOError; end
16
+ class Cancellation
17
+ def initialize
18
+ @lock = Mutex.new
19
+ @cancelled = false
20
+ @action = nil
21
+ @changed = ConditionVariable.new
22
+ end
23
+ def cancelled? = @lock.synchronize { @cancelled }
24
+ def register(&action)
25
+ run = @lock.synchronize { @action = action; @cancelled }
26
+ action.call if run
27
+ end
28
+ def wait(seconds)
29
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
30
+ @lock.synchronize do
31
+ until @cancelled
32
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ return true if left <= 0
34
+ @changed.wait(@lock, left)
35
+ end
36
+ end
37
+ false
38
+ end
39
+ def cancel
40
+ action = @lock.synchronize { @cancelled = true; @changed.broadcast; @action }
41
+ action&.call
42
+ end
43
+ end
44
+ NETWORK_ERRORS = [IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError,
45
+ Net::HTTPBadResponse, Net::ProtocolError, EOFError].freeze
46
+
47
+ attr_reader :base_url, :api_key, :timeout, :max_retries, :wait_for_capacity
48
+
49
+ def initialize(api_key:, base_url:, timeout:, max_retries:, max_connections:, wait_for_capacity:)
50
+ @api_key = api_key
51
+ @base_url = base_url
52
+ @uri = URI(base_url)
53
+ @timeout = timeout
54
+ @max_retries = max_retries
55
+ @wait_for_capacity = wait_for_capacity
56
+ @limit = [1, max_connections].max
57
+ @idle = []
58
+ @open = 0
59
+ @lock = Mutex.new
60
+ @freed = ConditionVariable.new
61
+ @client = "sdk-ruby/#{VERSION}"
62
+ end
63
+
64
+ # Percent-encodes one path segment, as encodeURIComponent does.
65
+ def self.segment(value)
66
+ value.to_s.b.gsub(/[^A-Za-z0-9\-_.!~*'()]/) { |c| format("%%%02X", c.ord) }
67
+ end
68
+
69
+ # The value of a JSON call: a Hash, an Array or nil.
70
+ 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)
73
+ text = response.body.to_s
74
+ text.strip.empty? ? nil : JSON.parse(text)
75
+ rescue JSON::ParserError => e
76
+ raise Error.new("Unexpected answer from #{method} #{path}: #{e.message}", code: "unexpected_answer")
77
+ end
78
+ end
79
+
80
+ 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
83
+ end
84
+ end
85
+
86
+ # Newline-delimited JSON events as they arrive, one Hash per event, on a
87
+ # connection of their own.
88
+ def events(method, path, query: nil, body: nil, cancel: nil, **options, &block)
89
+ return enum_for(:events, method, path, query: query, body: body, cancel: cancel, **options) unless block
90
+
91
+ cancellation = Cancellation.new
92
+ cancel&.register { cancellation.cancel }
93
+ http = connect
94
+ begin
95
+ buffer = +""
96
+ send_call(http, method, path, query: query, body: body, accept: "application/x-ndjson", cancel: cancellation, **options) do |chunk|
97
+ buffer << chunk
98
+ while (newline = buffer.index("\n"))
99
+ line = buffer.slice!(0, newline + 1).strip
100
+ yield JSON.parse(line) unless line.empty?
101
+ end
102
+ end
103
+ yield JSON.parse(buffer) unless buffer.strip.empty?
104
+ rescue JSON::ParserError => e
105
+ raise Error.new("Unexpected stream line: #{e.message}", code: "unexpected_answer")
106
+ ensure
107
+ cancellation.cancel
108
+ begin
109
+ http.finish if http.started?
110
+ rescue IOError
111
+ nil
112
+ end
113
+ end
114
+ end
115
+
116
+ def connection_error(timed_out, write, key, cause = nil)
117
+ if timed_out
118
+ ConnectionError.new("The call was cancelled or ran past its deadline.", code: "timeout",
119
+ hint: "Allow a longer timeout for a long call, or retry with the same idempotency key.",
120
+ idempotency_key: key)
121
+ else
122
+ message = "No answer from Runtime at #{base_url}."
123
+ message += " The change may have happened; retrying with the same idempotency key is safe." if write
124
+ message += " (#{cause.class}: #{cause.message})" if cause
125
+ ConnectionError.new(message, code: "connection_error",
126
+ hint: "Check the network, HTTPS_PROXY, and RUNTIME_API_URL if you set it.",
127
+ idempotency_key: key)
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ # HTTPS_PROXY (or HTTP_PROXY for an http origin) and NO_PROXY, read the
134
+ # way curl reads them. Net::HTTP's own :ENV reads http_proxy even for HTTPS.
135
+ def self.proxy_for(uri, env = ENV)
136
+ names = uri.scheme == "https" ? %w[HTTPS_PROXY https_proxy] : %w[HTTP_PROXY http_proxy]
137
+ value = names.map { |name| env[name] }.find { |found| found && !found.strip.empty? }
138
+ return nil unless value
139
+
140
+ # URI's matcher knows neither curl's "*" (no proxy at all) nor "*.example.com".
141
+ exempt = (env["NO_PROXY"] || env["no_proxy"]).to_s.split(",").map(&:strip).reject(&:empty?)
142
+ return nil if exempt.include?("*")
143
+ exempt = exempt.map { |entry| entry.delete_prefix("*") }.join(",")
144
+ return nil if !exempt.empty? && !URI::Generic.use_proxy?(uri.host, uri.host, uri.port, exempt)
145
+
146
+ value = "http://#{value}" unless value.include?("://")
147
+ URI(value.strip)
148
+ end
149
+
150
+ def connect
151
+ proxy = Transport.proxy_for(@uri)
152
+ http = if proxy
153
+ Net::HTTP.new(@uri.host, @uri.port, proxy.host, proxy.port,
154
+ proxy.user && URI.decode_www_form_component(proxy.user),
155
+ proxy.password && URI.decode_www_form_component(proxy.password))
156
+ else
157
+ Net::HTTP.new(@uri.host, @uri.port, nil)
158
+ end
159
+ http.use_ssl = @uri.scheme == "https"
160
+ http.max_retries = 0
161
+ http.open_timeout = 30
162
+ http.keep_alive_timeout = 30
163
+ http
164
+ end
165
+
166
+ # Net::HTTP keeps its socket local until TCP, proxy CONNECT and TLS finish.
167
+ # Keep that opening alone in an owned worker: raising an IOError there runs
168
+ # Net::HTTP's connect rescue and closes its local socket. Never interrupt the
169
+ # caller's event block, and join before returning so cancellation leaks no
170
+ # opening thread or socket.
171
+ def open_connection(http, cancel)
172
+ return if http.started?
173
+ return http.start unless cancel
174
+
175
+ opened = false
176
+ gate = Queue.new
177
+ worker = Thread.new do
178
+ Thread.current.report_on_exception = false
179
+ gate.pop
180
+ http.start
181
+ end
182
+ worker.report_on_exception = false
183
+ cancel.register { worker.raise(OpeningCancelled, "Stream connection cancelled.") if worker.alive? }
184
+ gate.push(true)
185
+ worker.value
186
+ opened = true
187
+ ensure
188
+ if worker&.alive?
189
+ worker.raise(OpeningCancelled, "Stream connection cancelled.")
190
+ begin
191
+ worker.join
192
+ rescue *NETWORK_ERRORS
193
+ nil
194
+ end
195
+ end
196
+ # connect can assign @socket immediately before do_start marks the
197
+ # session started. An interruption between those lines skips connect's
198
+ # rescue, so finish/started? alone cannot clean up this failed opening.
199
+ if worker && !opened
200
+ begin
201
+ http.instance_variable_get(:@socket)&.close
202
+ rescue IOError, SystemCallError
203
+ nil
204
+ end
205
+ end
206
+ end
207
+
208
+ def with_connection(_options)
209
+ http = @lock.synchronize do
210
+ loop do
211
+ break @idle.pop unless @idle.empty?
212
+ if @open < @limit
213
+ @open += 1
214
+ break connect
215
+ end
216
+ @freed.wait(@lock)
217
+ end
218
+ end
219
+ healthy = false
220
+ begin
221
+ result = yield http
222
+ healthy = true
223
+ result
224
+ ensure
225
+ @lock.synchronize do
226
+ if healthy
227
+ @idle.push(http)
228
+ else
229
+ http.finish if http.started?
230
+ @open -= 1
231
+ end
232
+ @freed.signal
233
+ end
234
+ end
235
+ end
236
+
237
+ def backoff(attempt) = [8.0, 0.25 * (2**[attempt, 10].min)].min * (0.5 + rand)
238
+
239
+ # 0.5 s, 1 s, 2 s, 4 s, then every 8 s, jittered, so a queue of CI jobs spreads out.
240
+ def room_backoff(attempt) = [8.0, 0.5 * (2**[attempt, 10].min)].min * (0.75 + (rand * 0.5))
241
+
242
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
243
+
244
+ def target(path, query)
245
+ pairs = []
246
+ (query || {}).each do |key, value|
247
+ next if value.nil?
248
+
249
+ Array(value).each { |item| pairs << [key.to_s, item.to_s] }
250
+ end
251
+ pairs.empty? ? path : "#{path}?#{URI.encode_www_form(pairs)}"
252
+ end
253
+
254
+ # Sends a call, retrying as the rules say, and returns the response once it
255
+ # succeeded. With a block, the body is streamed to it.
256
+ def send_call(http, method, path, query: nil, body: nil, raw: nil, accept: "application/json", wait: nil,
257
+ key: nil, no_retry: false, room: 0, timeout: nil, cancel: nil, &stream)
258
+ write = method != "GET"
259
+ key = write ? (key || SecureRandom.uuid) : nil
260
+ deadline = now + (timeout || @timeout) + room
261
+ room_until = now + room
262
+ room_attempt = 0
263
+ payload = raw || (body.nil? ? nil : JSON.generate(body))
264
+ attempt = 0
265
+ loop do
266
+ raise connection_error(true, write, key) if cancel&.cancelled?
267
+ left = deadline - now
268
+ raise connection_error(true, write, key) if left <= 0
269
+
270
+ request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, target(path, query))
271
+ request["Authorization"] = "Bearer #{api_key}"
272
+ request["Accept"] = accept
273
+ request["X-Runtime-Client"] = @client
274
+ request["User-Agent"] = "withruntime-ruby/#{VERSION}"
275
+ request["Idempotency-Key"] = key if key
276
+ request["Prefer"] = "wait=#{[120, wait].min}" if wait&.positive?
277
+ if payload
278
+ request["Content-Type"] = raw ? "application/octet-stream" : "application/json"
279
+ request.body = payload
280
+ end
281
+ http.read_timeout = left
282
+ http.write_timeout = left if http.respond_to?(:write_timeout=)
283
+ response = nil
284
+ begin
285
+ open_connection(http, cancel)
286
+ cancel&.register do
287
+ http.read_timeout = 0.001
288
+ http.write_timeout = 0.001
289
+ http.open_timeout = 0.001
290
+ begin
291
+ http.instance_variable_get(:@socket)&.io&.close
292
+ rescue IOError, SystemCallError
293
+ nil
294
+ end
295
+ end
296
+ raise connection_error(true, write, key) if cancel&.cancelled?
297
+ if stream
298
+ http.request(request) do |answer|
299
+ response = answer
300
+ answer.read_body(&stream) if answer.code.to_i < 300
301
+ end
302
+ else
303
+ response = http.request(request)
304
+ end
305
+ status = response.code.to_i
306
+ if status >= 300 && status < 400
307
+ raise Net::HTTPBadResponse, "Runtime answered a redirect (#{status})"
308
+ end
309
+ rescue *NETWORK_ERRORS => e
310
+ 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
313
+
314
+ pause(backoff(attempt), deadline, write, key, nil, cancel)
315
+ attempt += 1
316
+ next
317
+ end
318
+ return response if status < 300
319
+
320
+ text = response.body.to_s
321
+ parsed = begin
322
+ JSON.parse(text)
323
+ rescue JSON::ParserError
324
+ { "error" => { "message" => text.strip.empty? ? nil : text.strip[0, 500] }.compact }
325
+ end
326
+ failure = Error.from(status, parsed, key)
327
+ if !no_retry && room.positive? && Error::WAITS_FOR_ROOM.include?(failure.code) && failure.details["field"] != "count"
328
+ room_left = room_until - now
329
+ raise failure if room_left <= 0
330
+
331
+ wait_for = failure.retry_after || room_backoff(room_attempt)
332
+ room_attempt += 1
333
+ pause([room_left, wait_for].min, deadline, write, key, failure, cancel)
334
+ next
335
+ end
336
+ retryable = [429, 502, 503, 504].include?(status) && !Error::DELIBERATE.include?(failure.code)
337
+ raise failure if no_retry || !retryable || attempt >= max_retries
338
+
339
+ wait_for = failure.retry_after
340
+ if wait_for.nil?
341
+ header = Float(response["Retry-After"], exception: false)
342
+ wait_for = header&.positive? ? header : backoff(attempt)
343
+ end
344
+ pause([30.0, wait_for].min * (0.9 + (rand * 0.2)), deadline, write, key, failure, cancel)
345
+ attempt += 1
346
+ end
347
+ end
348
+
349
+ def pause(seconds, deadline, write, key, failure = nil, cancel = nil)
350
+ if now + seconds >= deadline
351
+ raise failure if failure
352
+
353
+ raise connection_error(true, write, key)
354
+ end
355
+ if cancel
356
+ raise connection_error(true, write, key) unless cancel.wait(seconds)
357
+ else
358
+ sleep(seconds)
359
+ end
360
+ end
361
+ end
362
+ end