microsandbox-rb 0.11.0 → 0.13.0

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.
@@ -202,6 +202,48 @@ module Microsandbox
202
202
  end
203
203
  end
204
204
 
205
+ # One page of sandbox handles, returned by {Sandbox.list} and
206
+ # {Sandbox.list_with} — sandbox listing is cursor-paginated as of runtime
207
+ # v0.6.8. Enumerable over its {SandboxHandle}s; fetch the following page by
208
+ # passing {#next_cursor} to {Sandbox.list_with}.
209
+ #
210
+ # @example Walk every page
211
+ # page = Microsandbox::Sandbox.list
212
+ # loop do
213
+ # page.each { |h| puts h.name }
214
+ # break if page.last_page?
215
+ # page = Microsandbox::Sandbox.list_with(cursor: page.next_cursor)
216
+ # end
217
+ class SandboxPage
218
+ include Enumerable
219
+
220
+ # @return [Array<SandboxHandle>] the sandboxes in this page
221
+ attr_reader :sandboxes
222
+ # @return [String, nil] opaque cursor for the next page; nil on the final page
223
+ attr_reader :next_cursor
224
+
225
+ # @api private — construct via {Sandbox.list}/{Sandbox.list_with}.
226
+ def initialize(sandboxes, next_cursor)
227
+ @sandboxes = sandboxes
228
+ @next_cursor = next_cursor
229
+ end
230
+
231
+ def each(&) = @sandboxes.each(&)
232
+
233
+ # @return [Integer]
234
+ def size = @sandboxes.size
235
+ alias_method :length, :size
236
+
237
+ def empty? = @sandboxes.empty?
238
+
239
+ # Whether this is the final page (no cursor to continue from).
240
+ def last_page? = @next_cursor.nil?
241
+
242
+ def inspect
243
+ "#<Microsandbox::SandboxPage size=#{size} next_cursor=#{@next_cursor.inspect}>"
244
+ end
245
+ end
246
+
205
247
  # A running sandbox (microVM) — the primary entry point of the SDK.
206
248
  #
207
249
  # @example Block form (auto-stops on exit)
@@ -246,7 +288,14 @@ module Microsandbox
246
288
  # @param hostname [String, nil] guest hostname
247
289
  # @param labels [Hash, nil] metadata labels
248
290
  # @param scripts [Hash, nil] named scripts to install
249
- # @param entrypoint [Array<String>, nil] image entrypoint override
291
+ # @param entrypoint [Array<String>, nil] image ENTRYPOINT override. An
292
+ # explicit `[]` clears the image's ENTRYPOINT (so `exec_default` runs
293
+ # the CMD alone); `nil` (the default) inherits it.
294
+ # @param cmd [Array<String>, nil] image CMD override used by
295
+ # default-workload execution ({Sandbox#exec_default} et al., runtime
296
+ # v0.6.9). Durable configuration — it does **not** execute anything at
297
+ # create time (`create` boots the VM only). An explicit `[]` clears the
298
+ # image CMD; `nil` (the default) inherits it.
250
299
  # @param ports [Hash, nil] host_port => guest_port TCP publications
251
300
  # @param ports_udp [Hash, nil] host_port => guest_port UDP publications
252
301
  # @param volumes [Hash, nil] guest_path => mount spec. Each value is a host
@@ -280,6 +329,17 @@ module Microsandbox
280
329
  # @param ipv6_pool [String, nil] guest IPv6 address pool CIDR
281
330
  # @param max_connections [Integer, nil] cap on concurrent proxied connections
282
331
  # @param trust_host_cas [Boolean, nil] trust the host's CA bundle for upstream TLS
332
+ # @param rate_limiter [Hash, nil] per-sandbox egress/ingress token-bucket
333
+ # limits (runtime v0.6.9, local backend):
334
+ # `{ egress: { bandwidth: { size: 1_048_576, refill_time_ms: 1000,
335
+ # one_time_burst: 0 }, ops: { size: 1000, refill_time_ms: 1000 } },
336
+ # ingress: { ... } }`. `bandwidth` buckets meter bytes, `ops` buckets
337
+ # meter network frames; an omitted bucket or direction is unlimited.
338
+ # @param vsock [Hash, Array, nil] host sockets exposed on guest-to-host
339
+ # vsock ports (runtime v0.6.9): `{ "/host/api.sock" => 5000 }` (stream
340
+ # sockets), or an Array of
341
+ # `{ host_socket:, port:, socket_type: :stream|:dgram }` Hashes. Guests
342
+ # connect to host CID 2 on the given port; no in-guest proxy required.
283
343
  # @param from_snapshot [String, nil] boot from a snapshot name or digest
