microsandbox-rb 0.12.0 → 0.14.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.
@@ -288,7 +288,14 @@ module Microsandbox
288
288
  # @param hostname [String, nil] guest hostname
289
289
  # @param labels [Hash, nil] metadata labels
290
290
  # @param scripts [Hash, nil] named scripts to install
291
- # @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.
292
299
  # @param ports [Hash, nil] host_port => guest_port TCP publications
293
300
  # @param ports_udp [Hash, nil] host_port => guest_port UDP publications
294
301
  # @param volumes [Hash, nil] guest_path => mount spec. Each value is a host
@@ -322,6 +329,17 @@ module Microsandbox
322
329
  # @param ipv6_pool [String, nil] guest IPv6 address pool CIDR
323
330
  # @param max_connections [Integer, nil] cap on concurrent proxied connections
324
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.
325
343
  # @param from_snapshot [String, nil] boot from a snapshot name or digest
326
344
  # instead of an image (mutually exclusive with `image:`)
327
345
  # @param fstype [String, nil] inner filesystem type (e.g. "ext4") when
@@ -343,9 +361,12 @@ module Microsandbox
343
361
  # disk (runtime v0.6.7). An Integer is the managed ext4 upper's size cap
344
362
  # in MiB (default kind, 4 GiB when unset); a Hash picks a kind via the
345
363
  # {RootDisk} factory — `RootDisk.managed(8192)`, `RootDisk.tmpfs(2048)`
346
- # (RAM-backed, pristine on every boot), or
364
+ # (RAM-backed, pristine on every boot),
347
365
  # `RootDisk.disk("./scratch.img", format: "raw", fstype: "ext4")`
348
- # (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).
349
370
  # @param oci_upper_size [Integer, nil] deprecated alias for
350
371
  # `root_disk: <Integer>` (the managed kind); warns, and conflicts with
351
372
  # `root_disk:`
@@ -418,9 +439,9 @@ module Microsandbox
418
439
  def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
419
440
  env: nil, workdir: nil,
420
441
  shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
421
- 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,
422
443
  dns: nil, tls: nil, ipv4_pool: nil, ipv6_pool: nil,
423
- max_connections: nil, trust_host_cas: nil,
444
+ max_connections: nil, trust_host_cas: nil, rate_limiter: nil, vsock: nil,
424
445
  patches: nil,
425
446
  from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
426
447
  log_level: nil, quiet_logs: false, security: nil,
@@ -465,7 +486,11 @@ module Microsandbox
465
486
  opts["env"] = stringify(env) if env
466
487
  opts["labels"] = stringify(labels) if labels
467
488
  opts["scripts"] = stringify(scripts) if scripts
468
- 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?
469
494
  opts["ports"] = intify_ports(ports) if ports
470
495
  opts["ports_udp"] = intify_ports(ports_udp) if ports_udp
471
496
  opts["volumes"] = normalize_volumes(volumes) if volumes
@@ -477,6 +502,8 @@ module Microsandbox
477
502
  opts["ipv6_pool"] = ipv6_pool.to_s if ipv6_pool
478
503
  opts["max_connections"] = Integer(max_connections) if max_connections
479
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
480
507
  opts["log_level"] = log_level.to_s if log_level
481
508
  opts["quiet_logs"] = true if quiet_logs
482
509
  opts["security"] = security.to_s if security
@@ -638,6 +665,7 @@ module Microsandbox
638
665
  # always set (defaulting to "no_restart"); everything else is included only
639
666
  # when provided so an unset option means "leave unchanged".
640
667
  def build_modify_opts(cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
668
+ root_disk_size: nil,
641
669
  env: nil, remove_env: nil, labels: nil, remove_labels: nil, workdir: nil,
642
670
  secrets: nil, remove_secrets: nil, policy: nil, dry_run: false)
643
671
  opts = {}
@@ -645,6 +673,7 @@ module Microsandbox
645
673
  opts["max_cpus"] = Integer(max_cpus) if max_cpus
