microsandbox-rb 0.17.0 → 0.17.2
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 +229 -8
- data/Cargo.lock +45 -46
- data/DESIGN.md +90 -72
- data/README.md +178 -669
- data/SECURITY.md +65 -0
- data/docs/configuration.md +154 -0
- data/docs/errors.md +74 -0
- data/docs/execution.md +108 -0
- data/docs/filesystem.md +45 -0
- data/docs/images.md +65 -0
- data/docs/lifecycle.md +141 -0
- data/docs/observability.md +99 -0
- data/docs/releasing.md +117 -0
- data/docs/runtime.md +154 -0
- data/docs/surface.md +75 -0
- data/ext/microsandbox/Cargo.toml +9 -5
- data/ext/microsandbox/src/agent.rs +13 -11
- data/ext/microsandbox/src/backend.rs +1 -1
- data/ext/microsandbox/src/exec.rs +12 -8
- data/ext/microsandbox/src/fs_stream.rs +19 -7
- data/ext/microsandbox/src/host_path.rs +108 -0
- data/ext/microsandbox/src/image.rs +2 -2
- data/ext/microsandbox/src/lib.rs +3 -2
- data/ext/microsandbox/src/runtime.rs +189 -13
- data/ext/microsandbox/src/sandbox.rs +149 -102
- data/ext/microsandbox/src/snapshot.rs +17 -11
- data/ext/microsandbox/src/ssh.rs +22 -16
- data/ext/microsandbox/src/stream.rs +6 -2
- data/ext/microsandbox/src/volume.rs +50 -17
- 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 +8 -0
- data/lib/microsandbox/sandbox.rb +128 -13
- 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 +1 -1
- data/lib/microsandbox/volume.rb +31 -14
- data/lib/microsandbox-rb.rb +7 -0
- data/lib/microsandbox.rb +88 -31
- data/sig/microsandbox.rbs +24 -0
- metadata +14 -1
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
|
|
@@ -495,7 +495,11 @@ module Microsandbox
|
|
|
495
495
|
# @return [Sandbox, Object] the sandbox, or the block's return value
|
|
496
496
|
def create(name, **kwargs, &block)
|
|
497
497
|
opts = build_create_opts(**kwargs)
|
|
498
|
-
|
|
498
|
+
Microsandbox.ensure_runtime!
|
|
499
|
+
# Everything Ruby-level happens BEFORE `acquire`: its interrupt mask must
|
|
500
|
+
# cover only the native call plus the wrap, never caller-supplied code.
|
|
501
|
+
sandbox_name = name.to_s
|
|
502
|
+
sandbox = acquire { Native::Sandbox.create(sandbox_name, opts) }
|
|
499
503
|
return sandbox unless block_given?
|
|
500
504
|
|
|
501
505
|
begin
|
|
@@ -551,7 +555,9 @@ module Microsandbox
|
|
|
551
555
|
# @return [Sandbox, Object] the live sandbox, or the block's return value
|
|
552
556
|
def connect_or_create(name, **kwargs, &block)
|
|
553
557
|
opts = build_create_opts(**kwargs)
|
|
554
|
-
|
|
558
|
+
Microsandbox.ensure_runtime!
|
|
559
|
+
sandbox_name = name.to_s # outside the interrupt mask, like in {create}
|
|
560
|
+
sandbox = acquire { Native::Sandbox.connect_or_create(sandbox_name, opts) }
|
|
555
561
|
return sandbox unless block_given?
|
|
556
562
|
|
|
557
563
|
begin
|
|
@@ -582,11 +588,74 @@ module Microsandbox
|
|
|
582
588
|
"for progress, then call #sandbox and stop it when done"
|
|
583
589
|
end
|
|
584
590
|
opts = build_create_opts(**kwargs)
|
|
591
|
+
Microsandbox.ensure_runtime!
|
|
585
592
|
PullSession.new(Native::Sandbox.create_with_progress(name.to_s, opts))
|
|
586
593
|
end
|
|
587
594
|
|
|
588
595
|
# @api private
|
|
589
|
-
#
|
|
596
|
+
# Wrap the native sandbox the block acquires, and stop it again if an
|
|
597
|
+
# interrupt cuts in before the caller has it.
|
|
598
|
+
#
|
|
599
|
+
# A native call cannot be interrupted while it runs, so a `Thread#raise`
|
|
600
|
+
# (what `Timeout.timeout` uses) or a `Thread#kill` that arrives during the
|
|
601
|
+
# block is delivered after it — at the next interrupt checkpoint, which is
|
|
602
|
+
# still in here, before the caller ever sees the object. Left alone the
|
|
603
|
+
# sandbox would be garbage and only GC would stop it, "eventually"; for
|
|
604
|
+
# `Timeout.timeout { Sandbox.create(...) }` that is not good enough. So:
|
|
605
|
+
#
|
|
606
|
+
# - the acquisition runs under `Thread.handle_interrupt(Object => :never)`,
|
|
607
|
+
# which holds a queued exception (or kill) back until the wrapper is
|
|
608
|
+
# assigned. The mask covers only the uninterruptible native call plus
|
|
609
|
+
# the wrap, so it changes no observable timing; runtime provisioning
|
|
610
|
+
# ({Microsandbox.ensure_runtime!}) and the block forms' `yield` stay
|
|
611
|
+
# outside it, with normal interrupt handling. For the same reason the
|
|
612
|
+
# block passed here must contain NOTHING but the native call: argument
|
|
613
|
+
# conversion (`name.to_s`, option Hashes) is the caller's own code and
|
|
614
|
+
# has to run before `acquire`, where it stays interruptible;
|
|
615
|
+
# - the `ensure` plus the `acquired` flag — not `rescue Exception; raise` —
|
|
616
|
+
# is what also covers `Thread#kill`, which no `rescue` can catch.
|
|
617
|
+
#
|
|
618
|
+
# A SIGINT (Ctrl-C on the main thread) is the exception: it is not queued
|
|
619
|
+
# like those two and cannot be masked. One that lands during the native
|
|
620
|
+
# call is raised the moment the call returns — the VM checks interrupts
|
|
621
|
+
# as it pops the native method's frame, before any Ruby code here can
|
|
622
|
+
# hold the result — so no assignment order would catch it; the sandbox
|
|
623
|
+
# is then torn down when the unreferenced object is garbage-collected.
|
|
624
|
+
#
|
|
625
|
+
# Ownership: {connect_or_create} can hand back a sandbox that was already
|
|
626
|
+
# running for someone else, and {start} restarts a persisted one. The
|
|
627
|
+
# cleanup therefore applies EXACTLY the predicate the block form of
|
|
628
|
+
# {connect_or_create} uses — {Sandbox#owns_lifecycle?} — in all three
|
|
629
|
+
# callers, {create} included: a sandbox this call does not own is never
|
|
630
|
+
# stopped here. Its reference is just let go (GC closes the handle, the
|
|
631
|
+
# sandbox keeps running).
|
|
632
|
+
# @yieldreturn [Native::Sandbox]
|
|
633
|
+
# @return [Sandbox]
|
|
634
|
+
def acquire
|
|
635
|
+
sandbox = nil
|
|
636
|
+
acquired = false
|
|
637
|
+
begin
|
|
638
|
+
Thread.handle_interrupt(Object => :never) do
|
|
639
|
+
sandbox = new(yield)
|
|
640
|
+
end
|
|
641
|
+
acquired = true
|
|
642
|
+
ensure
|
|
643
|
+
if sandbox && !acquired && sandbox.owns_lifecycle?
|
|
644
|
+
begin
|
|
645
|
+
sandbox.stop
|
|
646
|
+
rescue Microsandbox::Error
|
|
647
|
+
# best-effort cleanup; the interrupt is what the caller must see
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
sandbox
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# @api private
|
|
655
|
+
# Shared keyword-option builder for {create}/{connect_or_create}/
|
|
656
|
+
# {create_with_progress}. Pure validation and normalization: the callers
|
|
657
|
+
# provision the runtime ({Microsandbox.ensure_runtime!}) only after this
|
|
658
|
+
# returns, so a rejected option never triggers a runtime download.
|
|
590
659
|
def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
|
|
591
660
|
env: nil, workdir: nil,
|
|
592
661
|
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
@@ -610,7 +679,14 @@ module Microsandbox
|
|
|
610
679
|
if root_disk && oci_upper_size
|
|
611
680
|
raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
|
|
612
681
|
end
|
|
613
|
-
|
|
682
|
+
# The core refuses patches on a snapshot-booted sandbox, but only after
|
|
683
|
+
# it has claimed the sandbox name on disk. Refuse the pair before any
|
|
684
|
+
# side effect.
|
|
685
|
+
if from_snapshot && patches && !Array(patches).empty?
|
|
686
|
+
raise ArgumentError,
|
|
687
|
+
"patches: cannot be combined with from_snapshot: (a snapshot restores its own " \
|
|
688
|
+
"root disk; apply changes after boot with sandbox.fs instead)"
|
|
689
|
+
end
|
|
614
690
|
# `fstype:` names the inner filesystem of a disk-image rootfs, so it only
|
|
615
691
|
# applies when `image:` is a disk-image path (a local path ending in
|
|
616
692
|
# .raw/.qcow2/.vmdk). Routing an OCI ref (e.g. "python") through the
|
|
@@ -692,7 +768,9 @@ module Microsandbox
|
|
|
692
768
|
# @return [Sandbox]
|
|
693
769
|
def start(name, detached: false)
|
|
694
770
|
Microsandbox.ensure_runtime!
|
|
695
|
-
|
|
771
|
+
sandbox_name = name.to_s # outside the interrupt mask, like in {create}
|
|
772
|
+
opts = {"detached" => detached}
|
|
773
|
+
acquire { Native::Sandbox.start(sandbox_name, opts) }
|
|
696
774
|
end
|
|
697
775
|
|
|
698
776
|
# Fetch a controllable handle for a sandbox by name (running or not).
|
|
@@ -779,7 +857,16 @@ module Microsandbox
|
|
|
779
857
|
end
|
|
780
858
|
|
|
781
859
|
def stringify(hash)
|
|
782
|
-
|
|
860
|
+
# Anything that converts to pairs is fine (a Hash, ENV, an Array of
|
|
861
|
+
# pairs). For the rest, name the type, not the value: env values are
|
|
862
|
+
# often credentials.
|
|
863
|
+
pairs = begin
|
|
864
|
+
hash.to_h if hash.respond_to?(:to_h)
|
|
865
|
+
rescue TypeError
|
|
866
|
+
nil
|
|
867
|
+
end
|
|
868
|
+
raise ArgumentError, "expected a Hash of names to values (got #{hash.class})" unless pairs
|
|
869
|
+
pairs.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
|
|
783
870
|
end
|
|
784
871
|
|
|
785
872
|
# Wrap a native page Hash ({"sandboxes" => [...], "next_cursor" => ...})
|
|
@@ -844,7 +931,21 @@ module Microsandbox
|
|
|
844
931
|
# :inject_basic_auth / :inject_query / :inject_body, and :on_violation (see
|
|
845
932
|
# {#normalize_violation}).
|
|
846
933
|
def normalize_secrets(secrets)
|
|
847
|
-
|
|
934
|
+
if secrets.is_a?(Hash)
|
|
935
|
+
# One spec given bare is fine. A Hash keyed by variable name is the
|
|
936
|
+
# shape {Sandbox#modify} takes, where `env:` also means something
|
|
937
|
+
# else (the HOST variable to read); it is not a create-time spec.
|
|
938
|
+
unless secrets.key?(:env) || secrets.key?("env")
|
|
939
|
+
raise ArgumentError,
|
|
940
|
+
"secrets: takes an Array of Hashes like { env:, value:, host: } at create time; " \
|
|
941
|
+
"the { \"NAME\" => { ... } } form belongs to #modify"
|
|
942
|
+
end
|
|
943
|
+
secrets = [secrets]
|
|
944
|
+
end
|
|
945
|
+
Array(secrets).map do |spec|
|
|
946
|
+
raise ArgumentError, "secret spec must be a Hash (got #{spec.class})" unless spec.is_a?(Hash)
|
|
947
|
+
normalize_secret(spec)
|
|
948
|
+
end
|
|
848
949
|
end
|
|
849
950
|
|
|
850
951
|
# @api private
|
|
@@ -1409,9 +1510,11 @@ module Microsandbox
|
|
|
1409
1510
|
# plain Hash) into string-keyed Hashes for the native layer. Values are
|
|
1410
1511
|
# passed through unchanged (mode stays Integer, content stays String).
|
|
1411
1512
|
def normalize_patches(patches)
|
|
1513
|
+
patches = [patches] if patches.is_a?(Hash) # a single patch, not a list of pairs
|
|
1412
1514
|
Array(patches).map do |p|
|
|
1413
1515
|
unless p.is_a?(Hash)
|
|
1414
|
-
|
|
1516
|
+
# Name the type, not the value: a patch can carry file content.
|
|
1517
|
+
raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*), got #{p.class}"
|
|
1415
1518
|
end
|
|
1416
1519
|
p.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
1417
1520
|
end
|
|
@@ -1957,10 +2060,22 @@ module Microsandbox
|
|
|
1957
2060
|
|
|
1958
2061
|
# The booted sandbox. Joins the create task (draining any remaining pull
|
|
1959
2062
|
# progress first), so call it after iterating progress. The returned
|
|
1960
|
-
# {Sandbox} is live — stop it when done. Memoized
|
|
2063
|
+
# {Sandbox} is live — stop it when done. Memoized: the native result can be
|
|
2064
|
+
# taken only once, so a later call returns the same {Sandbox}, or re-raises
|
|
2065
|
+
# the same error if the create failed.
|
|
2066
|
+
#
|
|
2067
|
+
# Interrupt-safe like {Sandbox.create}: if a `Timeout.timeout` (or another
|
|
2068
|
+
# `Thread#raise`/`Thread#kill`) arrives while this is waiting, the sandbox
|
|
2069
|
+
# the session produced is stopped before the exception propagates rather
|
|
2070
|
+
# than left running with nothing referring to it. The result is consumed
|
|
2071
|
+
# by then, so a later call raises instead of returning a sandbox.
|
|
1961
2072
|
# @return [Sandbox]
|
|
1962
2073
|
def sandbox
|
|
1963
|
-
@
|
|
2074
|
+
raise @failure if @failure
|
|
2075
|
+
@sandbox ||= Sandbox.acquire { @native.result }
|
|
2076
|
+
rescue Microsandbox::Error => e
|
|
2077
|
+
@failure ||= e
|
|
2078
|
+
raise
|
|
1964
2079
|
end
|
|
1965
2080
|
end
|
|
1966
2081
|
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,7 +8,7 @@ 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.17.
|
|
11
|
+
VERSION = "0.17.2"
|
|
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
|
data/lib/microsandbox/volume.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Microsandbox
|
|
|
38
38
|
|
|
39
39
|
# @return [Time, nil]
|
|
40
40
|
def created_at
|
|
41
|
-
|
|
41
|
+
Microsandbox.time_from_ms(@created_at_ms)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# A host-side filesystem view over this volume (read/write its contents
|
|
@@ -55,8 +55,10 @@ module Microsandbox
|
|
|
55
55
|
|
|
56
56
|
# A host-side filesystem view over a named volume, from {Volume.fs} or
|
|
57
57
|
# {VolumeInfo#fs}. Reads and writes the volume's contents directly on the host,
|
|
58
|
-
# without booting a sandbox. All paths are relative to the volume root
|
|
59
|
-
#
|
|
58
|
+
# without booting a sandbox. All paths are relative to the volume root (a
|
|
59
|
+
# leading `/` is the volume root) and may not contain a `..` segment — every
|
|
60
|
+
# operation raises {FilesystemError} for one. Mirrors the `VolumeFs` of the
|
|
61
|
+
# official Python/Node SDKs.
|
|
60
62
|
class VolumeFs
|
|
61
63
|
def initialize(native)
|
|
62
64
|
@native = native
|
|
@@ -65,13 +67,13 @@ module Microsandbox
|
|
|
65
67
|
# Read a file as raw bytes (ASCII-8BIT).
|
|
66
68
|
# @return [String]
|
|
67
69
|
def read(path)
|
|
68
|
-
@native.read(path
|
|
70
|
+
@native.read(contained(path))
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
# Read a file as a UTF-8 string.
|
|
72
74
|
# @return [String]
|
|
73
75
|
def read_text(path)
|
|
74
|
-
@native.read_text(path
|
|
76
|
+
@native.read_text(contained(path))
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
# Write data to a file, creating parent directories as needed.
|
|
@@ -80,60 +82,75 @@ module Microsandbox
|
|
|
80
82
|
# @return [nil]
|
|
81
83
|
def write(path, data)
|
|
82
84
|
bytes = Microsandbox.coerce_write_bytes(data)
|
|
83
|
-
@native.write(path
|
|
85
|
+
@native.write(contained(path), bytes)
|
|
84
86
|
nil
|
|
85
87
|
end
|
|
86
88
|
|
|
87
89
|
# List the entries of a directory.
|
|
88
90
|
# @return [Array<FsEntry>]
|
|
89
91
|
def list(path)
|
|
90
|
-
@native.list(path
|
|
92
|
+
@native.list(contained(path)).map { |entry| FsEntry.new(entry) }
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
# Create a directory (and any missing parents).
|
|
94
96
|
# @return [nil]
|
|
95
97
|
def mkdir(path)
|
|
96
|
-
@native.mkdir(path
|
|
98
|
+
@native.mkdir(contained(path))
|
|
97
99
|
nil
|
|
98
100
|
end
|
|
99
101
|
|
|
100
102
|
# Remove a single file.
|
|
101
103
|
# @return [nil]
|
|
102
104
|
def remove_file(path)
|
|
103
|
-
@native.remove_file(path
|
|
105
|
+
@native.remove_file(contained(path))
|
|
104
106
|
nil
|
|
105
107
|
end
|
|
106
108
|
|
|
107
109
|
# Remove a directory recursively.
|
|
108
110
|
# @return [nil]
|
|
109
111
|
def remove_dir(path)
|
|
110
|
-
@native.remove_dir(path
|
|
112
|
+
@native.remove_dir(contained(path))
|
|
111
113
|
nil
|
|
112
114
|
end
|
|
113
115
|
|
|
114
116
|
# @return [Boolean] whether the path exists in the volume
|
|
115
117
|
def exists?(path)
|
|
116
|
-
@native.exists(path
|
|
118
|
+
@native.exists(contained(path))
|
|
117
119
|
end
|
|
118
120
|
|
|
119
121
|
# Copy a file within the volume.
|
|
120
122
|
# @return [nil]
|
|
121
123
|
def copy(src, dst)
|
|
122
|
-
@native.copy(src
|
|
124
|
+
@native.copy(contained(src), contained(dst))
|
|
123
125
|
nil
|
|
124
126
|
end
|
|
125
127
|
|
|
126
128
|
# Rename/move a file or directory within the volume.
|
|
127
129
|
# @return [nil]
|
|
128
130
|
def rename(src, dst)
|
|
129
|
-
@native.rename(src
|
|
131
|
+
@native.rename(contained(src), contained(dst))
|
|
130
132
|
nil
|
|
131
133
|
end
|
|
132
134
|
|
|
133
135
|
# Stat a path.
|
|
134
136
|
# @return [FsMetadata]
|
|
135
137
|
def stat(path)
|
|
136
|
-
FsMetadata.new(@native.stat(path
|
|
138
|
+
FsMetadata.new(@native.stat(contained(path)))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# Volume paths are relative to the volume root and never need a `..`
|
|
144
|
+
# segment, so one is refused here with a clear message instead of being
|
|
145
|
+
# handed to the resolver. The native layer enforces the same rule; this
|
|
146
|
+
# check only exists so the error names the cause.
|
|
147
|
+
def contained(path)
|
|
148
|
+
s = path.to_s
|
|
149
|
+
if s.split("/").include?("..")
|
|
150
|
+
raise Microsandbox::FilesystemError,
|
|
151
|
+
"path traversal outside volume root (`..` segments are not allowed in volume paths)"
|
|
152
|
+
end
|
|
153
|
+
s
|
|
137
154
|
end
|
|
138
155
|
end
|
|
139
156
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entry point named after the gem. `require "microsandbox"` resolves to
|
|
4
|
+
# whichever installed gem ships that file first, and the separate
|
|
5
|
+
# `microsandbox` gem on RubyGems ships one too; outside Bundler,
|
|
6
|
+
# `require "microsandbox-rb"` always loads this gem.
|
|
7
|
+
require_relative "microsandbox"
|
data/lib/microsandbox.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
3
5
|
require_relative "microsandbox/version"
|
|
4
6
|
|
|
5
7
|
# Load the compiled native extension. Precompiled platform gems stage the
|
|
@@ -47,6 +49,13 @@ require_relative "microsandbox/sandbox"
|
|
|
47
49
|
# puts sb.exec("python", ["-c", "print('Hello, World!')"]).stdout
|
|
48
50
|
# end
|
|
49
51
|
module Microsandbox
|
|
52
|
+
# Serializes runtime provisioning within this process: {install}, {setup} and
|
|
53
|
+
# the first-use check in {ensure_runtime!}. The installer replaces files in
|
|
54
|
+
# place, so two threads racing through a first `Sandbox.create` must not both
|
|
55
|
+
# run it. A Monitor (re-entrant) because ensure_runtime! calls install.
|
|
56
|
+
RUNTIME_SETUP_LOCK = Monitor.new
|
|
57
|
+
private_constant :RUNTIME_SETUP_LOCK
|
|
58
|
+
|
|
50
59
|
class << self
|
|
51
60
|
# @return [String] the gem version
|
|
52
61
|
def version
|
|
@@ -74,7 +83,7 @@ module Microsandbox
|
|
|
74
83
|
# gem supplies the runtime.
|
|
75
84
|
# @return [nil]
|
|
76
85
|
def install
|
|
77
|
-
Native.install
|
|
86
|
+
RUNTIME_SETUP_LOCK.synchronize { Native.install }
|
|
78
87
|
nil
|
|
79
88
|
end
|
|
80
89
|
|
|
@@ -93,7 +102,7 @@ module Microsandbox
|
|
|
93
102
|
opts["version"] = version.to_s if version
|
|
94
103
|
opts["force"] = true if force
|
|
95
104
|
opts["skip_verify"] = true if skip_verify
|
|
96
|
-
Native.setup(opts)
|
|
105
|
+
RUNTIME_SETUP_LOCK.synchronize { Native.setup(opts) }
|
|
97
106
|
nil
|
|
98
107
|
end
|
|
99
108
|
|
|
@@ -130,34 +139,38 @@ module Microsandbox
|
|
|
130
139
|
# @return [nil]
|
|
131
140
|
def ensure_runtime!
|
|
132
141
|
return if @runtime_ready
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
142
|
+
RUNTIME_SETUP_LOCK.synchronize do
|
|
143
|
+
# Re-check under the lock: another thread may have finished meanwhile.
|
|
144
|
+
return if @runtime_ready
|
|
145
|
+
# A cloud backend has no local msb/libkrunfw runtime to provision: skip the
|
|
146
|
+
# presence check and the first-use download entirely. Resolving the kind
|
|
147
|
+
# uses the same lazy env/profile/config ladder every operation already
|
|
148
|
+
# consults, so this adds no work for local hosts (the common case).
|
|
149
|
+
return if default_backend_kind == :cloud
|
|
150
|
+
# The binaries gem won the resolver: its vendored msb (and the libkrunfw
|
|
151
|
+
# beside it) are exactly the version this gem was built for, so there is
|
|
152
|
+
# nothing to verify or download. (If `MSB_PATH` overrides it, the user owns
|
|
153
|
+
# the runtime and we fall through to the existing behaviour.)
|
|
154
|
+
if bundled_runtime_active?
|
|
155
|
+
@runtime_ready = true
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
# Opted out: the caller manages the runtime out of band, so don't fetch,
|
|
159
|
+
# verify, or repair it here. Memoize the decision (the env var is stable for
|
|
160
|
+
# the process); the operation resolves `msb` itself and surfaces any problem.
|
|
161
|
+
if auto_install_disabled?
|
|
162
|
+
@runtime_ready = true
|
|
163
|
+
return
|
|
164
|
+
end
|
|
153
165
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
unless installed?
|
|
167
|
+
warn "[microsandbox] runtime (msb + libkrunfw) not found; " \
|
|
168
|
+
"downloading to ~/.microsandbox (set MICROSANDBOX_NO_AUTO_INSTALL to skip, " \
|
|
169
|
+
"or install the microsandbox-rb-binaries gem to ship it with your bundle)..."
|
|
170
|
+
end
|
|
171
|
+
install
|
|
172
|
+
@runtime_ready = true
|
|
158
173
|
end
|
|
159
|
-
install
|
|
160
|
-
@runtime_ready = true
|
|
161
174
|
nil
|
|
162
175
|
end
|
|
163
176
|
|
|
@@ -176,7 +189,9 @@ module Microsandbox
|
|
|
176
189
|
# setter is a no-op — use `MSB_PATH` to override a bundled runtime.
|
|
177
190
|
# @param path [String]
|
|
178
191
|
# @return [void]
|
|
192
|
+
# @raise [ArgumentError] for nil or an empty path (the slot cannot be cleared)
|
|
179
193
|
def runtime_path=(path)
|
|
194
|
+
require_runtime_path!(:runtime_path=, path)
|
|
180
195
|
if @bundled_msb_path && path.to_s != @bundled_msb_path
|
|
181
196
|
warn "[microsandbox] runtime_path= ignored: the microsandbox-rb-binaries gem already " \
|
|
182
197
|
"claimed the set-once SDK slot (#{@bundled_msb_path}); set MSB_PATH to override it"
|
|
@@ -190,7 +205,9 @@ module Microsandbox
|
|
|
190
205
|
# Mirrors {runtime_path=} for libkrunfw.
|
|
191
206
|
# @param path [String]
|
|
192
207
|
# @return [void]
|
|
208
|
+
# @raise [ArgumentError] for nil or an empty path (the slot cannot be cleared)
|
|
193
209
|
def libkrunfw_path=(path)
|
|
210
|
+
require_runtime_path!(:libkrunfw_path=, path)
|
|
194
211
|
Native.set_runtime_libkrunfw_path(path.to_s)
|
|
195
212
|
end
|
|
196
213
|
|
|
@@ -272,6 +289,36 @@ module Microsandbox
|
|
|
272
289
|
raise TypeError, "data must be a String (got #{data.class})"
|
|
273
290
|
end
|
|
274
291
|
|
|
292
|
+
# A Time for an epoch-milliseconds Integer from the native layer, or nil.
|
|
293
|
+
# Integer arithmetic on purpose: `Time.at(ms / 1000.0)` goes through a
|
|
294
|
+
# Float and lands one millisecond low for about half of all timestamps.
|
|
295
|
+
# @api private
|
|
296
|
+
# @param ms [Integer, nil]
|
|
297
|
+
# @return [Time, nil]
|
|
298
|
+
def time_from_ms(ms)
|
|
299
|
+
ms && Time.at(ms.div(1000), ms % 1000, :millisecond)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Run the block, then close +resource+ — the shared tail of the block forms
|
|
303
|
+
# that own a resource whose close can fail (a write sink flushes on close).
|
|
304
|
+
# If the block raised, its exception is the one the caller needs: a failure
|
|
305
|
+
# from the close that follows is dropped instead of replacing it. If the
|
|
306
|
+
# block finished (or left via break/throw), a close failure propagates.
|
|
307
|
+
# @api private
|
|
308
|
+
def close_after(resource)
|
|
309
|
+
raised = false
|
|
310
|
+
yield
|
|
311
|
+
rescue Exception # standard:disable Lint/RescueException -- re-raised unchanged
|
|
312
|
+
raised = true
|
|
313
|
+
raise
|
|
314
|
+
ensure
|
|
315
|
+
begin
|
|
316
|
+
resource.close
|
|
317
|
+
rescue
|
|
318
|
+
raise unless raised
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
275
322
|
private
|
|
276
323
|
|
|
277
324
|
# Auto-provisioning is on by default; any non-empty, non-"0"/"false" value
|
|
@@ -358,12 +405,22 @@ module Microsandbox
|
|
|
358
405
|
private_constant :TRUSTED_BINARIES_OWNERS
|
|
359
406
|
|
|
360
407
|
# Name of the loaded gem whose files define Microsandbox::Binaries, or nil
|
|
361
|
-
# when it came from a bare load path.
|
|
408
|
+
# when it came from a bare load path. Gem roots can nest — a host project
|
|
409
|
+
# that is itself a gem, with its bundle installed under the project
|
|
410
|
+
# directory, is a prefix of every gem in that bundle — so the owner is the
|
|
411
|
+
# gem with the LONGEST matching root, not whichever one is listed first.
|
|
362
412
|
def bundled_runtime_owner
|
|
363
413
|
source = Binaries.method(:msb_path).source_location&.first
|
|
364
414
|
return nil unless source
|
|
365
|
-
|
|
366
|
-
|
|
415
|
+
roots = Gem.loaded_specs.values.select { |s| source.start_with?(File.join(s.full_gem_path, "")) }
|
|
416
|
+
roots.max_by { |s| s.full_gem_path.length }&.name
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The runtime path slots are set-once in the core and have no "unset": nil
|
|
420
|
+
# or "" would be stored as an empty path for the rest of the process.
|
|
421
|
+
def require_runtime_path!(setter, path)
|
|
422
|
+
return unless path.nil? || path.to_s.empty?
|
|
423
|
+
raise ArgumentError, "#{setter} needs a non-empty path (the slot is set-once and cannot be cleared)"
|
|
367
424
|
end
|
|
368
425
|
|
|
369
426
|
# Whether the binaries gem's msb is what the resolver actually returns — i.e.
|