microsandbox-rb 0.10.0 → 0.12.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.
@@ -107,6 +107,44 @@ module Microsandbox
107
107
  def prune
108
108
  ImagePruneReport.new(Native::Image.prune)
109
109
  end
110
+
111
+ # Load images into the cache from a local `docker save` tarball or an
112
+ # OCI Image Layout archive. Pass `"-"` to read the archive from `$stdin`
113
+ # (spooled to a temp file first, like the Python SDK — the core reads
114
+ # seekable files only). Mirrors the Python `Image.load` / Node
115
+ # `imageLoad` (runtime v0.6.7).
116
+ # @param input_path [String] archive path (or "-")
117
+ # @param tag [String, Array<String>, nil] retag the loaded image(s)
118
+ # @return [Array<ImageInfo>] one entry per loaded image
119
+ def load(input_path, tag: nil)
120
+ tags = Array(tag).map(&:to_s)
121
+ path = input_path.to_s
122
+ if path == "-"
123
+ require "tempfile"
124
+ Tempfile.create(["msb-image-load", ".tar"]) do |tmp|
125
+ tmp.binmode
126
+ IO.copy_stream($stdin, tmp)
127
+ tmp.flush
128
+ return Native::Image.load(tmp.path, tags).map { |info| ImageInfo.new(info) }
129
+ end
130
+ end
131
+ Native::Image.load(path, tags).map { |info| ImageInfo.new(info) }
132
+ end
133
+
134
+ # Save cached image(s) into a local archive.
135
+ # Mirrors the Python `Image.save` / Node `imageSave` (runtime v0.6.7).
136
+ # @param reference [String, Array<String>] image reference(s) to save
137
+ # @param output_path [String] destination archive path
138
+ # @param format [:docker, :oci] archive layout (default :docker, the
139
+ # shape `docker load` expects)
140
+ # @return [nil]
141
+ def save(reference, output_path:, format: :docker)
142
+ refs = Array(reference).map(&:to_s)
143
+ raise ArgumentError, "at least one image reference is required" if refs.empty?
144
+
145
+ Native::Image.save(refs, output_path.to_s, format.to_s)
146
+ nil
147
+ end
110
148
  end
111
149
  end
112
150
  end
@@ -62,6 +62,24 @@ module Microsandbox
62
62
  build("deny", destination, direction, protocol, protocols, port, ports)
63
63
  end
64
64
 
65
+ # Allow plain DNS (UDP/53 and TCP/53) to the sandbox gateway — the rule
66
+ # the composable profiles prepend automatically. Use it in custom policies
67
+ # that need name resolution. Expands to the same rule as the core's
68
+ # `Rule::allow_dns` (egress → group :host, udp+tcp, port 53).
69
+ # @return [Hash]
70
+ def allow_dns
71
+ {"action" => "allow", "direction" => "egress",
72
+ "destination_kind" => "group", "destination" => "host",
73
+ "protocols" => %w[udp tcp], "ports" => ["53"]}
74
+ end
75
+
76
+ # Deny plain DNS to the sandbox gateway — the deny counterpart of
77
+ # {allow_dns}, useful as an override placed before profile-generated rules.
78
+ # @return [Hash]
79
+ def deny_dns
80
+ allow_dns.merge("action" => "deny")
81
+ end
82
+
65
83
  # @api private
66
84
  def build(action, destination, direction, protocol, protocols, port, ports)
67
85
  rule = {"action" => action, "direction" => direction.to_s}
@@ -84,20 +102,29 @@ module Microsandbox
84
102
  end
85
103
  end
86
104
 
87
- # A sandbox network policy: a preset, or a custom set of allow/deny {Rule}s
88
- # with per-direction default actions and bulk domain denials.
105
+ # A sandbox network policy: composed profiles, a terminal preset, or a custom
106
+ # set of allow/deny {Rule}s with per-direction default actions and bulk
107
+ # domain denials.
89
108
  #
90
- # Pass to {Sandbox.create} via `network:` — either a {NetworkPolicy}, a preset
91
- # name (String/Symbol), or a plain Hash with the same keys as {custom}.
109
+ # Pass to {Sandbox.create} via `network:` — a {NetworkPolicy}, an Array of
110
+ # profile names (`[:public, :host]`), a single profile or preset name
111
+ # (String/Symbol), or a plain Hash with the same keys as {custom}.
92
112
  #