284
344
  # instead of an image (mutually exclusive with `image:`)
285
345
  # @param fstype [String, nil] inner filesystem type (e.g. "ext4") when
@@ -301,9 +361,12 @@ module Microsandbox
301
361
  # disk (runtime v0.6.7). An Integer is the managed ext4 upper's size cap
302
362
  # in MiB (default kind, 4 GiB when unset); a Hash picks a kind via the
303
363
  # {RootDisk} factory — `RootDisk.managed(8192)`, `RootDisk.tmpfs(2048)`
304
- # (RAM-backed, pristine on every boot), or
364
+ # (RAM-backed, pristine on every boot),
305
365
  # `RootDisk.disk("./scratch.img", format: "raw", fstype: "ext4")`
306
- # (user-supplied image attached writable).
366
+ # (user-supplied image attached writable), or
367
+ # `RootDisk.flat(8192, clone: :auto)` (a single complete ext4 root disk
368
+ # materialized from the OCI image, runtime v0.6.9 — skips the overlay
369
+ # stack; content-addressed and cached across sandboxes).
307
370
  # @param oci_upper_size [Integer, nil] deprecated alias for
308
371
  # `root_disk: <Integer>` (the managed kind); warns, and conflicts with
309
372
  # `root_disk:`
@@ -376,9 +439,9 @@ module Microsandbox
376
439
  def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
377
440
  env: nil, workdir: nil,
378
441
  shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
379
- entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
442
+ entrypoint: nil, cmd: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
380
443
  dns: nil, tls: nil, ipv4_pool: nil, ipv6_pool: nil,
381
- max_connections: nil, trust_host_cas: nil,
444
+ max_connections: nil, trust_host_cas: nil, rate_limiter: nil, vsock: nil,
382
445
  patches: nil,
383
446
  from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
384
447
  log_level: nil, quiet_logs: false, security: nil,
@@ -423,7 +486,11 @@ module Microsandbox
423
486
  opts["env"] = stringify(env) if env
424
487
  opts["labels"] = stringify(labels) if labels
425
488
  opts["scripts"] = stringify(scripts) if scripts
426
- opts["entrypoint"] = Array(entrypoint).map(&:to_s) if entrypoint
489
+ # entrypoint/cmd: an explicit empty Array clears the image's value
490
+ # (blocking the image-config merge), so presence is keyed on the kwarg
491
+ # itself — nil (the default) inherits from the image.
492
+ opts["entrypoint"] = Array(entrypoint).map(&:to_s) unless entrypoint.nil?
493
+ opts["cmd"] = Array(cmd).map(&:to_s) unless cmd.nil?
427
494
  opts["ports"] = intify_ports(ports) if ports
428
495
  opts["ports_udp"] = intify_ports(ports_udp) if ports_udp
429
496
  opts["volumes"] = normalize_volumes(volumes) if volumes
@@ -435,6 +502,8 @@ module Microsandbox
435
502
  opts["ipv6_pool"] = ipv6_pool.to_s if ipv6_pool
436
503
  opts["max_connections"] = Integer(max_connections) if max_connections
437
504
  set_bool(opts, "trust_host_cas", trust_host_cas)
505
+ opts["rate_limiter"] = normalize_rate_limiter(rate_limiter) if rate_limiter
506
+ opts["vsock"] = normalize_vsock(vsock) if vsock
438
507
  opts["log_level"] = log_level.to_s if log_level
439
508
  opts["quiet_logs"] = true if quiet_logs
440
509
  opts["security"] = security.to_s if security
@@ -478,18 +547,25 @@ module Microsandbox
478
547
  SandboxHandle.new(Native::Sandbox.get(name.to_s))
479
548
  end
480
549
 
