sandbox-provider 0.1.1 → 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 -37
- data/lib/sandbox/provider/version.rb +1 -1
- data/lib/sandbox-provider.rb +1 -0
- metadata +4 -3
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,13 +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 (root or the deploy user) shares it. A fork gets its own.
|
|
19
|
-
TOKENS = {}
|
|
20
|
-
TOKENS_LOCK = Mutex.new
|
|
21
15
|
|
|
22
16
|
def initialize(token, client: Client.new(token), **)
|
|
23
17
|
super(token, **)
|
|
@@ -150,7 +144,6 @@ to: Time.current.utc.iso8601 })
|
|
|
150
144
|
|
|
151
145
|
# A parent with live forks cannot go: the error says so.
|
|
152
146
|
def destroy(m)
|
|
153
|
-
TOKENS_LOCK.synchronize { TOKENS.delete(m.id) }
|
|
154
147
|
@client.delete("/sandbox/#{m.id}", force: true)
|
|
155
148
|
rescue Error => e
|
|
156
149
|
raise unless e.status == 404
|
|
@@ -164,20 +157,10 @@ to: Time.current.utc.iso8601 })
|
|
|
164
157
|
wake(machine(sandbox))
|
|
165
158
|
end
|
|
166
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.
|
|
167
162
|
def transport(m, key: nil, host_key: nil, on_host_key: nil)
|
|
168
|
-
|
|
169
|
-
kept = TOKENS[m.id]
|
|
170
|
-
kept = nil if kept.nil? || kept.until < Time.current
|
|
171
|
-
kept || (TOKENS[m.id] = mint(m.id))
|
|
172
|
-
end
|
|
173
|
-
Sandbox::Adapter::Ssh.new(
|
|
174
|
-
host: access.host,
|
|
175
|
-
port: access.port,
|
|
176
|
-
user: access.token,
|
|
177
|
-
key: nil,
|
|
178
|
-
host_key:,
|
|
179
|
-
on_host_key:
|
|
180
|
-
)
|
|
163
|
+
Toolbox.new(m.id, @token, base: m.data[:toolboxProxyUrl])
|
|
181
164
|
end
|
|
182
165
|
|
|
183
166
|
# Their preview link for the port: a host at their edge and a token it wants in a header. The token rotates
|
|
@@ -234,21 +217,6 @@ to: Time.current.utc.iso8601 })
|
|
|
234
217
|
{ cpu: 16, memory: 32, disk: 15 }
|
|
235
218
|
end
|
|
236
219
|
|
|
237
|
-
def mint(sandbox_id)
|
|
238
|
-
access = @client.post(
|
|
239
|
-
"/sandbox/#{sandbox_id}/ssh-access",
|
|
240
|
-
{},
|
|
241
|
-
params: { expiresInMinutes: TOKEN_MINUTES }
|
|
242
|
-
)
|
|
243
|
-
command = access[:sshCommand].to_s
|
|
244
|
-
Access.new(
|
|
245
|
-
host: command[/@(\S+)/, 1],
|
|
246
|
-
port: (command[/-p (\d+)/, 1] || 22).to_i,
|
|
247
|
-
token: access[:token],
|
|
248
|
-
until: Time.current + (TOKEN_MINUTES - 10).minutes
|
|
249
|
-
)
|
|
250
|
-
end
|
|
251
|
-
|
|
252
220
|
def wait(id, timeout: 300)
|
|
253
221
|
deadline = Time.current + timeout
|
|
254
222
|
loop do
|
data/lib/sandbox-provider.rb
CHANGED
|
@@ -12,6 +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/toolbox"
|
|
15
16
|
require_relative "sandbox/provider/e2b"
|
|
16
17
|
require_relative "sandbox/provider/e2b/client"
|
|
17
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,6 +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/toolbox.rb
|
|
55
56
|
- lib/sandbox/provider/e2b.rb
|
|
56
57
|
- lib/sandbox/provider/e2b/client.rb
|
|
57
58
|
- lib/sandbox/provider/e2b/envd.rb
|