Class: Xshellz::Sandbox
- Inherits:
-
Object
- Object
- Xshellz::Sandbox
- Defined in:
- lib/xshellz/sandbox.rb
Overview
A remote sandbox: control plane over HTTPS, data plane over SSH.
Create one with Sandbox.create, attach to an existing one with
Sandbox.connect, or enumerate them with Sandbox.list.
The block form destroys the sandbox when the block exits, unless
detach was called:
Xshellz::Sandbox.create do |sbx|
result = sbx.run("echo hello")
end
Constant Summary collapse
- STATUS_RUNNING =
"running"- STATUS_STOPPED =
"stopped"- JOBS_DIR =
"~/.xshellz/jobs"- RUN_CODE_LANGUAGES =
Interpreters
run_codeknows how to execute, keyed by language name. { "python" => { command: "python3", extension: ".py" }, "node" => { command: "node", extension: ".js" }, "bash" => { command: "bash", extension: ".sh" }, "ruby" => { command: "ruby", extension: ".rb" }, "php" => { command: "php", extension: ".php" } }.freeze
Instance Attribute Summary collapse
-
#info ⇒ Object
readonly
The last-known control-plane state (a SandboxInfo).
-
#private_key_openssh ⇒ Object
readonly
OpenSSH serialization of the private key authenticating this sandbox's SSH (persist it to reconnect later via
Sandbox.connect).
Class Method Summary collapse
-
.connect(uuid, private_key:, api_key: nil, api_url: nil, http: nil) ⇒ Object
Attach to an existing sandbox by UUID.
-
.create(name: nil, api_key: nil, api_url: nil, http: nil) ⇒ Object
Spawn a new sandbox and return it once it is RUNNING.
-
.find(client, uuid) ⇒ Object
Resolve one sandbox via the list endpoint (there is no GET show route).
-
.get_boxfile(api_key: nil, api_url: nil, http: nil) ⇒ Object
The account's saved
xshellz.boxprovisioning manifest, or nil when none is saved (+GET /shells/agent/boxfile+). -
.get_or_create(name, api_key: nil, api_url: nil, http: nil, private_key: nil, keystore: :default) ⇒ Object
Get-or-create a PERMANENT named sandbox: find the account's sandbox whose name is exactly
name, or create one with that name. -
.list(api_key: nil, api_url: nil, http: nil) ⇒ Array<SandboxInfo>
List the account's active sandboxes (a bare array on the wire).
-
.set_boxfile(manifest, api_key: nil, api_url: nil, http: nil) ⇒ Object
Save the account's
xshellz.boxmanifest (+PUT /shells/agent/boxfile+, max 16 KB).
Instance Method Summary collapse
-
#close ⇒ Object
Close the SSH connection (keeps the box alive).
-
#detach ⇒ Object
Keep the sandbox alive when the
createblock exits. - #detached? ⇒ Boolean
-
#download(remote_path, local_path) ⇒ Object
Download a file from the sandbox to a local path (SFTP).
-
#initialize(info:, client:, private_key_openssh: nil, transport: nil) ⇒ Sandbox
constructor
A new instance of Sandbox.
- #inspect ⇒ Object (also: #to_s)
-
#jobs ⇒ Array<JobHandle>
List the sandbox's background jobs (every job ever spawned whose
~/.xshellz/jobs/<id>.pidfile still exists). -
#kill ⇒ Object
Destroy the sandbox (+DELETE /shells/agent/#uuid+).
- #name ⇒ Object
-
#procs ⇒ SandboxProcs
Top processes + active SSH session count inside the box (+GET /shells/agent/#uuid/procs+).
-
#read_file(path) ⇒ Object
Read and return the contents of
pathinside the sandbox (SFTP). -
#refresh ⇒ Object
Re-fetch this sandbox's state from the control plane.
-
#restart ⇒ Object
Reboot a running box (+POST /shells/agent/#uuid/restart+): re-runs the entrypoint;
/homeis preserved. -
#run(command, cwd: nil, env: nil, timeout: nil, &block) ⇒ CommandResult
Run a shell command in the sandbox and wait for it to finish.
-
#run_code(language, code, cwd: nil, env: nil, timeout: nil, &block) ⇒ CommandResult
Execute a snippet of code in the sandbox: write it to a temp file, run the matching interpreter, always delete the temp file.
-
#spawn(command, name: nil) ⇒ JobHandle
Start
commandas a detached background process inside the sandbox and return immediately with a JobHandle. - #ssh_command ⇒ Object
- #ssh_host ⇒ Object
- #ssh_port ⇒ Object
-
#start ⇒ Object
Resume an idle-stopped box (+POST /shells/agent/#uuid/start+).
-
#stats ⇒ SandboxStats
Live resource usage (+GET /shells/agent/#uuid/stats+): memory, CPU, pids, disk, network and block IO, each alongside the plan-allowed ceiling.
- #status ⇒ Object
-
#terminal_url ⇒ String
Mint a fresh signed web-terminal URL for this box (+GET /shells/agent/#uuid/terminal+).
-
#upload(local_path, remote_path) ⇒ Object
Upload a local file into the sandbox (SFTP).
-
#uuid ⇒ Object
------------------------------------------------------------------ # Properties ------------------------------------------------------------------ #.
-
#write_file(path, data) ⇒ Object
Write
datatopathinside the sandbox (SFTP).
Constructor Details
#initialize(info:, client:, private_key_openssh: nil, transport: nil) ⇒ Sandbox
Returns a new instance of Sandbox.
40 41 42 43 44 45 46 47 |
# File 'lib/xshellz/sandbox.rb', line 40 def initialize(info:, client:, private_key_openssh: nil, transport: nil) @info = info @client = client @private_key_openssh = private_key_openssh @transport = transport @detached = false @killed = false end |
Instance Attribute Details
#info ⇒ Object (readonly)
The last-known control-plane state (a SandboxInfo).
34 35 36 |
# File 'lib/xshellz/sandbox.rb', line 34 def info @info end |
#private_key_openssh ⇒ Object (readonly)
OpenSSH serialization of the private key authenticating this sandbox's
SSH (persist it to reconnect later via Sandbox.connect).
38 39 40 |
# File 'lib/xshellz/sandbox.rb', line 38 def private_key_openssh @private_key_openssh end |
Class Method Details
.connect(uuid, private_key:, api_key: nil, api_url: nil, http: nil) ⇒ Object
Attach to an existing sandbox by UUID.
private_key is the OpenSSH-format private key whose public half the
box was created with (the private_key_openssh of the original
sandbox).
94 95 96 97 98 99 |
# File 'lib/xshellz/sandbox.rb', line 94 def self.connect(uuid, private_key:, api_key: nil, api_url: nil, http: nil) Ssh.load_private_key!(private_key) client = Client.new(api_key: api_key, api_url: api_url, http: http) info = find(client, uuid) new(info: info, client: client, private_key_openssh: private_key) end |
.create(name: nil, api_key: nil, api_url: nil, http: nil) ⇒ Object
Spawn a new sandbox and return it once it is RUNNING.
An in-memory ed25519 keypair is generated for the box; the private key never leaves this process and the server never sees it. Spawning is synchronous - the box is reachable when this returns.
With a block, the sandbox is yielded and destroyed when the block
exits (unless detach was called); the block's value is returned.
Raises AuthError (missing/invalid API key, insufficient scope, or an account gate), QuotaError (the plan's concurrent sandbox limit is reached or the plan has no sandbox entitlement), or ApiError (other API failures: throttle 429, host capacity 503, ...).
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/xshellz/sandbox.rb', line 66 def self.create(name: nil, api_key: nil, api_url: nil, http: nil) keypair = Keys.generate client = Client.new(api_key: api_key, api_url: api_url, http: http) body = { "ssh_public_key" => keypair.public_key_line } body["name"] = name unless name.nil? payload = client.post("/shells/agent", body) sandbox = new( info: SandboxInfo.from_api(payload), client: client, private_key_openssh: keypair.private_key_openssh ) return sandbox unless block_given? begin yield sandbox ensure sandbox.kill unless sandbox.detached? sandbox.close end end |
.find(client, uuid) ⇒ Object
Resolve one sandbox via the list endpoint (there is no GET show route).
180 181 182 183 184 185 186 187 |
# File 'lib/xshellz/sandbox.rb', line 180 def self.find(client, uuid) client.get("/shells/agent").each do |item| info = SandboxInfo.from_api(item) return info if info.uuid == uuid end raise SandboxNotRunningError, "Sandbox #{uuid} was not found among this account's active sandboxes." end |
.get_boxfile(api_key: nil, api_url: nil, http: nil) ⇒ Object
The account's saved xshellz.box provisioning manifest, or nil when
none is saved (+GET /shells/agent/boxfile+).
The boxfile is applied when a NEW box is created: it is seeded into
~/xshellz.box on every fresh box, so destroy+recreate reproduces
your package environment (preinstall deps, etc.).
165 166 167 168 |
# File 'lib/xshellz/sandbox.rb', line 165 def self.get_boxfile(api_key: nil, api_url: nil, http: nil) client = Client.new(api_key: api_key, api_url: api_url, http: http) (client.get("/shells/agent/boxfile") || {})["manifest"] end |
.get_or_create(name, api_key: nil, api_url: nil, http: nil, private_key: nil, keystore: :default) ⇒ Object
Get-or-create a PERMANENT named sandbox: find the account's sandbox
whose name is exactly name, or create one with that name.
The private key is persisted in a local keystore
(+~/.xshellz/keys/get_or_create from
another process can re-attach without you handling key material:
- Not found -> create; persist the generated key in the keystore.
- Found -> attach with the explicit
private_keyif given, else the keystore's key; a stopped box is started first. When no key can be found, raises MissingKeyError telling you where one was expected.
keystore accepts :default (=> ~/.xshellz/keys/), a directory
String, a Keystore instance, or +false+/+nil+ to disable persistence
entirely (then get_or_create can only create-or-error, unless you
pass private_key).
Security note: the keystore holds plaintext private keys on disk (0600). Delete the key file and kill the box to revoke.
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/xshellz/sandbox.rb', line 120 def self.get_or_create(name, api_key: nil, api_url: nil, http: nil, private_key: nil, keystore: :default) name = name.to_s raise ArgumentError, "get_or_create requires a non-empty sandbox name" if name.empty? store = resolve_keystore(keystore) client = Client.new(api_key: api_key, api_url: api_url, http: http) found = client.get("/shells/agent") .map { |item| SandboxInfo.from_api(item) } .find { |info| info.name == name } return create_named(client, name, store) if found.nil? key = private_key || store&.load(name) if key.nil? hint = if store.nil? "the keystore is disabled, so pass private_key: ..." else "expected a key file at #{store.path_for(name)}" end raise MissingKeyError, "Sandbox #{name.inspect} already exists but no private key is " \ "available to attach to it (#{hint}). Pass private_key: ..., " \ "or kill the box and let get_or_create recreate it." end Ssh.load_private_key!(key) sandbox = new(info: found, client: client, private_key_openssh: key) sandbox.start if sandbox.status == STATUS_STOPPED sandbox end |
.list(api_key: nil, api_url: nil, http: nil) ⇒ Array<SandboxInfo>
List the account's active sandboxes (a bare array on the wire).
154 155 156 157 |
# File 'lib/xshellz/sandbox.rb', line 154 def self.list(api_key: nil, api_url: nil, http: nil) client = Client.new(api_key: api_key, api_url: api_url, http: http) client.get("/shells/agent").map { |item| SandboxInfo.from_api(item) } end |
.set_boxfile(manifest, api_key: nil, api_url: nil, http: nil) ⇒ Object
Save the account's xshellz.box manifest (+PUT /shells/agent/boxfile+,
max 16 KB). Pass nil (or a blank string) to clear it. Returns the
stored manifest (nil when cleared). Applies to boxes created AFTER the
save - existing boxes are not touched.
174 175 176 177 |
# File 'lib/xshellz/sandbox.rb', line 174 def self.set_boxfile(manifest, api_key: nil, api_url: nil, http: nil) client = Client.new(api_key: api_key, api_url: api_url, http: http) (client.put("/shells/agent/boxfile", { "manifest" => manifest }) || {})["manifest"] end |
Instance Method Details
#close ⇒ Object
Close the SSH connection (keeps the box alive).
448 449 450 451 |
# File 'lib/xshellz/sandbox.rb', line 448 def close close_transport nil end |
#detach ⇒ Object
Keep the sandbox alive when the create block exits.
Persist private_key_openssh and uuid to re-attach later with
Sandbox.connect.
437 438 439 440 |
# File 'lib/xshellz/sandbox.rb', line 437 def detach @detached = true self end |
#detached? ⇒ Boolean
244 245 246 |
# File 'lib/xshellz/sandbox.rb', line 244 def detached? @detached end |
#download(remote_path, local_path) ⇒ Object
Download a file from the sandbox to a local path (SFTP).
282 283 284 |
# File 'lib/xshellz/sandbox.rb', line 282 def download(remote_path, local_path) transport.download(remote_path, local_path) end |
#inspect ⇒ Object Also known as: to_s
453 454 455 |
# File 'lib/xshellz/sandbox.rb', line 453 def inspect "#<Xshellz::Sandbox uuid=#{uuid.inspect} status=#{status.inspect} ssh=#{ssh_host}:#{ssh_port}>" end |
#jobs ⇒ Array<JobHandle>
List the sandbox's background jobs (every job ever spawned whose
~/.xshellz/jobs/<id>.pid file still exists). Check liveness per
handle with running?; read output with logs.
316 317 318 319 320 321 322 323 324 325 326 327 328 |
# File 'lib/xshellz/sandbox.rb', line 316 def jobs listing = transport.exec( "mkdir -p #{JOBS_DIR} && for f in #{JOBS_DIR}/*.pid; do " \ '[ -e "$f" ] || continue; ' \ 'printf "%s %s\n" "$(basename "$f" .pid)" "$(cat "$f")"; done' ) listing.stdout.each_line.filter_map do |line| id, pid = line.split next if id.nil? || pid.nil? || !pid.match?(/\A\d+\z/) JobHandle.new(sandbox: self, id: id, pid: pid) end end |
#kill ⇒ Object
Destroy the sandbox (+DELETE /shells/agent/#uuid+). Idempotent: a 404 (already gone) is swallowed.
420 421 422 423 424 425 426 427 428 429 430 431 |
# File 'lib/xshellz/sandbox.rb', line 420 def kill close_transport return if @killed begin @client.delete("/shells/agent/#{uuid}") rescue ApiError => e raise unless e.status == 404 end @killed = true nil end |
#name ⇒ Object
224 225 226 |
# File 'lib/xshellz/sandbox.rb', line 224 def name @info.name end |
#procs ⇒ SandboxProcs
Top processes + active SSH session count inside the box (+GET /shells/agent/#uuid/procs+).
404 405 406 |
# File 'lib/xshellz/sandbox.rb', line 404 def procs SandboxProcs.from_api(@client.get("/shells/agent/#{uuid}/procs")) end |
#read_file(path) ⇒ Object
Read and return the contents of path inside the sandbox (SFTP).
272 273 274 |
# File 'lib/xshellz/sandbox.rb', line 272 def read_file(path) transport.read_file(path) end |
#refresh ⇒ Object
Re-fetch this sandbox's state from the control plane.
443 444 445 |
# File 'lib/xshellz/sandbox.rb', line 443 def refresh @info = self.class.find(@client, uuid) end |
#restart ⇒ Object
Reboot a running box (+POST /shells/agent/#uuid/restart+): re-runs
the entrypoint; /home is preserved. Returns self with refreshed
info; the SSH connection is re-established on the next data-plane
call.
384 385 386 387 388 389 |
# File 'lib/xshellz/sandbox.rb', line 384 def restart payload = @client.post("/shells/agent/#{uuid}/restart") @info = SandboxInfo.from_api(payload) close_transport self end |
#run(command, cwd: nil, env: nil, timeout: nil, &block) ⇒ CommandResult
Run a shell command in the sandbox and wait for it to finish.
A non-zero exit code does NOT raise - inspect result.exit_code. An
optional block receives (:stdout | :stderr, chunk) as output arrives
(the full output is still returned in the result).
Raises SandboxNotRunningError when the box is not running, and
CommandTimeoutError when timeout seconds elapse before exit.
262 263 264 |
# File 'lib/xshellz/sandbox.rb', line 262 def run(command, cwd: nil, env: nil, timeout: nil, &block) transport.exec(Ssh.build_shell_command(command, cwd: cwd, env: env), timeout: timeout, &block) end |
#run_code(language, code, cwd: nil, env: nil, timeout: nil, &block) ⇒ CommandResult
Execute a snippet of code in the sandbox: write it to a temp file, run the matching interpreter, always delete the temp file.
Supported languages: python (python3), node, bash, ruby, php. An
unknown language raises UnsupportedLanguageError. Semantics otherwise
match run (same cwd/env/timeout/stream-block, non-zero exit is
data, returns a CommandResult).
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/xshellz/sandbox.rb', line 339 def run_code(language, code, cwd: nil, env: nil, timeout: nil, &block) interpreter = RUN_CODE_LANGUAGES[language.to_s.downcase] if interpreter.nil? raise UnsupportedLanguageError, "Unsupported run_code language #{language.inspect}. " \ "Supported languages: #{RUN_CODE_LANGUAGES.keys.join(", ")}." end path = "/tmp/xshellz-run-#{SecureRandom.hex(6)}#{interpreter[:extension]}" write_file(path, code) begin run("#{interpreter[:command]} #{Shellwords.escape(path)}", cwd: cwd, env: env, timeout: timeout, &block) ensure delete_remote_file_quietly(path) end end |
#spawn(command, name: nil) ⇒ JobHandle
Start command as a detached background process inside the sandbox
and return immediately with a JobHandle.
The process survives this SDK disconnecting (nohup, stdin from
/dev/null); combined stdout+stderr goes to ~/.xshellz/jobs/<id>.log
in the box. name (optional) prefixes the generated job id.
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 |
# File 'lib/xshellz/sandbox.rb', line 294 def spawn(command, name: nil) job_id = generate_job_id(name) script = "mkdir -p #{JOBS_DIR} && " \ "nohup bash -c #{Shellwords.escape(command)} " \ "> #{JOBS_DIR}/#{job_id}.log 2>&1 < /dev/null & " \ "echo $! > #{JOBS_DIR}/#{job_id}.pid; echo $!" result = transport.exec(script) pid = result.stdout[/\d+/] if !result.ok? || pid.nil? raise Error, "Failed to spawn background job #{job_id.inspect} " \ "(exit #{result.exit_code}): #{result.stderr.strip}" end JobHandle.new(sandbox: self, id: job_id, pid: pid) end |
#ssh_command ⇒ Object
240 241 242 |
# File 'lib/xshellz/sandbox.rb', line 240 def ssh_command @info.ssh_command end |
#ssh_host ⇒ Object
232 233 234 |
# File 'lib/xshellz/sandbox.rb', line 232 def ssh_host @info.ssh_host end |
#ssh_port ⇒ Object
236 237 238 |
# File 'lib/xshellz/sandbox.rb', line 236 def ssh_port @info.ssh_port end |
#start ⇒ Object
Resume an idle-stopped box (+POST /shells/agent/#uuid/start+).
Free-tier boxes idle-stop after ~30 minutes; this brings the same box
(same /home, same authorized key) back to running.
364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 |
# File 'lib/xshellz/sandbox.rb', line 364 def start begin payload = @client.post("/shells/agent/#{uuid}/start") rescue ApiError => e if e.status == 404 raise SandboxNotRunningError, "Sandbox #{uuid} has no stopped box to start - it may " \ "already be running, suspended, or deleted." end raise end @info = SandboxInfo.from_api(payload) close_transport self end |
#stats ⇒ SandboxStats
Live resource usage (+GET /shells/agent/#uuid/stats+): memory, CPU, pids, disk, network and block IO, each alongside the plan-allowed ceiling.
396 397 398 |
# File 'lib/xshellz/sandbox.rb', line 396 def stats SandboxStats.from_api(@client.get("/shells/agent/#{uuid}/stats")) end |
#status ⇒ Object
228 229 230 |
# File 'lib/xshellz/sandbox.rb', line 228 def status @info.status end |
#terminal_url ⇒ String
Mint a fresh signed web-terminal URL for this box (+GET /shells/agent/#uuid/terminal+). Open it in a browser for a root shell - no SSH client needed. The embedded HMAC token expires after ~1 hour; call again for a fresh URL rather than storing it.
414 415 416 |
# File 'lib/xshellz/sandbox.rb', line 414 def terminal_url (@client.get("/shells/agent/#{uuid}/terminal") || {})["url"].to_s end |
#upload(local_path, remote_path) ⇒ Object
Upload a local file into the sandbox (SFTP).
277 278 279 |
# File 'lib/xshellz/sandbox.rb', line 277 def upload(local_path, remote_path) transport.upload(local_path, remote_path) end |
#uuid ⇒ Object
------------------------------------------------------------------ # Properties ------------------------------------------------------------------ #
220 221 222 |
# File 'lib/xshellz/sandbox.rb', line 220 def uuid @info.uuid end |
#write_file(path, data) ⇒ Object
Write data to path inside the sandbox (SFTP).
267 268 269 |
# File 'lib/xshellz/sandbox.rb', line 267 def write_file(path, data) transport.write_file(path, data) end |