93
- # @example presets
94
- # Sandbox.create("b", image: "alpine", network: NetworkPolicy.public_only)
113
+ # Profiles (runtime v0.6.7) compose: `:public` (public internet), `:private`
114
+ # (LAN/private ranges), `:host` (the host machine/gateway). Any non-empty
115
+ # combination automatically allows gateway DNS. The default policy — public
116
+ # internet only — equals `from_profiles(:public)`.
117
+ #
118
+ # @example profiles
119
+ # Sandbox.create("b", image: "alpine", network: [:public, :private])
95
120
  # Sandbox.create("b", image: "alpine", network: :none)
121
+ # Sandbox.create("b", image: "alpine", network: NetworkPolicy.from_profiles(:host))
96
122
  #
97
123
  # @example custom
98
124
  # policy = Microsandbox::NetworkPolicy.custom(
99
125
  # default_egress: :deny,
100
126
  # rules: [
127
+ # Microsandbox::Rule.allow_dns,
101
128
  # Microsandbox::Rule.allow(destination: "api.openai.com", protocol: :tcp, port: "443"),
102
129
  # ],
103
130
  # deny_domain_suffixes: [".ads.example"],
@@ -106,18 +133,47 @@ module Microsandbox
106
133
  #
107
134
  # Mirrors `NetworkPolicy` / `Network` in the official Python/Node/Go SDKs.
108
135
  class NetworkPolicy
109
- # Canonical preset names keyed by every accepted alias.
136
+ # Composable profile names (runtime v0.6.7).
137
+ PROFILES = %w[public private host].freeze
138
+
139
+ # Canonical preset names keyed by every accepted alias. Only the terminal
140
+ # presets survive v0.6.7; the removed ones get migration guidance in
141
+ # {canonical_preset}.
110
142
  PRESET_ALIASES = {
111
143
  "none" => "none", "disabled" => "none", "disable" => "none", "airgapped" => "none",
112
- "public" => "public_only", "public_only" => "public_only", "public-only" => "public_only",
113
- "default" => "public_only",
114
- "all" => "allow_all", "allow_all" => "allow_all", "allow-all" => "allow_all",
115
- "non_local" => "non_local", "non-local" => "non_local", "nonlocal" => "non_local"
144
+ "all" => "allow_all", "allow_all" => "allow_all", "allow-all" => "allow_all"
145
+ }.freeze
146
+
147
+ # Removed v0.6.6 preset spellings → the exact profile replacement, used to
148
+ # build actionable migration errors. (`public_only` expanded to precisely
149
+ # `from_profiles(:public)`, `non_local` to `from_profiles(:public, :private)` —
150
+ # the replacements are rule-for-rule equivalent.)
151
+ REMOVED_PRESETS = {
152
+ "public_only" => "network: [:public] (or NetworkPolicy.from_profiles(:public); " \
153
+ "this is still the default policy)",
154
+ "public-only" => "network: [:public]",
155
+ "non_local" => "network: [:public, :private] " \
156
+ "(or NetworkPolicy.from_profiles(:public, :private))",
157
+ "non-local" => "network: [:public, :private]",
158
+ "nonlocal" => "network: [:public, :private]"
116
159
  }.freeze
117
160
 
118
161
  class << self
119
- # @return [NetworkPolicy] allow only public internet (the default)
120
- def public_only = preset("public_only")
162
+ # Compose a policy from network profiles (`:public`, `:private`, `:host`).
163
+ # Order and duplicates don't matter — the runtime expands the set into a
164
+ # canonical rule list plus a gateway-DNS allow. An empty set is a valid
165
+ # deny-egress/allow-ingress policy with no rules (and no DNS).
166
+ # @return [NetworkPolicy]
167
+ def from_profiles(*profiles)
168
+ list = normalize_profiles(profiles)
169
+ if list.empty?
170
+ # Same semantics as the runtime's from_profiles([]) — expressed as an
171
+ # explicit empty custom policy so the wire needs no empty-array case.
172
+ custom(default_egress: :deny, default_ingress: :allow, rules: [])
173
+ else
174
+ new("profiles" => list)
175
+ end
176
+ end
121
177
 
