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