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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -8
  3. data/Cargo.lock +45 -46
  4. data/DESIGN.md +90 -72
  5. data/README.md +178 -669
  6. data/SECURITY.md +65 -0
  7. data/docs/configuration.md +154 -0
  8. data/docs/errors.md +74 -0
  9. data/docs/execution.md +108 -0
  10. data/docs/filesystem.md +45 -0
  11. data/docs/images.md +65 -0
  12. data/docs/lifecycle.md +141 -0
  13. data/docs/observability.md +99 -0
  14. data/docs/releasing.md +117 -0
  15. data/docs/runtime.md +154 -0
  16. data/docs/surface.md +75 -0
  17. data/ext/microsandbox/Cargo.toml +9 -5
  18. data/ext/microsandbox/src/agent.rs +13 -11
  19. data/ext/microsandbox/src/backend.rs +1 -1
  20. data/ext/microsandbox/src/exec.rs +12 -8
  21. data/ext/microsandbox/src/fs_stream.rs +19 -7
  22. data/ext/microsandbox/src/host_path.rs +108 -0
  23. data/ext/microsandbox/src/image.rs +2 -2
  24. data/ext/microsandbox/src/lib.rs +3 -2
  25. data/ext/microsandbox/src/runtime.rs +189 -13
  26. data/ext/microsandbox/src/sandbox.rs +149 -102
  27. data/ext/microsandbox/src/snapshot.rs +17 -11
  28. data/ext/microsandbox/src/ssh.rs +22 -16
  29. data/ext/microsandbox/src/stream.rs +6 -2
  30. data/ext/microsandbox/src/volume.rs +50 -17
  31. data/lib/microsandbox/fs.rb +4 -8
  32. data/lib/microsandbox/image.rb +2 -2
  33. data/lib/microsandbox/log_entry.rb +1 -1
  34. data/lib/microsandbox/metrics.rb +1 -1
  35. data/lib/microsandbox/outbound_proxy.rb +8 -0
  36. data/lib/microsandbox/sandbox.rb +128 -13
  37. data/lib/microsandbox/snapshot.rb +1 -1
  38. data/lib/microsandbox/ssh.rb +2 -10
  39. data/lib/microsandbox/streams.rb +8 -5
  40. data/lib/microsandbox/version.rb +1 -1
  41. data/lib/microsandbox/volume.rb +31 -14
  42. data/lib/microsandbox-rb.rb +7 -0
  43. data/lib/microsandbox.rb +88 -31
  44. data/sig/microsandbox.rbs +24 -0
  45. metadata +14 -1
data/DESIGN.md CHANGED
@@ -50,7 +50,7 @@ multi-threaded tokio runtime via `runtime.block_on(fut)`.
50
50
 
51
51
  ### GVL release
52
52
 
53
- `block_on` is wrapped in `nogvl` (`rb_thread_call_without_gvl`) so the Ruby
53
+ `block_on` is wrapped in `nogvl` (`rb_thread_call_without_gvl2`) so the Ruby
54
54
  Global VM Lock is **released** while a sandbox operation is in flight. Without
55
55
  this, a long-running `exec` would freeze every other Ruby thread/fiber in the
56
56
  process (a real problem under Rails/Puma). The `nogvl` closure runs pure Rust
