sandbox-provider 0.1.2 → 0.1.3
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 +4 -4
- data/lib/sandbox/provider/daytona/toolbox.rb +128 -0
- data/lib/sandbox/provider/daytona.rb +5 -52
- data/lib/sandbox/provider/version.rb +1 -1
- data/lib/sandbox-provider.rb +1 -1
- metadata +4 -4
- data/lib/sandbox/provider/daytona/link.rb +0 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e0e09727229563480ccb1c17fa80d6006fc308373d485a5e74e6b5dbfeeaa95
|
|
4
|
+
data.tar.gz: eb2b8ab9b4e9c83dd2b1def46e15efbee8c3e659497e52b2a6b3b37d38ca35cc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9db2ef6f5b3ec73c6a9f81f50a825425a06ff07c9d890ab3675dc01efcd32f07289165be8bb78f1df42406004da737f355396bd6463e263c738d97312a569ef
|
|
7
|
+
data.tar.gz: d97de7d218e2118880fe0bb578ddaace7d5b58ad498f6f743a6b7c11aff91c30f16d3e54215c47987037a627e9c3d4d46f19fd3d37408c58bc396978e3bd9a0e
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
# Reaches a Daytona sandbox through its toolbox, the daemon Daytona runs in every sandbox, over HTTP with the
|
|
5
|
+
# organization's API key: nothing to mint, nothing another process can revoke. Answers the verbs Box asks of a
|
|
6
|
+
# transport, as Sandbox::Adapter::Ssh does.
|
|
7
|
+
#
|
|
8
|
+
# A command runs in a process session of its own, as root, started async; its log is read every POLL seconds
|
|
9
|
+
# and only the bytes not yet read are yielded, until the command has an exit code. The session is deleted
|
|
10
|
+
# afterwards, which ends anything the command left running. A command is always a subprocess of the session's
|
|
11
|
+
# shell (Box sends `bash -lc`), so its `exit` ends the command, not the session.
|
|
12
|
+
class Sandbox::Provider::Daytona::Toolbox
|
|
13
|
+
# The sandbox cannot be reached for now: stopped, restarting, the proxy or the network failing. Box retries
|
|
14
|
+
# it within its deadline, as it does a dropped SSH link.
|
|
15
|
+
Unreachable = Class.new(StandardError)
|
|
16
|
+
|
|
17
|
+
BASE = "https://proxy.app.daytona.io/toolbox".freeze
|
|
18
|
+
POLL = 0.5
|
|
19
|
+
# Seconds a command without `within` may run before it is given up on: a command whose exit never comes
|
|
20
|
+
# back (a toolbox that lost it) must not hold its caller for good. Longer than anything Box runs plainly.
|
|
21
|
+
LIMIT = 2 * 3600
|
|
22
|
+
# Answers that mean the sandbox is not there to answer yet, as opposed to refusing the request.
|
|
23
|
+
AGAIN = [ 408, 425, 429, 500, 502, 503, 504 ].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :sandbox_id
|
|
26
|
+
|
|
27
|
+
def initialize(sandbox_id, token, base: BASE)
|
|
28
|
+
@sandbox_id = sandbox_id
|
|
29
|
+
@token = token
|
|
30
|
+
@base = base.presence || BASE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def address = sandbox_id
|
|
34
|
+
def connection_errors = [ Unreachable ]
|
|
35
|
+
# A detached command's log can be followed again after a dropped request.
|
|
36
|
+
def reattaches? = true
|
|
37
|
+
# Takes `within:` on exec.
|
|
38
|
+
def bounded? = true
|
|
39
|
+
|
|
40
|
+
# Runs a shell command line; each output line is yielded; returns the exit status. With `within`, a command
|
|
41
|
+
# still running after that many seconds raises Unreachable, which its caller retries.
|
|
42
|
+
def exec(command, within: nil, &)
|
|
43
|
+
session = "nvoi-#{SecureRandom.hex(8)}"
|
|
44
|
+
request(Net::HTTP::Post, "/process/session", json: { sessionId: session })
|
|
45
|
+
begin
|
|
46
|
+
started = request(Net::HTTP::Post, "/process/session/#{session}/exec",
|
|
47
|
+
json: { command:, runAsync: true })
|
|
48
|
+
follow(session, JSON.parse(started).fetch("cmdId"), within:, &)
|
|
49
|
+
ensure
|
|
50
|
+
close(session)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def upload(path, io)
|
|
55
|
+
body = io.respond_to?(:read) ? io.read : File.binread(io)
|
|
56
|
+
boundary = "nvoi-#{SecureRandom.hex(12)}"
|
|
57
|
+
name = File.basename(path)
|
|
58
|
+
head = "--#{boundary}\r\n" \
|
|
59
|
+
"Content-Disposition: form-data; name=\"file\"; filename=\"#{name}\"\r\n" \
|
|
60
|
+
"Content-Type: application/octet-stream\r\n\r\n"
|
|
61
|
+
form = head.b + body.to_s.b + "\r\n--#{boundary}--\r\n".b
|
|
62
|
+
request(Net::HTTP::Post, "/files/upload", params: { path: }, body: form,
|
|
63
|
+
type: "multipart/form-data; boundary=#{boundary}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def download(path) = request(Net::HTTP::Get, "/files/download", params: { path: })
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# New log bytes until the command has an exit code, then whatever it printed last.
|
|
71
|
+
def follow(session, command, within:)
|
|
72
|
+
deadline = Time.current + (within || LIMIT)
|
|
73
|
+
read = 0
|
|
74
|
+
buffer = "".b
|
|
75
|
+
path = "/process/session/#{session}/command/#{command}"
|
|
76
|
+
loop do
|
|
77
|
+
status = JSON.parse(request(Net::HTTP::Get, path)).fetch("exitCode", nil)
|
|
78
|
+
log = request(Net::HTTP::Get, "#{path}/logs").b
|
|
79
|
+
Sandbox::Adapter::Box.each_line(buffer, log.byteslice(read..).to_s) { |line| yield line if block_given? }
|
|
80
|
+
read = log.bytesize
|
|
81
|
+
unless status.nil?
|
|
82
|
+
yield buffer.force_encoding("UTF-8").scrub if buffer.present? && block_given?
|
|
83
|
+
return status
|
|
84
|
+
end
|
|
85
|
+
if Time.current > deadline
|
|
86
|
+
raise Unreachable, "#{sandbox_id}: command still running after #{within}s" if within
|
|
87
|
+
|
|
88
|
+
raise Sandbox::Adapter::Box::Failed, "#{sandbox_id}: no exit status after #{LIMIT}s"
|
|
89
|
+
end
|
|
90
|
+
sleep POLL
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A session that cannot be deleted now ends with the sandbox; its command is not ours to wait on.
|
|
95
|
+
def close(session)
|
|
96
|
+
request(Net::HTTP::Delete, "/process/session/#{session}")
|
|
97
|
+
rescue Unreachable, Sandbox::Adapter::Box::Failed
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def request(klass, path, params: {}, json: nil, body: nil, type: nil)
|
|
102
|
+
uri = URI("#{@base}/#{sandbox_id}#{path}")
|
|
103
|
+
uri.query = URI.encode_www_form(params) if params.any?
|
|
104
|
+
req = klass.new(uri)
|
|
105
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
106
|
+
if json
|
|
107
|
+
req["Content-Type"] = "application/json"
|
|
108
|
+
req.body = json.to_json
|
|
109
|
+
elsif body
|
|
110
|
+
req["Content-Type"] = type
|
|
111
|
+
req.body = body
|
|
112
|
+
end
|
|
113
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10,
|
|
114
|
+
read_timeout: 60) { _1.request(req) }
|
|
115
|
+
answer(res, klass, path)
|
|
116
|
+
rescue *Sandbox::Adapter::Provider::UNREACHABLE => e
|
|
117
|
+
raise Unreachable, "#{sandbox_id}: #{e.class}: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def answer(res, klass, path)
|
|
121
|
+
return res.body.to_s if res.is_a?(Net::HTTPSuccess)
|
|
122
|
+
|
|
123
|
+
said = "#{klass::METHOD} #{path} #{res.code}: #{res.body.to_s.strip[0, 200]}"
|
|
124
|
+
raise Unreachable, "#{sandbox_id}: #{said}" if AGAIN.include?(res.code.to_i)
|
|
125
|
+
|
|
126
|
+
raise Sandbox::Adapter::Box::Failed, "daytona toolbox #{said}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
# Daytona linux-vm sandboxes: one VM snapshot per shape and region.
|
|
1
|
+
# Daytona linux-vm sandboxes: one VM snapshot per shape and region. Commands and files go through the
|
|
2
|
+
# sandbox's toolbox with the API key (Daytona::Toolbox), as root; there is no SSH.
|
|
2
3
|
class Sandbox::Provider::Daytona < Sandbox::Adapter::SandboxVm
|
|
3
4
|
IMAGE = "ubuntu:24.04"
|
|
4
5
|
REGION = "uk-south-1"
|
|
@@ -11,29 +12,6 @@ class Sandbox::Provider::Daytona < Sandbox::Adapter::SandboxVm
|
|
|
11
12
|
"build_failed" => :broken,
|
|
12
13
|
"destroyed" => :broken
|
|
13
14
|
}.freeze
|
|
14
|
-
TOKEN_MINUTES = 24 * 60
|
|
15
|
-
# An SSH token and where it lands, good until `until`.
|
|
16
|
-
Access = Data.define(:host, :port, :token, :until)
|
|
17
|
-
# One token per sandbox, kept until it nears expiry: minting a new one revokes the previous, so every
|
|
18
|
-
# transport to the same sandbox shares it, across processes when the store is shared. A fork gets its own.
|
|
19
|
-
# A store answers read(id), write(id, access), delete(id) and synchronize(id) { }.
|
|
20
|
-
class Tokens
|
|
21
|
-
def initialize
|
|
22
|
-
@tokens = {}
|
|
23
|
-
@lock = Monitor.new
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def read(id) = @tokens[id]
|
|
27
|
-
def write(id, access) = @tokens[id] = access
|
|
28
|
-
def delete(id) = @tokens.delete(id)
|
|
29
|
-
def synchronize(_id, &) = @lock.synchronize(&)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
class << self
|
|
33
|
-
attr_writer :tokens
|
|
34
|
-
|
|
35
|
-
def tokens = @tokens ||= Tokens.new
|
|
36
|
-
end
|
|
37
15
|
|
|
38
16
|
def initialize(token, client: Client.new(token), **)
|
|
39
17
|
super(token, **)
|
|
@@ -166,7 +144,6 @@ to: Time.current.utc.iso8601 })
|
|
|
166
144
|
|
|
167
145
|
# A parent with live forks cannot go: the error says so.
|
|
168
146
|
def destroy(m)
|
|
169
|
-
self.class.tokens.synchronize(m.id) { self.class.tokens.delete(m.id) }
|
|
170
147
|
@client.delete("/sandbox/#{m.id}", force: true)
|
|
171
148
|
rescue Error => e
|
|
172
149
|
raise unless e.status == 404
|
|
@@ -180,19 +157,10 @@ to: Time.current.utc.iso8601 })
|
|
|
180
157
|
wake(machine(sandbox))
|
|
181
158
|
end
|
|
182
159
|
|
|
160
|
+
# The sandbox's toolbox, reached with the API key: the same in every process, and nothing to revoke. A
|
|
161
|
+
# key or a host key has no use here.
|
|
183
162
|
def transport(m, key: nil, host_key: nil, on_host_key: nil)
|
|
184
|
-
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# The sandbox's token from the store, minted when there is none, it nears expiry, or it is `stale`: the
|
|
188
|
-
# one a refused connection used. Another process may have replaced it already; then that one is kept.
|
|
189
|
-
def access(id, stale: nil)
|
|
190
|
-
tokens = self.class.tokens
|
|
191
|
-
tokens.synchronize(id) do
|
|
192
|
-
kept = tokens.read(id)
|
|
193
|
-
kept = nil if kept.nil? || kept.until < Time.current || kept.token == stale
|
|
194
|
-
kept || mint(id).tap { |minted| tokens.write(id, minted) }
|
|
195
|
-
end
|
|
163
|
+
Toolbox.new(m.id, @token, base: m.data[:toolboxProxyUrl])
|
|
196
164
|
end
|
|
197
165
|
|
|
198
166
|
# Their preview link for the port: a host at their edge and a token it wants in a header. The token rotates
|
|
@@ -249,21 +217,6 @@ to: Time.current.utc.iso8601 })
|
|
|
249
217
|
{ cpu: 16, memory: 32, disk: 15 }
|
|
250
218
|
end
|
|
251
219
|
|
|
252
|
-
def mint(sandbox_id)
|
|
253
|
-
access = @client.post(
|
|
254
|
-
"/sandbox/#{sandbox_id}/ssh-access",
|
|
255
|
-
{},
|
|
256
|
-
params: { expiresInMinutes: TOKEN_MINUTES }
|
|
257
|
-
)
|
|
258
|
-
command = access[:sshCommand].to_s
|
|
259
|
-
Access.new(
|
|
260
|
-
host: command[/@(\S+)/, 1],
|
|
261
|
-
port: (command[/-p (\d+)/, 1] || 22).to_i,
|
|
262
|
-
token: access[:token],
|
|
263
|
-
until: Time.current + (TOKEN_MINUTES - 10).minutes
|
|
264
|
-
)
|
|
265
|
-
end
|
|
266
|
-
|
|
267
220
|
def wait(id, timeout: 300)
|
|
268
221
|
deadline = Time.current + timeout
|
|
269
222
|
loop do
|
data/lib/sandbox-provider.rb
CHANGED
|
@@ -12,7 +12,7 @@ require_relative "sandbox/provider/hetzner"
|
|
|
12
12
|
require_relative "sandbox/provider/hetzner/client"
|
|
13
13
|
require_relative "sandbox/provider/daytona"
|
|
14
14
|
require_relative "sandbox/provider/daytona/client"
|
|
15
|
-
require_relative "sandbox/provider/daytona/
|
|
15
|
+
require_relative "sandbox/provider/daytona/toolbox"
|
|
16
16
|
require_relative "sandbox/provider/e2b"
|
|
17
17
|
require_relative "sandbox/provider/e2b/client"
|
|
18
18
|
require_relative "sandbox/provider/e2b/envd"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sandbox-provider
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- nvoi
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.1.
|
|
18
|
+
version: 0.1.2
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.1.
|
|
25
|
+
version: 0.1.2
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: ed25519
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -52,7 +52,7 @@ files:
|
|
|
52
52
|
- lib/sandbox/provider/cloud_init.rb
|
|
53
53
|
- lib/sandbox/provider/daytona.rb
|
|
54
54
|
- lib/sandbox/provider/daytona/client.rb
|
|
55
|
-
- lib/sandbox/provider/daytona/
|
|
55
|
+
- lib/sandbox/provider/daytona/toolbox.rb
|
|
56
56
|
- lib/sandbox/provider/e2b.rb
|
|
57
57
|
- lib/sandbox/provider/e2b/client.rb
|
|
58
58
|
- lib/sandbox/provider/e2b/envd.rb
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# An SSH transport whose token another process can revoke by minting a new one. A command refused before
|
|
2
|
-
# it printed anything is tried once more with the store's current token.
|
|
3
|
-
class Sandbox::Provider::Daytona::Link
|
|
4
|
-
RETRY = [ *Sandbox::Adapter::Ssh::CONNECTION_ERRORS, Net::SSH::AuthenticationFailed ].freeze
|
|
5
|
-
|
|
6
|
-
def initialize(access, host_key: nil, on_host_key: nil)
|
|
7
|
-
@access = access
|
|
8
|
-
@host_key = host_key
|
|
9
|
-
@on_host_key = on_host_key
|
|
10
|
-
@ssh = build(access.call(nil))
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def address = @ssh.address
|
|
14
|
-
def host = @ssh.host
|
|
15
|
-
def port = @ssh.port
|
|
16
|
-
def user = @ssh.user
|
|
17
|
-
def key = @ssh.key
|
|
18
|
-
def connection_errors = Sandbox::Adapter::Ssh::CONNECTION_ERRORS
|
|
19
|
-
def reattaches? = true
|
|
20
|
-
|
|
21
|
-
def exec(command, &block)
|
|
22
|
-
printed = false
|
|
23
|
-
begin
|
|
24
|
-
@ssh.exec(command) do |line|
|
|
25
|
-
printed = true
|
|
26
|
-
block&.call(line)
|
|
27
|
-
end
|
|
28
|
-
rescue *RETRY
|
|
29
|
-
raise if printed
|
|
30
|
-
|
|
31
|
-
refresh
|
|
32
|
-
@ssh.exec(command, &block)
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def upload(path, io)
|
|
37
|
-
@ssh.upload(path, io)
|
|
38
|
-
rescue *RETRY
|
|
39
|
-
io.rewind if io.respond_to?(:rewind)
|
|
40
|
-
refresh
|
|
41
|
-
@ssh.upload(path, io)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def download(path)
|
|
45
|
-
@ssh.download(path)
|
|
46
|
-
rescue *RETRY
|
|
47
|
-
refresh
|
|
48
|
-
@ssh.download(path)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
private
|
|
52
|
-
|
|
53
|
-
def refresh = @ssh = build(@access.call(@ssh.user))
|
|
54
|
-
|
|
55
|
-
def build(access)
|
|
56
|
-
Sandbox::Adapter::Ssh.new(host: access.host, port: access.port, user: access.token, key: nil,
|
|
57
|
-
host_key: @host_key, on_host_key: ->(key) { @host_key = key; @on_host_key&.call(key) })
|
|
58
|
-
end
|
|
59
|
-
end
|