microsandbox-rb 0.5.8 → 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 +48 -0
- data/Cargo.lock +1 -1
- data/DESIGN.md +25 -16
- data/README.md +18 -11
- data/ext/microsandbox/Cargo.toml +1 -1
- 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 +494 -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 +87 -3
- data/lib/microsandbox/ssh.rb +247 -0
- data/lib/microsandbox/version.rb +1 -1
- data/lib/microsandbox.rb +4 -0
- data/sig/microsandbox.rbs +133 -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
|
|
@@ -139,6 +146,7 @@ module Microsandbox
|
|
|
139
146
|
image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
|
|
140
147
|
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
141
148
|
entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
|
|
149
|
+
patches: nil,
|
|
142
150
|
from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
|
|
143
151
|
oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
|
|
144
152
|
pull_policy: nil, registry_auth: nil, registry_insecure: false,
|
|
@@ -161,7 +169,8 @@ module Microsandbox
|
|
|
161
169
|
opts["ports"] = intify_ports(ports) if ports
|
|
162
170
|
opts["ports_udp"] = intify_ports(ports_udp) if ports_udp
|
|
163
171
|
opts["volumes"] = normalize_volumes(volumes) if volumes
|
|
164
|
-
opts["
|
|
172
|
+
opts["patches"] = normalize_patches(patches) if patches
|
|
173
|
+
apply_network_opts(opts, network) unless network.nil?
|
|
165
174
|
opts["log_level"] = log_level.to_s if log_level
|
|
166
175
|
opts["quiet_logs"] = true if quiet_logs
|
|
167
176
|
opts["security"] = security.to_s if security
|
|
@@ -302,6 +311,33 @@ module Microsandbox
|
|
|
302
311
|
end
|
|
303
312
|
end
|
|
304
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
|
|
305
341
|
end
|
|
306
342
|
|
|
307
343
|
def initialize(native)
|
|
@@ -351,12 +387,60 @@ module Microsandbox
|
|
|
351
387
|
exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
|
|
352
388
|
end
|
|
353
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
|
+
|
|
354
429
|
# Guest filesystem operations.
|
|
355
430
|
# @return [FS]
|
|
356
431
|
def fs
|
|
357
432
|
@fs ||= FS.new(@native)
|
|
358
433
|
end
|
|
359
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
|
+
|
|
360
444
|
# Latest resource-usage snapshot.
|
|
361
445
|
# @return [Metrics]
|
|
362
446
|
def metrics
|