microsandbox-rb 0.15.0 → 0.16.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, FlatClone, FsEntry, FsEntryKind, FsMetadata,
22
- HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
23
- RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
24
- SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
25
- SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
21
+ AttachOptionsBuilder, DestroyOptions, DiskImageFormat, EnvVar, FlatClone, FsEntry, FsEntryKind,
22
+ FsMetadata, HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle,
23
+ RestartOptions, RlimitResource, RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics,
24
+ SandboxModificationBuilder, SandboxModificationPatch, SandboxStatus, SandboxStopResult,
25
+ SecretBuilder, SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
26
26
  };
27
27
  use microsandbox::LogLevel;
28
28
  use microsandbox::MicrosandboxResult;
@@ -167,7 +167,8 @@ impl Sandbox {
167
167
  // Hash — guest (req), kind ("bind"/"named"/"tmpfs"/"disk"), source
168
168
  // (bind/named/disk), size_mib (tmpfs/disk), format + fstype (disk),
169
169
  // readonly/noexec/nosuid/nodev (bool), stat_virtualization,
170
- // host_permissions. Enum-valued options are validated up front (the
170
+ // host_permissions, override_uid/override_gid (u32 pair).
171
+ // Enum-valued options are validated up front (the
171
172
  // volume closure can't return an error); the core validates the rest
172
173
  // (e.g. rejecting stat_virtualization on tmpfs/disk) at create().
173
174
  for m in conv::opt_hash_vec(opts, "volumes")? {
@@ -195,6 +196,14 @@ impl Sandbox {
195
196
  let host_perms = conv::opt_string(m, "host_permissions")?
196
197
  .map(|s| host_permissions_from_str(&s))
197
198
  .transpose()?;
199
+ // v0.6.15: the fallback guest owner presented for host files that
200
+ // carry no per-file stat override. The Ruby layer validates the pair
201
+ // (both-or-neither, u32 range, bind/named only, not with
202
+ // stat_virtualization=off); whether a *named* volume is directory-
203
+ // or disk-backed is known only to the core, which rejects the
204
+ // disk-backed case at build().
205
+ let override_uid = conv::opt_u32(m, "override_uid")?;
206
+ let override_gid = conv::opt_u32(m, "override_gid")?;
198
207
  // v0.6.7: mount-root symlink protection is on by default; this is
199
208
  // the per-mount opt-out. Valid for bind/named-directory mounts —
200
209
  // the core rejects it elsewhere at build().
@@ -251,6 +260,9 @@ impl Sandbox {
251
260
  if let Some(hp) = host_perms {
252
261
  mb = mb.host_permissions(hp);
253
262
  }
263
+ if let (Some(uid), Some(gid)) = (override_uid, override_gid) {
264
+ mb = mb.owner(uid, gid);
265
+ }
254
266
  if let Some(follow) = follow_root_symlinks {
255
267
  mb = mb.follow_root_symlinks(follow);
256
268
  }
@@ -509,6 +521,16 @@ impl Sandbox {
509
521
  Ok(PullSession::new(handle, join))
510
522
  }
511
523
 
524
+ /// Converge on a live sandbox with this name: connect to (or start) the
525
+ /// persisted one when it already exists, otherwise create it from `opts`.
526
+ /// Concurrent callers converge on the winning identity. The core rejects
527
+ /// the combination with `replace`.
528
+ fn connect_or_create(name: String, opts: RHash) -> Result<Sandbox, Error> {
529
+ let b = Self::build_builder(name, opts)?;
530
+ let inner = block_on(b.connect_or_create()).map_err(error::to_ruby)?;
531
+ Ok(Sandbox::from_inner(inner))
532
+ }
533
+
512
534
  /// Restart a previously-defined sandbox by name.
513
535
  fn start(name: String, opts: RHash) -> Result<Sandbox, Error> {
514
536
  let detached = conv::opt_bool(opts, "detached")?;
@@ -580,6 +602,13 @@ impl Sandbox {
580
602
  self.inner.name().to_string()
581
603
  }
582
604
 
605
+ /// The opaque, backend-assigned identity of this persisted sandbox
606
+ /// (v0.6.16). Stable for the sandbox's lifetime, and different once the
607
+ /// same name is removed and recreated.
608
+ fn id(&self) -> String {
609
+ self.inner.id().to_string()
610
+ }
611
+
583
612
  /// Run a command (no shell). `args` is an Array of strings; `opts` is a
584
613
  /// string-keyed Hash (cwd, user, env, timeout, tty, stdin).
585
614
  fn exec(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<RHash, Error> {
@@ -644,17 +673,23 @@ impl Sandbox {
644
673
  Ok(ExecHandle::from_core(handle))
645
674
  }
646
675
 
647
- /// Graceful stop. Mirrors the official SDKs: the live handle routes through
648
- /// a freshly fetched `SandboxHandle::stop` (SIGTERM→SIGKILL escalation with
649
- /// a 10s default). Fine-grained control — a custom timeout or fire-and-
650
- /// return `request_*` — lives on `SandboxHandle`, obtained via `Sandbox.get`.
676
+ /// Graceful stop (SIGTERM→SIGKILL escalation with a 10s default), routed
677
+ /// straight through the live sandbox like every other lifecycle method here
678
+ /// and like both official bindings (`sdk/python/src/sandbox.rs`,
679
+ /// `sdk/ruby/ext/.../lib.rs`).
680
+ ///
681
+ /// This deliberately does NOT re-fetch a `SandboxHandle` by name first. It
682
+ /// used to, back when the core's live `stop` was not identity-scoped; as of
683
+ /// v0.6.16 the core routes `stop` → `request_stop` → `stop_identified(name,
684
+ /// self.identity())`, so going through `self.inner` is what makes the stop
685
+ /// refuse a same-name replacement (`SandboxReplacedError`) instead of
686
+ /// terminating whatever sandbox happens to own the name right now. The old
687
+ /// by-name refetch threw that identity away.
688
+ ///
689
+ /// Fine-grained control — a custom timeout or fire-and-return `request_*` —
690
+ /// lives on `SandboxHandle`, obtained via `Sandbox.get`.
651
691
  fn stop(&self) -> Result<(), Error> {
652
- let name = self.inner.name().to_string();
653
- block_on(async move {
654
- let handle = microsandbox::sandbox::Sandbox::get(&name).await?;
655
- handle.stop().await
656
- })
657
- .map_err(error::to_ruby)
692
+ block_on(self.inner.stop()).map_err(error::to_ruby)
658
693
  }
659
694
 
660
695
  /// Graceful stop, then wait for the process to exit. Returns an exit-status
@@ -680,6 +715,29 @@ impl Sandbox {
680
715
  Ok(exit_status_to_hash(status))
681
716
  }
682
717
 
718
+ /// Block until this exact sandbox reaches `status` (no built-in timeout),
719
+ /// returning a fresh handle. A same-name replacement raises
720
+ /// `SandboxReplacedError` instead of silently redirecting the wait.
721
+ fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
722
+ let status = sandbox_status_from_str(&status)?;
723
+ let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
724
+ Ok(SbHandle::from_inner(handle))
725
+ }
726
+
727
+ /// Stop and start this exact sandbox, returning the new live sandbox.
728
+ /// `opts` carries `force`/`timeout`/`detached`.
729
+ fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
730
+ let options = restart_options(opts)?;
731
+ let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
732
+ Ok(Sandbox::from_inner(inner))
733
+ }
734
+
735
+ /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
736
+ fn destroy(&self, opts: RHash) -> Result<(), Error> {
737
+ let options = destroy_options(opts)?;
738
+ block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
739
+ }
740
+
683
741
  /// Live status fetched from the backend (a round-trip per call).
684
742
  fn status(&self) -> Result<String, Error> {
685
743
  let status = block_on(self.inner.status()).map_err(error::to_ruby)?;
@@ -2096,6 +2154,55 @@ fn sandbox_status_str(status: SandboxStatus) -> &'static str {
2096
2154
  }
2097
2155
  }
2098
2156
 
2157
+ /// Parse a Ruby-facing status name back into a core `SandboxStatus`. The Ruby
2158
+ /// layer already validates the seven names (raising `ArgumentError`, the repo's
2159
+ /// idiom for a bad argument value), so reaching the fallback here means the
2160
+ /// native layer was called directly; keep it exhaustive rather than silent.
2161
+ fn sandbox_status_from_str(status: &str) -> Result<SandboxStatus, Error> {
2162
+ match status {
2163
+ "created" => Ok(SandboxStatus::Created),
2164
+ "starting" => Ok(SandboxStatus::Starting),
2165
+ "running" => Ok(SandboxStatus::Running),
2166
+ "draining" => Ok(SandboxStatus::Draining),
2167
+ "paused" => Ok(SandboxStatus::Paused),
2168
+ "stopped" => Ok(SandboxStatus::Stopped),
2169
+ "crashed" => Ok(SandboxStatus::Crashed),
2170
+ other => Err(error::base_error(format!(
2171
+ "unknown sandbox status {other:?} (expected created/starting/running/draining/\
2172
+ paused/stopped/crashed)"
2173
+ ))),
2174
+ }
2175
+ }
2176
+
2177
+ /// Build the core's `RestartOptions` from a string-keyed Hash
2178
+ /// (`force`/`timeout`/`detached`). An absent `timeout` keeps the core's
2179
+ /// ten-second graceful-shutdown default.
2180
+ fn restart_options(opts: RHash) -> Result<RestartOptions, Error> {
2181
+ let mut options = RestartOptions {
2182
+ force: conv::opt_bool(opts, "force")?,
2183
+ detached: conv::opt_bool(opts, "detached")?,
2184
+ ..Default::default()
2185
+ };
2186
+ if let Some(secs) = conv::opt_f64(opts, "timeout")? {
2187
+ options.timeout = secs_to_duration(secs)?;
2188
+ }
2189
+ Ok(options)
2190
+ }
2191
+
2192
+ /// Build the core's `DestroyOptions` from a string-keyed Hash
2193
+ /// (`force`/`timeout`). An absent `timeout` keeps the core's ten-second
2194
+ /// graceful-shutdown default.
2195
+ fn destroy_options(opts: RHash) -> Result<DestroyOptions, Error> {
2196
+ let mut options = DestroyOptions {
2197
+ force: conv::opt_bool(opts, "force")?,
2198
+ ..Default::default()
2199
+ };
2200
+ if let Some(secs) = conv::opt_f64(opts, "timeout")? {
2201
+ options.timeout = secs_to_duration(secs)?;
2202
+ }
2203
+ Ok(options)
2204
+ }
2205
+
2099
2206
  /// A `std::process::ExitStatus` as a Ruby Hash: `exit_code` (Integer or nil) and
2100
2207
  /// `success` (Boolean). Returned by the live `Sandbox#wait` / `#stop_and_wait`.
2101
2208
  fn exit_status_to_hash(status: std::process::ExitStatus) -> RHash {
@@ -2422,6 +2529,13 @@ impl SbHandle {
2422
2529
  self.inner.name().to_string()
2423
2530
  }
2424
2531
 
2532
+ /// The opaque, backend-assigned identity of the persisted sandbox this
2533
+ /// handle is bound to (v0.6.16). The lifecycle operations below refuse to
2534
+ /// act on a same-name replacement by comparing against it.
2535
+ fn id(&self) -> String {
2536
+ self.inner.id().to_string()
2537
+ }
2538
+
2425
2539
  /// Status snapshot captured when the handle was fetched (synchronous).
2426
2540
  fn status(&self) -> String {
2427
2541
  sandbox_status_str(self.inner.status_snapshot()).to_string()
@@ -2470,6 +2584,45 @@ impl SbHandle {
2470
2584
  block_on(self.inner.request_drain()).map_err(error::to_ruby)
2471
2585
  }
2472
2586
 
2587
+ /// Connect when this exact sandbox is running, wait while it is starting,
2588
+ /// or start it when it is created/stopped/crashed. `opts` carries
2589
+ /// `detached`, which applies only when a start is actually required.
2590
+ fn connect_or_start(&self, opts: RHash) -> Result<Sandbox, Error> {
2591
+ let detached = conv::opt_bool(opts, "detached")?;
2592
+ let inner = block_on(async {
2593
+ if detached {
2594
+ self.inner.connect_or_start_detached().await
2595
+ } else {
2596
+ self.inner.connect_or_start().await
2597
+ }
2598
+ })
2599
+ .map_err(error::to_ruby)?;
2600
+ Ok(Sandbox::from_inner(inner))
2601
+ }
2602
+
2603
+ /// Block until this exact sandbox reaches `status` (no built-in timeout),
2604
+ /// returning a fresh handle. A same-name replacement raises
2605
+ /// `SandboxReplacedError` instead of silently redirecting the wait.
2606
+ fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
2607
+ let status = sandbox_status_from_str(&status)?;
2608
+ let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
2609
+ Ok(SbHandle::from_inner(handle))
2610
+ }
2611
+
2612
+ /// Stop and start this exact sandbox, returning the new live sandbox.
2613
+ /// `opts` carries `force`/`timeout`/`detached`.
2614
+ fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
2615
+ let options = restart_options(opts)?;
2616
+ let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
2617
+ Ok(Sandbox::from_inner(inner))
2618
+ }
2619
+
2620
+ /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
2621
+ fn destroy(&self, opts: RHash) -> Result<(), Error> {
2622
+ let options = destroy_options(opts)?;
2623
+ block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
2624
+ }
2625
+
2473
2626
  /// Block until the sandbox reaches a terminal state; returns a stop-result
2474
2627
  /// Hash (name, status, exit_code, signal, observed_at_ms, source).
2475
2628
  fn wait_until_stopped(&self) -> Result<RHash, Error> {
@@ -2607,6 +2760,10 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2607
2760
  "create_with_progress",
2608
2761
  function!(Sandbox::create_with_progress, 2),
2609
2762
  )?;
2763
+ class.define_singleton_method(
2764
+ "connect_or_create",
2765
+ function!(Sandbox::connect_or_create, 2),
2766
+ )?;
2610
2767
  class.define_singleton_method("start", function!(Sandbox::start, 2))?;
2611
2768
  class.define_singleton_method("get", function!(Sandbox::get, 1))?;
2612
2769
  class.define_singleton_method("list", function!(Sandbox::list, 0))?;
@@ -2614,6 +2771,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2614
2771
  class.define_singleton_method("remove", function!(Sandbox::remove, 1))?;
2615
2772
 
2616
2773
  class.define_method("name", method!(Sandbox::name, 0))?;
2774
+ class.define_method("id", method!(Sandbox::id, 0))?;
2617
2775
  class.define_method("exec", method!(Sandbox::exec, 3))?;
2618
2776
  class.define_method("shell", method!(Sandbox::shell, 2))?;
2619
2777
  class.define_method("exec_stream", method!(Sandbox::exec_stream, 3))?;
@@ -2628,6 +2786,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2628
2786
  class.define_method("kill", method!(Sandbox::kill, 0))?;
2629
2787
  class.define_method("drain", method!(Sandbox::drain, 0))?;
2630
2788
  class.define_method("wait", method!(Sandbox::wait, 0))?;
2789
+ class.define_method("wait_for_status", method!(Sandbox::wait_for_status, 1))?;
2790
+ class.define_method("restart", method!(Sandbox::restart, 1))?;
2791
+ class.define_method("destroy", method!(Sandbox::destroy, 1))?;
2631
2792
  class.define_method("status", method!(Sandbox::status, 0))?;
2632
2793
  class.define_method("owns_lifecycle", method!(Sandbox::owns_lifecycle, 0))?;
2633
2794
  class.define_method("detach", method!(Sandbox::detach, 0))?;
@@ -2667,6 +2828,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2667
2828
 
2668
2829
  let handle = native.define_class("SandboxHandle", ruby.class_object())?;
2669
2830
  handle.define_method("name", method!(SbHandle::name, 0))?;
2831
+ handle.define_method("id", method!(SbHandle::id, 0))?;
2670
2832
  handle.define_method("status", method!(SbHandle::status, 0))?;
2671
2833
  handle.define_method("created_at_ms", method!(SbHandle::created_at_ms, 0))?;
2672
2834
  handle.define_method("updated_at_ms", method!(SbHandle::updated_at_ms, 0))?;
@@ -2674,6 +2836,10 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2674
2836
  handle.define_method("stop_with_timeout", method!(SbHandle::stop_with_timeout, 1))?;
2675
2837
  handle.define_method("kill", method!(SbHandle::kill, 0))?;
2676
2838
  handle.define_method("kill_with_timeout", method!(SbHandle::kill_with_timeout, 1))?;
2839
+ handle.define_method("connect_or_start", method!(SbHandle::connect_or_start, 1))?;
2840
+ handle.define_method("wait_for_status", method!(SbHandle::wait_for_status, 1))?;
2841
+ handle.define_method("restart", method!(SbHandle::restart, 1))?;
2842
+ handle.define_method("destroy", method!(SbHandle::destroy, 1))?;
2677
2843
  handle.define_method("request_stop", method!(SbHandle::request_stop, 0))?;
2678
2844
  handle.define_method("request_kill", method!(SbHandle::request_kill, 0))?;
2679
2845
  handle.define_method("request_drain", method!(SbHandle::request_drain, 0))?;
@@ -35,6 +35,13 @@ module Microsandbox
35
35
  define_error(:SandboxNotRunningError, "sandbox-not-running")
36
36
  define_error(:SandboxAlreadyExistsError, "sandbox-already-exists")
37
37
  define_error(:SandboxStillRunningError, "sandbox-still-running")
38
+ # v0.6.16: a lifecycle operation was aimed at a sandbox identity that no
39
+ # longer owns the name — the name was removed and recreated behind the
40
+ # caller's back. Raised by the identity-checked convergent APIs
41
+ # (`wait_for_status`, `restart`, `destroy`, `connect_or_start`) rather than
42
+ # letting them act on the replacement. Mirrors the Python SDK's
43
+ # SandboxReplacedError.
44
+ define_error(:SandboxReplacedError, "sandbox-replaced")
38
45
 
39
46
  # Execution errors --------------------------------------------------------
40
47
  define_error(:ExecTimeoutError, "exec-timeout")