122
178
  # @return [NetworkPolicy] block all network access
123
179
  def none = preset("none")
@@ -125,19 +181,17 @@ module Microsandbox
125
181
  # @return [NetworkPolicy] permit all traffic
126
182
  def allow_all = preset("allow_all")
127
183
 
128
- # @return [NetworkPolicy] allow public internet plus private/LAN egress
129
- def non_local = preset("non_local")
130
-
131
- # @return [NetworkPolicy] a bare preset policy
184
+ # @return [NetworkPolicy] a bare preset policy (:none or :allow_all)
132
185
  def preset(name)
133
186
  new("preset" => canonical_preset(name))
134
187
  end
135
188
 
136
189
  # Build a custom policy — an ordered rule list with per-direction default
137
- # actions. A custom policy stands on its own (no preset); to start from a
138
- # preset, use the preset factories (optionally with `deny_domains:` via the
139
- # Hash form passed to {Sandbox.create}). `preset:` and custom rules/defaults
140
- # are mutually exclusive, mirroring the official SDKs.
190
+ # actions. A custom policy stands on its own; to start from a profile
191
+ # base, use the Hash form passed to {Sandbox.create} (`profiles:` composes
192
+ # with `rules:`/deny lists). A terminal `preset:` stays exclusive with
193
+ # custom rules/defaults, mirroring the official SDKs. Custom policies
194
+ # need an explicit {Rule.allow_dns} if the sandbox should resolve names.
141
195
  #
142
196
  # @param default_egress [:deny, :allow, nil] fall-through for unmatched
143
197
  # outbound traffic (default :deny)
@@ -163,26 +217,64 @@ module Microsandbox
163
217
  def coerce(network)
164
218
  case network
165
219
  when NetworkPolicy then network.to_h
166
- when String, Symbol then {"preset" => canonical_preset(network)}
220
+ when Array then from_profiles(*network).to_h
221
+ when String, Symbol then coerce_name(network)
167
222
  when Hash then from_hash(network)
168
223
  else
169
224
  raise ArgumentError,
170
- "network: expects a preset name, a Microsandbox::NetworkPolicy, or a Hash " \
225
+ "network: expects profile(s) (:public/:private/:host, or an Array of them), " \
226
+ "a preset (:none/:allow_all), a Microsandbox::NetworkPolicy, or a Hash " \
171
227
  "(got #{network.class})"
172
228
  end
173
229
  end
174
230
 
175
231
  private
176
232
 
177
- # A `network:` Hash is either a preset (`preset:` + optional deny lists) or
178
- # a custom policy (`default_egress:`/`default_ingress:`/`rules:` + optional
179
- # deny lists). The two are mutually exclusive: a preset already defines its
180
- # rules and defaults, so layering custom rules/defaults on top would silently
181
- # override them (and diverge from the official SDKs, where preset and custom
182
- # are separate paths). A bare preset (only `preset:`, no deny lists) is
183
- # routed to the preset path by {Sandbox.create}, so its own defaults apply.
233
+ # A bare String/Symbol names a single profile (`:public`/`:private`/
234
+ # `:host`) or a terminal preset (`:none`/`:allow_all` and aliases).
235
+ # `"default"` stays accepted and means the default policy, which is
236
+ # exactly the `:public` profile.
237
+ def coerce_name(name)
238
+ key = name.to_s.downcase
239
+ return {"profiles" => [key]} if PROFILES.include?(key)
240
+ return {"profiles" => ["public"]} if key == "default"
241
+
242
+ {"preset" => canonical_preset(key)}
243
+ end
244
+
245
+ # Validate + stringify a profile list. Removed-preset spellings get the
246
+ # same migration guidance here as in {canonical_preset}, since
247
+ # `network: [:non_local]` is a plausible mis-migration.
248
+ def normalize_profiles(profiles)
249
+ Array(profiles).flatten.map do |p|
250
+ key = p.to_s.downcase
251
+ next key if PROFILES.include?(key)
252
+
253
+ if (replacement = REMOVED_PRESETS[key])
254
+ raise ArgumentError,
255
+ "#{p.inspect} is a removed v0.6.6 network preset, not a profile; use #{replacement}"
256
+ end
257
+ raise ArgumentError,
258
+ "unknown network profile #{p.inspect} (expected :public/:private/:host)"
259
+ end
260
+ end
261
+
262
+ # A `network:` Hash is composed profiles (`profiles:` + optional rules/
263
+ # defaults/deny lists), a terminal preset (`preset:` + optional deny
264
+ # lists), or a custom policy (`default_egress:`/`default_ingress:`/
265
+ # `rules:` + optional deny lists). Profiles compose with everything —
266
+ # they expand to an ordinary rule base that explicit rules append to —
267
+ # but a preset stays exclusive with rules/defaults: it already defines
268
+ # its rules and defaults, so layering more on top would silently override
269
+ # them. A bare preset or bare profile list is routed to its dedicated
270
+ # wire key by {Sandbox.create}, so the runtime's own expansion applies.
184
271
  def from_hash(hash)
