microsandbox-rb 0.5.9 → 0.5.11

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.
@@ -31,7 +31,7 @@ module Microsandbox
31
31
  # @param replace [Boolean] allow shadowing a path already in the rootfs
32
32
  # @return [Hash]
33
33
  def text(path, content, mode: nil, replace: false)
34
- h = { "kind" => "text", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false }
34
+ h = {"kind" => "text", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false}
35
35
  h["mode"] = Integer(mode) unless mode.nil?
36
36
  h
37
37
  end
@@ -43,7 +43,7 @@ module Microsandbox
43
43
  # @param replace [Boolean]
44
44
  # @return [Hash]
45
45
  def file(path, content, mode: nil, replace: false)
46
- h = { "kind" => "file", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false }
46
+ h = {"kind" => "file", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false}
47
47
  h["mode"] = Integer(mode) unless mode.nil?
48
48
  h
49
49
  end
@@ -52,7 +52,7 @@ module Microsandbox
52
52
  # lower image layer is copied up first, then appended.
53
53
  # @return [Hash]
54
54
  def append(path, content)
55
- { "kind" => "append", "path" => path.to_s, "content" => content.to_s }
55
+ {"kind" => "append", "path" => path.to_s, "content" => content.to_s}
56
56
  end
57
57
 
58
58
  # Copy a host file into the rootfs.
@@ -62,7 +62,7 @@ module Microsandbox
62
62
  # @param replace [Boolean]
63
63
  # @return [Hash]
64
64
  def copy_file(src, dst, mode: nil, replace: false)
65
- h = { "kind" => "copy_file", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false }
65
+ h = {"kind" => "copy_file", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false}
66
66
  h["mode"] = Integer(mode) unless mode.nil?
67
67
  h
68
68
  end
@@ -70,13 +70,13 @@ module Microsandbox
70
70
  # Copy a host directory (recursively) into the rootfs.
71
71
  # @return [Hash]
72
72
  def copy_dir(src, dst, replace: false)
73
- { "kind" => "copy_dir", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false }
73
+ {"kind" => "copy_dir", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false}
74
74
  end
75
75
 
76
76
  # Create a symlink at +link+ pointing to +target+.
77
77
  # @return [Hash]
78
78
  def symlink(target, link, replace: false)
79
- { "kind" => "symlink", "target" => target.to_s, "link" => link.to_s, "replace" => replace ? true : false }
79
+ {"kind" => "symlink", "target" => target.to_s, "link" => link.to_s, "replace" => replace ? true : false}
80
80
  end
81
81
 
82
82
  # Create a directory (idempotent — no error if it already exists).
@@ -84,7 +84,7 @@ module Microsandbox
84
84
  # @param mode [Integer, nil] directory mode (e.g. 0o755)
85
85
  # @return [Hash]
86
86
  def mkdir(path, mode: nil)
87
- h = { "kind" => "mkdir", "path" => path.to_s }
87
+ h = {"kind" => "mkdir", "path" => path.to_s}
88
88
  h["mode"] = Integer(mode) unless mode.nil?
89
89
  h
90
90
  end
@@ -92,7 +92,7 @@ module Microsandbox
92
92
  # Remove a file or directory (idempotent — no error if absent).
93
93
  # @return [Hash]
94
94
  def remove(path)
95
- { "kind" => "remove", "path" => path.to_s }
95
+ {"kind" => "remove", "path" => path.to_s}
96
96
  end
97
97
  end
98
98
  end
@@ -1,41 +1,119 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Microsandbox
4
- # Lightweight metadata about a sandbox, returned by {Sandbox.get} and
5
- # {Sandbox.list}. This is a snapshot, not a live handle.
6
- class SandboxInfo
4
+ # A controllable handle to a sandbox, returned by {Sandbox.get}, {Sandbox.list},
5
+ # and {Sandbox.list_with}. Carries a metadata snapshot (captured when fetched)
6
+ # plus the fine-grained lifecycle surface — `stop_with_timeout`, `request_stop`,
7
+ # `request_kill`, `request_drain`, `wait_until_stopped` — that mirrors the
8
+ # official SDKs' `SandboxHandle`. (The live {Sandbox} from {Sandbox.create}/
9
+ # {Sandbox.start} carries only the high-level `stop`/`kill`/`drain`/`wait`.)
10
+ #
11
+ # As of v0.5.8 this replaces the old read-only `SandboxInfo` (kept as a
12
+ # deprecated constant alias); `#status` here is a synchronous snapshot.
13
+ class SandboxHandle
14
+ def initialize(native)
15
+ @native = native
16
+ end
17
+
7
18
  # @return [String]
