sandbox-adapter 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 +20 -0
- data/lib/sandbox/adapter/box.rb +195 -0
- data/lib/sandbox/adapter/checkout.rb +54 -0
- data/lib/sandbox/adapter/credentials.rb +44 -0
- data/lib/sandbox/adapter/layout.rb +51 -0
- data/lib/sandbox/adapter/pages.rb +11 -0
- data/lib/sandbox/adapter/prices.rb +21 -0
- data/lib/sandbox/adapter/provider.rb +135 -0
- data/lib/sandbox/adapter/sandbox_vm.rb +16 -0
- data/lib/sandbox/adapter/ssh.rb +112 -0
- data/lib/sandbox/adapter/steps.rb +447 -0
- data/lib/sandbox/adapter/version.rb +5 -0
- data/lib/sandbox/adapter/vm.rb +42 -0
- data/lib/sandbox/adapter.rb +45 -0
- data/lib/sandbox-adapter.rb +28 -0
- metadata +147 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e5d358100e225bf920045edf88deedbeaacb2d1f90c57d83b9b6865d70df356a
|
|
4
|
+
data.tar.gz: 7b0744d3df1da971ae6d149845544735fb55cdaeea933ac9ff41811374d5f0c6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a7839666cd9fb2a1b33c8d2ab83b9df921f196a830fa89523f9aa3338588d390ce20c9f2d8a1e08257a3313fabe24f0e95ea86a977f54f34556896e1d4b31bc1
|
|
7
|
+
data.tar.gz: 3ecb9325f46085582afe337c36bea87a375a71f5e9056b2ff8c99976bfc355fa86bade972f8eab6db0d003e3da63eeffe707dc3b625c51d72389b8fcec2448a1
|
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,20 @@
|
|
|
1
|
+
# sandbox-adapter
|
|
2
|
+
|
|
3
|
+
Contract for sandbox providers, and the pieces that run on any of them.
|
|
4
|
+
|
|
5
|
+
- `Sandbox::Adapter::Provider`: `prepare`, `create`, `find`, `wake`, `halt`, `destroy`, `transport`, `preview`, `shapes`, `rate`, `usage`, `list`. `SandboxVm` adds `seal` and `fork`.
|
|
6
|
+
- `Sandbox::Adapter::Box`: runs commands over a provider's transport. `exec` detaches the command and follows its log, resuming from the last byte after a dropped connection.
|
|
7
|
+
- `Sandbox::Adapter::Checkout`: clones on the host and ships a git bundle, so the box never holds the token.
|
|
8
|
+
- `Sandbox::Adapter::Steps`: users, docker, agent, checkout, setup, preview.
|
|
9
|
+
|
|
10
|
+
## Configuration
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
Sandbox::Adapter.providers = { "daytona" => "Sandbox::Provider::Daytona" }
|
|
14
|
+
Sandbox::Adapter.credentials = MyCredentials.new # api_key(name), api_key!(name), setting(name), key(name, length:)
|
|
15
|
+
Sandbox::Adapter.reporter = Rails.error
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Credentials default to `ENV["<NAME>_API_KEY"]`, then Rails credentials.
|
|
19
|
+
|
|
20
|
+
Providers live in `sandbox-provider`.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
|
|
3
|
+
# Runs commands on a box through the transport its provider handed out (see Sandbox::Adapter::Provider#transport). `run`
|
|
4
|
+
# is one round trip per command, for probes. `exec` detaches the command on the box and follows its log, so a
|
|
5
|
+
# dropped link costs nothing: reconnect and resume from the last byte read. `as` runs every command under
|
|
6
|
+
# another account than the transport's login.
|
|
7
|
+
class Sandbox::Adapter::Box
|
|
8
|
+
Failed = Class.new(StandardError)
|
|
9
|
+
|
|
10
|
+
attr_reader :transport
|
|
11
|
+
|
|
12
|
+
def initialize(transport, scratch:, as: nil, path: nil)
|
|
13
|
+
@transport = transport
|
|
14
|
+
@address = transport.address
|
|
15
|
+
@dir = scratch
|
|
16
|
+
@as = as
|
|
17
|
+
@path = path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Retries only while the host is unreachable; any other error (bad key, refused user, host key) raises at once.
|
|
21
|
+
def wait(timeout: 300)
|
|
22
|
+
deadline = Time.current + timeout
|
|
23
|
+
begin
|
|
24
|
+
run("true")
|
|
25
|
+
rescue *@transport.connection_errors => e
|
|
26
|
+
raise Failed, "ssh to #{@address}: #{e.message}" if Time.current > deadline
|
|
27
|
+
sleep 5
|
|
28
|
+
retry
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the exit status. env (symbol-keyed) is exported, dir is the working directory.
|
|
33
|
+
def run(command, env: {}, dir: nil, &)
|
|
34
|
+
@transport.exec(shell(script(command, env:, dir:)), &)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run!(command, **, &)
|
|
38
|
+
status = run(command, **, &)
|
|
39
|
+
raise Failed, "exit #{status}: #{self.class.headline(command)}" unless status == 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The first line of a script that says what it does. A shell directive is not one: every script here
|
|
43
|
+
# opens with `set -e`, and a failure named after it says nothing about what failed.
|
|
44
|
+
DIRECTIVE = /\A(set|shopt|export|umask)\b/
|
|
45
|
+
def self.headline(script)
|
|
46
|
+
script.to_s.lines.map(&:strip).reject { _1.empty? || _1.match?(DIRECTIVE) }.first.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Starts `command` in its own process group under `id`, survives this connection, and streams its output from
|
|
50
|
+
# `offset` bytes on. The log is followed in bounded rounds, so a link that proxies cut when idle is never the
|
|
51
|
+
# failure signal; a dropped link is retried, and the deadline bounds only the time spent unreachable.
|
|
52
|
+
def exec(id, command, env: {}, dir: nil, offset: 0, unreachable_for: 120, &block)
|
|
53
|
+
raise ArgumentError, "bad id #{id.inspect}" unless id.to_s.match?(/\A[\w-]+\z/)
|
|
54
|
+
return attached(id, script(command, env:, dir:), &block) unless @transport.reattaches?
|
|
55
|
+
|
|
56
|
+
started = false
|
|
57
|
+
last_failure = nil
|
|
58
|
+
loop do
|
|
59
|
+
begin
|
|
60
|
+
start(id, script(command, env:, dir:)) unless started
|
|
61
|
+
started = true
|
|
62
|
+
status = follow(id, offset) { |line| offset += line.bytesize + 1; block&.call(line) }
|
|
63
|
+
return status if status
|
|
64
|
+
last_failure = nil
|
|
65
|
+
rescue *@transport.connection_errors => e
|
|
66
|
+
last_failure ||= Time.current
|
|
67
|
+
if Time.current - last_failure > unreachable_for
|
|
68
|
+
raise Failed, "#{@address} unreachable for #{unreachable_for}s: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
sleep 3
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def exec!(id, command, **, &)
|
|
76
|
+
status = exec(id, command, **, &)
|
|
77
|
+
raise Failed, "exit #{status}: #{self.class.headline(command)}" unless status == 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The bytes of a file on the box, over SFTP on a fresh connection.
|
|
81
|
+
def download(path) = @transport.download(path)
|
|
82
|
+
|
|
83
|
+
# Writes bytes to a path on the box, creating the directory first.
|
|
84
|
+
# Files land as the transport's login, so under another account a previous copy is cleared first and the new
|
|
85
|
+
# file handed over afterwards.
|
|
86
|
+
def upload(path, io)
|
|
87
|
+
dir, file = Shellwords.escape(File.dirname(path)), Shellwords.escape(path)
|
|
88
|
+
run!(@as ? "mkdir -p #{dir} && sudo rm -f #{file}" : "mkdir -p #{dir}")
|
|
89
|
+
@transport.upload(path, io)
|
|
90
|
+
run!("sudo chown #{Shellwords.escape(@as)} #{file}") if @as
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Sends TERM to the whole process group of `id`. A command that has already recorded its status has no
|
|
94
|
+
# group left to signal, which is what cancelling a finished command means; a command with no group recorded
|
|
95
|
+
# at all is a box that could not name one, and says so rather than reporting a cancel that did nothing.
|
|
96
|
+
def cancel(id)
|
|
97
|
+
d = "#{@dir}/#{id}"
|
|
98
|
+
run!(<<~SH)
|
|
99
|
+
[ -f #{d}/exit ] && exit 0
|
|
100
|
+
[ -s #{d}/pid ] || { echo "no process group recorded for #{id}" >&2; exit 1; }
|
|
101
|
+
kill -TERM -- -"$(cat #{d}/pid)" 2>/dev/null || [ -f #{d}/exit ]
|
|
102
|
+
SH
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# The script runs under `as` when there is one; the login stays what it is. A login shell, for the PATH
|
|
108
|
+
# a box's profile sets, unless the transport brings its own environment (this machine).
|
|
109
|
+
def shell(script)
|
|
110
|
+
flags = @transport.respond_to?(:profile?) && !@transport.profile? ? "-c" : "-lc"
|
|
111
|
+
inner = "bash #{flags} #{Shellwords.escape(script)}"
|
|
112
|
+
@as ? "runuser -u #{Shellwords.escape(@as)} -- #{inner}" : inner
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# path goes first, before the environment's own exports: a login shell's PATH is the box image's, and a
|
|
116
|
+
# profile script cannot outrank what the image put in front of it. It is the layout's own string and goes
|
|
117
|
+
# in unquoted, so bash expands the ~account it starts with.
|
|
118
|
+
# Scratch sits on /tmp, which a machine's restart empties: every script makes it again before anything
|
|
119
|
+
# uses it, shared (1777) since the login and the account under `as` both write there.
|
|
120
|
+
def script(command, env:, dir:)
|
|
121
|
+
exports = env.map { |k, v| "export #{k}=#{Shellwords.escape(v.to_s)}" }
|
|
122
|
+
exports.unshift(%(export PATH=#{@path}:"$PATH")) if @path
|
|
123
|
+
scratch = "mkdir -p -m 1777 #{Shellwords.escape(@dir)} 2>/dev/null"
|
|
124
|
+
[ *exports, scratch, ("cd #{Shellwords.escape(dir)}" if dir), command ].compact.join("\n")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# One streamed request for the whole command, for a transport that cannot follow a detached one (E2B's envd,
|
|
128
|
+
# this machine's shell). The pid file holds the group the transport already put this request in, so cancel
|
|
129
|
+
# reaches the command the same way it does a detached one; proc answers on Linux and ps on a box that has
|
|
130
|
+
# it, since a slim image ships neither procps nor a reason to. Nothing calls setsid here: it forks when the
|
|
131
|
+
# caller already leads a group, and then exits 0 whatever the command did.
|
|
132
|
+
def attached(id, script, &block)
|
|
133
|
+
d = "#{@dir}/#{id}"
|
|
134
|
+
run(<<~SH, env: { NVOI_SCRIPT: script }, &block)
|
|
135
|
+
mkdir -p -m 700 #{d} && rm -f #{d}/exit
|
|
136
|
+
printf '%s\\n' "$NVOI_SCRIPT" > #{d}/script && chmod 600 #{d}/script
|
|
137
|
+
{ awk '{print $5}' /proc/self/stat 2>/dev/null || ps -o pgid= -p $$ 2>/dev/null || echo $$; } \
|
|
138
|
+
| tr -d ' ' | head -1 > #{d}/pid
|
|
139
|
+
bash #{d}/script 2>&1; s=$?; echo $s > #{d}/exit; exit $s
|
|
140
|
+
SH
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The script and its log live under scratch/id. setsid makes the wrapper the group leader (its pid is the pgid);
|
|
144
|
+
# nohup keeps it alive when this session ends; the wrapper records the exit status last. Started already
|
|
145
|
+
# (a link dropped after the launch) is left alone.
|
|
146
|
+
def start(id, script)
|
|
147
|
+
d = "#{@dir}/#{id}"
|
|
148
|
+
run!(<<~SH, env: { NVOI_SCRIPT: script })
|
|
149
|
+
if [ -f #{d}/pid ] && [ ! -f #{d}/exit ] && kill -0 -- -$(cat #{d}/pid) 2>/dev/null; then exit 0; fi
|
|
150
|
+
mkdir -p -m 700 #{d} && rm -f #{d}/exit
|
|
151
|
+
printf '%s\\n' "$NVOI_SCRIPT" > #{d}/script && chmod 600 #{d}/script
|
|
152
|
+
setsid nohup bash -c 'echo $$ > #{d}/pid; bash #{d}/script > #{d}/log 2>&1; echo $? > #{d}/exit' >/dev/null 2>&1 &
|
|
153
|
+
for i in $(seq 1 50); do [ -f #{d}/pid ] && exit 0; sleep 0.1; done; exit 1
|
|
154
|
+
SH
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
ROUND = 45
|
|
158
|
+
# Whatever precedes it on the line, then the marker: `more` while the command runs, or an exit status.
|
|
159
|
+
MARKER = /\A(.*?)__nvoi_(more|exit) ?(\d+)?\z/
|
|
160
|
+
|
|
161
|
+
# Streams log bytes from offset for up to ROUND seconds or until the wrapper is gone. Returns the exit status
|
|
162
|
+
# once the wrapper has recorded it, nil when the round ended with the command still running.
|
|
163
|
+
def follow(id, offset, &)
|
|
164
|
+
d = "#{@dir}/#{id}"
|
|
165
|
+
status = nil
|
|
166
|
+
more = false
|
|
167
|
+
cmd = "timeout #{ROUND} tail -c +#{offset + 1} -f --pid=$(cat #{d}/pid) #{d}/log; " \
|
|
168
|
+
"if [ -f #{d}/exit ]; then " \
|
|
169
|
+
"[ -s #{d}/log ] && [ \"$(tail -c1 #{d}/log | wc -l)\" -eq 0 ] && echo; " \
|
|
170
|
+
"echo \"__nvoi_exit $(cat #{d}/exit)\"; " \
|
|
171
|
+
"elif kill -0 -- -$(cat #{d}/pid) 2>/dev/null; then echo __nvoi_more; " \
|
|
172
|
+
"else echo __nvoi_exit 255; fi"
|
|
173
|
+
# A round can end while the command is mid-line - apt prints "Generating locales..." and waits - and
|
|
174
|
+
# the marker is then written onto the end of that line. What comes before it is output; the marker is
|
|
175
|
+
# still the marker. Reading it only at the start of a line loses the round's status altogether.
|
|
176
|
+
run(cmd) do |line|
|
|
177
|
+
out, marker, code = line.match(MARKER)&.captures
|
|
178
|
+
yield out if out.present? && status.nil? && !more
|
|
179
|
+
if marker == "more" then more = true
|
|
180
|
+
elsif marker then status = code.to_i
|
|
181
|
+
elsif status.nil? && !more then yield line
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
raise Failed, "exec #{id}: no exit status" if status.nil? && !more
|
|
185
|
+
status
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Bytes arrive in arbitrary chunks; lines leave as valid UTF-8.
|
|
189
|
+
def self.each_line(buffer, data)
|
|
190
|
+
buffer << data.b
|
|
191
|
+
while (i = buffer.index("\n"))
|
|
192
|
+
yield buffer.slice!(0..i).chomp.force_encoding("UTF-8").scrub
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "tmpdir"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
# The repository as a git bundle made here, with the GitHub token, and unpacked on the box without it: the
|
|
5
|
+
# base branch and, when origin has it, the worktree's own branch.
|
|
6
|
+
class Sandbox::Adapter::Checkout
|
|
7
|
+
Bundle = Data.define(:path, :refs)
|
|
8
|
+
|
|
9
|
+
def initialize(repo, token:, url: repo.github_url)
|
|
10
|
+
@repo = repo
|
|
11
|
+
@token = token
|
|
12
|
+
@url = url
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Yields a Bundle holding `base` and any of `branches` origin knows. The file lives for the block.
|
|
16
|
+
def bundle(base, *branches)
|
|
17
|
+
Dir.mktmpdir do |dir|
|
|
18
|
+
clone = File.join(dir, "clone")
|
|
19
|
+
git(
|
|
20
|
+
dir, "clone", "-q", "--bare", "--no-tags", "--single-branch", "--branch", base, @url, clone
|
|
21
|
+
)
|
|
22
|
+
refs = [ base ]
|
|
23
|
+
branches.uniq.each do |branch|
|
|
24
|
+
next if branch == base
|
|
25
|
+
_, ok = Open3.capture2e(
|
|
26
|
+
auth_env, "git", "-C", clone, "fetch", "-q", "origin", "+#{branch}:refs/heads/#{branch}"
|
|
27
|
+
)
|
|
28
|
+
refs << branch if ok.success?
|
|
29
|
+
end
|
|
30
|
+
out = File.join(dir, "checkout.bundle")
|
|
31
|
+
git(clone, "bundle", "create", "-q", out, *refs)
|
|
32
|
+
yield Bundle.new(path: out, refs:)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def auth_env
|
|
39
|
+
basic = Base64.strict_encode64("x-access-token:#{@token}")
|
|
40
|
+
{
|
|
41
|
+
"GIT_CONFIG_COUNT" => "1",
|
|
42
|
+
"GIT_CONFIG_KEY_0" => "http.extraheader",
|
|
43
|
+
"GIT_CONFIG_VALUE_0" => "Authorization: Basic #{basic}"
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def git(dir, *args)
|
|
48
|
+
out, status = Open3.capture2e(auth_env, "git", "-C", dir, *args)
|
|
49
|
+
unless status.success?
|
|
50
|
+
raise Sandbox::Adapter::Box::Failed, "git #{args.first}: #{out.gsub(@token.to_s, "***").strip}"
|
|
51
|
+
end
|
|
52
|
+
out
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Where a provider's secrets come from. A provider asks by name and never reads a store: an environment
|
|
2
|
+
# variable first, so a box or a CI run can set one, then this installation's credentials.
|
|
3
|
+
#
|
|
4
|
+
# `api_key` is the key a provider is built with; `setting` is anything else a vendor needs to be addressed,
|
|
5
|
+
# such as an organization or a team. A name is always a provider's own, prefixed with its name, because
|
|
6
|
+
# both sides of the lookup are shared: `PATH` is an environment variable like any other, and the
|
|
7
|
+
# credentials object answers to more than the keys in the file.
|
|
8
|
+
class Sandbox::Adapter::Credentials
|
|
9
|
+
# A credential that is not there. Not a Sandbox::Adapter::Provider::Error: that one is a provider refusing us,
|
|
10
|
+
# which jobs report and carry on from, and a missing credential is this installation being wrong.
|
|
11
|
+
Missing = Class.new(StandardError)
|
|
12
|
+
|
|
13
|
+
def api_key(name) = read("#{name}_api_key")
|
|
14
|
+
|
|
15
|
+
def api_key!(name)
|
|
16
|
+
api_key(name) or
|
|
17
|
+
raise Missing, "no credential for #{name}: set #{name.to_s.upcase}_API_KEY"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A vendor's own name for something, asked for as <provider>_<what>: daytona_org_id, boat_dev_team_id.
|
|
21
|
+
def setting(name) = read(name)
|
|
22
|
+
|
|
23
|
+
# A key derived from this installation's own secret: every process derives the same one, and nothing is
|
|
24
|
+
# stored. The derivation is fixed - changing it invalidates every key a vendor has already authorised.
|
|
25
|
+
def key(label, length: 32)
|
|
26
|
+
raise Missing, "no secret to derive #{label} from" unless application
|
|
27
|
+
application.key_generator.generate_key(label, length)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def read(name) = env(name) || stored(name)
|
|
33
|
+
|
|
34
|
+
def env(name) = ENV[name.to_s.upcase].presence
|
|
35
|
+
|
|
36
|
+
# Read out of the credentials as a hash, never by sending the name: `key` and `content_path` are
|
|
37
|
+
# methods on that object, and sending them would answer with this installation's decryption key.
|
|
38
|
+
def stored(name)
|
|
39
|
+
return nil unless application
|
|
40
|
+
application.credentials.config[name.to_sym].presence
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def application = (Rails.application if defined?(Rails) && Rails.respond_to?(:application))
|
|
44
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# What a box looks like once a provider brings it up: who logs in, where the volume mounts, what is installed.
|
|
2
|
+
# login is who the transport lands as; user is who owns the checkout and runs the agent (created on the box
|
|
3
|
+
# when the login is root). browser_user renders pages the agent points at; it owns captures and nothing else.
|
|
4
|
+
# volume says whether mount is a block device to put in fstab or a plain directory on the box's own disk.
|
|
5
|
+
Sandbox::Adapter::Layout = Data.define(
|
|
6
|
+
:login,
|
|
7
|
+
:user,
|
|
8
|
+
:browser_user,
|
|
9
|
+
:mount,
|
|
10
|
+
:volume,
|
|
11
|
+
:volume_size,
|
|
12
|
+
:codename,
|
|
13
|
+
:node_major,
|
|
14
|
+
:playwright_version
|
|
15
|
+
) do
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
user: "deploy",
|
|
18
|
+
browser_user: "browser",
|
|
19
|
+
mount: "/mnt/data",
|
|
20
|
+
volume_size: 10,
|
|
21
|
+
codename: "noble",
|
|
22
|
+
node_major: 22,
|
|
23
|
+
playwright_version: "1.63.0"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def self.for(login:, volume:) = new(login:, volume:, **DEFAULTS)
|
|
27
|
+
|
|
28
|
+
def root? = login == "root"
|
|
29
|
+
def sudo = "sudo "
|
|
30
|
+
# Everything under mount is on the volume, so a paused box keeps it.
|
|
31
|
+
def workspace = "#{mount}/workspace"
|
|
32
|
+
def claude = "#{mount}/claude"
|
|
33
|
+
def attachments = "#{mount}/attachments"
|
|
34
|
+
def playwright = "#{mount}/playwright"
|
|
35
|
+
def captures = "#{mount}/captures"
|
|
36
|
+
# What the box records about itself, such as the setup marker.
|
|
37
|
+
def state = "#{mount}/state"
|
|
38
|
+
# Created on the volume and owned by user; captures by browser_user.
|
|
39
|
+
def owned = [ workspace, claude, attachments, playwright, state ]
|
|
40
|
+
|
|
41
|
+
def scratch = "/tmp/nvoi"
|
|
42
|
+
def env_file = "/etc/nvoi/env"
|
|
43
|
+
def mcp_bin = "/usr/local/bin/nvoi-mcp"
|
|
44
|
+
# Where mise itself lands.
|
|
45
|
+
def mise = "/usr/local/bin/mise"
|
|
46
|
+
# The mise shims of the account that runs commands, named first in every command's PATH: a box image may
|
|
47
|
+
# ship a runtime of its own under /usr/local/bin, and the version the environment asked mise for is the one
|
|
48
|
+
# to answer. Written as ~account so the box's passwd says where that home is.
|
|
49
|
+
def shims = "~#{user}/.local/share/mise/shims"
|
|
50
|
+
def node_modules = "/usr/lib/node_modules"
|
|
51
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
# A provider's pricing page, for the providers that publish no price API: fetched, stripped of tags and
|
|
4
|
+
# folded to one line, so a provider's `prices` can read its numbers with one expression each.
|
|
5
|
+
module Sandbox::Adapter::Pages
|
|
6
|
+
# Markdown is asked for: a docs site answers it, a plain page ignores it.
|
|
7
|
+
def self.fetch(url) = plain(Net::HTTP.get(URI(url), { "Accept" => "text/markdown" }))
|
|
8
|
+
def self.plain(text) = text.to_s.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ")
|
|
9
|
+
# Dollars as printed into cents per hour; `per` is the seconds the printed rate covers.
|
|
10
|
+
def self.cents(dollars, per: 1) = (dollars.to_f * 100 * per).round(6)
|
|
11
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# What a provider's keys cost, cents per hour, as published by the vendor and read on AS_OF. This is the
|
|
2
|
+
# table a provider is given when the installation names no other: one that keeps its own, refreshed per
|
|
3
|
+
# key, hands that in instead (see Sandbox::Adapter.price_table).
|
|
4
|
+
#
|
|
5
|
+
# Hetzner's keys are "<server_type>:<location>" and are held in USD cents; the provider converts at
|
|
6
|
+
# Sandbox::Provider::Hetzner::EUR_USD, which was read on the same day as the rates below. Refreshing one
|
|
7
|
+
# without the other is what makes a price drift.
|
|
8
|
+
class Sandbox::Adapter::Prices
|
|
9
|
+
AS_OF = "2026-09-22".freeze
|
|
10
|
+
|
|
11
|
+
RATES = {
|
|
12
|
+
"boat_dev" => { "small" => 1.8, "default" => 3.6, "large" => 7.2, "xlarge" => 20.0 },
|
|
13
|
+
"daytona" => { "vcpu_hour" => 5.04, "gib_hour" => 1.62, "disk_gib_hour" => 0.0108 },
|
|
14
|
+
"e2b" => { "vcpu_hour" => 5.04, "gib_hour" => 1.62 },
|
|
15
|
+
"hetzner" => { "cx23:hel1" => 1.03, "cx23:fsn1" => 1.03, "cx23:nbg1" => 1.03 }
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# A key nobody published is a mistake in the caller, not a zero.
|
|
19
|
+
def cents(provider, key) = RATES.fetch(provider).fetch(key)
|
|
20
|
+
def as_of(_provider) = AS_OF
|
|
21
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# What every provider shares, whichever interface it implements (Sandbox::Adapter::Vm, Sandbox::Adapter::SandboxVm): a machine
|
|
2
|
+
# by name, reached through a transport of the provider's own. A provider never runs a process itself: Box does,
|
|
3
|
+
# over that transport.
|
|
4
|
+
class Sandbox::Adapter::Provider
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :status
|
|
7
|
+
def initialize(message, status: nil) = (super(message); @status = status)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# What a vendor's API raises when it cannot be reached at all, as opposed to answering with an error.
|
|
11
|
+
UNREACHABLE = [ SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, SocketError, IOError,
|
|
12
|
+
Net::ProtocolError, Net::HTTPBadResponse ].freeze
|
|
13
|
+
|
|
14
|
+
# A vendor that cannot be reached is a provider error like any refusal, so every caller that handles
|
|
15
|
+
# one handles an outage too.
|
|
16
|
+
def self.reaching(vendor)
|
|
17
|
+
yield
|
|
18
|
+
rescue *UNREACHABLE => e
|
|
19
|
+
raise Error, "#{vendor} unreachable: #{e.class}: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Which interface each provider implements: vm (Sandbox::Adapter::Vm) or sandbox_vm (Sandbox::Adapter::SandboxVm).
|
|
23
|
+
TYPES = %w[vm sandbox_vm].freeze
|
|
24
|
+
|
|
25
|
+
# The providers this installation has, in the order it names them (see Sandbox::Adapter.providers). Declaring
|
|
26
|
+
# none is a caller asking before the installation has said, which is worth hearing about: every list
|
|
27
|
+
# that reads this would otherwise be quietly empty, and every name not included in it.
|
|
28
|
+
def self.names
|
|
29
|
+
declared = Sandbox::Adapter.providers
|
|
30
|
+
raise Error, "no providers declared: set Sandbox::Adapter.providers" if declared.empty?
|
|
31
|
+
declared.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A provider by name. Its key is <NAME>_API_KEY in the environment or <name>_api_key in credentials; a
|
|
35
|
+
# provider that is not keyed gets none.
|
|
36
|
+
def self.build(name, **)
|
|
37
|
+
klass = implementation(name)
|
|
38
|
+
klass.new(klass.keyed? ? api_key(name) : nil, **)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The family's name for a provider's.
|
|
42
|
+
def self.type(name) = implementation(name).type
|
|
43
|
+
# Whether the provider is reached with an API key.
|
|
44
|
+
def self.keyed? = true
|
|
45
|
+
|
|
46
|
+
# The class behind a name. Named as a string and resolved when asked: an installation may name a
|
|
47
|
+
# provider whose library it does not have, and only building one says so. Resolved without inheritance,
|
|
48
|
+
# so a name that is not there raises instead of finding a top-level class that happens to share it.
|
|
49
|
+
def self.implementation(name)
|
|
50
|
+
const = Sandbox::Adapter.providers[name.to_s] or raise Error, "unknown provider #{name.inspect}"
|
|
51
|
+
Object.const_get(const, false)
|
|
52
|
+
rescue NameError => e
|
|
53
|
+
raise Error, "provider #{name} is declared as #{const}, which is not here: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether the class behind a name is here at all. A declared provider whose library is not installed
|
|
57
|
+
# is not available, and is said out loud once rather than raising out of every list that reads it.
|
|
58
|
+
def self.installed?(name)
|
|
59
|
+
implementation(name)
|
|
60
|
+
true
|
|
61
|
+
rescue Error => e
|
|
62
|
+
Sandbox::Adapter.reporter.report(e, handled: true, context: { provider: name })
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.api_key(name) = Sandbox::Adapter.credentials.api_key!(name)
|
|
67
|
+
|
|
68
|
+
# Whether this installation holds that provider's credential. Asked, not caught: the bang above raises
|
|
69
|
+
# when a caller wants the key and there is none.
|
|
70
|
+
def self.api_key?(name) = Sandbox::Adapter.credentials.api_key(name).present?
|
|
71
|
+
|
|
72
|
+
# price_table answers `cents(provider_name, key)` and `as_of(provider_name)`: what a key costs, stored
|
|
73
|
+
# by whoever keeps prices. A provider does the arithmetic, never the lookup. Not to be confused with
|
|
74
|
+
# #prices below, which is the vendor's own published prices, read over the network.
|
|
75
|
+
def initialize(token, price_table: Sandbox::Adapter.price_table)
|
|
76
|
+
@token = token
|
|
77
|
+
@price_table = price_table
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
attr_reader :price_table
|
|
81
|
+
|
|
82
|
+
# How this provider's boxes are laid out; the same for every box unless the provider says otherwise.
|
|
83
|
+
def layout = raise NotImplementedError
|
|
84
|
+
def layout_for(_name) = layout
|
|
85
|
+
|
|
86
|
+
# Whatever the provider needs before a first create of that size; idempotent.
|
|
87
|
+
def prepare(shape = default_shape, disk = default_disk(shape)) = nil
|
|
88
|
+
# A running machine named `name`, made or found, of that size.
|
|
89
|
+
def create(name, labels: {}, shape: default_shape, disk: default_disk(shape), **)
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
end
|
|
92
|
+
def find(name) = raise NotImplementedError
|
|
93
|
+
# Running.
|
|
94
|
+
def wake(machine) = raise NotImplementedError
|
|
95
|
+
# Stopped, disk kept. Answers whether the machine is still there to wake (a VM provider removes the server
|
|
96
|
+
# and keeps only the volume).
|
|
97
|
+
def halt(machine) = raise NotImplementedError
|
|
98
|
+
# Gone; gone already counts.
|
|
99
|
+
def destroy(machine) = raise NotImplementedError
|
|
100
|
+
# What Box talks through: an object answering exec(command) { |line| } -> status, upload(path, io),
|
|
101
|
+
# download(path) -> bytes, address and connection_errors. A pinned host key applies to SSH.
|
|
102
|
+
def transport(machine, key: nil, host_key: nil, on_host_key: nil) = raise NotImplementedError
|
|
103
|
+
# Where `port` on the machine is reachable from outside, for the preview worker to proxy to: {host, scheme,
|
|
104
|
+
# token, headers}, the headers set verbatim on every request. nil is nothing to proxy to yet.
|
|
105
|
+
def preview(machine, port) = raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
# Shapes this provider can serve, keyed "<cpu>x<memory>": cpu in vCPU, memory and disk in GB, rate and
|
|
108
|
+
# held in cents per hour. disk is a number where the provider ties it to the size, or {min:, max:, step:,
|
|
109
|
+
# default:} where it floats. type is the provider's own name for the size, nil when free-form. source is
|
|
110
|
+
# :api, :table or :formula; as_of is the price's date when it is not live.
|
|
111
|
+
def shapes = raise NotImplementedError
|
|
112
|
+
# Cents per hour for the shape at the chosen disk; what the ledger snapshots at open.
|
|
113
|
+
def rate(key, disk: nil) = shapes.fetch(key)[:rate]
|
|
114
|
+
# The stopped price, cents per hour.
|
|
115
|
+
def held(key, disk: nil) = shapes.fetch(key)[:held]
|
|
116
|
+
def default_shape = "2x4"
|
|
117
|
+
def default_disk(key)
|
|
118
|
+
disk = shapes.fetch(key)[:disk]
|
|
119
|
+
disk.is_a?(Hash) ? disk[:default] : disk
|
|
120
|
+
end
|
|
121
|
+
# The provider's own meter for the machine since it was made, {seconds:, cents:} running time and its
|
|
122
|
+
# price; nil when the provider keeps none. cents is nil when it meters time without a price.
|
|
123
|
+
def usage(_machine) = nil
|
|
124
|
+
# Every machine of ours at the provider; empty when it cannot be listed.
|
|
125
|
+
def list = []
|
|
126
|
+
# The provider's current prices, fetched: {source:, rates: {key => cents per hour}}. rates is nil when
|
|
127
|
+
# the source no longer parses; the whole answer is nil when the provider publishes none.
|
|
128
|
+
def prices = nil
|
|
129
|
+
|
|
130
|
+
# Providers this installation can reach: the ones whose library is here and that say so themselves. A
|
|
131
|
+
# keyed one is reachable when its credential is.
|
|
132
|
+
def self.available = names.select { installed?(_1) && implementation(_1).available?(_1) }
|
|
133
|
+
|
|
134
|
+
def self.available?(name) = api_key?(name)
|
|
135
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# The sandbox interface: machines from a prepared image (a snapshot, a template), named by us, copied disk and
|
|
2
|
+
# memory from a running parent. Build makes the environment's seed, Fork makes worktree boxes from it. The
|
|
3
|
+
# login is root; every command runs as the deploy user through Sandbox::Adapter::Box.
|
|
4
|
+
class Sandbox::Adapter::SandboxVm < Sandbox::Adapter::Provider
|
|
5
|
+
def self.type = "sandbox_vm"
|
|
6
|
+
def layout = Sandbox::Adapter::Layout.for(login: "root", volume: false)
|
|
7
|
+
|
|
8
|
+
# The image machines of that size are created from, made once; idempotent.
|
|
9
|
+
def prepare(shape = default_shape, disk = default_disk(shape)) = raise NotImplementedError
|
|
10
|
+
# A seed that is done being built, as forks will read it: the same running machine where forking copies a
|
|
11
|
+
# live parent, an image of it where the provider forks from a saved state instead.
|
|
12
|
+
def seal(machine) = machine
|
|
13
|
+
# A running copy of `machine`, a sealed seed, named `name`, at the seed's size. A name already taken is
|
|
14
|
+
# the copy from a previous attempt.
|
|
15
|
+
def fork(machine, name, labels: {}, shape: nil, disk: nil) = raise NotImplementedError
|
|
16
|
+
end
|