185
272
  sym = hash.transform_keys(&:to_sym)
273
+ profiles = sym.key?(:profiles) ? normalize_profiles(sym[:profiles]) : nil
274
+ if profiles && sym.key?(:preset)
275
+ raise ArgumentError, "network profiles: and preset: are mutually exclusive"
276
+ end
277
+
186
278
  if sym.key?(:preset)
187
279
  if sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
188
280
  raise ArgumentError,
@@ -193,6 +285,18 @@ module Microsandbox
193
285
  h = {"preset" => canonical_preset(sym[:preset])}
194
286
  add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
195
287
  h
288
+ elsif profiles
289
+ h = {}
290
+ h["profiles"] = profiles unless profiles.empty?
291
+ h["default_egress"] = action_str(sym[:default_egress]) if sym[:default_egress]
292
+ h["default_ingress"] = action_str(sym[:default_ingress]) if sym[:default_ingress]
293
+ rules = Array(sym[:rules]).map { |r| normalize_rule(r) }
294
+ h["rules"] = rules unless rules.empty?
295
+ add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
296
+ # `profiles: []` alone is the runtime's empty composed policy:
297
+ # deny-egress/allow-ingress with no rules (and no DNS).
298
+ h = {"default_egress" => "deny", "default_ingress" => "allow", "rules" => []} if h.empty?
299
+ h
196
300
  elsif sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
197
301
  # An explicit custom policy: the caller chose the rule list and/or the
198
302
  # fall-through defaults (which default to :deny / :allow).
@@ -231,10 +335,14 @@ module Microsandbox
231
335
 
232
336
  def canonical_preset(name)
233
337
  key = name.to_s.downcase
338
+ if (replacement = REMOVED_PRESETS[key])
339
+ raise ArgumentError,
340
+ "the #{name.inspect} network preset was removed in runtime v0.6.7; use #{replacement}"
341
+ end
234
342
  PRESET_ALIASES[key] ||
235
343
  raise(ArgumentError,
236
344
  "unknown network preset #{name.inspect} " \
237
- "(expected one of public_only/none/allow_all/non_local)")
345
+ "(expected none/allow_all, or profiles :public/:private/:host)")
238
346
  end
239
347
 
240
348
  def action_str(action)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Factory for an OCI sandbox's writable **root disk** spec (runtime v0.6.7),
