microsandbox-rb 0.5.7 → 0.5.9
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 +75 -1
- data/Cargo.lock +1 -1
- data/DESIGN.md +46 -18
- data/README.md +103 -33
- data/ext/microsandbox/Cargo.toml +4 -1
- data/ext/microsandbox/extconf.rb +35 -0
- data/ext/microsandbox/src/agent.rs +166 -0
- data/ext/microsandbox/src/conv.rs +19 -1
- data/ext/microsandbox/src/lib.rs +4 -0
- data/ext/microsandbox/src/sandbox.rs +562 -3
- data/ext/microsandbox/src/ssh.rs +317 -0
- data/lib/microsandbox/agent.rb +181 -0
- data/lib/microsandbox/network.rb +300 -0
- data/lib/microsandbox/patch.rb +98 -0
- data/lib/microsandbox/sandbox.rb +119 -4
- data/lib/microsandbox/ssh.rb +247 -0
- data/lib/microsandbox/version.rb +5 -3
- data/lib/microsandbox.rb +47 -2
- data/sig/microsandbox.rbs +136 -1
- metadata +7 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# Factory for network-policy **rule destinations**. A destination is what an
|
|
5
|
+
# egress rule reaches (or, for an ingress rule, the connecting peer). Use the
|
|
6
|
+
# explicit constructors for unambiguous typing, or pass a plain String to
|
|
7
|
+
# {Rule.allow}/{Rule.deny} for shorthand classification (see {Rule}).
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# Microsandbox::Destination.cidr("10.0.0.0/8")
|
|
11
|
+
# Microsandbox::Destination.domain("api.openai.com")
|
|
12
|
+
# Microsandbox::Destination.group(:public)
|
|
13
|
+
#
|
|
14
|
+
# Mirrors the `Destination` factory in the official Python/Node/Go SDKs.
|
|
15
|
+
module Destination
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Match any destination.
|
|
19
|
+
def any = { "destination_kind" => "any" }
|
|
20
|
+
|
|
21
|
+
# A single IP address (stored as a /32 or /128).
|
|
22
|
+
def ip(value) = { "destination_kind" => "ip", "destination" => value.to_s }
|
|
23
|
+
|
|
24
|
+
# An IP network in CIDR notation (e.g. "10.0.0.0/8").
|
|
25
|
+
def cidr(value) = { "destination_kind" => "cidr", "destination" => value.to_s }
|
|
26
|
+
|
|
27
|
+
# An exact domain name (matched against the resolved-hostname cache / SNI).
|
|
28
|
+
def domain(value) = { "destination_kind" => "domain", "destination" => value.to_s }
|
|
29
|
+
|
|
30
|
+
# A domain suffix — matches the apex and any subdomain (e.g. ".internal").
|
|
31
|
+
def domain_suffix(value) = { "destination_kind" => "domain_suffix", "destination" => value.to_s }
|
|
32
|
+
|
|
33
|
+
# A predefined group: :public, :loopback, :private, :link_local, :metadata,
|
|
34
|
+
# :multicast, or :host.
|
|
35
|
+
def group(value) = { "destination_kind" => "group", "destination" => value.to_s.tr("_", "-") }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Factory for a single network-policy **rule**. A rule pairs an action
|
|
39
|
+
# (allow/deny) with a direction, a destination, and optional protocol/port
|
|
40
|
+
# filters; rules are evaluated first-match-wins per direction.
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# Microsandbox::Rule.allow(destination: "1.1.1.1", protocol: :tcp, port: "443")
|
|
44
|
+
# Microsandbox::Rule.deny(destination: Microsandbox::Destination.group(:metadata))
|
|
45
|
+
# Microsandbox::Rule.allow(direction: :ingress, destination: "10.0.0.0/8", port: "8000-9000")
|
|
46
|
+
#
|
|
47
|
+
# `destination:` accepts a {Destination} Hash, a shorthand String
|
|
48
|
+
# ("*", "public", "1.1.1.1", "10.0.0.0/8", ".internal", "api.example.com"),
|
|
49
|
+
# or nil (any). Mirrors the `Rule` factory in the official SDKs.
|
|
50
|
+
module Rule
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Build an allow rule. See {Rule} for argument semantics.
|
|
54
|
+
# @return [Hash]
|
|
55
|
+
def allow(destination: nil, direction: :egress, protocol: nil, protocols: nil, port: nil, ports: nil)
|
|
56
|
+
build("allow", destination, direction, protocol, protocols, port, ports)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build a deny rule.
|
|
60
|
+
# @return [Hash]
|
|
61
|
+
def deny(destination: nil, direction: :egress, protocol: nil, protocols: nil, port: nil, ports: nil)
|
|
62
|
+
build("deny", destination, direction, protocol, protocols, port, ports)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @api private
|
|
66
|
+
def build(action, destination, direction, protocol, protocols, port, ports)
|
|
67
|
+
rule = { "action" => action, "direction" => direction.to_s }
|
|
68
|
+
rule.merge!(normalize_destination(destination))
|
|
69
|
+
protos = (Array(protocols) + Array(protocol)).compact.map(&:to_s)
|
|
70
|
+
rule["protocols"] = protos unless protos.empty?
|
|
71
|
+
prts = (Array(ports) + Array(port)).compact.map(&:to_s)
|
|
72
|
+
rule["ports"] = prts unless prts.empty?
|
|
73
|
+
rule
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @api private
|
|
77
|
+
def normalize_destination(dest)
|
|
78
|
+
case dest
|
|
79
|
+
when nil then {}
|
|
80
|
+
when Hash then dest.each_with_object({}) { |(k, v), a| a[k.to_s] = v }
|
|
81
|
+
when String, Symbol then { "destination" => dest.to_s }
|
|
82
|
+
else raise ArgumentError, "invalid rule destination: #{dest.inspect}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
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.
|
|
89
|
+
#
|
|
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}.
|
|
92
|
+
#
|
|
93
|
+
# @example presets
|
|
94
|
+
# Sandbox.create("b", image: "alpine", network: NetworkPolicy.public_only)
|
|
95
|
+
# Sandbox.create("b", image: "alpine", network: :none)
|
|
96
|
+
#
|
|
97
|
+
# @example custom
|
|
98
|
+
# policy = Microsandbox::NetworkPolicy.custom(
|
|
99
|
+
# default_egress: :deny,
|
|
100
|
+
# rules: [
|
|
101
|
+
# Microsandbox::Rule.allow(destination: "api.openai.com", protocol: :tcp, port: "443"),
|
|
102
|
+
# ],
|
|
103
|
+
# deny_domain_suffixes: [".ads.example"],
|
|
104
|
+
# )
|
|
105
|
+
# Sandbox.create("b", image: "alpine", network: policy)
|
|
106
|
+
#
|
|
107
|
+
# Mirrors `NetworkPolicy` / `Network` in the official Python/Node/Go SDKs.
|
|
108
|
+
class NetworkPolicy
|
|
109
|
+
# Canonical preset names keyed by every accepted alias.
|
|
110
|
+
PRESET_ALIASES = {
|
|
111
|
+
"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"
|
|
116
|
+
}.freeze
|
|
117
|
+
|
|
118
|
+
class << self
|
|
119
|
+
# @return [NetworkPolicy] allow only public internet (the default)
|
|
120
|
+
def public_only = preset("public_only")
|
|
121
|
+
|
|
122
|
+
# @return [NetworkPolicy] block all network access
|
|
123
|
+
def none = preset("none")
|
|
124
|
+
|
|
125
|
+
# @return [NetworkPolicy] permit all traffic
|
|
126
|
+
def allow_all = preset("allow_all")
|
|
127
|
+
|
|
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
|
|
132
|
+
def preset(name)
|
|
133
|
+
new("preset" => canonical_preset(name))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# 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.
|
|
141
|
+
#
|
|
142
|
+
# @param default_egress [:deny, :allow, nil] fall-through for unmatched
|
|
143
|
+
# outbound traffic (default :deny)
|
|
144
|
+
# @param default_ingress [:deny, :allow, nil] fall-through for unmatched
|
|
145
|
+
# inbound traffic (default :allow)
|
|
146
|
+
# @param rules [Array<Hash>] ordered {Rule}s (first match wins per direction)
|
|
147
|
+
# @param deny_domains [Array<String>] exact domains to deny egress to
|
|
148
|
+
# (prepended, so they outrank later allow rules)
|
|
149
|
+
# @param deny_domain_suffixes [Array<String>] domain suffixes to deny
|
|
150
|
+
# @return [NetworkPolicy]
|
|
151
|
+
def custom(default_egress: :deny, default_ingress: :allow, rules: [],
|
|
152
|
+
deny_domains: [], deny_domain_suffixes: [])
|
|
153
|
+
h = {}
|
|
154
|
+
h["default_egress"] = action_str(default_egress) unless default_egress.nil?
|
|
155
|
+
h["default_ingress"] = action_str(default_ingress) unless default_ingress.nil?
|
|
156
|
+
h["rules"] = Array(rules).map { |r| normalize_rule(r) }
|
|
157
|
+
add_deny_lists(h, deny_domains, deny_domain_suffixes)
|
|
158
|
+
new(h)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Coerce a user-facing `network:` value into a normalized wire Hash.
|
|
162
|
+
# @api private
|
|
163
|
+
def coerce(network)
|
|
164
|
+
case network
|
|
165
|
+
when NetworkPolicy then network.to_h
|
|
166
|
+
when String, Symbol then { "preset" => canonical_preset(network) }
|
|
167
|
+
when Hash then from_hash(network)
|
|
168
|
+
else
|
|
169
|
+
raise ArgumentError,
|
|
170
|
+
"network: expects a preset name, a Microsandbox::NetworkPolicy, or a Hash " \
|
|
171
|
+
"(got #{network.class})"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
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.
|
|
184
|
+
def from_hash(hash)
|
|
185
|
+
sym = hash.transform_keys(&:to_sym)
|
|
186
|
+
if sym.key?(:preset)
|
|
187
|
+
if sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
|
|
188
|
+
raise ArgumentError,
|
|
189
|
+
"network preset: cannot be combined with rules:/default_egress:/" \
|
|
190
|
+
"default_ingress: (the preset already defines its rules and defaults); " \
|
|
191
|
+
"only deny_domains:/deny_domain_suffixes: may be layered on a preset"
|
|
192
|
+
end
|
|
193
|
+
h = { "preset" => canonical_preset(sym[:preset]) }
|
|
194
|
+
add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
|
|
195
|
+
h
|
|
196
|
+
elsif sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
|
|
197
|
+
# An explicit custom policy: the caller chose the rule list and/or the
|
|
198
|
+
# fall-through defaults (which default to :deny / :allow).
|
|
199
|
+
custom(
|
|
200
|
+
default_egress: sym.fetch(:default_egress, :deny),
|
|
201
|
+
default_ingress: sym.fetch(:default_ingress, :allow),
|
|
202
|
+
rules: sym[:rules] || [],
|
|
203
|
+
deny_domains: sym[:deny_domains] || [],
|
|
204
|
+
deny_domain_suffixes: sym[:deny_domain_suffixes] || []
|
|
205
|
+
).to_h
|
|
206
|
+
else
|
|
207
|
+
# Deny-list-only shorthand (`network: { deny_domains: [...] }`): keep the
|
|
208
|
+
# rest of the network reachable and just block the listed domains, using
|
|
209
|
+
# permissive defaults — mirrors the official SDKs' "full network minus
|
|
210
|
+
# blocked domains" semantics. An empty Hash is a no-op (leaves the
|
|
211
|
+
# default policy in place).
|
|
212
|
+
dd = Array(sym[:deny_domains]).map(&:to_s)
|
|
213
|
+
ds = Array(sym[:deny_domain_suffixes]).map(&:to_s)
|
|
214
|
+
return {} if dd.empty? && ds.empty?
|
|
215
|
+
|
|
216
|
+
h = { "default_egress" => "allow", "default_ingress" => "allow" }
|
|
217
|
+
add_deny_lists(h, dd, ds)
|
|
218
|
+
h
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Append `deny_domains`/`deny_domain_suffixes` to a wire Hash, omitting
|
|
223
|
+
# empty lists. Returns the Hash.
|
|
224
|
+
def add_deny_lists(h, deny_domains, deny_domain_suffixes)
|
|
225
|
+
dd = Array(deny_domains).map(&:to_s)
|
|
226
|
+
h["deny_domains"] = dd unless dd.empty?
|
|
227
|
+
ds = Array(deny_domain_suffixes).map(&:to_s)
|
|
228
|
+
h["deny_domain_suffixes"] = ds unless ds.empty?
|
|
229
|
+
h
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def canonical_preset(name)
|
|
233
|
+
key = name.to_s.downcase
|
|
234
|
+
PRESET_ALIASES[key] ||
|
|
235
|
+
raise(ArgumentError,
|
|
236
|
+
"unknown network preset #{name.inspect} " \
|
|
237
|
+
"(expected one of public_only/none/allow_all/non_local)")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def action_str(action)
|
|
241
|
+
case action.to_s.downcase
|
|
242
|
+
when "allow" then "allow"
|
|
243
|
+
when "deny" then "deny"
|
|
244
|
+
else raise ArgumentError, "network action must be :allow or :deny (got #{action.inspect})"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Canonicalize a rule Hash (from the {Rule} factory or hand-written) into
|
|
249
|
+
# the wire shape the native parser reads. Accepts singular `protocol`/`port`
|
|
250
|
+
# (the spelling the Go/Python `PolicyRule` use) as well as the plural
|
|
251
|
+
# `protocols`/`ports`, and a `destination` that is a shorthand String or a
|
|
252
|
+
# {Destination} Hash — so a hand-written `{ action:, destination:, protocol:,
|
|
253
|
+
# port: }` rule behaves identically to a factory-built one (without this, a
|
|
254
|
+
# singular `protocol`/`port` was silently dropped, widening the rule).
|
|
255
|
+
def normalize_rule(rule)
|
|
256
|
+
unless rule.is_a?(Hash)
|
|
257
|
+
raise ArgumentError, "rule must be a Hash (use Microsandbox::Rule.allow/deny): #{rule.inspect}"
|
|
258
|
+
end
|
|
259
|
+
sym = rule.transform_keys { |k| k.to_s.to_sym }
|
|
260
|
+
out = {}
|
|
261
|
+
out["action"] = sym[:action].to_s if sym[:action]
|
|
262
|
+
out["direction"] = sym[:direction].to_s if sym[:direction]
|
|
263
|
+
normalize_rule_destination(sym, out)
|
|
264
|
+
protos = (Array(sym[:protocols]) + Array(sym[:protocol])).compact.map(&:to_s)
|
|
265
|
+
out["protocols"] = protos unless protos.empty?
|
|
266
|
+
ports = (Array(sym[:ports]) + Array(sym[:port])).compact.map(&:to_s)
|
|
267
|
+
out["ports"] = ports unless ports.empty?
|
|
268
|
+
out
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Resolve a rule's destination (explicit kind+value, a {Destination} Hash,
|
|
272
|
+
# or a shorthand String) onto the wire `out` Hash.
|
|
273
|
+
def normalize_rule_destination(sym, out)
|
|
274
|
+
if sym.key?(:destination_kind)
|
|
275
|
+
out["destination_kind"] = sym[:destination_kind].to_s
|
|
276
|
+
out["destination"] = sym[:destination].to_s unless sym[:destination].nil?
|
|
277
|
+
elsif sym[:destination].is_a?(Hash)
|
|
278
|
+
dest = sym[:destination].transform_keys(&:to_s)
|
|
279
|
+
out["destination_kind"] = dest["destination_kind"].to_s if dest["destination_kind"]
|
|
280
|
+
out["destination"] = dest["destination"].to_s if dest.key?("destination")
|
|
281
|
+
elsif !sym[:destination].nil?
|
|
282
|
+
out["destination"] = sym[:destination].to_s
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def initialize(wire)
|
|
288
|
+
@wire = wire
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @return [Hash] the normalized wire representation
|
|
292
|
+
def to_h
|
|
293
|
+
@wire
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def inspect
|
|
297
|
+
"#<Microsandbox::NetworkPolicy #{@wire.inspect}>"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# Factory for **rootfs patches** — modifications applied to a sandbox's root
|
|
5
|
+
# filesystem *before* the microVM boots. Pass them to {Sandbox.create} via the
|
|
6
|
+
# `patches:` keyword:
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# Microsandbox::Sandbox.create("box", image: "alpine",
|
|
10
|
+
# patches: [
|
|
11
|
+
# Microsandbox::Patch.text("/etc/app.conf", "key = value\n", mode: 0o644),
|
|
12
|
+
# Microsandbox::Patch.mkdir("/opt/app"),
|
|
13
|
+
# Microsandbox::Patch.copy_file("./cert.pem", "/etc/ssl/app.pem"),
|
|
14
|
+
# Microsandbox::Patch.symlink("/etc/app.conf", "/etc/app.link"),
|
|
15
|
+
# Microsandbox::Patch.remove("/etc/motd"),
|
|
16
|
+
# ]) do |sb|
|
|
17
|
+
# # ...
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Patches apply to OverlayFS (OCI) and bind rootfs; they are **not** compatible
|
|
21
|
+
# with disk-image roots. Each factory returns a plain Hash, so a patch list is
|
|
22
|
+
# just an Array of Hashes — you may also build them by hand. Mirrors the
|
|
23
|
+
# `Patch` factory in the official Python/Node/Go SDKs.
|
|
24
|
+
module Patch
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Write UTF-8 text to a file, creating it (or replacing it when +replace+).
|
|
28
|
+
# @param path [String] absolute guest path
|
|
29
|
+
# @param content [String] text to write
|
|
30
|
+
# @param mode [Integer, nil] file mode (e.g. 0o644)
|
|
31
|
+
# @param replace [Boolean] allow shadowing a path already in the rootfs
|
|
32
|
+
# @return [Hash]
|
|
33
|
+
def text(path, content, mode: nil, replace: false)
|
|
34
|
+
h = { "kind" => "text", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false }
|
|
35
|
+
h["mode"] = Integer(mode) unless mode.nil?
|
|
36
|
+
h
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Write raw bytes to a file (binary-safe; content may contain NUL).
|
|
40
|
+
# @param path [String] absolute guest path
|
|
41
|
+
# @param content [String] raw bytes to write
|
|
42
|
+
# @param mode [Integer, nil] file mode
|
|
43
|
+
# @param replace [Boolean]
|
|
44
|
+
# @return [Hash]
|
|
45
|
+
def file(path, content, mode: nil, replace: false)
|
|
46
|
+
h = { "kind" => "file", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false }
|
|
47
|
+
h["mode"] = Integer(mode) unless mode.nil?
|
|
48
|
+
h
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Append text to an existing file. For OCI roots, a file living only in a
|
|
52
|
+
# lower image layer is copied up first, then appended.
|
|
53
|
+
# @return [Hash]
|
|
54
|
+
def append(path, content)
|
|
55
|
+
{ "kind" => "append", "path" => path.to_s, "content" => content.to_s }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Copy a host file into the rootfs.
|
|
59
|
+
# @param src [String] host path
|
|
60
|
+
# @param dst [String] absolute guest destination
|
|
61
|
+
# @param mode [Integer, nil] file mode (preserves source mode when nil)
|
|
62
|
+
# @param replace [Boolean]
|
|
63
|
+
# @return [Hash]
|
|
64
|
+
def copy_file(src, dst, mode: nil, replace: false)
|
|
65
|
+
h = { "kind" => "copy_file", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false }
|
|
66
|
+
h["mode"] = Integer(mode) unless mode.nil?
|
|
67
|
+
h
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Copy a host directory (recursively) into the rootfs.
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def copy_dir(src, dst, replace: false)
|
|
73
|
+
{ "kind" => "copy_dir", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Create a symlink at +link+ pointing to +target+.
|
|
77
|
+
# @return [Hash]
|
|
78
|
+
def symlink(target, link, replace: false)
|
|
79
|
+
{ "kind" => "symlink", "target" => target.to_s, "link" => link.to_s, "replace" => replace ? true : false }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Create a directory (idempotent — no error if it already exists).
|
|
83
|
+
# @param path [String] absolute guest path
|
|
84
|
+
# @param mode [Integer, nil] directory mode (e.g. 0o755)
|
|
85
|
+
# @return [Hash]
|
|
86
|
+
def mkdir(path, mode: nil)
|
|
87
|
+
h = { "kind" => "mkdir", "path" => path.to_s }
|
|
88
|
+
h["mode"] = Integer(mode) unless mode.nil?
|
|
89
|
+
h
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Remove a file or directory (idempotent — no error if absent).
|
|
93
|
+
# @return [Hash]
|
|
94
|
+
def remove(path)
|
|
95
|
+
{ "kind" => "remove", "path" => path.to_s }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -108,8 +108,15 @@ module Microsandbox
|
|
|
108
108
|
# @param entrypoint [Array<String>, nil] image entrypoint override
|
|
109
109
|
# @param ports [Hash, nil] host_port => guest_port TCP publications
|
|
110
110
|
# @param ports_udp [Hash, nil] host_port => guest_port UDP publications
|
|
111
|
-
# @param network [
|
|
112
|
-
#
|
|
111
|
+
# @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
|
|
112
|
+
# A preset name ("public_only" (default), "none", "allow_all",
|
|
113
|
+
# "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
|
|
114
|
+
# describing a custom policy (`default_egress:`, `default_ingress:`,
|
|
115
|
+
# `rules:`, `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy}
|
|
116
|
+
# and {Rule}.
|
|
117
|
+
# @param patches [Array<Hash>, nil] rootfs patches applied before boot, each
|
|
118
|
+
# built with the {Patch} factory (e.g. `Patch.text(...)`, `Patch.mkdir(...)`).
|
|
119
|
+
# Not compatible with disk-image roots.
|
|
113
120
|
# @param log_level ["error","warn","info","debug","trace", nil] guest log verbosity
|
|
114
121
|
# @param quiet_logs [Boolean] suppress sandbox process logs
|
|
115
122
|
# @param security ["default", "restricted", nil] exec security profile
|
|
@@ -119,6 +126,14 @@ module Microsandbox
|
|
|
119
126
|
# @param rlimits [Hash, nil] resource limits: { resource => limit } or
|
|
120
127
|
# { resource => [soft, hard] } (e.g. { nofile: 65_535 })
|
|
121
128
|
# @param pull_policy ["always","if-missing","never", nil] image pull behavior
|
|
129
|
+
# @param registry_auth [Hash, nil] credentials for a private/authenticated
|
|
130
|
+
# registry: { username:, password: } (the password may be a token).
|
|
131
|
+
# Without this the core's default resolution chain still applies (OS
|
|
132
|
+
# keyring, global config, `~/.docker/config.json`).
|
|
133
|
+
# @param registry_insecure [Boolean] reach the registry over plain HTTP
|
|
134
|
+
# instead of HTTPS (for local/self-hosted registries)
|
|
135
|
+
# @param registry_ca_certs [String, Array<String>, nil] extra PEM-encoded CA
|
|
136
|
+
# root certificate(s) to trust (for a registry with a private CA)
|
|
122
137
|
# @param secrets [Array<Hash>, nil] placeholder-protected secrets, each
|
|
123
138
|
# { env:, value:, host: } — the value is substituted by the TLS proxy only
|
|
124
139
|
# for the allowed host (auto-enables TLS interception)
|
|
@@ -131,10 +146,13 @@ module Microsandbox
|
|
|
131
146
|
image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
|
|
132
147
|
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
133
148
|
entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
|
|
149
|
+
patches: nil,
|
|
134
150
|
from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
|
|
135
151
|
oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
|
|
136
|
-
pull_policy: nil,
|
|
152
|
+
pull_policy: nil, registry_auth: nil, registry_insecure: false,
|
|
153
|
+
registry_ca_certs: nil, secrets: nil,
|
|
137
154
|
detached: false, replace: false, replace_with_timeout: nil)
|
|
155
|
+
Microsandbox.ensure_runtime!
|
|
138
156
|
opts = {}
|
|
139
157
|
opts["image"] = image.to_s if image
|
|
140
158
|
opts["from_snapshot"] = from_snapshot.to_s if from_snapshot
|
|
@@ -151,7 +169,8 @@ module Microsandbox
|
|
|
151
169
|
opts["ports"] = intify_ports(ports) if ports
|
|
152
170
|
opts["ports_udp"] = intify_ports(ports_udp) if ports_udp
|
|
153
171
|
opts["volumes"] = normalize_volumes(volumes) if volumes
|
|
154
|
-
opts["
|
|
172
|
+
opts["patches"] = normalize_patches(patches) if patches
|
|
173
|
+
apply_network_opts(opts, network) unless network.nil?
|
|
155
174
|
opts["log_level"] = log_level.to_s if log_level
|
|
156
175
|
opts["quiet_logs"] = true if quiet_logs
|
|
157
176
|
opts["security"] = security.to_s if security
|
|
@@ -160,6 +179,7 @@ module Microsandbox
|
|
|
160
179
|
opts["idle_timeout"] = Integer(idle_timeout) if idle_timeout
|
|
161
180
|
opts["rlimits"] = normalize_rlimits(rlimits) if rlimits
|
|
162
181
|
opts["pull_policy"] = pull_policy.to_s if pull_policy
|
|
182
|
+
apply_registry_opts(opts, registry_auth, registry_insecure, registry_ca_certs)
|
|
163
183
|
opts["secrets"] = normalize_secrets(secrets) if secrets
|
|
164
184
|
opts["detached"] = true if detached
|
|
165
185
|
if replace_with_timeout
|
|
@@ -185,6 +205,7 @@ module Microsandbox
|
|
|
185
205
|
# Restart a previously-defined sandbox by name.
|
|
186
206
|
# @return [Sandbox]
|
|
187
207
|
def start(name, detached: false)
|
|
208
|
+
Microsandbox.ensure_runtime!
|
|
188
209
|
new(Native::Sandbox.start(name.to_s, { "detached" => detached }))
|
|
189
210
|
end
|
|
190
211
|
|
|
@@ -225,6 +246,25 @@ module Microsandbox
|
|
|
225
246
|
ports.each_with_object({}) { |(k, v), acc| acc[Integer(k)] = Integer(v) }
|
|
226
247
|
end
|
|
227
248
|
|
|
249
|
+
# Flatten the registry options into the native layer's `registry_*` keys.
|
|
250
|
+
# `auth` is a Hash { username:, password: } (string or symbol keys); both
|
|
251
|
+
# are required when given. `ca_certs` accepts one PEM string or an Array.
|
|
252
|
+
def apply_registry_opts(opts, auth, insecure, ca_certs)
|
|
253
|
+
if auth
|
|
254
|
+
username = auth[:username] || auth["username"]
|
|
255
|
+
password = auth[:password] || auth["password"]
|
|
256
|
+
unless username && password
|
|
257
|
+
# Report only the keys given, never the values — auth carries secrets.
|
|
258
|
+
raise ArgumentError,
|
|
259
|
+
"registry_auth needs :username and :password (got keys: #{auth.keys.inspect})"
|
|
260
|
+
end
|
|
261
|
+
opts["registry_username"] = username.to_s
|
|
262
|
+
opts["registry_password"] = password.to_s
|
|
263
|
+
end
|
|
264
|
+
opts["registry_insecure"] = true if insecure
|
|
265
|
+
opts["registry_ca_certs"] = Array(ca_certs).map(&:to_s) if ca_certs
|
|
266
|
+
end
|
|
267
|
+
|
|
228
268
|
# Normalize secrets into [env, value, host] triples for the native layer.
|
|
229
269
|
# Each entry is a Hash { env:, value:, host: } (string or symbol keys).
|
|
230
270
|
def normalize_secrets(secrets)
|
|
@@ -271,6 +311,33 @@ module Microsandbox
|
|
|
271
311
|
end
|
|
272
312
|
end
|
|
273
313
|
end
|
|
314
|
+
|
|
315
|
+
# Normalize a list of patches (each a Hash from the {Patch} factory, or a
|
|
316
|
+
# plain Hash) into string-keyed Hashes for the native layer. Values are
|
|
317
|
+
# passed through unchanged (mode stays Integer, content stays String).
|
|
318
|
+
def normalize_patches(patches)
|
|
319
|
+
Array(patches).map do |p|
|
|
320
|
+
unless p.is_a?(Hash)
|
|
321
|
+
raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*): #{p.inspect}"
|
|
322
|
+
end
|
|
323
|
+
p.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Route the `network:` argument to either the preset path
|
|
328
|
+
# (`opts["network"]`, the original string-preset behavior) or the custom
|
|
329
|
+
# policy path (`opts["network_policy"]`). Accepts a preset String/Symbol, a
|
|
330
|
+
# {NetworkPolicy}, or a plain Hash.
|
|
331
|
+
def apply_network_opts(opts, network)
|
|
332
|
+
norm = NetworkPolicy.coerce(network)
|
|
333
|
+
return if norm.empty? # e.g. network: {} — leave the default policy in place
|
|
334
|
+
|
|
335
|
+
if norm.keys == ["preset"]
|
|
336
|
+
opts["network"] = norm["preset"]
|
|
337
|
+
else
|
|
338
|
+
opts["network_policy"] = norm
|
|
339
|
+
end
|
|
340
|
+
end
|
|
274
341
|
end
|
|
275
342
|
|
|
276
343
|
def initialize(native)
|
|
@@ -320,12 +387,60 @@ module Microsandbox
|
|
|
320
387
|
exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
|
|
321
388
|
end
|
|
322
389
|
|
|
390
|
+
# Attach an interactive terminal to a command in the sandbox.
|
|
391
|
+
#
|
|
392
|
+
# Puts the **host** terminal into raw mode and forwards keystrokes (and
|
|
393
|
+
# SIGWINCH resizes) to the guest until the command exits or the detach
|
|
394
|
+
# sequence is typed. Requires a real TTY on stdin/stdout, so it is for CLI
|
|
395
|
+
# use, not library/automation code (use {#exec}/{#exec_stream} there). Blocks
|
|
396
|
+
# until the session ends. Mirrors the official SDKs' `attach`.
|
|
397
|
+
#
|
|
398
|
+
# @param command [String] the program to run
|
|
399
|
+
# @param args [Array<String>] its arguments
|
|
400
|
+
# @param cwd [String, nil] working directory
|
|
401
|
+
# @param user [String, nil] user to run as
|
|
402
|
+
# @param env [Hash, nil] extra environment variables
|
|
403
|
+
# @param detach_keys [String, nil] detach sequence (e.g. "ctrl-p,ctrl-q";
|
|
404
|
+
# default "ctrl-]")
|
|
405
|
+
# @param rlimits [Hash, nil] resource limits (see {#exec})
|
|
406
|
+
# @return [Integer] the command's exit code (or the code at detach)
|
|
407
|
+
def attach(command, args = [], cwd: nil, user: nil, env: nil, detach_keys: nil, rlimits: nil)
|
|
408
|
+
opts = {}
|
|
409
|
+
opts["cwd"] = cwd.to_s if cwd
|
|
410
|
+
opts["user"] = user.to_s if user
|
|
411
|
+
opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
|
|
412
|
+
opts["detach_keys"] = detach_keys.to_s if detach_keys
|
|
413
|
+
if rlimits
|
|
414
|
+
opts["rlimits"] = rlimits.map do |resource, limit|
|
|
415
|
+
soft, hard = limit.is_a?(Array) ? [limit[0], limit[1]] : [limit, limit]
|
|
416
|
+
[resource.to_s, Integer(soft), Integer(hard)]
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
@native.attach(command.to_s, Array(args).map(&:to_s), opts)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Attach an interactive terminal running the sandbox's default shell.
|
|
423
|
+
# See {#attach} for the host-TTY requirements.
|
|
424
|
+
# @return [Integer] the shell's exit code (or the code at detach)
|
|
425
|
+
def attach_shell
|
|
426
|
+
@native.attach_shell
|
|
427
|
+
end
|
|
428
|
+
|
|
323
429
|
# Guest filesystem operations.
|
|
324
430
|
# @return [FS]
|
|
325
431
|
def fs
|
|
326
432
|
@fs ||= FS.new(@native)
|
|
327
433
|
end
|
|
328
434
|
|
|
435
|
+
# SSH access to the sandbox — open a native in-process SSH client or prepare
|
|
436
|
+
# a reusable server endpoint.
|
|
437
|
+
# @return [SshOps]
|
|
438
|
+
# @example
|
|
439
|
+
# sb.ssh.open_client { |c| puts c.exec("hostname").stdout }
|
|
440
|
+
def ssh
|
|
441
|
+
SshOps.new(@native)
|
|
442
|
+
end
|
|
443
|
+
|
|
329
444
|
# Latest resource-usage snapshot.
|
|
330
445
|
# @return [Metrics]
|
|
331
446
|
def metrics
|