646
674
  opts["memory"] = Integer(memory) if memory
647
675
  opts["max_memory"] = Integer(max_memory) if max_memory
676
+ opts["root_disk_size"] = Integer(root_disk_size) if root_disk_size
648
677
  opts["env"] = stringify(env) if env
649
678
  opts["remove_env"] = Array(remove_env).map(&:to_s) if remove_env
650
679
  opts["labels"] = stringify(labels) if labels
@@ -831,6 +860,71 @@ module Microsandbox
831
860
  end
832
861
 
833
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
+
834
928
  def normalize_dns(dns)
835
929
  raise ArgumentError, "dns: must be a Hash" unless dns.is_a?(Hash)
836
930
  out = {}
@@ -928,9 +1022,29 @@ module Microsandbox
928
1022
  h["format"] = spec["format"].to_s if spec["format"]
929
1023
  h["fstype"] = spec["fstype"].to_s if spec["fstype"]
930
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
931
1045
  else
932
1046
  raise ArgumentError,
933
- "unknown root_disk kind #{kind.inspect} (expected managed/tmpfs/disk)"
1047
+ "unknown root_disk kind #{kind.inspect} (expected managed/tmpfs/disk/flat)"
934
1048
  end
935
1049
  end
936
1050
 
@@ -1116,6 +1230,21 @@ module Microsandbox
1116
1230
  exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
1117
1231
  end
1118
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
+
1119
1248
  # Run a command and stream its output as it arrives.
1120
1249
  #
1121
1250
  # Pass +stdin: :pipe+ to feed the process interactively: {ExecHandle#stdin}
@@ -1142,6 +1271,17 @@ module Microsandbox
1142
1271
  exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)))
1143
1272
  end
1144
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
+
1145
1285
  # Attach an interactive terminal to a command in the sandbox.
1146
1286
  #
1147
1287
  # Puts the **host** terminal into raw mode and forwards keystrokes (and
@@ -1181,6 +1321,32 @@ module Microsandbox
1181
1321
  @native.attach_shell
1182
1322
  end
1183
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
+
1184
1350
  # Guest filesystem operations.
1185
1351
  # @return [FS]
1186
1352
  def fs
@@ -1241,6 +1407,11 @@ module Microsandbox
1241
1407
  # @param max_cpus [Integer, nil] desired boot-time maximum vCPU ceiling
1242
1408
  # @param memory [Integer, nil] desired effective guest memory in MiB
1243
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
1244
1415
  # @param env [Hash, nil] environment variables to set for future execs
1245
1416
  # @param remove_env [Array<String>, nil] environment variable names to remove
1246
1417
  # @param labels [Hash, nil] labels to set
@@ -1406,8 +1577,9 @@ module Microsandbox
1406
1577
  when :pipe
1407
1578
  unless pipe_ok
1408
1579
  raise ArgumentError,
1409
- "stdin: :pipe is only valid for exec_stream/shell_stream — a blocking " \
1410
- "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"
1411
1583
  end
1412
1584
  opts["stdin_pipe"] = true
1413
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.12.0"
11
+ VERSION = "0.14.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.8"
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"
@@ -63,12 +64,13 @@ module Microsandbox
63
64
  # Download and install the `msb` runtime + `libkrunfw` into
64
65
  # `~/.microsandbox` (idempotent).
65
66
  #
66
- # When the gem is built from source, the native extension provisions the
67
- # runtime at build time, so this is usually a no-op. Precompiled platform
68
- # gems (which skip the local Rust build) do NOT provision it that way, so the
69
- # runtime is fetched on first use — see {ensure_runtime!}. Call this
70
- # explicitly to provision ahead of time (e.g. while baking a container
71
- # image) so the first {Sandbox.create} doesn't pay the download.
67
+ # This gem is SDK-only: nothing is provisioned at build/install time. The
68
+ # runtime comes from the companion `microsandbox-rb-binaries` gem when it is
69
+ # installed (see {ensure_runtime!}); otherwise it is fetched into
70
+ # `~/.microsandbox` on first use. Call this explicitly to provision ahead of
71
+ # time (e.g. while baking a container image) so the first {Sandbox.create}
72
+ # doesn't pay the download. Not needed — and not used — when the binaries
73
+ # gem supplies the runtime.
72
74
  # @return [nil]