5
+ # passed to {Sandbox.create} via `root_disk:`. Three kinds:
6
+ #
7
+ # - {managed} — the default: a sparse ext4 upper file created and resized by
8
+ # the runtime (4 GiB cap when no size is given). A bare Integer passed to
9
+ # `root_disk:` means the same thing.
10
+ # - {tmpfs} — RAM-backed upper, so the rootfs is pristine on every boot.
11
+ # Default size is half the sandbox memory; the size must not exceed sandbox
12
+ # memory (no swap in the guest). Cannot be snapshotted or patched.
13
+ # - {disk} — a user-supplied disk image attached writable as the upper. The
14
+ # file determines its own size; the runtime never creates, resizes, or
15
+ # deletes it. Cannot be snapshotted or patched.
16
+ #
17
+ # @example
18
+ # Sandbox.create("worker", image: "python", root_disk: 8192)
19
+ # Sandbox.create("ci", image: "python", root_disk: Microsandbox::RootDisk.tmpfs(2048))
20
+ # Sandbox.create("warm", image: "python",
21
+ # root_disk: Microsandbox::RootDisk.disk("./scratch.img", fstype: "ext4"))
22
+ #
23
+ # Mirrors the `RootDisk` factory in the official Python/Node/Go SDKs.
24
+ module RootDisk
25
+ module_function
26
+
27
+ # The managed sparse-ext4 upper (the default kind).
28
+ # @param size_mib [Integer, nil] size cap in MiB (default 4096)
29
+ # @return [Hash]
30
+ def managed(size_mib = nil)
31
+ h = {"kind" => "managed"}
32
+ h["size_mib"] = Integer(size_mib) if size_mib
33
+ h
34
+ end
35
+
36
+ # A RAM-backed tmpfs upper — pristine rootfs on every boot.
37
+ # @param size_mib [Integer, nil] size in MiB (default: half sandbox memory)
38
+ # @return [Hash]
39
+ def tmpfs(size_mib = nil)
40
+ h = {"kind" => "tmpfs"}
41
+ h["size_mib"] = Integer(size_mib) if size_mib
42
+ h
43
+ end
44
+
45
+ # A user-supplied disk image attached writable as the upper.
46
+ # @param path [String] path to the image file
47
+ # @param format ["raw", "qcow2", nil] image format (inferred from the
48
+ # .img/.raw/.qcow2 extension when omitted)
49
+ # @param fstype [String, nil] filesystem type inside the image, e.g. "ext4"
50
+ # @return [Hash]
51
+ def disk(path, format: nil, fstype: nil)
52
+ h = {"kind" => "disk", "path" => path.to_s}
53
+ h["format"] = format.to_s if format
54
+ h["fstype"] = fstype.to_s if fstype
55
+ h
56
+ end
57
+ end
58
+ end
@@ -142,20 +142,16 @@ module Microsandbox
142
142
 
143
143
  # Snapshot this (stopped) sandbox under a bare name, resolved under the
144
144
  # snapshots dir. Convenience equivalent of
145
- # `Snapshot.create(name, name: <snapshot-name>)` addressed by this handle.
145
+ # `Snapshot.create(<snapshot-name>, from_sandbox: <this sandbox>)`.
146
+ # (`snapshot_to(path)` was removed in 0.11.0 with runtime v0.6.7 — for
147
+ # explicit placement use `Snapshot.create(name, from_sandbox:, dest_dir:)`;
148
+ # the artifact lands at `dest_dir/<name>`.)
146
149
  # @param name [String] destination snapshot name
147
150
  # @return [SnapshotInfo]
148
151
  def snapshot(name)
149
152
  SnapshotInfo.new(@native.snapshot(name.to_s))
150
153
  end
151
154
 
152
- # Snapshot this (stopped) sandbox to an explicit filesystem path.
153
- # @param path [String] destination directory
154
- # @return [SnapshotInfo]
155
- def snapshot_to(path)
156
- SnapshotInfo.new(@native.snapshot_to(path.to_s))
157
- end
158
-
159
155
  def inspect
160
156
  "#<Microsandbox::SandboxHandle name=#{name.inspect} status=#{status}>"
161
157
  end
@@ -206,6 +202,48 @@ module Microsandbox
206
202
  end
207
203
  end
208
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
+
209
247
  # A running sandbox (microVM) — the primary entry point of the SDK.
210
248
  #
211
249
  # @example Block form (auto-stops on exit)
@@ -262,13 +300,18 @@ module Microsandbox
262
300
  # `host_permissions:` (:private/:mirror). A bind mount accepts
263
301
  # `quota_mib:` to override the runtime's default guest-write budget
264
302
  # (4 GiB as of `v0.5.10`); the core rejects it on tmpfs/disk/named (for a