481
- # List all sandboxes as controllable handles.
482
- # @return [Array<SandboxHandle>]
550
+ # List the first page of sandboxes (default page size 20) as controllable
551
+ # handles. Cursor-paginated as of runtime v0.6.8 — follow
552
+ # {SandboxPage#next_cursor} via {.list_with} for subsequent pages.
553
+ # @return [SandboxPage]
483
554
  def list
484
- Native::Sandbox.list.map { |h| SandboxHandle.new(h) }
555
+ build_page(Native::Sandbox.list)
485
556
  end
486
557
 
487
- # List sandboxes carrying all of the given labels (AND-matched).
488
- # @param labels [Hash] required key => value labels
489
- # @return [Array<SandboxHandle>]
490
- def list_with(labels: {})
558
+ # List one configured page of sandboxes.
559
+ # @param labels [Hash] required key => value labels (AND-matched)
560
+ # @param limit [Integer, nil] page size, 1..100 (upstream default 20)
561
+ # @param cursor [String, nil] opaque cursor from a previous page's
562
+ # {SandboxPage#next_cursor}
563
+ # @return [SandboxPage]
564
+ def list_with(labels: {}, limit: nil, cursor: nil)
491
565
  opts = {"labels" => stringify(labels)}
492
- Native::Sandbox.list_with(opts).map { |h| SandboxHandle.new(h) }
566
+ opts["limit"] = Integer(limit) if limit
567
+ opts["cursor"] = cursor.to_s if cursor
568
+ build_page(Native::Sandbox.list_with(opts))
493
569
  end
494
570
 
495
571
  # Remove a (stopped) sandbox by name.
@@ -518,6 +594,15 @@ module Microsandbox
518
594
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
519
595
  end
520
596
 
597
+ # Wrap a native page Hash ({"sandboxes" => [...], "next_cursor" => ...})
598
+ # into a {SandboxPage} of {SandboxHandle}s.
599
+ def build_page(data)
600
+ SandboxPage.new(
601
+ data["sandboxes"].map { |h| SandboxHandle.new(h) },
602
+ data["next_cursor"]
603
+ )
604
+ end
605
+
521
606
  def intify_ports(ports)
522
607
  ports.each_with_object({}) { |(k, v), acc| acc[Integer(k)] = Integer(v) }
523
608
  end
@@ -580,6 +665,7 @@ module Microsandbox
580
665
  # always set (defaulting to "no_restart"); everything else is included only
581
666
  # when provided so an unset option means "leave unchanged".
582
667
  def build_modify_opts(cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
668
+ root_disk_size: nil,
583
669
  env: nil, remove_env: nil, labels: nil, remove_labels: nil, workdir: nil,
584
670
  secrets: nil, remove_secrets: nil, policy: nil, dry_run: false)
585
671
  opts = {}
@@ -587,6 +673,7 @@ module Microsandbox
587
673
  opts["max_cpus"] = Integer(max_cpus) if max_cpus
588
674
  opts["memory"] = Integer(memory) if memory
589
675
  opts["max_memory"] = Integer(max_memory) if max_memory
676
+ opts["root_disk_size"] = Integer(root_disk_size) if root_disk_size
590
677
  opts["env"] = stringify(env) if env
591
678
  opts["remove_env"] = Array(remove_env).map(&:to_s) if remove_env
592
679
  opts["labels"] = stringify(labels) if labels
@@ -773,6 +860,71 @@ module Microsandbox
773
860
  end
774
861
 
775
862
  # Normalize the `dns:` config Hash for the native layer.