8
- attr_reader :name
9
- # @return [Symbol] :running, :draining, :paused, :stopped, or :crashed
10
- attr_reader :status
19
+ def name = @native.name
11
20
 
12
- def initialize(data)
13
- @name = data["name"]
14
- @status = data["status"].to_sym
15
- @created_at_ms = data["created_at_ms"]
16
- @updated_at_ms = data["updated_at_ms"]
17
- end
21
+ # @return [Symbol] :created, :starting, :running, :draining, :paused,
22
+ # :stopped, or :crashed (a snapshot, captured when this handle was fetched)
23
+ def status = @native.status.to_sym
18
24
 
19
- def running? = @status == :running
20
- def stopped? = @status == :stopped
25
+ # Whether the fetch-time {#status} snapshot is `:running` / `:stopped`.
26
+ # Like {#status}, these do NOT refresh: to observe a state change after
27
+ # {#request_stop}/{#request_kill}/{#request_drain}, use {#wait_until_stopped}
28
+ # or re-fetch the handle with {Sandbox.get}.
29
+ def running? = status == :running
30
+ def stopped? = status == :stopped
21
31
 
22
32
  # @return [Time, nil]
23
33
  def created_at
24
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
34
+ ms = @native.created_at_ms
35
+ ms && Time.at(ms / 1000.0)
25
36
  end
26
37
 
27
38
  # @return [Time, nil]
28
39
  def updated_at
29
- @updated_at_ms && Time.at(@updated_at_ms / 1000.0)
40
+ ms = @native.updated_at_ms
41
+ ms && Time.at(ms / 1000.0)
42
+ end
43
+
44
+ # Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default).
45
+ # @return [nil]
46
+ def stop
47
+ @native.stop
48
+ nil
49
+ end
50
+
51
+ # Gracefully stop with a custom escalation timeout.
52
+ # @param timeout [Numeric] seconds to wait before escalating to SIGKILL
53
+ # @return [nil]
54
+ def stop_with_timeout(timeout)
55
+ @native.stop_with_timeout(Sandbox.send(:coerce_duration, timeout, "timeout"))
56
+ nil
57
+ end
58
+
59
+ # Force-kill the sandbox (SIGKILL).
60
+ # @return [nil]
61
+ def kill
62
+ @native.kill
63
+ nil
64
+ end
65
+
66
+ # Force-kill, waiting up to `timeout` seconds for the process to disappear.
67
+ # @param timeout [Numeric]
68
+ # @return [nil]
69
+ def kill_with_timeout(timeout)
70
+ @native.kill_with_timeout(Sandbox.send(:coerce_duration, timeout, "timeout"))
71
+ nil
72
+ end
73
+
74
+ # Send the graceful-shutdown request and return immediately, without waiting.
75
+ # Pair with {#wait_until_stopped}.
76
+ # @return [nil]
77
+ def request_stop
78
+ @native.request_stop
79
+ nil
80
+ end
81
+
82
+ # Send the force-kill request and return immediately, without waiting.
83
+ # @return [nil]
84
+ def request_kill
85
+ @native.request_kill
86
+ nil
87
+ end
88
+
89
+ # Request a graceful drain (SIGUSR1) and return immediately, without waiting.
90
+ # @return [nil]
91
+ def request_drain
92
+ @native.request_drain
93
+ nil
94
+ end
95
+
96
+ # Block until the sandbox is observed in a terminal (non-running) state.
97
+ # @return [SandboxStopResult]
98
+ def wait_until_stopped
99
+ SandboxStopResult.new(@native.wait_until_stopped)
30
100
  end
31
101
 
32
102
  def inspect
33
- "#<Microsandbox::SandboxInfo name=#{@name.inspect} status=#{@status}>"
103
+ "#<Microsandbox::SandboxHandle name=#{name.inspect} status=#{status}>"
34
104
  end
35
105
  end
36
106
 
