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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 012300e69a3b0389101f1d843340008aab5e7cc0ef0173227187e7a0f889ec52
4
+ data.tar.gz: 10e39f638d1aec981f651a0ed391a07834fbb0a900944ae8ddcff6996a726cea
5
+ SHA512:
6
+ metadata.gz: 12d574beb0f49b05519c9ff95fae5de37d5c54baf0fa034b1d0d64d138e99d6925531ea48d449e02d7ca4adbfe4a7d64597067a750e73b57a358de96388d66e5
7
+ data.tar.gz: 7370726a28469cf76d5d55d55a20fc226928bea484e9739be9cba51f6e88bc32ab0b75b4787f6ce421f8486bd2ee22d27fcd1c354e78a434885c7c5436286f4d
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nvoi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # sandbox-provider
2
+
3
+ Providers for `sandbox-adapter`.
4
+
5
+ | Name | Class | Kind |
6
+ |---|---|---|
7
+ | daytona | `Sandbox::Provider::Daytona` | sandbox, forks from a seed snapshot, SSH |
8
+ | boat_dev | `Sandbox::Provider::BoatDev` | sandbox, SSH, named snapshots |
9
+ | e2b | `Sandbox::Provider::E2b` | sandbox, envd transport |
10
+ | hetzner | `Sandbox::Provider::Hetzner` | full VM, cloud-init |
11
+
12
+ ```ruby
13
+ require "sandbox-provider"
14
+
15
+ Sandbox::Adapter.providers = {
16
+ "daytona" => "Sandbox::Provider::Daytona",
17
+ "e2b" => "Sandbox::Provider::E2b"
18
+ }
19
+ ```
20
+
21
+ Keys: `DAYTONA_API_KEY`, `E2B_API_KEY`, `BOAT_DEV_API_KEY`, `HETZNER_API_KEY`. Settings: `daytona_org_id`, `boat_dev_team_id`.
@@ -0,0 +1,75 @@
1
+ require "net/http"
2
+
3
+ # Thin JSON client for https://boat.dev/api/v1. Raises Sandbox::Adapter::Provider::Error on non-2xx; 404 reads as nil
4
+ # where a lookup is meant. Every body comes back symbol-keyed. `org` is the team whose wallet pays, sent as
5
+ # X-Boat-Org on every request; without one the key's own account pays.
6
+ class Sandbox::Provider::BoatDev::Client
7
+ BASE = "https://boat.dev/api/v1"
8
+ RETRIED = [ 429, 502, 503, 504 ].freeze
9
+
10
+ def initialize(token, org: nil)
11
+ @token = token
12
+ @org = org
13
+ end
14
+
15
+ def get(path, params = {}) = request(Net::HTTP::Get, path, params:)
16
+ def post(path, body = {}, again: true, headers: {})
17
+ request(Net::HTTP::Post, path, body:, again:, headers:)
18
+ end
19
+ def patch(path, body = {}) = request(Net::HTTP::Patch, path, body:)
20
+ def delete(path, headers: {}) = request(Net::HTTP::Delete, path, headers:)
21
+
22
+ # nil when the resource is not there.
23
+ def find(path)
24
+ get(path)
25
+ rescue Sandbox::Adapter::Provider::Error => e
26
+ raise unless e.status == 404
27
+ nil
28
+ end
29
+
30
+ private
31
+
32
+ def request(klass, path, params: {}, body: nil, headers: {}, again: true)
33
+ uri = URI("#{BASE}#{path}")
34
+ uri.query = URI.encode_www_form(params) if params.any?
35
+ attempts = 0
36
+ loop do
37
+ attempts += 1
38
+ req = klass.new(uri)
39
+ req["Authorization"] = "Bearer #{@token}"
40
+ req["Content-Type"] = "application/json"
41
+ req["X-Boat-Org"] = @org if @org.present?
42
+ headers.each { |k, v| req[k] = v }
43
+ req.body = body.to_json if body
44
+ res = Sandbox::Adapter::Provider.reaching("boat.dev") do
45
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 120) do |http|
46
+ http.request(req)
47
+ end
48
+ end
49
+ json = res.body.to_s.empty? ? {} : parse(res.body)
50
+ return json if res.is_a?(Net::HTTPSuccess)
51
+
52
+ code = res.code.to_i
53
+ if again && RETRIED.include?(code) && attempts < 4
54
+ sleep attempts * 0.5
55
+ next
56
+ end
57
+ raise Sandbox::Adapter::Provider::Error.new(
58
+ "boat.dev #{req.method} #{path} #{code}: #{detail(json)}",
59
+ status: code
60
+ )
61
+ end
62
+ end
63
+
64
+ # Their envelope carries a code and a message; a body that is not JSON is kept as text.
65
+ def detail(json)
66
+ return json.to_s[0, 200] unless json.is_a?(Hash)
67
+ [ json[:code], json[:message] ].compact.join(" ")
68
+ end
69
+
70
+ def parse(body)
71
+ JSON.parse(body, symbolize_names: true)
72
+ rescue JSON::ParserError
73
+ body
74
+ end
75
+ end
@@ -0,0 +1,30 @@
1
+ require "ed25519"
2
+
3
+ # The one SSH key pair the app presents to boat.dev sandboxes: ed25519, derived from the app's secret, so every
4
+ # process holds the same key and a sandbox authorises it once. Net::SSH reads the private half in OpenSSH's
5
+ # own format, built here since OpenSSL cannot write it.
6
+ module Sandbox::Provider::BoatDev::Key
7
+ COMMENT = "nvoi"
8
+
9
+ def self.private_key = @private_key ||= openssh(seed)
10
+ def self.public_key = ::SshKey.public_key(private_key)
11
+
12
+ def self.seed = Sandbox::Adapter.credentials.key("boat_dev_ssh", length: 32)
13
+
14
+ # An unencrypted openssh-key-v1 envelope around the seed and its public half.
15
+ def self.openssh(seed)
16
+ signing = Ed25519::SigningKey.new(seed)
17
+ pk = signing.verify_key.to_bytes
18
+ public_blob = field("ssh-ed25519") + field(pk)
19
+ check = SecureRandom.bytes(4)
20
+ private_blob = check + check + field("ssh-ed25519") + field(pk) + field(seed + pk) +
21
+ field(COMMENT)
22
+ private_blob << (1..(8 - private_blob.bytesize % 8) % 8).to_a.pack("C*")
23
+ blob = "openssh-key-v1\0".b + field("none") + field("none") + field("") + [ 1 ].pack("N") +
24
+ field(public_blob) + field(private_blob)
25
+ lines = Base64.strict_encode64(blob).scan(/.{1,70}/).join("\n")
26
+ "-----BEGIN OPENSSH PRIVATE KEY-----\n#{lines}\n-----END OPENSSH PRIVATE KEY-----\n"
27
+ end
28
+
29
+ def self.field(bytes) = [ bytes.bytesize ].pack("N") + bytes.b
30
+ end
@@ -0,0 +1,380 @@
1
+ # boat.dev sandboxes: Ubuntu VMs from their own image, named through the display name (they keep no labels).
2
+ # A sealed seed is a named snapshot, deployable by name across the account and independent of the machine
3
+ # it was taken from; a fork is a sandbox created from it. SSH lands as `user`, who has sudo, behind the
4
+ # app's key.
5
+ class Sandbox::Provider::BoatDev < Sandbox::Adapter::SandboxVm
6
+ # Seconds before boat.dev archives a sandbox, counted from its last create, resume or refresh (they have no
7
+ # idle timer): the net for a dead control plane, refreshed with every transport. The app's own IdleJob
8
+ # parks boxes long before. Two hours is the most a trial account may set.
9
+ TIMEOUT = 2 * 3600
10
+ # A sealed seed's id, as machines carry it: `snapshot:<name>`. Named snapshots are the account's, read,
11
+ # deployed and deleted by name; the machine they were taken from is kept beside them, stopped, so the
12
+ # seed is still found by its name.
13
+ SNAPSHOT = "snapshot:"
14
+ # By the state string the API answers; the rest is on its way up.
15
+ STATES = {
16
+ "ready" => :running,
17
+ "idle" => :running,
18
+ "running" => :running,
19
+ "archiving" => :stopped,
20
+ "archived" => :stopped,
21
+ "error" => :broken
22
+ }.freeze
23
+ ACTIVE = %w[ready idle running].freeze
24
+ SETTLED = (ACTIVE + %w[archived error]).freeze
25
+ LOGIN = "user"
26
+ # What a snapshot carries: /home/user, /etc, /usr, /opt, /var/lib; not /mnt. The data lives in the home.
27
+ MOUNT = "/home/user/nvoi"
28
+ # A fork copies the parent's latest snapshot, taken about once a minute while it runs: a fork waits this
29
+ # long for one newer than the request, then goes with what there is.
30
+ SNAPSHOT_WAIT = 150
31
+ ACCESS_MINUTES = 5
32
+ # Where SSH lands and the host key their API vouches for, good until `until`. A resume is a new machine
33
+ # with a new key, so nothing outlives a wake.
34
+ Access = Data.define(:host, :port, :host_key, :until)
35
+ ACCESS = {}
36
+ ACCESS_LOCK = Mutex.new
37
+
38
+ # The team whose wallet pays, when there is one.
39
+ def self.team_id
40
+ Sandbox::Adapter.credentials.setting(:boat_dev_team_id)
41
+ end
42
+
43
+ def initialize(token, team_id: self.class.team_id, client: Client.new(token, org: team_id), **)
44
+ super(token, **)
45
+ @client = client
46
+ end
47
+
48
+ # The login is the deploy user: nothing runs as root, sudo does the rest.
49
+ def layout = Sandbox::Adapter::Layout.for(login: LOGIN, volume: false).with(user: LOGIN, mount: MOUNT)
50
+
51
+ # Their image is what sandboxes come from; nothing to make.
52
+ def prepare(shape = default_shape, disk = default_disk(shape)) = nil
53
+
54
+ # Their sizes, disk fixed by the tier, at the table's price. small is the shape every sandbox gets today.
55
+ TIERS = {
56
+ "2x4" => [ "small", 12 ],
57
+ "4x8" => [ "default", 50 ],
58
+ "8x16" => [ "large", 125 ],
59
+ "16x32" => [ "xlarge", 251 ]
60
+ }.freeze
61
+
62
+ def shapes
63
+ as_of = price_table.as_of("boat_dev")
64
+ TIERS.to_h do |key, (type, disk)|
65
+ cpu, memory = key.split("x").map(&:to_i)
66
+ [ key, Sandbox::Adapter.shape(cpu:, memory:, disk:, type:, rate: price_table.cents("boat_dev", type),
67
+ source: :table, as_of:) ]
68
+ end
69
+ end
70
+
71
+ PRICING = "https://docs.boat.dev/pricing".freeze
72
+
73
+ # Their pricing page, markdown: a table row per type, its $/hour in bold.
74
+ def prices(page = Sandbox::Adapter::Pages.fetch(PRICING))
75
+ rates = TIERS.to_h do |_, (type, _)|
76
+ dollars = page[/`#{type}`[^$\n]{0,40}\$(\d+\.\d+)/,
77
+ 1] or return { source: "page", rates: nil }
78
+ [ type, Sandbox::Adapter::Pages.cents(dollars) ]
79
+ end
80
+ { source: "page", rates: }
81
+ end
82
+
83
+ # Their meter since creation. The billed seconds carry the type's multiplier; wall seconds are those
84
+ # divided back. A snapshot has no meter.
85
+ def usage(m)
86
+ return nil if snapshot?(m)
87
+ usage = @client.get("/sandboxes/#{m.id}/usage")
88
+ multiplier = usage[:billingMultiplier].to_f.nonzero? || 1.0
89
+ { seconds: (usage[:seconds].to_f / multiplier).round,
90
+ cents: (usage[:dollars].to_f * 100).round(6) }
91
+ end
92
+
93
+ # Every sandbox of the account, newest first.
94
+ def list
95
+ machines = []
96
+ cursor = nil
97
+ loop do
98
+ page = @client.get("/sandboxes", { limit: 200, cursor: }.compact)
99
+ machines.concat(page[:sandboxes].map { machine(_1) })
100
+ cursor = page.dig(:pageInfo, :nextCursor)
101
+ return machines if cursor.blank?
102
+ end
103
+ end
104
+
105
+ # Retried safely: the name and the request are the idempotency key, so a lost answer does not make a
106
+ # second sandbox, and the same name asked differently later is a new request. noEnv keeps the account's
107
+ # secrets out of it.
108
+ def create(name, labels: {}, shape: default_shape, disk: default_disk(shape), from: nil, **)
109
+ body = { type: TIERS.fetch(shape).first, ttlSeconds: TIMEOUT, noEnv: true, from: }.compact
110
+ key = "nvoi-#{Digest::SHA256.hexdigest("#{name}\n#{body.to_json}")[0, 32]}"
111
+ sandbox = lookup(name) || named(
112
+ @client.post("/sandboxes", body, headers: { "Idempotency-Key" => key })[:sandbox],
113
+ name
114
+ )
115
+ wake(machine(sandbox))
116
+ end
117
+
118
+ # A name is a sandbox's; a sealed seed's sandbox stays, stopped, as the snapshot's source, so the seed
119
+ # is found through it.
120
+ def find(name)
121
+ saved = snapshot(snapshot_name(name))
122
+ return snapshot_machine(snapshot_name(name), saved) if saved
123
+ sandbox = lookup(name) or return nil
124
+ machine(sandbox)
125
+ end
126
+
127
+ # The seed's machine becomes a named snapshot of it, and the machine is stopped: forks deploy the
128
+ # snapshot by name, nothing has to keep running for them.
129
+ def seal(m)
130
+ name = snapshot_name(m.name)
131
+ @client.post("/named-snapshots", { sandboxId: m.id, name: })
132
+ saved = wait_snapshot(name)
133
+ @client.post("/sandboxes/#{m.id}/stop", {}, again: false)
134
+ snapshot_machine(name, saved)
135
+ end
136
+
137
+ # A snapshot is always ready; a sandbox archived is resumed.
138
+ def wake(m)
139
+ return m if snapshot?(m)
140
+ current = wait(m.id) { |s| SETTLED.include?(s) }
141
+ if current[:state] == "archived"
142
+ @client.post("/sandboxes/#{m.id}/resume", { ttlSeconds: TIMEOUT }, again: false)
143
+ ACCESS_LOCK.synchronize { ACCESS.delete(m.id) }
144
+ current = wait(m.id) { |s| ACTIVE.include?(s) || s == "error" }
145
+ end
146
+ unless ACTIVE.include?(current[:state])
147
+ raise Error, "sandbox #{m.name} #{current[:state]}: #{current[:error]}"
148
+ end
149
+ machine(current)
150
+ end
151
+
152
+ # A stop saves the disk first, in the background; the answer is whether there is still a machine to wake.
153
+ # A snapshot has nothing to stop.
154
+ def halt(m)
155
+ return true if snapshot?(m)
156
+ ACCESS_LOCK.synchronize { ACCESS.delete(m.id) }
157
+ sandbox = @client.find("/sandboxes/#{m.id}")&.dig(:sandbox)
158
+ return false if sandbox.nil?
159
+ unless %w[archived archiving].include?(sandbox[:state])
160
+ @client.post("/sandboxes/#{m.id}/stop")
161
+ end
162
+ true
163
+ end
164
+
165
+ # A sandbox: force-stopped and gone, along with its own snapshots. A sealed seed: the named snapshot,
166
+ # and the stopped machine it was taken from when it is still there. Gone already is fine.
167
+ def destroy(m)
168
+ ACCESS_LOCK.synchronize { ACCESS.delete(m.id) }
169
+ if snapshot?(m)
170
+ name = snapshot_ref(m)
171
+ saved = snapshot(name)
172
+ if saved
173
+ @client.delete("/named-snapshots/#{name}", headers: { "X-Ascii-Confirm-Delete" => name })
174
+ source = @client.find("/sandboxes/#{saved[:sourceSandboxId]}")&.dig(:sandbox)
175
+ destroy(machine(source)) if source
176
+ end
177
+ else
178
+ @client.delete("/sandboxes/#{m.id}", headers: { "X-Ascii-Confirm-Delete" => m.id })
179
+ end
180
+ rescue Error => e
181
+ raise unless e.status == 404
182
+ end
183
+
184
+ # A sandbox from the sealed seed's snapshot: its disk as sealed, up in seconds. A name already taken is
185
+ # the one from a previous attempt. Labels have nowhere to go.
186
+ def fork(m, name, labels: {}, shape: nil, disk: nil)
187
+ raise Error, "#{m.name} is not a sealed seed" unless snapshot?(m)
188
+ m = Sandbox::Adapter::Machine.new(id: m.id, name: m.name, state: m.state,
189
+ data: m.data.merge({ shape:, disk: }.compact))
190
+ create(name, from: snapshot_ref(m), shape: m.data[:shape] || default_shape,
191
+ disk: m.data[:disk] || default_disk(m.data[:shape] || default_shape))
192
+ end
193
+
194
+ # SSH as `user` with the app's key, at the endpoint their API answers along with the machine's host key,
195
+ # which is the pin: a resume lands on a new machine, so a key kept from before would only mismatch.
196
+ # Access is kept per process, and another process may have paused or resumed the sandbox since: a key
197
+ # that no longer matches, or an endpoint that no longer answers, is asked of their API again.
198
+ def transport(m, key: nil, host_key: nil, on_host_key: nil)
199
+ ssh = lambda do |fresh|
200
+ access = ACCESS_LOCK.synchronize do
201
+ kept = ACCESS[m.id]
202
+ kept = nil if fresh || kept.nil? || kept.until < Time.current
203
+ kept || (ACCESS[m.id] = mint(m.id))
204
+ end
205
+ Sandbox::Adapter::Ssh.new(
206
+ host: access.host,
207
+ port: access.port,
208
+ user: LOGIN,
209
+ key: Key.private_key,
210
+ host_key: access.host_key || (host_key unless fresh),
211
+ on_host_key: (on_host_key unless access.host_key)
212
+ )
213
+ end
214
+ Repinning.new(ssh)
215
+ end
216
+
217
+ # An SSH transport that asks for the sandbox's current access when the one it holds fails: a pinned key
218
+ # that no longer matches (raised on connect, before any command runs) is retried at once on the fresh
219
+ # access; a refused or dropped connection is raised as it was, for the caller's own retry to use the fresh
220
+ # access. Access is asked again at most every AGAIN seconds.
221
+ class Repinning < SimpleDelegator
222
+ AGAIN = 10
223
+
224
+ def initialize(ssh)
225
+ @ssh = ssh
226
+ super(ssh.call(false))
227
+ end
228
+
229
+ def exec(...) = again { __getobj__.exec(...) }
230
+ def upload(...) = again { __getobj__.upload(...) }
231
+ def download(...) = again { __getobj__.download(...) }
232
+
233
+ private
234
+
235
+ def again
236
+ yield
237
+ rescue Sandbox::Adapter::Ssh::HostKeyMismatch
238
+ raise unless refresh
239
+ yield
240
+ rescue *Sandbox::Adapter::Ssh::CONNECTION_ERRORS
241
+ refresh
242
+ raise
243
+ end
244
+
245
+ def refresh
246
+ return false if @refreshed && Time.current - @refreshed < AGAIN
247
+ @refreshed = Time.current
248
+ __setobj__(@ssh.call(true))
249
+ true
250
+ end
251
+ end
252
+
253
+ # Their stable route for the port, TLS at their edge, opened ungated: the gate they offer is a `_token`
254
+ # query parameter that turns into a cookie, which the worker cannot set without dropping the visitor's
255
+ # own. Idempotent, so it is asked on every call. A parked sandbox is nothing to proxy to.
256
+ def preview(m, port)
257
+ sandbox = @client.find("/sandboxes/#{m.id}")&.dig(:sandbox) or return nil
258
+ return nil unless ACTIVE.include?(sandbox[:state])
259
+
260
+ # Private at their edge: the url carries a token their edge takes once as `?_token=` and then wants
261
+ # as a `_port_auth` cookie; the worker sends the cookie on every request. No header form exists.
262
+ hosted = @client.post("/sandboxes/#{m.id}/host", { port:, public: false })
263
+ uri = URI(hosted[:url].to_s)
264
+ raise Error, "sandbox #{m.id} answered no url for port #{port}" if uri.host.blank?
265
+ token = URI.decode_www_form(uri.query.to_s).to_h["_token"]
266
+ raise Error, "sandbox #{m.id} answered no token for port #{port}" if token.blank?
267
+ {
268
+ host: [ uri.host, (uri.port unless uri.port == uri.default_port) ].compact.join(":"),
269
+ scheme: uri.scheme,
270
+ token:,
271
+ headers: { "cookie" => "_port_auth=#{token}" }
272
+ }
273
+ rescue Error => e
274
+ raise unless e.status == 400 && e.message.include?("machine_not_running")
275
+ nil
276
+ end
277
+
278
+ private
279
+
280
+ # The sandbox of ours by that name, newest first; the list has no filter of its own.
281
+ def lookup(name)
282
+ cursor = nil
283
+ loop do
284
+ page = @client.get("/sandboxes", { limit: 200, cursor: }.compact)
285
+ found = page[:sandboxes].find { |s| s[:name] == name }
286
+ return found if found
287
+ cursor = page.dig(:pageInfo, :nextCursor)
288
+ return nil if cursor.blank?
289
+ end
290
+ end
291
+
292
+ def named(sandbox, name)
293
+ @client.patch("/sandboxes/#{sandbox[:id]}", { name: })[:sandbox]
294
+ end
295
+
296
+ # Authorises the key and refreshes the deadline; asked again while the machine is still coming up.
297
+ def mint(id)
298
+ deadline = Time.current + 60
299
+ begin
300
+ access = @client.post("/sandboxes/#{id}/sshkey", { key: Key.public_key })
301
+ rescue Error => e
302
+ raise unless e.status == 409 && Time.current < deadline
303
+ sleep 2
304
+ retry
305
+ end
306
+ @client.patch("/sandboxes/#{id}", { ttlSeconds: TIMEOUT })
307
+ host, port = access[:sshEndpoint].presence&.split(":") || [ access[:machineIp], 22 ]
308
+ raise Error, "sandbox #{id} has no address" if host.blank?
309
+ Access.new(
310
+ host:,
311
+ port: port.to_i,
312
+ host_key: access[:hostKey].presence,
313
+ until: Time.current + ACCESS_MINUTES.minutes
314
+ )
315
+ end
316
+
317
+ # Named snapshots are the account's; the name is the seed's, within their alphabet.
318
+ def snapshot_name(seed_name) = seed_name.downcase.gsub(/[^a-z0-9-]/, "-")[0, 63]
319
+ def snapshot?(m) = m.id.to_s.start_with?(SNAPSHOT)
320
+ def snapshot_ref(m) = m.id.delete_prefix(SNAPSHOT)
321
+
322
+ def snapshot(name)
323
+ @client.find("/named-snapshots/#{name}")&.dig(:snapshot)
324
+ end
325
+
326
+ def snapshot_machine(name, saved)
327
+ state = { "ready" => :running, "saving" => :starting }.fetch(saved[:status], :broken)
328
+ Sandbox::Adapter::Machine.new(id: "#{SNAPSHOT}#{name}", name:, state:,
329
+ data: { source: saved[:sourceSandboxId] })
330
+ end
331
+
332
+ # Saving takes as long as the disk; a snapshot that failed says why.
333
+ def wait_snapshot(name)
334
+ deadline = Time.current + SNAPSHOT_WAIT
335
+ loop do
336
+ saved = snapshot(name) or raise Error, "snapshot #{name} is gone"
337
+ return saved if saved[:status] == "ready"
338
+ raise Error, "snapshot #{name} failed: #{saved[:error]}" if saved[:status] == "failed"
339
+ raise Error,
340
+ "snapshot #{name} still saving after #{SNAPSHOT_WAIT}s" if Time.current > deadline
341
+ sleep 3
342
+ end
343
+ end
344
+
345
+ # A snapshot newer than now, when the parent is up: what a fork copies is the latest one, and the parent
346
+ # may have written since. A stopped parent's final snapshot is complete already.
347
+ def snapshotted(id)
348
+ asked = Time.current
349
+ deadline = asked + SNAPSHOT_WAIT
350
+ loop do
351
+ sandbox = @client.get("/sandboxes/#{id}")[:sandbox]
352
+ return unless ACTIVE.include?(sandbox[:state])
353
+ taken = sandbox[:snapshotCompletedAt]
354
+ return if taken && Time.parse(taken) >= asked
355
+ return if Time.current > deadline
356
+ sleep 5
357
+ end
358
+ end
359
+
360
+ def wait(id, timeout: 300)
361
+ deadline = Time.current + timeout
362
+ loop do
363
+ sandbox = @client.get("/sandboxes/#{id}")[:sandbox]
364
+ return sandbox if yield(sandbox[:state])
365
+ if Time.current > deadline
366
+ raise Error, "sandbox #{id} still #{sandbox[:state]} after #{timeout}s"
367
+ end
368
+ sleep 2
369
+ end
370
+ end
371
+
372
+ def machine(sandbox)
373
+ Sandbox::Adapter::Machine.new(
374
+ id: sandbox[:id],
375
+ name: sandbox[:name],
376
+ state: STATES.fetch(sandbox[:state], :starting),
377
+ data: sandbox
378
+ )
379
+ end
380
+ end
@@ -0,0 +1,19 @@
1
+ # Boot config for a new server: one sudoer, key-only SSH, root disabled. Docker is installed later over SSH.
2
+ module Sandbox::Provider::CloudInit
3
+ def self.render(hostname:, user:, public_key:)
4
+ {
5
+ hostname:,
6
+ users: [
7
+ {
8
+ name: user,
9
+ groups: [ "sudo" ],
10
+ shell: "/bin/bash",
11
+ sudo: "ALL=(ALL) NOPASSWD:ALL",
12
+ ssh_authorized_keys: [ public_key ]
13
+ }
14
+ ],
15
+ ssh_pwauth: false,
16
+ disable_root: true
17
+ }.deep_stringify_keys.to_yaml(line_width: -1).sub("---\n", "#cloud-config\n")
18
+ end
19
+ end
@@ -0,0 +1,70 @@
1
+ require "net/http"
2
+
3
+ # Thin JSON client for https://app.daytona.io/api. Raises Sandbox::Adapter::Provider::Error on non-2xx; 404 reads as nil
4
+ # where a lookup is meant. Every body comes back symbol-keyed.
5
+ class Sandbox::Provider::Daytona::Client
6
+ BASE = "https://app.daytona.io/api"
7
+ ANALYTICS = "https://analytics.app.daytona.io"
8
+ RETRIED = [ 429, 502, 503, 504 ].freeze
9
+
10
+ def initialize(token)
11
+ @token = token
12
+ end
13
+
14
+ def get(path, params = {}) = request(Net::HTTP::Get, path, params:)
15
+ def post(path, body = {}, params: {}, again: true)
16
+ request(Net::HTTP::Post, path, body:, params:, again:)
17
+ end
18
+ def put(path, body = {}) = request(Net::HTTP::Put, path, body:)
19
+ def delete(path, params = {}) = request(Net::HTTP::Delete, path, params:)
20
+ # The analytics API, same key: usage periods per sandbox.
21
+ def analytics(path, params = {}) = request(Net::HTTP::Get, path, params:, base: ANALYTICS)
22
+
23
+ # nil when the resource is not there.
24
+ def find(path)
25
+ get(path)
26
+ rescue Sandbox::Adapter::Provider::Error => e
27
+ raise unless e.status == 404
28
+ nil
29
+ end
30
+
31
+ private
32
+
33
+ def request(klass, path, params: {}, body: nil, again: true, base: BASE)
34
+ uri = URI("#{base}#{path}")
35
+ uri.query = URI.encode_www_form(params) if params.any?
36
+ attempts = 0
37
+ loop do
38
+ attempts += 1
39
+ req = klass.new(uri)
40
+ req["Authorization"] = "Bearer #{@token}"
41
+ req["Content-Type"] = "application/json"
42
+ req.body = body.to_json if body
43
+ res = Sandbox::Adapter::Provider.reaching("daytona") do
44
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 120) do |http|
45
+ http.request(req)
46
+ end
47
+ end
48
+ json = res.body.to_s.empty? ? {} : parse(res.body)
49
+ return json if res.is_a?(Net::HTTPSuccess)
50
+
51
+ code = res.code.to_i
52
+ if again && RETRIED.include?(code) && attempts < 4
53
+ sleep attempts * 0.5
54
+ next
55
+ end
56
+ detail = json.is_a?(Hash) ? json[:message] : json.to_s[0, 200]
57
+ raise Sandbox::Adapter::Provider::Error.new(
58
+ "daytona #{req.method} #{path} #{code}: #{detail}",
59
+ status: code
60
+ )
61
+ end
62
+ end
63
+
64
+ # Symbol-keyed; a body that is not JSON is kept as text for the error.
65
+ def parse(body)
66
+ JSON.parse(body, symbolize_names: true)
67
+ rescue JSON::ParserError
68
+ body
69
+ end
70
+ end