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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +139 -0
- data/Cargo.lock +82 -95
- data/README.md +21 -13
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/backend.rs +7 -9
- data/ext/microsandbox/src/error.rs +89 -1
- data/ext/microsandbox/src/image.rs +53 -5
- data/ext/microsandbox/src/lib.rs +9 -9
- data/ext/microsandbox/src/sandbox.rs +198 -55
- data/ext/microsandbox/src/snapshot.rs +79 -35
- data/ext/microsandbox/src/volume.rs +5 -3
- data/lib/microsandbox/errors.rb +16 -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 +174 -30
- data/lib/microsandbox/snapshot.rb +87 -37
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +1 -0
- data/sig/microsandbox.rbs +51 -14
- metadata +2 -1
data/lib/microsandbox/image.rb
CHANGED
|
@@ -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
|
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
|
|
@@ -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
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
462
|
-
#
|
|
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
|
|
528
|
+
build_page(Native::Sandbox.list)
|
|
465
529
|
end
|
|
466
530
|
|
|
467
|
-
# List
|
|
468
|
-
# @param labels [Hash] required key => value labels
|
|
469
|
-
# @
|
|
470
|
-
|
|
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
|
-
|
|
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
|
|
922
|
-
# (`opts["
|
|
923
|
-
# policy path (`opts["network_policy"]`). Accepts
|
|
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
|