265
- # named volume, set its quota via {Volume.create}).
266
- # @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
267
- # A preset name ("public_only" (default), "none", "allow_all",
268
- # "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
269
- # describing a custom policy (`default_egress:`, `default_ingress:`,
270
- # `rules:`, `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy}
271
- # and {Rule}.
303
+ # named volume, set its quota via {Volume.create}). Bind/named mounts
304
+ # also accept `follow_root_symlinks: true` to opt out of the default-on
305
+ # mount-root symlink protection (runtime v0.6.7).
306
+ # @param network [Array, String, Symbol, NetworkPolicy, Hash, nil] network
307
+ # policy. Composable profiles (`[:public]` (the default), `[:public,
308
+ # :private]`, `:host`, ), a terminal preset (:none, :allow_all), a
309
+ # {NetworkPolicy} (e.g. {NetworkPolicy.from_profiles},
310
+ # {NetworkPolicy.custom}), or a Hash describing a custom policy
311
+ # (`profiles:`, `default_egress:`, `default_ingress:`, `rules:`,
312
+ # `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy} and
313
+ # {Rule}. The v0.6.6 `public_only`/`non_local` presets were removed in
314
+ # runtime v0.6.7 — use `[:public]` / `[:public, :private]`.
272
315
  # @param dns [Hash, nil] custom DNS: `{ nameservers: [...],
273
316
  # rebind_protection: true, query_timeout_ms: 2000 }`
274
317
  # @param tls [Hash, nil] TLS-interception tuning: `{ bypass: [...patterns],
@@ -296,7 +339,16 @@ module Microsandbox
296
339
  # @param log_level ["error","warn","info","debug","trace", nil] guest log verbosity
297
340
  # @param quiet_logs [Boolean] suppress sandbox process logs
298
341
  # @param security ["default", "restricted", nil] exec security profile
299
- # @param oci_upper_size [Integer, nil] writable upper-layer size cap, in MiB
342
+ # @param root_disk [Integer, Hash, nil] the OCI sandbox's writable root
343
+ # disk (runtime v0.6.7). An Integer is the managed ext4 upper's size cap
344
+ # in MiB (default kind, 4 GiB when unset); a Hash picks a kind via the
345
+ # {RootDisk} factory — `RootDisk.managed(8192)`, `RootDisk.tmpfs(2048)`
346
+ # (RAM-backed, pristine on every boot), or
347
+ # `RootDisk.disk("./scratch.img", format: "raw", fstype: "ext4")`
348
+ # (user-supplied image attached writable).
349
+ # @param oci_upper_size [Integer, nil] deprecated alias for
350
+ # `root_disk: <Integer>` (the managed kind); warns, and conflicts with
351
+ # `root_disk:`
300
352
  # @param max_duration [Integer, nil] hard wall-clock lifetime, in seconds
301
353
  # @param idle_timeout [Integer, nil] stop after this many idle seconds
302
354
  # @param rlimits [Hash, nil] resource limits: { resource => limit } or
@@ -372,7 +424,7 @@ module Microsandbox
372
424
  patches: nil,
373
425
  from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
374
426
  log_level: nil, quiet_logs: false, security: nil,
375
- oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
427
+ root_disk: nil, oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
376
428
  pull_policy: nil, registry_auth: nil, registry_insecure: false,
377
429
  registry_ca_certs: nil, secrets: nil, on_secret_violation: nil,
378
430
  detached: false, replace: false, replace_with_timeout: nil)
@@ -382,6 +434,9 @@ module Microsandbox
382
434
  if image && from_snapshot
383
435
  raise ArgumentError, "provide either image: or from_snapshot:, not both"
384
436
  end
437
+ if root_disk && oci_upper_size
438
+ raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
439
+ end
385
440
  Microsandbox.ensure_runtime!
386
441
  # `fstype:` names the inner filesystem of a disk-image rootfs, so it only
387
442
  # applies when `image:` is a disk-image path (a local path ending in
@@ -425,7 +480,14 @@ module Microsandbox
425
480
  opts["log_level"] = log_level.to_s if log_level
426
481
  opts["quiet_logs"] = true if quiet_logs
427
482
  opts["security"] = security.to_s if security
428
- opts["oci_upper_size"] = Integer(oci_upper_size) if oci_upper_size
483
+ if oci_upper_size
484
+ warn "Microsandbox: oci_upper_size: is deprecated; use root_disk: " \
485
+ "(an Integer size in MiB, or RootDisk.managed/tmpfs/disk)",
486
+ uplevel: 2
487
+ opts["root_disk"] = coerce_root_disk_size(oci_upper_size, "oci_upper_size:")
488
+ elsif root_disk
489
+ opts["root_disk"] = normalize_root_disk(root_disk)
490
+ end
429
491
  opts["max_duration"] = Integer(max_duration) if max_duration
430
492
  opts["idle_timeout"] = Integer(idle_timeout) if idle_timeout