107
+ # @deprecated since v0.5.8. {Sandbox.get}/{Sandbox.list} now return a
108
+ # controllable {SandboxHandle}; this constant remains as an alias so code
109
+ # that referenced the old read-only metadata type by name (e.g. `is_a?`
110
+ # checks) still resolves. Note it is now the same class as {SandboxHandle},
111
+ # whose constructor takes a native handle, not the metadata Hash the old
112
+ # `SandboxInfo.new` accepted — construct via {Sandbox.get}/{Sandbox.list}.
113
+ SandboxInfo = SandboxHandle
114
+
37
115
  # The terminal observation of a stopped sandbox, returned by
38
- # {Sandbox#wait_until_stopped}. Mirrors the official SDKs' `SandboxStopResult`.
116
+ # {SandboxHandle#wait_until_stopped}. Mirrors the official SDKs' `SandboxStopResult`.
39
117
  class SandboxStopResult
40
118
  # @return [String]
41
119
  attr_reader :name
@@ -67,7 +145,7 @@ module Microsandbox
67
145
 
68
146
  def inspect
69
147
  "#<Microsandbox::SandboxStopResult name=#{@name.inspect} status=#{@status}" \
70
- "#{@exit_code ? " exit_code=#{@exit_code}" : ""}#{@signal ? " signal=#{@signal}" : ""}>"
148
+ "#{" exit_code=#{@exit_code}" if @exit_code}#{" signal=#{@signal}" if @signal}>"
71
149
  end
72
150
  end
73
151
 
@@ -143,15 +221,21 @@ module Microsandbox
143
221
  # @yieldparam sandbox [Sandbox]
144
222
  # @return [Sandbox, Object] the sandbox, or the block's return value
145
223
  def create(name,
146
- image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
147
- shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
148
- entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
149
- patches: nil,
150
- from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
151
- oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
152
- pull_policy: nil, registry_auth: nil, registry_insecure: false,
153
- registry_ca_certs: nil, secrets: nil,
154
- detached: false, replace: false, replace_with_timeout: nil)
224
+ image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
225
+ shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
226
+ entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
227
+ patches: nil,
228
+ from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
229
+ oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
230
+ pull_policy: nil, registry_auth: nil, registry_insecure: false,
231
+ registry_ca_certs: nil, secrets: nil,
232
+ detached: false, replace: false, replace_with_timeout: nil)
233
+ # A sandbox boots from exactly one rootfs source. The core would reject a
234
+ # contradictory pair, but only after a runtime round-trip; fail fast and
235
+ # clearly here (the Python SDK validates this the same way).
236
+ if image && from_snapshot
237
+ raise ArgumentError, "provide either image: or from_snapshot:, not both"
238
+ end
155
239
  Microsandbox.ensure_runtime!
156
240
  opts = {}
157
241
  opts["image"] = image.to_s if image
@@ -183,7 +267,7 @@ module Microsandbox
183
267
  opts["secrets"] = normalize_secrets(secrets) if secrets
184
268
  opts["detached"] = true if detached
185
269
  if replace_with_timeout
186
- opts["replace_with_timeout"] = Float(replace_with_timeout)
270
+ opts["replace_with_timeout"] = coerce_duration(replace_with_timeout, "replace_with_timeout")
187
271
  elsif replace
188
272
  opts["replace"] = true
189
273
  end
@@ -206,27 +290,27 @@ module Microsandbox
206
290
  # @return [Sandbox]
207
291
  def start(name, detached: false)
208
292
  Microsandbox.ensure_runtime!
209
- new(Native::Sandbox.start(name.to_s, { "detached" => detached }))
293
+ new(Native::Sandbox.start(name.to_s, {"detached" => detached}))
210
294
  end
211
295
 
212
- # Fetch metadata for a sandbox by name.
213
- # @return [SandboxInfo]
296
+ # Fetch a controllable handle for a sandbox by name (running or not).
297
+ # @return [SandboxHandle]
214
298
  def get(name)
215
- SandboxInfo.new(Native::Sandbox.get(name.to_s))
299
+ SandboxHandle.new(Native::Sandbox.get(name.to_s))
216
300
  end
217
301
 
218
- # List all sandboxes.
219
- # @return [Array<SandboxInfo>]
302
+ # List all sandboxes as controllable handles.
303
+ # @return [Array<SandboxHandle>]
220
304
  def list