73
75
  def install
74
76
  Native.install
@@ -101,10 +103,14 @@ module Microsandbox
101
103
 
102
104
  # Ensure the `msb` runtime + `libkrunfw` are present *and version-matched*,
103
105
  # provisioning them on first use if not. Called automatically by
104
- # {Sandbox.create}/{Sandbox.start} so precompiled-gem users (who never ran the
105
- # source build) get a working runtime without a manual {install} step.
106
+ # {Sandbox.create}/{Sandbox.start} so a freshly installed gem gets a working
107
+ # runtime without a manual {install} step.
106
108
  #
107
- # Runs at most once per process. Opt out by setting
109
+ # Runs at most once per process. When the companion `microsandbox-rb-binaries`
110
+ # gem supplies the runtime (it was activated at load time and its `msb` is
111
+ # what the resolver now returns from {runtime_path}), nothing is downloaded
112
+ # or touched in `~/.microsandbox` — the bundled binaries are already the
113
+ # matching version. Opt out of the download by setting
108
114
  # `MICROSANDBOX_NO_AUTO_INSTALL` (e.g. air-gapped hosts that provision the
109
115
  # runtime out of band); the runtime is then left untouched and a missing or
110
116
  # stale one surfaces at the operation itself.
@@ -128,6 +134,14 @@ module Microsandbox
128
134
  # uses the same lazy env/profile/config ladder every operation already
129
135
  # consults, so this adds no work for local hosts (the common case).
130
136
  return if default_backend_kind == :cloud
137
+ # The binaries gem won the resolver: its vendored msb (and the libkrunfw
138
+ # beside it) are exactly the version this gem was built for, so there is
139
+ # nothing to verify or download. (If `MSB_PATH` overrides it, the user owns
140
+ # the runtime and we fall through to the existing behaviour.)
141
+ if bundled_runtime_active?
142
+ @runtime_ready = true
143
+ return
144
+ end
131
145
  # Opted out: the caller manages the runtime out of band, so don't fetch,
132
146
  # verify, or repair it here. Memoize the decision (the env var is stable for
133
147
  # the process); the operation resolves `msb` itself and surfaces any problem.
@@ -138,7 +152,8 @@ module Microsandbox
138
152
 
139
153
  unless installed?
140
154
  warn "[microsandbox] runtime (msb + libkrunfw) not found; " \
141
- "downloading to ~/.microsandbox (set MICROSANDBOX_NO_AUTO_INSTALL to skip)..."
155
+ "downloading to ~/.microsandbox (set MICROSANDBOX_NO_AUTO_INSTALL to skip, " \
156
+ "or install the microsandbox-rb-binaries gem to ship it with your bundle)..."
142
157
  end
143
158
  install
144
159
  @runtime_ready = true
@@ -154,9 +169,17 @@ module Microsandbox
154
169
  # resolver, below only the `MSB_PATH` environment variable). Process-level
155
170
  # and set-once: a second call is silently ignored, and the `MSB_PATH`
156
171
  # environment variable still wins. Mirrors {libkrunfw_path=}.
172
+ #
173
+ # The companion `microsandbox-rb-binaries` gem claims this same slot when
174
+ # `require "microsandbox"` activates it, so with that gem installed this
175
+ # setter is a no-op — use `MSB_PATH` to override a bundled runtime.
157
176
  # @param path [String]
158
177
  # @return [void]
159
178
  def runtime_path=(path)
