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,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WithRuntime
|
|
4
|
+
# One authenticated WebSocket multiplexes connections to the sandbox's loopback.
|
|
5
|
+
class Tunnel
|
|
6
|
+
WINDOW = 1 << 20
|
|
7
|
+
def initialize(sandbox)
|
|
8
|
+
@lock, @credit, @send_lock = Mutex.new, ConditionVariable.new, Mutex.new
|
|
9
|
+
@streams, @next, @sent, @credited, @closed = {}, 1, 0, 0, false
|
|
10
|
+
@socket = WebSocket.new(sandbox.transport, sandbox.path("/tunnel"))
|
|
11
|
+
deadline = @socket.now + @socket.timeout
|
|
12
|
+
loop do
|
|
13
|
+
frame = @socket.read(deadline)
|
|
14
|
+
raise Error.new("Tunnel ended before ready.", code: "tunnel_refused") unless frame
|
|
15
|
+
next unless frame[0] == 1
|
|
16
|
+
reply = JSON.parse(frame[1])
|
|
17
|
+
raise Error.from(0, reply, nil) if reply["type"] == "error"
|
|
18
|
+
break if reply["type"] == "ready"
|
|
19
|
+
end
|
|
20
|
+
@reader = Thread.new do
|
|
21
|
+
begin
|
|
22
|
+
while (frame = @socket.read)
|
|
23
|
+
receive(*frame)
|
|
24
|
+
end
|
|
25
|
+
close
|
|
26
|
+
rescue StandardError => error
|
|
27
|
+
finish(error)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
rescue StandardError
|
|
31
|
+
@socket&.close
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
def connect(port)
|
|
35
|
+
raise ArgumentError, "port must be 1 to 65535" unless port.is_a?(Integer) && port.between?(1, 65535)
|
|
36
|
+
open_stream("tcp #{port}")
|
|
37
|
+
end
|
|
38
|
+
def ssh(public_key) = open_stream("ssh #{public_key}")
|
|
39
|
+
def open_stream(target)
|
|
40
|
+
stream = @lock.synchronize do
|
|
41
|
+
raise closed_error if @closed
|
|
42
|
+
raise Error.new("Tunnel stream IDs exhausted.", code: "tunnel_exhausted") if @next > 0xffffffff
|
|
43
|
+
id = @next
|
|
44
|
+
@next += 1
|
|
45
|
+
@streams[id] = Stream.new(self, id)
|
|
46
|
+
end
|
|
47
|
+
begin
|
|
48
|
+
control("o", stream.id, target)
|
|
49
|
+
stream.wait_open(@socket.timeout)
|
|
50
|
+
stream
|
|
51
|
+
rescue StandardError
|
|
52
|
+
stream.close
|
|
53
|
+
raise
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
def closed_error = @error || Error.new("The tunnel is closed.", code: "tunnel_closed")
|
|
57
|
+
def control(kind, id, data = "")
|
|
58
|
+
raise closed_error if @lock.synchronize { @closed }
|
|
59
|
+
@socket.send_frame(2, kind.b + [id].pack("N") + data.b)
|
|
60
|
+
end
|
|
61
|
+
def write(stream, bytes)
|
|
62
|
+
@send_lock.synchronize do
|
|
63
|
+
(0...bytes.bytesize).step(65536) do |at|
|
|
64
|
+
part = bytes.byteslice(at, 65536)
|
|
65
|
+
@lock.synchronize do
|
|
66
|
+
@credit.wait(@lock) while !@closed && !stream.closed? && @sent + part.bytesize - @credited > WINDOW
|
|
67
|
+
raise closed_error if @closed
|
|
68
|
+
raise IOError, "Stream closed" if stream.closed?
|
|
69
|
+
@sent += part.bytesize
|
|
70
|
+
end
|
|
71
|
+
control("d", stream.id, part)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
bytes.bytesize
|
|
75
|
+
end
|
|
76
|
+
def forget(id)
|
|
77
|
+
@lock.synchronize { @streams.delete(id); @credit.broadcast }
|
|
78
|
+
end
|
|
79
|
+
def async_close(id)
|
|
80
|
+
Thread.new do
|
|
81
|
+
control("c", id)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
def receive(op, data)
|
|
87
|
+
if op == 1
|
|
88
|
+
reply = JSON.parse(data)
|
|
89
|
+
raise Error.from(0, reply, nil) if reply["type"] == "error"
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
raise Error.new("Malformed tunnel frame.", code: "tunnel_protocol") if data.bytesize < 5
|
|
93
|
+
kind, id, bytes = data.byteslice(0, 1), data.byteslice(1, 4).unpack1("N"), data.byteslice(5..)
|
|
94
|
+
if kind == "a"
|
|
95
|
+
raise Error.new("Malformed tunnel credit.", code: "tunnel_protocol") unless bytes.bytesize == 8
|
|
96
|
+
amount = bytes.unpack1("Q>")
|
|
97
|
+
@lock.synchronize do
|
|
98
|
+
return if @closed
|
|
99
|
+
raise Error.new("Invalid tunnel credit.", code: "tunnel_protocol") unless amount.between?(@credited, @sent)
|
|
100
|
+
@credited = amount
|
|
101
|
+
@credit.broadcast
|
|
102
|
+
end
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
stream = @lock.synchronize { @streams[id] }
|
|
106
|
+
return unless stream
|
|
107
|
+
case kind
|
|
108
|
+
when "o" then stream.opened
|
|
109
|
+
when "d"
|
|
110
|
+
unless stream.push(bytes)
|
|
111
|
+
stream.finish(Error.new("Read responses while writing; this stream's unread buffer is full.", code: "tunnel_receive_overflow"))
|
|
112
|
+
async_close(id)
|
|
113
|
+
end
|
|
114
|
+
when "e" then stream.eof
|
|
115
|
+
when "c"
|
|
116
|
+
code = { "closed" => "port_closed", "reserved" => "port_reserved", "no-sshd" => "sshd_missing" }.fetch(bytes, "tunnel_refused")
|
|
117
|
+
stream.finish(bytes == "done" ? nil : Error.new("Sandbox connection ended: #{bytes}", code: code))
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
def finish(error = nil)
|
|
121
|
+
streams = @lock.synchronize do
|
|
122
|
+
return if @closed
|
|
123
|
+
@closed, @error = true, error
|
|
124
|
+
@credit.broadcast
|
|
125
|
+
values = @streams.values
|
|
126
|
+
@streams = {}
|
|
127
|
+
values
|
|
128
|
+
end
|
|
129
|
+
@socket.close
|
|
130
|
+
streams.each { |stream| stream.finish(error) }
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
def close = finish
|
|
134
|
+
|
|
135
|
+
class Stream
|
|
136
|
+
attr_reader :id
|
|
137
|
+
def initialize(tunnel, id)
|
|
138
|
+
@tunnel, @id, @buffer = tunnel, id, StreamBuffer.new
|
|
139
|
+
@lock, @opened, @write_lock = Mutex.new, ConditionVariable.new, Mutex.new
|
|
140
|
+
@closed = @ready = @eof = false
|
|
141
|
+
end
|
|
142
|
+
def closed? = @closed
|
|
143
|
+
def opened = @lock.synchronize { @ready = true; @opened.broadcast }
|
|
144
|
+
def wait_open(timeout)
|
|
145
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
146
|
+
@lock.synchronize do
|
|
147
|
+
until @ready || @closed
|
|
148
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
149
|
+
raise Error.new("Opening the tunnel stream timed out.", code: "timeout") unless left.positive?
|
|
150
|
+
@opened.wait(@lock, left)
|
|
151
|
+
end
|
|
152
|
+
raise(@error || Error.new("The stream closed before opening.", code: "tunnel_refused")) unless @ready
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
def push(bytes) = @buffer.push(bytes)
|
|
156
|
+
def eof = @buffer.finish
|
|
157
|
+
def read(length = 65536) = @buffer.read(length)
|
|
158
|
+
def write(bytes)
|
|
159
|
+
@write_lock.synchronize do
|
|
160
|
+
raise IOError, "Stream input is closed" if @closed || @eof
|
|
161
|
+
@tunnel.write(self, bytes.to_s.b)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
def close_write
|
|
165
|
+
@write_lock.synchronize do
|
|
166
|
+
return if @eof || @closed
|
|
167
|
+
@eof = true
|
|
168
|
+
@tunnel.control("e", @id)
|
|
169
|
+
end
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
def finish(error = nil)
|
|
173
|
+
changed = @lock.synchronize do
|
|
174
|
+
next false if @closed
|
|
175
|
+
@closed, @error = true, error
|
|
176
|
+
@opened.broadcast
|
|
177
|
+
true
|
|
178
|
+
end
|
|
179
|
+
if changed
|
|
180
|
+
@buffer.finish(error)
|
|
181
|
+
@tunnel.forget(@id)
|
|
182
|
+
end
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
def close
|
|
186
|
+
return if @closed
|
|
187
|
+
finish
|
|
188
|
+
@tunnel.async_close(@id)
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Copies each accepted local TCP connection over a stream in one tunnel.
|
|
195
|
+
class PortForward
|
|
196
|
+
attr_reader :host, :port
|
|
197
|
+
def initialize(sandbox, remote_port, host:, local_port:)
|
|
198
|
+
raise ArgumentError, "port must be 1 to 65535" unless remote_port.is_a?(Integer) && remote_port.between?(1, 65535)
|
|
199
|
+
@lock, @clients, @closed = Mutex.new, [], false
|
|
200
|
+
@listener = TCPServer.new(host, local_port)
|
|
201
|
+
@host, @port = @listener.addr[3], @listener.addr[1]
|
|
202
|
+
@tunnel = sandbox.open_tunnel
|
|
203
|
+
@accept = Thread.new do
|
|
204
|
+
loop do
|
|
205
|
+
local = @listener.accept
|
|
206
|
+
@lock.synchronize { @clients << local }
|
|
207
|
+
if @closed
|
|
208
|
+
close_local(local)
|
|
209
|
+
break
|
|
210
|
+
end
|
|
211
|
+
Thread.new(local) { |client| forward(client, remote_port) }
|
|
212
|
+
end
|
|
213
|
+
rescue IOError, SystemCallError
|
|
214
|
+
close
|
|
215
|
+
end
|
|
216
|
+
rescue StandardError
|
|
217
|
+
@listener&.close
|
|
218
|
+
@tunnel&.close
|
|
219
|
+
raise
|
|
220
|
+
end
|
|
221
|
+
def forward(local, remote_port)
|
|
222
|
+
stream = @tunnel.connect(remote_port)
|
|
223
|
+
directions, lock = 2, Mutex.new
|
|
224
|
+
finish = lambda do
|
|
225
|
+
last = lock.synchronize { directions -= 1; directions.zero? }
|
|
226
|
+
if last
|
|
227
|
+
stream.close
|
|
228
|
+
close_local(local)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
Thread.new do
|
|
232
|
+
begin
|
|
233
|
+
loop { stream.write(local.readpartial(65536)) }
|
|
234
|
+
rescue EOFError
|
|
235
|
+
stream.close_write
|
|
236
|
+
rescue StandardError
|
|
237
|
+
stream.close
|
|
238
|
+
close_local(local)
|
|
239
|
+
ensure
|
|
240
|
+
finish.call
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
begin
|
|
244
|
+
while (data = stream.read)
|
|
245
|
+
local.write(data)
|
|
246
|
+
end
|
|
247
|
+
local.close_write
|
|
248
|
+
rescue StandardError
|
|
249
|
+
stream.close
|
|
250
|
+
close_local(local)
|
|
251
|
+
ensure
|
|
252
|
+
finish.call
|
|
253
|
+
end
|
|
254
|
+
rescue StandardError
|
|
255
|
+
close_local(local)
|
|
256
|
+
end
|
|
257
|
+
def close_local(local)
|
|
258
|
+
@lock.synchronize { @clients.delete(local) }
|
|
259
|
+
local.close unless local.closed?
|
|
260
|
+
rescue IOError, SystemCallError
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
def close
|
|
264
|
+
clients = @lock.synchronize do
|
|
265
|
+
return if @closed
|
|
266
|
+
@closed = true
|
|
267
|
+
@clients.dup
|
|
268
|
+
end
|
|
269
|
+
@listener.close unless @listener.closed?
|
|
270
|
+
clients.each { |client| close_local(client) }
|
|
271
|
+
@tunnel.close
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module WithRuntime
|
|
7
|
+
# One bounded RFC6455 connection, using the transport's origin, key and proxy rules.
|
|
8
|
+
class WebSocket
|
|
9
|
+
attr_reader :timeout
|
|
10
|
+
def initialize(transport, path, query = nil)
|
|
11
|
+
@transport = transport
|
|
12
|
+
@timeout = transport.timeout
|
|
13
|
+
@write_lock, @close_lock = Mutex.new, Mutex.new
|
|
14
|
+
@closed = false
|
|
15
|
+
uri = URI(transport.base_url)
|
|
16
|
+
proxy = Transport.send(:proxy_for, uri)
|
|
17
|
+
if proxy && proxy.scheme != "http"
|
|
18
|
+
raise Error.new("WebSocket proxy must use http CONNECT.", code: "proxy_unsupported")
|
|
19
|
+
end
|
|
20
|
+
@raw = Socket.tcp(proxy ? proxy.host : uri.host, proxy ? proxy.port : uri.port, connect_timeout: @timeout)
|
|
21
|
+
@io = @raw
|
|
22
|
+
deadline = now + @timeout
|
|
23
|
+
if proxy
|
|
24
|
+
authority = "#{uri.host}:#{uri.port}"
|
|
25
|
+
auth = proxy.user ? "Proxy-Authorization: Basic #{["#{URI.decode_www_form_component(proxy.user)}:#{URI.decode_www_form_component(proxy.password.to_s)}"].pack("m0")}\r\n" : ""
|
|
26
|
+
write_all("CONNECT #{authority} HTTP/1.1\r\nHost: #{authority}\r\n#{auth}\r\n", deadline)
|
|
27
|
+
raise Error.new("The proxy refused the WebSocket connection.", code: "proxy_refused") unless headers(deadline).first.match?(/\AHTTP\/1\.[01] 200 /)
|
|
28
|
+
end
|
|
29
|
+
if uri.scheme == "https"
|
|
30
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
31
|
+
context.set_params
|
|
32
|
+
tls = OpenSSL::SSL::SSLSocket.new(@raw, context)
|
|
33
|
+
tls.hostname = uri.host
|
|
34
|
+
@io = tls
|
|
35
|
+
loop do
|
|
36
|
+
result = tls.connect_nonblock(exception: false)
|
|
37
|
+
break unless %i[wait_readable wait_writable].include?(result)
|
|
38
|
+
wait(result, deadline)
|
|
39
|
+
end
|
|
40
|
+
tls.post_connection_check(uri.host)
|
|
41
|
+
end
|
|
42
|
+
key = [SecureRandom.random_bytes(16)].pack("m0")
|
|
43
|
+
target = path + (query && !query.empty? ? "?#{URI.encode_www_form(query.reject { |_, value| value.nil? })}" : "")
|
|
44
|
+
host = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
|
|
45
|
+
write_all("GET #{target} HTTP/1.1\r\nHost: #{host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: #{key}\r\nAuthorization: Bearer #{transport.api_key}\r\nX-Runtime-Client: sdk-ruby/#{VERSION}\r\n\r\n", deadline)
|
|
46
|
+
status, fields = headers(deadline)
|
|
47
|
+
accept = [Digest::SHA1.digest(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")].pack("m0")
|
|
48
|
+
unless status.match?(/\AHTTP\/1\.[01] 101 /) && fields["sec-websocket-accept"] == accept && fields["upgrade"].to_s.downcase == "websocket"
|
|
49
|
+
raise Error.new("The server refused the WebSocket upgrade.", code: "websocket_refused")
|
|
50
|
+
end
|
|
51
|
+
rescue *Transport::NETWORK_ERRORS => error
|
|
52
|
+
close
|
|
53
|
+
raise @transport.connection_error(error.is_a?(Timeout::Error), false, nil, error)
|
|
54
|
+
rescue StandardError
|
|
55
|
+
close
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
60
|
+
def wait(direction, deadline)
|
|
61
|
+
left = deadline && deadline - now
|
|
62
|
+
raise Error.new("The stream deadline passed.", code: "timeout") if left && left <= 0
|
|
63
|
+
selected = direction == :wait_writable ? IO.select(nil, [@io], nil, left) : IO.select([@io], nil, nil, left)
|
|
64
|
+
raise Error.new("The stream deadline passed.", code: "timeout") unless selected
|
|
65
|
+
end
|
|
66
|
+
def exact(length, deadline = nil)
|
|
67
|
+
out = +"".b
|
|
68
|
+
while out.bytesize < length
|
|
69
|
+
part = @io.read_nonblock(length - out.bytesize, exception: false)
|
|
70
|
+
raise EOFError if part.nil?
|
|
71
|
+
if part.is_a?(Symbol)
|
|
72
|
+
wait(part, deadline)
|
|
73
|
+
else
|
|
74
|
+
out << part
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
out
|
|
78
|
+
end
|
|
79
|
+
def write_all(bytes, deadline)
|
|
80
|
+
at = 0
|
|
81
|
+
while at < bytes.bytesize
|
|
82
|
+
n = @io.write_nonblock(bytes.byteslice(at..), exception: false)
|
|
83
|
+
n.is_a?(Symbol) ? wait(n, deadline) : at += n
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
def headers(deadline)
|
|
87
|
+
text = +""
|
|
88
|
+
until text.end_with?("\r\n\r\n")
|
|
89
|
+
raise Error.new("WebSocket response headers are too large.", code: "websocket_protocol") if text.bytesize >= 65536
|
|
90
|
+
text << exact(1, deadline)
|
|
91
|
+
end
|
|
92
|
+
lines = text.split("\r\n")
|
|
93
|
+
[lines.shift, lines.to_h { |line| name, value = line.split(":", 2); [name.downcase, value.to_s.strip] }]
|
|
94
|
+
end
|
|
95
|
+
def send_frame(op, bytes)
|
|
96
|
+
bytes = bytes.b
|
|
97
|
+
@write_lock.synchronize do
|
|
98
|
+
raise IOError, "WebSocket closed" if @closed
|
|
99
|
+
mask = SecureRandom.random_bytes(4)
|
|
100
|
+
size = bytes.bytesize
|
|
101
|
+
header = [0x80 | op].pack("C")
|
|
102
|
+
header << if size < 126 then [0x80 | size].pack("C") elsif size <= 65535 then [0xfe, size].pack("Cn") else [0xff, size].pack("CQ>") end
|
|
103
|
+
masked = bytes.bytes.each_with_index.map { |value, index| value ^ mask.getbyte(index % 4) }.pack("C*")
|
|
104
|
+
write_all(header + mask + masked, now + @timeout)
|
|
105
|
+
end
|
|
106
|
+
rescue *Transport::NETWORK_ERRORS => error
|
|
107
|
+
raise @transport.connection_error(error.is_a?(Timeout::Error), false, nil, error)
|
|
108
|
+
end
|
|
109
|
+
def read(deadline = nil)
|
|
110
|
+
message = +"".b
|
|
111
|
+
kind = nil
|
|
112
|
+
loop do
|
|
113
|
+
first, second = exact(2, deadline).unpack("CC")
|
|
114
|
+
raise Error.new("Invalid WebSocket frame.", code: "websocket_protocol") unless (first & 0x70).zero? && (second & 0x80).zero?
|
|
115
|
+
op, final, length = first & 15, (first & 0x80) != 0, second & 127
|
|
116
|
+
length = exact(2, deadline).unpack1("n") if length == 126
|
|
117
|
+
length = exact(8, deadline).unpack1("Q>") if length == 127
|
|
118
|
+
if length > 1_048_581 || (op >= 8 && (!final || length > 125))
|
|
119
|
+
raise Error.new("WebSocket frame exceeds its bound.", code: "stream_overflow")
|
|
120
|
+
end
|
|
121
|
+
data = exact(length, deadline)
|
|
122
|
+
return nil if op == 8
|
|
123
|
+
if op == 9
|
|
124
|
+
send_frame(10, data)
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
next if op == 10
|
|
128
|
+
if op == 0
|
|
129
|
+
raise Error.new("Unexpected continuation.", code: "websocket_protocol") unless kind
|
|
130
|
+
else
|
|
131
|
+
raise Error.new("Invalid data frame.", code: "websocket_protocol") unless [1, 2].include?(op) && kind.nil?
|
|
132
|
+
kind = op
|
|
133
|
+
end
|
|
134
|
+
message << data
|
|
135
|
+
raise Error.new("WebSocket message exceeds its bound.", code: "stream_overflow") if message.bytesize > 1_048_581
|
|
136
|
+
return [kind, message] if final
|
|
137
|
+
end
|
|
138
|
+
rescue *Transport::NETWORK_ERRORS => error
|
|
139
|
+
raise @transport.connection_error(error.is_a?(Timeout::Error), false, nil, error)
|
|
140
|
+
end
|
|
141
|
+
def close
|
|
142
|
+
return unless @close_lock
|
|
143
|
+
@close_lock.synchronize do
|
|
144
|
+
return if @closed
|
|
145
|
+
@closed = true
|
|
146
|
+
@raw&.close unless @raw&.closed?
|
|
147
|
+
end
|
|
148
|
+
rescue IOError, SystemCallError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Producer never waits for a reader. The stream owner decides how overflow is reported.
|
|
154
|
+
class StreamBuffer
|
|
155
|
+
def initialize
|
|
156
|
+
@lock, @changed = Mutex.new, ConditionVariable.new
|
|
157
|
+
@queue, @bytes, @ended = [], 0, false
|
|
158
|
+
end
|
|
159
|
+
def push(bytes)
|
|
160
|
+
@lock.synchronize do
|
|
161
|
+
return true if @ended
|
|
162
|
+
return false if @bytes + bytes.bytesize > 1 << 20 || @queue.length >= 1024
|
|
163
|
+
unless bytes.empty?
|
|
164
|
+
@queue << bytes
|
|
165
|
+
@bytes += bytes.bytesize
|
|
166
|
+
@changed.broadcast
|
|
167
|
+
end
|
|
168
|
+
true
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
def finish(error = nil)
|
|
172
|
+
@lock.synchronize { unless @ended; @ended = true; @error = error; @changed.broadcast; end }
|
|
173
|
+
end
|
|
174
|
+
def read(length = 65536)
|
|
175
|
+
raise ArgumentError, "length must be positive" unless length.positive?
|
|
176
|
+
@lock.synchronize do
|
|
177
|
+
@changed.wait(@lock) while @queue.empty? && !@ended
|
|
178
|
+
if @queue.empty?
|
|
179
|
+
raise @error if @error
|
|
180
|
+
return nil
|
|
181
|
+
end
|
|
182
|
+
data = @queue.shift
|
|
183
|
+
if data.bytesize > length
|
|
184
|
+
@queue.unshift(data.byteslice(length..))
|
|
185
|
+
data = data.byteslice(0, length)
|
|
186
|
+
end
|
|
187
|
+
@bytes -= data.bytesize
|
|
188
|
+
data
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
class Terminal
|
|
194
|
+
attr_reader :process_id, :exit_code
|
|
195
|
+
def initialize(sandbox, **options)
|
|
196
|
+
@socket = WebSocket.new(sandbox.transport, sandbox.path("/terminal"), Fields.body(options))
|
|
197
|
+
@buffer = StreamBuffer.new
|
|
198
|
+
deadline = @socket.now + @socket.timeout
|
|
199
|
+
loop do
|
|
200
|
+
message = @socket.read(deadline)
|
|
201
|
+
raise Error.new("Terminal ended before ready.", code: "terminal_refused") unless message
|
|
202
|
+
next unless message[0] == 1
|
|
203
|
+
reply = JSON.parse(message[1])
|
|
204
|
+
raise Error.from(0, reply, nil) if reply["type"] == "error"
|
|
205
|
+
if reply["type"] == "ready"
|
|
206
|
+
@process_id = reply["processId"]
|
|
207
|
+
break
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
@reader = Thread.new do
|
|
211
|
+
begin
|
|
212
|
+
while (message = @socket.read)
|
|
213
|
+
if message[0] == 2
|
|
214
|
+
raise Error.new("Read terminal output while writing input; its unread buffer is full.", code: "terminal_receive_overflow") unless @buffer.push(message[1])
|
|
215
|
+
else
|
|
216
|
+
reply = JSON.parse(message[1])
|
|
217
|
+
if reply["type"] == "exit"
|
|
218
|
+
@exit_code = reply["exitCode"]
|
|
219
|
+
break
|
|
220
|
+
end
|
|
221
|
+
raise Error.from(0, reply, nil) if reply["type"] == "error"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
@buffer.finish
|
|
225
|
+
rescue StandardError => error
|
|
226
|
+
@buffer.finish(@closed ? nil : error)
|
|
227
|
+
ensure
|
|
228
|
+
@socket.close
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
rescue StandardError
|
|
232
|
+
@socket&.close
|
|
233
|
+
raise
|
|
234
|
+
end
|
|
235
|
+
def read(length = 65536) = @buffer.read(length)
|
|
236
|
+
def write(bytes)
|
|
237
|
+
bytes = bytes.to_s.b
|
|
238
|
+
(0...bytes.bytesize).step(65536) { |at| @socket.send_frame(2, bytes.byteslice(at, 65536)) }
|
|
239
|
+
bytes.bytesize
|
|
240
|
+
end
|
|
241
|
+
def resize(cols:, rows:) = @socket.send_frame(1, JSON.generate(type: "resize", cols: cols, rows: rows))
|
|
242
|
+
def close
|
|
243
|
+
@closed = true
|
|
244
|
+
@socket.close
|
|
245
|
+
@buffer.finish
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
data/lib/withruntime.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Ruby client for Runtime Cloud: Linux sandboxes for agents, and every
|
|
4
|
+
# other Runtime product as it launches. Standard library only.
|
|
5
|
+
#
|
|
6
|
+
# require "withruntime"
|
|
7
|
+
#
|
|
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
|
|
11
|
+
# sbx.stop
|
|
12
|
+
#
|
|
13
|
+
# The guide is at https://withruntime.com/docs/ruby.
|
|
14
|
+
require "json"
|
|
15
|
+
require "openssl"
|
|
16
|
+
|
|
17
|
+
require_relative "withruntime/version"
|
|
18
|
+
require_relative "withruntime/errors"
|
|
19
|
+
require_relative "withruntime/record"
|
|
20
|
+
require_relative "withruntime/fields"
|
|
21
|
+
require_relative "withruntime/transport"
|
|
22
|
+
require_relative "withruntime/credentials"
|
|
23
|
+
require_relative "withruntime/tar"
|
|
24
|
+
require_relative "withruntime/sandbox"
|
|
25
|
+
require_relative "withruntime/products"
|
|
26
|
+
require_relative "withruntime/images"
|
|
27
|
+
require_relative "withruntime/network_products"
|
|
28
|
+
require_relative "withruntime/parity_products"
|
|
29
|
+
require_relative "withruntime/file_watch"
|
|
30
|
+
require_relative "withruntime/websocket"
|
|
31
|
+
require_relative "withruntime/tunnel"
|
|
32
|
+
require_relative "withruntime/client"
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: withruntime
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Runtime
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Linux sandboxes for agents, and every other Runtime Cloud product: streaming
|
|
13
|
+
commands and terminals, files and watches, images, durable volumes and backups,
|
|
14
|
+
domains, TCP ports, private networking and secrets. Standard library only.'
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- GUIDE.md
|
|
20
|
+
- LICENSE
|
|
21
|
+
- lib/withruntime.rb
|
|
22
|
+
- lib/withruntime/client.rb
|
|
23
|
+
- lib/withruntime/credentials.rb
|
|
24
|
+
- lib/withruntime/errors.rb
|
|
25
|
+
- lib/withruntime/fields.rb
|
|
26
|
+
- lib/withruntime/file_watch.rb
|
|
27
|
+
- lib/withruntime/images.rb
|
|
28
|
+
- lib/withruntime/network_products.rb
|
|
29
|
+
- lib/withruntime/parity_products.rb
|
|
30
|
+
- lib/withruntime/products.rb
|
|
31
|
+
- lib/withruntime/record.rb
|
|
32
|
+
- lib/withruntime/sandbox.rb
|
|
33
|
+
- lib/withruntime/tar.rb
|
|
34
|
+
- lib/withruntime/transport.rb
|
|
35
|
+
- lib/withruntime/tunnel.rb
|
|
36
|
+
- lib/withruntime/version.rb
|
|
37
|
+
- lib/withruntime/websocket.rb
|
|
38
|
+
homepage: https://withruntime.com
|
|
39
|
+
licenses:
|
|
40
|
+
- Apache-2.0
|
|
41
|
+
metadata:
|
|
42
|
+
homepage_uri: https://withruntime.com
|
|
43
|
+
documentation_uri: https://withruntime.com/docs/ruby
|
|
44
|
+
source_code_uri: https://github.com/withruntime/runtime/tree/main/sdks/ruby
|
|
45
|
+
bug_tracker_uri: https://github.com/withruntime/runtime/issues
|
|
46
|
+
rubygems_mfa_required: 'true'
|
|
47
|
+
rdoc_options: []
|
|
48
|
+
require_paths:
|
|
49
|
+
- lib
|
|
50
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.2'
|
|
55
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
requirements: []
|
|
61
|
+
rubygems_version: 4.0.16
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: 'Runtime Cloud: one client for every Runtime product. Sandboxes first.'
|
|
64
|
+
test_files: []
|