microsandbox-rb 0.5.8 → 0.5.10
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 +125 -0
- data/Cargo.lock +90 -45
- data/DESIGN.md +32 -19
- data/README.md +108 -36
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/extconf.rb +6 -2
- data/ext/microsandbox/src/agent.rs +166 -0
- data/ext/microsandbox/src/backend.rs +170 -0
- data/ext/microsandbox/src/conv.rs +19 -1
- data/ext/microsandbox/src/error.rs +6 -0
- data/ext/microsandbox/src/image.rs +7 -7
- data/ext/microsandbox/src/lib.rs +31 -4
- data/ext/microsandbox/src/sandbox.rs +666 -61
- data/ext/microsandbox/src/ssh.rs +317 -0
- data/ext/microsandbox/src/volume.rs +6 -1
- data/lib/microsandbox/agent.rb +181 -0
- data/lib/microsandbox/errors.rb +6 -0
- data/lib/microsandbox/exec_handle.rb +14 -11
- data/lib/microsandbox/fs.rb +7 -7
- data/lib/microsandbox/image.rb +1 -1
- data/lib/microsandbox/network.rb +300 -0
- data/lib/microsandbox/patch.rb +98 -0
- data/lib/microsandbox/sandbox.rb +285 -77
- data/lib/microsandbox/snapshot.rb +2 -2
- data/lib/microsandbox/ssh.rb +247 -0
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox/volume.rb +3 -3
- data/lib/microsandbox.rb +65 -0
- data/sig/microsandbox.rbs +164 -14
- metadata +8 -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
|