221
- Native::Sandbox.list.map { |info| SandboxInfo.new(info) }
305
+ Native::Sandbox.list.map { |h| SandboxHandle.new(h) }
222
306
  end
223
307
 
224
308
  # List sandboxes carrying all of the given labels (AND-matched).
225
309
  # @param labels [Hash] required key => value labels
226
- # @return [Array<SandboxInfo>]
310
+ # @return [Array<SandboxHandle>]
227
311
  def list_with(labels: {})
228
- opts = { "labels" => stringify(labels) }
229
- Native::Sandbox.list_with(opts).map { |info| SandboxInfo.new(info) }
312
+ opts = {"labels" => stringify(labels)}
313
+ Native::Sandbox.list_with(opts).map { |h| SandboxHandle.new(h) }
230
314
  end
231
315
 
232
316
  # Remove a (stopped) sandbox by name.
@@ -238,6 +322,19 @@ module Microsandbox
238
322
 
239
323
  private
240
324
 
325
+ # Coerce a seconds value to a finite, non-negative Float. Rejects negatives,
326
+ # NaN, and infinities *here* (a clean ArgumentError) rather than letting them
327
+ # reach the native layer, where `Duration::from_secs_f64` panics across the
328
+ # FFI boundary on exactly those inputs. Shared by every duration option.
329
+ def coerce_duration(value, label)
330
+ seconds = Float(value)
331
+ unless seconds.finite? && seconds >= 0
332
+ raise ArgumentError,
333
+ "#{label} must be a finite, non-negative number of seconds (got #{value.inspect})"
334
+ end
335
+ seconds
336
+ end
337
+
241
338
  def stringify(hash)
242
339
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
243
340
  end
@@ -256,7 +353,7 @@ module Microsandbox
256
353
  unless username && password
257
354
  # Report only the keys given, never the values — auth carries secrets.
258
355
  raise ArgumentError,
259
- "registry_auth needs :username and :password (got keys: #{auth.keys.inspect})"
356
+ "registry_auth needs :username and :password (got keys: #{auth.keys.inspect})"
260
357
  end
261
358
  opts["registry_username"] = username.to_s
262
359
  opts["registry_password"] = password.to_s
@@ -290,8 +387,10 @@ module Microsandbox
290
387
  end
291
388
 
292
389
  # Normalize volumes (Hash of guest_path => spec) into [guest, kind, source]
293
- # triples for the native layer. A spec is a host path String (bind mount),
294
- # or a Hash { bind: "/host" } / { named: "volume-name" }.
390
+ # triples (or [guest, kind, source, options] quads) for the native layer. A
391
+ # spec is a host path String (read-write bind mount), or a Hash
392
+ # { bind: "/host" } / { named: "volume-name" } optionally carrying mount
393
+ # options: { bind: "/host", ro: true } / { named: "v", options: %w[ro noexec] }.
295
394
  def normalize_volumes(volumes)
296
395
  volumes.map do |guest, spec|
297
396
  guest = guest.to_s
@@ -299,19 +398,39 @@ module Microsandbox
299
398
  when String
300
399
  [guest, "bind", spec]
301
400
  when Hash
302
- if (named = spec[:named] || spec["named"])
303
- [guest, "named", named.to_s]
304
- elsif (bind = spec[:bind] || spec["bind"])
305
- [guest, "bind", bind.to_s]
306
- else
307
- raise ArgumentError, "volume spec for #{guest.inspect} needs :bind or :named"
308
- end
401
+ triple =
402
+ if (named = spec[:named] || spec["named"])
403
+ [guest, "named", named.to_s]
404
+ elsif (bind = spec[:bind] || spec["bind"])
405
+ [guest, "bind", bind.to_s]
406
+ else
407
+ raise ArgumentError, "volume spec for #{guest.inspect} needs :bind or :named"
408
+ end
409
+ # Optional 4th element: comma-joined mount options. Omitted when empty
410
+ # so existing [guest,kind,source] consumers and the common read-write
411
+ # case are byte-for-byte unchanged.
412
+ opts = mount_options(spec)
413
+ opts.empty? ? triple : triple + [opts.join(",")]
309
414
  else
