sandbox-provider 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +21 -0
- data/lib/sandbox/provider/boat_dev/client.rb +75 -0
- data/lib/sandbox/provider/boat_dev/key.rb +30 -0
- data/lib/sandbox/provider/boat_dev.rb +380 -0
- data/lib/sandbox/provider/cloud_init.rb +19 -0
- data/lib/sandbox/provider/daytona/client.rb +70 -0
- data/lib/sandbox/provider/daytona.rb +272 -0
- data/lib/sandbox/provider/e2b/client.rb +52 -0
- data/lib/sandbox/provider/e2b/envd.rb +121 -0
- data/lib/sandbox/provider/e2b.rb +222 -0
- data/lib/sandbox/provider/hetzner/client.rb +58 -0
- data/lib/sandbox/provider/hetzner.rb +238 -0
- data/lib/sandbox/provider/version.rb +5 -0
- data/lib/sandbox-provider.rb +20 -0
- metadata +83 -0
|
@@ -0,0 +1,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,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: []
|