microsandbox-rb 0.6.0 → 0.7.0
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 +96 -1
- data/Cargo.lock +1 -1
- data/DESIGN.md +16 -6
- data/README.md +27 -14
- data/ext/microsandbox/Cargo.toml +1 -1
- data/ext/microsandbox/src/agent.rs +18 -7
- data/ext/microsandbox/src/conv.rs +37 -1
- data/ext/microsandbox/src/fs_stream.rs +92 -0
- data/ext/microsandbox/src/image.rs +6 -0
- data/ext/microsandbox/src/lib.rs +40 -0
- data/ext/microsandbox/src/sandbox.rs +673 -64
- data/ext/microsandbox/src/snapshot.rs +72 -6
- data/ext/microsandbox/src/volume.rs +113 -1
- data/lib/microsandbox/agent.rb +3 -1
- data/lib/microsandbox/exec_handle.rb +7 -3
- data/lib/microsandbox/exec_output.rb +7 -7
- data/lib/microsandbox/fs.rb +84 -2
- data/lib/microsandbox/image.rb +2 -1
- data/lib/microsandbox/log_entry.rb +4 -2
- data/lib/microsandbox/metrics.rb +9 -0
- data/lib/microsandbox/sandbox.rb +461 -70
- data/lib/microsandbox/snapshot.rb +63 -6
- data/lib/microsandbox/ssh.rb +14 -9
- data/lib/microsandbox/version.rb +1 -1
- data/lib/microsandbox/volume.rb +100 -1
- data/lib/microsandbox.rb +35 -1
- data/sig/microsandbox.rbs +70 -6
- metadata +2 -1
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Microsandbox
|
|
4
6
|
# A controllable handle to a sandbox, returned by {Sandbox.get}, {Sandbox.list},
|
|
5
7
|
# and {Sandbox.list_with}. Carries a metadata snapshot (captured when fetched)
|
|
@@ -99,6 +101,37 @@ module Microsandbox
|
|
|
99
101
|
SandboxStopResult.new(@native.wait_until_stopped)
|
|
100
102
|
end
|
|
101
103
|
|
|
104
|
+
# The sandbox's stored configuration as a raw JSON string (synchronous — the
|
|
105
|
+
# handle already carries it, no runtime round-trip). Mirrors the Python/Node
|
|
106
|
+
# `config_json`.
|
|
107
|
+
# @return [String]
|
|
108
|
+
def config_json
|
|
109
|
+
@native.config_json
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The sandbox's stored configuration, parsed into a Hash (image, cpus,
|
|
113
|
+
# memory, mounts, …). Mirrors the Python/Node `config`.
|
|
114
|
+
# @return [Hash]
|
|
115
|
+
def config
|
|
116
|
+
JSON.parse(@native.config_json)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Snapshot this (stopped) sandbox under a bare name, resolved under the
|
|
120
|
+
# snapshots dir. Convenience equivalent of
|
|
121
|
+
# `Snapshot.create(name, name: <snapshot-name>)` addressed by this handle.
|
|
122
|
+
# @param name [String] destination snapshot name
|
|
123
|
+
# @return [SnapshotInfo]
|
|
124
|
+
def snapshot(name)
|
|
125
|
+
SnapshotInfo.new(@native.snapshot(name.to_s))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Snapshot this (stopped) sandbox to an explicit filesystem path.
|
|
129
|
+
# @param path [String] destination directory
|
|
130
|
+
# @return [SnapshotInfo]
|
|
131
|
+
def snapshot_to(path)
|
|
132
|
+
SnapshotInfo.new(@native.snapshot_to(path.to_s))
|
|
133
|
+
end
|
|
134
|
+
|
|
102
135
|
def inspect
|
|
103
136
|
"#<Microsandbox::SandboxHandle name=#{name.inspect} status=#{status}>"
|
|
104
137
|
end
|
|
@@ -165,6 +198,11 @@ module Microsandbox
|
|
|
165
198
|
# sb.stop
|
|
166
199
|
# end
|
|
167
200
|
class Sandbox
|
|
201
|
+
# Recognized disk-image rootfs extensions, mirroring the upstream
|
|
202
|
+
# `DiskImageFormat::from_extension`/`FromStr` set. Used by {disk_image_rootfs?}
|
|
203
|
+
# to gate the `fstype:`-vs-OCI check; keep in sync on a runtime-tag bump.
|
|
204
|
+
DISK_IMAGE_EXTENSIONS = %w[raw qcow2 vmdk].freeze
|
|
205
|
+
|
|
168
206
|
class << self
|
|
169
207
|
# Create and boot a sandbox.
|
|
170
208
|
#
|
|
@@ -186,12 +224,40 @@ module Microsandbox
|
|
|
186
224
|
# @param entrypoint [Array<String>, nil] image entrypoint override
|
|
187
225
|
# @param ports [Hash, nil] host_port => guest_port TCP publications
|
|
188
226
|
# @param ports_udp [Hash, nil] host_port => guest_port UDP publications
|
|
227
|
+
# @param volumes [Hash, nil] guest_path => mount spec. Each value is a host
|
|
228
|
+
# path String (a bind mount), or a Hash: `{ bind: "/host" }`,
|
|
229
|
+
# `{ named: "vol" }`, `{ tmpfs: true, size_mib: 64 }`, or
|
|
230
|
+
# `{ disk: "/img.raw", format: "raw", fstype: "ext4" }`. Any mount may add
|
|
231
|
+
# flags `ro:`/`readonly:`, `noexec:`, `nosuid:`, `nodev:`, and (bind/named
|
|
232
|
+
# only) `stat_virtualization:` (:strict/:relaxed/:off) and
|
|
233
|
+
# `host_permissions:` (:private/:mirror).
|
|
189
234
|
# @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
|
|
190
235
|
# A preset name ("public_only" (default), "none", "allow_all",
|
|
191
236
|
# "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
|
|
192
237
|
# describing a custom policy (`default_egress:`, `default_ingress:`,
|
|
193
238
|
# `rules:`, `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy}
|
|
194
239
|
# and {Rule}.
|
|
240
|
+
# @param dns [Hash, nil] custom DNS: `{ nameservers: [...],
|
|
241
|
+
# rebind_protection: true, query_timeout_ms: 2000 }`
|
|
242
|
+
# @param tls [Hash, nil] TLS-interception tuning: `{ bypass: [...patterns],
|
|
243
|
+
# verify_upstream: true, intercepted_ports: [443, 8443], block_quic: true,
|
|
244
|
+
# upstream_ca_cert:, intercept_ca_cert:, intercept_ca_key: }` (paths). Use
|
|
245
|
+
# this to inject `secrets:` on non-443 ports or to trust a private CA.
|
|
246
|
+
# @param ipv4_pool [String, nil] guest IPv4 address pool CIDR (e.g. "10.0.0.0/24")
|
|
247
|
+
# @param ipv6_pool [String, nil] guest IPv6 address pool CIDR
|
|
248
|
+
# @param max_connections [Integer, nil] cap on concurrent proxied connections
|
|
249
|
+
# @param trust_host_cas [Boolean, nil] trust the host's CA bundle for upstream TLS
|
|
250
|
+
# @param from_snapshot [String, nil] boot from a snapshot name or digest
|
|
251
|
+
# instead of an image (mutually exclusive with `image:`)
|
|
252
|
+
# @param fstype [String, nil] inner filesystem type (e.g. "ext4") when
|
|
253
|
+
# `image:` is a disk-image rootfs path whose filesystem can't be
|
|
254
|
+
# auto-probed; ignored for OCI images
|
|
255
|
+
# @param init [String, Hash, nil] hand guest PID 1 to an init system: a
|
|
256
|
+
# command path (e.g. "/lib/systemd/systemd" or "auto"), or a Hash
|
|
257
|
+
# `{ cmd:, args:, env: }` when the init binary takes argv/extra env
|
|
258
|
+
# @param ephemeral [Boolean] auto-remove the sandbox's stored state (DB
|
|
259
|
+
# row, disk, logs, captured output) once it reaches a terminal state
|
|
260
|
+
# (default: state is persisted until {.remove})
|
|
195
261
|
# @param patches [Array<Hash>, nil] rootfs patches applied before boot, each
|
|
196
262
|
# built with the {Patch} factory (e.g. `Patch.text(...)`, `Patch.mkdir(...)`).
|
|
197
263
|
# Not compatible with disk-image roots.
|
|
@@ -212,23 +278,70 @@ module Microsandbox
|
|
|
212
278
|
# instead of HTTPS (for local/self-hosted registries)
|
|
213
279
|
# @param registry_ca_certs [String, Array<String>, nil] extra PEM-encoded CA
|
|
214
280
|
# root certificate(s) to trust (for a registry with a private CA)
|
|
215
|
-
# @param secrets [Array<Hash>, nil] placeholder-protected secrets
|
|
216
|
-
#
|
|
217
|
-
#
|
|
281
|
+
# @param secrets [Array<Hash>, nil] placeholder-protected secrets injected by
|
|
282
|
+
# the TLS proxy (auto-enables TLS interception). Each Hash needs `env:` and
|
|
283
|
+
# `value:` plus an allow list — `host:` (single), `hosts:` (Array), and/or
|
|
284
|
+
# `host_patterns:` (wildcards like "*.stripe.com"). Optional per-secret:
|
|
285
|
+
# `placeholder:`, `require_tls:`, injection toggles `inject_headers:` /
|
|
286
|
+
# `inject_basic_auth:` / `inject_query:` / `inject_body:`, and `on_violation:`.
|
|
287
|
+
# @param on_secret_violation [String, Symbol, Hash, nil] sandbox-wide
|
|
288
|
+
# secret-leak policy: "block", "block_and_log", "block_and_terminate",
|
|
289
|
+
# "passthrough" (passthrough-all-hosts), or a Hash
|
|
290
|
+
# `{ passthrough_hosts:, passthrough_host_patterns:, passthrough_all_hosts: }`
|
|
218
291
|
# @param detached [Boolean] keep running after this process exits
|
|
219
292
|
# @param replace [Boolean] replace an existing sandbox with the same name
|
|
220
293
|
# @param replace_with_timeout [Numeric, nil] replace, waiting up to N seconds
|
|
221
294
|
# @yieldparam sandbox [Sandbox]
|
|
222
295
|
# @return [Sandbox, Object] the sandbox, or the block's return value
|
|
223
|
-
def create(name,
|
|
224
|
-
|
|
296
|
+
def create(name, **kwargs, &block)
|
|
297
|
+
opts = build_create_opts(**kwargs)
|
|
298
|
+
sandbox = new(Native::Sandbox.create(name.to_s, opts))
|
|
299
|
+
return sandbox unless block_given?
|
|
300
|
+
|
|
301
|
+
begin
|
|
302
|
+
yield sandbox
|
|
303
|
+
ensure
|
|
304
|
+
begin
|
|
305
|
+
sandbox.stop
|
|
306
|
+
rescue Microsandbox::Error
|
|
307
|
+
# best-effort cleanup; ignore stop failures during teardown
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Create a sandbox while streaming image-pull progress. Accepts the same
|
|
313
|
+
# options as {create}; returns a {PullSession} — iterate it (an
|
|
314
|
+
# {Enumerable} of progress-event Hashes, each with a "kind"), then call
|
|
315
|
+
# {PullSession#sandbox} for the booted {Sandbox}. Mirrors the Python
|
|
316
|
+
# `create_with_progress` / Node `createWithPullProgress`.
|
|
317
|
+
# @return [PullSession]
|
|
318
|
+
def create_with_progress(name, **kwargs)
|
|
319
|
+
# Unlike {create}, this has no block form: the booted sandbox is reached
|
|
320
|
+
# via {PullSession#sandbox} (after iterating progress) and stopped by the
|
|
321
|
+
# caller. A block would be silently dropped — and the sandbox leaked — so
|
|
322
|
+
# reject it loudly rather than let a `create`-style block call misfire.
|
|
323
|
+
if block_given?
|
|
324
|
+
raise ArgumentError,
|
|
325
|
+
"create_with_progress takes no block; iterate the returned PullSession " \
|
|
326
|
+
"for progress, then call #sandbox and stop it when done"
|
|
327
|
+
end
|
|
328
|
+
opts = build_create_opts(**kwargs)
|
|
329
|
+
PullSession.new(Native::Sandbox.create_with_progress(name.to_s, opts))
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @api private
|
|
333
|
+
# Shared keyword-option builder for {create}/{create_with_progress}.
|
|
334
|
+
def build_create_opts(image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
|
|
225
335
|
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
226
336
|
entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
|
|
337
|
+
dns: nil, tls: nil, ipv4_pool: nil, ipv6_pool: nil,
|
|
338
|
+
max_connections: nil, trust_host_cas: nil,
|
|
227
339
|
patches: nil,
|
|
228
|
-
from_snapshot: nil,
|
|
340
|
+
from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
|
|
341
|
+
log_level: nil, quiet_logs: false, security: nil,
|
|
229
342
|
oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
|
|
230
343
|
pull_policy: nil, registry_auth: nil, registry_insecure: false,
|
|
231
|
-
registry_ca_certs: nil, secrets: nil,
|
|
344
|
+
registry_ca_certs: nil, secrets: nil, on_secret_violation: nil,
|
|
232
345
|
detached: false, replace: false, replace_with_timeout: nil)
|
|
233
346
|
# A sandbox boots from exactly one rootfs source. The core would reject a
|
|
234
347
|
# contradictory pair, but only after a runtime round-trip; fail fast and
|
|
@@ -237,9 +350,22 @@ module Microsandbox
|
|
|
237
350
|
raise ArgumentError, "provide either image: or from_snapshot:, not both"
|
|
238
351
|
end
|
|
239
352
|
Microsandbox.ensure_runtime!
|
|
353
|
+
# `fstype:` names the inner filesystem of a disk-image rootfs, so it only
|
|
354
|
+
# applies when `image:` is a disk-image path (a local path ending in
|
|
355
|
+
# .raw/.qcow2/.vmdk). Routing an OCI ref (e.g. "python") through the
|
|
356
|
+
# disk-image builder would make the core treat it as a host disk path and
|
|
357
|
+
# fail at boot, so reject the combination up front instead of forwarding a
|
|
358
|
+
# value the native layer can't honour.
|
|
359
|
+
if fstype && !disk_image_rootfs?(image)
|
|
360
|
+
raise ArgumentError,
|
|
361
|
+
"fstype: only applies to a disk-image rootfs; image: must be a local " \
|
|
362
|
+
"path ending in .raw, .qcow2, or .vmdk (got #{image.inspect}). " \
|
|
363
|
+
"OCI references auto-detect their filesystem — drop fstype:."
|
|
364
|
+
end
|
|
240
365
|
opts = {}
|
|
241
366
|
opts["image"] = image.to_s if image
|
|
242
367
|
opts["from_snapshot"] = from_snapshot.to_s if from_snapshot
|
|
368
|
+
opts["fstype"] = fstype.to_s if fstype
|
|
243
369
|
opts["cpus"] = Integer(cpus) if cpus
|
|
244
370
|
opts["memory"] = Integer(memory) if memory
|
|
245
371
|
opts["workdir"] = workdir.to_s if workdir
|
|
@@ -255,6 +381,12 @@ module Microsandbox
|
|
|
255
381
|
opts["volumes"] = normalize_volumes(volumes) if volumes
|
|
256
382
|
opts["patches"] = normalize_patches(patches) if patches
|
|
257
383
|
apply_network_opts(opts, network) unless network.nil?
|
|
384
|
+
opts["dns"] = normalize_dns(dns) if dns
|
|
385
|
+
opts["tls"] = normalize_tls(tls) if tls
|
|
386
|
+
opts["ipv4_pool"] = ipv4_pool.to_s if ipv4_pool
|
|
387
|
+
opts["ipv6_pool"] = ipv6_pool.to_s if ipv6_pool
|
|
388
|
+
opts["max_connections"] = Integer(max_connections) if max_connections
|
|
389
|
+
set_bool(opts, "trust_host_cas", trust_host_cas)
|
|
258
390
|
opts["log_level"] = log_level.to_s if log_level
|
|
259
391
|
opts["quiet_logs"] = true if quiet_logs
|
|
260
392
|
opts["security"] = security.to_s if security
|
|
@@ -265,6 +397,9 @@ module Microsandbox
|
|
|
265
397
|
opts["pull_policy"] = pull_policy.to_s if pull_policy
|
|
266
398
|
apply_registry_opts(opts, registry_auth, registry_insecure, registry_ca_certs)
|
|
267
399
|
opts["secrets"] = normalize_secrets(secrets) if secrets
|
|
400
|
+
opts["on_secret_violation"] = normalize_violation(on_secret_violation) if on_secret_violation
|
|
401
|
+
opts["init"] = normalize_init(init) unless init.nil?
|
|
402
|
+
opts["ephemeral"] = true if ephemeral
|
|
268
403
|
opts["detached"] = true if detached
|
|
269
404
|
if replace_with_timeout
|
|
270
405
|
opts["replace_with_timeout"] = coerce_duration(replace_with_timeout, "replace_with_timeout")
|
|
@@ -272,18 +407,7 @@ module Microsandbox
|
|
|
272
407
|
opts["replace"] = true
|
|
273
408
|
end
|
|
274
409
|
|
|
275
|
-
|
|
276
|
-
return sandbox unless block_given?
|
|
277
|
-
|
|
278
|
-
begin
|
|
279
|
-
yield sandbox
|
|
280
|
-
ensure
|
|
281
|
-
begin
|
|
282
|
-
sandbox.stop
|
|
283
|
-
rescue Microsandbox::Error
|
|
284
|
-
# best-effort cleanup; ignore stop failures during teardown
|
|
285
|
-
end
|
|
286
|
-
end
|
|
410
|
+
opts
|
|
287
411
|
end
|
|
288
412
|
|
|
289
413
|
# Restart a previously-defined sandbox by name.
|
|
@@ -343,6 +467,28 @@ module Microsandbox
|
|
|
343
467
|
ports.each_with_object({}) { |(k, v), acc| acc[Integer(k)] = Integer(v) }
|
|
344
468
|
end
|
|
345
469
|
|
|
470
|
+
# Normalize the `init:` option into the native { "cmd" =>, "args" =>,
|
|
471
|
+
# "env" => } shape. Accepts a bare command (String/Symbol — an init binary
|
|
472
|
+
# path or the literal "auto") or a Hash { cmd:, args:, env: }, mirroring the
|
|
473
|
+
# Python InitConfig / Node init options.
|
|
474
|
+
def normalize_init(init)
|
|
475
|
+
case init
|
|
476
|
+
when String, Symbol
|
|
477
|
+
{"cmd" => init.to_s}
|
|
478
|
+
when Hash
|
|
479
|
+
cmd = init[:cmd] || init["cmd"]
|
|
480
|
+
raise ArgumentError, "init: requires a :cmd" unless cmd
|
|
481
|
+
spec = {"cmd" => cmd.to_s}
|
|
482
|
+
args = init[:args] || init["args"]
|
|
483
|
+
spec["args"] = Array(args).map(&:to_s) if args
|
|
484
|
+
env = init[:env] || init["env"]
|
|
485
|
+
spec["env"] = stringify(env) if env
|
|
486
|
+
spec
|
|
487
|
+
else
|
|
488
|
+
raise ArgumentError, "init: must be a String command or a Hash {cmd:, args:, env:}"
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
346
492
|
# Flatten the registry options into the native layer's `registry_*` keys.
|
|
347
493
|
# `auth` is a Hash { username:, password: } (string or symbol keys); both
|
|
348
494
|
# are required when given. `ca_certs` accepts one PEM string or an Array.
|
|
@@ -362,18 +508,144 @@ module Microsandbox
|
|
|
362
508
|
opts["registry_ca_certs"] = Array(ca_certs).map(&:to_s) if ca_certs
|
|
363
509
|
end
|
|
364
510
|
|
|
365
|
-
# Normalize secrets into
|
|
366
|
-
# Each entry is a Hash
|
|
511
|
+
# Normalize secrets into per-secret string-keyed Hashes for the native
|
|
512
|
+
# layer. Each entry is a Hash (string or symbol keys); required :env and
|
|
513
|
+
# :value, plus at least one allowed host via :host (single), :hosts (Array),
|
|
514
|
+
# or :host_patterns (Array of wildcards like "*.stripe.com"). Optional:
|
|
515
|
+
# :placeholder, :require_tls, the injection toggles :inject_headers /
|
|
516
|
+
# :inject_basic_auth / :inject_query / :inject_body, and :on_violation (see
|
|
517
|
+
# {#normalize_violation}).
|
|
367
518
|
def normalize_secrets(secrets)
|
|
368
|
-
Array(secrets).map
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
519
|
+
Array(secrets).map { |spec| normalize_secret(spec) }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def normalize_secret(spec)
|
|
523
|
+
env = spec[:env] || spec["env"]
|
|
524
|
+
value = spec[:value] || spec["value"]
|
|
525
|
+
unless env && value
|
|
526
|
+
raise ArgumentError, "secret spec needs :env and :value (got #{spec.inspect})"
|
|
527
|
+
end
|
|
528
|
+
out = {"env" => env.to_s, "value" => value.to_s}
|
|
529
|
+
hosts = Array(spec[:hosts] || spec["hosts"]).map(&:to_s)
|
|
530
|
+
single = spec[:host] || spec["host"]
|
|
531
|
+
hosts << single.to_s if single
|
|
532
|
+
patterns = Array(spec[:host_patterns] || spec["host_patterns"]).map(&:to_s)
|
|
533
|
+
if hosts.empty? && patterns.empty?
|
|
534
|
+
raise ArgumentError,
|
|
535
|
+
"secret spec needs :host, :hosts, or :host_patterns (got #{spec.inspect})"
|
|
536
|
+
end
|
|
537
|
+
out["hosts"] = hosts unless hosts.empty?
|
|
538
|
+
out["host_patterns"] = patterns unless patterns.empty?
|
|
539
|
+
pl = spec[:placeholder] || spec["placeholder"]
|
|
540
|
+
out["placeholder"] = pl.to_s if pl
|
|
541
|
+
# Booleans must honor an explicit `false` (to disable a default-on toggle),
|
|
542
|
+
# so probe key presence rather than truthiness.
|
|
543
|
+
%i[require_tls inject_headers inject_basic_auth inject_query inject_body].each do |k|
|
|
544
|
+
set_bool(out, k.to_s, fetch_opt(spec, k))
|
|
545
|
+
end
|
|
546
|
+
ov = spec[:on_violation] || spec["on_violation"]
|
|
547
|
+
out["on_violation"] = normalize_violation(ov) if ov
|
|
548
|
+
out
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Read a symbol-or-string key from a Hash, distinguishing an explicit
|
|
552
|
+
# `false`/`nil` value from an absent key.
|
|
553
|
+
def fetch_opt(spec, sym)
|
|
554
|
+
return spec[sym] if spec.key?(sym)
|
|
555
|
+
spec[sym.to_s]
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Write a boolean option onto `out`, honoring an explicit `false` (to
|
|
559
|
+
# disable a default-on toggle) while leaving an absent (`nil`) value unset.
|
|
560
|
+
# Centralizes the "probe presence, not truthiness" rule for all the boolean
|
|
561
|
+
# toggles (require_tls/inject_*/verify_upstream/block_quic/rebind_protection/
|
|
562
|
+
# trust_host_cas) so a future toggle can't silently drop a `false`.
|
|
563
|
+
def set_bool(out, key, value)
|
|
564
|
+
out[key] = (value ? true : false) unless value.nil?
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Normalize an `on_violation`/`on_secret_violation` spec for the native
|
|
568
|
+
# layer: a String/Symbol block variant ("block"/"block_and_log"/
|
|
569
|
+
# "block_and_terminate"), or a Hash describing a passthrough action
|
|
570
|
+
# ({ passthrough_hosts:, passthrough_host_patterns:, passthrough_all_hosts: }).
|
|
571
|
+
def normalize_violation(spec)
|
|
572
|
+
case spec
|
|
573
|
+
when String, Symbol
|
|
574
|
+
# The bare "passthrough" string maps to passthrough-all-hosts, matching
|
|
575
|
+
# the Python/Node SDKs (so a string-form policy copied from another SDK
|
|
576
|
+
# ports over unchanged); block variants stay as their action string.
|
|
577
|
+
if spec.to_s.strip.downcase.tr("-", "_") == "passthrough"
|
|
578
|
+
{"passthrough_all_hosts" => true}
|
|
579
|
+
else
|
|
580
|
+
normalize_violation_action(spec)
|
|
581
|
+
end
|
|
582
|
+
when Hash
|
|
583
|
+
hosts = Array(spec[:passthrough_hosts] || spec["passthrough_hosts"]).map(&:to_s)
|
|
584
|
+
patterns = Array(spec[:passthrough_host_patterns] || spec["passthrough_host_patterns"]).map(&:to_s)
|
|
585
|
+
all = !!(spec[:passthrough_all_hosts] || spec["passthrough_all_hosts"])
|
|
586
|
+
# An empty passthrough (no hosts, no patterns, not all) passes nothing
|
|
587
|
+
# through — a no-op the native builder silently degrades to its default
|
|
588
|
+
# action. Reject it with actionable guidance rather than accept a spec
|
|
589
|
+
# that does nothing the caller intended.
|
|
590
|
+
if hosts.empty? && patterns.empty? && !all
|
|
591
|
+
raise ArgumentError,
|
|
592
|
+
"passthrough on_violation needs at least one of :passthrough_hosts, " \
|
|
593
|
+
":passthrough_host_patterns, or :passthrough_all_hosts (use \"block\" " \
|
|
594
|
+
"if blocking is the intent)"
|
|
374
595
|
end
|
|
375
|
-
|
|
596
|
+
out = {}
|
|
597
|
+
out["passthrough_hosts"] = hosts unless hosts.empty?
|
|
598
|
+
out["passthrough_host_patterns"] = patterns unless patterns.empty?
|
|
599
|
+
out["passthrough_all_hosts"] = true if all
|
|
600
|
+
out
|
|
601
|
+
else
|
|
602
|
+
raise ArgumentError, "on_violation must be a String or a Hash (got #{spec.inspect})"
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Map a block-variant action onto the canonical underscore spelling the
|
|
607
|
+
# native layer expects. Also accepts the upstream kebab-case wire spellings
|
|
608
|
+
# ("block-and-log"/"block-and-terminate") used by the CLI, Go SDK, and
|
|
609
|
+
# sandbox config files, so a policy copied from another SDK ports over
|
|
610
|
+
# unchanged instead of being rejected.
|
|
611
|
+
def normalize_violation_action(spec)
|
|
612
|
+
action = spec.to_s.strip.downcase.tr("-", "_")
|
|
613
|
+
unless %w[block block_and_log block_and_terminate].include?(action)
|
|
614
|
+
raise ArgumentError,
|
|
615
|
+
"unknown on_violation #{spec.inspect} (expected block, " \
|
|
616
|
+
"block_and_log/block-and-log, block_and_terminate/block-and-terminate, " \
|
|
617
|
+
"passthrough, or a Hash with :passthrough_hosts/:passthrough_host_patterns/:passthrough_all_hosts)"
|
|
618
|
+
end
|
|
619
|
+
action
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Normalize the `dns:` config Hash for the native layer.
|
|
623
|
+
def normalize_dns(dns)
|
|
624
|
+
raise ArgumentError, "dns: must be a Hash" unless dns.is_a?(Hash)
|
|
625
|
+
out = {}
|
|
626
|
+
ns = dns[:nameservers] || dns["nameservers"]
|
|
627
|
+
out["nameservers"] = Array(ns).map(&:to_s) if ns
|
|
628
|
+
set_bool(out, "rebind_protection", fetch_opt(dns, :rebind_protection))
|
|
629
|
+
qt = dns[:query_timeout_ms] || dns["query_timeout_ms"]
|
|
630
|
+
out["query_timeout_ms"] = Integer(qt) if qt
|
|
631
|
+
out
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Normalize the `tls:` interception-tuning Hash for the native layer.
|
|
635
|
+
def normalize_tls(tls)
|
|
636
|
+
raise ArgumentError, "tls: must be a Hash" unless tls.is_a?(Hash)
|
|
637
|
+
out = {}
|
|
638
|
+
bypass = tls[:bypass] || tls["bypass"]
|
|
639
|
+
out["bypass"] = Array(bypass).map(&:to_s) if bypass
|
|
640
|
+
set_bool(out, "verify_upstream", fetch_opt(tls, :verify_upstream))
|
|
641
|
+
ports = tls[:intercepted_ports] || tls["intercepted_ports"]
|
|
642
|
+
out["intercepted_ports"] = Array(ports).map { |p| Integer(p) } if ports
|
|
643
|
+
set_bool(out, "block_quic", fetch_opt(tls, :block_quic))
|
|
644
|
+
%i[upstream_ca_cert intercept_ca_cert intercept_ca_key].each do |k|
|
|
645
|
+
v = tls[k] || tls[k.to_s]
|
|
646
|
+
out[k.to_s] = v.to_s if v
|
|
376
647
|
end
|
|
648
|
+
out
|
|
377
649
|
end
|
|
378
650
|
|
|
379
651
|
# Normalize an rlimits Hash into [resource, soft, hard] triples for the
|
|
@@ -386,49 +658,112 @@ module Microsandbox
|
|
|
386
658
|
end
|
|
387
659
|
end
|
|
388
660
|
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
661
|
+
# True when `image` names a disk-image rootfs the way the core auto-detects
|
|
662
|
+
# one: a local-path-looking string (`/`, `./`, `../` prefix) whose extension
|
|
663
|
+
# is a recognized disk-image format. This re-implements two upstream
|
|
664
|
+
# heuristics the native layer can't reach from here (the `microsandbox`
|
|
665
|
+
# crate re-exports neither): `looks_like_local_path_text`
|
|
666
|
+
# (microsandbox/crates/utils/lib/lib.rs) and `DiskImageFormat::from_extension`
|
|
667
|
+
# / `FromStr` (microsandbox/packages/microsandbox-types/rust/lib/domain.rs,
|
|
668
|
+
# qcow2/raw/vmdk). Keep both in sync when bumping the pinned runtime tag —
|
|
669
|
+
# the "disk_image_rootfs? contract" examples in sandbox_spec.rb pin it.
|
|
670
|
+
def disk_image_rootfs?(image)
|
|
671
|
+
s = image.to_s
|
|
672
|
+
return false unless s.start_with?("/", "./", "../")
|
|
673
|
+
DISK_IMAGE_EXTENSIONS.include?(File.extname(s).delete_prefix(".").downcase)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Normalize volumes (Hash of guest_path => spec) into per-mount string-keyed
|
|
677
|
+
# Hashes for the native layer. A spec is a host path String (a read-write
|
|
678
|
+
# bind mount) or a Hash describing the mount:
|
|
679
|
+
# { bind: "/host", ro: true, noexec: true } # host bind mount
|
|
680
|
+
# { named: "vol" } # named volume
|
|
681
|
+
# { tmpfs: true, size_mib: 64 } # memory-backed
|
|
682
|
+
# { disk: "/img.raw", format: "raw", fstype: "ext4" } # disk-image mount
|
|
683
|
+
# Any mount may also carry stat_virtualization: (:strict/:relaxed/:off) and
|
|
684
|
+
# host_permissions: (:private/:mirror).
|
|
394
685
|
def normalize_volumes(volumes)
|
|
395
686
|
volumes.map do |guest, spec|
|
|
396
|
-
|
|
687
|
+
mount = {"guest" => guest.to_s}
|
|
397
688
|
case spec
|
|
398
689
|
when String
|
|
399
|
-
[
|
|
690
|
+
mount["kind"] = "bind"
|
|
691
|
+
mount["source"] = spec
|
|
400
692
|
when Hash
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
[guest, "named", named.to_s]
|
|
404
|
-
elsif (bind = spec[:bind] || spec["bind"])
|
|
405
|
-
[guest, "bind", bind.to_s]
|
|
406
|
-
else
|
|
407
|
-
raise ArgumentError, "volume spec for #{guest.inspect} needs :bind or :named"
|
|
408
|
-
end
|
|
409
|
-
# Optional 4th element: comma-joined mount options. Omitted when empty
|
|
410
|
-
# so existing [guest,kind,source] consumers and the common read-write
|
|
411
|
-
# case are byte-for-byte unchanged.
|
|
412
|
-
opts = mount_options(spec)
|
|
413
|
-
opts.empty? ? triple : triple + [opts.join(",")]
|
|
693
|
+
apply_mount_kind(mount, spec, guest)
|
|
694
|
+
apply_mount_flags(mount, spec)
|
|
414
695
|
else
|
|
415
696
|
raise ArgumentError, "invalid volume spec for #{guest.inspect}: #{spec.inspect}"
|
|
416
697
|
end
|
|
698
|
+
mount
|
|
417
699
|
end
|
|
418
700
|
end
|
|
419
701
|
|
|
420
|
-
#
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
702
|
+
# Resolve a volume spec Hash's mount kind + source/size/format/fstype.
|
|
703
|
+
def apply_mount_kind(mount, spec, guest)
|
|
704
|
+
if (bind = spec[:bind] || spec["bind"])
|
|
705
|
+
mount["kind"] = "bind"
|
|
706
|
+
mount["source"] = bind.to_s
|
|
707
|
+
elsif (named = spec[:named] || spec["named"])
|
|
708
|
+
mount["kind"] = "named"
|
|
709
|
+
mount["source"] = named.to_s
|
|
710
|
+
elsif spec[:tmpfs] || spec["tmpfs"]
|
|
711
|
+
mount["kind"] = "tmpfs"
|
|
712
|
+
elsif (disk = spec[:disk] || spec["disk"])
|
|
713
|
+
mount["kind"] = "disk"
|
|
714
|
+
mount["source"] = disk.to_s
|
|
715
|
+
fmt = spec[:format] || spec["format"]
|
|
716
|
+
mount["format"] = fmt.to_s if fmt
|
|
717
|
+
else
|
|
718
|
+
raise ArgumentError,
|
|
719
|
+
"volume spec for #{guest.inspect} needs :bind, :named, :tmpfs, or :disk"
|
|
720
|
+
end
|
|
721
|
+
size = spec[:size_mib] || spec["size_mib"]
|
|
722
|
+
mount["size_mib"] = Integer(size) if size
|
|
723
|
+
fstype = spec[:fstype] || spec["fstype"]
|
|
724
|
+
mount["fstype"] = fstype.to_s if fstype
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# Apply a volume spec Hash's mount flags. `ro:`/`readonly:` makes the mount
|
|
728
|
+
# read-only; `noexec:`/`nosuid:`/`nodev:` set the matching flags;
|
|
729
|
+
# `stat_virtualization:`/`host_permissions:` set the passthrough policies
|
|
730
|
+
# (only valid on bind/named — the core rejects them on tmpfs/disk).
|
|
731
|
+
def apply_mount_flags(mount, spec)
|
|
732
|
+
mount["readonly"] = true if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
|
|
733
|
+
mount["noexec"] = true if spec[:noexec] || spec["noexec"]
|
|
734
|
+
mount["nosuid"] = true if spec[:nosuid] || spec["nosuid"]
|
|
735
|
+
mount["nodev"] = true if spec[:nodev] || spec["nodev"]
|
|
736
|
+
apply_legacy_mount_options(mount, spec)
|
|
737
|
+
sv = spec[:stat_virtualization] || spec["stat_virtualization"]
|
|
738
|
+
mount["stat_virtualization"] = sv.to_s if sv
|
|
739
|
+
hp = spec[:host_permissions] || spec["host_permissions"]
|
|
740
|
+
mount["host_permissions"] = hp.to_s if hp
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Translate the pre-0.7.0 `options:` array form (e.g. options: %w[ro noexec])
|
|
744
|
+
# onto the discrete boolean flags the native layer now consumes. Kept for
|
|
745
|
+
# backward compatibility with configs written against 0.5.11–0.6.0. Unknown
|
|
746
|
+
# tokens raise rather than being silently dropped: a mount requested
|
|
747
|
+
# read-only/noexec that quietly mounted read-write/executable would be a
|
|
748
|
+
# security regression, not a cosmetic one.
|
|
749
|
+
def apply_legacy_mount_options(mount, spec)
|
|
750
|
+
Array(spec[:options] || spec["options"]).each do |opt|
|
|
751
|
+
case opt.to_s
|
|
752
|
+
when "ro", "readonly" then mount["readonly"] = true
|
|
753
|
+
# "rw" is read-write, the default — accepted as a no-op (the pre-0.7.0
|
|
754
|
+
# native validator and the upstream wire form both treat it that way).
|
|
755
|
+
# Dropping it would break a previously-valid `options:` array, which the
|
|
756
|
+
# CHANGELOG promises is still honored.
|
|
757
|
+
when "rw" then nil
|
|
758
|
+
when "noexec" then mount["noexec"] = true
|
|
759
|
+
when "nosuid" then mount["nosuid"] = true
|
|
760
|
+
when "nodev" then mount["nodev"] = true
|
|
761
|
+
else
|
|
762
|
+
raise ArgumentError,
|
|
763
|
+
"unknown mount option #{opt.inspect} in options: — use " \
|
|
764
|
+
"ro/readonly, rw, noexec, nosuid, or nodev (or the matching boolean keys)"
|
|
765
|
+
end
|
|
766
|
+
end
|
|
432
767
|
end
|
|
433
768
|
|
|
434
769
|
# Normalize a list of patches (each a Hash from the {Patch} factory, or a
|
|
@@ -600,7 +935,7 @@ module Microsandbox
|
|
|
600
935
|
# Stream captured logs as they appear.
|
|
601
936
|
#
|
|
602
937
|
# @param sources [Array<String,Symbol>, nil] filter by source
|
|
603
|
-
# ("stdout"/"stderr"/"output"/"system")
|
|
938
|
+
# ("stdout"/"stderr"/"output"/"system"/"all")
|
|
604
939
|
# @param since_ms [Numeric, nil] start at the first entry at/after this Unix ms
|
|
605
940
|
# @param from_cursor [String, nil] resume exactly after a prior {LogEntry#cursor}
|
|
606
941
|
# (mutually exclusive with since_ms; takes precedence if both given)
|
|
@@ -692,15 +1027,20 @@ module Microsandbox
|
|
|
692
1027
|
opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
|
|
693
1028
|
opts["timeout"] = coerce_duration(timeout, "timeout") if timeout
|
|
694
1029
|
opts["tty"] = true if tty
|
|
695
|
-
#
|
|
696
|
-
#
|
|
697
|
-
#
|
|
698
|
-
#
|
|
699
|
-
#
|
|
700
|
-
#
|
|
701
|
-
#
|
|
1030
|
+
# stdin is a closed set of modes, mirroring the official SDKs:
|
|
1031
|
+
# nil / :null — no stdin (the guest sees /dev/null)
|
|
1032
|
+
# :pipe — open a streaming stdin pipe; write via {ExecHandle#stdin}
|
|
1033
|
+
# and close to send EOF. Only meaningful for the streaming
|
|
1034
|
+
# variants (which return an ExecHandle); a blocking
|
|
1035
|
+
# exec/shell collects to completion and has nowhere to hand
|
|
1036
|
+
# back the sink, so a piped process reading stdin would
|
|
1037
|
+
# block forever waiting for EOF — rejected there.
|
|
1038
|
+
# a String — fed as a fixed byte buffer (closed automatically).
|
|
1039
|
+
# An unrecognized Symbol is a loud error rather than being fed as its bytes
|
|
1040
|
+
# (so a typo'd or mistaken `stdin: :null`-style mode never silently sends
|
|
1041
|
+
# the literal characters of the mode name to the process).
|
|
702
1042
|
case stdin
|
|
703
|
-
when nil then nil
|
|
1043
|
+
when nil, :null then nil
|
|
704
1044
|
when :pipe
|
|
705
1045
|
unless pipe_ok
|
|
706
1046
|
raise ArgumentError,
|
|
@@ -708,7 +1048,14 @@ module Microsandbox
|
|
|
708
1048
|
"exec/shell cannot expose a writable stdin sink; pass a String to feed bytes"
|
|
709
1049
|
end
|
|
710
1050
|
opts["stdin_pipe"] = true
|
|
711
|
-
|
|
1051
|
+
when Symbol
|
|
1052
|
+
raise ArgumentError,
|
|
1053
|
+
"unknown stdin mode #{stdin.inspect}; expected nil or :null (no stdin), " \
|
|
1054
|
+
":pipe (exec_stream/shell_stream only), or a String of bytes to feed"
|
|
1055
|
+
else
|
|
1056
|
+
bytes = String.try_convert(stdin) or
|
|
1057
|
+
raise TypeError, "stdin must be nil, :null, :pipe, or a String (got #{stdin.class})"
|
|
1058
|
+
opts["stdin"] = bytes
|
|
712
1059
|
end
|
|
713
1060
|
if rlimits
|
|
714
1061
|
opts["rlimits"] = rlimits.map do |resource, limit|
|
|
@@ -719,4 +1066,48 @@ module Microsandbox
|
|
|
719
1066
|
opts
|
|
720
1067
|
end
|
|
721
1068
|
end
|
|
1069
|
+
|
|
1070
|
+
# A streaming image-pull + create session, from {Sandbox.create_with_progress}.
|
|
1071
|
+
# Iterate it (it is {Enumerable}) to consume progress-event Hashes as the image
|
|
1072
|
+
# pulls, then call {#sandbox} to get the booted {Sandbox}. Each event Hash has a
|
|
1073
|
+
# "kind" key (e.g. "resolving", "resolved", "layer_download_progress",
|
|
1074
|
+
# "layer_materialize_progress", "complete") plus kind-specific fields.
|
|
1075
|
+
#
|
|
1076
|
+
# @example
|
|
1077
|
+
# session = Microsandbox::Sandbox.create_with_progress("box", image: "python")
|
|
1078
|
+
# session.each { |ev| puts "#{ev["kind"]} #{ev["downloaded_bytes"]}" }
|
|
1079
|
+
# sb = session.sandbox
|
|
1080
|
+
# begin
|
|
1081
|
+
# sb.exec("python", ["-V"])
|
|
1082
|
+
# ensure
|
|
1083
|
+
# sb.stop
|
|
1084
|
+
# end
|
|
1085
|
+
class PullSession
|
|
1086
|
+
include Enumerable
|
|
1087
|
+
|
|
1088
|
+
def initialize(native)
|
|
1089
|
+
@native = native
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
# Yield each progress-event Hash until the pull finishes. Returns an
|
|
1093
|
+
# Enumerator when called without a block.
|
|
1094
|
+
# @yieldparam event [Hash]
|
|
1095
|
+
# @return [self, Enumerator]
|
|
1096
|
+
def each
|
|
1097
|
+
return enum_for(:each) unless block_given?
|
|
1098
|
+
|
|
1099
|
+
while (event = @native.recv)
|
|
1100
|
+
yield event
|
|
1101
|
+
end
|
|
1102
|
+
self
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
# The booted sandbox. Joins the create task (draining any remaining pull
|
|
1106
|
+
# progress first), so call it after iterating progress. The returned
|
|
1107
|
+
# {Sandbox} is live — stop it when done. Memoized; callable once.
|
|
1108
|
+
# @return [Sandbox]
|
|
1109
|
+
def sandbox
|
|
1110
|
+
@sandbox ||= Sandbox.new(@native.result)
|
|
1111
|
+
end
|
|
1112
|
+
end
|
|
722
1113
|
end
|