microsandbox-rb 0.10.0 → 0.11.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/Cargo.lock +74 -95
- data/README.md +12 -9
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +4 -0
- data/ext/microsandbox/src/image.rs +38 -0
- data/ext/microsandbox/src/sandbox.rs +165 -37
- data/ext/microsandbox/src/snapshot.rs +79 -35
- data/lib/microsandbox/errors.rb +4 -0
- data/lib/microsandbox/image.rb +38 -0
- data/lib/microsandbox/network.rb +139 -31
- data/lib/microsandbox/root_disk.rb +58 -0
- data/lib/microsandbox/sandbox.rb +108 -22
- data/lib/microsandbox/snapshot.rb +87 -37
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +1 -0
- data/sig/microsandbox.rbs +32 -11
- metadata +2 -1
data/lib/microsandbox/network.rb
CHANGED
|
@@ -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
|
|
88
|
-
# with per-direction default actions and bulk
|
|
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:` —
|
|
91
|
-
#
|
|
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
|
-
#
|
|
94
|
-
#
|
|
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
|
-
#
|
|
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
|
-
"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
#
|
|
120
|
-
|
|
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]
|
|
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
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
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
|
|
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
|
|
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
|
|
178
|
-
# a
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -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(
|
|
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
|
|
@@ -262,13 +258,18 @@ module Microsandbox
|
|
|
262
258
|
# `host_permissions:` (:private/:mirror). A bind mount accepts
|
|
263
259
|
# `quota_mib:` to override the runtime's default guest-write budget
|
|
264
260
|
# (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
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
261
|
+
# named volume, set its quota via {Volume.create}). Bind/named mounts
|
|
262
|
+
# also accept `follow_root_symlinks: true` to opt out of the default-on
|
|
263
|
+
# mount-root symlink protection (runtime v0.6.7).
|
|
264
|
+
# @param network [Array, String, Symbol, NetworkPolicy, Hash, nil] network
|
|
265
|
+
# policy. Composable profiles (`[:public]` (the default), `[:public,
|
|
266
|
+
# :private]`, `:host`, …), a terminal preset (:none, :allow_all), a
|
|
267
|
+
# {NetworkPolicy} (e.g. {NetworkPolicy.from_profiles},
|
|
268
|
+
# {NetworkPolicy.custom}), or a Hash describing a custom policy
|
|
269
|
+
# (`profiles:`, `default_egress:`, `default_ingress:`, `rules:`,
|
|
270
|
+
# `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy} and
|
|
271
|
+
# {Rule}. The v0.6.6 `public_only`/`non_local` presets were removed in
|
|
272
|
+
# runtime v0.6.7 — use `[:public]` / `[:public, :private]`.
|
|
272
273
|
# @param dns [Hash, nil] custom DNS: `{ nameservers: [...],
|
|
273
274
|
# rebind_protection: true, query_timeout_ms: 2000 }`
|
|
274
275
|
# @param tls [Hash, nil] TLS-interception tuning: `{ bypass: [...patterns],
|
|
@@ -296,7 +297,16 @@ module Microsandbox
|
|
|
296
297
|
# @param log_level ["error","warn","info","debug","trace", nil] guest log verbosity
|
|
297
298
|
# @param quiet_logs [Boolean] suppress sandbox process logs
|
|
298
299
|
# @param security ["default", "restricted", nil] exec security profile
|
|
299
|
-
# @param
|
|
300
|
+
# @param root_disk [Integer, Hash, nil] the OCI sandbox's writable root
|
|
301
|
+
# disk (runtime v0.6.7). An Integer is the managed ext4 upper's size cap
|
|
302
|
+
# in MiB (default kind, 4 GiB when unset); a Hash picks a kind via the
|
|
303
|
+
# {RootDisk} factory — `RootDisk.managed(8192)`, `RootDisk.tmpfs(2048)`
|
|
304
|
+
# (RAM-backed, pristine on every boot), or
|
|
305
|
+
# `RootDisk.disk("./scratch.img", format: "raw", fstype: "ext4")`
|
|
306
|
+
# (user-supplied image attached writable).
|
|
307
|
+
# @param oci_upper_size [Integer, nil] deprecated alias for
|
|
308
|
+
# `root_disk: <Integer>` (the managed kind); warns, and conflicts with
|
|
309
|
+
# `root_disk:`
|
|
300
310
|
# @param max_duration [Integer, nil] hard wall-clock lifetime, in seconds
|
|
301
311
|
# @param idle_timeout [Integer, nil] stop after this many idle seconds
|
|
302
312
|
# @param rlimits [Hash, nil] resource limits: { resource => limit } or
|
|
@@ -372,7 +382,7 @@ module Microsandbox
|
|
|
372
382
|
patches: nil,
|
|
373
383
|
from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
|
|
374
384
|
log_level: nil, quiet_logs: false, security: nil,
|
|
375
|
-
oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
|
|
385
|
+
root_disk: nil, oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
|
|
376
386
|
pull_policy: nil, registry_auth: nil, registry_insecure: false,
|
|
377
387
|
registry_ca_certs: nil, secrets: nil, on_secret_violation: nil,
|
|
378
388
|
detached: false, replace: false, replace_with_timeout: nil)
|
|
@@ -382,6 +392,9 @@ module Microsandbox
|
|
|
382
392
|
if image && from_snapshot
|
|
383
393
|
raise ArgumentError, "provide either image: or from_snapshot:, not both"
|
|
384
394
|
end
|
|
395
|
+
if root_disk && oci_upper_size
|
|
396
|
+
raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
|
|
397
|
+
end
|
|
385
398
|
Microsandbox.ensure_runtime!
|
|
386
399
|
# `fstype:` names the inner filesystem of a disk-image rootfs, so it only
|
|
387
400
|
# applies when `image:` is a disk-image path (a local path ending in
|
|
@@ -425,7 +438,14 @@ module Microsandbox
|
|
|
425
438
|
opts["log_level"] = log_level.to_s if log_level
|
|
426
439
|
opts["quiet_logs"] = true if quiet_logs
|
|
427
440
|
opts["security"] = security.to_s if security
|
|
428
|
-
|
|
441
|
+
if oci_upper_size
|
|
442
|
+
warn "Microsandbox: oci_upper_size: is deprecated; use root_disk: " \
|
|
443
|
+
"(an Integer size in MiB, or RootDisk.managed/tmpfs/disk)",
|
|
444
|
+
uplevel: 2
|
|
445
|
+
opts["root_disk"] = coerce_root_disk_size(oci_upper_size, "oci_upper_size:")
|
|
446
|
+
elsif root_disk
|
|
447
|
+
opts["root_disk"] = normalize_root_disk(root_disk)
|
|
448
|
+
end
|
|
429
449
|
opts["max_duration"] = Integer(max_duration) if max_duration
|
|
430
450
|
opts["idle_timeout"] = Integer(idle_timeout) if idle_timeout
|
|
431
451
|
opts["rlimits"] = normalize_rlimits(rlimits) if rlimits
|
|
@@ -816,6 +836,58 @@ module Microsandbox
|
|
|
816
836
|
# { disk: "/img.raw", format: "raw", fstype: "ext4" } # disk-image mount
|
|
817
837
|
# Any mount may also carry stat_virtualization: (:strict/:relaxed/:off) and
|
|
818
838
|
# host_permissions: (:private/:mirror); a bind mount may carry quota_mib:.
|
|
839
|
+
# Coerce the `root_disk:` argument — an Integer (managed size in MiB), a
|
|
840
|
+
# {RootDisk} factory Hash, or an equivalent hand-written Hash — into the
|
|
841
|
+
# wire shape, validating kind/field combinations up front (mirrors the
|
|
842
|
+
# Python SDK's extract_root_disk guards).
|
|
843
|
+
def normalize_root_disk(root_disk)
|
|
844
|
+
return coerce_root_disk_size(root_disk, "root_disk:") unless root_disk.is_a?(Hash)
|
|
845
|
+
|
|
846
|
+
spec = root_disk.transform_keys(&:to_s)
|
|
847
|
+
kind = (spec["kind"] || "managed").to_s
|
|
848
|
+
case kind
|
|
849
|
+
when "managed", "tmpfs"
|
|
850
|
+
%w[path format fstype].each do |key|
|
|
851
|
+
if spec[key]
|
|
852
|
+
raise ArgumentError, "root_disk #{key}: is only valid for the disk kind"
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
h = {"kind" => kind}
|
|
856
|
+
if spec["size_mib"]
|
|
857
|
+
h["size_mib"] = coerce_root_disk_size(spec["size_mib"], "root_disk size_mib:")
|
|
858
|
+
end
|
|
859
|
+
h
|
|
860
|
+
when "disk", "disk-image", "disk_image"
|
|
861
|
+
path = spec["path"]
|
|
862
|
+
if path.nil? || path.to_s.empty?
|
|
863
|
+
raise ArgumentError, "root_disk disk kind requires path:"
|
|
864
|
+
end
|
|
865
|
+
if spec["size_mib"]
|
|
866
|
+
raise ArgumentError, "root_disk size_mib: is not valid for a disk image " \
|
|
867
|
+
"(the image file determines the size)"
|
|
868
|
+
end
|
|
869
|
+
h = {"kind" => "disk", "path" => path.to_s}
|
|
870
|
+
h["format"] = spec["format"].to_s if spec["format"]
|
|
871
|
+
h["fstype"] = spec["fstype"].to_s if spec["fstype"]
|
|
872
|
+
h
|
|
873
|
+
else
|
|
874
|
+
raise ArgumentError,
|
|
875
|
+
"unknown root_disk kind #{kind.inspect} (expected managed/tmpfs/disk)"
|
|
876
|
+
end
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# Coerce + range-check a root-disk size. The wire carries u32 MiB; an
|
|
880
|
+
# out-of-range value would otherwise surface as a confusing ext-level
|
|
881
|
+
# type error (or a raw RangeError from the native conversion).
|
|
882
|
+
def coerce_root_disk_size(value, label)
|
|
883
|
+
mib = Integer(value)
|
|
884
|
+
unless mib.positive? && mib <= 4_294_967_295
|
|
885
|
+
raise ArgumentError,
|
|
886
|
+
"#{label} must be a positive size in MiB that fits in 32 bits (got #{mib})"
|
|
887
|
+
end
|
|
888
|
+
mib
|
|
889
|
+
end
|
|
890
|
+
|
|
819
891
|
def normalize_volumes(volumes)
|
|
820
892
|
volumes.map do |guest, spec|
|
|
821
893
|
mount = {"guest" => guest.to_s}
|
|
@@ -868,6 +940,9 @@ module Microsandbox
|
|
|
868
940
|
# read-only; `noexec:`/`nosuid:`/`nodev:` set the matching flags;
|
|
869
941
|
# `stat_virtualization:`/`host_permissions:` set the passthrough policies
|
|
870
942
|
# (only valid on bind/named — the core rejects them on tmpfs/disk).
|
|
943
|
+
# `follow_root_symlinks:` opts out of the default-on mount-root symlink
|
|
944
|
+
# protection (runtime v0.6.7; bind/named only — the core silently ignores
|
|
945
|
+
# it on tmpfs/disk mounts, so reject those here instead).
|
|
871
946
|
def apply_mount_flags(mount, spec)
|
|
872
947
|
mount["readonly"] = true if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
|
|
873
948
|
mount["noexec"] = true if spec[:noexec] || spec["noexec"]
|
|
@@ -878,6 +953,15 @@ module Microsandbox
|
|
|
878
953
|
mount["stat_virtualization"] = sv.to_s if sv
|
|
879
954
|
hp = spec[:host_permissions] || spec["host_permissions"]
|
|
880
955
|
mount["host_permissions"] = hp.to_s if hp
|
|
956
|
+
follow = spec.fetch(:follow_root_symlinks, spec["follow_root_symlinks"])
|
|
957
|
+
unless follow.nil?
|
|
958
|
+
unless %w[bind named].include?(mount["kind"])
|
|
959
|
+
raise ArgumentError,
|
|
960
|
+
"follow_root_symlinks: only applies to bind/named mounts " \
|
|
961
|
+
"(got a #{mount["kind"]} mount); a #{mount["kind"]} mount has no host root to protect"
|
|
962
|
+
end
|
|
963
|
+
mount["follow_root_symlinks"] = !!follow
|
|
964
|
+
end
|
|
881
965
|
end
|
|
882
966
|
|
|
883
967
|
# Translate the pre-0.7.0 `options:` array form (e.g. options: %w[ro noexec])
|
|
@@ -918,16 +1002,18 @@ module Microsandbox
|
|
|
918
1002
|
end
|
|
919
1003
|
end
|
|
920
1004
|
|
|
921
|
-
# Route the `network:` argument to
|
|
922
|
-
# (`opts["
|
|
923
|
-
# policy path (`opts["network_policy"]`). Accepts
|
|
924
|
-
# {NetworkPolicy}, or a plain Hash.
|
|
1005
|
+
# Route the `network:` argument to the preset path (`opts["network"]`),
|
|
1006
|
+
# the composed-profiles path (`opts["network_profiles"]`), or the custom
|
|
1007
|
+
# policy path (`opts["network_policy"]`). Accepts profile Symbol(s)/Array,
|
|
1008
|
+
# a preset String/Symbol, a {NetworkPolicy}, or a plain Hash.
|
|
925
1009
|
def apply_network_opts(opts, network)
|
|
926
1010
|
norm = NetworkPolicy.coerce(network)
|
|
927
1011
|
return if norm.empty? # e.g. network: {} — leave the default policy in place
|
|
928
1012
|
|
|
929
1013
|
if norm.keys == ["preset"]
|
|
930
1014
|
opts["network"] = norm["preset"]
|
|
1015
|
+
elsif norm.keys == ["profiles"]
|
|
1016
|
+
opts["network_profiles"] = norm["profiles"]
|
|
931
1017
|
else
|
|
932
1018
|
opts["network_policy"] = norm
|
|
933
1019
|
end
|