179
+ if @bundled_msb_path && path.to_s != @bundled_msb_path
180
+ warn "[microsandbox] runtime_path= ignored: the microsandbox-rb-binaries gem already " \
181
+ "claimed the set-once SDK slot (#{@bundled_msb_path}); set MSB_PATH to override it"
182
+ end
160
183
  Native.set_runtime_msb_path(path.to_s)
161
184
  end
162
185
 
@@ -172,9 +195,13 @@ module Microsandbox
172
195
 
173
196
  # Install a process-wide default backend (v0.5.8 backend routing). Without a
174
197
  # 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.
198
+ # ladder (`MSB_BACKEND` → `MSB_PROFILE` → `~/.microsandbox/config.json`) is
199
+ # resolved lazily on first use. Since runtime v0.6.9 a bare `MSB_API_KEY`
200
+ # no longer selects the cloud — cloud intent must be explicit via
201
+ # `MSB_BACKEND=cloud` (paired with `MSB_API_URL`/`MSB_API_KEY`), a cloud
202
+ # profile, or this method; invalid cloud config raises
203
+ # {InvalidConfigError} instead of falling back to local. Call once at
204
+ # startup, before any sandbox operations.
178
205
  #
179
206
  # @param kind ["local","cloud", Symbol] backend kind
180
207
  # @param url [String, nil] cloud control-plane URL (cloud, unless `profile:`)
@@ -216,6 +243,14 @@ module Microsandbox
216
243
  Native.default_backend_kind.to_sym
217
244
  end
218
245
 
246
+ # Secret-safe description of the active default backend (runtime v0.6.9).
247
+ # Like {default_backend_kind}, the first call freezes ambient env/profile
248
+ # resolution for the process. The API key is never included.
249
+ # @return [BackendInfo]
250
+ def default_backend_info
251
+ BackendInfo.new(Native.default_backend_info)
252
+ end
253
+
219
254
  # Latest resource-usage snapshot for every running sandbox, keyed by name.
220
255
  # Mirrors the official `all_sandbox_metrics`/`allSandboxMetrics` helpers.
221
256
  # @return [Hash{String => Metrics}]
@@ -244,5 +279,101 @@ module Microsandbox
244
279
  v = ENV["MICROSANDBOX_NO_AUTO_INSTALL"]
245
280
  !v.nil? && !v.empty? && !%w[0 false no].include?(v.downcase)
246
281
  end
