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.
@@ -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