microsandbox-rb 0.11.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.
@@ -18,11 +18,11 @@ 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
- RootDiskBuilder, SandboxBuilder, SandboxFilter, SandboxHandle, SandboxMetrics,
24
- SandboxModificationBuilder, SandboxModificationPatch, SandboxStatus, SandboxStopResult,
25
- SecretBuilder, SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
23
+ RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
24
+ SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
25
+ SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
26
26
  };
27
27
  use microsandbox::LogLevel;
28
28
  use microsandbox::MicrosandboxResult;
@@ -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
- let entrypoint = conv::opt_string_vec(opts, "entrypoint")?;
132
- if !entrypoint.is_empty() {
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
@@ -477,29 +527,44 @@ impl Sandbox {
477
527
  Ok(SbHandle::from_inner(handle))
478
528
  }
479
529
 
480
- /// All sandboxes as controllable handles.
481
- fn list() -> Result<RArray, Error> {
482
- let handles = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
483
- let arr = ruby().ary_new();
484
- for h in handles {
485
- arr.push(SbHandle::from_inner(h))?;
486
- }
487
- Ok(arr)
488
- }
489
-
490
- /// Sandboxes filtered by required `key=value` labels (AND-matched), as
491
- /// controllable handles. `opts` carries a string→string `labels` map.
492
- fn list_with(opts: RHash) -> Result<RArray, Error> {
493
- let mut filter = SandboxFilter::new();
494
- for (k, v) in conv::opt_string_map(opts, "labels")? {
495
- filter = filter.label(k, v);
496
- }
497
- let handles = block_on(microsandbox::Sandbox::list_with(filter)).map_err(error::to_ruby)?;
530
+ /// Convert one `SandboxPage` into a `{ "sandboxes" => [SbHandle], "next_cursor" => String|nil }`
531
+ /// Ruby Hash (v0.6.8 paginated listing contract).
532
+ fn page_to_hash(page: microsandbox::sandbox::SandboxPage) -> Result<RHash, Error> {
498
533
  let arr = ruby().ary_new();
499
- for h in handles {
534
+ for h in page.sandboxes {
500
535
  arr.push(SbHandle::from_inner(h))?;
501
536
  }
502
- Ok(arr)
537
+ let hash = ruby().hash_new();
538
+ hash.aset("sandboxes", arr)?;
539
+ hash.aset("next_cursor", page.next_cursor)?;
540
+ Ok(hash)
541
+ }
542
+
543
+ /// First page of sandboxes (default page size), as a page Hash of
544
+ /// controllable handles.
545
+ fn list() -> Result<RHash, Error> {
546
+ let page = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
547
+ Self::page_to_hash(page)
548
+ }
549
+
550
+ /// One configured page of sandboxes as a page Hash. `opts` carries an
551
+ /// optional string→string `labels` map (AND-matched), an optional `limit`
552
+ /// (1..=100) and an optional opaque `cursor` from a previous page.
553
+ fn list_with(opts: RHash) -> Result<RHash, Error> {
554
+ let labels = conv::opt_string_map(opts, "labels")?;
555
+ let limit = conv::opt_u32(opts, "limit")?;
556
+ let cursor = conv::opt_string(opts, "cursor")?;
557
+ let page = block_on(microsandbox::Sandbox::list_with(move |mut b| {
558
+ if let Some(limit) = limit {
559
+ b = b.limit(limit);
560
+ }
561
+ if let Some(cursor) = cursor {
562
+ b = b.cursor(cursor);
563
+ }
564
+ b.labels(labels)
565
+ }))
566
+ .map_err(error::to_ruby)?;
567
+ Self::page_to_hash(page)
503
568
  }
504
569
 
505
570
  /// Remove a (stopped) sandbox by name.
@@ -545,6 +610,29 @@ impl Sandbox {
545
610
  Ok(ExecHandle::from_core(handle))
546
611
  }
547
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
+
548
636
  /// Streaming shell execution.
549
637
  fn shell_stream(&self, script: String, opts: RHash) -> Result<ExecHandle, Error> {
550
638
  let parsed = ExecOpts::parse(Vec::new(), opts)?;
@@ -838,6 +926,14 @@ impl Sandbox {
838
926
  fn attach_shell(&self) -> Result<i32, Error> {
839
927
  block_on(self.inner.attach_shell()).map_err(error::to_ruby)
840
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
+ }
841
937
  }
842
938
 
843
939
  //--------------------------------------------------------------------------------------------------
@@ -1253,6 +1349,83 @@ fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
1253
1349
  })
1254
1350
  }
1255
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
+
1256
1429
  struct TlsSpec {
1257
1430
  bypass: Vec<String>,
1258
1431
  verify_upstream: Option<bool>,
@@ -1394,12 +1567,14 @@ struct RootDiskSpec {
1394
1567
  size_mib: Option<u32>,
1395
1568
  format: Option<DiskImageFormat>,
1396
1569
  fstype: Option<String>,
1570
+ clone: Option<FlatClone>,
1397
1571
  }
1398
1572
 
1399
1573
  enum RootDiskKindSpec {
1400
1574
  Managed,
1401
1575
  Tmpfs,
1402
1576
  Disk(String),
1577
+ Flat,
1403
1578
  }
1404
1579
 
1405
1580
  impl RootDiskSpec {
@@ -1408,6 +1583,7 @@ impl RootDiskSpec {
1408
1583
  RootDiskKindSpec::Managed => {}
1409
1584
  RootDiskKindSpec::Tmpfs => d = d.tmpfs(),
1410
1585
  RootDiskKindSpec::Disk(path) => d = d.disk_image(path),
1586
+ RootDiskKindSpec::Flat => d = d.flat(),
1411
1587
  }
1412
1588
  if let Some(mib) = self.size_mib {
1413
1589
  d = d.size(mib);
@@ -1418,6 +1594,9 @@ impl RootDiskSpec {
1418
1594
  if let Some(ft) = self.fstype {
1419
1595
  d = d.fstype(ft);
1420
1596
  }
1597
+ if let Some(c) = self.clone {
1598
+ d = d.clone_strategy(c);
1599
+ }
1421
1600
  d
1422
1601
  }
1423
1602
  }
@@ -1429,12 +1608,13 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1429
1608
  size_mib: Some(mib),
1430
1609
  format: None,
1431
1610
  fstype: None,
1611
+ clone: None,
1432
1612
  });
1433
1613
  }
