microsandbox-rb 0.12.0 โ 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/Cargo.lock +993 -552
- data/README.md +39 -6
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/backend.rs +22 -0
- data/ext/microsandbox/src/error.rs +4 -0
- data/ext/microsandbox/src/sandbox.rs +198 -9
- data/ext/microsandbox/src/snapshot.rs +10 -3
- data/ext/microsandbox/src/volume.rs +9 -0
- data/lib/microsandbox/backend_info.rb +46 -0
- data/lib/microsandbox/errors.rb +4 -0
- data/lib/microsandbox/root_disk.rb +24 -0
- data/lib/microsandbox/sandbox.rb +181 -9
- data/lib/microsandbox/snapshot.rb +12 -8
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox/volume.rb +16 -0
- data/lib/microsandbox.rb +16 -3
- data/sig/microsandbox.rbs +29 -2
- metadata +2 -1
data/README.md
CHANGED
|
@@ -20,7 +20,13 @@ them. Our deepest thanks to the maintainers and community. ๐
|
|
|
20
20
|
[Rust](https://github.com/superradcompany/microsandbox/tree/main/sdk) ยท
|
|
21
21
|
[Python](https://github.com/superradcompany/microsandbox/tree/main/sdk/python) ยท
|
|
22
22
|
[TypeScript / Node](https://github.com/superradcompany/microsandbox/tree/main/sdk/node-ts) ยท
|
|
23
|
-
[Go](https://github.com/superradcompany/microsandbox/tree/main/sdk/go)
|
|
23
|
+
[Go](https://github.com/superradcompany/microsandbox/tree/main/sdk/go) ยท
|
|
24
|
+
[Ruby](https://github.com/superradcompany/microsandbox/tree/main/sdk/ruby)
|
|
25
|
+
(since upstream `v0.6.9` there is an **official** `microsandbox` gem โ a
|
|
26
|
+
compact veneer over the same Rust SDK. This gem predates it and covers a
|
|
27
|
+
larger surface (snapshots, SSH, streaming, volumes fs, network policy DSL,
|
|
28
|
+
RBS types); both define the `Microsandbox` module, so use one or the other,
|
|
29
|
+
not both, in a single process.)
|
|
24
30
|
- **Agents** โ [Agent Skills](https://github.com/superradcompany/skills) ยท [MCP server](https://github.com/superradcompany/microsandbox-mcp)
|
|
25
31
|
- **Community** โ [Discord](https://discord.gg/T95Y3XnEAK)
|
|
26
32
|
|
|
@@ -193,6 +199,22 @@ end
|
|
|
193
199
|
A non-zero exit is **not** an error โ inspect `exit_code`/`success?`. Spawn-time
|
|
194
200
|
failures (e.g. command not found) and timeouts raise typed errors (see below).
|
|
195
201
|
|
|
202
|
+
**Default workload** (runtime `v0.6.9`): `create` is strictly boot-only โ it
|
|
203
|
+
never runs the image's `ENTRYPOINT`/`CMD`. Execute the image's own command
|
|
204
|
+
explicitly:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
Microsandbox::Sandbox.create("worker", image: "example/worker:latest",
|
|
208
|
+
cmd: ["worker.py", "--once"]) do |sb| # cmd: overrides the durable image CMD
|
|
209
|
+
out = sb.exec_default(timeout: 300) # buffered; exec-style options
|
|
210
|
+
handle = sb.exec_default_stream # or streaming (returns an ExecHandle)
|
|
211
|
+
sb.attach_default # or interactive (host TTY)
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
An image whose entrypoint and CMD resolve to no executable command raises
|
|
216
|
+
`Microsandbox::NoDefaultCommandError`.
|
|
217
|
+
|
|
196
218
|
### Guest filesystem
|
|
197
219
|
|
|
198
220
|
```ruby
|
|
@@ -256,6 +278,10 @@ Microsandbox::Sandbox.create("live", image: "public.ecr.aws/docker/library/alpin
|
|
|
256
278
|
# Live resize โ applies to the running VM under the default :no_restart policy:
|
|
257
279
|
sb.modify(cpus: 2, memory: 1024)
|
|
258
280
|
|
|
281
|
+
# Grow the root disk (managed upper or flat, MiB โ runtime v0.6.9). Applied
|
|
282
|
+
# while stopped; growth-only:
|
|
283
|
+
sb.modify(root_disk_size: 8192, policy: :next_start)
|
|
284
|
+
|
|
259
285
|
# env/labels/workdir changes on a *running* sandbox require a restart, so the
|
|
260
286
|
# default :no_restart policy rejects the whole apply (it raises rather than
|
|
261
287
|
# partially applying). Persist them for the next start โ or restart now โ
|
|
@@ -430,9 +456,15 @@ Microsandbox.with_backend(:local) { Microsandbox::Sandbox.create("box", image: "
|
|
|
430
456
|
```
|
|
431
457
|
|
|
432
458
|
Resolution order when no backend is set programmatically: `MSB_BACKEND`
|
|
433
|
-
(`local`/`cloud`) โ `
|
|
434
|
-
|
|
435
|
-
|
|
459
|
+
(`local`/`cloud`) โ `MSB_PROFILE` โ the `active_profile` in
|
|
460
|
+
`~/.microsandbox/config.json` (path overridable via `MSB_CONFIG_PATH`) โ
|
|
461
|
+
local. **Cloud intent must be explicit** (since runtime `v0.6.9`): a bare
|
|
462
|
+
`MSB_API_KEY` is treated as credential material, not backend intent, and no
|
|
463
|
+
longer selects the cloud on its own โ pair it with `MSB_BACKEND=cloud` (which
|
|
464
|
+
reads `MSB_API_URL`/`MSB_API_KEY`), or select a cloud profile. Invalid cloud
|
|
465
|
+
configuration (e.g. `MSB_BACKEND=cloud` without a usable API key or cloud
|
|
466
|
+
profile) fails closed with `Microsandbox::InvalidConfigError` instead of
|
|
467
|
+
silently running locally. The cloud backend currently supports a subset of
|
|
436
468
|
operations (create/start/stop/remove/get/list, one-shot exec, follow log
|
|
437
469
|
streaming); unsupported operations raise `Microsandbox::UnsupportedError`.
|
|
438
470
|
|
|
@@ -445,8 +477,8 @@ change diverged the two numbers โ the gem version is **not** a reliable indica
|
|
|
445
477
|
of the embedded runtime version. To learn which runtime a build wraps, ask it:
|
|
446
478
|
|
|
447
479
|
```ruby
|
|
448
|
-
Microsandbox::VERSION # => "0.
|
|
449
|
-
Microsandbox.runtime_version # => "v0.6.
|
|
480
|
+
Microsandbox::VERSION # => "0.13.0" (the gem's own version)
|
|
481
|
+
Microsandbox.runtime_version # => "v0.6.9" (the embedded upstream runtime tag)
|
|
450
482
|
```
|
|
451
483
|
|
|
452
484
|
| Gem version | Upstream runtime | Notes |
|
|
@@ -469,6 +501,7 @@ Microsandbox.runtime_version # => "v0.6.8" (the embedded upstream runtime tag
|
|
|
469
501
|
| `0.10.0` | `v0.6.6` | `v0.6.6` API parity: live `modify`/resize, `ping`/`touch`, create `max_cpus`/`max_memory` |
|
|
470
502
|
| `0.11.0` | `v0.6.7` | adopts upstream `v0.6.7` (**breaking**): network profiles replace `public_only`/`non_local`, structured `root_disk:` replaces `oci_upper_size:` (deprecated alias kept), snapshot descriptor contract (`create` re-keyed by name, `save`/`load` rename, `snapshot_to` removed, on-disk auto-migration), `Image.load`/`Image.save`, `follow_root_symlinks:`; runtime carries the GHSA-4vq3-cjpp-v7fg `msb copy` fix |
|
|
471
503
|
| `0.12.0` | `v0.6.8` | adopts upstream `v0.6.8` (**breaking**): `Sandbox.list`/`.list_with` return a cursor-paginated `SandboxPage` (`limit:`/`cursor:` keywords), `UnsupportedError` re-keyed by structured operations with `#operation`/`#hint`; runtime adds a shared log registry for followed streams and cloud exec/ssh reconnects |
|
|
504
|
+
| `0.13.0` | `v0.6.9` | adopts upstream `v0.6.9` (**breaking**): a bare `MSB_API_KEY` no longer selects the cloud backend (explicit `MSB_BACKEND=cloud` or a cloud profile required; invalid cloud config fails closed with `InvalidConfigError`); snapshot payload integrity becomes opt-in (`record_integrity:`, `verify` can report `:not_recorded`). Parity: default-workload execution (`exec_default`/`exec_default_stream`/`attach_default`, `cmd:`), flat root disks (`RootDisk.flat`), `modify(root_disk_size:)`, `rate_limiter:`, `vsock:`, `default_backend_info`, `Volume.get_default` |
|
|
472
505
|
|
|
473
506
|
**Going forward** โ the gem version moves on its own semver track and no longer
|
|
474
507
|
mirrors the upstream tag:
|
data/ext/microsandbox/Cargo.toml
CHANGED
|
@@ -6,8 +6,8 @@ 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.
|
|
10
|
-
version = "0.
|
|
9
|
+
# The core-crate dependency below stays pinned at its own tag (v0.6.9).
|
|
10
|
+
version = "0.13.0"
|
|
11
11
|
authors = ["Super Rad Company <development@superrad.company>"]
|
|
12
12
|
repository = "https://github.com/superradcompany/microsandbox"
|
|
13
13
|
license = "Apache-2.0"
|
|
@@ -35,8 +35,8 @@ rb-sys = "0.9"
|
|
|
35
35
|
# `.cargo/config.toml.example`). "ssh" matches the feature set the Python/Node
|
|
36
36
|
# SDKs ship with; default features add "prebuilt" (provisions msb + libkrunfw at
|
|
37
37
|
# build time), "net", and "keyring".
|
|
38
|
-
microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.
|
|
39
|
-
microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.
|
|
38
|
+
microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.9", default-features = true, features = ["ssh"] }
|
|
39
|
+
microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.9" }
|
|
40
40
|
|
|
41
41
|
# Async core bridged to Ruby's synchronous API via a blocking tokio runtime.
|
|
42
42
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
|
@@ -159,10 +159,32 @@ fn default_backend_kind() -> String {
|
|
|
159
159
|
.to_string()
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/// Secret-safe description of the active default backend (v0.6.9):
|
|
163
|
+
/// {kind, api_url, source, profile}. `source` names what selected the backend
|
|
164
|
+
/// (e.g. "MSB_BACKEND", "programmatic", "default"); the API key is never
|
|
165
|
+
/// included. Like `default_backend_kind`, the first call freezes ambient
|
|
166
|
+
/// env/profile resolution for the process.
|
|
167
|
+
fn default_backend_info() -> magnus::RHash {
|
|
168
|
+
let info = microsandbox::default_backend_info();
|
|
169
|
+
let hash = crate::runtime::ruby().hash_new();
|
|
170
|
+
let _ = hash.aset(
|
|
171
|
+
"kind",
|
|
172
|
+
match info.kind {
|
|
173
|
+
microsandbox::BackendKind::Local => "local",
|
|
174
|
+
microsandbox::BackendKind::Cloud => "cloud",
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
let _ = hash.aset("api_url", info.api_url);
|
|
178
|
+
let _ = hash.aset("source", info.source.as_str());
|
|
179
|
+
let _ = hash.aset("profile", info.profile);
|
|
180
|
+
hash
|
|
181
|
+
}
|
|
182
|
+
|
|
162
183
|
pub fn define(_ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
163
184
|
native.define_singleton_method("set_default_backend", function!(set_default_backend, 4))?;
|
|
164
185
|
native.define_singleton_method("push_default_backend", function!(push_default_backend, 4))?;
|
|
165
186
|
native.define_singleton_method("pop_default_backend", function!(pop_default_backend, 1))?;
|
|
166
187
|
native.define_singleton_method("default_backend_kind", function!(default_backend_kind, 0))?;
|
|
188
|
+
native.define_singleton_method("default_backend_info", function!(default_backend_info, 0))?;
|
|
167
189
|
Ok(())
|
|
168
190
|
}
|
|
@@ -60,6 +60,10 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
|
|
|
60
60
|
// unconditionally enables the core's `net` feature (default-features),
|
|
61
61
|
// so this variant is always present.
|
|
62
62
|
NetworkBuilder(_) => "NetworkPolicyError",
|
|
63
|
+
// v0.6.9: `exec_default`/`attach_default` on an image whose resolved
|
|
64
|
+
// ENTRYPOINT+CMD provide no executable command. Mirrors the Python
|
|
65
|
+
// SDK's `NoDefaultCommandError`.
|
|
66
|
+
NoDefaultCommand => "NoDefaultCommandError",
|
|
63
67
|
_ => "Error",
|
|
64
68
|
}
|
|
65
69
|
}
|
|
@@ -18,7 +18,7 @@ use microsandbox::logs::{
|
|
|
18
18
|
LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
|
|
19
19
|
};
|
|
20
20
|
use microsandbox::sandbox::{
|
|
21
|
-
AttachOptionsBuilder, DiskImageFormat, EnvVar, FsEntry, FsEntryKind, FsMetadata,
|
|
21
|
+
AttachOptionsBuilder, DiskImageFormat, EnvVar, FlatClone, FsEntry, FsEntryKind, FsMetadata,
|
|
22
22
|
HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
|
|
23
23
|
RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
|
|
24
24
|
SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
|
|
@@ -128,13 +128,41 @@ impl Sandbox {
|
|
|
128
128
|
for (k, v) in conv::opt_string_map(opts, "scripts")? {
|
|
129
129
|
b = b.script(k, v);
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
// entrypoint/cmd: presence-keyed, NOT non-emptiness-keyed โ an
|
|
132
|
+
// explicitly *empty* array is meaningful for both (it clears the
|
|
133
|
+
// image's ENTRYPOINT / CMD, blocking the image-config merge), exactly
|
|
134
|
+
// like the Python binding. Keying on non-emptiness silently resurrects
|
|
135
|
+
// the image ENTRYPOINT and runs the wrong command under
|
|
136
|
+
// `exec_default`/`attach_default`.
|
|
137
|
+
if let Some(entrypoint) = conv::opt::<Vec<String>>(opts, "entrypoint")? {
|
|
133
138
|
b = b.entrypoint(entrypoint);
|
|
134
139
|
}
|
|
140
|
+
if let Some(cmd) = conv::opt::<Vec<String>>(opts, "cmd")? {
|
|
141
|
+
b = b.cmd(cmd);
|
|
142
|
+
}
|
|
135
143
|
for (host, guest) in conv::opt_port_map(opts, "ports")? {
|
|
136
144
|
b = b.port(host, guest);
|
|
137
145
|
}
|
|
146
|
+
// vsock: host Unix sockets exposed on guest-to-host vsock ports
|
|
147
|
+
// (v0.6.9). Each route is normalized by the Ruby layer to a
|
|
148
|
+
// string-keyed Hash {host_socket:, port:, socket_type: "stream"|"dgram"}.
|
|
149
|
+
for route in conv::opt_hash_vec(opts, "vsock")? {
|
|
150
|
+
let Some(host_socket) = conv::opt_string(route, "host_socket")? else {
|
|
151
|
+
return Err(error::base_error("vsock route requires host_socket:"));
|
|
152
|
+
};
|
|
153
|
+
let Some(port) = conv::opt_u32(route, "port")? else {
|
|
154
|
+
return Err(error::base_error("vsock route requires port:"));
|
|
155
|
+
};
|
|
156
|
+
match conv::opt_string(route, "socket_type")?.as_deref() {
|
|
157
|
+
None | Some("stream") => b = b.vsock(host_socket, port),
|
|
158
|
+
Some("dgram") => b = b.vsock_dgram(host_socket, port),
|
|
159
|
+
Some(other) => {
|
|
160
|
+
return Err(error::base_error(format!(
|
|
161
|
+
"unknown vsock socket_type {other:?} (expected stream/dgram)"
|
|
162
|
+
)))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
138
166
|
// volumes: each mount is normalized by the Ruby layer to a string-keyed
|
|
139
167
|
// Hash โ guest (req), kind ("bind"/"named"/"tmpfs"/"disk"), source
|
|
140
168
|
// (bind/named/disk), size_mib (tmpfs/disk), format + fstype (disk),
|
|
@@ -413,6 +441,28 @@ impl Sandbox {
|
|
|
413
441
|
n
|
|
414
442
|
});
|
|
415
443
|
}
|
|
444
|
+
// rate_limiter: per-sandbox egress/ingress token-bucket limits
|
|
445
|
+
// (v0.6.9). The Ruby layer normalizes the option to a string-keyed
|
|
446
|
+
// Hash {egress:, ingress:} of {bandwidth:, ops:} buckets
|
|
447
|
+
// {size:, refill_time_ms:, one_time_burst:}; mirrors the Python SDK's
|
|
448
|
+
// `NetworkRateLimiter`. Applied via the network builder, accumulating
|
|
449
|
+
// on top of any configuration above.
|
|
450
|
+
if let Some(spec) = conv::opt::<RHash>(opts, "rate_limiter")?
|
|
451
|
+
.map(parse_rate_limiter)
|
|
452
|
+
.transpose()?
|
|
453
|
+
{
|
|
454
|
+
b = b.network(move |n| {
|
|
455
|
+
n.rate_limiter(move |mut r| {
|
|
456
|
+
if let Some(egress) = spec.egress {
|
|
457
|
+
r = r.egress(move |rl| egress.apply(rl));
|
|
458
|
+
}
|
|
459
|
+
if let Some(ingress) = spec.ingress {
|
|
460
|
+
r = r.ingress(move |rl| ingress.apply(rl));
|
|
461
|
+
}
|
|
462
|
+
r
|
|
463
|
+
})
|
|
464
|
+
});
|
|
465
|
+
}
|
|
416
466
|
// init: hand guest PID 1 to an init system. The Ruby layer normalizes
|
|
417
467
|
// `init:` to a Hash { cmd:, args?:, env?: }. `init_with` with empty
|
|
418
468
|
// args/env builds the same HandoffInit as the plain `init(cmd)`, so route
|
|
@@ -560,6 +610,29 @@ impl Sandbox {
|
|
|
560
610
|
Ok(ExecHandle::from_core(handle))
|
|
561
611
|
}
|
|
562
612
|
|
|
613
|
+
/// Run the image's resolved OCI ENTRYPOINT and CMD (the default workload,
|
|
614
|
+
/// v0.6.9) and wait for completion. `create` is strictly boot-only, so this
|
|
615
|
+
/// is how the image's own command is executed. `opts` matches `exec` minus
|
|
616
|
+
/// the command: cwd, user, env, timeout, tty, stdin, rlimits. Raises
|
|
617
|
+
/// NoDefaultCommandError when the image resolves no executable command.
|
|
618
|
+
fn exec_default(&self, opts: RHash) -> Result<RHash, Error> {
|
|
619
|
+
let parsed = ExecOpts::parse(Vec::new(), opts)?;
|
|
620
|
+
let output = block_on(self.inner.exec_default_with(move |b| parsed.apply(b)))
|
|
621
|
+
.map_err(error::to_ruby)?;
|
|
622
|
+
exec_output_to_hash(output)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/// Streaming default-workload execution. Returns an ExecHandle.
|
|
626
|
+
fn exec_default_stream(&self, opts: RHash) -> Result<ExecHandle, Error> {
|
|
627
|
+
let parsed = ExecOpts::parse(Vec::new(), opts)?;
|
|
628
|
+
let handle = block_on(
|
|
629
|
+
self.inner
|
|
630
|
+
.exec_default_stream_with(move |b| parsed.apply(b)),
|
|
631
|
+
)
|
|
632
|
+
.map_err(error::to_ruby)?;
|
|
633
|
+
Ok(ExecHandle::from_core(handle))
|
|
634
|
+
}
|
|
635
|
+
|
|
563
636
|
/// Streaming shell execution.
|
|
564
637
|
fn shell_stream(&self, script: String, opts: RHash) -> Result<ExecHandle, Error> {
|
|
565
638
|
let parsed = ExecOpts::parse(Vec::new(), opts)?;
|
|
@@ -853,6 +926,14 @@ impl Sandbox {
|
|
|
853
926
|
fn attach_shell(&self) -> Result<i32, Error> {
|
|
854
927
|
block_on(self.inner.attach_shell()).map_err(error::to_ruby)
|
|
855
928
|
}
|
|
929
|
+
|
|
930
|
+
/// Attach an interactive terminal to the image's resolved OCI ENTRYPOINT
|
|
931
|
+
/// and CMD (the default workload, v0.6.9); returns its exit code. `opts`:
|
|
932
|
+
/// cwd, user, env, detach_keys, rlimits.
|
|
933
|
+
fn attach_default(&self, opts: RHash) -> Result<i32, Error> {
|
|
934
|
+
let parsed = AttachOpts::parse(Vec::new(), opts)?;
|
|
935
|
+
block_on(self.inner.attach_default_with(move |b| parsed.apply(b))).map_err(error::to_ruby)
|
|
936
|
+
}
|
|
856
937
|
}
|
|
857
938
|
|
|
858
939
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -1268,6 +1349,83 @@ fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
|
|
|
1268
1349
|
})
|
|
1269
1350
|
}
|
|
1270
1351
|
|
|
1352
|
+
/// One token bucket of the `rate_limiter` create option (v0.6.9):
|
|
1353
|
+
/// `(size, refill_time_ms, one_time_burst)`. Size is bytes for bandwidth
|
|
1354
|
+
/// buckets, frames for ops buckets.
|
|
1355
|
+
type TokenBucketSpec = (u64, u64, u64);
|
|
1356
|
+
|
|
1357
|
+
/// One direction of the `rate_limiter` create option.
|
|
1358
|
+
struct RateLimiterSpec {
|
|
1359
|
+
bandwidth: Option<TokenBucketSpec>,
|
|
1360
|
+
ops: Option<TokenBucketSpec>,
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/// Parsed `rate_limiter` create option (v0.6.9, mirrors the Python SDK's
|
|
1364
|
+
/// `NetworkRateLimiter`): per-direction bandwidth/ops token buckets. Parsed up
|
|
1365
|
+
/// front because the network builder closure cannot return an error.
|
|
1366
|
+
struct NetworkRateLimiterSpec {
|
|
1367
|
+
egress: Option<RateLimiterSpec>,
|
|
1368
|
+
ingress: Option<RateLimiterSpec>,
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
fn parse_token_bucket(b: RHash, dir: &str, dim: &str) -> Result<TokenBucketSpec, Error> {
|
|
1372
|
+
let Some(size) = conv::opt::<u64>(b, "size")? else {
|
|
1373
|
+
return Err(error::base_error(format!(
|
|
1374
|
+
"rate_limiter {dir} {dim} bucket requires size:"
|
|
1375
|
+
)));
|
|
1376
|
+
};
|
|
1377
|
+
let Some(refill) = conv::opt::<u64>(b, "refill_time_ms")? else {
|
|
1378
|
+
return Err(error::base_error(format!(
|
|
1379
|
+
"rate_limiter {dir} {dim} bucket requires refill_time_ms:"
|
|
1380
|
+
)));
|
|
1381
|
+
};
|
|
1382
|
+
let burst = conv::opt::<u64>(b, "one_time_burst")?.unwrap_or(0);
|
|
1383
|
+
Ok((size, refill, burst))
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
fn parse_rate_limiter_direction(h: RHash, dir: &str) -> Result<RateLimiterSpec, Error> {
|
|
1387
|
+
Ok(RateLimiterSpec {
|
|
1388
|
+
bandwidth: conv::opt::<RHash>(h, "bandwidth")?
|
|
1389
|
+
.map(|b| parse_token_bucket(b, dir, "bandwidth"))
|
|
1390
|
+
.transpose()?,
|
|
1391
|
+
ops: conv::opt::<RHash>(h, "ops")?
|
|
1392
|
+
.map(|b| parse_token_bucket(b, dir, "ops"))
|
|
1393
|
+
.transpose()?,
|
|
1394
|
+
})
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
fn parse_rate_limiter(h: RHash) -> Result<NetworkRateLimiterSpec, Error> {
|
|
1398
|
+
Ok(NetworkRateLimiterSpec {
|
|
1399
|
+
egress: conv::opt::<RHash>(h, "egress")?
|
|
1400
|
+
.map(|d| parse_rate_limiter_direction(d, "egress"))
|
|
1401
|
+
.transpose()?,
|
|
1402
|
+
ingress: conv::opt::<RHash>(h, "ingress")?
|
|
1403
|
+
.map(|d| parse_rate_limiter_direction(d, "ingress"))
|
|
1404
|
+
.transpose()?,
|
|
1405
|
+
})
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
impl RateLimiterSpec {
|
|
1409
|
+
fn apply(
|
|
1410
|
+
self,
|
|
1411
|
+
mut rl: microsandbox_network::builder::RateLimiterBuilder,
|
|
1412
|
+
) -> microsandbox_network::builder::RateLimiterBuilder {
|
|
1413
|
+
if let Some((size, refill_ms, burst)) = self.bandwidth {
|
|
1414
|
+
rl = rl.bandwidth(size, Duration::from_millis(refill_ms));
|
|
1415
|
+
if burst > 0 {
|
|
1416
|
+
rl = rl.bandwidth_burst(burst);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
if let Some((count, refill_ms, burst)) = self.ops {
|
|
1420
|
+
rl = rl.ops(count, Duration::from_millis(refill_ms));
|
|
1421
|
+
if burst > 0 {
|
|
1422
|
+
rl = rl.ops_burst(burst);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
rl
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1271
1429
|
struct TlsSpec {
|
|
1272
1430
|
bypass: Vec<String>,
|
|
1273
1431
|
verify_upstream: Option<bool>,
|
|
@@ -1409,12 +1567,14 @@ struct RootDiskSpec {
|
|
|
1409
1567
|
size_mib: Option<u32>,
|
|
1410
1568
|
format: Option<DiskImageFormat>,
|
|
1411
1569
|
fstype: Option<String>,
|
|
1570
|
+
clone: Option<FlatClone>,
|
|
1412
1571
|
}
|
|
1413
1572
|
|
|
1414
1573
|
enum RootDiskKindSpec {
|
|
1415
1574
|
Managed,
|
|
1416
1575
|
Tmpfs,
|
|
1417
1576
|
Disk(String),
|
|
1577
|
+
Flat,
|
|
1418
1578
|
}
|
|
1419
1579
|
|
|
1420
1580
|
impl RootDiskSpec {
|
|
@@ -1423,6 +1583,7 @@ impl RootDiskSpec {
|
|
|
1423
1583
|
RootDiskKindSpec::Managed => {}
|
|
1424
1584
|
RootDiskKindSpec::Tmpfs => d = d.tmpfs(),
|
|
1425
1585
|
RootDiskKindSpec::Disk(path) => d = d.disk_image(path),
|
|
1586
|
+
RootDiskKindSpec::Flat => d = d.flat(),
|
|
1426
1587
|
}
|
|
1427
1588
|
if let Some(mib) = self.size_mib {
|
|
1428
1589
|
d = d.size(mib);
|
|
@@ -1433,6 +1594,9 @@ impl RootDiskSpec {
|
|
|
1433
1594
|
if let Some(ft) = self.fstype {
|
|
1434
1595
|
d = d.fstype(ft);
|
|
1435
1596
|
}
|
|
1597
|
+
if let Some(c) = self.clone {
|
|
1598
|
+
d = d.clone_strategy(c);
|
|
1599
|
+
}
|
|
1436
1600
|
d
|
|
1437
1601
|
}
|
|
1438
1602
|
}
|
|
@@ -1444,12 +1608,13 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
|
|
|
1444
1608
|
size_mib: Some(mib),
|
|
1445
1609
|
format: None,
|
|
1446
1610
|
fstype: None,
|
|
1611
|
+
clone: None,
|
|
1447
1612
|
});
|
|
1448
1613
|
}
|
|
1449
1614
|
let Ok(h) = RHash::try_convert(v) else {
|
|
1450
1615
|
return Err(error::base_error(
|
|
1451
1616
|
"root_disk: expects an Integer (managed size in MiB) or a Hash \
|
|
1452
|
-
(use Microsandbox::RootDisk.managed/tmpfs/disk)",
|
|
1617
|
+
(use Microsandbox::RootDisk.managed/tmpfs/disk/flat)",
|
|
1453
1618
|
));
|
|
1454
1619
|
};
|
|
1455
1620
|
let kind = match conv::opt_string(h, "kind")?.as_deref() {
|
|
@@ -1461,20 +1626,35 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
|
|
|
1461
1626
|
};
|
|
1462
1627
|
RootDiskKindSpec::Disk(path)
|
|
1463
1628
|
}
|
|
1629
|
+
Some("flat") => RootDiskKindSpec::Flat,
|
|
1464
1630
|
Some(other) => {
|
|
1465
1631
|
return Err(error::base_error(format!(
|
|
1466
|
-
"unknown root_disk kind {other:?} (expected managed/tmpfs/disk)"
|
|
1632
|
+
"unknown root_disk kind {other:?} (expected managed/tmpfs/disk/flat)"
|
|
1467
1633
|
)))
|
|
1468
1634
|
}
|
|
1469
1635
|
};
|
|
1470
1636
|
let format = conv::opt_string(h, "format")?
|
|
1471
1637
|
.map(|f| disk_format_from_str(&f))
|
|
1472
1638
|
.transpose()?;
|
|
1639
|
+
// clone: flat-only private-disk clone strategy (v0.6.9). Validated here
|
|
1640
|
+
// (unknown values error) but kind cross-validation stays in the core
|
|
1641
|
+
// builder, matching the other fields.
|
|
1642
|
+
let clone = conv::opt_string(h, "clone")?
|
|
1643
|
+
.map(|c| match c.as_str() {
|
|
1644
|
+
"auto" => Ok(FlatClone::Auto),
|
|
1645
|
+
"copy" => Ok(FlatClone::Copy),
|
|
1646
|
+
"reflink" => Ok(FlatClone::Reflink),
|
|
1647
|
+
other => Err(error::base_error(format!(
|
|
1648
|
+
"unknown root_disk clone strategy {other:?} (expected auto/copy/reflink)"
|
|
1649
|
+
))),
|
|
1650
|
+
})
|
|
1651
|
+
.transpose()?;
|
|
1473
1652
|
Ok(RootDiskSpec {
|
|
1474
1653
|
kind,
|
|
1475
1654
|
size_mib: conv::opt_u32(h, "size_mib")?,
|
|
1476
1655
|
format,
|
|
1477
1656
|
fstype: conv::opt_string(h, "fstype")?,
|
|
1657
|
+
clone,
|
|
1478
1658
|
})
|
|
1479
1659
|
}
|
|
1480
1660
|
|
|
@@ -1969,9 +2149,9 @@ fn run_modify(builder: SandboxModificationBuilder, opts: RHash) -> Result<String
|
|
|
1969
2149
|
|
|
1970
2150
|
/// Build the canonical `SandboxModificationPatch` from the modify Hash. Env and
|
|
1971
2151
|
/// label pairs are sorted so repeated calls with the same arguments produce the
|
|
1972
|
-
/// same patch (and plan) ordering, mirroring the Python binding.
|
|
1973
|
-
///
|
|
1974
|
-
///
|
|
2152
|
+
/// same patch (and plan) ordering, mirroring the Python binding. As of v0.6.9
|
|
2153
|
+
/// every patch field is surfaced (`root_disk_size` covers what the deprecated
|
|
2154
|
+
/// CLI-only `oci_upper_size` used to mean).
|
|
1975
2155
|
fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
|
|
1976
2156
|
let mut env_pairs = conv::opt_string_map(opts, "env")?;
|
|
1977
2157
|
env_pairs.sort();
|
|
@@ -1983,6 +2163,10 @@ fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
|
|
|
1983
2163
|
max_cpus: conv::opt_u8(opts, "max_cpus")?,
|
|
1984
2164
|
memory_mib: conv::opt_u32(opts, "memory")?,
|
|
1985
2165
|
max_memory_mib: conv::opt_u32(opts, "max_memory")?,
|
|
2166
|
+
// v0.6.9 ("root disk resizing in every SDK"): grow the sandbox-owned
|
|
2167
|
+
// layered upper or flat root disk. Restart/next-start semantics and
|
|
2168
|
+
// backing-specific limits are enforced by the core.
|
|
2169
|
+
root_disk_size_mib: conv::opt_u32(opts, "root_disk_size")?,
|
|
1986
2170
|
env: env_pairs
|
|
1987
2171
|
.into_iter()
|
|
1988
2172
|
.map(|(k, v)| EnvVar::new(k, v))
|
|
@@ -1993,7 +2177,6 @@ fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
|
|
|
1993
2177
|
workdir: conv::opt_string(opts, "workdir")?,
|
|
1994
2178
|
secrets: parse_modify_secrets(opts)?,
|
|
1995
2179
|
secrets_remove: conv::opt_string_vec(opts, "remove_secrets")?,
|
|
1996
|
-
..Default::default()
|
|
1997
2180
|
})
|
|
1998
2181
|
}
|
|
1999
2182
|
|
|
@@ -2422,6 +2605,11 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
2422
2605
|
class.define_method("shell", method!(Sandbox::shell, 2))?;
|
|
2423
2606
|
class.define_method("exec_stream", method!(Sandbox::exec_stream, 3))?;
|
|
2424
2607
|
class.define_method("shell_stream", method!(Sandbox::shell_stream, 2))?;
|
|
2608
|
+
class.define_method("exec_default", method!(Sandbox::exec_default, 1))?;
|
|
2609
|
+
class.define_method(
|
|
2610
|
+
"exec_default_stream",
|
|
2611
|
+
method!(Sandbox::exec_default_stream, 1),
|
|
2612
|
+
)?;
|
|
2425
2613
|
class.define_method("stop", method!(Sandbox::stop, 0))?;
|
|
2426
2614
|
class.define_method("stop_and_wait", method!(Sandbox::stop_and_wait, 0))?;
|
|
2427
2615
|
class.define_method("kill", method!(Sandbox::kill, 0))?;
|
|
@@ -2461,6 +2649,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
2461
2649
|
)?;
|
|
2462
2650
|
|
|
2463
2651
|
class.define_method("attach", method!(Sandbox::attach, 3))?;
|
|
2652
|
+
class.define_method("attach_default", method!(Sandbox::attach_default, 1))?;
|
|
2464
2653
|
class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
|
|
2465
2654
|
|
|
2466
2655
|
let handle = native.define_class("SandboxHandle", ruby.class_object())?;
|
|
@@ -171,9 +171,10 @@ fn remove(name_or_path: String, force: bool) -> Result<(), Error> {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/// Verify a snapshot's recorded upper-layer integrity. Returns
|
|
174
|
-
/// {digest, path, upper_status, upper_algorithm, upper_digest}.
|
|
175
|
-
///
|
|
176
|
-
///
|
|
174
|
+
/// {digest, path, upper_status, upper_algorithm, upper_digest}. As of v0.6.9
|
|
175
|
+
/// payload integrity is opt-in at create time: opted-in snapshots verify to
|
|
176
|
+
/// "verified" (mismatches raise SnapshotIntegrityError), snapshots without
|
|
177
|
+
/// recorded integrity report "not_recorded" with no algorithm/digest keys.
|
|
177
178
|
fn verify(name_or_path: String) -> Result<RHash, Error> {
|
|
178
179
|
let snap = block_on(Snapshot::open(&name_or_path)).map_err(error::to_ruby)?;
|
|
179
180
|
let report = block_on(snap.verify()).map_err(error::to_ruby)?;
|
|
@@ -248,6 +249,12 @@ fn verify_report_to_hash(report: &SnapshotVerifyReport) -> RHash {
|
|
|
248
249
|
let _ = hash.aset("upper_algorithm", algorithm.clone());
|
|
249
250
|
let _ = hash.aset("upper_digest", digest.clone());
|
|
250
251
|
}
|
|
252
|
+
// v0.6.9 (#1346): payload integrity is opt-in at create time
|
|
253
|
+
// (`record_integrity`); a snapshot without it verifies structurally
|
|
254
|
+
// but has no content digest to check.
|
|
255
|
+
UpperVerifyStatus::NotRecorded => {
|
|
256
|
+
let _ = hash.aset("upper_status", "not_recorded");
|
|
257
|
+
}
|
|
251
258
|
}
|
|
252
259
|
hash
|
|
253
260
|
}
|
|
@@ -18,6 +18,7 @@ fn handle_to_hash(h: &VolumeHandle) -> RHash {
|
|
|
18
18
|
let hash = ruby().hash_new();
|
|
19
19
|
let _ = hash.aset("name", h.name().to_string());
|
|
20
20
|
let _ = hash.aset("kind", h.kind().as_str().to_string());
|
|
21
|
+
let _ = hash.aset("default", h.is_default());
|
|
21
22
|
let _ = hash.aset("quota_mib", h.quota_mib());
|
|
22
23
|
let _ = hash.aset("used_bytes", h.used_bytes());
|
|
23
24
|
let _ = hash.aset("capacity_bytes", h.capacity_bytes());
|
|
@@ -83,6 +84,13 @@ fn get(name: String) -> Result<RHash, Error> {
|
|
|
83
84
|
Ok(handle_to_hash(&handle))
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/// The backend's default volume (v0.6.9). Cloud-backend only โ the local
|
|
88
|
+
/// backend rejects it (Unsupported) to avoid accidental host access.
|
|
89
|
+
fn get_default() -> Result<RHash, Error> {
|
|
90
|
+
let handle = block_on(microsandbox::Volume::get_default()).map_err(error::to_ruby)?;
|
|
91
|
+
Ok(handle_to_hash(&handle))
|
|
92
|
+
}
|
|
93
|
+
|
|
86
94
|
fn list() -> Result<RArray, Error> {
|
|
87
95
|
let handles = block_on(microsandbox::Volume::list()).map_err(error::to_ruby)?;
|
|
88
96
|
let arr = ruby().ary_new();
|
|
@@ -196,6 +204,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
196
204
|
let class = native.define_class("Volume", ruby.class_object())?;
|
|
197
205
|
class.define_singleton_method("create", function!(create, 2))?;
|
|
198
206
|
class.define_singleton_method("get", function!(get, 1))?;
|
|
207
|
+
class.define_singleton_method("get_default", function!(get_default, 0))?;
|
|
199
208
|
class.define_singleton_method("list", function!(list, 0))?;
|
|
200
209
|
class.define_singleton_method("remove", function!(remove, 1))?;
|
|
201
210
|
class.define_singleton_method("fs", function!(fs, 1))?;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# Secret-safe description of the active default backend, from
|
|
5
|
+
# {Microsandbox.default_backend_info} (runtime v0.6.9). Describes what
|
|
6
|
+
# selected the backend and where it points โ never its credential: the API
|
|
7
|
+
# key is deliberately absent.
|
|
8
|
+
class BackendInfo
|
|
9
|
+
# @return [String, nil] effective cloud API endpoint (nil for local)
|
|
10
|
+
attr_reader :api_url
|
|
11
|
+
# @return [String, nil] the selected profile name, when a profile chose
|
|
12
|
+
# the backend
|
|
13
|
+
attr_reader :profile
|
|
14
|
+
|
|
15
|
+
def initialize(data)
|
|
16
|
+
@kind = data["kind"]
|
|
17
|
+
@api_url = data["api_url"]
|
|
18
|
+
@source = data["source"]
|
|
19
|
+
@profile = data["profile"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Symbol] :local or :cloud
|
|
23
|
+
def kind
|
|
24
|
+
@kind.to_sym
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# What selected this backend: `:programmatic` (an SDK setter),
|
|
28
|
+
# `:MSB_BACKEND` / `:MSB_API_KEY` / `:MSB_PROFILE` (environment),
|
|
29
|
+
# `:profile` (an explicit SDK profile constructor), `:active_profile`
|
|
30
|
+
# (the SDK config file), or `:default` (the final local fallback).
|
|
31
|
+
# @return [Symbol]
|
|
32
|
+
def source
|
|
33
|
+
@source.to_sym
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def local? = kind == :local
|
|
37
|
+
|
|
38
|
+
def cloud? = kind == :cloud
|
|
39
|
+
|
|
40
|
+
def inspect
|
|
41
|
+
"#<Microsandbox::BackendInfo kind=#{@kind} source=#{@source.inspect}" \
|
|
42
|
+
"#{" api_url=#{@api_url.inspect}" if @api_url}" \
|
|
43
|
+
"#{" profile=#{@profile.inspect}" if @profile}>"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/microsandbox/errors.rb
CHANGED
|
@@ -39,6 +39,10 @@ module Microsandbox
|
|
|
39
39
|
# Execution errors --------------------------------------------------------
|
|
40
40
|
define_error(:ExecTimeoutError, "exec-timeout")
|
|
41
41
|
define_error(:ExecFailedError, "exec-failed")
|
|
42
|
+
# v0.6.9: `exec_default`/`attach_default` on an image whose resolved
|
|
43
|
+
# ENTRYPOINT+CMD provide no executable command. Mirrors the Python SDK's
|
|
44
|
+
# NoDefaultCommandError.
|
|
45
|
+
define_error(:NoDefaultCommandError, "no-default-command")
|
|
42
46
|
|
|
43
47
|
# Filesystem errors -------------------------------------------------------
|
|
44
48
|
define_error(:FilesystemError, "filesystem-error")
|
|
@@ -13,12 +13,18 @@ module Microsandbox
|
|
|
13
13
|
# - {disk} โ a user-supplied disk image attached writable as the upper. The
|
|
14
14
|
# file determines its own size; the runtime never creates, resizes, or
|
|
15
15
|
# deletes it. Cannot be snapshotted or patched.
|
|
16
|
+
# - {flat} โ a single complete ext4 root disk materialized directly from the
|
|
17
|
+
# OCI image (runtime v0.6.9), skipping the layered EROFS+OverlayFS stack at
|
|
18
|
+
# runtime. Content-addressed and cached across sandboxes; resizable.
|
|
19
|
+
# Pre-materialize with `msb pull IMAGE --materialize flat`.
|
|
16
20
|
#
|
|
17
21
|
# @example
|
|
18
22
|
# Sandbox.create("worker", image: "python", root_disk: 8192)
|
|
19
23
|
# Sandbox.create("ci", image: "python", root_disk: Microsandbox::RootDisk.tmpfs(2048))
|
|
20
24
|
# Sandbox.create("warm", image: "python",
|
|
21
25
|
# root_disk: Microsandbox::RootDisk.disk("./scratch.img", fstype: "ext4"))
|
|
26
|
+
# Sandbox.create("fast", image: "python",
|
|
27
|
+
# root_disk: Microsandbox::RootDisk.flat(8192, clone: :reflink))
|
|
22
28
|
#
|
|
23
29
|
# Mirrors the `RootDisk` factory in the official Python/Node/Go SDKs.
|
|
24
30
|
module RootDisk
|
|
@@ -54,5 +60,23 @@ module Microsandbox
|
|
|
54
60
|
h["fstype"] = fstype.to_s if fstype
|
|
55
61
|
h
|
|
56
62
|
end
|
|
63
|
+
|
|
64
|
+
# A complete, microsandbox-owned root disk materialized from the OCI image
|
|
65
|
+
# (runtime v0.6.9).
|
|
66
|
+
# @param size_mib [Integer, nil] resizable ext4 size in MiB
|
|
67
|
+
# @param fstype [String, nil] generated filesystem type (default "ext4")
|
|
68
|
+
# @param clone [Symbol, String, nil] how each sandbox's private disk is
|
|
69
|
+
# created from the cached artifact: `:auto` (CoW clone when the host
|
|
70
|
+
# filesystem supports it, else a sparse copy โ the default), `:copy`
|
|
71
|
+
# (always an independent sparse copy), or `:reflink` (require a CoW
|
|
72
|
+
# clone; fail where unsupported)
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def flat(size_mib = nil, fstype: nil, clone: nil)
|
|
75
|
+
h = {"kind" => "flat"}
|
|
76
|
+
h["size_mib"] = Integer(size_mib) if size_mib
|
|
77
|
+
h["fstype"] = fstype.to_s if fstype
|
|
78
|
+
h["clone"] = clone.to_s if clone
|
|
79
|
+
h
|
|
80
|
+
end
|
|
57
81
|
end
|
|
58
82
|
end
|