863
+ # Normalize the `rate_limiter:` Hash (v0.6.9) — per-direction
864
+ # bandwidth/ops token buckets — for the native layer. Mirrors the Python
865
+ # SDK's `NetworkRateLimiter`/`RateLimiter`/`TokenBucket` shapes as plain
866
+ # Hashes: `{egress: {bandwidth: {size:, refill_time_ms:, one_time_burst:},
867
+ # ops: {...}}, ingress: {...}}`.
868
+ def normalize_rate_limiter(rl)
869
+ raise ArgumentError, "rate_limiter: must be a Hash" unless rl.is_a?(Hash)
870
+ out = {}
871
+ %i[egress ingress].each do |dir|
872
+ spec = fetch_opt(rl, dir)
873
+ next if spec.nil?
874
+ unless spec.is_a?(Hash)
875
+ raise ArgumentError, "rate_limiter #{dir}: must be a Hash"
876
+ end
877
+ dout = {}
878
+ %i[bandwidth ops].each do |dim|
879
+ bucket = fetch_opt(spec, dim)
880
+ next if bucket.nil?
881
+ unless bucket.is_a?(Hash)
882
+ raise ArgumentError, "rate_limiter #{dir} #{dim}: must be a Hash"
883
+ end
884
+ size = fetch_opt(bucket, :size)
885
+ refill = fetch_opt(bucket, :refill_time_ms)
886
+ if size.nil? || refill.nil?
887
+ raise ArgumentError,
888
+ "rate_limiter #{dir} #{dim}: requires size: and refill_time_ms:"
889
+ end
890
+ bout = {"size" => Integer(size), "refill_time_ms" => Integer(refill)}
891
+ burst = fetch_opt(bucket, :one_time_burst)
892
+ bout["one_time_burst"] = Integer(burst) if burst
893
+ dout[dim.to_s] = bout
894
+ end
895
+ out[dir.to_s] = dout
896
+ end
897
+ out
898
+ end
899
+
900
+ # Normalize the `vsock:` create option (v0.6.9) into the native array of
901
+ # route Hashes. Accepts the Python SDK's two shapes: a Hash of
902
+ # `{ host_socket => port }` (stream sockets), or an Array of
903
+ # `{host_socket:, port:, socket_type: :stream|:dgram}` Hashes.
904
+ def normalize_vsock(vsock)
905
+ if vsock.is_a?(Hash)
906
+ vsock.map { |path, port| {"host_socket" => path.to_s, "port" => Integer(port)} }
907
+ elsif vsock.is_a?(Array)
908
+ vsock.map do |route|
909
+ unless route.is_a?(Hash)
910
+ raise ArgumentError, "vsock: array entries must be Hashes {host_socket:, port:, socket_type:}"
911
+ end
912
+ path = fetch_opt(route, :host_socket)
913
+ port = fetch_opt(route, :port)
914
+ if path.nil? || port.nil?
915
+ raise ArgumentError, "vsock route requires host_socket: and port:"
916
+ end
917
+ out = {"host_socket" => path.to_s, "port" => Integer(port)}
918
+ st = fetch_opt(route, :socket_type)
919
+ out["socket_type"] = st.to_s if st
920
+ out
921
+ end
922
+ else
923
+ raise ArgumentError,
924
+ "vsock: must be a Hash of {host_socket => port} or an Array of route Hashes"
925
+ end
926
+ end
927
+
776
928
  def normalize_dns(dns)
777
929
  raise ArgumentError, "dns: must be a Hash" unless dns.is_a?(Hash)
778
930
  out = {}
@@ -870,9 +1022,29 @@ module Microsandbox
870
1022
  h["format"] = spec["format"].to_s if spec["format"]
871
1023
  h["fstype"] = spec["fstype"].to_s if spec["fstype"]
872
1024
  h
1025
+ when "flat"
1026
+ %w[path format].each do |key|
1027
+ if spec[key]
1028
+ raise ArgumentError, "root_disk #{key}: is only valid for the disk kind"
1029
+ end
1030
+ end
1031
+ h = {"kind" => "flat"}
1032
+ if spec["size_mib"]
1033
+ h["size_mib"] = coerce_root_disk_size(spec["size_mib"], "root_disk size_mib:")
1034
+ end
1035
+ h["fstype"] = spec["fstype"].to_s if spec["fstype"]
1036
+ if spec["clone"]
1037
+ clone = spec["clone"].to_s
1038
+ unless %w[auto copy reflink].include?(clone)
1039
+ raise ArgumentError,
1040
+ "unknown root_disk clone strategy #{clone.inspect} (expected auto/copy/reflink)"
1041
+ end
1042
+ h["clone"] = clone
1043
+ end
1044
+ h
873
1045
  else
874
1046
  raise ArgumentError,
875
- "unknown root_disk kind #{kind.inspect} (expected managed/tmpfs/disk)"
1047
+ "unknown root_disk kind #{kind.inspect} (expected managed/tmpfs/disk/flat)"
876
1048
  end