431
493
  opts["rlimits"] = normalize_rlimits(rlimits) if rlimits
@@ -458,18 +520,25 @@ module Microsandbox
458
520
  SandboxHandle.new(Native::Sandbox.get(name.to_s))
459
521
  end
460
522
 
461
- # List all sandboxes as controllable handles.
462
- # @return [Array<SandboxHandle>]
523
+ # List the first page of sandboxes (default page size 20) as controllable
524
+ # handles. Cursor-paginated as of runtime v0.6.8 — follow
525
+ # {SandboxPage#next_cursor} via {.list_with} for subsequent pages.
526
+ # @return [SandboxPage]
463
527
  def list
464
- Native::Sandbox.list.map { |h| SandboxHandle.new(h) }
528
+ build_page(Native::Sandbox.list)
465
529
  end
466
530
 
467
- # List sandboxes carrying all of the given labels (AND-matched).
468
- # @param labels [Hash] required key => value labels
469
- # @return [Array<SandboxHandle>]
470
- def list_with(labels: {})
531
+ # List one configured page of sandboxes.
532
+ # @param labels [Hash] required key => value labels (AND-matched)
533
+ # @param limit [Integer, nil] page size, 1..100 (upstream default 20)
534
+ # @param cursor [String, nil] opaque cursor from a previous page's
535
+ # {SandboxPage#next_cursor}
536
+ # @return [SandboxPage]
537
+ def list_with(labels: {}, limit: nil, cursor: nil)
471
538
  opts = {"labels" => stringify(labels)}
472
- Native::Sandbox.list_with(opts).map { |h| SandboxHandle.new(h) }
539
+ opts["limit"] = Integer(limit) if limit
540
+ opts["cursor"] = cursor.to_s if cursor
541
+ build_page(Native::Sandbox.list_with(opts))
473
542
  end
474
543
 
475
544
  # Remove a (stopped) sandbox by name.
@@ -498,6 +567,15 @@ module Microsandbox
498
567
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
499
568
  end
500
569
 
570
+ # Wrap a native page Hash ({"sandboxes" => [...], "next_cursor" => ...})
571
+ # into a {SandboxPage} of {SandboxHandle}s.
572
+ def build_page(data)
573
+ SandboxPage.new(
574
+ data["sandboxes"].map { |h| SandboxHandle.new(h) },
575
+ data["next_cursor"]
576
+ )
577
+ end
578
+
501
579
  def intify_ports(ports)
502
580
  ports.each_with_object({}) { |(k, v), acc| acc[Integer(k)] = Integer(v) }
503
581
  end
@@ -816,6 +894,58 @@ module Microsandbox
816
894
  # { disk: "/img.raw", format: "raw", fstype: "ext4" } # disk-image mount
817
895
  # Any mount may also carry stat_virtualization: (:strict/:relaxed/:off) and
818
896
  # host_permissions: (:private/:mirror); a bind mount may carry quota_mib:.
897
+ # Coerce the `root_disk:` argument — an Integer (managed size in MiB), a
898
+ # {RootDisk} factory Hash, or an equivalent hand-written Hash — into the
899
+ # wire shape, validating kind/field combinations up front (mirrors the
900
+ # Python SDK's extract_root_disk guards).
901
+ def normalize_root_disk(root_disk)
902
+ return coerce_root_disk_size(root_disk, "root_disk:") unless root_disk.is_a?(Hash)
903
+
904
+ spec = root_disk.transform_keys(&:to_s)
905
+ kind = (spec["kind"] || "managed").to_s
906
+ case kind
907
+ when "managed", "tmpfs"
908
+ %w[path format fstype].each do |key|
909
+ if spec[key]
910
+ raise ArgumentError, "root_disk #{key}: is only valid for the disk kind"
911
+ end
912
+ end
913
+ h = {"kind" => kind}
914
+ if spec["size_mib"]
915
+ h["size_mib"] = coerce_root_disk_size(spec["size_mib"], "root_disk size_mib:")
916
+ end
917
+ h
918
+ when "disk", "disk-image", "disk_image"
919
+ path = spec["path"]
920
+ if path.nil? || path.to_s.empty?
921
+ raise ArgumentError, "root_disk disk kind requires path:"
922
+ end
923
+ if spec["size_mib"]
924
+ raise ArgumentError, "root_disk size_mib: is not valid for a disk image " \
925
+ "(the image file determines the size)"
926
+ end
927
+ h = {"kind" => "disk", "path" => path.to_s}
928
+ h["format"] = spec["format"].to_s if spec["format"]
929
+ h["fstype"] = spec["fstype"].to_s if spec["fstype"]
930
+ h
931
+ else
932
+ raise ArgumentError,
933
+ "unknown root_disk kind #{kind.inspect} (expected managed/tmpfs/disk)"
934
+ end
935
+ end
936
+
937
+ # Coerce + range-check a root-disk size. The wire carries u32 MiB; an
938
+ # out-of-range value would otherwise surface as a confusing ext-level
939
+ # type error (or a raw RangeError from the native conversion).
940
+ def coerce_root_disk_size(value, label)
941
+ mib = Integer(value)
942
+ unless mib.positive? && mib <= 4_294_967_295
943
+ raise ArgumentError,
944
+ "#{label} must be a positive size in MiB that fits in 32 bits (got #{mib})"
945
+ end
946
+ mib
947
+ end
948
+
819
949
  def normalize_volumes(volumes)