310
415
  raise ArgumentError, "invalid volume spec for #{guest.inspect}: #{spec.inspect}"
311
416
  end
312
417
  end
313
418
  end
314
419
 
420
+ # Collect mount options from a volume spec Hash. `ro:`/`readonly:` makes the
421
+ # mount read-only (host virtiofs rejects writes + guest kernel returns EROFS);
422
+ # `noexec:`/`nosuid:`/`nodev:` set the matching flags; an explicit `options:`
423
+ # array passes through verbatim. Unknown options are rejected natively.
424
+ def mount_options(spec)
425
+ opts = []
426
+ opts << "ro" if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
427
+ opts << "noexec" if spec[:noexec] || spec["noexec"]
428
+ opts << "nosuid" if spec[:nosuid] || spec["nosuid"]
429
+ opts << "nodev" if spec[:nodev] || spec["nodev"]
430
+ Array(spec[:options] || spec["options"]).each { |o| opts << o.to_s }
431
+ opts.uniq
432
+ end
433
+
315
434
  # Normalize a list of patches (each a Hash from the {Patch} factory, or a
316
435
  # plain Hash) into string-keyed Hashes for the native layer. Values are
317
436
  # passed through unchanged (mode stays Integer, content stays String).
@@ -358,33 +477,39 @@ module Microsandbox
358
477
  # @param env [Hash, nil] extra environment variables
359
478
  # @param timeout [Numeric, nil] kill after N seconds
360
479
  # @param tty [Boolean] allocate a pseudo-terminal
361
- # @param stdin [String, nil] data to feed to stdin
480
+ # @param stdin [String, Symbol, nil] bytes to feed to stdin, or +:pipe+ to
481
+ # open a streaming stdin pipe (write/close it via {ExecHandle#stdin}; only
482
+ # useful with the streaming variants)
362
483
  # @return [ExecOutput]