877
1049
  end
878
1050
 
@@ -1058,6 +1230,21 @@ module Microsandbox
1058
1230
  exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
1059
1231
  end
1060
1232
 
1233
+ # Run the image's resolved OCI `ENTRYPOINT` and `CMD` — the **default
1234
+ # workload** (runtime v0.6.9) — and collect output. {Sandbox.create} is
1235
+ # strictly boot-only, so this is how the image's own command gets
1236
+ # executed; override the durable CMD at create time via `cmd:`.
1237
+ #
1238
+ # Options match {#exec} minus the command itself.
1239
+ # @return [ExecOutput]
1240
+ # @raise [NoDefaultCommandError] when the image's effective entrypoint and
1241
+ # CMD resolve to no executable command
1242
+ def exec_default(cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
1243
+ ExecOutput.new(@native.exec_default(
1244
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)
1245
+ ))
1246
+ end
1247
+
1061
1248
  # Run a command and stream its output as it arrives.
1062
1249
  #
1063
1250
  # Pass +stdin: :pipe+ to feed the process interactively: {ExecHandle#stdin}
@@ -1084,6 +1271,17 @@ module Microsandbox
1084
1271
  exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)))
1085
1272
  end
1086
1273
 
1274
+ # Run the default workload (see {#exec_default}) and stream its output.
1275
+ # @note Like {#exec_stream}, +timeout:+ is accepted but **not applied** on
1276
+ # the streaming path.
1277
+ # @return [ExecHandle]
1278
+ # @raise [NoDefaultCommandError] when no executable default command resolves
1279
+ def exec_default_stream(cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
1280
+ ExecHandle.new(@native.exec_default_stream(
1281
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)
1282
+ ))
1283
+ end
1284
+
1087
1285
  # Attach an interactive terminal to a command in the sandbox.
1088
1286
  #
1089
1287
  # Puts the **host** terminal into raw mode and forwards keystrokes (and
@@ -1123,6 +1321,32 @@ module Microsandbox
1123
1321
  @native.attach_shell
1124
1322
  end
1125
1323
 
1324
+ # Attach an interactive terminal to the image's resolved OCI `ENTRYPOINT`
1325
+ # and `CMD` — the default workload (runtime v0.6.9). See {#attach} for the
1326
+ # host-TTY requirements and {#exec_default} for default-workload semantics.
1327
+ #
1328
+ # @param cwd [String, nil] working directory
1329
+ # @param user [String, nil] user to run as
1330
+ # @param env [Hash, nil] extra environment variables
1331
+ # @param detach_keys [String, nil] detach sequence (default "ctrl-]")
1332
+ # @param rlimits [Hash, nil] resource limits (see {#exec})
1333
+ # @return [Integer] the workload's exit code (or the code at detach)
1334
+ # @raise [NoDefaultCommandError] when no executable default command resolves
1335
+ def attach_default(cwd: nil, user: nil, env: nil, detach_keys: nil, rlimits: nil)
1336
+ opts = {}
1337
+ opts["cwd"] = cwd.to_s if cwd
1338
+ opts["user"] = user.to_s if user
1339
+ opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
1340
+ opts["detach_keys"] = detach_keys.to_s if detach_keys
1341
+ if rlimits
1342
+ opts["rlimits"] = rlimits.map do |resource, limit|
1343
+ soft, hard = limit.is_a?(Array) ? [limit[0], limit[1]] : [limit, limit]
1344
+ [resource.to_s, Integer(soft), Integer(hard)]
1345
+ end
1346
+ end
1347
+ @native.attach_default(opts)
1348
+ end
1349
+
1126
1350
  # Guest filesystem operations.
1127
1351
  # @return [FS]
1128
1352
  def fs
@@ -1183,6 +1407,11 @@ module Microsandbox
1183
1407
  # @param max_cpus [Integer, nil] desired boot-time maximum vCPU ceiling
1184
1408
  # @param memory [Integer, nil] desired effective guest memory in MiB
1185
1409
  # @param max_memory [Integer, nil] desired boot-time maximum memory (MiB)
