sandbox-provider 0.1.1
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/LICENSE +21 -0
- data/README.md +21 -0
- data/lib/sandbox/provider/boat_dev/client.rb +75 -0
- data/lib/sandbox/provider/boat_dev/key.rb +30 -0
- data/lib/sandbox/provider/boat_dev.rb +380 -0
- data/lib/sandbox/provider/cloud_init.rb +19 -0
- data/lib/sandbox/provider/daytona/client.rb +70 -0
- data/lib/sandbox/provider/daytona.rb +272 -0
- data/lib/sandbox/provider/e2b/client.rb +52 -0
- data/lib/sandbox/provider/e2b/envd.rb +121 -0
- data/lib/sandbox/provider/e2b.rb +222 -0
- data/lib/sandbox/provider/hetzner/client.rb +58 -0
- data/lib/sandbox/provider/hetzner.rb +238 -0
- data/lib/sandbox/provider/version.rb +5 -0
- data/lib/sandbox-provider.rb +20 -0
- metadata +83 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Daytona linux-vm sandboxes: one VM snapshot per shape and region. SSH lands as root behind a short-lived token.
|
|
2
|
+
class Sandbox::Provider::Daytona < Sandbox::Adapter::SandboxVm
|
|
3
|
+
IMAGE = "ubuntu:24.04"
|
|
4
|
+
REGION = "uk-south-1"
|
|
5
|
+
# By the state string the API answers.
|
|
6
|
+
STATES = {
|
|
7
|
+
"started" => :running,
|
|
8
|
+
"stopped" => :stopped,
|
|
9
|
+
"archived" => :stopped,
|
|
10
|
+
"error" => :broken,
|
|
11
|
+
"build_failed" => :broken,
|
|
12
|
+
"destroyed" => :broken
|
|
13
|
+
}.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
|
+
|
|
22
|
+
def initialize(token, client: Client.new(token), **)
|
|
23
|
+
super(token, **)
|
|
24
|
+
@client = client
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The VM snapshot sandboxes are created from, in this region at this size: made once per organization,
|
|
28
|
+
# asked for by name. A failed one raises with its reason.
|
|
29
|
+
def prepare(shape = default_shape, disk = default_disk(shape))
|
|
30
|
+
cpu, memory = shape.split("x").map(&:to_i)
|
|
31
|
+
size = { cpu:, memory:, disk: }
|
|
32
|
+
digest = Digest::SHA256.hexdigest("#{IMAGE}|#{size.values.join("|")}|#{REGION}")
|
|
33
|
+
name = "nvoi-vm-#{digest[0, 8]}"
|
|
34
|
+
loop do
|
|
35
|
+
snapshot = @client.find("/snapshots/#{name}")
|
|
36
|
+
case snapshot&.dig(:state)
|
|
37
|
+
when "active" then return name
|
|
38
|
+
when nil
|
|
39
|
+
@client.post(
|
|
40
|
+
"/snapshots",
|
|
41
|
+
{ name:, imageName: IMAGE, sandboxClass: "linux-vm", **size, regionId: REGION },
|
|
42
|
+
again: false
|
|
43
|
+
)
|
|
44
|
+
when "error", "build_failed" then raise Error, "snapshot #{name}: #{snapshot[:errorReason]}"
|
|
45
|
+
else sleep 5
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
LADDER = %w[2x4 4x8 8x16 16x32].freeze
|
|
51
|
+
DISK = { min: 10, step: 5, default: 10 }.freeze
|
|
52
|
+
|
|
53
|
+
# The ladder within the org's per-sandbox limits for this region and class, priced by the formula; disk
|
|
54
|
+
# floats up to the limit.
|
|
55
|
+
def shapes
|
|
56
|
+
max = limits
|
|
57
|
+
as_of = price_table.as_of("daytona")
|
|
58
|
+
LADDER.filter_map do |key|
|
|
59
|
+
cpu, memory = key.split("x").map(&:to_i)
|
|
60
|
+
next if cpu > max[:cpu] || memory > max[:memory]
|
|
61
|
+
disk = DISK.merge(max: max[:disk])
|
|
62
|
+
[ key, Sandbox::Adapter.shape(cpu:, memory:, disk:, rate: formula(cpu, memory, DISK[:default]),
|
|
63
|
+
held: disk_rate(DISK[:default]), source: :formula, as_of:) ]
|
|
64
|
+
end.to_h
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rate(key, disk: nil)
|
|
68
|
+
cpu, memory = key.split("x").map(&:to_i)
|
|
69
|
+
formula(cpu, memory, disk || DISK[:default])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def held(_key, disk: nil) = disk_rate(disk || DISK[:default])
|
|
73
|
+
|
|
74
|
+
PRICING = "https://www.daytona.io/pricing".freeze
|
|
75
|
+
|
|
76
|
+
# Their pricing page: three hourly rates, per vCPU, per GiB of memory, per GiB of storage.
|
|
77
|
+
def prices(page = Sandbox::Adapter::Pages.fetch(PRICING))
|
|
78
|
+
vcpu = page[%r{vCPU\s*\$(\d+\.\d+)/h}, 1]
|
|
79
|
+
gib = page[%r{Memory\s+GiB\s*\$(\d+\.\d+)/h}, 1]
|
|
80
|
+
disk = page[%r{Storage\s+GiB\s*\$(\d+\.\d+)/h}, 1]
|
|
81
|
+
return { source: "page", rates: nil } unless vcpu && gib && disk
|
|
82
|
+
{ source: "page", rates: { "vcpu_hour" => Sandbox::Adapter::Pages.cents(vcpu), "gib_hour" => Sandbox::Adapter::Pages.cents(gib),
|
|
83
|
+
"disk_gib_hour" => Sandbox::Adapter::Pages.cents(disk) } }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Their meter since creation, from the analytics periods: the ones with cpu are running time, the rest
|
|
87
|
+
# is disk while stopped; every period is priced.
|
|
88
|
+
def usage(m)
|
|
89
|
+
from = m.data[:createdAt].presence || "2020-01-01T00:00:00Z"
|
|
90
|
+
periods = @client.analytics("/organization/#{org_id}/sandbox/#{m.id}/usage",
|
|
91
|
+
{ from: Time.iso8601(from).utc.iso8601,
|
|
92
|
+
to: Time.current.utc.iso8601 })
|
|
93
|
+
running = periods.select { _1[:cpu].to_i.positive? }
|
|
94
|
+
{
|
|
95
|
+
seconds: running.sum { (Time.iso8601(_1[:endAt]) - Time.iso8601(_1[:startAt])).round },
|
|
96
|
+
cents: periods.sum { _1[:price].to_f * 100 }.round(4)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Ours alone: the org may be shared, and a sandbox of ours carries an nvoi- label from create or fork.
|
|
101
|
+
def list
|
|
102
|
+
sandboxes.select { |s| (s[:labels] || {}).keys.any? {
|
|
103
|
+
_1.to_s.start_with?("nvoi-") } }.map { machine(_1) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# autoStopInterval is a net for a dead control plane; the app's own timers stop boxes sooner. Never retried:
|
|
107
|
+
# a retry would orphan a VM.
|
|
108
|
+
def create(name, labels: {}, shape: default_shape, disk: default_disk(shape), **)
|
|
109
|
+
sandbox = @client.find("/sandbox/#{name}") || @client.post(
|
|
110
|
+
"/sandbox",
|
|
111
|
+
{
|
|
112
|
+
name:,
|
|
113
|
+
snapshot: prepare(shape, disk),
|
|
114
|
+
target: REGION,
|
|
115
|
+
labels:,
|
|
116
|
+
autoStopInterval: 60,
|
|
117
|
+
autoDeleteInterval: -1
|
|
118
|
+
},
|
|
119
|
+
again: false
|
|
120
|
+
)
|
|
121
|
+
wake(machine(sandbox))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def find(name)
|
|
125
|
+
sandbox = @client.find("/sandbox/#{name}") or return nil
|
|
126
|
+
machine(sandbox)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def wake(m)
|
|
130
|
+
current = wait(m.id) { |s| %w[started stopped error build_failed destroyed].include?(s) }
|
|
131
|
+
if current[:state] == "stopped"
|
|
132
|
+
@client.post("/sandbox/#{m.id}/start")
|
|
133
|
+
current = wait(m.id) { |s| %w[started error].include?(s) }
|
|
134
|
+
end
|
|
135
|
+
unless current[:state] == "started"
|
|
136
|
+
raise Error, "sandbox #{m.name} #{current[:state]}: #{current[:errorReason]}"
|
|
137
|
+
end
|
|
138
|
+
machine(current)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def halt(m)
|
|
142
|
+
sandbox = @client.find("/sandbox/#{m.id}")
|
|
143
|
+
return false if sandbox.nil? || sandbox[:state] == "destroyed"
|
|
144
|
+
unless %w[stopped stopping].include?(sandbox[:state])
|
|
145
|
+
@client.post("/sandbox/#{m.id}/stop")
|
|
146
|
+
wait(m.id) { |s| s == "stopped" }
|
|
147
|
+
end
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A parent with live forks cannot go: the error says so.
|
|
152
|
+
def destroy(m)
|
|
153
|
+
TOKENS_LOCK.synchronize { TOKENS.delete(m.id) }
|
|
154
|
+
@client.delete("/sandbox/#{m.id}", force: true)
|
|
155
|
+
rescue Error => e
|
|
156
|
+
raise unless e.status == 404
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A copy of a started parent, disk and memory. A name already taken is the copy from a previous attempt.
|
|
160
|
+
def fork(m, name, labels: {}, shape: nil, disk: nil)
|
|
161
|
+
sandbox = @client.find("/sandbox/#{name}") ||
|
|
162
|
+
@client.post("/sandbox/#{m.id}/fork", { name: }, again: false)
|
|
163
|
+
@client.put("/sandbox/#{sandbox[:id]}/labels", { labels: }) if labels.any?
|
|
164
|
+
wake(machine(sandbox))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def transport(m, key: nil, host_key: nil, on_host_key: nil)
|
|
168
|
+
access = TOKENS_LOCK.synchronize do
|
|
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
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Their preview link for the port: a host at their edge and a token it wants in a header. The token rotates
|
|
184
|
+
# across a park, so nothing keeps it. A sandbox that is not there answers 404, which is nothing to proxy to.
|
|
185
|
+
def preview(m, port)
|
|
186
|
+
body = @client.find("/sandbox/#{m.id}/ports/#{port}/preview-url") or return nil
|
|
187
|
+
url, token = body.values_at(:url, :token)
|
|
188
|
+
if url.blank? || token.blank?
|
|
189
|
+
raise Error, "sandbox #{m.id} answered half a preview for port #{port}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
uri = URI(url)
|
|
193
|
+
{
|
|
194
|
+
host: [ uri.host, (uri.port unless uri.port == uri.default_port) ].compact.join(":"),
|
|
195
|
+
scheme: uri.scheme,
|
|
196
|
+
token:,
|
|
197
|
+
# Without the second one, the first response is their interstitial warning page.
|
|
198
|
+
headers: { "x-daytona-preview-token" => token, "x-daytona-skip-preview-warning" => "true" }
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def formula(cpu, memory, disk)
|
|
205
|
+
(cpu * cents("vcpu_hour") + memory * cents("gib_hour") + disk_rate(disk)).round(6)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def cents(key) = price_table.cents("daytona", key)
|
|
209
|
+
|
|
210
|
+
def disk_rate(disk) = (disk * cents("disk_gib_hour")).round(6)
|
|
211
|
+
|
|
212
|
+
# The list, an array or paged under items.
|
|
213
|
+
def sandboxes
|
|
214
|
+
body = @client.get("/sandbox")
|
|
215
|
+
body.is_a?(Hash) ? body[:items] : body
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The org: a setting, or read off any sandbox; there is no endpoint for it under an API key.
|
|
219
|
+
def org_id
|
|
220
|
+
@org_id ||= Sandbox::Adapter.credentials.setting(:daytona_org_id) ||
|
|
221
|
+
sandboxes.first&.dig(:organizationId) or
|
|
222
|
+
raise Error, "daytona: no sandbox to read the organization from"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The org's ceiling per sandbox in this region and class; the ladder's top when it says nothing.
|
|
226
|
+
def limits
|
|
227
|
+
region = @client.get("/organizations/#{org_id}/usage")[:regionUsage]
|
|
228
|
+
.find { _1[:regionId] == REGION && _1[:sandboxClass] == "linux-vm" }
|
|
229
|
+
{ cpu: region&.dig(:maxCpuPerSandbox) || 16, memory: region&.dig(:maxMemoryPerSandbox) || 32,
|
|
230
|
+
disk: region&.dig(:maxDiskPerSandbox) || 15 }
|
|
231
|
+
rescue Error
|
|
232
|
+
# The ladder's top stands in when the org says nothing; a size it refuses fails at create with
|
|
233
|
+
# their own reason.
|
|
234
|
+
{ cpu: 16, memory: 32, disk: 15 }
|
|
235
|
+
end
|
|
236
|
+
|
|
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
|
+
def wait(id, timeout: 300)
|
|
253
|
+
deadline = Time.current + timeout
|
|
254
|
+
loop do
|
|
255
|
+
sandbox = @client.get("/sandbox/#{id}")
|
|
256
|
+
return sandbox if yield(sandbox[:state])
|
|
257
|
+
if Time.current > deadline
|
|
258
|
+
raise Error, "sandbox #{id} still #{sandbox[:state]} after #{timeout}s"
|
|
259
|
+
end
|
|
260
|
+
sleep 3
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def machine(sandbox)
|
|
265
|
+
Sandbox::Adapter::Machine.new(
|
|
266
|
+
id: sandbox[:id],
|
|
267
|
+
name: sandbox[:name],
|
|
268
|
+
state: STATES.fetch(sandbox[:state], :starting),
|
|
269
|
+
data: sandbox
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
# Thin JSON client for https://api.e2b.dev. Raises Sandbox::Adapter::Provider::Error on non-2xx; 404 reads as nil where
|
|
4
|
+
# a lookup is meant. Every body comes back symbol-keyed.
|
|
5
|
+
class Sandbox::Provider::E2b::Client
|
|
6
|
+
BASE = "https://api.e2b.dev"
|
|
7
|
+
|
|
8
|
+
def initialize(token)
|
|
9
|
+
@token = token
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def get(path, params = {}) = request(Net::HTTP::Get, path, params:)
|
|
13
|
+
def post(path, body = {}) = request(Net::HTTP::Post, path, body:)
|
|
14
|
+
def delete(path) = request(Net::HTTP::Delete, path)
|
|
15
|
+
|
|
16
|
+
def find(path)
|
|
17
|
+
get(path)
|
|
18
|
+
rescue Sandbox::Adapter::Provider::Error => e
|
|
19
|
+
raise unless e.status == 404
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def request(klass, path, params: {}, body: nil)
|
|
26
|
+
uri = URI("#{BASE}#{path}")
|
|
27
|
+
uri.query = URI.encode_www_form(params) if params.any?
|
|
28
|
+
req = klass.new(uri)
|
|
29
|
+
req["X-API-Key"] = @token
|
|
30
|
+
req["Content-Type"] = "application/json"
|
|
31
|
+
req.body = body.to_json if body
|
|
32
|
+
res = Sandbox::Adapter::Provider.reaching("e2b") do
|
|
33
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 120) do |http|
|
|
34
|
+
http.request(req)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
json = res.body.to_s.empty? ? {} : parse(res.body)
|
|
38
|
+
return json if res.is_a?(Net::HTTPSuccess)
|
|
39
|
+
detail = json.is_a?(Hash) ? json[:message] : json.to_s[0, 200]
|
|
40
|
+
raise Sandbox::Adapter::Provider::Error.new(
|
|
41
|
+
"e2b #{req.method} #{path} #{res.code}: #{detail}",
|
|
42
|
+
status: res.code.to_i
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Symbol-keyed; a body that is not JSON is kept as text for the error.
|
|
47
|
+
def parse(body)
|
|
48
|
+
JSON.parse(body, symbolize_names: true)
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
body
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
# Reaches an E2B sandbox through envd, its in-box daemon: Connect-RPC for processes (streamed, so a long command
|
|
4
|
+
# is followed as it runs) and the files endpoint for uploads and downloads. Everything runs as root; Box's
|
|
5
|
+
# `as` handles the rest. What E2B hands Sandbox::Adapter::Box.
|
|
6
|
+
class Sandbox::Provider::E2b::Envd
|
|
7
|
+
HOST = "https://sandbox.e2b.app"
|
|
8
|
+
PORT = "49983"
|
|
9
|
+
END_FLAG = 2
|
|
10
|
+
CONNECTION_ERRORS = [
|
|
11
|
+
Errno::ECONNREFUSED,
|
|
12
|
+
Errno::ECONNRESET,
|
|
13
|
+
Errno::ETIMEDOUT,
|
|
14
|
+
Errno::EPIPE,
|
|
15
|
+
Net::OpenTimeout,
|
|
16
|
+
Net::ReadTimeout,
|
|
17
|
+
SocketError,
|
|
18
|
+
IOError
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(sandbox_id, access_token, user: "root")
|
|
22
|
+
@sandbox_id = sandbox_id
|
|
23
|
+
@access_token = access_token
|
|
24
|
+
@user = user
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def address = @sandbox_id
|
|
28
|
+
def connection_errors = CONNECTION_ERRORS
|
|
29
|
+
# Following a detached command through a second request does not hold up; a command is one streamed request.
|
|
30
|
+
def reattaches? = false
|
|
31
|
+
|
|
32
|
+
# Runs a shell command line; each output line is yielded as it arrives; returns the exit status.
|
|
33
|
+
def exec(command)
|
|
34
|
+
status = nil
|
|
35
|
+
buffers = Hash.new { |h, k| h[k] = "".b }
|
|
36
|
+
start = { process: { cmd: "/bin/bash", args: [ "-c", command ] } }
|
|
37
|
+
frames("process.Process/Start", start) do |frame|
|
|
38
|
+
event = frame.dig(:result, :event) || frame[:event] || {}
|
|
39
|
+
%i[stdout stderr].each do |stream|
|
|
40
|
+
data = event.dig(:data, stream) or next
|
|
41
|
+
Sandbox::Adapter::Box.each_line(buffers[stream], data.unpack1("m0")) { |line| yield line if block_given? }
|
|
42
|
+
end
|
|
43
|
+
status = event.dig(:end, :exitCode).to_i if event[:end]
|
|
44
|
+
end
|
|
45
|
+
buffers.each_value do |rest|
|
|
46
|
+
yield rest.force_encoding("UTF-8").scrub if rest.present? && block_given?
|
|
47
|
+
end
|
|
48
|
+
status
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def upload(path, io)
|
|
52
|
+
uri = URI("#{HOST}/files?path=#{CGI.escape(path)}&username=#{@user}")
|
|
53
|
+
req = Net::HTTP::Post.new(uri)
|
|
54
|
+
headers.each { |k, v| req[k] = v }
|
|
55
|
+
req.set_form([ [ "file", io, { filename: File.basename(path) } ] ], "multipart/form-data")
|
|
56
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 600) do |http|
|
|
57
|
+
http.request(req)
|
|
58
|
+
end
|
|
59
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
60
|
+
raise Sandbox::Adapter::Box::Failed, "envd upload #{path}: #{res.code} #{res.body.to_s[0, 200]}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def download(path)
|
|
65
|
+
uri = URI("#{HOST}/files?path=#{CGI.escape(path)}&username=#{@user}")
|
|
66
|
+
req = Net::HTTP::Get.new(uri)
|
|
67
|
+
headers.each { |k, v| req[k] = v }
|
|
68
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 600) do |http|
|
|
69
|
+
http.request(req)
|
|
70
|
+
end
|
|
71
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
72
|
+
raise Sandbox::Adapter::Box::Failed, "envd download #{path}: #{res.code} #{res.body.to_s[0, 200]}"
|
|
73
|
+
end
|
|
74
|
+
res.body
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def headers
|
|
80
|
+
{
|
|
81
|
+
"E2b-Sandbox-Id" => @sandbox_id,
|
|
82
|
+
"E2b-Sandbox-Port" => PORT,
|
|
83
|
+
"X-Access-Token" => @access_token.to_s,
|
|
84
|
+
"Authorization" => "Basic #{Base64.strict_encode64("#{@user}:")}"
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A Connect stream: 5-byte envelopes, the last one flagged, read as the body arrives; each frame is yielded
|
|
89
|
+
# symbol-keyed.
|
|
90
|
+
def frames(method, body)
|
|
91
|
+
uri = URI("#{HOST}/#{method}")
|
|
92
|
+
req = Net::HTTP::Post.new(uri)
|
|
93
|
+
connect = {
|
|
94
|
+
"Connect-Protocol-Version" => "1",
|
|
95
|
+
"Content-Type" => "application/connect+json",
|
|
96
|
+
"Connect-Timeout-Ms" => (24 * 3600 * 1000).to_s
|
|
97
|
+
}
|
|
98
|
+
headers.merge(connect).each { |k, v| req[k] = v }
|
|
99
|
+
payload = JSON.dump(body)
|
|
100
|
+
req.body = [ 0 ].pack("C") + [ payload.bytesize ].pack("N") + payload
|
|
101
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 24 * 3600) do |http|
|
|
102
|
+
http.request(req) do |res|
|
|
103
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
104
|
+
raise Sandbox::Adapter::Box::Failed, "envd #{method}: #{res.code} #{res.body.to_s[0, 200]}"
|
|
105
|
+
end
|
|
106
|
+
pending = "".b
|
|
107
|
+
res.read_body do |chunk|
|
|
108
|
+
pending << chunk.b
|
|
109
|
+
while pending.bytesize >= 5
|
|
110
|
+
flags, length = pending.unpack("CN")
|
|
111
|
+
break if pending.bytesize < 5 + length
|
|
112
|
+
frame = pending.byteslice(5, length)
|
|
113
|
+
pending = pending.byteslice(5 + length..) || "".b
|
|
114
|
+
return if flags & END_FLAG != 0
|
|
115
|
+
yield JSON.parse(frame, symbolize_names: true)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# E2B sandboxes: one template per shape (a base image with docker), sandboxes named through their metadata.
|
|
2
|
+
# No SSH: Box talks to envd, the daemon in the sandbox, as root.
|
|
3
|
+
class Sandbox::Provider::E2b < Sandbox::Adapter::SandboxVm
|
|
4
|
+
IMAGE = "ubuntu:24.04"
|
|
5
|
+
# Seconds a sandbox may run before E2B pauses it on its own; the app's own timers pause boxes sooner.
|
|
6
|
+
TIMEOUT = 3600
|
|
7
|
+
# By the state string the API answers.
|
|
8
|
+
STATES = { "running" => :running, "paused" => :stopped }.freeze
|
|
9
|
+
# The metadata key carrying the name.
|
|
10
|
+
LABEL = :"nvoi-name"
|
|
11
|
+
# Where a sandbox's ports are served, unless the sandbox names another domain.
|
|
12
|
+
DOMAIN = "e2b.app".freeze
|
|
13
|
+
# A fork inherits its parent's metadata and can take none of its own, so the name a sandbox answers to is
|
|
14
|
+
# written inside it; a running sandbox is asked, a paused one is not woken for it.
|
|
15
|
+
MARKER = "/etc/nvoi-name"
|
|
16
|
+
LADDER = %w[2x4 4x8 8x16 16x32].freeze
|
|
17
|
+
# The plan's ceiling per sandbox; there is no endpoint for it.
|
|
18
|
+
LIMITS = { cpu: 8, memory: 8, disk: 20 }.freeze
|
|
19
|
+
DISK = { min: 10, max: LIMITS[:disk], step: 5, default: 10 }.freeze
|
|
20
|
+
ENDED = %w[sandbox.lifecycle.paused sandbox.lifecycle.killed].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(token, client: Client.new(token), **)
|
|
23
|
+
super(token, **)
|
|
24
|
+
@client = client
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# What the template build runs on the image, as root (builds run as "user" by default): the tools the first
|
|
28
|
+
# steps need, and docker. No init runs in a sandbox, so dockerd and the app are started by the steps themselves.
|
|
29
|
+
BUILD = [
|
|
30
|
+
{ type: "USER", args: [ "root" ] },
|
|
31
|
+
{
|
|
32
|
+
type: "RUN",
|
|
33
|
+
args: [
|
|
34
|
+
"apt-get update -qq && " \
|
|
35
|
+
"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl ca-certificates gnupg git sudo"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{ type: "RUN", args: [ "curl -fsSL https://get.docker.com | sh" ] }
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# The template sandboxes are made from, at this size; built once per team, named by what goes in.
|
|
42
|
+
def prepare(shape = default_shape, disk = default_disk(shape))
|
|
43
|
+
cpu, memory = shape.split("x").map(&:to_i)
|
|
44
|
+
digest = Digest::SHA256.hexdigest("#{IMAGE}|#{cpu}|#{memory}|#{disk}|#{BUILD.to_json}")
|
|
45
|
+
name = "nvoi-#{digest[0, 8]}"
|
|
46
|
+
found = @client.get("/templates").find { |t| Array(t[:aliases]).include?(name) }
|
|
47
|
+
return found[:templateID] if found
|
|
48
|
+
|
|
49
|
+
built = @client.post(
|
|
50
|
+
"/v3/templates",
|
|
51
|
+
{
|
|
52
|
+
name:,
|
|
53
|
+
cpuCount: cpu,
|
|
54
|
+
memoryMB: memory * 1024,
|
|
55
|
+
minFreeDiskMb: disk * 1024
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
@client.post("/v2/templates/#{built[:templateID]}/builds/#{built[:buildID]}", {
|
|
59
|
+
fromImage: IMAGE,
|
|
60
|
+
steps: BUILD
|
|
61
|
+
})
|
|
62
|
+
deadline = Time.current + 1800
|
|
63
|
+
loop do
|
|
64
|
+
status = @client.get("/templates/#{built[:templateID]}/builds/#{built[:buildID]}/status")
|
|
65
|
+
return built[:templateID] if status[:status] == "ready"
|
|
66
|
+
raise Error, "template #{name}: #{status[:reason]}" if status[:status] == "error"
|
|
67
|
+
raise Error, "template #{name} still building after 30 minutes" if Time.current > deadline
|
|
68
|
+
sleep 10
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The ladder within the plan's limit, priced by the formula; storage is free, so held is zero.
|
|
73
|
+
def shapes
|
|
74
|
+
as_of = price_table.as_of("e2b")
|
|
75
|
+
LADDER.filter_map do |key|
|
|
76
|
+
cpu, memory = key.split("x").map(&:to_i)
|
|
77
|
+
next if cpu > LIMITS[:cpu] || memory > LIMITS[:memory]
|
|
78
|
+
[ key,
|
|
79
|
+
Sandbox::Adapter.shape(cpu:, memory:, disk: DISK, rate: formula(cpu, memory), source: :formula, as_of:) ]
|
|
80
|
+
end.to_h
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def rate(key, disk: nil)
|
|
84
|
+
cpu, memory = key.split("x").map(&:to_i)
|
|
85
|
+
formula(cpu, memory)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def held(_key, disk: nil) = 0.0
|
|
89
|
+
|
|
90
|
+
PRICING = "https://e2b.dev/pricing".freeze
|
|
91
|
+
|
|
92
|
+
# Their pricing page: two per-second rates, the first row of the vCPU table and of the memory table.
|
|
93
|
+
def prices(page = Sandbox::Adapter::Pages.fetch(PRICING))
|
|
94
|
+
vcpu = page[%r{vCPUs\s+Plan\s+Costs\s+1\s+[\w /]+\$(\d+\.\d+)/s}, 1]
|
|
95
|
+
gib = page[%r{Memory \(GiB\)\s+Plan\s+Costs\s+1\s+[\w /]+\$(\d+\.\d+)/s}, 1]
|
|
96
|
+
return { source: "page", rates: nil } unless vcpu && gib
|
|
97
|
+
{ source: "page", rates: { "vcpu_hour" => Sandbox::Adapter::Pages.cents(vcpu, per: 3600),
|
|
98
|
+
"gib_hour" => Sandbox::Adapter::Pages.cents(gib, per: 3600) } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Their meter: each pause or kill event carries the execution it ended, so the sum is the running time
|
|
102
|
+
# up to the last stop; a run underway is not in it yet. Events are kept seven days. No price.
|
|
103
|
+
def usage(m)
|
|
104
|
+
events = @client.get("/events/sandboxes/#{m.id}", { limit: 100 })
|
|
105
|
+
ended = events.select { ENDED.include?(_1[:type]) }
|
|
106
|
+
{ seconds: (ended.sum {
|
|
107
|
+
_1.dig(:eventData, :execution, :execution_time).to_f } / 1000).round, cents: nil }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Every sandbox with our label, named by it; a fork shares its parent's, which is the name it was made
|
|
111
|
+
# under and enough for a sweep.
|
|
112
|
+
def list
|
|
113
|
+
@client.get("/v2/sandboxes").filter_map do |s|
|
|
114
|
+
name = s.dig(:metadata, LABEL) or next
|
|
115
|
+
machine(s, name)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def create(name, labels: {}, shape: default_shape, disk: default_disk(shape), **)
|
|
120
|
+
find(name) || begin
|
|
121
|
+
data = @client.post(
|
|
122
|
+
"/sandboxes",
|
|
123
|
+
{
|
|
124
|
+
templateID: prepare(shape, disk),
|
|
125
|
+
timeout: TIMEOUT,
|
|
126
|
+
autoPause: true,
|
|
127
|
+
metadata: labels.merge(LABEL => name)
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
named(machine(data, name))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Every sandbox of ours is a candidate, since a fork carries its parent's metadata: a running one is asked its
|
|
135
|
+
# name; a paused one counts only when its own metadata says so (a seed, never a fork).
|
|
136
|
+
def find(name)
|
|
137
|
+
ours = @client.get("/v2/sandboxes").select do |s|
|
|
138
|
+
s.dig(:metadata, LABEL) && %w[running paused].include?(s[:state])
|
|
139
|
+
end
|
|
140
|
+
data = ours.find { |s| s[:state] == "running" && marker(machine(s, name)) == name } ||
|
|
141
|
+
ours.find do |s|
|
|
142
|
+
s[:state] == "paused" && s.dig(:metadata, LABEL) == name &&
|
|
143
|
+
ours.none? { |o| o[:state] == "paused" && o[:parent] == s[:sandboxID] }
|
|
144
|
+
end
|
|
145
|
+
data && machine(data, name)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Connect resumes a paused sandbox and refreshes the timeout on a running one.
|
|
149
|
+
def wake(m) = machine(@client.post("/sandboxes/#{m.id}/connect", { timeout: TIMEOUT }), m.name)
|
|
150
|
+
|
|
151
|
+
def halt(m)
|
|
152
|
+
@client.post("/sandboxes/#{m.id}/pause")
|
|
153
|
+
true
|
|
154
|
+
rescue Error => e
|
|
155
|
+
raise unless [ 404, 409 ].include?(e.status)
|
|
156
|
+
e.status == 409
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def destroy(m)
|
|
160
|
+
@client.delete("/sandboxes/#{m.id}")
|
|
161
|
+
rescue Error => e
|
|
162
|
+
raise unless e.status == 404
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A copy of a running parent, disk and memory, given its own name inside.
|
|
166
|
+
def fork(m, name, labels: {}, shape: nil, disk: nil)
|
|
167
|
+
find(name) || begin
|
|
168
|
+
result = @client.post("/sandboxes/#{m.id}/fork", { timeout: TIMEOUT, count: 1 }).first
|
|
169
|
+
raise Error, "fork of #{m.name}: #{result.dig(:error, :message)}" if result[:error]
|
|
170
|
+
named(machine(result[:sandbox], name))
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def transport(m, key: nil, host_key: nil, on_host_key: nil)
|
|
175
|
+
data = m.data
|
|
176
|
+
unless data[:envdAccessToken]
|
|
177
|
+
data = @client.post("/sandboxes/#{m.id}/connect", { timeout: TIMEOUT })
|
|
178
|
+
end
|
|
179
|
+
Envd.new(m.id, data[:envdAccessToken])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Their host for the port, TLS at their edge. Sandboxes are made with public traffic allowed, so no token
|
|
183
|
+
# goes with it; a restricted one would carry `trafficAccessToken` as `e2b-traffic-access-token`. A paused
|
|
184
|
+
# sandbox is nothing to proxy to; asking about it does not wake it.
|
|
185
|
+
def preview(m, port)
|
|
186
|
+
data = @client.find("/sandboxes/#{m.id}") or return nil
|
|
187
|
+
return nil unless data[:state] == "running"
|
|
188
|
+
|
|
189
|
+
host = "#{port}-#{m.id}.#{data[:domain].presence || DOMAIN}"
|
|
190
|
+
{ host:, scheme: "https", token: nil, headers: {} }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def formula(cpu, memory)
|
|
196
|
+
(cpu * cents("vcpu_hour") + memory * cents("gib_hour")).round(6)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def cents(key) = price_table.cents("e2b", key)
|
|
200
|
+
|
|
201
|
+
def named(m)
|
|
202
|
+
transport(m).exec("printf '%s' #{Shellwords.escape(m.name)} > #{MARKER}")
|
|
203
|
+
m
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def marker(m)
|
|
207
|
+
out = +""
|
|
208
|
+
transport(m).exec("cat #{MARKER} 2>/dev/null") { |line| out << line }
|
|
209
|
+
out.strip
|
|
210
|
+
rescue Sandbox::Adapter::Box::Failed, *Envd::CONNECTION_ERRORS
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def machine(data, name)
|
|
215
|
+
Sandbox::Adapter::Machine.new(
|
|
216
|
+
id: data[:sandboxID],
|
|
217
|
+
name:,
|
|
218
|
+
state: STATES.fetch(data[:state], :running),
|
|
219
|
+
data:
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
end
|