363
484
  def exec(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
364
485
  ExecOutput.new(@native.exec(command.to_s, Array(args).map(&:to_s),
365
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
486
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
366
487
  end
367
488
 
368
489
  # Run a shell script (pipes, redirects, etc. allowed) and collect output.
369
490
  # @return [ExecOutput]
370
491
  def shell(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
371
492
  ExecOutput.new(@native.shell(script.to_s,
372
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
493
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
373
494
  end
374
495
 
375
496
  # Run a command and stream its output as it arrives.
497
+ #
498
+ # Pass +stdin: :pipe+ to feed the process interactively: {ExecHandle#stdin}
499
+ # then returns a writable sink; close it to send EOF (a process like +cat+
500
+ # that reads until EOF will otherwise block forever).
376
501
  # @return [ExecHandle]
377
502
  # @see ExecHandle
378
503
  def exec_stream(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
379
504
  ExecHandle.new(@native.exec_stream(command.to_s, Array(args).map(&:to_s),
380
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
505
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)))
381
506
  end
382
507
 
383
508
  # Run a shell script and stream its output as it arrives.
384
509
  # @return [ExecHandle]
385
510
  def shell_stream(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
386
511
  ExecHandle.new(@native.shell_stream(script.to_s,
387
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
512
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)))
388
513
  end
389
514
 
390
515
  # Attach an interactive terminal to a command in the sandbox.
@@ -469,7 +594,7 @@ module Microsandbox
469
594
  # @param interval [Numeric] seconds between snapshots
470
595
  # @return [MetricsStream] an {Enumerable} of {Metrics}
471
596
  def metrics_stream(interval: 1.0)
472
- MetricsStream.new(@native.metrics_stream(Float(interval)))
597
+ MetricsStream.new(@native.metrics_stream(coerce_duration(interval, "interval")))
473
598
  end
474
599
 
475
600
  # Stream captured logs as they appear.
@@ -492,48 +617,46 @@ module Microsandbox
492
617
  LogStream.new(@native.log_stream(opts))
493
618
  end
494
619
 
495
- # Gracefully stop the sandbox (and wait for it to terminate).
496
- # @param timeout [Numeric, nil] seconds to wait before SIGKILL
620
+ # Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default) and
621
+ # wait for it to terminate. For a custom timeout or fire-and-return
622
+ # `request_*` control, fetch a {SandboxHandle} via {Sandbox.get}.
497
623
  # @return [nil]
498
- def stop(timeout: nil)
499
- @native.stop(timeout && Float(timeout))
624
+ def stop
625
+ @native.stop
500
626
  nil
501
627
  end
502
628
 
503
- # Force-kill the sandbox (SIGKILL).
504
- # @param timeout [Numeric, nil] seconds to wait
505
- # @return [nil]
506
- def kill(timeout: nil)
507
- @native.kill(timeout && Float(timeout))
508
- nil
629
+ # Gracefully stop, then wait for the process to exit.
630
+ # @return [ExitStatus]
631
+ def stop_and_wait
632
+ ExitStatus.new(@native.stop_and_wait)
509
633
  end
510
634
 
511
- # Send the graceful-shutdown request and return immediately, without waiting
512
- # for the sandbox to terminate. Pair with {#wait_until_stopped}.
635
+ # Force-kill the sandbox (SIGKILL).
513
636
  # @return [nil]
514
- def request_stop
515
- @native.request_stop
637
+ def kill
638
+ @native.kill
516
639
  nil
517
640
  end
518
641
 
519
- # Send the force-kill request and return immediately, without waiting.
642
+ # Trigger a graceful drain (SIGUSR1).
520
643
  # @return [nil]
521
- def request_kill
522
- @native.request_kill
644
+ def drain
645
+ @native.drain
523
646
  nil
524
647
  end
525
648
 
526
- # Request a graceful drain and return immediately, without waiting.
527
- # @return [nil]
528
- def request_drain
529
- @native.request_drain
530
- nil
649
+ # Wait for the sandbox process to exit.
650
+ # @return [ExitStatus]
651
+ def wait
652
+ ExitStatus.new(@native.wait)
531
653
  end
532
654
 
533
- # Block until the sandbox is observed in a terminal (non-running) state.
534
- # @return [SandboxStopResult]
535
- def wait_until_stopped
536
- SandboxStopResult.new(@native.wait_until_stopped)
655
+ # The live status, fetched from the backend (a round-trip per call).
656
+ # @return [Symbol] :created, :starting, :running, :draining, :paused,
657
+ # :stopped, or :crashed
658
+ def status
659
+ @native.status.to_sym
537
660
  end
538
661
 
539
662
  # @return [Boolean] whether this handle owns the sandbox process lifecycle
@@ -556,14 +679,37 @@ module Microsandbox
556
679
 
557
680
  private
558
681
 
559
- def exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)
682
+ # Instance-side shim for the shared, class-private duration validator (used
683
+ # by #exec/#shell timeouts, #stop/#kill, and #metrics_stream).
684
+ def coerce_duration(value, label)
685
+ self.class.send(:coerce_duration, value, label)
686
+ end
687
+
688
+ def exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: false)
560
689
  opts = {}
561
690
  opts["cwd"] = cwd.to_s if cwd
562
691
  opts["user"] = user.to_s if user
563
692
  opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
564
- opts["timeout"] = Float(timeout) if timeout
693
+ opts["timeout"] = coerce_duration(timeout, "timeout") if timeout
565
694
  opts["tty"] = true if tty
566
- opts["stdin"] = stdin.to_s if stdin
695
+ # `stdin: :pipe` opens a streaming stdin pipe — write to it via
696
+ # {ExecHandle#stdin} and close to send EOF. It is only meaningful for the
697
+ # streaming variants (which return an ExecHandle); a blocking exec/shell
698
+ # collects to completion and has nowhere to hand back the sink, so a piped
699
+ # process that reads stdin would block forever waiting for EOF. Reject it
700
+ # there. Any other truthy value is fed as a fixed byte buffer (closed
701
+ # automatically). nil means no stdin.
702
+ case stdin
703
+ when nil then nil
704
+ when :pipe
705
+ unless pipe_ok
706
+ raise ArgumentError,
707
+ "stdin: :pipe is only valid for exec_stream/shell_stream — a blocking " \
708
+ "exec/shell cannot expose a writable stdin sink; pass a String to feed bytes"
709
+ end
710
+ opts["stdin_pipe"] = true
711
+ else opts["stdin"] = stdin.to_s
712
+ end
567
713
  if rlimits
568
714
  opts["rlimits"] = rlimits.map do |resource, limit|
569
715
  soft, hard = limit.is_a?(Array) ? [limit[0], limit[1]] : [limit, limit]
@@ -43,7 +43,7 @@ module Microsandbox
43
43
  end
44
44
 
45
45
  def inspect
46
- "#<Microsandbox::SnapshotInfo digest=#{@digest.inspect}#{@name ? " name=#{@name.inspect}" : ""}>"
46
+ "#<Microsandbox::SnapshotInfo digest=#{@digest.inspect}#{" name=#{@name.inspect}" if @name}>"
47
47
  end
48
48
  end
49
49
 
@@ -142,7 +142,7 @@ module Microsandbox
142
142
  # @param dest [String, nil] explicit destination directory
143
143
  # @return [SnapshotInfo]
144
144
  def import(archive_path, dest: nil)
145
- SnapshotInfo.new(Native::Snapshot.import(archive_path.to_s, dest && dest.to_s))
145
+ SnapshotInfo.new(Native::Snapshot.import(archive_path.to_s, dest&.to_s))
146
146
  end
147
147
 
148
148
  private
@@ -218,7 +218,7 @@ module Microsandbox
218
218
  # @yieldparam client [SshClient]
219
219
  # @return [SshClient, Object]
220
220
  def open_client(user: "root", term: nil, sftp: true)
221
- opts = { "user" => user.to_s, "sftp" => sftp ? true : false }
221
+ opts = {"user" => user.to_s, "sftp" => sftp ? true : false}
222
222
  opts["term"] = term.to_s if term
223
223
  client = SshClient.new(@native.ssh_open_client(opts))
224
224
  return client unless block_given?
@@ -237,7 +237,7 @@ module Microsandbox
237
237
  # @param sftp [Boolean] enable the SFTP subsystem (default true)
238
238
  # @return [SshServer]
239
239
  def prepare_server(host_key_path: nil, authorized_keys_path: nil, user: nil, sftp: true)
240
- opts = { "sftp" => sftp ? true : false }
240
+ opts = {"sftp" => sftp ? true : false}
241
241
  opts["host_key_path"] = host_key_path.to_s if host_key_path
242
242
  opts["authorized_keys_path"] = authorized_keys_path.to_s if authorized_keys_path
243
243
  opts["user"] = user.to_s if user
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Microsandbox
4
- # Gem version. Tracks the upstream microsandbox runtime (currently `v0.5.7`,
4
+ # Gem version. Tracks the upstream microsandbox runtime (currently `v0.5.8`,
5
5
  # the pinned core-crate tag); the patch segment advances for gem-only revisions
6
6
  # that add bindings atop the same core. Must equal the native ext's Cargo crate
7
7
  # version (`Native.version`), enforced by spec/unit/version_spec.rb.
8
- VERSION = "0.5.9"
8
+ VERSION = "0.5.11"
9
9
  end
@@ -7,7 +7,7 @@ module Microsandbox
7
7
  # (`kind`, `used_bytes`, …) are populated when returned from {Volume.get}/{Volume.list}.
8
8
  class VolumeInfo
9
9
  attr_reader :name, :path, :quota_mib, :used_bytes, :capacity_bytes,
10
- :disk_format, :disk_fstype, :labels
10
+ :disk_format, :disk_fstype, :labels
11
11
 
12
12
  def initialize(data)
13
13
  @name = data["name"]
@@ -33,7 +33,7 @@ module Microsandbox
33
33
  end
34
34
 
35
35
  def inspect
36
- "#<Microsandbox::VolumeInfo name=#{@name.inspect}#{@kind ? " kind=#{@kind}" : ""}>"
36
+ "#<Microsandbox::VolumeInfo name=#{@name.inspect}#{" kind=#{@kind}" if @kind}>"
37
37
  end
38
38
  end
39
39
 
@@ -49,7 +49,7 @@ module Microsandbox
49
49
  # @param labels [Hash, nil]
50
50
  # @return [VolumeInfo]
51
51
  def create(name, kind: "dir", size_mib: nil, quota_mib: nil, labels: nil)
52
- opts = { "kind" => kind.to_s }
52
+ opts = {"kind" => kind.to_s}
53
53
  opts["size_mib"] = Integer(size_mib) if size_mib
54
54
  opts["quota_mib"] = Integer(quota_mib) if quota_mib
55
55
  opts["labels"] = labels.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if labels