pinesandbox 0.3.18 → 0.3.20
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 +4 -4
- data/LICENSE +201 -0
- data/README.md +222 -161
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +90 -114
- data/lib/pinesandbox/agent_events.rb +2 -2
- data/lib/pinesandbox/agent_turn.rb +25 -2
- data/lib/pinesandbox/artifacts.rb +776 -0
- data/lib/pinesandbox/attach_credentials_source.rb +58 -16
- data/lib/pinesandbox/bind_hpke.rb +4 -4
- data/lib/pinesandbox/client.rb +75 -37
- data/lib/pinesandbox/computer.rb +146 -116
- data/lib/pinesandbox/computer_binding.rb +14 -10
- data/lib/pinesandbox/control_plane.rb +10 -10
- data/lib/pinesandbox/error_taxonomy.rb +3 -0
- data/lib/pinesandbox/errors.rb +83 -76
- data/lib/pinesandbox/models/artifact.rb +1 -1
- data/lib/pinesandbox/models/artifact_file.rb +40 -0
- data/lib/pinesandbox/models/computer_session.rb +7 -3
- data/lib/pinesandbox/operations.rb +2 -3
- data/lib/pinesandbox/{runtime_details.rb → sandbox_details.rb} +4 -4
- data/lib/pinesandbox/{runtime_handle.rb → sandbox_handle.rb} +16 -20
- data/lib/pinesandbox/session.rb +39 -4
- data/lib/pinesandbox/spec_version_middleware.rb +1 -1
- data/lib/pinesandbox/streamed_read.rb +80 -0
- data/lib/pinesandbox/version.rb +3 -1
- data/lib/pinesandbox/zone.rb +8 -11
- data/lib/pinesandbox.rb +4 -2
- data/pinesandbox.gemspec +5 -3
- metadata +11 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "faraday"
|
|
2
2
|
require "json"
|
|
3
|
+
require "time"
|
|
3
4
|
require_relative "errors"
|
|
4
5
|
|
|
5
6
|
module PineSandbox
|
|
@@ -12,17 +13,19 @@ module PineSandbox
|
|
|
12
13
|
# * POST /v1/computers — optional legacy
|
|
13
14
|
# pre-registration helper. Normal attach does not require it.
|
|
14
15
|
# * POST /v1/computers/:computer_id/attach-credentials — body carries the
|
|
15
|
-
#
|
|
16
|
+
# start's coordinates {pod_uid, coord_boot_id}; returns {bind_token,
|
|
16
17
|
# broker_grant, key_assertion, binding_revision}. The portal authenticates
|
|
17
18
|
# the pk_ and atomically creates/rechecks its minimal authorization row,
|
|
18
19
|
# advances the key floor, supersedes the prior binding, and mints.
|
|
19
20
|
# * DELETE /v1/computers/:computer_id — permanent delete.
|
|
20
21
|
# 204 for deleted, already deleted, unknown, and cross-project ids alike.
|
|
22
|
+
# * POST /v1/computers/:computer_id/artifacts/token — a short-lived
|
|
23
|
+
# storage token for the Computer's artifacts (Artifacts).
|
|
21
24
|
#
|
|
22
|
-
# This source caches nothing. Computer#run_bind calls it exactly once for
|
|
23
|
-
#
|
|
24
|
-
# (the bind is idempotent on jti + ciphertext). A
|
|
25
|
-
# requires a
|
|
25
|
+
# This source caches nothing. Computer#run_bind calls it exactly once for each
|
|
26
|
+
# start, then convergence retries re-send the held envelope verbatim
|
|
27
|
+
# (the bind is idempotent on jti + ciphertext). A change of pod_uid or
|
|
28
|
+
# coord_boot_id requires a new start; it never authorizes the same start twice.
|
|
26
29
|
# The pk_ never appears in #inspect.
|
|
27
30
|
class AttachCredentialsSource
|
|
28
31
|
REGISTER_PATH = "/v1/computers"
|
|
@@ -97,7 +100,31 @@ module PineSandbox
|
|
|
97
100
|
payload
|
|
98
101
|
end
|
|
99
102
|
|
|
100
|
-
#
|
|
103
|
+
# A short-lived storage token for the Computer's artifacts, and where they
|
|
104
|
+
# are: "access_token", "expires_at", "endpoint", "bucket", "prefix",
|
|
105
|
+
# "writable", "used_bytes" and "limit_bytes". With `copy_from` (other
|
|
106
|
+
# Computer ids of the project), it may also read their artifacts, and
|
|
107
|
+
# "copy_from" maps each id to its prefix. Works whether or not the
|
|
108
|
+
# Computer is running. Returns the response Hash.
|
|
109
|
+
def artifact_token(computer_id, copy_from: nil)
|
|
110
|
+
raise ArgumentError, "computer_id required" if computer_id.to_s.empty?
|
|
111
|
+
|
|
112
|
+
copy_from = Array(copy_from).map(&:to_s)
|
|
113
|
+
body = copy_from.empty? ? {} : { copy_from: copy_from }
|
|
114
|
+
resp = post("#{REGISTER_PATH}/#{computer_id}/artifacts/token", json: body, op: "artifact token")
|
|
115
|
+
if resp.status == 404
|
|
116
|
+
raise UnknownComputerError.new(error_message(resp, "Computer not found"), **error_attributes(resp))
|
|
117
|
+
end
|
|
118
|
+
raise PortalError.new(error_message(resp, "Artifact token unavailable"), **error_attributes(resp)) unless resp.status == 200
|
|
119
|
+
|
|
120
|
+
payload = parse_json(resp)
|
|
121
|
+
unless artifact_token?(payload, copy_from)
|
|
122
|
+
raise PortalError.new("Invalid artifact token response", **error_attributes(resp))
|
|
123
|
+
end
|
|
124
|
+
payload
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# One authorization-registry read before Start; never a Computer status poll.
|
|
101
128
|
def current_binding(computer_id)
|
|
102
129
|
resp = get("#{REGISTER_PATH}/#{computer_id}", op: "computer binding lookup")
|
|
103
130
|
if resp.status == 404
|
|
@@ -114,7 +141,7 @@ module PineSandbox
|
|
|
114
141
|
|
|
115
142
|
# Permanently delete `computer_id`. The portal tombstones it at once (no
|
|
116
143
|
# further attach, renewal, or lease refresh), destroys its bound sandboxes
|
|
117
|
-
# asynchronously, keeps saved browser state for
|
|
144
|
+
# asynchronously, keeps saved browser state for 25 hours before purging it,
|
|
118
145
|
# and never accepts the id again. Idempotent and non-disclosing: an unknown,
|
|
119
146
|
# already-deleted, or cross-project id is the same 204. Returns true.
|
|
120
147
|
def delete_computer(computer_id)
|
|
@@ -134,9 +161,9 @@ module PineSandbox
|
|
|
134
161
|
end
|
|
135
162
|
end
|
|
136
163
|
|
|
137
|
-
# Mint the per-attach credentials for one
|
|
138
|
-
#
|
|
139
|
-
#
|
|
164
|
+
# Mint the per-attach credentials for one Computer sandbox and boot. `sandbox_id`
|
|
165
|
+
# is the sandbox id this attach targets — the portal records which Computer it
|
|
166
|
+
# belongs to at mint (its renewal gate keys off it).
|
|
140
167
|
# Returns a Hash with string keys: "bind_token", "broker_grant",
|
|
141
168
|
# "key_assertion", "binding_revision", and the effective "location"
|
|
142
169
|
# (plus expiry metadata).
|
|
@@ -208,13 +235,15 @@ module PineSandbox
|
|
|
208
235
|
)
|
|
209
236
|
when 412
|
|
210
237
|
problem = parse_json(resp)
|
|
238
|
+
# A Portal that predates current_revision sends only current_binding_revision.
|
|
239
|
+
revision = problem.fetch("current_revision") { problem["current_binding_revision"] }
|
|
211
240
|
raise BindingRevisionConflictError.new(
|
|
212
241
|
binding_conflict_message(
|
|
213
242
|
error_message(resp, "binding revision changed; reload and adopt the winning binding"),
|
|
214
|
-
|
|
243
|
+
revision, problem["current_sandbox_id"]
|
|
215
244
|
),
|
|
216
|
-
current_binding_revision:
|
|
217
|
-
|
|
245
|
+
current_binding_revision: revision,
|
|
246
|
+
current_sandbox_id: problem["current_sandbox_id"],
|
|
218
247
|
**error_attributes(resp)
|
|
219
248
|
)
|
|
220
249
|
else
|
|
@@ -243,6 +272,19 @@ module PineSandbox
|
|
|
243
272
|
{ country: normalized.fetch("country") }
|
|
244
273
|
end
|
|
245
274
|
|
|
275
|
+
def artifact_token?(payload, copy_from)
|
|
276
|
+
return false unless payload.is_a?(Hash)
|
|
277
|
+
return false unless %w[access_token expires_at endpoint bucket].all? { |key| payload[key].is_a?(String) && !payload[key].empty? }
|
|
278
|
+
return false unless payload["prefix"].is_a?(String) && [true, false].include?(payload["writable"])
|
|
279
|
+
return false unless payload["used_bytes"].is_a?(Integer) && payload["limit_bytes"].is_a?(Integer)
|
|
280
|
+
|
|
281
|
+
Time.iso8601(payload["expires_at"])
|
|
282
|
+
prefixes = payload["copy_from"] || {}
|
|
283
|
+
prefixes.is_a?(Hash) && copy_from.all? { |id| prefixes[id].is_a?(String) }
|
|
284
|
+
rescue ArgumentError
|
|
285
|
+
false
|
|
286
|
+
end
|
|
287
|
+
|
|
246
288
|
def response_location?(value)
|
|
247
289
|
value.is_a?(Hash) && value["country"].is_a?(String) &&
|
|
248
290
|
value["country"].match?(/\A[A-Z]{2}\z/)
|
|
@@ -347,12 +389,12 @@ module PineSandbox
|
|
|
347
389
|
|
|
348
390
|
# The attach revision is a fence against two live attaches of one
|
|
349
391
|
# Computer, so the SDK never retries past it; the message says what to do.
|
|
350
|
-
def binding_conflict_message(message, current_revision,
|
|
392
|
+
def binding_conflict_message(message, current_revision, current_sandbox_id)
|
|
351
393
|
return message unless current_revision.is_a?(Integer)
|
|
352
394
|
|
|
353
|
-
holder =
|
|
395
|
+
holder = current_sandbox_id.to_s.empty? ? "" : " (Computer sandbox #{current_sandbox_id})"
|
|
354
396
|
"#{message}. Another start changed the Computer binding#{holder}. " \
|
|
355
|
-
"Do not retry automatically. Check the
|
|
397
|
+
"Do not retry automatically. Check whether the Computer is still running before starting again."
|
|
356
398
|
end
|
|
357
399
|
|
|
358
400
|
def error_message(resp, default)
|
|
@@ -15,7 +15,7 @@ module PineSandbox
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
17
|
# info binds the sealed payload to the receiving process identity so a
|
|
18
|
-
# ciphertext sealed for one
|
|
18
|
+
# ciphertext sealed for one Computer sandbox/boot cannot be opened by another.
|
|
19
19
|
# MUST byte-match the receiver's info string.
|
|
20
20
|
#
|
|
21
21
|
# @param pod_uid [String]
|
|
@@ -26,8 +26,8 @@ module PineSandbox
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# restore_info is the two-round v3 restore's round-2 domain: the
|
|
29
|
-
# integrator-unwrapped component secrets sealed back to the same
|
|
30
|
-
# key. Same suite, DISTINCT domain — a bind ciphertext can never be
|
|
29
|
+
# integrator-unwrapped component secrets sealed back to the same Computer
|
|
30
|
+
# sandbox key. Same suite, DISTINCT domain — a bind ciphertext can never be
|
|
31
31
|
# replayed as a secrets payload or vice versa. MUST byte-match the
|
|
32
32
|
# receiver's restore info string.
|
|
33
33
|
def restore_info(pod_uid, coord_boot_id)
|
|
@@ -36,7 +36,7 @@ module PineSandbox
|
|
|
36
36
|
|
|
37
37
|
# seal encrypts plaintext to the recipient's raw 32-byte X25519 public
|
|
38
38
|
# key. The AAD is fixed empty (the portal attach path carries no nonce —
|
|
39
|
-
# payload-to-
|
|
39
|
+
# payload-to-sandbox binding lives in the info string).
|
|
40
40
|
#
|
|
41
41
|
# @param recipient_pub_raw [String] 32 raw bytes (NOT base64).
|
|
42
42
|
# @param plaintext [String] bytes to seal.
|
data/lib/pinesandbox/client.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "control_plane"
|
|
|
7
7
|
require_relative "connection"
|
|
8
8
|
require_relative "computer"
|
|
9
9
|
require_relative "diagnostics"
|
|
10
|
+
require_relative "artifacts"
|
|
10
11
|
|
|
11
12
|
module PineSandbox
|
|
12
13
|
# The SDK entry point. Holds the project credential (`pk_`) + the endpoint
|
|
@@ -107,6 +108,7 @@ module PineSandbox
|
|
|
107
108
|
PineSandbox::Zone.new(endpoint, control_host: control_host, gateway_url: gateway_url)
|
|
108
109
|
end
|
|
109
110
|
@spec_version = spec_version
|
|
111
|
+
@clock = clock
|
|
110
112
|
@diagnostics = Diagnostics.new(api_key: api_key, zone: @zone) unless api_key.to_s.empty?
|
|
111
113
|
|
|
112
114
|
token_source =
|
|
@@ -176,7 +178,7 @@ module PineSandbox
|
|
|
176
178
|
provider.available_locations
|
|
177
179
|
end
|
|
178
180
|
|
|
179
|
-
# Optional configuration discovery, not a
|
|
181
|
+
# Optional configuration discovery, not a lifecycle operation prerequisite.
|
|
180
182
|
def project_policy
|
|
181
183
|
provider = @connection.attach_provider
|
|
182
184
|
raise ArgumentError, "project policy requires a project API key provider" unless provider.respond_to?(:project_policy)
|
|
@@ -184,12 +186,25 @@ module PineSandbox
|
|
|
184
186
|
provider.project_policy
|
|
185
187
|
end
|
|
186
188
|
|
|
189
|
+
# A Computer's artifacts, running or stopped: the files it keeps beyond
|
|
190
|
+
# its sessions, to list, upload, download, open, copy and delete (see
|
|
191
|
+
# Artifacts). Needs the project API key; no request is sent until the
|
|
192
|
+
# first operation.
|
|
193
|
+
def artifacts(computer_id)
|
|
194
|
+
raise ArgumentError, "id must be a lowercase UUIDv7" unless computer_id.to_s.match?(Computer::UUID_V7)
|
|
195
|
+
|
|
196
|
+
provider = @connection.attach_provider
|
|
197
|
+
raise ArgumentError, "artifacts require a project API key provider" unless provider.respond_to?(:artifact_token)
|
|
198
|
+
|
|
199
|
+
Artifacts.new(computer_id: computer_id, token_source: provider, clock: @clock)
|
|
200
|
+
end
|
|
201
|
+
|
|
187
202
|
# PERMANENTLY delete a Computer: its identity and saved state end (stop
|
|
188
203
|
# and kill keep both). Accepts a PineSandbox::Computer or its id String. A
|
|
189
204
|
# Computer that is still attached is force-terminated first (#kill — no
|
|
190
205
|
# final capture, the state is being deleted). The portal tombstones the id
|
|
191
206
|
# immediately, destroys any remaining sandboxes asynchronously, purges saved
|
|
192
|
-
# state after
|
|
207
|
+
# state after 25 hours, and never accepts the id again. Idempotent: unknown,
|
|
193
208
|
# already-deleted, and other projects' ids all return true. No undelete.
|
|
194
209
|
def delete_computer(computer)
|
|
195
210
|
computer_id =
|
|
@@ -253,8 +268,8 @@ module PineSandbox
|
|
|
253
268
|
location ? provider.register_computer(computer_id, location: location) : provider.register_computer(computer_id)
|
|
254
269
|
end
|
|
255
270
|
|
|
256
|
-
# Re-attach an EXISTING persistent Computer by its stored id + state key.
|
|
257
|
-
#
|
|
271
|
+
# Re-attach an EXISTING persistent Computer by its stored id + state key. The
|
|
272
|
+
# Computer starts and is bound with its prior state restored.
|
|
258
273
|
# The id may be given positionally or as `id:` (matching #create_computer).
|
|
259
274
|
def start_computer(*args, **options)
|
|
260
275
|
if args.first.is_a?(Computer::Credentials)
|
|
@@ -264,11 +279,11 @@ module PineSandbox
|
|
|
264
279
|
credentials, *args = args
|
|
265
280
|
options = credentials_options(credentials, options)
|
|
266
281
|
end
|
|
267
|
-
start_existing_computer(*args, **options,
|
|
282
|
+
start_existing_computer(*args, **options, recover: false)
|
|
268
283
|
end
|
|
269
284
|
|
|
270
285
|
# Gracefully stop the binding observed for this ID, even when a caller lost
|
|
271
|
-
# its Start response before saving the
|
|
286
|
+
# its Start response before saving the Computer token. Never follow a newer
|
|
272
287
|
# binding during this invocation; project authorization is sufficient.
|
|
273
288
|
def stop_computer(computer_id, **wait_options)
|
|
274
289
|
raise ArgumentError, "id must be a lowercase UUIDv7" unless computer_id.to_s.match?(Computer::UUID_V7)
|
|
@@ -277,27 +292,27 @@ module PineSandbox
|
|
|
277
292
|
raise ArgumentError, "Stop by ID requires a provider with current_binding"
|
|
278
293
|
end
|
|
279
294
|
reference = provider.current_binding(computer_id)
|
|
280
|
-
|
|
281
|
-
return true if
|
|
295
|
+
sandbox_id = reference["sandbox_id"]
|
|
296
|
+
return true if sandbox_id.nil?
|
|
282
297
|
|
|
283
|
-
|
|
298
|
+
SandboxHandle.new(control_plane: @connection.control_plane, sandbox_id: sandbox_id).stop(**wait_options)
|
|
284
299
|
rescue UnknownComputerError
|
|
285
300
|
true
|
|
286
301
|
end
|
|
287
302
|
|
|
288
|
-
# Explicitly
|
|
289
|
-
# committed state. This loses unsaved memory; it never replaces a healthy
|
|
303
|
+
# Explicitly shut down a Computer that failed, wait for the shutdown, then
|
|
304
|
+
# Start from committed state. This loses unsaved memory; it never replaces a healthy
|
|
290
305
|
# paused or running Computer and never silently follows failed Resume.
|
|
291
306
|
def recover_computer(*args, **options)
|
|
292
307
|
if args.first.is_a?(Computer::Credentials)
|
|
293
308
|
credentials, *args = args
|
|
294
309
|
options = credentials_options(credentials, options)
|
|
295
310
|
end
|
|
296
|
-
start_existing_computer(*args, **options,
|
|
311
|
+
start_existing_computer(*args, **options, recover: true)
|
|
297
312
|
end
|
|
298
313
|
|
|
299
314
|
def start_existing_computer(positional_id = nil, id: nil, key:, capture_keypair: nil, prior_capture_keypairs: [],
|
|
300
|
-
binding_revision: 0, prior_keys: {}, ephemeral: false, location: nil,
|
|
315
|
+
binding_revision: 0, prior_keys: {}, ephemeral: false, location: nil, recover: false, **attach_opts)
|
|
301
316
|
if positional_id && id && positional_id != id
|
|
302
317
|
raise ArgumentError, "attach_computer got two different ids (positional and id:)"
|
|
303
318
|
end
|
|
@@ -305,22 +320,22 @@ module PineSandbox
|
|
|
305
320
|
raise ArgumentError, "attach_computer requires the Computer id" if id.nil?
|
|
306
321
|
|
|
307
322
|
provider = @connection.attach_provider
|
|
308
|
-
if
|
|
323
|
+
if recover && !provider.respond_to?(:current_binding)
|
|
309
324
|
raise ArgumentError, "saved-state recovery requires a provider with current_binding"
|
|
310
325
|
end
|
|
311
326
|
if provider.respond_to?(:current_binding)
|
|
312
327
|
reference = provider.current_binding(id)
|
|
313
328
|
binding_revision = reference.fetch("binding_revision")
|
|
314
|
-
if (
|
|
329
|
+
if (sandbox_id = reference["sandbox_id"])
|
|
315
330
|
begin
|
|
316
|
-
|
|
317
|
-
unless
|
|
318
|
-
unless
|
|
319
|
-
raise
|
|
331
|
+
sandbox = @connection.control_plane.get(sandbox_id)
|
|
332
|
+
unless sandbox.status == :terminated && sandbox.reason == "COMPUTE_RELEASED"
|
|
333
|
+
unless recover || sandbox.status == :stopping
|
|
334
|
+
raise SandboxRetainedError.new(sandbox_id: sandbox_id, state: sandbox.status)
|
|
320
335
|
end
|
|
321
|
-
handle =
|
|
322
|
-
unless handle.
|
|
323
|
-
raise TimeoutError, "
|
|
336
|
+
handle = SandboxHandle.new(control_plane: @connection.control_plane, sandbox_id: sandbox_id)
|
|
337
|
+
unless handle.release_failed_sandbox(sandbox)
|
|
338
|
+
raise TimeoutError, "The failed Computer's shutdown is unconfirmed; nothing was started"
|
|
324
339
|
end
|
|
325
340
|
end
|
|
326
341
|
rescue NotFoundError
|
|
@@ -343,14 +358,14 @@ module PineSandbox
|
|
|
343
358
|
alias_method :attach_computer, :start_computer
|
|
344
359
|
|
|
345
360
|
# Adopt an ALREADY-bound, still-live Computer from its persisted `{id, key,
|
|
346
|
-
#
|
|
361
|
+
# sandbox_id, ct_}` — NO new start, NO re-bind. For driving a running Computer
|
|
347
362
|
# across requests (e.g. re-minting a desktop `dt_` for a reconnect) from a
|
|
348
|
-
# backend that cached the `ct_`. Use #start_computer to
|
|
349
|
-
#
|
|
350
|
-
def adopt_existing(id:, key:, computer_token:,
|
|
363
|
+
# backend that cached the `ct_`. Use #start_computer to start it again with state
|
|
364
|
+
# restored.
|
|
365
|
+
def adopt_existing(id:, key:, sandbox_id:, computer_token:, status: :running,
|
|
351
366
|
binding_revision: 0, ephemeral: false)
|
|
352
367
|
Computer.new(id, key: key, binding_revision: binding_revision, ephemeral: ephemeral)
|
|
353
|
-
.adopt(@connection,
|
|
368
|
+
.adopt(@connection, sandbox_id: sandbox_id, computer_token: computer_token,
|
|
354
369
|
status: status, binding_revision: binding_revision)
|
|
355
370
|
end
|
|
356
371
|
|
|
@@ -366,7 +381,7 @@ module PineSandbox
|
|
|
366
381
|
raise ArgumentError, "credentials and binding belong to different Computers"
|
|
367
382
|
end
|
|
368
383
|
computer = adopt_existing(id: credentials.id.dup, key: credentials.key.dup,
|
|
369
|
-
|
|
384
|
+
sandbox_id: binding.sandbox_id.dup, computer_token: binding.computer_token.dup,
|
|
370
385
|
binding_revision: binding.binding_revision, ephemeral: credentials.ephemeral)
|
|
371
386
|
configure_capture_keypairs(computer, credentials.capture_keypair, credentials.prior_capture_keypairs)
|
|
372
387
|
credentials.prior_keys.each { |version, key| computer.add_prior_key(version, key.dup) }
|
|
@@ -374,18 +389,41 @@ module PineSandbox
|
|
|
374
389
|
end
|
|
375
390
|
|
|
376
391
|
# Block form: yields a ready Computer and stops it on exit.
|
|
377
|
-
# Failed saves and unconfirmed deletion are raised to the caller.
|
|
392
|
+
# Failed saves and unconfirmed deletion are raised to the caller. When the
|
|
393
|
+
# block raises, its error propagates and a failed Stop is only warned about.
|
|
378
394
|
def with_computer(**opts)
|
|
379
395
|
computer = create_computer(**opts)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
396
|
+
failure = nil
|
|
397
|
+
begin
|
|
398
|
+
yield computer
|
|
399
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised
|
|
400
|
+
failure = e
|
|
401
|
+
raise
|
|
402
|
+
ensure
|
|
403
|
+
# Also runs when the block leaves early with return, break or throw.
|
|
404
|
+
if failure
|
|
405
|
+
stop_after_failure(computer, failure)
|
|
406
|
+
elsif !computer.stop
|
|
407
|
+
raise TimeoutError, "Computer #{computer.id} stop is unconfirmed; retry Stop with its saved binding"
|
|
408
|
+
end
|
|
384
409
|
end
|
|
385
410
|
end
|
|
386
411
|
|
|
387
412
|
private
|
|
388
413
|
|
|
414
|
+
# Stop after with_computer's block failed: the block's error is the one the
|
|
415
|
+
# caller gets, so a Stop that fails or stays unconfirmed is only reported.
|
|
416
|
+
# The failure is recorded where the block raised, never read from $!, which
|
|
417
|
+
# inside a caller's own rescue clause is the exception that clause handles.
|
|
418
|
+
def stop_after_failure(computer, failure)
|
|
419
|
+
return if computer.stop
|
|
420
|
+
|
|
421
|
+
warn "PineSandbox: Computer #{computer.id} stop is unconfirmed; retry Stop with its saved binding " \
|
|
422
|
+
"(while #{failure.class} propagates)"
|
|
423
|
+
rescue StandardError => e
|
|
424
|
+
warn "PineSandbox: Computer #{computer.id} stop failed (#{e.class}: #{e.message}) while #{failure.class} propagates"
|
|
425
|
+
end
|
|
426
|
+
|
|
389
427
|
def credentials_options(credentials, options)
|
|
390
428
|
unless credentials.is_a?(Computer::Credentials)
|
|
391
429
|
raise ArgumentError, "credentials must be a PineSandbox::Computer::Credentials"
|
|
@@ -401,13 +439,13 @@ module PineSandbox
|
|
|
401
439
|
computer.attach(@connection, **attach_opts)
|
|
402
440
|
rescue StandardError => e
|
|
403
441
|
revision = computer.binding_revision
|
|
404
|
-
|
|
405
|
-
raise if revision <= initial_revision ||
|
|
442
|
+
sandbox_id = computer.last_authorized_sandbox_id
|
|
443
|
+
raise if revision <= initial_revision || sandbox_id.to_s.empty?
|
|
406
444
|
|
|
407
445
|
raise AttachAuthorizationCommittedError.new(
|
|
408
|
-
"attach authorization committed at revision #{revision} for
|
|
446
|
+
"attach authorization committed at revision #{revision} for Computer sandbox #{sandbox_id}, " \
|
|
409
447
|
"but the bind failed: #{e.message}",
|
|
410
|
-
binding_revision: revision,
|
|
448
|
+
binding_revision: revision, sandbox_id: sandbox_id,
|
|
411
449
|
credentials: recovery_credentials,
|
|
412
450
|
status: (e.respond_to?(:status) ? e.status : nil), cause: e
|
|
413
451
|
)
|