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
@@ -0,0 +1,99 @@
1
+ # Metrics, logs, and live modification
2
+
3
+ ## Metrics & logs
4
+
5
+ ```ruby
6
+ Microsandbox::Sandbox.create("obs", image: "public.ecr.aws/docker/library/alpine:latest") do |sb|
7
+ # On v0.6.x runtimes the metrics slot goes live a beat after create returns,
8
+ # so `metrics` can briefly raise "no live metrics slot" right after boot —
9
+ # retry for a few hundred ms rather than treating the first failure as fatal.
10
+ m = sb.metrics # => Microsandbox::Metrics
11
+ m.cpu_percent
12
+ m.memory_bytes
13
+ m.uptime_secs
14
+
15
+ sb.logs(tail: 100, sources: ["stdout", "stderr"]).each do |entry|
16
+ puts "[#{entry.source}] #{entry.text}"
17
+ end
18
+ end
19
+ ```
20
+
21
+ Streaming counterparts: `sb.metrics_stream` (`MetricsStream` over `Metrics`)
22
+ and `sb.log_stream` (`LogStream` over `LogEntry`) — both single-pass, see
23
+ [execution.md](execution.md#streams-are-single-pass).
24
+ `Microsandbox.all_sandbox_metrics` reports every running sandbox at once.
25
+
26
+ `MetricsDisabledError` means metrics are turned off for the sandbox. The
27
+ metrics slot goes live a moment *after* `create` returns; a call inside that
28
+ window raises the base `Microsandbox::Error` with a message containing
29
+ `no live metrics slot` (not `MetricsUnavailableError`, which the pinned runtime
30
+ never raises). Retry on that message:
31
+
32
+ ```ruby
33
+ metrics = begin
34
+ sb.metrics
35
+ rescue Microsandbox::Error => e
36
+ raise unless e.message.include?("no live metrics slot")
37
+ sleep 0.1
38
+ retry
39
+ end
40
+ ```
41
+
42
+ `sb.log_stream(follow: true)` keeps waiting for new entries and **does not end
43
+ when the sandbox stops** — the runtime follows the log file, not the sandbox —
44
+ and the wait cannot be interrupted. A thread blocked in it also keeps the
45
+ process from exiting normally. Read what exists with `sb.logs` or
46
+ `sb.log_stream` (no `follow:`), or follow from a separate process you can kill.
47
+
48
+ ## Live modification and health
49
+
50
+ Resize, reconfigure, or rotate secrets on a sandbox **without recreating it**,
51
+ and probe the guest agent's liveness. To leave headroom for a live CPU/memory
52
+ resize, reserve a ceiling at create time with `max_cpus:`/`max_memory:`:
53
+
54
+ ```ruby
55
+ Microsandbox::Sandbox.create("live", image: "public.ecr.aws/docker/library/alpine:latest",
56
+ cpus: 1, max_cpus: 4, memory: 512, max_memory: 2048) do |sb|
57
+ # Health check — does NOT refresh the idle timer:
58
+ ping = sb.ping # => Microsandbox::PingResult
59
+ ping.latency_ms # round-trip latency
60
+
61
+ # Explicitly refresh the idle-activity timer (resets any idle_timeout:):
62
+ sb.touch.activity_seq # => Microsandbox::TouchResult
63
+
64
+ # Preview a change without applying it:
65
+ plan = sb.modify(cpus: 2, memory: 1024, dry_run: true)
66
+ plan.applied? # => false
67
+ plan.changes # => [{ kind: "config", field: "cpus", ... }, ...]
68
+
69
+ # Live resize — applies to the running VM under the default :no_restart policy:
70
+ sb.modify(cpus: 2, memory: 1024)
71
+
72
+ # Grow the root disk (managed upper or flat, MiB — runtime v0.6.9). Applied
73
+ # while stopped; growth-only:
74
+ sb.modify(root_disk_size: 8192, policy: :next_start)
75
+
76
+ # env/labels/workdir changes on a *running* sandbox require a restart, so the
77
+ # default :no_restart policy rejects the whole apply (it raises rather than
78
+ # partially applying). Persist them for the next start — or restart now —
79
+ # by saying so explicitly:
80
+ sb.modify(env: { "TIER" => "prod" }, remove_env: ["DEBUG"],
81
+ labels: { "role" => "worker" }, policy: :next_start) # or policy: :restart
82
+
83
+ # Rotating/removing an *existing* secret (or updating its allowed hosts) is
84
+ # live; *adding* a new secret is restart-required, like env. Specs are keyed
85
+ # by name; env:/store:/value: are mutually exclusive:
86
+ sb.modify(
87
+ secrets: { "API_KEY" => { env: "HOST_API_KEY", allowed_hosts: ["api.example.com"] } },
88
+ remove_secrets: ["OLD_TOKEN"],
89
+ )
90
+ end
91
+ ```
92
+
93
+ The apply is **all-or-nothing**: under a given `policy:` every planned change
94
+ must be applicable, or the whole `modify` raises — nothing is partially
95
+ applied. Use `dry_run: true` to inspect each change's `disposition` (`"live"`,
96
+ `"next start"`, `"requires restart"`) before committing.
97
+
98
+ `SandboxHandle` (from `Sandbox.get`/`list`) carries the same `#ping`/`#touch`/
99
+ `#modify`; on a stopped sandbox `#ping`/`#touch` raise `SandboxNotRunningError`.
data/docs/releasing.md ADDED
@@ -0,0 +1,117 @@
1
+ # Releasing
2
+
3
+ Releases are automated by `.github/workflows/release.yml` via RubyGems
4
+ **Trusted Publishing** (OIDC) — there is no API key to store as a secret. The
5
+ trusted publisher is already configured for this gem, so no per-release secret
6
+ or credential setup is needed.
7
+
8
+ ## Each release
9
+
10
+ 1. Bump the gem's **own** version on its independent semver track (see the
11
+ [Versioning](../README.md#versioning) section of the README); **don't** pick
12
+ the number to mirror the upstream tag. The version is written in three
13
+ files that must stay equal (`spec/unit/version_spec.rb` asserts it), and
14
+ recorded in the lockfile:
15
+ - `lib/microsandbox/version.rb` — `Microsandbox::VERSION`;
16
+ - `ext/microsandbox/Cargo.toml` — the `[package] version`;
17
+ - `binaries/lib/microsandbox/binaries.rb` — `Microsandbox::Binaries::VERSION`
18
+ (the companion gem ships in lockstep);
19
+ - `Cargo.lock` — committed and packed into the gem, and it records the
20
+ extension crate's version, so a version bump must commit the regenerated
21
+ lock (`bundle exec rake compile` rewrites it).
22
+
23
+ If the release also adopts a new upstream runtime, move the runtime tag
24
+ everywhere it is named:
25
+ - `ext/microsandbox/Cargo.toml` — the `tag = "vX.Y.Z"` on **all three**
26
+ upstream git deps (`microsandbox`, `microsandbox-network`,
27
+ `microsandbox-runtime`);
28
+ - `lib/microsandbox/version.rb` — `Microsandbox::RUNTIME_VERSION`;
29
+ - `binaries/lib/microsandbox/binaries.rb` —
30
+ `Microsandbox::Binaries::RUNTIME_VERSION`;
31
+ - `binaries/checksums/<tag>.sha256` — the release's `checksums.sha256`,
32
+ committed (vendoring fails closed without it);
33
+ - `Cargo.lock` — regenerated again, since it pins the git deps to the new
34
+ tag's commit.
35
+
36
+ Then add a row to the Versioning table in the README and update
37
+ `CHANGELOG.md`.
38
+ 2. Push a `vX.Y.Z` tag. Once the [release preflight](#release-preflight)
39
+ passes, CI pushes the **source gem** to RubyGems via
40
+ `rubygems/configure-rubygems-credentials` (OIDC, `id-token: write`) — no
41
+ `RUBYGEMS_API_KEY` secret required — then creates the GitHub Release.
42
+
43
+ ## Release preflight
44
+
45
+ `release.yml`'s `preflight` job runs `.github/scripts/release_preflight.sh
46
+ <tag>` and the `publish` job needs it, so nothing reaches RubyGems unless:
47
+
48
+ - the tag is `vX.Y.Z` and the three version constants **in the tagged commit**
49
+ — `Microsandbox::VERSION`, `[package] version` in
50
+ `ext/microsandbox/Cargo.toml`, `Microsandbox::Binaries::VERSION` — all equal
51
+ `X.Y.Z`;
52
+ - the tagged commit is on `main`;
53
+ - the `CI` workflow's push run for that exact commit concluded `success`.
54
+
55
+ Pushing `main` and the tag together is fine: the preflight **waits** for that
56
+ CI run to appear and finish (polling every 30 s, up to 45 min —
57
+ `PREFLIGHT_CI_WAIT_SECONDS` / `PREFLIGHT_CI_POLL_SECONDS`), so a release
58
+ publishes only after CI, including the real-microVM integration lanes, is
59
+ green. A failed or timed-out preflight leaves the tag unpublished. How to
60
+ recover depends on whether the tag has to move:
61
+
62
+ - **The tagged commit is right** (CI flaked, or was still red): re-run the
63
+ `CI` run for that commit until it is green, then re-run the failed `Release`
64
+ run.
65
+ - **The tag must point at a different commit** (wrong version constants, not
66
+ on `main`): delete the tag, fix the commit, and push the tag again — that
67
+ starts a new `Release` run. Never re-run the old run after retagging: a
68
+ re-run keeps the commit it was started for, so it checks the old commit
69
+ again and fails the same way.
70
+
71
+ Dry-run it against an existing tag — nothing is published on a manual
72
+ dispatch — or run the script locally from a full clone:
73
+
74
+ ```bash
75
+ gh workflow run release.yml --ref main -f preflight_ref=vX.Y.Z
76
+ GH_TOKEN=$(gh auth token) .github/scripts/release_preflight.sh vX.Y.Z
77
+ ```
78
+
79
+ It is a workflow-level gate; branch protection and a protected environment for
80
+ the trusted publisher are separate repository settings.
81
+
82
+ CI's `lint` job also runs `cargo deny check advisories` against `Cargo.lock`;
83
+ the exceptions (advisories with no fixed release at the pinned runtime) are in
84
+ `deny.toml` at the repository root — re-check them on every runtime adoption.
85
+
86
+ ## Precompiled per-platform gems
87
+
88
+ Precompiled *extension* gems are not built or published. Users install the
89
+ source gem, which compiles the extension via `rb_sys` at install time (so a
90
+ stable Rust toolchain is required on the installing host). The companion
91
+ `microsandbox-rb-binaries` gems below are unaffected: they carry the host
92
+ runtime, not the extension.
93
+
94
+ ## Runtime binaries gems
95
+
96
+ `microsandbox-rb-binaries` (source in `binaries/`) is a *separate* artifact from
97
+ the SDK gem: it carries no Ruby extension, only the
98
+ upstream `msb` + `libkrunfw` for one platform, verified against the release's
99
+ published `checksums.sha256` when vendored. CI's `binaries` job vendors and
100
+ builds all three (`arm64-darwin`, `x86_64-linux-gnu`, `aarch64-linux-gnu`) on
101
+ every run and smoke-tests the host one; `release.yml`'s `binaries-gems` job
102
+ does the same on a tag and a separate `publish-binaries` job pushes them after
103
+ the SDK gem is live. The SDK `publish` job needs `binaries-gems`, so a
104
+ companion **build** failure blocks the SDK publish as well. A companion
105
+ **push** failure is its own red job once the SDK gem is live: the release is
106
+ then incomplete until `publish-binaries` is re-run. They use their own RubyGems
107
+ trusted-publisher entry (same repo + workflow, gem name
108
+ `microsandbox-rb-binaries`). To build them by hand:
109
+
110
+ ```bash
111
+ rake -C binaries vendor[arm64-darwin] # download + sha256-verify into binaries/vendor
112
+ rake -C binaries build[arm64-darwin] # package into binaries/pkg/*.gem
113
+ rake -C binaries verify # host-only: run the vendored `msb --version`
114
+ ```
115
+
116
+ `binaries/vendor` and `binaries/pkg` are gitignored — binaries are never
117
+ committed; CI rebuilds them from the upstream release.
data/docs/runtime.md ADDED
@@ -0,0 +1,154 @@
1
+ # Runtime binaries, resolution, and backends
2
+
3
+ ## The runtime binaries
4
+
5
+ `microsandbox-rb` is **SDK-only** — it wraps the microVM runtime, it doesn't
6
+ carry it. The host-side `msb` runtime and the `libkrunfw` firmware come from an
7
+ optional companion gem, **`microsandbox-rb-binaries`**, published as one gem per
8
+ platform (`arm64-darwin`, `x86_64-linux-gnu`, `aarch64-linux-gnu`) and
9
+ versioned in lockstep with this gem:
10
+
11
+ ```ruby
12
+ # Gemfile — install both gems at the same version
13
+ gem "microsandbox-rb", require: "microsandbox"
14
+ gem "microsandbox-rb-binaries"
15
+ ```
16
+
17
+ That's all the wiring there is: `require "microsandbox"` finds the companion
18
+ gem, checks it is the same version and built for the same upstream runtime, and
19
+ points the resolver at its vendored `msb` — so your bundle carries the runtime
20
+ and nothing is downloaded at install time or on first call. **Recommended
21
+ whenever you boot local microVMs.** Cloud-only users (`MSB_BACKEND=cloud`)
22
+ should skip it: it is a separate, optional gem precisely so nobody has to fetch
23
+ ~50 MB of binaries they won't run. Neither gem depends on the other.
24
+
25
+ > **Availability.** The binaries gems are published on every release tag
26
+ > alongside `microsandbox-rb` since `0.14.0`. On an older SDK version, or a
27
+ > platform without a bundle, use the fallback below (or build them yourself
28
+ > from `binaries/` — see [binaries/README.md](https://github.com/ya-luotao/microsandbox-rb/blob/main/binaries/README.md)).
29
+
30
+ ### Fallback — first-use download
31
+
32
+ Without the companion gem, the `msb` runtime and `libkrunfw` firmware are
33
+ provisioned into `~/.microsandbox` automatically on first use (the first
34
+ `Sandbox.create`/`start` downloads them if missing). To provision ahead of time
35
+ — e.g. while baking a container image, or to avoid the first-call latency —
36
+ call `install` explicitly:
37
+
38
+ ```ruby
39
+ Microsandbox.install unless Microsandbox.installed?
40
+ ```
41
+
42
+ Set `MICROSANDBOX_NO_AUTO_INSTALL` to disable the automatic first-use download
43
+ (e.g. on air-gapped hosts that provision the runtime out of band). None of this
44
+ applies when the companion gem supplies the runtime: its binaries are already
45
+ the matching version, so `ensure_runtime!` skips the installer entirely and
46
+ nothing is written to `~/.microsandbox`. `Microsandbox.setup` customizes the
47
+ provisioning step itself.
48
+
49
+ ## Runtime path resolution
50
+
51
+ The `msb` runtime path is resolved in this order: the `MSB_PATH` environment
52
+ variable → the `microsandbox-rb-binaries` gem (or another SDK-set override) →
53
+ the config file → `~/.microsandbox/bin/msb` → `msb` on `PATH`.
54
+ `Microsandbox.runtime_path` reports the winner.
55
+
56
+ ```ruby
57
+ Microsandbox.installed? # => true/false
58
+ Microsandbox.install # download + install the runtime (idempotent)
59
+ Microsandbox.runtime_path # => "/Users/you/.microsandbox/bin/msb"
60
+ Microsandbox.runtime_path = "/opt/microsandbox/bin/msb" # override (set-once)
61
+ Microsandbox.libkrunfw_path = "/opt/microsandbox/lib/libkrunfw.dylib" # override (set-once)
62
+ ```
63
+
64
+ When `microsandbox-rb-binaries` is installed, `require "microsandbox"` claims
65
+ that SDK-set slot with the gem's vendored `msb` (the firmware is found alongside
66
+ it), and `runtime_path` points into the gem. The two gems are versioned in
67
+ lockstep and the companion gem must be the same version **and** built for the
68
+ same upstream runtime — a mismatch of either is reported with a warning and
69
+ skipped, and the SDK falls back to `~/.microsandbox` rather than driving a
70
+ runtime it doesn't match. Because the slot is **set-once**,
71
+ `Microsandbox.runtime_path=` is then a no-op: use the `MSB_PATH` environment
72
+ variable, which outranks it, to point at a different runtime.
73
+
74
+ ## Backend routing
75
+
76
+ Every operation runs through a backend. The default is the local libkrun
77
+ backend; without any configuration nothing changes. A backend can be selected
78
+ programmatically or via the environment:
79
+
80
+ ```ruby
81
+ Microsandbox.default_backend_kind # => :local (or :cloud)
82
+ Microsandbox.default_backend_info # => Microsandbox::BackendInfo
83
+ Microsandbox.set_default_backend(:cloud, url: "https://api.example.com", api_key: ENV["MSB_API_KEY"])
84
+ # or a named profile from ~/.microsandbox/config.json:
85
+ Microsandbox.set_default_backend(:cloud, profile: "prod")
86
+
87
+ # Scoped override (restored afterward, even on error):
88
+ Microsandbox.with_backend(:local) do
89
+ Microsandbox::Sandbox.create("box", image: "alpine") { |sb| puts sb.shell("uname -a").stdout }
90
+ end
91
+ ```
92
+
93
+ Resolution order when no backend is set programmatically: `MSB_BACKEND`
94
+ (`local`/`cloud`) → `MSB_PROFILE` → the `active_profile` in
95
+ `~/.microsandbox/config.json` (path overridable via `MSB_CONFIG_PATH`) →
96
+ local. **Cloud intent must be explicit** (since runtime `v0.6.9`): a bare
97
+ `MSB_API_KEY` is treated as credential material, not backend intent, and no
98
+ longer selects the cloud on its own — pair it with `MSB_BACKEND=cloud` (which
99
+ reads `MSB_API_URL`/`MSB_API_KEY`), or select a cloud profile. Invalid cloud
100
+ configuration (e.g. `MSB_BACKEND=cloud` without a usable API key or cloud
101
+ profile) fails closed with `Microsandbox::InvalidConfigError` instead of
102
+ silently running locally. The cloud backend currently supports a subset of
103
+ operations (create/start/stop/remove/get/list, one-shot exec, follow log
104
+ streaming); unsupported operations raise `Microsandbox::UnsupportedError`
105
+ (with `#operation` and `#hint`).
106
+
107
+ ## One runtime line per home
108
+
109
+ The runtime home (`MSB_HOME`, default `~/.microsandbox`) belongs to one
110
+ runtime line at a time. This gem embeds runtime `v0.6.18` and, on first use,
111
+ installs that version of `msb` into the home — replacing a different version
112
+ that is already there, including a newer `msb` installed by the official CLI.
113
+ In the other direction, a `0.7.x` `msb` migrates the catalog to a newer schema
114
+ the first time it opens it, after which every local call from this gem fails
115
+ with `database schema is newer than this msb binary`. The migration is one-way.
116
+
117
+ If the official CLI (or another SDK) on a different runtime line shares the
118
+ machine, give this gem its own home:
119
+
120
+ ```sh
121
+ export MSB_HOME="$HOME/.microsandbox-rb"
122
+ ```
123
+
124
+ ## Known issues at the pinned runtime
125
+
126
+ These are fixed upstream in runtime releases this gem has not adopted yet. See
127
+ [SECURITY.md](../SECURITY.md) for the security advisories.
128
+
129
+ - **macOS hosts: guest `fallocate` can truncate a file on a bind mount or a
130
+ directory-backed named volume.** Preallocating a range that ends inside an
131
+ existing file shrinks the file to the end of that range, and the call
132
+ reports success. Fixed upstream in `v0.7.0`. Until then, keep data that
133
+ guest programs preallocate (databases, download tools) on the guest's OCI
134
+ root disk or a `disk:` volume, not on a host directory.
135
+ - **`modify(secrets:)` adding the first secret** to a sandbox created without
136
+ any does not turn on TLS interception, so the value is not substituted.
137
+ Create the sandbox with at least one secret.
138
+ - **`patches:`** resets the owner and mode of parent directories that already
139
+ exist in the image to `root:root 0755`. Write after boot with `sb.fs`, or
140
+ restore ownership yourself.
141
+
142
+ ## Environment variables
143
+
144
+ | Variable | Effect |
145
+ |----------|--------|
146
+ | `MSB_PATH` | Path to the `msb` runtime binary; outranks every other source, including the binaries gem |
147
+ | `MSB_LIBKRUNFW_PATH` | Path to the `libkrunfw` shared library; outranks `Microsandbox.libkrunfw_path=` |
148
+ | `MSB_HOME` | Runtime home: the catalog, image cache, sandboxes, volumes, snapshots, and the first-use runtime install. Default `~/.microsandbox` |
149
+ | `MSB_BACKEND` | `local` or `cloud`; cloud additionally reads `MSB_API_URL` / `MSB_API_KEY` |
150
+ | `MSB_PROFILE` | Select a named profile from the config file |
151
+ | `MSB_CONFIG_PATH` | Override the `~/.microsandbox/config.json` location |
152
+ | `MICROSANDBOX_NO_AUTO_INSTALL` | Disable the first-use runtime download |
153
+ | `MICROSANDBOX_INTEGRATION` | Set to `1` to run the integration specs (development only) |
154
+ | `MICROSANDBOX_TEST_IMAGE` | Override the integration-spec image (development only) |
data/docs/surface.md ADDED
@@ -0,0 +1,75 @@
1
+ # Supported surface
2
+
3
+ The binding covers the official-SDK surface of the upstream Rust core. This
4
+ page is the authoritative scope statement; `sig/microsandbox.rbs` is the typed
5
+ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
6
+
7
+ ## Implemented
8
+
9
+ - **Sandbox lifecycle** — the live `Sandbox` (`stop`/`stop_and_wait`/`kill`/
10
+ `drain`/`wait`/`status`/`detach`/`owns_lifecycle?`), the `SandboxHandle`
11
+ controls from `Sandbox.get` (`stop_with_timeout`/`request_stop`/
12
+ `request_kill`/`request_drain`/`wait_until_stopped`/`config`/`config_json`/
13
+ `snapshot`), the cursor-paginated `list`/`list_with` with label filters, and
14
+ the convergent lifecycle (`connect_or_create`, `#id`, `wait_for_status`,
15
+ `restart`, `destroy`, `SandboxHandle#connect_or_start`,
16
+ `SandboxReplacedError`).
17
+ - **Backend routing** — `set_default_backend`/`with_backend`/
18
+ `default_backend_kind`/`default_backend_info`.
19
+ - **Execution** — `exec`/`shell` (collected and streaming), the default
20
+ workload (`exec_default`/`exec_default_stream`/`attach_default`, `cmd:`),
21
+ interactive `attach`/`attach_shell`, per-call `rlimits`.
22
+ - **Guest filesystem** — the full API including streaming `read_stream`/
23
+ `write_stream`.
24
+ - **Observability** — per-sandbox `metrics`, `Microsandbox.all_sandbox_metrics`,
25
+ streaming `metrics_stream`/`log_stream`, `logs`, `ping`/`touch`.
26
+ - **Live modification** — `modify` with `dry_run:` and `policy:`: CPU/memory
27
+ resize (`cpus`/`max_cpus`/`memory`/`max_memory`), root-disk growth
28
+ (`root_disk_size`, MiB), `env`/`remove_env`, `labels`/`remove_labels`,
29
+ `workdir`, and secret rotation (`secrets`/`remove_secrets`).
30
+ - **OCI image cache** — `Image.get`/`list`/`inspect`/`remove`/`prune`, image
31
+ archives (`Image.load`/`Image.save`), streaming pull progress
32
+ (`Sandbox.create_with_progress` → `PullSession`).
33
+ - **Named volumes** — `Volume.create`/`get`/`list`/`remove`/`get_default`, plus
34
+ host-side `Volume.fs`/`VolumeInfo#fs` read/write.
35
+ - **Snapshots** — create/open/list/list_dir/reindex/verify/save/load, boot from
36
+ snapshot, opt-in `record_integrity:`.
37
+ - **Rootfs patches** — `Microsandbox::Patch` (text/file/append/copy_file/
38
+ copy_dir/symlink/mkdir/remove) via `create(patches:)`.
39
+ - **Root disk** — structured `root_disk:` (managed / tmpfs / disk / flat via
40
+ `Microsandbox::RootDisk`), `follow_root_symlinks:`; `oci_upper_size:` is the
41
+ deprecated alias for a managed `root_disk:` size.
42
+ - **Network configuration** — composable profiles, custom per-rule
43
+ `Microsandbox::NetworkPolicy`/`Rule`/`Destination`, custom DNS (`dns:`),
44
+ TLS-interception tuning (`tls:`), guest address pools (`ipv4_pool:`/
45
+ `ipv6_pool:`), `max_connections`, `trust_host_cas`, `strict` hostname mode,
46
+ outbound SOCKS4/SOCKS5 `proxy:` (`OutboundProxy`/`SecretSource`),
47
+ `rate_limiter:`, `vsock:`.
48
+ - **Secrets** — multi-host / wildcard allow-lists, injection toggles,
49
+ per-secret `on_violation:` and sandbox-level `on_secret_violation:` policy.
50
+ - **SSH** — `Sandbox#ssh` → `SshClient`/`SftpClient`/`SshServer`, with
51
+ `inactivity_timeout:`.
52
+ - **Raw agent client** — `Microsandbox::AgentClient` → `AgentStream`/
53
+ `AgentFrame`.
54
+ - **Runtime provisioning** — `install`/`installed?`/`ensure_runtime!`/
55
+ `runtime_path`/`libkrunfw_path`, customizable via `Microsandbox.setup`; the
56
+ companion `microsandbox-rb-binaries` gem.
57
+ - **Registries** — `registry_auth`/`registry_insecure`/`registry_ca_certs` on
58
+ `create`.
59
+ - **Errors** — the typed hierarchy in [errors.md](errors.md).
60
+
61
+ Create options span resources (`cpus`/`memory`/`max_cpus`/`max_memory`),
62
+ `env`/`workdir`/`labels`/`user`/`hostname`/`shell`, named `scripts`, the image
63
+ `entrypoint`/`cmd` overrides, `init`/`ephemeral`,
64
+ disk-image `fstype`, `ports`/`ports_udp`, network policy + config,
65
+ `log_level`/`quiet_logs`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches`/`volumes`
66
+ (bind/named/tmpfs/disk with mount policies, `uid`/`gid`, `quota_mib`),
67
+ `idle_timeout`/`max_duration`, `from_snapshot`, `detached`, and
68
+ `replace`/`replace_with_timeout`.
69
+
70
+ ## Not yet exposed
71
+
72
+ A few secondary upstream knobs are not yet exposed: per-published-port host
73
+ bind address (ports always bind loopback), network interface overrides, and
74
+ inline named-volume create-mode (pre-create the volume with `Volume.create`,
75
+ then mount it with `{ named: "…" }`).
@@ -6,8 +6,9 @@ name = "microsandbox_rb"
6
6
  description = "Ruby SDK native extension for microsandbox — secure, fast microVM-based sandboxing."
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
- # The core-crate dependency below stays pinned at its own tag (v0.6.16).
10
- version = "0.17.0"
9
+ # The core-crate dependencies below stay on their own tag, which is
10
+ # Microsandbox::RUNTIME_VERSION and moves independently of this version.
11
+ version = "0.17.2"
11
12
  authors = ["Super Rad Company <development@superrad.company>"]
12
13
  repository = "https://github.com/superradcompany/microsandbox"
13
14
  license = "Apache-2.0"
@@ -22,9 +23,12 @@ name = "microsandbox_rb"
22
23
  crate-type = ["cdylib"]
23
24
 
24
25
  [dependencies]
25
- magnus = "0.8"
26
- # Low-level Ruby C API — used directly for rb_thread_call_without_gvl (GVL release
27
- # during blocking sandbox calls). Kept in lock-step with magnus's own rb-sys.
26
+ # "rb-sys" exposes `magnus::rb_sys::protect` (rb_protect), which the GVL bridge
27
+ # uses to check interrupts without letting Ruby longjmp over Rust frames.
28
+ magnus = { version = "0.8", features = ["rb-sys"] }
29
+ # Low-level Ruby C API — used directly for rb_thread_call_without_gvl2 (GVL
30
+ # release during blocking sandbox calls) and rb_thread_check_ints. Kept in
31
+ # lock-step with magnus's own rb-sys.
28
32
  rb-sys = "0.9"
29
33
 
30
34
  # The microsandbox core runtime, wrapped via FFI exactly like the official
@@ -41,8 +41,8 @@ impl AgentClient {
41
41
  /// Connect to a running sandbox by name. `timeout` is optional seconds.
42
42
  fn connect_sandbox(name: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
43
43
  let bridge = match dur(timeout)? {
44
- Some(t) => block_on(AgentBridge::connect_sandbox_with_timeout(&name, t)),
45
- None => block_on(AgentBridge::connect_sandbox(&name)),
44
+ Some(t) => block_on(AgentBridge::connect_sandbox_with_timeout(&name, t))?,
45
+ None => block_on(AgentBridge::connect_sandbox(&name))?,
46
46
  }
47
47
  .map_err(to_ruby_agent)?;
48
48
  Ok(AgentClient::from_bridge(bridge))
@@ -51,8 +51,8 @@ impl AgentClient {
51
51
  /// Connect to an agentd relay socket by path. `timeout` is optional seconds.
52
52
  fn connect_path(path: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
53
53
  let bridge = match dur(timeout)? {
54
- Some(t) => block_on(AgentBridge::connect_path_with_timeout(&path, t)),
55
- None => block_on(AgentBridge::connect_path(&path)),
54
+ Some(t) => block_on(AgentBridge::connect_path_with_timeout(&path, t))?,
55
+ None => block_on(AgentBridge::connect_path(&path))?,
56
56
  }
57
57
  .map_err(to_ruby_agent)?;
58
58
  Ok(AgentClient::from_bridge(bridge))
@@ -73,7 +73,7 @@ impl AgentClient {
73
73
  let body = unsafe { body.as_slice() }.to_vec();
74
74
  let inner = Arc::clone(&self.inner);
75
75
  let frame =
76
- block_on(async move { inner.request(flags, body).await }).map_err(to_ruby_agent)?;
76
+ block_on(async move { inner.request(flags, body).await })?.map_err(to_ruby_agent)?;
77
77
  Ok(frame_to_hash(frame))
78
78
  }
79
79
 
@@ -81,8 +81,8 @@ impl AgentClient {
81
81
  fn stream_open(&self, flags: u8, body: RString) -> Result<RHash, Error> {
82
82
  let body = unsafe { body.as_slice() }.to_vec();
83
83
  let inner = Arc::clone(&self.inner);
84
- let (id, handle) =
85
- block_on(async move { inner.stream_open(flags, body).await }).map_err(to_ruby_agent)?;
84
+ let (id, handle) = block_on(async move { inner.stream_open(flags, body).await })?
85
+ .map_err(to_ruby_agent)?;
86
86
  let hash = ruby().hash_new();
87
87
  hash.aset("id", id)?;
88
88
  hash.aset("handle", handle)?;
@@ -93,14 +93,14 @@ impl AgentClient {
93
93
  fn stream_next(&self, handle: u64) -> Result<Option<RHash>, Error> {
94
94
  let inner = Arc::clone(&self.inner);
95
95
  let frame =
96
- block_on(async move { inner.stream_next(handle).await }).map_err(to_ruby_agent)?;
96
+ block_on(async move { inner.stream_next(handle).await })?.map_err(to_ruby_agent)?;
97
97
  Ok(frame.map(frame_to_hash))
98
98
  }
99
99
 
100
100
  /// Close a stream handle. Idempotent.
101
101
  fn stream_close(&self, handle: u64) -> Result<(), Error> {
102
102
  let inner = Arc::clone(&self.inner);
103
- block_on(async move { inner.stream_close(handle).await });
103
+ block_on(async move { inner.stream_close(handle).await })?;
104
104
  Ok(())
105
105
  }
106
106
 
@@ -108,7 +108,7 @@ impl AgentClient {
108
108
  fn send(&self, id: u32, flags: u8, body: RString) -> Result<(), Error> {
109
109
  let body = unsafe { body.as_slice() }.to_vec();
110
110
  let inner = Arc::clone(&self.inner);
111
- block_on(async move { inner.send(id, flags, body).await }).map_err(to_ruby_agent)
111
+ block_on(async move { inner.send(id, flags, body).await })?.map_err(to_ruby_agent)
112
112
  }
113
113
 
114
114
  /// Cached handshake `core.ready` frame body bytes (CBOR).
@@ -120,7 +120,7 @@ impl AgentClient {
120
120
  /// Close the connection. Idempotent.
121
121
  fn close(&self) -> Result<(), Error> {
122
122
  let inner = Arc::clone(&self.inner);
123
- block_on(async move { inner.close().await });
123
+ block_on(async move { inner.close().await })?;
124
124
  Ok(())
125
125
  }
126
126
  }
@@ -157,6 +157,8 @@ fn frame_to_hash(frame: BridgeFrame) -> RHash {
157
157
 
158
158
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
159
159
  let class = native.define_class("AgentClient", ruby.class_object())?;
160
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
161
+ crate::runtime::preload_class::<AgentClient>(ruby);
160
162
 
161
163
  class.define_singleton_method(
162
164
  "connect_sandbox",
@@ -59,7 +59,7 @@ pub fn with_local_backend<T>(
59
59
  .as_local()
60
60
  .expect("local_backend() guarantees a local backend");
61
61
  op(local).await
62
- })
62
+ })?
63
63
  .map_err(error::to_ruby)
64
64
  }
65
65
 
@@ -43,7 +43,7 @@ impl ExecHandle {
43
43
  /// Next event as a Hash, or nil when the stream ends.
44
44
  fn recv(&self) -> Result<Option<RHash>, Error> {
45
45
  let inner = Arc::clone(&self.inner);
46
- let event = block_on(async move { inner.lock().await.recv().await });
46
+ let event = block_on(async move { inner.lock().await.recv().await })?;
47
47
  Ok(event.map(exec_event_to_hash))
48
48
  }
49
49
 
@@ -51,7 +51,7 @@ impl ExecHandle {
51
51
  fn wait(&self) -> Result<RHash, Error> {
52
52
  let inner = Arc::clone(&self.inner);
53
53
  let status =
54
- block_on(async move { inner.lock().await.wait().await }).map_err(error::to_ruby)?;
54
+ block_on(async move { inner.lock().await.wait().await })?.map_err(error::to_ruby)?;
55
55
  let hash = ruby().hash_new();
56
56
  hash.aset("exit_code", status.code)?;
57
57
  hash.aset("success", status.success)?;
@@ -62,20 +62,20 @@ impl ExecHandle {
62
62
  fn collect(&self) -> Result<RHash, Error> {
63
63
  let inner = Arc::clone(&self.inner);
64
64
  let output =
65
- block_on(async move { inner.lock().await.collect().await }).map_err(error::to_ruby)?;
65
+ block_on(async move { inner.lock().await.collect().await })?.map_err(error::to_ruby)?;
66
66
  crate::sandbox::exec_output_to_hash(output)
67
67
  }
68
68
 
69
69
  fn signal(&self, sig: i32) -> Result<(), Error> {
70
- block_on(self.control.signal(sig)).map_err(error::to_ruby)
70
+ block_on(self.control.signal(sig))?.map_err(error::to_ruby)
71
71
  }
72
72
 
73
73
  fn kill(&self) -> Result<(), Error> {
74
- block_on(self.control.kill()).map_err(error::to_ruby)
74
+ block_on(self.control.kill())?.map_err(error::to_ruby)
75
75
  }
76
76
 
77
77
  fn resize(&self, rows: u16, cols: u16) -> Result<(), Error> {
78
- block_on(self.control.resize(rows, cols)).map_err(error::to_ruby)
78
+ block_on(self.control.resize(rows, cols))?.map_err(error::to_ruby)
79
79
  }
80
80
 
81
81
  /// The stdin sink (only the first call returns it; nil afterwards or if
@@ -95,11 +95,11 @@ impl ExecSink {
95
95
  fn write(&self, data: RString) -> Result<(), Error> {
96
96
  // Copy out while holding the GVL (see sandbox::fs_write for the rationale).
97
97
  let bytes = unsafe { data.as_slice() }.to_vec();
98
- block_on(self.inner.write(&bytes)).map_err(error::to_ruby)
98
+ block_on(self.inner.write(&bytes))?.map_err(error::to_ruby)
99
99
  }
100
100
 
101
101
  fn close(&self) -> Result<(), Error> {
102
- block_on(self.inner.close()).map_err(error::to_ruby)
102
+ block_on(self.inner.close())?.map_err(error::to_ruby)
103
103
  }
104
104
  }
105
105
 
@@ -141,6 +141,8 @@ fn exec_event_to_hash(event: microsandbox::ExecEvent) -> RHash {
141
141
 
142
142
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
143
143
  let handle = native.define_class("ExecHandle", ruby.class_object())?;
144
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
145
+ crate::runtime::preload_class::<ExecHandle>(ruby);
144
146
  handle.define_method("id", method!(ExecHandle::id, 0))?;
145
147
  handle.define_method("recv", method!(ExecHandle::recv, 0))?;
146
148
  handle.define_method("wait", method!(ExecHandle::wait, 0))?;
@@ -151,6 +153,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
151
153
  handle.define_method("take_stdin", method!(ExecHandle::take_stdin, 0))?;
152
154
 
153
155
  let sink = native.define_class("ExecSink", ruby.class_object())?;
156
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
157
+ crate::runtime::preload_class::<ExecSink>(ruby);
154
158
  sink.define_method("write", method!(ExecSink::write, 1))?;
155
159
  sink.define_method("close", method!(ExecSink::close, 0))?;
156
160