microsandbox-rb 0.8.0 → 0.8.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 +158 -1
- data/Cargo.lock +1 -1
- data/DESIGN.md +22 -4
- data/README.md +10 -1
- data/ext/microsandbox/Cargo.toml +1 -1
- data/ext/microsandbox/extconf.rb +33 -14
- data/ext/microsandbox/src/error.rs +16 -0
- data/ext/microsandbox/src/sandbox.rs +39 -9
- data/lib/microsandbox/agent.rb +4 -0
- data/lib/microsandbox/errors.rb +15 -0
- data/lib/microsandbox/exec_handle.rb +7 -0
- data/lib/microsandbox/fs.rb +5 -0
- data/lib/microsandbox/sandbox.rb +37 -5
- data/lib/microsandbox/ssh.rb +15 -4
- data/lib/microsandbox/streams.rb +13 -0
- data/lib/microsandbox/version.rb +1 -1
- data/lib/microsandbox.rb +36 -17
- data/sig/microsandbox.rbs +14 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9fa6d9703f579a81424b9a32d4ab7c066eb5a48c1f8f7407ae2f6ca98770fd85
|
|
4
|
+
data.tar.gz: 6f83fea6ecf71906786bbb2416d6ee3ad52a588077024a5da0eed71eb49430ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e18a042c506b1d501f072816c6bdd20f429c52a18410112cb82fb9fc97981d2ce38f53dc02b4024627c64a7dc6b643060c0d67c0dd073500b91a83a3d7c6319b
|
|
7
|
+
data.tar.gz: b7b473d7e9d5fc5321abd6f9e2348ed9f0e75d1a924e64fa2e19580eb78e40230cddbdc310132ffd9248c984e6684293868956abb365a72ca3dcf4d3b828f384
|
data/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,164 @@ All notable changes to this gem are documented here. The format is based on
|
|
|
6
6
|
microsandbox runtime it embeds; each release notes the upstream runtime tag it
|
|
7
7
|
wraps, and the README's Versioning section keeps the full gem→runtime map.
|
|
8
8
|
|
|
9
|
-
## [
|
|
9
|
+
## [0.8.2] - 2026-06-29
|
|
10
|
+
|
|
11
|
+
Gem-only release on the `v0.5.10` runtime (unchanged). Bundles the post-`0.8.1`
|
|
12
|
+
audit follow-ups: a secret-leak fix, typed snapshot errors, panic-free duration
|
|
13
|
+
parsing, the precompiled fat-gem loader + `extconf` preflight corrections, and a
|
|
14
|
+
sweep of threading/streaming/SSH documentation.
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
- **Calling-thread non-preemption is now documented** (issue #24). The GVL is
|
|
19
|
+
released during native calls so *other* threads keep running, but the
|
|
20
|
+
*calling* thread blocks uninterruptibly until the call returns —
|
|
21
|
+
`Timeout::timeout`/`Thread#kill`/Ctrl-C can't interrupt it. README and
|
|
22
|
+
DESIGN.md now state this and steer callers to the genuinely-bounding
|
|
23
|
+
`exec(timeout:)` / `shell(timeout:)` knobs, and clarify that
|
|
24
|
+
`AgentClient.connect_sandbox`/`connect_path`'s `timeout:` bounds only the
|
|
25
|
+
connect handshake while `AgentClient#request`/`#stream` and the streaming
|
|
26
|
+
paths have no timeout knob and can block indefinitely — rather than reaching
|
|
27
|
+
for `Timeout::timeout`.
|
|
28
|
+
- **`exec` `timeout: 0` semantics clarified** (issue #29). The `@param timeout`
|
|
29
|
+
doc now notes the asymmetry: omit or `nil` means *no* timeout, while `0` is an
|
|
30
|
+
immediate (zero) deadline that kills the command before any output and raises
|
|
31
|
+
`ExecTimeoutError` — so use `nil`/omit, never `0`, for "no limit". Also noted
|
|
32
|
+
that `exec_stream`/`shell_stream` accept `timeout:` but do **not** apply it
|
|
33
|
+
(the streaming path discards it).
|
|
34
|
+
- **Streaming classes documented as single-pass / single-consumer** (issues #34,
|
|
35
|
+
#31). `ExecHandle`, `LogStream`, `MetricsStream`, `FsReadStream`,
|
|
36
|
+
`PullSession`, and `AgentStream` are `Enumerable` but drain a one-shot native
|
|
37
|
+
channel: forward-only, not rewindable, and meant for one consumer on one
|
|
38
|
+
thread. A second `each` (or a combinator after a partial drain) silently
|
|
39
|
+
yields nothing. Noted on each class and in a README streaming caveat.
|
|
40
|
+
- **SSH `close` disconnect behavior documented** (issue #33). `SshClient#close` /
|
|
41
|
+
`SftpClient#close` send the graceful protocol disconnect; relying on GC skips
|
|
42
|
+
it (only the in-process server task is aborted). The block-less
|
|
43
|
+
`open_client`/`sftp` docs now tell callers to `close` (or use the block form)
|
|
44
|
+
for a clean disconnect.
|
|
45
|
+
|
|
46
|
+
### Internal
|
|
47
|
+
|
|
48
|
+
- **`DESIGN.md` refreshed** (issue #35). The stale runtime-pin references
|
|
49
|
+
(`v0.5.7`/`v0.5.8`) now point at `v0.5.10` via `RUNTIME_VERSION`, and the
|
|
50
|
+
hard-coded unit-example count is replaced with rot-proof phrasing.
|
|
51
|
+
- **RBS gains a note on SDK-constructed types** (issue #36). `sig/microsandbox.rbs`
|
|
52
|
+
now carries a top-of-file note explaining that native-backed value/handle/stream
|
|
53
|
+
types are constructed by the SDK from an internal native handle or data hash, not
|
|
54
|
+
by user code. Their `initialize` signatures are kept: RBS derives `new` from
|
|
55
|
+
`initialize`, so omitting them would not hide the constructor — it would
|
|
56
|
+
synthesize a misleading zero-arg `() -> instance` that Ruby actually rejects.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **Precompiled fat-gem loader now finds the staged binary** (issue #25). The
|
|
61
|
+
native-extension require used `RbConfig::CONFIG["ruby_version"]` — the API
|
|
62
|
+
string `"3.4.0"` — but rake-compiler stages a multi-version fat gem's binaries
|
|
63
|
+
under the **major.minor** subdir (`3.4`), so the versioned require always
|
|
64
|
+
missed and fell to the flat-path rescue, which is **absent** in a precompiled
|
|
65
|
+
gem (only the versioned binary is packed). Every fat-gem install would have
|
|
66
|
+
failed at `require "microsandbox"` the moment precompiled gems are promoted.
|
|
67
|
+
The loader now derives the subdir from `RUBY_VERSION[/\d+\.\d+/]` (`"3.4"`),
|
|
68
|
+
matching the staged path; the flat-path rescue still covers source builds.
|
|
69
|
+
- **`extconf` MSRV preflight probes the compiler the build actually runs**
|
|
70
|
+
(issue #39). The preflight ran a bare `rustc --version` and hard-aborted when
|
|
71
|
+
`< 1.91`, but the build is driven by `cargo`, which resolves its compiler from
|
|
72
|
+
`$RUSTC` if set, otherwise the bare `rustc` on PATH — it never uses the `rustc`
|
|
73
|
+
beside the `cargo` binary, and the rustup `cargo` shim neither sets `$RUSTC` nor
|
|
74
|
+
reorders PATH. The preflight now mirrors that exact resolution (`$RUSTC`, else
|
|
75
|
+
PATH `rustc`), so it neither false-passes when a stale non-rustup `rustc`
|
|
76
|
+
shadows a rustup `cargo` (the build would compile with that stale `rustc` and
|
|
77
|
+
fail deep in smoltcp) nor false-aborts when `$RUSTC` points at a newer compiler.
|
|
78
|
+
|
|
79
|
+
### Internal
|
|
80
|
+
|
|
81
|
+
- **CI now installs the packed gem from source** (issue #37). A new `package`
|
|
82
|
+
job runs `rake build`, `gem install`s the packed gem (exercising the gemspec
|
|
83
|
+
`spec.files` glob and the full from-gem `extconf` + `cargo` compile against the
|
|
84
|
+
packed `Cargo.toml`/`Cargo.lock`/`rust-toolchain.toml`), and requires it from
|
|
85
|
+
outside the repo. Previously every job compiled the working tree in place, so
|
|
86
|
+
a packaging regression could reach RubyGems undetected.
|
|
87
|
+
- **`version_spec` now guards the `Cargo.lock` version** (issue #38). The spec
|
|
88
|
+
already asserted `Native.version == VERSION` and the runtime-tag pin, but
|
|
89
|
+
nothing checked the `microsandbox_rb` version in the committed `Cargo.lock`,
|
|
90
|
+
which the gemspec packs. A release that bumped `version.rb` + `Cargo.toml` but
|
|
91
|
+
forgot to refresh the lock would ship a stale lock (and a `--locked` build
|
|
92
|
+
would reject it) — a recurring release mistake this now catches.
|
|
93
|
+
### Security
|
|
94
|
+
|
|
95
|
+
- **Secret values no longer leak into `ArgumentError` messages** (issue #23).
|
|
96
|
+
`Sandbox.create(secrets:)` validation interpolated the whole secret spec via
|
|
97
|
+
`spec.inspect` into two error messages — and because the `:value`-present
|
|
98
|
+
guard runs first, the "needs `:host`/`:hosts`/`:host_patterns`" error *always*
|
|
99
|
+
embedded the cleartext secret value (and the env/value error did whenever a
|
|
100
|
+
value was supplied). Such messages routinely reach logs and error trackers.
|
|
101
|
+
Both messages now report the spec's keys only (`spec.keys.inspect`), mirroring
|
|
102
|
+
the existing `registry_auth` handling, with a unit spec asserting the value
|
|
103
|
+
is never present in the raised message.
|
|
104
|
+
|
|
105
|
+
### Fixed
|
|
106
|
+
|
|
107
|
+
- **Native duration parsing is panic-free regardless of the Ruby layer**
|
|
108
|
+
(issue #30). The native binding called `Duration::from_secs_f64` directly at
|
|
109
|
+
five sites (`exec`/`shell` timeout, `stop_with_timeout`, `kill_with_timeout`,
|
|
110
|
+
`metrics_stream` interval, `replace_with_timeout`); that panics on NaN/Inf/
|
|
111
|
+
negative *and on finite-but-out-of-range* values (e.g. `Float::MAX`), which
|
|
112
|
+
surfaced as an ugly panic-turned-exception. The Ruby `coerce_duration` guard
|
|
113
|
+
set no upper bound, so a large finite value still reached and panicked the
|
|
114
|
+
native layer. All five sites now route through a `secs_to_duration` helper
|
|
115
|
+
(`try_from_secs_f64` + a clean `Microsandbox::Error`), matching the existing
|
|
116
|
+
agent-client pattern — defense in depth so the native layer is panic-free on
|
|
117
|
+
its own.
|
|
118
|
+
### Added
|
|
119
|
+
|
|
120
|
+
- **Typed snapshot error classes** (issue #28). The five core snapshot error
|
|
121
|
+
variants — reachable through the gem's fully-wired `Snapshot` API — previously
|
|
122
|
+
collapsed to the base `Microsandbox::Error`, forcing callers to string-match
|
|
123
|
+
the message. They now raise typed subclasses:
|
|
124
|
+
`SnapshotNotFoundError` (`snapshot-not-found`),
|
|
125
|
+
`SnapshotAlreadyExistsError` (`snapshot-already-exists`),
|
|
126
|
+
`SnapshotSandboxRunningError` (`snapshot-sandbox-running`),
|
|
127
|
+
`SnapshotImageMissingError` (`snapshot-image-missing`), and
|
|
128
|
+
`SnapshotIntegrityError` (`snapshot-integrity`). This goes **beyond** the
|
|
129
|
+
Python SDK mirror (which defines no snapshot classes), matching the Go SDK's
|
|
130
|
+
per-variant coverage — a deliberate divergence. Additionally, the previously
|
|
131
|
+
orphaned `NetworkPolicyError` now also carries the core's `NetworkBuilder`
|
|
132
|
+
build/validation error (a `network(|n| ...)` failure), which previously fell
|
|
133
|
+
through to the base `Error`. All additive — existing `rescue Microsandbox::Error`
|
|
134
|
+
handlers still catch them.
|
|
135
|
+
|
|
136
|
+
## [0.8.1] - 2026-06-25
|
|
137
|
+
|
|
138
|
+
Gem-only release on the `v0.5.10` runtime (unchanged) — the two follow-ups to
|
|
139
|
+
`0.8.0`'s runtime adoption that review surfaced.
|
|
140
|
+
|
|
141
|
+
### Added
|
|
142
|
+
|
|
143
|
+
- **Per-bind-mount guest-write quota override** (issue #19). An inline `volumes:`
|
|
144
|
+
bind mount now accepts a `quota_mib:` key to override the runtime's default
|
|
145
|
+
guest-write budget (4 GiB as of `v0.5.10`, documented in `0.8.0`), e.g.
|
|
146
|
+
`volumes: { "/out" => { bind: "/host/out", quota_mib: 16_384 } }`. The runtime
|
|
147
|
+
still applies the 4 GiB default when unset; there is no unbounded option, so
|
|
148
|
+
raise the value if a workload writes more. Valid on bind mounts only — the core
|
|
149
|
+
rejects it on tmpfs/disk/named mounts (set a named volume's quota via
|
|
150
|
+
`Volume.create(quota_mib:)`).
|
|
151
|
+
|
|
152
|
+
### Fixed
|
|
153
|
+
|
|
154
|
+
- **Stale local runtime is now re-provisioned instead of boot-failing**
|
|
155
|
+
(issue #18). `Microsandbox.ensure_runtime!` short-circuited as soon as
|
|
156
|
+
`installed?` was true, but that check confirms only that the `msb`/`libkrunfw`
|
|
157
|
+
files *exist*, not that their version matches the runtime this gem build links.
|
|
158
|
+
An older `msb` left in `~/.microsandbox` by a previous gem version therefore
|
|
159
|
+
passed and then failed every `Sandbox.create` at boot on a host↔guest
|
|
160
|
+
wire-protocol mismatch (e.g. a `v0.5.8` `msb` rejecting the `--config-fd` flag
|
|
161
|
+
the `v0.5.10` runtime passes). `ensure_runtime!` now delegates to the
|
|
162
|
+
idempotent, version-correcting installer on first use even when the runtime is
|
|
163
|
+
present (a cheap `msb --version`; re-downloads only on absence/mismatch), so an
|
|
164
|
+
upgrade-over-stale-install self-heals. Source-gem installs were already
|
|
165
|
+
corrected at build time; this closes the gap for the precompiled-gem upgrade
|
|
166
|
+
path. `MICROSANDBOX_NO_AUTO_INSTALL` still fully opts out.
|
|
10
167
|
|
|
11
168
|
## [0.8.0] - 2026-06-25
|
|
12
169
|
|
data/Cargo.lock
CHANGED
data/DESIGN.md
CHANGED
|
@@ -58,6 +58,23 @@ only (never touches the Ruby C API) and uses `catch_unwind` so a Rust panic is
|
|
|
58
58
|
captured and re-raised *after* the GVL is re-acquired rather than unwinding
|
|
59
59
|
across the C frame (which would be UB).
|
|
60
60
|
|
|
61
|
+
**The *calling* thread is not preemptible during the call.** `nogvl` passes a
|
|
62
|
+
null unblock-function to `rb_thread_call_without_gvl`, and Ruby only checks
|
|
63
|
+
pending interrupts *before* and *after* the GVL-released region — so while a
|
|
64
|
+
native call blocks, the thread that issued it cannot be interrupted:
|
|
65
|
+
`Timeout::timeout` (which relies on `Thread#raise`), `Thread#kill`, and `SIGINT`
|
|
66
|
+
(Ctrl-C) are all deferred until the call returns on its own. The GVL release
|
|
67
|
+
keeps *other* threads live; it does **not** make the calling thread
|
|
68
|
+
cancelable. This is harmless for the bounded calls (`exec`/`shell` with a
|
|
69
|
+
`timeout:`, `AgentClient` with a `timeout:`) because the deadline fires inside
|
|
70
|
+
the future, but the unbounded/streaming paths — `exec`/`shell` with
|
|
71
|
+
`timeout: nil`, `ExecHandle#recv`/`#wait`, `log_stream`/`metrics_stream` `recv`
|
|
72
|
+
(especially `follow: true`), `Sandbox#wait`/`SandboxHandle#wait_until_stopped`,
|
|
73
|
+
and `AgentClient#request` with no timeout — can block their caller indefinitely
|
|
74
|
+
if the guest wedges or a relay drops. **Bound such calls with the explicit
|
|
75
|
+
`timeout:` knobs rather than wrapping them in `Timeout::timeout`**, which will
|
|
76
|
+
not fire while the native call is in flight.
|
|
77
|
+
|
|
61
78
|
## Error mapping
|
|
62
79
|
|
|
63
80
|
The core returns one big `MicrosandboxError` enum. `error.rs` maps each variant
|
|
@@ -88,7 +105,8 @@ out (air-gapped hosts that provision out of band). libkrunfw is `dlopen`'d by
|
|
|
88
105
|
## Core-crate dependency (self-contained)
|
|
89
106
|
|
|
90
107
|
`ext/microsandbox/Cargo.toml` depends on the core crate via a **pinned git tag**
|
|
91
|
-
(`microsandbox` / `microsandbox-network
|
|
108
|
+
(`microsandbox` / `microsandbox-network`, pinned to the same tag as
|
|
109
|
+
`Microsandbox::RUNTIME_VERSION` — currently `v0.5.10`), so the gem builds anywhere
|
|
92
110
|
— CI, `rake-compiler-dock` release containers, and end-user source installs —
|
|
93
111
|
without an adjacent checkout. For fast local development against a sibling
|
|
94
112
|
microsandbox checkout, copy `.cargo/config.toml.example` to `.cargo/config.toml`
|
|
@@ -166,8 +184,8 @@ Create options now cover `image`, `cpus`, `memory`, `oci_upper_size`, `env`,
|
|
|
166
184
|
|
|
167
185
|
The binding is verified at four levels:
|
|
168
186
|
|
|
169
|
-
1. **Unit** (
|
|
170
|
-
objects, with the native layer stubbed.
|
|
187
|
+
1. **Unit** (several hundred examples) — the Ruby layer's option normalization
|
|
188
|
+
and value objects, with the native layer stubbed.
|
|
171
189
|
2. **Real-microVM integration** (`spec/integration`, opt-in via
|
|
172
190
|
`MICROSANDBOX_INTEGRATION=1`) — boots actual sandboxes and round-trips
|
|
173
191
|
`exec`/`shell`/`fs`/`metrics`/`logs`/streaming/snapshots. Run locally on
|
|
@@ -193,7 +211,7 @@ create options, full mount options (tmpfs/disk + stat-virtualization/
|
|
|
193
211
|
host-permissions), and snapshot inspection (`open`/`list_dir`/`reindex`).
|
|
194
212
|
|
|
195
213
|
A few **secondary** upstream knobs remain unexposed (a genuine binding gap, not
|
|
196
|
-
upstream-gated — they exist at the pinned `v0.5.
|
|
214
|
+
upstream-gated — they exist at the pinned `v0.5.10` runtime): per-published-port host
|
|
197
215
|
**bind address** (ports always bind loopback), network **interface overrides**,
|
|
198
216
|
and inline **named-volume create-mode** (pre-create with `Volume.create`, then
|
|
199
217
|
mount with `{ named: }`). These slot in module-by-module exactly as the existing
|
data/README.md
CHANGED
|
@@ -43,7 +43,7 @@ them. Our deepest thanks to the maintainers and community. 🙏
|
|
|
43
43
|
- **SSH & SFTP** — native in-process SSH client/server and file transfer (`Sandbox#ssh`)
|
|
44
44
|
- **Raw agent client** — byte-level access to the guest `agentd` protocol (`Microsandbox::AgentClient`)
|
|
45
45
|
- **Idiomatic Ruby** — keyword arguments, block-scoped lifecycle, a typed error hierarchy
|
|
46
|
-
- **Thread-friendly** — the GVL is released during sandbox calls, so
|
|
46
|
+
- **Thread-friendly** — the GVL is released during sandbox calls, so _other_ Ruby threads keep running. The _calling_ thread blocks uninterruptibly until the call returns (`Timeout::timeout`/`Thread#kill`/Ctrl-C can't interrupt a blocked native call), so bound long-running work with a real deadline where one exists: `exec(timeout:)` / `shell(timeout:)` kill the guest command after N seconds, and `AgentClient.connect_sandbox`/`connect_path` take a `timeout:` that bounds only the connect handshake. The streaming paths and `AgentClient#request`/`#stream` have no timeout knob and can block indefinitely if the guest wedges — see DESIGN.md
|
|
47
47
|
|
|
48
48
|
## Requirements
|
|
49
49
|
|
|
@@ -244,6 +244,13 @@ Microsandbox::Sandbox.create("stream", image: "public.ecr.aws/docker/library/pyt
|
|
|
244
244
|
end
|
|
245
245
|
```
|
|
246
246
|
|
|
247
|
+
> **Streams are single-pass.** `ExecHandle`, `LogStream`, `MetricsStream`,
|
|
248
|
+
> `FsReadStream`, `PullSession`, and `AgentStream` are `Enumerable`, but `each`
|
|
249
|
+
> drains a one-shot native channel — they are forward-only, not rewindable, and
|
|
250
|
+
> meant for a single consumer. Iterate (or `collect`/`read`) exactly once: a
|
|
251
|
+
> second `each`, or a combinator after a partial drain (`count` then `each`,
|
|
252
|
+
> `to_a` twice), silently yields nothing. Don't share one handle across threads.
|
|
253
|
+
|
|
247
254
|
### Images
|
|
248
255
|
|
|
249
256
|
Manage the local OCI image cache (images are pulled automatically on `create`):
|
|
@@ -398,6 +405,8 @@ Microsandbox.runtime_version # => "v0.5.10" (the embedded upstream runtime ta
|
|
|
398
405
|
| `0.6.0` | `v0.5.8` | gem version decoupled from the upstream tag; adds `runtime_version` |
|
|
399
406
|
| `0.7.0` | `v0.5.8` | SDK parity release (large binding-gap closure) |
|
|
400
407
|
| `0.8.0` | `v0.5.10` | adopts upstream `v0.5.10` (idle-only heartbeat, config-fd hardening, **4 GiB default bind-mount quota**); supersedes the reverted `v0.5.9` attempt |
|
|
408
|
+
| `0.8.1` | `v0.5.10` | gem-only: re-provision a stale local runtime; per-bind-mount `quota_mib:` override |
|
|
409
|
+
| `0.8.2` | `v0.5.10` | gem-only: redact secrets from errors, typed snapshot errors, panic-free durations, fat-gem loader + `extconf` preflight fixes, threading/streaming docs |
|
|
401
410
|
|
|
402
411
|
**Going forward** — the gem version moves on its own semver track and no longer
|
|
403
412
|
mirrors the upstream tag:
|
data/ext/microsandbox/Cargo.toml
CHANGED
|
@@ -7,7 +7,7 @@ description = "Ruby SDK native extension for microsandbox — secure, fast micro
|
|
|
7
7
|
# Must equal Microsandbox::VERSION (lib/microsandbox/version.rb) — Native.version
|
|
8
8
|
# returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
|
|
9
9
|
# The core-crate dependency below stays pinned at its own tag (v0.5.10).
|
|
10
|
-
version = "0.8.
|
|
10
|
+
version = "0.8.2"
|
|
11
11
|
authors = ["Super Rad Company <development@superrad.company>"]
|
|
12
12
|
repository = "https://github.com/superradcompany/microsandbox"
|
|
13
13
|
license = "Apache-2.0"
|
data/ext/microsandbox/extconf.rb
CHANGED
|
@@ -2,31 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
require "mkmf"
|
|
4
4
|
require "rb_sys/mkmf"
|
|
5
|
+
require "shellwords"
|
|
5
6
|
|
|
6
7
|
# Preflight: the embedded microsandbox core is edition 2024 and pulls smoltcp,
|
|
7
|
-
# which sets a Minimum Supported Rust Version of 1.91.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# which sets a Minimum Supported Rust Version of 1.91. An older rustc (commonly
|
|
9
|
+
# Homebrew's, which shadows a newer rustup on PATH and ignores this gem's
|
|
10
|
+
# rust-toolchain.toml) fails deep in the build with a cryptic "rustc X is not
|
|
11
|
+
# supported by smoltcp" error. Detect it up front and explain the fix instead.
|
|
12
|
+
#
|
|
13
|
+
# Probe the *same* rustc the build will invoke. create_rust_makefile drives the
|
|
14
|
+
# build through `cargo`, and cargo resolves its compiler exactly as: the `RUSTC`
|
|
15
|
+
# env var if set, otherwise the bare `rustc` found on PATH. It does NOT use the
|
|
16
|
+
# `rustc` sitting beside the `cargo` binary, and the rustup `cargo` shim neither
|
|
17
|
+
# sets `RUSTC` nor prepends its toolchain bin to PATH — toolchain selection
|
|
18
|
+
# survives only because the PATH `rustc` is normally itself a rustup shim that
|
|
19
|
+
# honors rust-toolchain.toml. So a non-rustup rustc earlier on PATH (which reads
|
|
20
|
+
# neither RUSTUP_TOOLCHAIN nor the toolchain file) is what the build actually
|
|
21
|
+
# runs. Mirroring cargo's RUSTC-then-PATH resolution is the only probe that
|
|
22
|
+
# neither false-passes (the trap of checking the cargo sibling, which stays a
|
|
23
|
+
# valid rustup shim while the build silently uses the stale PATH rustc) nor
|
|
24
|
+
# false-aborts (when `RUSTC` points at a newer compiler than the PATH `rustc`).
|
|
12
25
|
MSRV = Gem::Version.new("1.91")
|
|
26
|
+
|
|
27
|
+
def build_rustc
|
|
28
|
+
rustc = ENV["RUSTC"].to_s.strip
|
|
29
|
+
rustc.empty? ? "rustc" : rustc
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
rustc = build_rustc
|
|
13
33
|
rustc_version = begin
|
|
14
|
-
out =
|
|
34
|
+
out = `#{rustc.shellescape} --version 2>/dev/null`
|
|
15
35
|
out[/\d+\.\d+(\.\d+)?/] && Gem::Version.new(out[/\d+\.\d+(\.\d+)?/])
|
|
16
36
|
rescue
|
|
17
37
|
nil
|
|
18
38
|
end
|
|
19
39
|
|
|
20
40
|
if rustc_version && rustc_version < MSRV
|
|
21
|
-
which_rustc = begin
|
|
22
|
-
`which rustc 2>/dev/null`.strip
|
|
23
|
-
rescue
|
|
24
|
-
""
|
|
25
|
-
end
|
|
26
41
|
abort(<<~MSG)
|
|
27
42
|
|
|
28
43
|
[microsandbox-rb] Rust #{rustc_version} is too old — the embedded core requires rustc >= #{MSRV}.
|
|
29
|
-
Found: #{
|
|
44
|
+
Found: #{rustc} (#{rustc_version})
|
|
30
45
|
|
|
31
46
|
This usually means an older rustc (e.g. Homebrew's) is ahead of a newer
|
|
32
47
|
rustup toolchain on your PATH. Fixes:
|
|
@@ -37,8 +52,12 @@ if rustc_version && rustc_version < MSRV
|
|
|
37
52
|
rustup install stable && rustup default stable
|
|
38
53
|
• Or upgrade your system Rust to >= #{MSRV}.
|
|
39
54
|
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
• Or point `RUSTC` at a recent compiler (cargo honors it over PATH):
|
|
56
|
+
RUSTC="$HOME/.cargo/bin/rustc" gem install microsandbox-rb
|
|
57
|
+
|
|
58
|
+
(This gem ships a rust-toolchain.toml pinning `stable`, but only a rustup
|
|
59
|
+
`rustc` shim reads it — a non-rustup `rustc` ahead on PATH ignores it, and
|
|
60
|
+
cargo invokes that PATH `rustc` unless `RUSTC` says otherwise.)
|
|
42
61
|
MSG
|
|
43
62
|
end
|
|
44
63
|
|
|
@@ -34,6 +34,22 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
|
|
|
34
34
|
// client's `UnsupportedOperation` above.
|
|
35
35
|
CloudHttp { .. } => "CloudHttpError",
|
|
36
36
|
Unsupported { .. } => "UnsupportedError",
|
|
37
|
+
// Snapshot operations, all reachable through the gem's fully-wired
|
|
38
|
+
// `Snapshot` API. Upstream raises these un-wrapped, so without a mapping
|
|
39
|
+
// they collapse to the base `Error` and callers must string-match the
|
|
40
|
+
// message. This goes BEYOND the Python mirror (which has no Snapshot
|
|
41
|
+
// classes and matches the Go SDK's per-variant coverage instead) — a
|
|
42
|
+
// deliberate divergence noted in `lib/microsandbox/errors.rb`.
|
|
43
|
+
SnapshotNotFound(_) => "SnapshotNotFoundError",
|
|
44
|
+
SnapshotAlreadyExists(_) => "SnapshotAlreadyExistsError",
|
|
45
|
+
SnapshotSandboxRunning(_) => "SnapshotSandboxRunningError",
|
|
46
|
+
SnapshotImageMissing(_) => "SnapshotImageMissingError",
|
|
47
|
+
SnapshotIntegrity(_) => "SnapshotIntegrityError",
|
|
48
|
+
// Give the already-defined-but-orphaned `NetworkPolicyError` a mapping:
|
|
49
|
+
// a builder parse/validation error from `network(|n| ...)`. The gem
|
|
50
|
+
// unconditionally enables the core's `net` feature (default-features),
|
|
51
|
+
// so this variant is always present.
|
|
52
|
+
NetworkBuilder(_) => "NetworkPolicyError",
|
|
37
53
|
_ => "Error",
|
|
38
54
|
}
|
|
39
55
|
}
|
|
@@ -40,6 +40,21 @@ use crate::exec::ExecHandle;
|
|
|
40
40
|
use crate::runtime::{block_on, ruby};
|
|
41
41
|
use crate::stream::{LogStream, MetricsStream};
|
|
42
42
|
|
|
43
|
+
/// Convert a seconds `f64` into a `Duration`, surfacing a clean Ruby error
|
|
44
|
+
/// instead of the panic `Duration::from_secs_f64` raises on NaN/Inf/negative —
|
|
45
|
+
/// *and on finite-but-out-of-range* values (e.g. `Float::MAX`). The Ruby
|
|
46
|
+
/// `coerce_duration` already guards the public paths, but it sets no upper
|
|
47
|
+
/// bound, so a large finite value would still reach (and panic) the native
|
|
48
|
+
/// layer; this keeps the native layer panic-free regardless of the Ruby layer,
|
|
49
|
+
/// mirroring `agent::dur`.
|
|
50
|
+
fn secs_to_duration(secs: f64) -> Result<Duration, Error> {
|
|
51
|
+
Duration::try_from_secs_f64(secs).map_err(|e| {
|
|
52
|
+
error::base_error(format!(
|
|
53
|
+
"duration must be a non-negative, finite number of seconds in range (got {secs}: {e})"
|
|
54
|
+
))
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
#[magnus::wrap(class = "Microsandbox::Native::Sandbox", free_immediately, size)]
|
|
44
59
|
pub struct Sandbox {
|
|
45
60
|
inner: microsandbox::Sandbox,
|
|
@@ -122,6 +137,10 @@ impl Sandbox {
|
|
|
122
137
|
.ok_or_else(|| error::base_error("volume mount is missing :kind"))?;
|
|
123
138
|
let source = conv::opt_string(m, "source")?;
|
|
124
139
|
let size_mib = conv::opt_u32(m, "size_mib")?;
|
|
140
|
+
// Bind-mount guest-write quota override (MiB). The core's MountBuilder
|
|
141
|
+
// applies it to bind mounts only and rejects it on tmpfs/disk/named at
|
|
142
|
+
// build(); we forward it as-is so that validation lives in one place.
|
|
143
|
+
let quota_mib = conv::opt_u32(m, "quota_mib")?;
|
|
125
144
|
let fstype = conv::opt_string(m, "fstype")?;
|
|
126
145
|
let readonly = conv::opt_bool(m, "readonly")?;
|
|
127
146
|
let noexec = conv::opt_bool(m, "noexec")?;
|
|
@@ -161,6 +180,9 @@ impl Sandbox {
|
|
|
161
180
|
if let Some(n) = size_mib {
|
|
162
181
|
mb = mb.size(n);
|
|
163
182
|
}
|
|
183
|
+
if let Some(q) = quota_mib {
|
|
184
|
+
mb = mb.quota(q);
|
|
185
|
+
}
|
|
164
186
|
if let Some(f) = format {
|
|
165
187
|
mb = mb.format(f);
|
|
166
188
|
}
|
|
@@ -373,7 +395,7 @@ impl Sandbox {
|
|
|
373
395
|
b = b.detached(true);
|
|
374
396
|
}
|
|
375
397
|
if let Some(secs) = conv::opt_f64(opts, "replace_with_timeout")? {
|
|
376
|
-
b = b.replace_with_timeout(
|
|
398
|
+
b = b.replace_with_timeout(secs_to_duration(secs)?);
|
|
377
399
|
} else if conv::opt_bool(opts, "replace")? {
|
|
378
400
|
b = b.replace();
|
|
379
401
|
}
|
|
@@ -572,14 +594,22 @@ impl Sandbox {
|
|
|
572
594
|
|
|
573
595
|
/// Stream metrics snapshots at `interval` seconds. Returns a MetricsStream
|
|
574
596
|
/// to pull snapshots from.
|
|
575
|
-
fn metrics_stream(&self, interval: f64) -> MetricsStream {
|
|
576
|
-
|
|
597
|
+
fn metrics_stream(&self, interval: f64) -> Result<MetricsStream, Error> {
|
|
598
|
+
// `interval <= 0.0` (0 or negative) keeps the prior 1s-default behavior;
|
|
599
|
+
// NaN (for which `<= 0.0` is false) and finite-but-out-of-range values
|
|
600
|
+
// fall through to `secs_to_duration`, which errors cleanly rather than
|
|
601
|
+
// letting `from_secs_f64` panic across the FFI boundary.
|
|
602
|
+
let dur = if interval <= 0.0 {
|
|
603
|
+
Duration::from_secs(1)
|
|
604
|
+
} else {
|
|
605
|
+
secs_to_duration(interval)?
|
|
606
|
+
};
|
|
577
607
|
// `metrics_stream` is synchronous but builds a `tokio::time::interval`,
|
|
578
608
|
// which panics ("no reactor running") unless constructed inside the
|
|
579
609
|
// runtime context — so build it under `block_on`. (`log_stream` is async
|
|
580
610
|
// and already runs inside `block_on`, so it needs no such wrapper.)
|
|
581
611
|
let stream = block_on(async { self.inner.metrics_stream(dur) });
|
|
582
|
-
MetricsStream::from_stream(stream)
|
|
612
|
+
Ok(MetricsStream::from_stream(stream))
|
|
583
613
|
}
|
|
584
614
|
|
|
585
615
|
/// Stream captured logs as they appear. `opts`: sources, since_ms,
|
|
@@ -1537,7 +1567,9 @@ impl ExecOpts {
|
|
|
1537
1567
|
cwd: conv::opt_string(opts, "cwd")?,
|
|
1538
1568
|
user: conv::opt_string(opts, "user")?,
|
|
1539
1569
|
env: conv::opt_string_map(opts, "env")?,
|
|
1540
|
-
timeout: conv::opt_f64(opts, "timeout")
|
|
1570
|
+
timeout: conv::opt_f64(opts, "timeout")?
|
|
1571
|
+
.map(secs_to_duration)
|
|
1572
|
+
.transpose()?,
|
|
1541
1573
|
tty: conv::opt_bool(opts, "tty")?,
|
|
1542
1574
|
stdin,
|
|
1543
1575
|
stdin_pipe: conv::opt_bool(opts, "stdin_pipe")?,
|
|
@@ -1900,8 +1932,7 @@ impl SbHandle {
|
|
|
1900
1932
|
|
|
1901
1933
|
/// Graceful stop with a custom escalation timeout (seconds).
|
|
1902
1934
|
fn stop_with_timeout(&self, secs: f64) -> Result<(), Error> {
|
|
1903
|
-
block_on(self.inner.stop_with_timeout(
|
|
1904
|
-
.map_err(error::to_ruby)
|
|
1935
|
+
block_on(self.inner.stop_with_timeout(secs_to_duration(secs)?)).map_err(error::to_ruby)
|
|
1905
1936
|
}
|
|
1906
1937
|
|
|
1907
1938
|
/// Force kill (SIGKILL) and wait.
|
|
@@ -1911,8 +1942,7 @@ impl SbHandle {
|
|
|
1911
1942
|
|
|
1912
1943
|
/// Force kill, waiting up to `secs` for the process to disappear.
|
|
1913
1944
|
fn kill_with_timeout(&self, secs: f64) -> Result<(), Error> {
|
|
1914
|
-
block_on(self.inner.kill_with_timeout(
|
|
1915
|
-
.map_err(error::to_ruby)
|
|
1945
|
+
block_on(self.inner.kill_with_timeout(secs_to_duration(secs)?)).map_err(error::to_ruby)
|
|
1916
1946
|
}
|
|
1917
1947
|
|
|
1918
1948
|
/// Send the graceful-shutdown request and return without waiting.
|
data/lib/microsandbox/agent.rb
CHANGED
|
@@ -35,6 +35,10 @@ module Microsandbox
|
|
|
35
35
|
# consume {AgentFrame}s until the stream ends (the terminal frame is delivered,
|
|
36
36
|
# then iteration stops). Mirrors the official SDKs' `AgentStream`.
|
|
37
37
|
#
|
|
38
|
+
# @note **Single-pass, forward-only, single-consumer.** `each` drains a
|
|
39
|
+
# one-shot native channel — not rewindable, iterate once from a single
|
|
40
|
+
# thread; a second pass or a post-drain combinator yields nothing.
|
|
41
|
+
#
|
|
38
42
|
# @example
|
|
39
43
|
# stream = client.stream(0, request_body)
|
|
40
44
|
# stream.each { |frame| handle(frame) }
|
data/lib/microsandbox/errors.rb
CHANGED
|
@@ -51,7 +51,22 @@ module Microsandbox
|
|
|
51
51
|
define_error(:ImageInUseError, "image-in-use")
|
|
52
52
|
define_error(:ImagePullFailedError, "image-pull-failed")
|
|
53
53
|
|
|
54
|
+
# Snapshot errors ---------------------------------------------------------
|
|
55
|
+
# These go BEYOND the Python SDK mirror (which has no Snapshot classes and
|
|
56
|
+
# collapses all five to its base error) — a deliberate divergence matching the
|
|
57
|
+
# Go SDK's per-variant coverage, so callers can rescue a missing/duplicate/
|
|
58
|
+
# running-source/missing-image/corrupt snapshot specifically. The native layer
|
|
59
|
+
# (ext/microsandbox/src/error.rs) maps the five core Snapshot* variants here.
|
|
60
|
+
define_error(:SnapshotNotFoundError, "snapshot-not-found")
|
|
61
|
+
define_error(:SnapshotAlreadyExistsError, "snapshot-already-exists")
|
|
62
|
+
define_error(:SnapshotSandboxRunningError, "snapshot-sandbox-running")
|
|
63
|
+
define_error(:SnapshotImageMissingError, "snapshot-image-missing")
|
|
64
|
+
define_error(:SnapshotIntegrityError, "snapshot-integrity")
|
|
65
|
+
|
|
54
66
|
# Networking / secrets errors ---------------------------------------------
|
|
67
|
+
# NetworkPolicyError now also carries the core's `NetworkBuilder` build/parse
|
|
68
|
+
# error (a `network(|n| ...)` validation failure), which was previously
|
|
69
|
+
# unmapped and fell through to the base Error.
|
|
55
70
|
define_error(:NetworkPolicyError, "network-policy-error")
|
|
56
71
|
define_error(:SecretViolationError, "secret-violation")
|
|
57
72
|
define_error(:TlsError, "tls-error")
|
|
@@ -86,6 +86,13 @@ module Microsandbox
|
|
|
86
86
|
# Iterate it (it is {Enumerable}) to consume {ExecEvent}s as they arrive, or
|
|
87
87
|
# call {#collect} to drain it into an {ExecOutput}.
|
|
88
88
|
#
|
|
89
|
+
# @note **Single-pass, forward-only, single-consumer.** `each`/`collect` drain
|
|
90
|
+
# a one-shot native event channel, so the handle is *not* rewindable: a
|
|
91
|
+
# second `each`, or `collect` after a partial `each` (and vice versa),
|
|
92
|
+
# yields only what is left. Consume it once, from one thread. (An
|
|
93
|
+
# out-of-band {#kill}/{#signal} via the control channel is the exception —
|
|
94
|
+
# it can unblock a parked `each` from another thread.)
|
|
95
|
+
#
|
|
89
96
|
# @example
|
|
90
97
|
# handle = sb.exec_stream("python", ["-u", "script.py"])
|
|
91
98
|
# handle.each do |event|
|
data/lib/microsandbox/fs.rb
CHANGED
|
@@ -199,6 +199,11 @@ module Microsandbox
|
|
|
199
199
|
# A streaming reader over a guest file, from {FS#read_stream}. Iterate it (it
|
|
200
200
|
# is {Enumerable}) to consume byte chunks (ASCII-8BIT) as they arrive, or call
|
|
201
201
|
# {#read} to drain it into one String.
|
|
202
|
+
#
|
|
203
|
+
# @note **Single-pass, forward-only, single-consumer.** `each`/`read` drain a
|
|
204
|
+
# one-shot native channel — not rewindable, and not safe to share across
|
|
205
|
+
# threads. Consume it once: a second `each`, or `read` after a partial
|
|
206
|
+
# `each`, yields only the remaining bytes.
|
|
202
207
|
class FsReadStream
|
|
203
208
|
include Enumerable
|
|
204
209
|
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -230,7 +230,10 @@ module Microsandbox
|
|
|
230
230
|
# `{ disk: "/img.raw", format: "raw", fstype: "ext4" }`. Any mount may add
|
|
231
231
|
# flags `ro:`/`readonly:`, `noexec:`, `nosuid:`, `nodev:`, and (bind/named
|
|
232
232
|
# only) `stat_virtualization:` (:strict/:relaxed/:off) and
|
|
233
|
-
# `host_permissions:` (:private/:mirror).
|
|
233
|
+
# `host_permissions:` (:private/:mirror). A bind mount accepts
|
|
234
|
+
# `quota_mib:` to override the runtime's default guest-write budget
|
|
235
|
+
# (4 GiB as of `v0.5.10`); the core rejects it on tmpfs/disk/named (for a
|
|
236
|
+
# named volume, set its quota via {Volume.create}).
|
|
234
237
|
# @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
|
|
235
238
|
# A preset name ("public_only" (default), "none", "allow_all",
|
|
236
239
|
# "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
|
|
@@ -523,7 +526,10 @@ module Microsandbox
|
|
|
523
526
|
env = spec[:env] || spec["env"]
|
|
524
527
|
value = spec[:value] || spec["value"]
|
|
525
528
|
unless env && value
|
|
526
|
-
|
|
529
|
+
# Report only the keys given, never the values — a secret spec carries
|
|
530
|
+
# the cleartext :value, and exception messages routinely land in logs
|
|
531
|
+
# and error trackers. Mirrors apply_registry_opts above.
|
|
532
|
+
raise ArgumentError, "secret spec needs :env and :value (got keys: #{spec.keys.inspect})"
|
|
527
533
|
end
|
|
528
534
|
out = {"env" => env.to_s, "value" => value.to_s}
|
|
529
535
|
hosts = Array(spec[:hosts] || spec["hosts"]).map(&:to_s)
|
|
@@ -531,8 +537,10 @@ module Microsandbox
|
|
|
531
537
|
hosts << single.to_s if single
|
|
532
538
|
patterns = Array(spec[:host_patterns] || spec["host_patterns"]).map(&:to_s)
|
|
533
539
|
if hosts.empty? && patterns.empty?
|
|
540
|
+
# Keys only — this branch runs after :value is confirmed present, so
|
|
541
|
+
# spec.inspect would always embed the cleartext secret value.
|
|
534
542
|
raise ArgumentError,
|
|
535
|
-
"secret spec needs :host, :hosts, or :host_patterns (got #{spec.inspect})"
|
|
543
|
+
"secret spec needs :host, :hosts, or :host_patterns (got keys: #{spec.keys.inspect})"
|
|
536
544
|
end
|
|
537
545
|
out["hosts"] = hosts unless hosts.empty?
|
|
538
546
|
out["host_patterns"] = patterns unless patterns.empty?
|
|
@@ -677,11 +685,12 @@ module Microsandbox
|
|
|
677
685
|
# Hashes for the native layer. A spec is a host path String (a read-write
|
|
678
686
|
# bind mount) or a Hash describing the mount:
|
|
679
687
|
# { bind: "/host", ro: true, noexec: true } # host bind mount
|
|
688
|
+
# { bind: "/host", quota_mib: 16_384 } # raise the 4 GiB cap
|
|
680
689
|
# { named: "vol" } # named volume
|
|
681
690
|
# { tmpfs: true, size_mib: 64 } # memory-backed
|
|
682
691
|
# { disk: "/img.raw", format: "raw", fstype: "ext4" } # disk-image mount
|
|
683
692
|
# Any mount may also carry stat_virtualization: (:strict/:relaxed/:off) and
|
|
684
|
-
# host_permissions: (:private/:mirror)
|
|
693
|
+
# host_permissions: (:private/:mirror); a bind mount may carry quota_mib:.
|
|
685
694
|
def normalize_volumes(volumes)
|
|
686
695
|
volumes.map do |guest, spec|
|
|
687
696
|
mount = {"guest" => guest.to_s}
|
|
@@ -722,6 +731,12 @@ module Microsandbox
|
|
|
722
731
|
mount["size_mib"] = Integer(size) if size
|
|
723
732
|
fstype = spec[:fstype] || spec["fstype"]
|
|
724
733
|
mount["fstype"] = fstype.to_s if fstype
|
|
734
|
+
# Bind-mount guest-write quota (MiB). Overrides the runtime's default
|
|
735
|
+
# budget (4 GiB as of v0.5.10); the core applies it only to bind mounts
|
|
736
|
+
# and rejects it on tmpfs/disk/named. `0` is forwarded as-is (a zero
|
|
737
|
+
# budget), not treated as "unset".
|
|
738
|
+
quota = spec[:quota_mib] || spec["quota_mib"]
|
|
739
|
+
mount["quota_mib"] = Integer(quota) if quota
|
|
725
740
|
end
|
|
726
741
|
|
|
727
742
|
# Apply a volume spec Hash's mount flags. `ro:`/`readonly:` makes the mount
|
|
@@ -810,7 +825,11 @@ module Microsandbox
|
|
|
810
825
|
# @param cwd [String, nil] working directory
|
|
811
826
|
# @param user [String, nil] user to run as
|
|
812
827
|
# @param env [Hash, nil] extra environment variables
|
|
813
|
-
# @param timeout [Numeric, nil] kill after N seconds
|
|
828
|
+
# @param timeout [Numeric, nil] kill the command after N seconds and raise
|
|
829
|
+
# {ExecTimeoutError}. Omit or pass +nil+ for *no* timeout. Note the
|
|
830
|
+
# asymmetry: +0+ is **not** "disable" — it is an immediate (zero) deadline,
|
|
831
|
+
# so the command is killed before producing any output and {ExecTimeoutError}
|
|
832
|
+
# is raised. Use +nil+/omit, never +0+, to mean "no limit".
|
|
814
833
|
# @param tty [Boolean] allocate a pseudo-terminal
|
|
815
834
|
# @param stdin [String, Symbol, nil] bytes to feed to stdin, or +:pipe+ to
|
|
816
835
|
# open a streaming stdin pipe (write/close it via {ExecHandle#stdin}; only
|
|
@@ -833,6 +852,11 @@ module Microsandbox
|
|
|
833
852
|
# Pass +stdin: :pipe+ to feed the process interactively: {ExecHandle#stdin}
|
|
834
853
|
# then returns a writable sink; close it to send EOF (a process like +cat+
|
|
835
854
|
# that reads until EOF will otherwise block forever).
|
|
855
|
+
#
|
|
856
|
+
# @note +timeout:+ is accepted for signature symmetry with {#exec} but is
|
|
857
|
+
# **not applied** on the streaming path (the runtime discards it). Enforce a
|
|
858
|
+
# deadline yourself around the iteration, or use blocking {#exec} with
|
|
859
|
+
# +timeout:+ for the kill-after-N-seconds behavior.
|
|
836
860
|
# @return [ExecHandle]
|
|
837
861
|
# @see ExecHandle
|
|
838
862
|
def exec_stream(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
|
|
@@ -841,6 +865,8 @@ module Microsandbox
|
|
|
841
865
|
end
|
|
842
866
|
|
|
843
867
|
# Run a shell script and stream its output as it arrives.
|
|
868
|
+
# @note Like {#exec_stream}, +timeout:+ is accepted but **not applied** on
|
|
869
|
+
# the streaming path.
|
|
844
870
|
# @return [ExecHandle]
|
|
845
871
|
def shell_stream(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
|
|
846
872
|
ExecHandle.new(@native.shell_stream(script.to_s,
|
|
@@ -1073,6 +1099,12 @@ module Microsandbox
|
|
|
1073
1099
|
# "kind" key (e.g. "resolving", "resolved", "layer_download_progress",
|
|
1074
1100
|
# "layer_materialize_progress", "complete") plus kind-specific fields.
|
|
1075
1101
|
#
|
|
1102
|
+
# @note **Single-pass, forward-only, single-consumer.** `each` drains a
|
|
1103
|
+
# one-shot native progress channel — not rewindable, iterate once from a
|
|
1104
|
+
# single thread. {#sandbox} works whether or not you iterated (it awaits the
|
|
1105
|
+
# create either way), so you can skip `each` entirely; but don't expect a
|
|
1106
|
+
# second `each` to replay the events.
|
|
1107
|
+
#
|
|
1076
1108
|
# @example
|
|
1077
1109
|
# session = Microsandbox::Sandbox.create_with_progress("box", image: "python")
|
|
1078
1110
|
# session.each { |ev| puts "#{ev["kind"]} #{ev["downloaded_bytes"]}" }
|
data/lib/microsandbox/ssh.rb
CHANGED
|
@@ -121,7 +121,10 @@ module Microsandbox
|
|
|
121
121
|
@native.read_link(path.to_s)
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
# Close the SFTP session. Idempotent.
|
|
124
|
+
# Close the SFTP session. Idempotent. Sends the graceful SFTP close;
|
|
125
|
+
# relying on GC to reclaim an unclosed session drops it abruptly (no
|
|
126
|
+
# graceful close), so call this — or use the block form of
|
|
127
|
+
# {SshClient#sftp} — for a clean shutdown.
|
|
125
128
|
# @return [nil]
|
|
126
129
|
def close
|
|
127
130
|
@native.close
|
|
@@ -161,7 +164,9 @@ module Microsandbox
|
|
|
161
164
|
end
|
|
162
165
|
|
|
163
166
|
# Open an SFTP session over this connection. With a block, the session is
|
|
164
|
-
# yielded and closed when the block returns.
|
|
167
|
+
# yielded and closed when the block returns. Without a block you own the
|
|
168
|
+
# returned {SftpClient} and must call {SftpClient#close} (letting it GC skips
|
|
169
|
+
# the graceful disconnect).
|
|
165
170
|
# @yieldparam sftp [SftpClient]
|
|
166
171
|
# @return [SftpClient, Object]
|
|
167
172
|
def sftp
|
|
@@ -175,7 +180,11 @@ module Microsandbox
|
|
|
175
180
|
end
|
|
176
181
|
end
|
|
177
182
|
|
|
178
|
-
# Close the SSH client session. Idempotent.
|
|
183
|
+
# Close the SSH client session. Idempotent. Sends the graceful protocol
|
|
184
|
+
# disconnect (russh `Disconnect::ByApplication`); if the client is instead
|
|
185
|
+
# left to GC, only the in-process server task is aborted and that disconnect
|
|
186
|
+
# is skipped. Prefer the block form of {SshOps#open_client}, or call this in
|
|
187
|
+
# an `ensure`, so the session closes cleanly.
|
|
179
188
|
# @return [nil]
|
|
180
189
|
def close
|
|
181
190
|
@native.close
|
|
@@ -216,7 +225,9 @@ module Microsandbox
|
|
|
216
225
|
end
|
|
217
226
|
|
|
218
227
|
# Open a native in-process SSH client to the sandbox. With a block, the
|
|
219
|
-
# client is yielded and closed when the block returns.
|
|
228
|
+
# client is yielded and closed when the block returns. Without a block you
|
|
229
|
+
# own the returned {SshClient} and must call {SshClient#close} for a clean
|
|
230
|
+
# protocol disconnect (letting it GC only aborts the in-process server task).
|
|
220
231
|
# @param user [String] guest user to authenticate as (default "root")
|
|
221
232
|
# @param term [String, nil] TERM value for the session
|
|
222
233
|
# @param sftp [Boolean] enable the SFTP subsystem (default true)
|
data/lib/microsandbox/streams.rb
CHANGED
|
@@ -6,6 +6,14 @@ module Microsandbox
|
|
|
6
6
|
# iteration blocks for new entries until the sandbox stops; otherwise it ends
|
|
7
7
|
# once the historical log is drained.
|
|
8
8
|
#
|
|
9
|
+
# @note **Single-pass, forward-only, single-consumer.** `each` drains a
|
|
10
|
+
# one-shot native channel, so it is *not* rewindable: a second `each` — or
|
|
11
|
+
# any `Enumerable` combinator after a partial drain (`to_a` twice, `count`
|
|
12
|
+
# then `each`, `first(n)` then `each`) — silently yields nothing. Iterate
|
|
13
|
+
# 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
|
+
#
|
|
9
17
|
# @example
|
|
10
18
|
# sb.log_stream(follow: true).each { |entry| print entry.text }
|
|
11
19
|
class LogStream
|
|
@@ -31,6 +39,11 @@ module Microsandbox
|
|
|
31
39
|
# Enumerable: iteration yields one snapshot per interval tick until the
|
|
32
40
|
# sandbox stops.
|
|
33
41
|
#
|
|
42
|
+
# @note **Single-pass, forward-only, single-consumer.** Like {LogStream},
|
|
43
|
+
# `each` drains a one-shot native channel — not rewindable, iterate once
|
|
44
|
+
# from a single thread; a second pass or a post-drain combinator yields
|
|
45
|
+
# nothing.
|
|
46
|
+
#
|
|
34
47
|
# @example
|
|
35
48
|
# sb.metrics_stream(interval: 0.5).each { |m| puts m.cpu_percent }
|
|
36
49
|
class MetricsStream
|
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.8.
|
|
11
|
+
VERSION = "0.8.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.rb
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "microsandbox/version"
|
|
4
4
|
|
|
5
|
-
# Load the compiled native extension. Precompiled platform gems
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Load the compiled native extension. Precompiled platform gems stage the
|
|
6
|
+
# binary under a major.minor subdirectory (e.g.
|
|
7
|
+
# lib/microsandbox/3.4/microsandbox_rb.bundle) — that is the directory
|
|
8
|
+
# rake-compiler builds (it matches the `ruby_version` against /(\d+\.\d+)/), NOT
|
|
9
|
+
# the API string "3.4.0" that RbConfig::CONFIG["ruby_version"] returns. Use
|
|
10
|
+
# RUBY_VERSION's major.minor so the require hits the staged path; fall back to
|
|
11
|
+
# the flat path source builds produce.
|
|
8
12
|
begin
|
|
9
|
-
|
|
10
|
-
require_relative "microsandbox/#{
|
|
13
|
+
abi_version = RUBY_VERSION[/\d+\.\d+/]
|
|
14
|
+
require_relative "microsandbox/#{abi_version}/microsandbox_rb"
|
|
11
15
|
rescue LoadError
|
|
12
16
|
require_relative "microsandbox/microsandbox_rb"
|
|
13
17
|
end
|
|
@@ -93,16 +97,27 @@ module Microsandbox
|
|
|
93
97
|
Native.installed?
|
|
94
98
|
end
|
|
95
99
|
|
|
96
|
-
# Ensure the `msb` runtime + `libkrunfw` are present
|
|
97
|
-
# first use if not. Called automatically by
|
|
98
|
-
# so precompiled-gem users (who never ran the
|
|
99
|
-
# runtime without a manual {install} step.
|
|
100
|
+
# Ensure the `msb` runtime + `libkrunfw` are present *and version-matched*,
|
|
101
|
+
# provisioning them on first use if not. Called automatically by
|
|
102
|
+
# {Sandbox.create}/{Sandbox.start} so precompiled-gem users (who never ran the
|
|
103
|
+
# source build) get a working runtime without a manual {install} step.
|
|
100
104
|
#
|
|
101
|
-
#
|
|
105
|
+
# Runs at most once per process. Opt out by setting
|
|
102
106
|
# `MICROSANDBOX_NO_AUTO_INSTALL` (e.g. air-gapped hosts that provision the
|
|
103
|
-
# runtime out of band); the
|
|
104
|
-
#
|
|
105
|
-
#
|
|
107
|
+
# runtime out of band); the runtime is then left untouched and a missing or
|
|
108
|
+
# stale one surfaces at the operation itself.
|
|
109
|
+
#
|
|
110
|
+
# NOTE: this delegates to {install} even when {installed?} is already true,
|
|
111
|
+
# rather than short-circuiting on presence. {installed?} (upstream
|
|
112
|
+
# `verify_installation`) only confirms the `msb`/`libkrunfw` files *exist*, not
|
|
113
|
+
# that their version matches the runtime this gem build links. {install} is
|
|
114
|
+
# idempotent and *version-correcting*: it runs a cheap `msb --version` and
|
|
115
|
+
# re-downloads ONLY when the binary is absent or its version differs, then
|
|
116
|
+
# no-ops. A presence-only short-circuit would let a stale `msb` left in
|
|
117
|
+
# `~/.microsandbox` by an older gem pass, then fail every {Sandbox.create} on a
|
|
118
|
+
# host↔guest wire-protocol mismatch (e.g. a `v0.5.8` `msb` rejecting the
|
|
119
|
+
# `--config-fd` flag the `v0.5.10` runtime passes). Keep the {install} call on
|
|
120
|
+
# this path — do not "optimize" it back to skip-when-present.
|
|
106
121
|
# @return [nil]
|
|
107
122
|
def ensure_runtime!
|
|
108
123
|
return if @runtime_ready
|
|
@@ -111,14 +126,18 @@ module Microsandbox
|
|
|
111
126
|
# uses the same lazy env/profile/config ladder every operation already
|
|
112
127
|
# consults, so this adds no work for local hosts (the common case).
|
|
113
128
|
return if default_backend_kind == :cloud
|
|
114
|
-
|
|
129
|
+
# Opted out: the caller manages the runtime out of band, so don't fetch,
|
|
130
|
+
# verify, or repair it here. Memoize the decision (the env var is stable for
|
|
131
|
+
# the process); the operation resolves `msb` itself and surfaces any problem.
|
|
132
|
+
if auto_install_disabled?
|
|
115
133
|
@runtime_ready = true
|
|
116
134
|
return
|
|
117
135
|
end
|
|
118
|
-
return if auto_install_disabled?
|
|
119
136
|
|
|
120
|
-
|
|
121
|
-
|
|
137
|
+
unless installed?
|
|
138
|
+
warn "[microsandbox] runtime (msb + libkrunfw) not found; " \
|
|
139
|
+
"downloading to ~/.microsandbox (set MICROSANDBOX_NO_AUTO_INSTALL to skip)..."
|
|
140
|
+
end
|
|
122
141
|
install
|
|
123
142
|
@runtime_ready = true
|
|
124
143
|
nil
|
data/sig/microsandbox.rbs
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
# Type signatures for the public microsandbox Ruby API.
|
|
2
|
+
#
|
|
3
|
+
# Note: value, stream, and handle types (e.g. ExecOutput, Metrics, SandboxHandle,
|
|
4
|
+
# the Ssh* clients, AgentClient/AgentStream, PullSession, Snapshot*/Image*/Volume*)
|
|
5
|
+
# are constructed by the SDK from an internal native handle or data hash, not by
|
|
6
|
+
# user code — you receive them from SDK calls. Their `initialize` is documented
|
|
7
|
+
# below only to keep `new`'s synthesized signature accurate (RBS derives `new`
|
|
8
|
+
# from `initialize`; omitting it does NOT hide the constructor — it falls back to
|
|
9
|
+
# a misleading zero-arg `() -> instance` that Ruby actually rejects). Treat these
|
|
10
|
+
# constructors as internal.
|
|
2
11
|
|
|
3
12
|
module Microsandbox
|
|
4
13
|
VERSION: String
|
|
@@ -38,6 +47,11 @@ module Microsandbox
|
|
|
38
47
|
class ImageNotFoundError < Error end
|
|
39
48
|
class ImageInUseError < Error end
|
|
40
49
|
class ImagePullFailedError < Error end
|
|
50
|
+
class SnapshotNotFoundError < Error end
|
|
51
|
+
class SnapshotAlreadyExistsError < Error end
|
|
52
|
+
class SnapshotSandboxRunningError < Error end
|
|
53
|
+
class SnapshotImageMissingError < Error end
|
|
54
|
+
class SnapshotIntegrityError < Error end
|
|
41
55
|
class NetworkPolicyError < Error end
|
|
42
56
|
class SecretViolationError < Error end
|
|
43
57
|
class TlsError < Error end
|