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.
@@ -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
@@ -42,13 +42,13 @@ module Microsandbox
42
42
  # @return [Time, nil]
43
43
  def created_at
44
44
  ms = @native.created_at_ms
45
- ms && Time.at(ms / 1000.0)
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
- ms && Time.at(ms / 1000.0)
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
- Time.at(@observed_at_ms / 1000.0)
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}/{create_with_progress}.
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, vsock: 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
- Microsandbox.ensure_runtime!
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
- hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
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
- Array(secrets).map { |spec| normalize_secret(spec) }
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
- raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*): #{p.inspect}"
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; callable once.
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
@@ -92,7 +92,7 @@ module Microsandbox
92
92
 
93
93
  # @return [Time, nil]
94
94
  def created_at
95
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
95
+ Microsandbox.time_from_ms(@created_at_ms)
96
96
  end
97
97
 
98
98
  # Re-open this snapshot's artifact (cheap metadata validation), returning a
@@ -173,11 +173,7 @@ module Microsandbox
173
173
  session = SftpClient.new(@native.sftp)
174
174
  return session unless block_given?
175
175
 
176
- begin
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
- begin
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.
@@ -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. With `follow: true` the
6
- # iteration blocks for new entries until the sandbox stops; otherwise it ends
7
- # once the historical log is drained.
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 until the sandbox stops (see DESIGN.md on
15
- # GVL release).
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 }
@@ -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.16.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.16"
18
+ RUNTIME_VERSION = "v0.6.18"
19
19
  end