282
+
283
+ # Wire the companion `microsandbox-rb-binaries` gem into the core resolver.
284
+ # Runs once, at `require "microsandbox"` time (like the Node SDK, which pushes
285
+ # its platform package's msb into the same set-once SDK slot at module load)
286
+ # so every entry point that spawns msb — not just {Sandbox.create} — sees it.
287
+ #
288
+ # The gem is optional and has no dependency edge to this one (cloud-only
289
+ # users skip the ~50 MB download; RubyGems has no optional dependencies), so
290
+ # discovery is by require: absent → nothing happens and the resolver's lower
291
+ # tiers (`~/.microsandbox`, then `PATH`) plus the first-use download take
292
+ # over. Present but built for a different upstream runtime → warn and skip
293
+ # it rather than hand the core a mismatched msb (a stale runtime passes an
294
+ # exists-check and then fails every create on a wire-protocol mismatch).
295
+ # Only `MSB_PATH` (env) outranks the slot claimed here.
296
+ #
297
+ # Never raises: a broken companion gem must not take `require "microsandbox"`
298
+ # down with it.
299
+ # @return [String, nil] the activated msb path
300
+ def activate_bundled_runtime!
301
+ @bundled_msb_path = nil
302
+ begin
303
+ # Pin the lockstep version when RubyGems (not Bundler) picks the gem, so
304
+ # a newer/older companion left around doesn't get activated over the
305
+ # matching one. Under Bundler the Gemfile already decides; a companion
306
+ # that isn't in the bundle raises here and `require` then fails below.
307
+ gem "microsandbox-rb-binaries", "= #{VERSION}"
308
+ rescue Gem::LoadError
309
+ # Not installed at this version — `require` settles it.
310
+ end
311
+ begin
312
+ require "microsandbox/binaries"
313
+ rescue LoadError
314
+ return nil
315
+ end
316
+ # A bare require activates the newest gem of ANY name that ships this
317
+ # feature path; only accept the companion gem itself, this gem's own tree
318
+ # (a source checkout, where Bundler's path gem spans the whole repo and so
319
+ # owns binaries/lib too), or a plain load path (RUBYLIB/-I) no gem owns.
320
+ owner = bundled_runtime_owner
321
+ if owner && !TRUSTED_BINARIES_OWNERS.include?(owner)
322
+ warn "[microsandbox] ignoring microsandbox/binaries provided by the #{owner} gem " \
323
+ "(only microsandbox-rb-binaries is trusted for the bundled runtime)"
324
+ return nil
325
+ end
326
+ # Lockstep gate on BOTH constants. The runtime tag is what the wire
327
+ # protocol depends on; the gem version is the documented contract
328
+ # ("install both at the same version") and also covers this file's own
329
+ # API/packaging — the `gem "…", "= VERSION"` pin above is best-effort (it
330
+ # raises under Bundler whenever the bundle picked any other version, and
331
+ # that is swallowed), so it cannot be what enforces it.
332
+ unless Binaries::VERSION == VERSION && Binaries::RUNTIME_VERSION == RUNTIME_VERSION
333
+ warn "[microsandbox] ignoring microsandbox-rb-binaries #{Binaries::VERSION} " \
334
+ "(runtime #{Binaries::RUNTIME_VERSION}): microsandbox-rb #{VERSION} " \
335
+ "(runtime #{RUNTIME_VERSION}) needs the companion gem at the same version. " \
336
+ "Install both gems at the same version; falling back to ~/.microsandbox."
337
+ return nil
338
+ end
339
+ msb = Binaries.msb_path
340
+ unless msb && Binaries.libkrunfw_path
341
+ warn "[microsandbox] microsandbox-rb-binaries #{Binaries::VERSION} is installed but " \
342
+ "carries no runtime under #{Binaries.root}; falling back to ~/.microsandbox."
343
+ return nil
344
+ end
345
+ Native.set_runtime_msb_path(msb)
346
+ @bundled_msb_path = msb
347
+ rescue StandardError, ScriptError => e
348
+ # ScriptError too: a corrupt or newer-syntax binaries.rb raises
349
+ # SyntaxError (not a StandardError) out of `require`, and the optional
350
+ # companion must never take `require "microsandbox"` down with it.
351
+ warn "[microsandbox] could not activate microsandbox-rb-binaries: #{e.class}: #{e.message}"
352
+ nil
353
+ end
354
+
355
+ # Gems allowed to provide `microsandbox/binaries` (see activate_bundled_runtime!).
356
+ TRUSTED_BINARIES_OWNERS = %w[microsandbox-rb-binaries microsandbox-rb].freeze
357
+ private_constant :TRUSTED_BINARIES_OWNERS
358
+
359
+ # Name of the loaded gem whose files define Microsandbox::Binaries, or nil
360
+ # when it came from a bare load path.
361
+ def bundled_runtime_owner
362
+ source = Binaries.method(:msb_path).source_location&.first
363
+ return nil unless source
364
+ spec = Gem.loaded_specs.values.find { |s| source.start_with?(File.join(s.full_gem_path, "")) }
365
+ spec&.name
366
+ end
367
+
368
+ # Whether the binaries gem's msb is what the resolver actually returns — i.e.
369
+ # it was activated and nothing higher in the ladder (`MSB_PATH`) overrides it.
370
+ def bundled_runtime_active?
371
+ return false unless @bundled_msb_path
372
+ runtime_path == @bundled_msb_path
373
+ rescue Microsandbox::Error
374
+ false
375
+ end
247
376
  end
377
+
378
+ send(:activate_bundled_runtime!)
248
379
  end