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