1410
+ # @param root_disk_size [Integer, nil] desired root-disk size in MiB
1411
+ # (runtime v0.6.9): grows the sandbox-owned managed upper or flat root
1412
+ # disk. Growth-only, applied while the sandbox is stopped —
1413
+ # restart/next-start semantics and backing-specific limits are enforced
1414
+ # by the runtime
1186
1415
  # @param env [Hash, nil] environment variables to set for future execs
1187
1416
  # @param remove_env [Array<String>, nil] environment variable names to remove
1188
1417
  # @param labels [Hash, nil] labels to set
@@ -1348,8 +1577,9 @@ module Microsandbox
1348
1577
  when :pipe
1349
1578
  unless pipe_ok
1350
1579
  raise ArgumentError,
1351
- "stdin: :pipe is only valid for exec_stream/shell_stream — a blocking " \
1352
- "exec/shell cannot expose a writable stdin sink; pass a String to feed bytes"
1580
+ "stdin: :pipe is only valid for the streaming variants (exec_stream/" \
1581
+ "shell_stream/exec_default_stream) — a blocking exec cannot expose a " \
1582
+ "writable stdin sink; pass a String to feed bytes"
1353
1583
  end
1354
1584
  opts["stdin_pipe"] = true
1355
1585
  when Symbol
@@ -115,19 +115,21 @@ module Microsandbox
115
115
  end
116
116
  end
117
117
 
118
- # The result of {Snapshot.verify}. Schema-1 descriptors always record
119
- # integrity, so a returned report is always `:verified` — an integrity
120
- # mismatch raises {SnapshotIntegrityError} instead of returning.
118
+ # The result of {Snapshot.verify}. Since runtime v0.6.9 payload integrity is
119
+ # recorded only when the snapshot was created with `record_integrity: true`:
120
+ # such snapshots report `:verified` (an integrity mismatch raises
121
+ # {SnapshotIntegrityError} instead of returning), snapshots without recorded
122
+ # integrity report `:not_recorded` with `algorithm`/`content_digest` nil.
121
123
  class SnapshotVerifyReport
122
124
  # @return [String] descriptor digest
123
125
  attr_reader :digest
124
126
  # @return [String] artifact directory path
125
127
  attr_reader :path
126
- # @return [Symbol] :verified
128
+ # @return [Symbol] :verified or :not_recorded
127
129
  attr_reader :status
128
- # @return [String] digest algorithm
130
+ # @return [String, nil] digest algorithm (nil when :not_recorded)
129
131
  attr_reader :algorithm
130
- # @return [String] matched content digest
132
+ # @return [String, nil] matched content digest (nil when :not_recorded)
131
133
  attr_reader :content_digest
132
134
 
133
135
  def initialize(data)
@@ -161,8 +163,10 @@ module Microsandbox
161
163
  # is written at `dest_dir/<name>` (default: the snapshots dir)
162
164
  # @param labels [Hash, nil] user labels
163
165
  # @param force [Boolean] overwrite an existing artifact at the destination
164
- # @param record_integrity [Boolean] accepted for compatibility; schema-1
165
- # descriptors always record integrity, so this is a no-op
166
+ # @param record_integrity [Boolean] record persistent payload integrity
167
+ # (a Merkle content digest) in the artifact — opt-in since runtime
168
+ # v0.6.9 because hashing large allocated uppers is expensive; without
169
+ # it {Snapshot.verify} reports `:not_recorded`
166
170
  # @param resumable [Boolean] request a resumable (memory+device) snapshot;
167
171
  # raises {UnsupportedError} until VM pause/resume lands upstream
168
172
  # @return [SnapshotInfo]
@@ -8,12 +8,12 @@ module Microsandbox
8
8
  # Versioning section of the README for the full gem-to-runtime map. Must equal
9
9
  # the native ext's Cargo crate version (`Native.version`), enforced by
10
10
  # spec/unit/version_spec.rb.
11
- VERSION = "0.11.0"
11
+ VERSION = "0.13.0"
12
12
 
13
13
  # The upstream microsandbox runtime release this gem build embeds — the `tag`
14
14
  # pinned on the `microsandbox`/`microsandbox-network` git deps in
15
15
  # ext/microsandbox/Cargo.toml. Exposed at runtime as
16
16
  # {Microsandbox.runtime_version}. spec/unit/version_spec.rb asserts it stays in