820
950
  volumes.map do |guest, spec|
821
951
  mount = {"guest" => guest.to_s}
@@ -868,6 +998,9 @@ module Microsandbox
868
998
  # read-only; `noexec:`/`nosuid:`/`nodev:` set the matching flags;
869
999
  # `stat_virtualization:`/`host_permissions:` set the passthrough policies
870
1000
  # (only valid on bind/named — the core rejects them on tmpfs/disk).
1001
+ # `follow_root_symlinks:` opts out of the default-on mount-root symlink
1002
+ # protection (runtime v0.6.7; bind/named only — the core silently ignores
1003
+ # it on tmpfs/disk mounts, so reject those here instead).
871
1004
  def apply_mount_flags(mount, spec)
872
1005
  mount["readonly"] = true if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
873
1006
  mount["noexec"] = true if spec[:noexec] || spec["noexec"]
@@ -878,6 +1011,15 @@ module Microsandbox
878
1011
  mount["stat_virtualization"] = sv.to_s if sv
879
1012
  hp = spec[:host_permissions] || spec["host_permissions"]
880
1013
  mount["host_permissions"] = hp.to_s if hp
1014
+ follow = spec.fetch(:follow_root_symlinks, spec["follow_root_symlinks"])
1015
+ unless follow.nil?
1016
+ unless %w[bind named].include?(mount["kind"])
1017
+ raise ArgumentError,
1018
+ "follow_root_symlinks: only applies to bind/named mounts " \
1019
+ "(got a #{mount["kind"]} mount); a #{mount["kind"]} mount has no host root to protect"
1020
+ end
1021
+ mount["follow_root_symlinks"] = !!follow
1022
+ end
881
1023
  end
882
1024
 
883
1025
  # Translate the pre-0.7.0 `options:` array form (e.g. options: %w[ro noexec])
@@ -918,16 +1060,18 @@ module Microsandbox
918
1060
  end
919
1061
  end
920
1062
 
921
- # Route the `network:` argument to either the preset path
922
- # (`opts["network"]`, the original string-preset behavior) or the custom
923
- # policy path (`opts["network_policy"]`). Accepts a preset String/Symbol, a
924
- # {NetworkPolicy}, or a plain Hash.
1063
+ # Route the `network:` argument to the preset path (`opts["network"]`),
1064
+ # the composed-profiles path (`opts["network_profiles"]`), or the custom
1065
+ # policy path (`opts["network_policy"]`). Accepts profile Symbol(s)/Array,
1066
+ # a preset String/Symbol, a {NetworkPolicy}, or a plain Hash.
925
1067
  def apply_network_opts(opts, network)
926
1068
  norm = NetworkPolicy.coerce(network)
927
1069
  return if norm.empty? # e.g. network: {} — leave the default policy in place
928
1070
 
929
1071
  if norm.keys == ["preset"]
930
1072
  opts["network"] = norm["preset"]
1073
+ elsif norm.keys == ["profiles"]
1074
+ opts["network_profiles"] = norm["profiles"]
931
1075
  else
932
1076
  opts["network_policy"] = norm
933
1077
  end