microsandbox-rb 0.12.0 → 0.14.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,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
- 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
@@ -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. `oci_upper_size`
1973
- /// is deliberately not surfaced (CLI-only upstream; the Python/Node SDKs omit it
1974
- /// 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).
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}. 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
  }
@@ -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
@@ -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