microsandbox-rb 0.5.9 → 0.5.10

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.
@@ -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
@@ -358,33 +455,39 @@ module Microsandbox
358
455
  # @param env [Hash, nil] extra environment variables
359
456
  # @param timeout [Numeric, nil] kill after N seconds
360
457
  # @param tty [Boolean] allocate a pseudo-terminal
361
- # @param stdin [String, nil] data to feed to stdin
458
+ # @param stdin [String, Symbol, nil] bytes to feed to stdin, or +:pipe+ to
459
+ # open a streaming stdin pipe (write/close it via {ExecHandle#stdin}; only
460
+ # useful with the streaming variants)
362
461
  # @return [ExecOutput]
363
462
  def exec(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
364
463
  ExecOutput.new(@native.exec(command.to_s, Array(args).map(&:to_s),
365
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
464
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
366
465
  end
367
466
 
368
467
  # Run a shell script (pipes, redirects, etc. allowed) and collect output.
369
468
  # @return [ExecOutput]
370
469
  def shell(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
371
470
  ExecOutput.new(@native.shell(script.to_s,
372
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
471
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
373
472
  end
374
473
 
375
474
  # Run a command and stream its output as it arrives.
475
+ #
476
+ # Pass +stdin: :pipe+ to feed the process interactively: {ExecHandle#stdin}
477
+ # then returns a writable sink; close it to send EOF (a process like +cat+
478
+ # that reads until EOF will otherwise block forever).
376
479
  # @return [ExecHandle]
377
480
  # @see ExecHandle
378
481
  def exec_stream(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
379
482
  ExecHandle.new(@native.exec_stream(command.to_s, Array(args).map(&:to_s),
380
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
483
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)))
381
484
  end
382
485
 
383
486
  # Run a shell script and stream its output as it arrives.
384
487
  # @return [ExecHandle]
385
488
  def shell_stream(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
386
489
  ExecHandle.new(@native.shell_stream(script.to_s,
387
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
490
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)))
388
491
  end
389
492
 
390
493
  # Attach an interactive terminal to a command in the sandbox.
@@ -469,7 +572,7 @@ module Microsandbox
469
572
  # @param interval [Numeric] seconds between snapshots
470
573
  # @return [MetricsStream] an {Enumerable} of {Metrics}
471
574
  def metrics_stream(interval: 1.0)
472
- MetricsStream.new(@native.metrics_stream(Float(interval)))
575
+ MetricsStream.new(@native.metrics_stream(coerce_duration(interval, "interval")))
473
576
  end
474
577
 
475
578
  # Stream captured logs as they appear.
@@ -492,48 +595,46 @@ module Microsandbox
492
595
  LogStream.new(@native.log_stream(opts))
493
596
  end
494
597
 
495
- # Gracefully stop the sandbox (and wait for it to terminate).
496
- # @param timeout [Numeric, nil] seconds to wait before SIGKILL
598
+ # Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default) and
599
+ # wait for it to terminate. For a custom timeout or fire-and-return
600
+ # `request_*` control, fetch a {SandboxHandle} via {Sandbox.get}.
497
601
  # @return [nil]
498
- def stop(timeout: nil)
499
- @native.stop(timeout && Float(timeout))
602
+ def stop
603
+ @native.stop
500
604
  nil
501
605
  end
502
606
 
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
607
+ # Gracefully stop, then wait for the process to exit.
608
+ # @return [ExitStatus]
609
+ def stop_and_wait
610
+ ExitStatus.new(@native.stop_and_wait)
509
611
  end
510
612
 
511
- # Send the graceful-shutdown request and return immediately, without waiting
512
- # for the sandbox to terminate. Pair with {#wait_until_stopped}.
613
+ # Force-kill the sandbox (SIGKILL).
513
614
  # @return [nil]
514
- def request_stop
515
- @native.request_stop
615
+ def kill
616
+ @native.kill
516
617
  nil
517
618
  end
518
619
 
519
- # Send the force-kill request and return immediately, without waiting.
620
+ # Trigger a graceful drain (SIGUSR1).
520
621
  # @return [nil]
521
- def request_kill
522
- @native.request_kill
622
+ def drain
623
+ @native.drain
523
624
  nil
524
625
  end
525
626
 
526
- # Request a graceful drain and return immediately, without waiting.
527
- # @return [nil]
528
- def request_drain
529
- @native.request_drain
530
- nil
627
+ # Wait for the sandbox process to exit.
628
+ # @return [ExitStatus]
629
+ def wait
630
+ ExitStatus.new(@native.wait)
531
631
  end
532
632
 
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)
633
+ # The live status, fetched from the backend (a round-trip per call).
634
+ # @return [Symbol] :created, :starting, :running, :draining, :paused,
635
+ # :stopped, or :crashed
636
+ def status
637
+ @native.status.to_sym
537
638
  end
538
639
 
539
640
  # @return [Boolean] whether this handle owns the sandbox process lifecycle
@@ -556,14 +657,37 @@ module Microsandbox
556
657
 
557
658
  private
558
659
 
559
- def exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)
660
+ # Instance-side shim for the shared, class-private duration validator (used
661
+ # by #exec/#shell timeouts, #stop/#kill, and #metrics_stream).
662
+ def coerce_duration(value, label)
663
+ self.class.send(:coerce_duration, value, label)
664
+ end
665
+
666
+ def exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: false)
560
667
  opts = {}
561
668
  opts["cwd"] = cwd.to_s if cwd
562
669
  opts["user"] = user.to_s if user
563
670
  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
671
+ opts["timeout"] = coerce_duration(timeout, "timeout") if timeout
565
672
  opts["tty"] = true if tty
566
- opts["stdin"] = stdin.to_s if stdin
673
+ # `stdin: :pipe` opens a streaming stdin pipe — write to it via
674
+ # {ExecHandle#stdin} and close to send EOF. It is only meaningful for the
675
+ # streaming variants (which return an ExecHandle); a blocking exec/shell
676
+ # collects to completion and has nowhere to hand back the sink, so a piped
677
+ # process that reads stdin would block forever waiting for EOF. Reject it
678
+ # there. Any other truthy value is fed as a fixed byte buffer (closed
679
+ # automatically). nil means no stdin.
680
+ case stdin
681
+ when nil then nil
682
+ when :pipe
683
+ unless pipe_ok
684
+ raise ArgumentError,
685
+ "stdin: :pipe is only valid for exec_stream/shell_stream — a blocking " \
686
+ "exec/shell cannot expose a writable stdin sink; pass a String to feed bytes"
687
+ end
688
+ opts["stdin_pipe"] = true
689
+ else opts["stdin"] = stdin.to_s
690
+ end
567
691
  if rlimits
568
692
  opts["rlimits"] = rlimits.map do |resource, limit|
569
693
  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.10"
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
data/lib/microsandbox.rb CHANGED
@@ -78,6 +78,11 @@ module Microsandbox
78
78
  # @return [nil]
79
79
  def ensure_runtime!
80
80
  return if @runtime_ready
81
+ # A cloud backend has no local msb/libkrunfw runtime to provision: skip the
82
+ # presence check and the first-use download entirely. Resolving the kind
83
+ # uses the same lazy env/profile/config ladder every operation already
84
+ # consults, so this adds no work for local hosts (the common case).
85
+ return if default_backend_kind == :cloud
81
86
  if installed?
82
87
  @runtime_ready = true
83
88
  return
@@ -104,6 +109,62 @@ module Microsandbox
104
109
  Native.set_runtime_msb_path(path.to_s)
105
110
  end
106
111
 
112
+ # Override the `libkrunfw` shared-library path (SDK tier of the resolver,
113
+ # below the `MSB_LIBKRUNFW_PATH` environment variable). Process-level and
114
+ # set-once: a second call is silently ignored, and the env var still wins.
115
+ # Mirrors {runtime_path=} for libkrunfw.
116
+ # @param path [String]
117
+ # @return [void]
118
+ def libkrunfw_path=(path)
119
+ Native.set_runtime_libkrunfw_path(path.to_s)
120
+ end
121
+
122
+ # Install a process-wide default backend (v0.5.8 backend routing). Without a
123
+ # call to this, operations use a local libkrun backend; the env/profile
124
+ # ladder (`MSB_BACKEND`, `MSB_API_URL`+`MSB_API_KEY`, `MSB_PROFILE`,
125
+ # `~/.microsandbox/config.json`) is resolved lazily on first use. Call once
126
+ # at startup, before any sandbox operations.
127
+ #
128
+ # @param kind ["local","cloud", Symbol] backend kind
129
+ # @param url [String, nil] cloud control-plane URL (cloud, unless `profile:`)
130
+ # @param api_key [String, nil] cloud API key (cloud, unless `profile:`)
131
+ # @param profile [String, nil] named profile from `~/.microsandbox/config.json`
132
+ # @return [void]
133
+ def set_default_backend(kind, url: nil, api_key: nil, profile: nil)
134
+ Native.set_default_backend(kind.to_s, url&.to_s, api_key&.to_s, profile&.to_s)
135
+ end
136
+
137
+ # Run the given block with a temporary default backend, restoring the
138
+ # previous one afterward (even on error). NOTE: the swap is process-wide
139
+ # while the block runs, not fiber/thread-local — concurrent threads observe
140
+ # the temporary backend. It is also NOT safe to call from multiple threads
141
+ # at once: two interleaved `with_backend` calls can restore each other's
142
+ # saved backend out of order and leave a temporary backend installed
143
+ # permanently. Use it only when no other thread is changing the backend, and
144
+ # avoid calling {set_default_backend} inside the block (the restore on exit
145
+ # would overwrite that change). Mirrors the official SDKs' scoped-backend helper.
146
+ #
147
+ # @param kind ["local","cloud", Symbol]
148
+ # @param url [String, nil]
149
+ # @param api_key [String, nil]
150
+ # @param profile [String, nil]
151
+ # @yield with the temporary backend installed
152
+ # @return [Object] the block's return value
153
+ def with_backend(kind, url: nil, api_key: nil, profile: nil)
154
+ token = Native.push_default_backend(kind.to_s, url&.to_s, api_key&.to_s, profile&.to_s)
155
+ begin
156
+ yield
157
+ ensure
158
+ Native.pop_default_backend(token)
159
+ end
160
+ end
161
+
162
+ # @return [Symbol] the active default backend kind, :local or :cloud.
163
+ # The first call resolves the env/profile/config ladder.
164
+ def default_backend_kind
165
+ Native.default_backend_kind.to_sym
166
+ end
167
+
107
168
  # Latest resource-usage snapshot for every running sandbox, keyed by name.
108
169
  # Mirrors the official `all_sandbox_metrics`/`allSandboxMetrics` helpers.
109
170
  # @return [Hash{String => Metrics}]