@@ -59,21 +59,81 @@ 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
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.
62
+ null unblock-function, so while a native call blocks, the thread that issued it
63
+ cannot be interrupted: `Timeout::timeout` (which relies on `Thread#raise`),
64
+ `Thread#kill`, and `SIGINT` (Ctrl-C) are all deferred until the call returns on
65
+ its own. The GVL release keeps *other* threads live; it does **not** make the
66
+ calling thread cancelable. This is harmless for the bounded calls
67
+ (`exec`/`shell` with a `timeout:`, `AgentClient` with a `timeout:`) because the
68
+ deadline fires inside the future, but the unbounded/streaming paths —
69
+ `exec`/`shell` with `timeout: nil`, `ExecHandle#recv`/`#wait`,
70
+ `log_stream`/`metrics_stream` `recv` (especially `follow: true`),
71
+ `Sandbox#wait`/`SandboxHandle#wait_until_stopped`, and `AgentClient#request`
72
+ with no timeout — can block their caller indefinitely if the guest wedges or a
73
+ relay drops. **Bound such calls with the explicit `timeout:` knobs rather than
74
+ wrapping them in `Timeout::timeout`**, which will not fire while the native
75
+ call is in flight.
76
+
77
+ ### Interrupts never lose a result
78
+
79
+ An interrupt that arrives during a native call is delivered **after the call
80
+ returns**, and the call's result is not lost. Ruby delivers `Thread#raise`,
81
+ `Thread#kill` and `SIGINT` with a `longjmp`, which skips Rust frames without
82
+ running destructors; if that happened while the value a call had just produced
83
+ (a booted sandbox, a guest exec session, megabytes of output) lived only in a
84
+ Rust frame, it would be neither returned to Ruby nor dropped. The plain
85
+ `rb_thread_call_without_gvl` does exactly that — it checks interrupts once more
86
+ after the callback, before returning — which is why it is not used. Two rules
87
+ in `runtime.rs` replace it:
88
+
89
+ 1. `nogvl` checks interrupts itself, under `rb_protect`, **only before the work
90
+ starts**: once up front, and again whenever `rb_thread_call_without_gvl2`
91
+ declined to run the callback because an interrupt was pending at entry. A
92
+ raise or kill there is an ordinary `Err` that unwinds the Rust frames
93
+ normally — nothing has happened yet. After the callback has run, nothing is
94
+ checked. (`nogvl` and `block_on` therefore return `Result`, and every call
95
+ site propagates it with `?`. A bound method that needs two awaits does both
96
+ in one `block_on`, so no value sits between two entry checks.)
97
+ 2. Between `block_on` returning and the bound method returning its value, the
98
+ binding does not call into Ruby: no `funcall` (the VM checks interrupts
99
+ after every one), only C-level Hash/Array/String construction and
100
+ `TypedData` wrapping. The class lookup magnus would otherwise do with
101
+ `const_get` on a type's first wrap is resolved at extension load instead.
102
+
103
+ So the next interrupt checkpoint after a native call is the Ruby VM's own,
104
+ reached once the result is a Ruby object. What happens to it then:
105
+
106
+ - **`Sandbox.create`, `Sandbox.connect_or_create`, `Sandbox.start`, and
107
+ `PullSession#sandbox`** mask interrupts (`Thread.handle_interrupt(Object =>
108
+ :never)`) around the native call plus the wrap, and an `ensure` **stops the
109
+ sandbox** when an exception or a kill cuts in before the caller has the
110
+ object — but only a sandbox the call owns (`Sandbox#owns_lifecycle?`, the
111
+ same predicate as `connect_or_create`'s block form): one that was already
112
+ running for someone else is left running. A `SIGINT` cannot be masked this
113
+ way; it can arrive before the wrapper is assigned, and then the next case
114
+ applies.
115
+ - **Every other wrapper** (`ExecHandle`, streams, SSH clients, write sinks, …)
116
+ and every plain result simply becomes garbage; GC frees the wrapper and the
117
+ Rust value's `Drop` closes the session or connection.
118
+
119
+ The calls themselves remain uninterruptible, so `Timeout.timeout { Sandbox.create(...) }`
120
+ still waits for the create to finish — and now also for the stop that cleans
121
+ it up — before `Timeout::Error` surfaces. That stop is best-effort (a failure
122
+ is swallowed so the interrupt is what the caller sees; GC then tears the
123
+ sandbox down).
124
+
125
+ `Thread.handle_interrupt(X => :on_blocking)` treats a native call as the
126
+ blocking point at its *start*: the bridge raises an exception that is already
127
+ deferred before the call begins, and one that arrives during the call waits
128
+ for the thread's next blocking operation. The four acquisition methods are
129
+ the exception: their own `Object => :never` mask sits on top of the caller's,
130
+ so a deferred exception is not raised at the native call's entry there — the
131
+ sandbox is returned and the exception is delivered at the next blocking
132
+ operation after the acquisition.
133
+
134
+ `spec/fake_runtime/interrupt_safety_spec.rb` pins all of this against the real
135
+ extension, driving it with a fake `msb` (`spec/support/fake_runtime/`) whose
136
+ log shows whether a sandbox was stopped, dropped, or left attached.
77
137
 
78
138
  ## Error mapping
79
139
 
@@ -185,7 +245,7 @@ extension.
185
245
  `ext/microsandbox/Cargo.toml` depends on the core crate via a **pinned git tag**
186
246
  (`microsandbox` / `microsandbox-network`, pinned to the same tag as
187
247
  `Microsandbox::RUNTIME_VERSION` — currently `v0.6.18`), so the gem builds anywhere
188
- — CI, `rake-compiler-dock` release containers, and end-user source installs —
248
+ — CI and end-user source installs —
189
249
  without an adjacent checkout. For fast local development against a sibling
190
250
  microsandbox checkout, copy `.cargo/config.toml.example` to `.cargo/config.toml`
191
251
  (gitignored); its `paths` override builds against the local crates instead of
@@ -195,25 +255,13 @@ git. The override must never be committed — it would break container builds.
195
255
 
196
256
  * **Source gem**: compiles the extension via `extconf.rb` (rb-sys
197
257
  `create_rust_makefile`); requires a Rust toolchain (MSRV below).
198
- * **Precompiled platform gems**: built best-effort by the `cross-gems` job
199
- (`.github/workflows/release.yml`) with `oxidize-rb/cross-gem-action`
200
- (`rake-compiler-dock`) per `Gem::Platform`, shipping multi-ABI
201
- `lib/microsandbox/<ruby_abi>/` native artifacts — the same model Node uses with
202
- per-platform packages. End users then install with no Rust toolchain, and the
203
- host runtime comes from the `microsandbox-rb-binaries` gem — or, without it, is
204
- fetched on first use (see above). The guest `agentd` is baked into
205
- the extension by *target* arch (`filesystem/build.rs` uses
206
- `CARGO_CFG_TARGET_ARCH` + `include_bytes!`), so it cross-compiles correctly;
207
- the real cross work is linking the *target* native libs — `libcap-ng` on Linux
208
- (via Debian multiarch for `aarch64-linux`) and the Hypervisor + Security
209
- frameworks on macOS (via osxcross; `arm64-darwin` is the platform still to
210
- confirm — if osxcross can't link them, move it to a native `macos-14` runner).
211
- The job is gated to `workflow_dispatch` and **not** auto-published on tags:
212
- since CI can't boot a microVM to prove a built gem actually works, gems are
213
- promoted to the publish path manually after per-platform validation. Published
214
- to RubyGems via Trusted Publishing (OIDC). See [Releasing](README.md#releasing).
258
+ * **Precompiled platform gems**: not built or published. Every platform
259
+ installs the source gem, which compiles the extension via `rb_sys` at install
260
+ time; the host runtime comes from the `microsandbox-rb-binaries` gem — or,
261
+ without it, is fetched on first use (see above). The companion binaries gems
262
+ below are unaffected. See [docs/releasing.md](docs/releasing.md).
215
263
  * **Runtime binaries gems** (`microsandbox-rb-binaries`): a distinct artifact
216
- from the precompiled *extension* gems above — no Ruby code beyond a small
264
+ from the SDK gem — no Ruby code beyond a small
217
265
  locator module, just the verified upstream `msb` + `libkrunfw` for one
218
266
  platform. CI's `binaries` job vendors and builds all three platforms on every
219
267
  run and smoke-tests the host one; the `package` job installs the source gem
@@ -232,42 +280,11 @@ git. The override must never be committed — it would break container builds.
232
280
 
233
281
  ## Implemented surface (v1) vs roadmap
234
282
 
235
- **Implemented:** sandbox lifecycle (`create`/`start`/`get`/`list`/`list_with`/
236
- `remove`; the live `Sandbox` exposes `stop`/`stop_and_wait`/`kill`/`drain`/
237
- `wait` (→ `ExitStatus`) / `status` / `detach` / `owns_lifecycle?`, while the
238
- controllable `SandboxHandle` from `get`/`list` carries the fine-grained
239
- `stop_with_timeout`/`request_stop`/`request_kill`/`request_drain`/
240
- `wait_until_stopped` (→ `SandboxStopResult`) controls — the v0.5.8 live-vs-handle
241
- split that mirrors the official SDKs), backend routing (`set_default_backend`/
242
- `with_backend`/`default_backend_kind`), block form), `exec`/`shell` with collected `ExecOutput`,
243
- **streaming** `exec_stream`/`shell_stream` (`ExecHandle` is `Enumerable` over
244
- `ExecEvent`s, with stdin sink + signal/kill/resize), the full guest filesystem
245
- API (`fs.read`/`write`/`list`/`mkdir`/`remove`/`stat`/…), `metrics`,
246
- `Microsandbox.all_sandbox_metrics`, **streaming `metrics_stream`/`log_stream`**
247
- (`Enumerable` over `Metrics`/`LogEntry`), `logs`,
248
- **OCI image-cache management** (`Image.get`/`list`/`inspect`/`remove`/`prune`),
249
- **named volumes** (`Volume.create`/`get`/`list`/`remove` + `volumes:` mounts),
250
- **snapshots** (`Snapshot.create`/`get`/`list`/`remove`/`verify`/`export`/`import`
251
- + `from_snapshot:` boot), **rootfs patches** (`Patch.text`/`file`/`append`/
252
- `copy_file`/`copy_dir`/`symlink`/`mkdir`/`remove` via `create(patches:)`),
253
- **custom per-rule network policies** (`NetworkPolicy`/`Rule`/`Destination` —
254
- CIDR/IP/domain/suffix/group allow-deny rules with per-direction defaults and
255
- bulk domain denials, alongside the presets), interactive **`attach`/
256
- `attach_shell`** (host-TTY coupled — raw mode + SIGWINCH), **SSH**
257
- (`Sandbox#ssh` → `SshClient`/`SftpClient`/`SshServer`), the **raw agent client**
258
- (`AgentClient` → `AgentStream`/`AgentFrame`),
259
- `version`/`install`/`installed?`/`ensure_runtime!`, **registry auth**
260
- (`registry_auth`/`registry_insecure`/`registry_ca_certs` on `create`, for
261
- private/authenticated registries), and the typed error hierarchy.
262
-
263
- Create options now cover `image`, `cpus`, `memory`, `oci_upper_size`, `env`,
264
- `workdir`, `shell`, `user`, `hostname`, `labels`, `scripts`, `entrypoint`,
265
- `ports`/`ports_udp`, `volumes`, `patches`, `network` (policy presets
266
- `public_only`/`none`/`allow_all`/`non_local`, or a custom `NetworkPolicy`/Hash),
267
- `log_level`, `quiet_logs`, `security`, `max_duration`, `idle_timeout`, `rlimits`,
268
- `pull_policy`, `registry_auth`/`registry_insecure`/`registry_ca_certs`,
269
- `secrets`, `from_snapshot`, `detached`, and `replace`/`replace_with_timeout`.
270
- `exec`/`shell` add per-call `rlimits`.
283
+ The authoritative, up-to-date scope statement — every implemented API family,
284
+ the full create-option list, and the handful of upstream knobs not yet exposed —
285
+ lives in [docs/surface.md](docs/surface.md), with the typed counterpart in
286
+ `sig/microsandbox.rbs`. It is maintained there (and only there) so this file
287
+ and the README cannot drift from each other.
271
288
 
272
289
  ## Verification
273
290
 
@@ -300,7 +317,8 @@ create options, full mount options (tmpfs/disk + stat-virtualization/
300
317
  host-permissions), and snapshot inspection (`open`/`list_dir`/`reindex`).
301
318
 
302
319
  A few **secondary** upstream knobs remain unexposed (a genuine binding gap, not
303
- upstream-gated — they exist at the pinned `v0.6.2` runtime): per-published-port host
320
+ upstream-gated — they exist at the pinned runtime,
321
+ `Microsandbox::RUNTIME_VERSION`): per-published-port host
304
322
  **bind address** (ports always bind loopback), network **interface overrides**,
305
323
  and inline **named-volume create-mode** (pre-create with `Volume.create`, then
306
324
  mount with `{ named: }`). These slot in module-by-module exactly as the existing