17
17
  # sync with the Cargo tag so it can't silently drift out of date.
18
- RUNTIME_VERSION = "v0.6.7"
18
+ RUNTIME_VERSION = "v0.6.9"
19
19
  end
@@ -13,6 +13,7 @@ module Microsandbox
13
13
  @name = data["name"]
14
14
  @path = data["path"]
15
15
  @kind = data["kind"]
16
+ @default = data["default"]
16
17
  @quota_mib = data["quota_mib"]
17
18
  @used_bytes = data["used_bytes"]
18
19
  @capacity_bytes = data["capacity_bytes"]
@@ -28,6 +29,13 @@ module Microsandbox
28
29
  @kind&.to_sym
29
30
  end
30
31
 
32
+ # Whether this is the backend's default volume (runtime v0.6.9, see
33
+ # {Volume.get_default}).
34
+ # @return [Boolean]
35
+ def default?
36
+ !!@default
37
+ end
38
+
31
39
  # @return [Time, nil]
32
40
  def created_at
33
41
  @created_at_ms && Time.at(@created_at_ms / 1000.0)
@@ -154,6 +162,14 @@ module Microsandbox
154
162
  VolumeInfo.new(Native::Volume.get(name.to_s))
155
163
  end
156
164
 
165
+ # The backend's default volume (runtime v0.6.9). Cloud backend only —
166
+ # the full {VolumeInfo#fs} surface works against it; the local backend
167
+ # raises {UnsupportedError} to avoid accidental host access.
168
+ # @return [VolumeInfo]
169
+ def get_default
170
+ VolumeInfo.new(Native::Volume.get_default)
171
+ end
172
+
157
173
  # All volumes.
158
174
  # @return [Array<VolumeInfo>]
159
175
  def list
data/lib/microsandbox.rb CHANGED
@@ -17,6 +17,7 @@ rescue LoadError
17
17
  end
18
18
 
19
19
  require_relative "microsandbox/errors"
20
+ require_relative "microsandbox/backend_info"
20
21
  require_relative "microsandbox/exec_output"
21
22
  require_relative "microsandbox/exec_handle"
22
23
  require_relative "microsandbox/fs"
@@ -172,9 +173,13 @@ module Microsandbox
172
173
 
173
174
  # Install a process-wide default backend (v0.5.8 backend routing). Without a
174
175
  # call to this, operations use a local libkrun backend; the env/profile
175
- # ladder (`MSB_BACKEND`, `MSB_API_URL`+`MSB_API_KEY`, `MSB_PROFILE`,
176
- # `~/.microsandbox/config.json`) is resolved lazily on first use. Call once
177
- # at startup, before any sandbox operations.
176
+ # ladder (`MSB_BACKEND` → `MSB_PROFILE` → `~/.microsandbox/config.json`) is
177
+ # resolved lazily on first use. Since runtime v0.6.9 a bare `MSB_API_KEY`
178
+ # no longer selects the cloud — cloud intent must be explicit via
179
+ # `MSB_BACKEND=cloud` (paired with `MSB_API_URL`/`MSB_API_KEY`), a cloud
180
+ # profile, or this method; invalid cloud config raises
181
+ # {InvalidConfigError} instead of falling back to local. Call once at
182
+ # startup, before any sandbox operations.
178
183
  #
179
184
  # @param kind ["local","cloud", Symbol] backend kind
180
185
  # @param url [String, nil] cloud control-plane URL (cloud, unless `profile:`)
@@ -216,6 +221,14 @@ module Microsandbox
216
221
  Native.default_backend_kind.to_sym
217
222
  end
218
223
 
224
+ # Secret-safe description of the active default backend (runtime v0.6.9).
225
+ # Like {default_backend_kind}, the first call freezes ambient env/profile
226
+ # resolution for the process. The API key is never included.
227
+ # @return [BackendInfo]
228
+ def default_backend_info
229
+ BackendInfo.new(Native.default_backend_info)
230
+ end
231
+
219
232
  # Latest resource-usage snapshot for every running sandbox, keyed by name.
220
233
  # Mirrors the official `all_sandbox_metrics`/`allSandboxMetrics` helpers.
221
234
  # @return [Hash{String => Metrics}]