1434
1614
  let Ok(h) = RHash::try_convert(v) else {
1435
1615
  return Err(error::base_error(
1436
1616
  "root_disk: expects an Integer (managed size in MiB) or a Hash \
1437
- (use Microsandbox::RootDisk.managed/tmpfs/disk)",
1617
+ (use Microsandbox::RootDisk.managed/tmpfs/disk/flat)",
1438
1618
  ));
1439
1619
  };
1440
1620
  let kind = match conv::opt_string(h, "kind")?.as_deref() {
@@ -1446,20 +1626,35 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1446
1626
  };
1447
1627
  RootDiskKindSpec::Disk(path)
1448
1628
  }
1629
+ Some("flat") => RootDiskKindSpec::Flat,
1449
1630
  Some(other) => {
1450
1631
  return Err(error::base_error(format!(
1451
- "unknown root_disk kind {other:?} (expected managed/tmpfs/disk)"
1632
+ "unknown root_disk kind {other:?} (expected managed/tmpfs/disk/flat)"
1452
1633
  )))
1453
1634
  }
1454
1635
  };
1455
1636
  let format = conv::opt_string(h, "format")?
1456
1637
  .map(|f| disk_format_from_str(&f))
1457
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()?;
1458
1652
  Ok(RootDiskSpec {
1459
1653
  kind,
1460
1654
  size_mib: conv::opt_u32(h, "size_mib")?,
1461
1655
  format,
1462
1656
  fstype: conv::opt_string(h, "fstype")?,
1657
+ clone,
1463
1658
  })
1464
1659
  }
1465
1660
 
@@ -1954,9 +2149,9 @@ fn run_modify(builder: SandboxModificationBuilder, opts: RHash) -> Result<String
1954
2149
 
1955
2150
  /// Build the canonical `SandboxModificationPatch` from the modify Hash. Env and
1956
2151
  /// label pairs are sorted so repeated calls with the same arguments produce the
1957
- /// same patch (and plan) ordering, mirroring the Python binding. `oci_upper_size`
1958
- /// is deliberately not surfaced (CLI-only upstream; the Python/Node SDKs omit it
1959
- /// too), so it stays unset via `..Default::default()`.
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).
1960
2155
  fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
1961
2156
  let mut env_pairs = conv::opt_string_map(opts, "env")?;
1962
2157
  env_pairs.sort();
@@ -1968,6 +2163,10 @@ fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
1968
2163
  max_cpus: conv::opt_u8(opts, "max_cpus")?,
1969
2164
  memory_mib: conv::opt_u32(opts, "memory")?,
1970
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")?,
1971
2170
  env: env_pairs
1972
2171
  .into_iter()
1973
2172
  .map(|(k, v)| EnvVar::new(k, v))
@@ -1978,7 +2177,6 @@ fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
1978
2177
  workdir: conv::opt_string(opts, "workdir")?,
1979
2178
  secrets: parse_modify_secrets(opts)?,
1980
2179
  secrets_remove: conv::opt_string_vec(opts, "remove_secrets")?,
