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,58 @@
1
+ require "net/http"
2
+
3
+ # Thin JSON client for https://api.hetzner.cloud/v1. Raises Sandbox::Adapter::Provider::Error on non-2xx. Every body
4
+ # comes back symbol-keyed.
5
+ class Sandbox::Provider::Hetzner::Client
6
+ BASE = "https://api.hetzner.cloud/v1"
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
+ # Lists filtered by name return an array; nil when nothing matches. `collection` is the symbol the list
17
+ # answers under (:servers, :volumes…).
18
+ def find(collection, name) = get("/#{collection}", name:)[collection].first
19
+
20
+ def wait(action)
21
+ return if action.nil?
22
+ 60.times do
23
+ current = get("/actions/#{action[:id]}")[:action]
24
+ return current if current[:status] == "success"
25
+ if current[:status] == "error"
26
+ raise Sandbox::Adapter::Provider::Error,
27
+ "hetzner action #{current[:id]}: #{current.dig(:error, :message)}"
28
+ end
29
+ sleep 3
30
+ end
31
+ raise Sandbox::Adapter::Provider::Error, "hetzner action #{action[:id]} timed out"
32
+ end
33
+
34
+ private
35
+
36
+ def request(klass, path, params: {}, body: nil)
37
+ uri = URI("#{BASE}#{path}")
38
+ uri.query = URI.encode_www_form(params) if params.any?
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("hetzner") do
44
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 30) do |http|
45
+ http.request(req)
46
+ end
47
+ end
48
+ json = res.body.to_s.empty? ? {} : JSON.parse(res.body, symbolize_names: true)
49
+ unless res.is_a?(Net::HTTPSuccess)
50
+ raise Sandbox::Adapter::Provider::Error,
51
+ "hetzner #{req.method} #{path} #{res.code}: #{json.dig(:error, :message)}"
52
+ end
53
+ json
54
+ rescue JSON::ParserError
55
+ raise Sandbox::Adapter::Provider::Error,
56
+ "hetzner #{req.method} #{path} #{res.code}: #{res.body.to_s[0, 200]}"
57
+ end
58
+ end
@@ -0,0 +1,238 @@
1
+ # Hetzner Cloud: the server in the workspace's network behind a firewall that opens SSH to everyone and the
2
+ # preview port to Cloudflare alone, where the preview worker's requests come from. ensure_* calls are
3
+ # find-or-create by name and safe to repeat.
4
+ class Sandbox::Provider::Hetzner < Sandbox::Adapter::Vm
5
+ IMAGE = "ubuntu-24.04"
6
+ IP_RANGE = "10.0.0.0/16"
7
+ ANYONE = [ "0.0.0.0/0", "::/0" ].freeze
8
+ SSH_RULE = { direction: "in", protocol: "tcp", port: "22", source_ips: ANYONE }.freeze
9
+ FIREWALL_RULES = [ SSH_RULE ].freeze
10
+
11
+ # The ranges Cloudflare publishes; a Worker's fetch leaves from them. Fetched once per process, with the
12
+ # list as of 2026 behind it so a network blip does not close every preview.
13
+ CLOUDFLARE_LISTS = %w[https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6].freeze
14
+ CLOUDFLARE_IPS = %w[
15
+ 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18
16
+ 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17
17
+ 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
18
+ 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29
19
+ 2c0f:f248::/32
20
+ ].freeze
21
+
22
+ # A list this box cannot reach leaves the built-in copy, reported so the staleness is visible. Only the
23
+ # reaching is caught: anything else here is a bug, not an unreachable Cloudflare.
24
+ def self.cloudflare_ips
25
+ @cloudflare_ips ||= CLOUDFLARE_LISTS.flat_map { Net::HTTP.get(URI(_1)).split }.presence ||
26
+ CLOUDFLARE_IPS
27
+ rescue SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, SocketError => e
28
+ Sandbox::Adapter.reporter.report(e, handled: true)
29
+ CLOUDFLARE_IPS
30
+ end
31
+
32
+ # SSH for everyone, the preview port for Cloudflare only.
33
+ def self.rules
34
+ preview = { direction: "in", protocol: "tcp", port: Sandbox::Adapter::Vm::PREVIEW_PORT.to_s,
35
+ source_ips: cloudflare_ips }
36
+ [ SSH_RULE, preview ]
37
+ end
38
+ # The one size offered until the production catalog reads /server_types: cx23 at hel1, from the table.
39
+ # held is the volume, EUR per GB-month turned into USD cents per hour.
40
+ SIZE = { key: "2x4", type: "cx23", location: "hel1", cpu: 2, memory: 4, disk: 40 }.freeze
41
+ VOLUME_EUR_GB_MONTH = 0.0572
42
+ HOURS_MONTH = 730
43
+ # The label a deploy puts on everything it makes here, and the one thing #list can ask for.
44
+ LABEL = "nvoi-worktree".freeze
45
+
46
+ # Hetzner bills in EUR; every rate kept for it is USD cents. The rate comes from the bank that issues
47
+ # the euro, which publishes one reference rate a day and asks for no key. It publishes on business days
48
+ # only, so the newest is up to three days old, which is closer than a number typed in once.
49
+ #
50
+ # The constant is what the last look gave and what answers when the bank cannot be reached, so a box is
51
+ # still priced; it was read on the day the hetzner rates in Sandbox::Adapter::Prices were.
52
+ EUR_USD = 1.17
53
+ ECB = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml".freeze
54
+
55
+ # Dollars to the euro, as the ECB published it. Held for the process: a rate that moves once a day is
56
+ # not worth a request per price.
57
+ def self.eur_usd
58
+ @eur_usd ||= Net::HTTP.get(URI(ECB))[/currency=['"]USD['"]\s+rate=['"]([\d.]+)['"]/, 1]&.to_f ||
59
+ EUR_USD
60
+ rescue SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, SocketError => e
61
+ Sandbox::Adapter.reporter.report(e, handled: true)
62
+ EUR_USD
63
+ end
64
+
65
+ def shapes
66
+ gb_hour = (VOLUME_EUR_GB_MONTH * 100 * self.class.eur_usd / HOURS_MONTH)
67
+ volume = (gb_hour * layout.volume_size).round(6)
68
+ key = "#{SIZE[:type]}:#{SIZE[:location]}"
69
+ { SIZE[:key] => Sandbox::Adapter.shape(cpu: SIZE[:cpu], memory: SIZE[:memory], disk: SIZE[:disk],
70
+ type: SIZE[:type], rate: price_table.cents("hetzner", key),
71
+ held: volume, source: :table,
72
+ as_of: price_table.as_of("hetzner")) }
73
+ end
74
+
75
+ # The server types priced: the AX node and the production sizes to come.
76
+ PRICED = %w[cx23 cx33 cx43].freeze
77
+
78
+ # Their price API, EUR net per hour per type and location, into USD cents.
79
+ def prices
80
+ types = @client.get("/pricing").dig(:pricing, :server_types)
81
+ rates = types.select { PRICED.include?(_1[:name]) }.flat_map do |type|
82
+ type[:prices].map do |price|
83
+ [ "#{type[:name]}:#{price[:location]}",
84
+ (price.dig(:price_hourly, :net).to_f * 100 * self.class.eur_usd).round(6) ]
85
+ end
86
+ end.to_h
87
+ { source: "api", rates: }
88
+ end
89
+
90
+ # By the status string the API answers.
91
+ STATES = {
92
+ "running" => :running,
93
+ "off" => :stopped,
94
+ "initializing" => :starting,
95
+ "starting" => :starting,
96
+ "stopping" => :starting,
97
+ "migrating" => :starting,
98
+ "rebuilding" => :starting,
99
+ "deleting" => :starting
100
+ }.freeze
101
+
102
+ def initialize(token, client: Client.new(token), **)
103
+ super(token, **)
104
+ @client = client
105
+ end
106
+
107
+ # Network and firewall for the workspace, a volume and a server for the worktree; the volume's device path
108
+ # rides along in the machine's data.
109
+ # The size names the server type; the location is the one size's.
110
+ def create(name, labels: {}, shape: default_shape, disk: default_disk(shape), workspace:,
111
+ user_data:)
112
+ server_type = shapes.fetch(shape)[:type]
113
+ location = SIZE[:location]
114
+ network = ensure_network(workspace)
115
+ firewall = ensure_firewall(workspace, rules: self.class.rules)
116
+ volume = ensure_volume(name, size: layout.volume_size, location:, labels:)
117
+ server = @client.find(:servers, name) || @client.post(
118
+ "/servers",
119
+ name:,
120
+ server_type:,
121
+ location:,
122
+ image: IMAGE,
123
+ user_data:,
124
+ labels:,
125
+ networks: [ network[:id] ],
126
+ firewalls: [ { firewall: firewall[:id] } ],
127
+ volumes: [ volume[:id] ]
128
+ ).then do |body|
129
+ @client.wait(body[:action])
130
+ body[:next_actions].each { |a| @client.wait(a) }
131
+ @client.get("/servers/#{body.dig(:server, :id)}")[:server]
132
+ end
133
+ machine(server, volume)
134
+ end
135
+
136
+ def find(name)
137
+ server = @client.find(:servers, name) or return nil
138
+ machine(server, @client.find(:volumes, name))
139
+ end
140
+
141
+ # Every server of ours at this project, by the label a deploy puts on one. Without it nothing could see
142
+ # a server whose row this app has lost, which is a server that bills until somebody notices by hand.
143
+ # The volume each one carries is fetched by the same name.
144
+ def list
145
+ volumes = @client.get("/volumes", label_selector: LABEL)[:volumes]
146
+ @client.get("/servers", label_selector: LABEL)[:servers].map do |server|
147
+ machine(server, volumes.find { _1[:name] == server[:name] })
148
+ end
149
+ end
150
+
151
+ def wake(m)
152
+ server = @client.find(:servers, m.name) or raise Error, "server #{m.name} is gone"
153
+ if server[:status] == "off"
154
+ @client.wait(@client.post("/servers/#{server[:id]}/actions/poweron")[:action])
155
+ server = @client.get("/servers/#{server[:id]}")[:server]
156
+ end
157
+ machine(server, @client.find(:volumes, m.name))
158
+ end
159
+
160
+ # The server is shut down cleanly, so the volume's filesystem is consistent, then goes; the volume stays for
161
+ # the next create.
162
+ def halt(m)
163
+ found = @client.find(:servers, m.name)
164
+ return false if found.nil?
165
+ if found[:status] == "running"
166
+ @client.wait(@client.post("/servers/#{found[:id]}/actions/shutdown")[:action])
167
+ 30.times do
168
+ break if @client.get("/servers/#{found[:id]}").dig(:server, :status) == "off"
169
+ sleep 2
170
+ end
171
+ end
172
+ @client.wait(@client.delete("/servers/#{found[:id]}")[:action])
173
+ false
174
+ end
175
+
176
+ def destroy(m)
177
+ halt(m)
178
+ found = @client.find(:volumes, m.name)
179
+ return if found.nil?
180
+ if found[:server]
181
+ @client.wait(@client.post("/volumes/#{found[:id]}/actions/detach")[:action])
182
+ end
183
+ @client.delete("/volumes/#{found[:id]}")
184
+ end
185
+
186
+ def destroy_network(workspace)
187
+ found = @client.find(:firewalls, workspace)
188
+ @client.delete("/firewalls/#{found[:id]}") if found
189
+ found = @client.find(:networks, workspace)
190
+ @client.delete("/networks/#{found[:id]}") if found
191
+ end
192
+
193
+ private
194
+
195
+ def ensure_network(name)
196
+ @client.find(:networks, name) || @client.post(
197
+ "/networks",
198
+ name:,
199
+ ip_range: IP_RANGE,
200
+ subnets: [ { type: "cloud", ip_range: IP_RANGE, network_zone: "eu-central" } ]
201
+ )[:network]
202
+ end
203
+
204
+ def ensure_firewall(name, rules: FIREWALL_RULES)
205
+ found = @client.find(:firewalls, name)
206
+ return @client.post("/firewalls", name:, rules:)[:firewall] if found.nil?
207
+
208
+ if found[:rules].map { |r| r[:port] }.sort != rules.map { |r| r[:port] }.sort
209
+ @client.post("/firewalls/#{found[:id]}/actions/set_rules", rules:)[:actions].each do |a|
210
+ @client.wait(a)
211
+ end
212
+ end
213
+ found
214
+ end
215
+
216
+ # Labelled like the server it belongs to: a volume outlives its server, and one nothing can list is
217
+ # one that bills on quietly.
218
+ def ensure_volume(name, size:, location:, labels:)
219
+ @client.find(:volumes, name) || begin
220
+ body = @client.post("/volumes", name:, size:, location:, format: "ext4", labels:)
221
+ @client.wait(body[:action])
222
+ body[:volume]
223
+ end
224
+ end
225
+
226
+ def machine(server, volume)
227
+ Sandbox::Adapter::Machine.new(
228
+ id: server[:id].to_s,
229
+ name: server[:name],
230
+ state: STATES.fetch(server[:status], :broken),
231
+ data: {
232
+ address: server.dig(:public_net, :ipv4, :ip),
233
+ hostname: server.dig(:public_net, :ipv4, :dns_ptr),
234
+ device: volume&.dig(:linux_device)
235
+ }
236
+ )
237
+ end
238
+ end
@@ -0,0 +1,5 @@
1
+ module Sandbox
2
+ module Provider
3
+ VERSION = "0.1.1"
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ # The vendors a box can be bought from: a server of its own (Hetzner) and the sandboxes that fork from a
2
+ # seed (Daytona, E2B, boat.dev). Each answers the Sandbox::Adapter::Provider contract and nothing else.
3
+ #
4
+ # A provider is required before the classes nested in it, which name it as they are defined.
5
+ require "sandbox-adapter"
6
+
7
+ module Sandbox::Provider; end
8
+ require_relative "sandbox/provider/version"
9
+
10
+ require_relative "sandbox/provider/cloud_init"
11
+ require_relative "sandbox/provider/hetzner"
12
+ require_relative "sandbox/provider/hetzner/client"
13
+ require_relative "sandbox/provider/daytona"
14
+ require_relative "sandbox/provider/daytona/client"
15
+ require_relative "sandbox/provider/e2b"
16
+ require_relative "sandbox/provider/e2b/client"
17
+ require_relative "sandbox/provider/e2b/envd"
18
+ require_relative "sandbox/provider/boat_dev"
19
+ require_relative "sandbox/provider/boat_dev/client"
20
+ require_relative "sandbox/provider/boat_dev/key"
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sandbox-provider
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - nvoi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sandbox-adapter
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: ed25519
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ email:
41
+ - admin@nvoi.to
42
+ executables: []
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - LICENSE
47
+ - README.md
48
+ - lib/sandbox-provider.rb
49
+ - lib/sandbox/provider/boat_dev.rb
50
+ - lib/sandbox/provider/boat_dev/client.rb
51
+ - lib/sandbox/provider/boat_dev/key.rb
52
+ - lib/sandbox/provider/cloud_init.rb
53
+ - lib/sandbox/provider/daytona.rb
54
+ - lib/sandbox/provider/daytona/client.rb
55
+ - lib/sandbox/provider/e2b.rb
56
+ - lib/sandbox/provider/e2b/client.rb
57
+ - lib/sandbox/provider/e2b/envd.rb
58
+ - lib/sandbox/provider/hetzner.rb
59
+ - lib/sandbox/provider/hetzner/client.rb
60
+ - lib/sandbox/provider/version.rb
61
+ homepage: https://rubygems.org/gems/sandbox-provider
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ rubygems_mfa_required: 'true'
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '3.4'
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 3.6.7
81
+ specification_version: 4
82
+ summary: 'Sandbox providers for sandbox-adapter: Daytona, E2B, boat.dev and Hetzner.'
83
+ test_files: []