microsandbox-rb 0.10.0 → 0.12.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.
@@ -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.6).
10
- version = "0.10.0"
9
+ # The core-crate dependency below stays pinned at its own tag (v0.6.7).
10
+ version = "0.12.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.6", default-features = true, features = ["ssh"] }
39
- microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.6" }
38
+ microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.8", default-features = true, features = ["ssh"] }
39
+ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.8" }
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"] }
@@ -20,7 +20,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
20
20
  use std::sync::{Arc, Mutex, OnceLock};
21
21
 
22
22
  use magnus::{function, prelude::*, Error, RModule, Ruby};
23
- use microsandbox::{Backend, MicrosandboxError};
23
+ use microsandbox::{Backend, MicrosandboxError, Operation};
24
24
 
25
25
  use crate::error;
26
26
  use crate::runtime::block_on;
@@ -31,17 +31,14 @@ use crate::runtime::block_on;
31
31
  /// borrowing `&LocalBackend` from it (`as_local()` borrows the `Arc`). Pure
32
32
  /// Rust — safe to call inside `block_on` (no Ruby C API), and it returns a raw
33
33
  /// `MicrosandboxError` so the Ruby-exception mapping happens *after* `block_on`
34
- /// re-acquires the GVL. Cloud backends yield `Unsupported`, mirroring pyo3's
35
- /// `resolve_local`.
36
- pub fn local_backend() -> Result<Arc<dyn Backend>, MicrosandboxError> {
34
+ /// re-acquires the GVL. Cloud backends yield `Unsupported` for the given
35
+ /// operation (v0.6.8 `Operation`-keyed shape), mirroring pyo3's `resolve_local`.
36
+ pub fn local_backend(op: Operation) -> Result<Arc<dyn Backend>, MicrosandboxError> {
37
37
  let backend = microsandbox::default_backend();
38
38
  if backend.as_local().is_some() {
39
39
  Ok(backend)
40
40
  } else {
41
- Err(MicrosandboxError::Unsupported {
42
- feature: "this operation requires a local backend".into(),
43
- available_when: "with the local backend (the default)".into(),
44
- })
41
+ Err(MicrosandboxError::local_only(op))
45
42
  }
46
43
  }
47
44
 
@@ -53,10 +50,11 @@ pub fn local_backend() -> Result<Arc<dyn Backend>, MicrosandboxError> {
53
50
  /// it and returns the same `Arc` kept alive for the borrow — so it lives here
54
51
  /// once rather than at every call site.
55
52
  pub fn with_local_backend<T>(
53
+ operation: Operation,
56
54
  op: impl AsyncFnOnce(&microsandbox::LocalBackend) -> Result<T, MicrosandboxError>,
57
55
  ) -> Result<T, Error> {
58
56
  block_on(async move {
59
- let backend = local_backend()?;
57
+ let backend = local_backend(operation)?;
60
58
  let local = backend
61
59
  .as_local()
62
60
  .expect("local_backend() guarantees a local backend");
@@ -6,7 +6,7 @@
6
6
  //! is always the core error's `to_string()`.
7
7
 
8
8
  use magnus::{value::ReprValue, Error, ExceptionClass, Module, RClass, RModule, Ruby};
9
- use microsandbox::{AgentClientError, MicrosandboxError};
9
+ use microsandbox::{AgentClientError, MicrosandboxError, Operation, UnsupportedReason};
10
10
 
11
11
  /// The Ruby class (relative to the `Microsandbox` module) for a core error.
12
12
  /// `"Error"` is the base class; anything else is a named subclass.
@@ -51,6 +51,10 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
51
51
  SnapshotSandboxRunning(_) => "SnapshotSandboxRunningError",
52
52
  SnapshotImageMissing(_) => "SnapshotImageMissingError",
53
53
  SnapshotIntegrity(_) => "SnapshotIntegrityError",
54
+ // v0.6.7 (#1200): the automatic v0.6.6→v0.6.7 snapshot-descriptor
55
+ // migration (run at backend connect / artifact open) failed and needs
56
+ // repair. Mirrors the Python `SnapshotMigrationError`.
57
+ SnapshotMigration { .. } => "SnapshotMigrationError",
54
58
  // Give the already-defined-but-orphaned `NetworkPolicyError` a mapping:
55
59
  // a builder parse/validation error from `network(|n| ...)`. The gem
56
60
  // unconditionally enables the core's `net` feature (default-features),
@@ -79,12 +83,96 @@ pub fn to_ruby(err: MicrosandboxError) -> Error {
79
83
  Err(_) => return Error::new(magnus::exception::runtime_error(), message),
80
84
  };
81
85
 
86
+ // `Unsupported` gets a Ruby-idiom message (`sandbox.kill` instead of
87
+ // `Sandbox::kill`) plus structured `operation` / `hint` attributes on the
88
+ // exception instance, mirroring the Python SDK's enrichment (v0.6.8).
89
+ if let MicrosandboxError::Unsupported { op, reason } = &err {
90
+ return unsupported_error(&ruby, &ruby_api_name(*op), &ruby_hint(reason));
91
+ }
92
+
82
93
  match exception_class(&ruby, class_name(&err)) {
83
94
  Some(class) => Error::new(class, message),
84
95
  None => Error::new(ruby.exception_runtime_error(), message),
85
96
  }
86
97
  }
87
98
 
99
+ /// `UnsupportedError` for shim-only entry points that require the local
100
+ /// backend but have no SDK [`Operation`] (Ruby-only diagnostic hooks such as
101
+ /// `Microsandbox.runtime_path`). `name` is the Ruby-facing API name. Mirrors
102
+ /// the Python SDK's name-based `local_only` helper.
103
+ #[allow(deprecated)]
104
+ pub fn local_only(name: &str) -> Error {
105
+ match Ruby::get() {
106
+ Ok(ruby) => unsupported_error(&ruby, name, "use a local backend"),
107
+ Err(_) => Error::new(
108
+ magnus::exception::runtime_error(),
109
+ format!("{name} is not supported by this backend: use a local backend"),
110
+ ),
111
+ }
112
+ }
113
+
114
+ /// Build a `Microsandbox::UnsupportedError` carrying the rendered message and
115
+ /// the structured `@operation` / `@hint` attributes.
116
+ fn unsupported_error(ruby: &Ruby, operation: &str, hint: &str) -> Error {
117
+ let message = format!("{operation} is not supported by this backend: {hint}");
118
+ let Some(class) = exception_class(ruby, "UnsupportedError") else {
119
+ return Error::new(ruby.exception_runtime_error(), message);
120
+ };
121
+ match class
122
+ .as_value()
123
+ .funcall::<_, _, magnus::Exception>("new", (message.as_str(),))
124
+ {
125
+ Ok(exc) => {
126
+ // Best-effort extras; the message already carries both.
127
+ let _ = exc
128
+ .funcall::<_, _, magnus::Value>("instance_variable_set", ("@operation", operation));
129
+ let _ = exc.funcall::<_, _, magnus::Value>("instance_variable_set", ("@hint", hint));
130
+ exc.into()
131
+ }
132
+ Err(_) => Error::new(class, message),
133
+ }
134
+ }
135
+
136
+ /// Render an [`Operation`] as the Ruby API it corresponds to: `Sandbox::kill`
137
+ /// becomes `sandbox.kill` and `SandboxFsOps::stat_handle` becomes
138
+ /// `sandbox_fs_ops.stat_handle`; a parenthetical keeps its Ruby keyword shape
139
+ /// (`log_stream(follow=false)` becomes `log_stream(follow: false)`). Plain
140
+ /// phrases without a `Type::method` shape (`config`, `snapshot operations`)
141
+ /// pass through as-is. Mirrors the Python SDK's `py_api_name`.
142
+ fn ruby_api_name(op: Operation) -> String {
143
+ let path = op.api_path();
144
+ let Some((ty, method)) = path.split_once("::") else {
145
+ return path.to_string();
146
+ };
147
+ format!("{}.{}", camel_to_snake(ty), method.replace('=', ": "))
148
+ }
149
+
150
+ /// Render an [`UnsupportedReason`] with `use instead` targets pointing at the
151
+ /// Ruby API name rather than the Rust path.
152
+ fn ruby_hint(reason: &UnsupportedReason) -> String {
153
+ match reason {
154
+ UnsupportedReason::UseInstead(op) => format!("use {}", ruby_api_name(*op)),
155
+ other => other.hint(),
156
+ }
157
+ }
158
+
159
+ /// Lower a `CamelCase` type name to `snake_case` (`SandboxFsOps` becomes
160
+ /// `sandbox_fs_ops`).
161
+ fn camel_to_snake(name: &str) -> String {
162
+ let mut out = String::with_capacity(name.len() + 4);
163
+ for (i, ch) in name.char_indices() {
164
+ if ch.is_ascii_uppercase() {
165
+ if i > 0 {
166
+ out.push('_');
167
+ }
168
+ out.push(ch.to_ascii_lowercase());
169
+ } else {
170
+ out.push(ch);
171
+ }
172
+ }
173
+ out
174
+ }
175
+
88
176
  /// A plain `Microsandbox::Error` (base) with a custom message — used for
89
177
  /// binding-level validation errors that have no core counterpart.
90
178
  #[allow(deprecated)]
@@ -7,9 +7,11 @@
7
7
 
8
8
  use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
9
9
  use microsandbox::image::{Image, ImageDetail, ImageHandle, ImagePruneReport};
10
+ use microsandbox::{ImageArchiveFormat, Operation};
10
11
 
11
12
  use crate::backend::with_local_backend;
12
13
  use crate::conv;
14
+ use crate::error;
13
15
  use crate::runtime::ruby;
14
16
 
15
17
  fn handle_to_hash(h: &ImageHandle) -> RHash {
@@ -82,12 +84,16 @@ fn report_to_hash(report: ImagePruneReport) -> RHash {
82
84
  }
83
85
 
84
86
  fn get(reference: String) -> Result<RHash, Error> {
85
- let handle = with_local_backend(async |local| Image::get_local(local, &reference).await)?;
87
+ let handle = with_local_backend(Operation::ImageGet, async |local| {
88
+ Image::get_local(local, &reference).await
89
+ })?;
86
90
  Ok(handle_to_hash(&handle))
87
91
  }
88
92
 
89
93
  fn list() -> Result<RArray, Error> {
90
- let handles = with_local_backend(async |local| Image::list_local(local).await)?;
94
+ let handles = with_local_backend(Operation::ImageList, async |local| {
95
+ Image::list_local(local).await
96
+ })?;
91
97
  let arr = ruby().ary_new();
92
98
  for h in handles.iter() {
93
99
  arr.push(handle_to_hash(h))?;
@@ -96,19 +102,59 @@ fn list() -> Result<RArray, Error> {
96
102
  }
97
103
 
98
104
  fn inspect(reference: String) -> Result<RHash, Error> {
99
- let detail = with_local_backend(async |local| Image::inspect_local(local, &reference).await)?;
105
+ let detail = with_local_backend(Operation::ImageInspect, async |local| {
106
+ Image::inspect_local(local, &reference).await
107
+ })?;
100
108
  Ok(detail_to_hash(detail))
101
109
  }
102
110
 
103
111
  fn remove(reference: String, force: bool) -> Result<(), Error> {
104
- with_local_backend(async |local| Image::remove_local(local, &reference, force).await)
112
+ with_local_backend(Operation::ImageRemove, async |local| {
113
+ Image::remove_local(local, &reference, force).await
114
+ })
105
115
  }
106
116
 
107
117
  fn prune() -> Result<RHash, Error> {
108
- let report = with_local_backend(async |local| Image::prune_local(local).await)?;
118
+ let report = with_local_backend(Operation::ImagePrune, async |local| {
119
+ Image::prune_local(local).await
120
+ })?;
109
121
  Ok(report_to_hash(report))
110
122
  }
111
123
 
124
+ /// Load images into the cache from a local `docker save` tarball or an OCI
125
+ /// Image Layout archive. `tags` optionally retags what was loaded. Returns an
126
+ /// Array of image-handle Hashes. Mirrors the Python `Image.load` / Node
127
+ /// `imageLoad` added in v0.6.7. (The `"-"` stdin form is spooled to a temp
128
+ /// file by the Ruby layer — the core reads seekable files only.)
129
+ fn load(input_path: String, tags: Vec<String>) -> Result<RArray, Error> {
130
+ let handles = with_local_backend(Operation::ImageLoad, async |local| {
131
+ Image::load_local(local, std::path::Path::new(&input_path), tags).await
132
+ })?;
133
+ let arr = ruby().ary_new();
134
+ for h in handles.iter() {
135
+ arr.push(handle_to_hash(h))?;
136
+ }
137
+ Ok(arr)
138
+ }
139
+
140
+ /// Save cached image(s) into a local archive. `format` is "docker" (default
141
+ /// shape callers expect from `docker load`) or "oci" (OCI Image Layout).
142
+ /// Mirrors the Python `Image.save` / Node `imageSave` added in v0.6.7.
143
+ fn save(references: Vec<String>, output_path: String, format: String) -> Result<(), Error> {
144
+ let fmt = match format.as_str() {
145
+ "docker" => ImageArchiveFormat::Docker,
146
+ "oci" => ImageArchiveFormat::Oci,
147
+ other => {
148
+ return Err(error::base_error(format!(
149
+ "invalid archive format {other:?}: expected \"docker\" or \"oci\""
150
+ )))
151
+ }
152
+ };
153
+ with_local_backend(Operation::ImageSave, async |local| {
154
+ Image::save_local(local, &references, std::path::Path::new(&output_path), fmt).await
155
+ })
156
+ }
157
+
112
158
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
113
159
  let class = native.define_class("Image", ruby.class_object())?;
114
160
  class.define_singleton_method("get", function!(get, 1))?;
@@ -116,5 +162,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
116
162
  class.define_singleton_method("inspect", function!(inspect, 1))?;
117
163
  class.define_singleton_method("remove", function!(remove, 2))?;
118
164
  class.define_singleton_method("prune", function!(prune, 0))?;
165
+ class.define_singleton_method("load", function!(load, 2))?;
166
+ class.define_singleton_method("save", function!(save, 3))?;
119
167
  Ok(())
120
168
  }
@@ -33,9 +33,10 @@ fn version() -> String {
33
33
  /// Ruby Hash. Mirrors the official `all_sandbox_metrics` / `allSandboxMetrics`
34
34
  /// helpers (Python/Node/Go).
35
35
  fn all_sandbox_metrics() -> Result<RHash, Error> {
36
- let map = backend::with_local_backend(async |local| {
37
- microsandbox::sandbox::all_sandbox_metrics_local(local).await
38
- })?;
36
+ let map =
37
+ backend::with_local_backend(microsandbox::Operation::AllSandboxMetrics, async |local| {
38
+ microsandbox::sandbox::all_sandbox_metrics_local(local).await
39
+ })?;
39
40
  let hash = runtime::ruby().hash_new();
40
41
  for (name, metrics) in &map {
41
42
  hash.aset(name.as_str(), sandbox::metrics_to_hash(metrics))?;
@@ -107,12 +108,11 @@ fn set_runtime_libkrunfw_path(path: String) {
107
108
  /// backend's config.
108
109
  fn resolved_msb_path() -> Result<String, Error> {
109
110
  let backend = microsandbox::default_backend();
110
- let local = backend.as_local().ok_or_else(|| {
111
- error::to_ruby(microsandbox::MicrosandboxError::Unsupported {
112
- feature: "resolved_msb_path requires a local backend".into(),
113
- available_when: "with the local backend".into(),
114
- })
115
- })?;
111
+ // Shim-only entry point with no SDK `Operation` — report the Ruby-facing
112
+ // name (`Microsandbox.runtime_path`), like Python's name-based local_only.
113
+ let local = backend
114
+ .as_local()
115
+ .ok_or_else(|| error::local_only("runtime_path"))?;
116
116
  let path = local.config().resolve_msb_path().map_err(error::to_ruby)?;
117
117
  Ok(path.to_string_lossy().into_owned())
118
118
  }