1981
- ..Default::default()
1982
2180
  })
1983
2181
  }
1984
2182
 
@@ -2407,6 +2605,11 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2407
2605
  class.define_method("shell", method!(Sandbox::shell, 2))?;
2408
2606
  class.define_method("exec_stream", method!(Sandbox::exec_stream, 3))?;
2409
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
+ )?;
2410
2613
  class.define_method("stop", method!(Sandbox::stop, 0))?;
2411
2614
  class.define_method("stop_and_wait", method!(Sandbox::stop_and_wait, 0))?;
2412
2615
  class.define_method("kill", method!(Sandbox::kill, 0))?;
@@ -2446,6 +2649,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2446
2649
  )?;
2447
2650
 
2448
2651
  class.define_method("attach", method!(Sandbox::attach, 3))?;
2652
+ class.define_method("attach_default", method!(Sandbox::attach_default, 1))?;
2449
2653
  class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
2450
2654
 
2451
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}. Schema-1
175
- /// descriptors always record integrity, so the status is always "verified"
176
- /// on success (mismatches raise SnapshotIntegrityError instead).
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
  }
@@ -9,7 +9,6 @@ use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RStrin
9
9
  use microsandbox::volume::VolumeHandle;
10
10
  use microsandbox::Backend;
11
11
 
12
- use crate::backend::local_backend;
13
12
  use crate::conv;
14
13
  use crate::error;
15
14
  use crate::runtime::{block_on, ruby};
@@ -19,6 +18,7 @@ fn handle_to_hash(h: &VolumeHandle) -> RHash {
19
18
  let hash = ruby().hash_new();
20
19
  let _ = hash.aset("name", h.name().to_string());
21
20
  let _ = hash.aset("kind", h.kind().as_str().to_string());
21
+ let _ = hash.aset("default", h.is_default());
22
22
  let _ = hash.aset("quota_mib", h.quota_mib());
23
23
  let _ = hash.aset("used_bytes", h.used_bytes());
24
24
  let _ = hash.aset("capacity_bytes", h.capacity_bytes());
@@ -84,6 +84,13 @@ fn get(name: String) -> Result<RHash, Error> {
84
84
  Ok(handle_to_hash(&handle))
85
85
  }
86
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
+
87
94
  fn list() -> Result<RArray, Error> {
88
95
  let handles = block_on(microsandbox::Volume::list()).map_err(error::to_ruby)?;
89
96
  let arr = ruby().ary_new();
@@ -108,10 +115,13 @@ pub struct VolumeFs {
108
115
  }
109
116
 
110
117
  impl VolumeFs {
111
- /// Resolve the (local) backend once and bind it to `name`.
118
+ /// Resolve the ambient backend once and bind it to `name`. As of v0.6.8
119
+ /// every `VolumeFs` op dispatches through the backend's `VolumeBackend`
120
+ /// trait, which yields precise per-operation `Unsupported` errors on
121
+ /// backends that can't serve it — no local-only downcast needed here.
112
122
  fn for_volume(name: String) -> Result<VolumeFs, Error> {
113
123
  Ok(VolumeFs {
114
- backend: local_backend().map_err(error::to_ruby)?,
124
+ backend: microsandbox::default_backend(),
115
125
  name,
116
126
  })
117
127
  }
@@ -194,6 +204,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
194
204
  let class = native.define_class("Volume", ruby.class_object())?;
195
205
  class.define_singleton_method("create", function!(create, 2))?;
196
206
  class.define_singleton_method("get", function!(get, 1))?;
207
+ class.define_singleton_method("get_default", function!(get_default, 0))?;
197
208
  class.define_singleton_method("list", function!(list, 0))?;
198
209
  class.define_singleton_method("remove", function!(remove, 1))?;
199
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
@@ -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")
@@ -90,4 +94,16 @@ module Microsandbox
90
94
  # cloud backend) is distinct from `UnsupportedOperationError` above.
91
95
  define_error(:CloudHttpError, "cloud-http")
92
96
  define_error(:UnsupportedError, "unsupported")
97
+
98
+ # As of runtime v0.6.8 the core keys Unsupported errors by a structured
99
+ # (operation, reason) pair. The native layer renders both into the message
100
+ # ("sandbox.kill is not supported by this backend: ...") and also attaches
101
+ # them here as structured attributes, mirroring the Python SDK's
102
+ # `UnsupportedError.operation` / `.hint`.
103
+ class UnsupportedError
104
+ # @return [String, nil] the rejected API in Ruby rendering, e.g. "sandbox.kill"
105
+ attr_reader :operation
106
+ # @return [String, nil] why it was rejected / what to use instead
107
+ attr_reader :hint
108
+ end
93
109
  end
@@ -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