Class: Xshellz::Sandbox

Inherits:
Object
  • Object
show all
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"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(info:, client:, private_key_openssh: nil, transport: nil) ⇒ Sandbox

Returns a new instance of Sandbox.



25
26
27
28
29
30
31
32
# File 'lib/xshellz/sandbox.rb', line 25

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

#infoObject (readonly)

The last-known control-plane state (a SandboxInfo).



19
20
21
# File 'lib/xshellz/sandbox.rb', line 19

def info
  @info
end

#private_key_opensshObject (readonly)

OpenSSH serialization of the private key authenticating this sandbox's SSH (persist it to reconnect later via Sandbox.connect).



23
24
25
# File 'lib/xshellz/sandbox.rb', line 23

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



79
80
81
82
83
84
# File 'lib/xshellz/sandbox.rb', line 79

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, ...).



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/xshellz/sandbox.rb', line 51

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



95
96
97
98
99
100
101
102
# File 'lib/xshellz/sandbox.rb', line 95

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

.list(api_key: nil, api_url: nil, http: nil) ⇒ Array<SandboxInfo>

List the account's active sandboxes (a bare array on the wire).

Returns:



89
90
91
92
# File 'lib/xshellz/sandbox.rb', line 89

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

Instance Method Details

#closeObject

Close the SSH connection (keeps the box alive).



228
229
230
231
# File 'lib/xshellz/sandbox.rb', line 228

def close
  close_transport
  nil
end

#detachObject

Keep the sandbox alive when the create block exits.

Persist private_key_openssh and uuid to re-attach later with Sandbox.connect.



217
218
219
220
# File 'lib/xshellz/sandbox.rb', line 217

def detach
  @detached = true
  self
end

#detached?Boolean

Returns:

  • (Boolean)


132
133
134
# File 'lib/xshellz/sandbox.rb', line 132

def detached?
  @detached
end

#download(remote_path, local_path) ⇒ Object

Download a file from the sandbox to a local path (SFTP).



170
171
172
# File 'lib/xshellz/sandbox.rb', line 170

def download(remote_path, local_path)
  transport.download(remote_path, local_path)
end

#inspectObject Also known as: to_s



233
234
235
# File 'lib/xshellz/sandbox.rb', line 233

def inspect
  "#<Xshellz::Sandbox uuid=#{uuid.inspect} status=#{status.inspect} ssh=#{ssh_host}:#{ssh_port}>"
end

#killObject

Destroy the sandbox (+DELETE /shells/agent/#uuid+). Idempotent: a 404 (already gone) is swallowed.



200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/xshellz/sandbox.rb', line 200

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

#nameObject



112
113
114
# File 'lib/xshellz/sandbox.rb', line 112

def name
  @info.name
end

#read_file(path) ⇒ Object

Read and return the contents of path inside the sandbox (SFTP).



160
161
162
# File 'lib/xshellz/sandbox.rb', line 160

def read_file(path)
  transport.read_file(path)
end

#refreshObject

Re-fetch this sandbox's state from the control plane.



223
224
225
# File 'lib/xshellz/sandbox.rb', line 223

def refresh
  @info = self.class.find(@client, uuid)
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.

Returns:



150
151
152
# File 'lib/xshellz/sandbox.rb', line 150

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

#ssh_commandObject



128
129
130
# File 'lib/xshellz/sandbox.rb', line 128

def ssh_command
  @info.ssh_command
end

#ssh_hostObject



120
121
122
# File 'lib/xshellz/sandbox.rb', line 120

def ssh_host
  @info.ssh_host
end

#ssh_portObject



124
125
126
# File 'lib/xshellz/sandbox.rb', line 124

def ssh_port
  @info.ssh_port
end

#startObject

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.



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/xshellz/sandbox.rb', line 182

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

#statusObject



116
117
118
# File 'lib/xshellz/sandbox.rb', line 116

def status
  @info.status
end

#upload(local_path, remote_path) ⇒ Object

Upload a local file into the sandbox (SFTP).



165
166
167
# File 'lib/xshellz/sandbox.rb', line 165

def upload(local_path, remote_path)
  transport.upload(local_path, remote_path)
end

#uuidObject

------------------------------------------------------------------ # Properties ------------------------------------------------------------------ #



108
109
110
# File 'lib/xshellz/sandbox.rb', line 108

def uuid
  @info.uuid
end

#write_file(path, data) ⇒ Object

Write data to path inside the sandbox (SFTP).



155
156
157
# File 'lib/xshellz/sandbox.rb', line 155

def write_file(path, data)
  transport.write_file(path, data)
end