microsandbox-rb 0.16.0 → 0.17.1
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 +234 -1
- data/Cargo.lock +236 -201
- data/DESIGN.md +7 -38
- data/README.md +178 -626
- data/SECURITY.md +65 -0
- data/docs/configuration.md +154 -0
- data/docs/errors.md +74 -0
- data/docs/execution.md +101 -0
- data/docs/filesystem.md +45 -0
- data/docs/images.md +65 -0
- data/docs/lifecycle.md +108 -0
- data/docs/observability.md +99 -0
- data/docs/releasing.md +66 -0
- data/docs/runtime.md +152 -0
- data/docs/surface.md +70 -0
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +7 -0
- data/ext/microsandbox/src/host_path.rs +108 -0
- data/ext/microsandbox/src/lib.rs +1 -0
- data/ext/microsandbox/src/sandbox.rs +143 -31
- data/ext/microsandbox/src/volume.rs +43 -12
- data/lib/microsandbox/fs.rb +4 -8
- data/lib/microsandbox/image.rb +2 -2
- data/lib/microsandbox/log_entry.rb +1 -1
- data/lib/microsandbox/metrics.rb +1 -1
- data/lib/microsandbox/outbound_proxy.rb +340 -0
- data/lib/microsandbox/sandbox.rb +73 -10
- data/lib/microsandbox/snapshot.rb +1 -1
- data/lib/microsandbox/ssh.rb +2 -10
- data/lib/microsandbox/streams.rb +8 -5
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox/volume.rb +31 -14
- data/lib/microsandbox-rb.rb +7 -0
- data/lib/microsandbox.rb +89 -31
- data/sig/microsandbox.rbs +40 -1
- metadata +15 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# Host-side source for secret material (runtime v0.6.17). The only kind is
|
|
5
|
+
# `env`: the secret is read from an environment variable of the **host**
|
|
6
|
+
# process (the one running this gem) when the sandbox is created — the
|
|
7
|
+
# variable's *name* travels to the runtime, never its value.
|
|
8
|
+
#
|
|
9
|
+
# Used for the password of an authenticated SOCKS5 {OutboundProxy}. Mirrors
|
|
10
|
+
# the Python SDK's frozen `SecretSource` dataclass.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# Microsandbox::SecretSource.env("PROXY_PASSWORD")
|
|
14
|
+
class SecretSource
|
|
15
|
+
KINDS = %w[env].freeze
|
|
16
|
+
|
|
17
|
+
# @return [String] the source kind (always `"env"`)
|
|
18
|
+
attr_reader :kind
|
|
19
|
+
# @return [String] the host environment variable name
|
|
20
|
+
attr_reader :var
|
|
21
|
+
|
|
22
|
+
# Resolve the secret from a host environment variable.
|
|
23
|
+
# @param variable [String, Symbol] the variable name (non-empty)
|
|
24
|
+
# @return [SecretSource]
|
|
25
|
+
def self.env(variable)
|
|
26
|
+
new("env", variable)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Coerce a user-facing value into a {SecretSource}: an instance passes
|
|
30
|
+
# through, a Hash must be `{ env: "VAR" }` or the wire form
|
|
31
|
+
# `{ kind: "env", var: "VAR" }`.
|
|
32
|
+
#
|
|
33
|
+
# A rejected value is NEVER rendered into the error: a caller who reaches
|
|
34
|
+
# for `{ value: "hunter2" }` or `{ store: ... }` by analogy with other
|
|
35
|
+
# secret APIs has just handed us a real password, and the exception
|
|
36
|
+
# message is the one place it must not resurface (logs, bug reports).
|
|
37
|
+
# Errors describe the expected shape and, at most, the offending class.
|
|
38
|
+
# @api private
|
|
39
|
+
def self.coerce(value, context = "password")
|
|
40
|
+
case value
|
|
41
|
+
when SecretSource then value
|
|
42
|
+
when Hash
|
|
43
|
+
env = fetch_key(value, :env)
|
|
44
|
+
return new("env", env) unless env.nil?
|
|
45
|
+
|
|
46
|
+
kind = fetch_key(value, :kind)
|
|
47
|
+
var = fetch_key(value, :var)
|
|
48
|
+
if kind.nil? && var.nil?
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"#{context}: expects { env: \"VAR\" } naming a host environment variable " \
|
|
51
|
+
"(got a Hash without env:; plaintext password values are not accepted)"
|
|
52
|
+
end
|
|
53
|
+
new(kind, var)
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"#{context}: expects a Microsandbox::SecretSource (SecretSource.env(\"VAR\")) " \
|
|
57
|
+
"or a Hash { env: \"VAR\" } (got #{value.class}; plaintext password values " \
|
|
58
|
+
"are not accepted)"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @api private
|
|
63
|
+
def self.fetch_key(hash, key)
|
|
64
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
65
|
+
end
|
|
66
|
+
private_class_method :fetch_key
|
|
67
|
+
|
|
68
|
+
# @api private — use {.env}.
|
|
69
|
+
#
|
|
70
|
+
# Retained Strings are private frozen copies: the caller's originals are
|
|
71
|
+
# neither frozen nor aliased, so mutating them later (or a String obtained
|
|
72
|
+
# from {#var}/{#to_h}) cannot change this object's state. Only a
|
|
73
|
+
# String/Symbol kind or var is described in an error, and only by the
|
|
74
|
+
# allowed-shape wording — a nested Hash/other object is named by class so
|
|
75
|
+
# a misplaced secret is never rendered.
|
|
76
|
+
def initialize(kind, var)
|
|
77
|
+
unless kind.is_a?(String) || kind.is_a?(Symbol)
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"secret source kind must be \"env\" (got #{kind.class})"
|
|
80
|
+
end
|
|
81
|
+
# An unsupported kind is reported by class only, never by value: a
|
|
82
|
+
# caller who mis-keys a password into `kind:` must not see it echoed.
|
|
83
|
+
unless KINDS.include?(kind.to_s)
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"secret source kind must be \"env\" (only environment-backed secret sources " \
|
|
86
|
+
"are supported; got an unsupported #{kind.class})"
|
|
87
|
+
end
|
|
88
|
+
kind = kind.to_s
|
|
89
|
+
unless var.is_a?(String) || var.is_a?(Symbol)
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"secret source environment variable must be a String name (got #{var.class})"
|
|
92
|
+
end
|
|
93
|
+
var = var.to_s
|
|
94
|
+
raise ArgumentError, "secret source environment variable must not be empty" if var.empty?
|
|
95
|
+
@kind = kind.dup.freeze
|
|
96
|
+
@var = var.dup.freeze
|
|
97
|
+
freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Wire form for the native layer (Python's `_to_dict`).
|
|
101
|
+
# @return [Hash{String => String}]
|
|
102
|
+
def to_h
|
|
103
|
+
{"kind" => @kind, "var" => @var}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ==(other)
|
|
107
|
+
other.is_a?(SecretSource) && other.kind == kind && other.var == var
|
|
108
|
+
end
|
|
109
|
+
alias_method :eql?, :==
|
|
110
|
+
|
|
111
|
+
def hash
|
|
112
|
+
[SecretSource, @kind, @var].hash
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def inspect
|
|
116
|
+
"#<Microsandbox::SecretSource env=#{@var}>"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# An outbound proxy for a sandbox's egress traffic (runtime v0.6.17, upstream
|
|
121
|
+
# #1234 / #1507), passed to {Sandbox.create} via `proxy:`. The runtime's
|
|
122
|
+
# host-side network stack dials the proxy — so the address is resolved from
|
|
123
|
+
# the **host**, and `127.0.0.1` names the host's loopback, not the guest's.
|
|
124
|
+
#
|
|
125
|
+
# - {socks4} — SOCKS4 for TCP, with an optional `user_id:` sent in the
|
|
126
|
+
# handshake.
|
|
127
|
+
# - {socks5} — SOCKS5 for TCP and non-DNS UDP; chain {#credentials} for
|
|
128
|
+
# username/password authentication, the password coming from a host
|
|
129
|
+
# {SecretSource} (only its variable name reaches the runtime).
|
|
130
|
+
#
|
|
131
|
+
# The proxy applies uniformly to TLS-intercepted and bypassed/plain TCP; the
|
|
132
|
+
# egress policy (`network:`) still decides which destinations may be reached.
|
|
133
|
+
# Not accepted by the cloud backend (`UnsupportedError`).
|
|
134
|
+
#
|
|
135
|
+
# Instances are immutable: {#credentials} returns a new proxy. `proxy:` also
|
|
136
|
+
# accepts the equivalent plain Hash (see {.coerce}).
|
|
137
|
+
#
|
|
138
|
+
# @example
|
|
139
|
+
# Sandbox.create("worker", image: "python",
|
|
140
|
+
# proxy: Microsandbox::OutboundProxy.socks5("127.0.0.1:1080"))
|
|
141
|
+
# Sandbox.create("worker", image: "python",
|
|
142
|
+
# proxy: Microsandbox::OutboundProxy.socks5("10.0.0.5:1080")
|
|
143
|
+
# .credentials("sandbox", Microsandbox::SecretSource.env("PROXY_PASSWORD")))
|
|
144
|
+
# Sandbox.create("worker", image: "python",
|
|
145
|
+
# proxy: { protocol: :socks4, address: "127.0.0.1:1080", user_id: "ci" })
|
|
146
|
+
#
|
|
147
|
+
# Mirrors the Python SDK's frozen `OutboundProxy` dataclass (`socks4` /
|
|
148
|
+
# `socks5` / `credentials`).
|
|
149
|
+
class OutboundProxy
|
|
150
|
+
PROTOCOLS = %w[socks4 socks5].freeze
|
|
151
|
+
|
|
152
|
+
# @return [String] `"socks4"` or `"socks5"`
|
|
153
|
+
attr_reader :protocol
|
|
154
|
+
# @return [String] the proxy's `IP:port` address, as seen from the host
|
|
155
|
+
attr_reader :address
|
|
156
|
+
# @return [String, nil] SOCKS4 user ID
|
|
157
|
+
attr_reader :user_id
|
|
158
|
+
# @return [String, nil] SOCKS5 username
|
|
159
|
+
attr_reader :username
|
|
160
|
+
# @return [SecretSource, nil] SOCKS5 password source
|
|
161
|
+
attr_reader :password
|
|
162
|
+
|
|
163
|
+
# A SOCKS4 outbound proxy.
|
|
164
|
+
# @param address [String] `IP:port` of the proxy, resolved from the host
|
|
165
|
+
# @param user_id [String, nil] optional user ID sent in the SOCKS4 handshake
|
|
166
|
+
# @return [OutboundProxy]
|
|
167
|
+
def self.socks4(address, user_id: nil)
|
|
168
|
+
new(protocol: "socks4", address: address, user_id: user_id)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A SOCKS5 outbound proxy (unauthenticated; chain {#credentials}).
|
|
172
|
+
# @param address [String] `IP:port` of the proxy, resolved from the host
|
|
173
|
+
# @return [OutboundProxy]
|
|
174
|
+
def self.socks5(address)
|
|
175
|
+
new(protocol: "socks5", address: address)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Coerce a user-facing `proxy:` value into the normalized wire Hash:
|
|
179
|
+
# an {OutboundProxy}, or a Hash `{ protocol: :socks4|:socks5, address:,
|
|
180
|
+
# user_id:, credentials: { username:, password: SecretSource | { env: } } }`.
|
|
181
|
+
# @api private
|
|
182
|
+
# @return [Hash{String => untyped}]
|
|
183
|
+
def self.coerce(value)
|
|
184
|
+
case value
|
|
185
|
+
# No re-validation for an existing instance, and none is needed: the
|
|
186
|
+
# constructor is the only writer, the object is frozen (no ivar can be
|
|
187
|
+
# reassigned), and every retained String is a private frozen copy, so
|
|
188
|
+
# {#to_h} is a pure function of already-validated state. Re-running the
|
|
189
|
+
# checks would only re-examine data the constructor itself produced.
|
|
190
|
+
when OutboundProxy then value.to_h
|
|
191
|
+
when Hash then from_hash(value).to_h
|
|
192
|
+
else
|
|
193
|
+
raise ArgumentError,
|
|
194
|
+
"proxy: expects a Microsandbox::OutboundProxy (OutboundProxy.socks4/socks5) " \
|
|
195
|
+
"or a Hash { protocol:, address:, ... } (got #{value.class})"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @api private
|
|
200
|
+
def self.from_hash(hash)
|
|
201
|
+
protocol = fetch_key(hash, :protocol)
|
|
202
|
+
raise ArgumentError, "proxy: requires protocol: (:socks4 or :socks5)" if protocol.nil?
|
|
203
|
+
address = fetch_key(hash, :address)
|
|
204
|
+
raise ArgumentError, "proxy: requires address:" if address.nil?
|
|
205
|
+
user_id = fetch_key(hash, :user_id)
|
|
206
|
+
credentials = fetch_key(hash, :credentials)
|
|
207
|
+
username = nil
|
|
208
|
+
password = nil
|
|
209
|
+
unless credentials.nil?
|
|
210
|
+
unless credentials.is_a?(Hash)
|
|
211
|
+
raise ArgumentError,
|
|
212
|
+
"proxy credentials: must be a Hash { username:, password: } (got #{credentials.class})"
|
|
213
|
+
end
|
|
214
|
+
username = fetch_key(credentials, :username)
|
|
215
|
+
password = fetch_key(credentials, :password)
|
|
216
|
+
if username.nil? || password.nil?
|
|
217
|
+
raise ArgumentError, "proxy credentials: requires both username: and password:"
|
|
218
|
+
end
|
|
219
|
+
password = SecretSource.coerce(password, "proxy credentials password")
|
|
220
|
+
end
|
|
221
|
+
new(protocol: protocol, address: address, user_id: user_id,
|
|
222
|
+
username: username, password: password)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @api private
|
|
226
|
+
def self.fetch_key(hash, key)
|
|
227
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
228
|
+
end
|
|
229
|
+
private_class_method :fetch_key
|
|
230
|
+
|
|
231
|
+
# @api private — use {.socks4} / {.socks5}.
|
|
232
|
+
def initialize(protocol:, address:, user_id: nil, username: nil, password: nil)
|
|
233
|
+
unless protocol.is_a?(String) || protocol.is_a?(Symbol)
|
|
234
|
+
raise ArgumentError,
|
|
235
|
+
"unsupported outbound proxy protocol (got #{protocol.class}; expected :socks4 or :socks5)"
|
|
236
|
+
end
|
|
237
|
+
protocol = protocol.to_s.downcase
|
|
238
|
+
unless PROTOCOLS.include?(protocol)
|
|
239
|
+
raise ArgumentError,
|
|
240
|
+
"unsupported outbound proxy protocol (expected :socks4 or :socks5)"
|
|
241
|
+
end
|
|
242
|
+
unless address.is_a?(String)
|
|
243
|
+
raise ArgumentError,
|
|
244
|
+
"proxy address must be a non-empty \"IP:port\" String (got #{address.class})"
|
|
245
|
+
end
|
|
246
|
+
if address.empty?
|
|
247
|
+
raise ArgumentError, "proxy address must be a non-empty \"IP:port\" String (got \"\")"
|
|
248
|
+
end
|
|
249
|
+
# A URL-style address (`socks5://user:pass@host:1080`) is never valid, and
|
|
250
|
+
# the core would quote it back in its error. Refuse it by shape, without
|
|
251
|
+
# echoing it, so the embedded credentials stay out of logs.
|
|
252
|
+
if address.match?(%r{@|://|\s})
|
|
253
|
+
raise ArgumentError,
|
|
254
|
+
"proxy address must be \"IP:port\" with no scheme, credentials or whitespace " \
|
|
255
|
+
"(pass SOCKS5 credentials as username:/password:)"
|
|
256
|
+
end
|
|
257
|
+
# Same rules as the Python SDK's OutboundProxy.__post_init__; the address
|
|
258
|
+
# itself is parsed by the core (which reports e.g. "invalid SOCKS5 proxy
|
|
259
|
+
# address" as an InvalidConfigError at create time).
|
|
260
|
+
# Identifiers are documented as Strings (RBS: String?). A misplaced
|
|
261
|
+
# container (a credentials Hash, an Array) must not be stringified into
|
|
262
|
+
# the wire hash / #inspect; reject it by class, never rendering it.
|
|
263
|
+
unless user_id.nil? || user_id.is_a?(String) || user_id.is_a?(Symbol)
|
|
264
|
+
raise ArgumentError, "SOCKS4 user_id must be a String (got #{user_id.class})"
|
|
265
|
+
end
|
|
266
|
+
unless username.nil? || username.is_a?(String) || username.is_a?(Symbol)
|
|
267
|
+
raise ArgumentError, "SOCKS5 username must be a String (got #{username.class})"
|
|
268
|
+
end
|
|
269
|
+
if protocol != "socks4" && !user_id.nil?
|
|
270
|
+
raise ArgumentError, "user_id is only supported for SOCKS4 proxies"
|
|
271
|
+
end
|
|
272
|
+
if protocol != "socks5" && !(username.nil? && password.nil?)
|
|
273
|
+
raise ArgumentError, "credentials are only supported for SOCKS5 proxies"
|
|
274
|
+
end
|
|
275
|
+
if username.nil? != password.nil?
|
|
276
|
+
raise ArgumentError, "SOCKS5 username and password must be provided together"
|
|
277
|
+
end
|
|
278
|
+
unless password.nil? || password.is_a?(SecretSource)
|
|
279
|
+
raise ArgumentError,
|
|
280
|
+
"SOCKS5 password must be a Microsandbox::SecretSource (SecretSource.env(\"VAR\")), " \
|
|
281
|
+
"got #{password.class}"
|
|
282
|
+
end
|
|
283
|
+
# Private frozen copies (see SecretSource#initialize): the caller keeps
|
|
284
|
+
# its own, unfrozen Strings; readers and {#to_h} hand out these frozen
|
|
285
|
+
# ones, so neither side can mutate stored state after construction.
|
|
286
|
+
@protocol = protocol.dup.freeze
|
|
287
|
+
@address = address.dup.freeze
|
|
288
|
+
@user_id = user_id&.to_s&.dup&.freeze
|
|
289
|
+
@username = username&.to_s&.dup&.freeze
|
|
290
|
+
@password = password
|
|
291
|
+
freeze
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Username/password authentication for a SOCKS5 proxy. Returns a **new**
|
|
295
|
+
# proxy; the receiver is unchanged.
|
|
296
|
+
# @param username [String]
|
|
297
|
+
# @param password [SecretSource] host-side password source
|
|
298
|
+
# ({SecretSource.env}); its variable *name* is what reaches the runtime
|
|
299
|
+
# @return [OutboundProxy]
|
|
300
|
+
# @raise [ArgumentError] on a SOCKS4 proxy
|
|
301
|
+
def credentials(username, password)
|
|
302
|
+
raise ArgumentError, "credentials are only supported for SOCKS5 proxies" unless socks5?
|
|
303
|
+
self.class.new(protocol: @protocol, address: @address,
|
|
304
|
+
username: username, password: SecretSource.coerce(password, "proxy credentials password"))
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def socks4? = @protocol == "socks4"
|
|
308
|
+
|
|
309
|
+
def socks5? = @protocol == "socks5"
|
|
310
|
+
|
|
311
|
+
# Wire form for the native layer (Python's `_to_dict`): `user_id` only when
|
|
312
|
+
# set, `credentials` only when both halves are set.
|
|
313
|
+
# @return [Hash{String => untyped}]
|
|
314
|
+
def to_h
|
|
315
|
+
h = {"protocol" => @protocol, "address" => @address}
|
|
316
|
+
h["user_id"] = @user_id unless @user_id.nil?
|
|
317
|
+
if @username && @password
|
|
318
|
+
h["credentials"] = {"username" => @username, "password" => @password.to_h}
|
|
319
|
+
end
|
|
320
|
+
h
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def ==(other)
|
|
324
|
+
other.is_a?(OutboundProxy) && other.to_h == to_h
|
|
325
|
+
end
|
|
326
|
+
alias_method :eql?, :==
|
|
327
|
+
|
|
328
|
+
def hash
|
|
329
|
+
[OutboundProxy, to_h].hash
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Never renders a password value — there is none; only the env var name.
|
|
333
|
+
def inspect
|
|
334
|
+
parts = ["#{@protocol} #{@address}"]
|
|
335
|
+
parts << "user_id=#{@user_id}" if @user_id
|
|
336
|
+
parts << "username=#{@username} password=env:#{@password.var}" if @username
|
|
337
|
+
"#<Microsandbox::OutboundProxy #{parts.join(" ")}>"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -42,13 +42,13 @@ module Microsandbox
|
|
|
42
42
|
# @return [Time, nil]
|
|
43
43
|
def created_at
|
|
44
44
|
ms = @native.created_at_ms
|
|
45
|
-
|
|
45
|
+
Microsandbox.time_from_ms(ms)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# @return [Time, nil]
|
|
49
49
|
def updated_at
|
|
50
50
|
ms = @native.updated_at_ms
|
|
51
|
-
|
|
51
|
+
Microsandbox.time_from_ms(ms)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default).
|
|
@@ -263,7 +263,7 @@ module Microsandbox
|
|
|
263
263
|
|
|
264
264
|
# @return [Time] when the stopped state was observed
|
|
265
265
|
def observed_at
|
|
266
|
-
|
|
266
|
+
Microsandbox.time_from_ms(@observed_at_ms)
|
|
267
267
|
end
|
|
268
268
|
|
|
269
269
|
def inspect
|
|
@@ -408,12 +408,28 @@ module Microsandbox
|
|
|
408
408
|
# @param ipv6_pool [String, nil] guest IPv6 address pool CIDR
|
|
409
409
|
# @param max_connections [Integer, nil] cap on concurrent proxied connections
|
|
410
410
|
# @param trust_host_cas [Boolean, nil] trust the host's CA bundle for upstream TLS
|
|
411
|
+
# @param strict [Boolean, nil] strict hostname-policy mode (runtime
|
|
412
|
+
# v0.6.18, default false): a hostname-rule allow must be backed by an
|
|
413
|
+
# inspectable request authority — plain-HTTP `Host`, or SNI/authority
|
|
414
|
+
# under TLS interception. Without that visibility (e.g. bypassed or
|
|
415
|
+
# non-intercepted HTTPS) a flow allowed only by a hostname rule is
|
|
416
|
+
# denied before the upstream dial. Create-only; `modify` cannot change it.
|
|
411
417
|
# @param rate_limiter [Hash, nil] per-sandbox egress/ingress token-bucket
|
|
412
418
|
# limits (runtime v0.6.9, local backend):
|
|
413
419
|
# `{ egress: { bandwidth: { size: 1_048_576, refill_time_ms: 1000,
|
|
414
420
|
# one_time_burst: 0 }, ops: { size: 1000, refill_time_ms: 1000 } },
|
|
415
421
|
# ingress: { ... } }`. `bandwidth` buckets meter bytes, `ops` buckets
|
|
416
422
|
# meter network frames; an omitted bucket or direction is unlimited.
|
|
423
|
+
# @param proxy [OutboundProxy, Hash, nil] outbound SOCKS proxy for the
|
|
424
|
+
# sandbox's egress traffic (runtime v0.6.17, local backend):
|
|
425
|
+
# {OutboundProxy.socks4}(address, user_id:) or {OutboundProxy.socks5}
|
|
426
|
+
# (address), optionally `.credentials(username, SecretSource.env("VAR"))`
|
|
427
|
+
# — or the equivalent Hash `{ protocol: :socks5, address: "IP:port",
|
|
428
|
+
# credentials: { username:, password: { env: "VAR" } } }`. The address is
|
|
429
|
+
# dialed from the host; only the password's env var *name* is sent. The
|
|
430
|
+
# cloud backend rejects it with {UnsupportedError}. An unparseable
|
|
431
|
+
# address is rejected by the core at create time with
|
|
432
|
+
# {InvalidConfigError}, before any boot.
|
|
417
433
|
# @param vsock [Hash, Array, nil] host sockets exposed on guest-to-host
|
|
418
434
|
# vsock ports (runtime v0.6.9): `{ "/host/api.sock" => 5000 }` (stream
|
|
419
435
|
# sockets), or an Array of
|
|
@@ -479,6 +495,7 @@ module Microsandbox
|
|
|
479
495
|
# @return [Sandbox, Object] the sandbox, or the block's return value
|
|
480
496
|
def create(name, **kwargs, &block)
|
|
481
497
|
opts = build_create_opts(**kwargs)
|
|
498
|
+
Microsandbox.ensure_runtime!
|
|
482
499
|
sandbox = new(Native::Sandbox.create(name.to_s, opts))
|
|
483
500
|
return sandbox unless block_given?
|
|
484
501
|
|
|
@@ -535,6 +552,7 @@ module Microsandbox
|
|
|
535
552
|
# @return [Sandbox, Object] the live sandbox, or the block's return value
|
|
536
553
|
def connect_or_create(name, **kwargs, &block)
|
|
537
554
|
opts = build_create_opts(**kwargs)
|
|
555
|
+
Microsandbox.ensure_runtime!
|
|
538
556
|
sandbox = new(Native::Sandbox.connect_or_create(name.to_s, opts))
|
|
539
557
|
return sandbox unless block_given?
|
|
540
558
|
|
|
@@ -566,17 +584,22 @@ module Microsandbox
|
|
|
566
584
|
"for progress, then call #sandbox and stop it when done"
|
|
567
585
|
end
|
|
568
586
|
opts = build_create_opts(**kwargs)
|
|
587
|
+
Microsandbox.ensure_runtime!
|
|
569
588
|
PullSession.new(Native::Sandbox.create_with_progress(name.to_s, opts))
|
|
570
589
|
end
|
|
571
590
|
|
|
572
591
|
# @api private
|
|
573
|
-
# Shared keyword-option builder for {create}/{
|
|
592
|
+
# Shared keyword-option builder for {create}/{connect_or_create}/
|
|
593
|
+
# {create_with_progress}. Pure validation and normalization: the callers
|
|
594
|
+
# provision the runtime ({Microsandbox.ensure_runtime!}) only after this
|
|
595
|
+
# returns, so a rejected option never triggers a runtime download.
|
|
574
596
|
def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
|
|
575
597
|
env: nil, workdir: nil,
|
|
576
598
|
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
577
599
|
entrypoint: nil, cmd: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
|
|
578
600
|
dns: nil, tls: nil, ipv4_pool: nil, ipv6_pool: nil,
|
|
579
|
-
max_connections: nil, trust_host_cas: nil, rate_limiter: nil,
|
|
601
|
+
max_connections: nil, trust_host_cas: nil, strict: nil, rate_limiter: nil, proxy: nil,
|
|
602
|
+
vsock: nil,
|
|
580
603
|
patches: nil,
|
|
581
604
|
from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
|
|
582
605
|
log_level: nil, quiet_logs: false, security: nil,
|
|
@@ -593,7 +616,14 @@ module Microsandbox
|
|
|
593
616
|
if root_disk && oci_upper_size
|
|
594
617
|
raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
|
|
595
618
|
end
|
|
596
|
-
|
|
619
|
+
# The core refuses patches on a snapshot-booted sandbox, but only after
|
|
620
|
+
# it has claimed the sandbox name on disk. Refuse the pair before any
|
|
621
|
+
# side effect.
|
|
622
|
+
if from_snapshot && patches && !Array(patches).empty?
|
|
623
|
+
raise ArgumentError,
|
|
624
|
+
"patches: cannot be combined with from_snapshot: (a snapshot restores its own " \
|
|
625
|
+
"root disk; apply changes after boot with sandbox.fs instead)"
|
|
626
|
+
end
|
|
597
627
|
# `fstype:` names the inner filesystem of a disk-image rootfs, so it only
|
|
598
628
|
# applies when `image:` is a disk-image path (a local path ending in
|
|
599
629
|
# .raw/.qcow2/.vmdk). Routing an OCI ref (e.g. "python") through the
|
|
@@ -637,7 +667,9 @@ module Microsandbox
|
|
|
637
667
|
opts["ipv6_pool"] = ipv6_pool.to_s if ipv6_pool
|
|
638
668
|
opts["max_connections"] = Integer(max_connections) if max_connections
|
|
639
669
|
set_bool(opts, "trust_host_cas", trust_host_cas)
|
|
670
|
+
set_bool(opts, "strict", strict)
|
|
640
671
|
opts["rate_limiter"] = normalize_rate_limiter(rate_limiter) if rate_limiter
|
|
672
|
+
opts["proxy"] = OutboundProxy.coerce(proxy) unless proxy.nil?
|
|
641
673
|
opts["vsock"] = normalize_vsock(vsock) if vsock
|
|
642
674
|
opts["log_level"] = log_level.to_s if log_level
|
|
643
675
|
opts["quiet_logs"] = true if quiet_logs
|
|
@@ -760,7 +792,16 @@ module Microsandbox
|
|
|
760
792
|
end
|
|
761
793
|
|
|
762
794
|
def stringify(hash)
|
|
763
|
-
|
|
795
|
+
# Anything that converts to pairs is fine (a Hash, ENV, an Array of
|
|
796
|
+
# pairs). For the rest, name the type, not the value: env values are
|
|
797
|
+
# often credentials.
|
|
798
|
+
pairs = begin
|
|
799
|
+
hash.to_h if hash.respond_to?(:to_h)
|
|
800
|
+
rescue TypeError
|
|
801
|
+
nil
|
|
802
|
+
end
|
|
803
|
+
raise ArgumentError, "expected a Hash of names to values (got #{hash.class})" unless pairs
|
|
804
|
+
pairs.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
|
|
764
805
|
end
|
|
765
806
|
|
|
766
807
|
# Wrap a native page Hash ({"sandboxes" => [...], "next_cursor" => ...})
|
|
@@ -825,7 +866,21 @@ module Microsandbox
|
|
|
825
866
|
# :inject_basic_auth / :inject_query / :inject_body, and :on_violation (see
|
|
826
867
|
# {#normalize_violation}).
|
|
827
868
|
def normalize_secrets(secrets)
|
|
828
|
-
|
|
869
|
+
if secrets.is_a?(Hash)
|
|
870
|
+
# One spec given bare is fine. A Hash keyed by variable name is the
|
|
871
|
+
# shape {Sandbox#modify} takes, where `env:` also means something
|
|
872
|
+
# else (the HOST variable to read); it is not a create-time spec.
|
|
873
|
+
unless secrets.key?(:env) || secrets.key?("env")
|
|
874
|
+
raise ArgumentError,
|
|
875
|
+
"secrets: takes an Array of Hashes like { env:, value:, host: } at create time; " \
|
|
876
|
+
"the { \"NAME\" => { ... } } form belongs to #modify"
|
|
877
|
+
end
|
|
878
|
+
secrets = [secrets]
|
|
879
|
+
end
|
|
880
|
+
Array(secrets).map do |spec|
|
|
881
|
+
raise ArgumentError, "secret spec must be a Hash (got #{spec.class})" unless spec.is_a?(Hash)
|
|
882
|
+
normalize_secret(spec)
|
|
883
|
+
end
|
|
829
884
|
end
|
|
830
885
|
|
|
831
886
|
# @api private
|
|
@@ -1390,9 +1445,11 @@ module Microsandbox
|
|
|
1390
1445
|
# plain Hash) into string-keyed Hashes for the native layer. Values are
|
|
1391
1446
|
# passed through unchanged (mode stays Integer, content stays String).
|
|
1392
1447
|
def normalize_patches(patches)
|
|
1448
|
+
patches = [patches] if patches.is_a?(Hash) # a single patch, not a list of pairs
|
|
1393
1449
|
Array(patches).map do |p|
|
|
1394
1450
|
unless p.is_a?(Hash)
|
|
1395
|
-
|
|
1451
|
+
# Name the type, not the value: a patch can carry file content.
|
|
1452
|
+
raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*), got #{p.class}"
|
|
1396
1453
|
end
|
|
1397
1454
|
p.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
1398
1455
|
end
|
|
@@ -1938,10 +1995,16 @@ module Microsandbox
|
|
|
1938
1995
|
|
|
1939
1996
|
# The booted sandbox. Joins the create task (draining any remaining pull
|
|
1940
1997
|
# progress first), so call it after iterating progress. The returned
|
|
1941
|
-
# {Sandbox} is live — stop it when done. Memoized
|
|
1998
|
+
# {Sandbox} is live — stop it when done. Memoized: the native result can be
|
|
1999
|
+
# taken only once, so a later call returns the same {Sandbox}, or re-raises
|
|
2000
|
+
# the same error if the create failed.
|
|
1942
2001
|
# @return [Sandbox]
|
|
1943
2002
|
def sandbox
|
|
2003
|
+
raise @failure if @failure
|
|
1944
2004
|
@sandbox ||= Sandbox.new(@native.result)
|
|
2005
|
+
rescue Microsandbox::Error => e
|
|
2006
|
+
@failure ||= e
|
|
2007
|
+
raise
|
|
1945
2008
|
end
|
|
1946
2009
|
end
|
|
1947
2010
|
end
|
data/lib/microsandbox/ssh.rb
CHANGED
|
@@ -173,11 +173,7 @@ module Microsandbox
|
|
|
173
173
|
session = SftpClient.new(@native.sftp)
|
|
174
174
|
return session unless block_given?
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
yield session
|
|
178
|
-
ensure
|
|
179
|
-
session.close
|
|
180
|
-
end
|
|
176
|
+
Microsandbox.close_after(session) { yield session }
|
|
181
177
|
end
|
|
182
178
|
|
|
183
179
|
# Close the SSH client session. Idempotent. Sends the graceful protocol
|
|
@@ -245,11 +241,7 @@ module Microsandbox
|
|
|
245
241
|
client = SshClient.new(@native.ssh_open_client(opts))
|
|
246
242
|
return client unless block_given?
|
|
247
243
|
|
|
248
|
-
|
|
249
|
-
yield client
|
|
250
|
-
ensure
|
|
251
|
-
client.close
|
|
252
|
-
end
|
|
244
|
+
Microsandbox.close_after(client) { yield client }
|
|
253
245
|
end
|
|
254
246
|
|
|
255
247
|
# Prepare a reusable SSH server endpoint for the sandbox.
|
data/lib/microsandbox/streams.rb
CHANGED
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Microsandbox
|
|
4
4
|
# A live stream of {LogEntry}s, returned by {Sandbox#log_stream}. Enumerable:
|
|
5
|
-
# iterate to consume entries as they are appended.
|
|
6
|
-
# iteration
|
|
7
|
-
#
|
|
5
|
+
# iterate to consume entries as they are appended. Without `follow:` the
|
|
6
|
+
# iteration ends once the historical log is drained. With `follow: true` it
|
|
7
|
+
# keeps waiting for new entries and does NOT end when the sandbox stops: the
|
|
8
|
+
# runtime follows the log file, not the sandbox, and there is no way to
|
|
9
|
+
# cancel the wait.
|
|
8
10
|
#
|
|
9
11
|
# @note **Single-pass, forward-only, single-consumer.** `each` drains a
|
|
10
12
|
# one-shot native channel, so it is *not* rewindable: a second `each` — or
|
|
11
13
|
# any `Enumerable` combinator after a partial drain (`to_a` twice, `count`
|
|
12
14
|
# then `each`, `first(n)` then `each`) — silently yields nothing. Iterate
|
|
13
15
|
# exactly once, from one thread. With `follow: true`, `each` blocks the
|
|
14
|
-
# calling thread uninterruptibly
|
|
15
|
-
#
|
|
16
|
+
# calling thread uninterruptibly and indefinitely (see DESIGN.md on GVL
|
|
17
|
+
# release); a thread blocked there also keeps the process from exiting
|
|
18
|
+
# normally, so follow from a process you are prepared to kill.
|
|
16
19
|
#
|
|
17
20
|
# @example
|
|
18
21
|
# sb.log_stream(follow: true).each { |entry| print entry.text }
|
data/lib/microsandbox/version.rb
CHANGED
|
@@ -8,12 +8,12 @@ module Microsandbox
|
|
|
8
8
|
# Versioning section of the README for the full gem-to-runtime map. Must equal
|
|
9
9
|
# the native ext's Cargo crate version (`Native.version`), enforced by
|
|
10
10
|
# spec/unit/version_spec.rb.
|
|
11
|
-
VERSION = "0.
|
|
11
|
+
VERSION = "0.17.1"
|
|
12
12
|
|
|
13
13
|
# The upstream microsandbox runtime release this gem build embeds — the `tag`
|
|
14
14
|
# pinned on the `microsandbox`/`microsandbox-network` git deps in
|
|
15
15
|
# ext/microsandbox/Cargo.toml. Exposed at runtime as
|
|
16
16
|
# {Microsandbox.runtime_version}. spec/unit/version_spec.rb asserts it stays in
|
|
17
17
|
# sync with the Cargo tag so it can't silently drift out of date.
|
|
18
|
-
RUNTIME_VERSION = "v0.6.
|
|
18
|
+
RUNTIME_VERSION = "v0.6.18"
|
|
19
19
|
end
|