Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion sdk/ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ them, and rotate them after suspected host compromise.

The gem supports sandbox lifecycle operations, collected exec and shell
output, SSH exec, logs, metrics, guest filesystem operations, local image,
volume, and snapshot management, and local or cloud backend selection.
volume, and snapshot management, local or cloud backend selection, and typed
error classes (see [Errors](#errors)).

SSH exec inherits the global inactivity timeout by default. Override it for a
single command in seconds, or use `0` to disable it:
Expand All @@ -173,6 +174,68 @@ Streaming exec, logs, metrics, and filesystem handles; interactive SSH/SFTP;
live modification plans; and the complete Rust network and mount builders are
not currently exposed. Use the Rust SDK when those APIs are required.

## Errors

Every error reported by a sandbox, image, volume, snapshot, or backend
operation is a `Microsandbox::Error`, so `rescue Microsandbox::Error` catches
all of them. The native layer raises the subclass matching the core error,
which lets callers branch on the failure without matching message text:

```ruby
begin
sandbox.exec("sleep", ["30"], timeout: 1)
rescue Microsandbox::ExecTimeoutError => error
puts "timed out: #{error.message}"
rescue Microsandbox::Error => error
puts "#{error.code}: #{error.message}"
end
```

Class names and `#code` strings mirror the Python SDK; the snapshot,
exec-failed, and volume-already-exists classes follow the Go SDK's finer
coverage. All classes are direct subclasses of `Microsandbox::Error`
(code `microsandbox-error`):

| Group | Classes |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Configuration | `InvalidConfigError`, `NoDefaultCommandError` |
| Lifecycle | `SandboxNotFoundError`, `SandboxNotRunningError`, `SandboxAlreadyExistsError`, `SandboxStillRunningError` |
| Execution | `ExecTimeoutError`, `ExecFailedError` |
| Filesystem | `FilesystemError`, `PathNotFoundError` |
| Volumes and images | `VolumeNotFoundError`, `VolumeAlreadyExistsError`, `ImageNotFoundError`, `ImageInUseError`, `ImagePullFailedError` |
| Snapshots | `SnapshotNotFoundError`, `SnapshotAlreadyExistsError`, `SnapshotSandboxRunningError`, `SnapshotImageMissingError`, `SnapshotIntegrityError`, `SnapshotMigrationError` |
| Networking | `NetworkPolicyError`, `SecretViolationError`, `TlsError` |
| I/O | `IoError` |
| Metrics | `MetricsDisabledError`, `MetricsUnavailableError` |
| Runtime compatibility | `UnsupportedOperationError` |
| Backend routing | `CloudHttpError`, `UnsupportedError` |

Each class exposes its stable, machine-readable code through `.code` and
`#code` (for example `Microsandbox::ExecTimeoutError.code == "exec-timeout"`).
Core errors without a dedicated class raise `Microsandbox::Error` itself.
`PathNotFoundError`, `ImagePullFailedError`, `SecretViolationError`, and
`TlsError` are defined for parity with the Python SDK but are not raised by
the current core.

`UnsupportedError` is raised when the selected backend does not implement an
operation. Its message names the Ruby API and the remedy, both also available
as attributes:

```ruby
Microsandbox.use_cloud_backend!(ENV.fetch("MSB_API_KEY"))
begin
Microsandbox::Sandbox.create("my-sandbox", image: "python", replace: true)
rescue Microsandbox::UnsupportedError => error
error.message # => "sandbox.create is not supported by this backend: the replace option is not accepted here"
error.operation # => "sandbox.create"
error.hint # => "the replace option is not accepted here"
end
```

Argument validation is not covered by that guarantee: unknown keywords and
wrongly typed values keep raising Ruby's `ArgumentError` and `TypeError`
before any operation runs.

## Development

The native extension is built against the published `microsandbox` Rust crate
Expand Down
154 changes: 136 additions & 18 deletions sdk/ruby/ext/microsandbox/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use std::{
ffi::c_void,
fmt::Display,
future::Future,
mem::ManuallyDrop,
panic::{AssertUnwindSafe, catch_unwind},
Expand All @@ -12,11 +11,12 @@ use std::{
};

use magnus::{
Error, ExceptionClass, RArray, RHash, RString, Ruby, Symbol, TryConvert, Value, function,
method, prelude::*, r_hash::ForEach, scan_args::scan_args, typed_data,
Error, ExceptionClass, RArray, RHash, RObject, RString, Ruby, Symbol, TryConvert, Value,
function, method, prelude::*, r_hash::ForEach, scan_args::scan_args, typed_data,
};
use microsandbox_core::{
BackendKind, MicrosandboxResult,
AgentClientError, BackendKind, MicrosandboxError, MicrosandboxResult, Operation,
UnsupportedReason,
backend::{
CloudBackend, LocalBackend, default_backend, resolve_default_backend, set_default_backend,
},
Expand Down Expand Up @@ -164,7 +164,7 @@ fn reset_backend_after_fork(ruby: &Ruby) -> Result<(), Error> {
.clone();
match selection {
BackendSelection::Ambient => {
let backend = resolve_default_backend().map_err(|error| native_error(ruby, error))?;
let backend = resolve_default_backend().map_err(|error| core_error(ruby, error))?;
set_default_backend(backend);
}
BackendSelection::Local => set_default_backend(LocalBackend::lazy()),
Expand All @@ -173,12 +173,12 @@ fn reset_backend_after_fork(ruby: &Ruby) -> Result<(), Error> {
Some(url) => CloudBackend::new(url, api_key),
None => CloudBackend::with_api_key(api_key),
}
.map_err(|error| native_error(ruby, error))?;
.map_err(|error| core_error(ruby, error))?;
set_default_backend(backend);
}
BackendSelection::CloudProfile(name) => {
let backend =
CloudBackend::from_profile(&name).map_err(|error| native_error(ruby, error))?;
CloudBackend::from_profile(&name).map_err(|error| core_error(ruby, error))?;
set_default_backend(backend);
}
}
Expand Down Expand Up @@ -225,13 +225,131 @@ fn runtime() -> Result<&'static tokio::runtime::Runtime, Error> {
Ok(unsafe { &*runtime_ptr })
}

fn native_error(ruby: &Ruby, error: impl Display) -> Error {
let msg = error.to_string();
let exc = ruby
.define_module("Microsandbox")
.and_then(|m| m.const_get::<_, ExceptionClass>("Error"))
.unwrap_or_else(|_| ruby.exception_runtime_error());
Error::new(exc, msg)
// -------------------------------------------------------------------------------------------------
// Core error mapping
// -------------------------------------------------------------------------------------------------
Comment on lines +228 to +230

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Put error helpers under the required Functions section

This newly added section contains free functions but uses the unqualified Core error mapping label instead of the repository-required Functions/Functions: ... organization. Rename it to an approved qualified Functions section so the extension follows the mandated Rust layout.

AGENTS.md reference: AGENTS.md:L198-L204

Useful? React with 👍 / 👎.


/// The `Microsandbox::*` exception class for a core error. `"Error"` is the
/// natively defined base class; the subclasses live in
/// `lib/microsandbox/errors.rb`. Class names mirror the Python SDK's bridge
/// (`sdk/python/src/error.rs`), extended with the Go SDK's per-variant coverage
/// for snapshots, exec spawn failures, and duplicate volumes. Every other
/// variant falls back to the base class.
fn core_error_class_name(error: &MicrosandboxError) -> &'static str {
match error {
MicrosandboxError::InvalidConfig(_) => "InvalidConfigError",
MicrosandboxError::NoDefaultCommand => "NoDefaultCommandError",
MicrosandboxError::CloudHttp { .. } => "CloudHttpError",
MicrosandboxError::SandboxNotFound(_) => "SandboxNotFoundError",
MicrosandboxError::SandboxNotRunning(_) => "SandboxNotRunningError",
MicrosandboxError::SandboxAlreadyExists(_) => "SandboxAlreadyExistsError",
MicrosandboxError::SandboxStillRunning(_) => "SandboxStillRunningError",
MicrosandboxError::ExecTimeout(_) => "ExecTimeoutError",
MicrosandboxError::ExecFailed(_) => "ExecFailedError",
MicrosandboxError::SandboxFsOps(_) => "FilesystemError",
MicrosandboxError::VolumeNotFound(_) => "VolumeNotFoundError",
MicrosandboxError::VolumeAlreadyExists(_) => "VolumeAlreadyExistsError",
MicrosandboxError::ImageNotFound(_) => "ImageNotFoundError",
MicrosandboxError::ImageInUse(_) => "ImageInUseError",
MicrosandboxError::SnapshotNotFound(_) => "SnapshotNotFoundError",
MicrosandboxError::SnapshotAlreadyExists(_) => "SnapshotAlreadyExistsError",
MicrosandboxError::SnapshotSandboxRunning(_) => "SnapshotSandboxRunningError",
MicrosandboxError::SnapshotImageMissing(_) => "SnapshotImageMissingError",
MicrosandboxError::SnapshotIntegrity(_) => "SnapshotIntegrityError",
MicrosandboxError::SnapshotMigration { .. } => "SnapshotMigrationError",
// Always present: the extension enables the core's `net` feature.
MicrosandboxError::NetworkBuilder(_) => "NetworkPolicyError",
Comment thread
ya-luotao marked this conversation as resolved.
MicrosandboxError::Io(_) => "IoError",
MicrosandboxError::MetricsDisabled(_) => "MetricsDisabledError",
MicrosandboxError::MetricsUnavailable(_) => "MetricsUnavailableError",
MicrosandboxError::AgentClient(AgentClientError::UnsupportedOperation { .. }) => {
"UnsupportedOperationError"
}
MicrosandboxError::Unsupported { .. } => "UnsupportedError",
_ => "Error",
}
}

/// Look up `Microsandbox::<name>`, falling back to the base `Error`, then to
/// `RuntimeError` if even that is missing.
fn exception_class(ruby: &Ruby, name: &str) -> ExceptionClass {
ruby.define_module("Microsandbox")
.and_then(|module| {
module
.const_get::<_, ExceptionClass>(name)
.or_else(|_| module.const_get::<_, ExceptionClass>("Error"))
})
.unwrap_or_else(|_| ruby.exception_runtime_error())
}

/// Convert a core error into the matching typed Ruby exception. The message is
/// always the core error's `Display` rendering, except for `Unsupported`,
/// which names the Ruby API instead of the Rust path.
fn core_error(ruby: &Ruby, error: MicrosandboxError) -> Error {
if let MicrosandboxError::Unsupported { op, reason } = &error {
return unsupported_error(ruby, &ruby_api_name(*op), &ruby_hint(reason));
}
Error::new(
exception_class(ruby, core_error_class_name(&error)),
error.to_string(),
)
}

/// Build a `Microsandbox::UnsupportedError` carrying the rendered message plus
/// the structured `@operation` / `@hint` attributes read by
/// `UnsupportedError#operation` / `#hint`.
fn unsupported_error(ruby: &Ruby, operation: &str, hint: &str) -> Error {
let message = format!("{operation} is not supported by this backend: {hint}");
let class = exception_class(ruby, "UnsupportedError");
match class.new_instance((message.as_str(),)) {
Ok(exception) => {
// Best-effort extras; the message already carries both.
if let Some(object) = RObject::from_value(exception.as_value()) {
let _ = object.ivar_set("@operation", operation);
let _ = object.ivar_set("@hint", hint);
}
exception.into()
}
Err(_) => Error::new(class, message),
}
}

/// Render an [`Operation`] as the Ruby API it corresponds to: `Sandbox::kill`
/// becomes `sandbox.kill` and `Sandbox::log_stream(follow=false)` becomes
/// `sandbox.log_stream(follow: false)`. Plain phrases without a `Type::method`
/// shape (`config`, `snapshot operations`) pass through as-is.
fn ruby_api_name(op: Operation) -> String {
let path = op.api_path();
let Some((ty, method)) = path.split_once("::") else {
return path.to_string();
};
format!("{}.{}", camel_to_snake(ty), method.replace('=', ": "))
}

/// Render an [`UnsupportedReason`] with `use instead` targets pointing at the
/// Ruby API name rather than the Rust path.
fn ruby_hint(reason: &UnsupportedReason) -> String {
match reason {
UnsupportedReason::UseInstead(op) => format!("use {}", ruby_api_name(*op)),
other => other.hint(),
}
}

/// Lower a `CamelCase` type name to `snake_case` (`SandboxFsOps` becomes
/// `sandbox_fs_ops`).
fn camel_to_snake(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 4);
for (i, ch) in name.char_indices() {
if ch.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}

/// Spawn `future` on the tokio runtime and block the Ruby thread **without the
Expand Down Expand Up @@ -296,7 +414,7 @@ where
F: Future<Output = MicrosandboxResult<T>> + Send + 'static,
T: Send + 'static,
{
block_without_gvl(ruby, future)?.map_err(|e| native_error(ruby, e))
block_without_gvl(ruby, future)?.map_err(|e| core_error(ruby, e))
}

// -------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -410,7 +528,7 @@ fn restricted_network_policy(ruby: &Ruby, value: Value) -> Result<NetworkPolicy,
.default_deny()
.egress(|eg| eg.tcp().ports(ports).allow_domains(hosts))
.build()
.map_err(|e| native_error(ruby, e))
.map_err(|e| core_error(ruby, MicrosandboxError::from(e)))
}

fn apply_secret_options(
Expand Down Expand Up @@ -1573,13 +1691,13 @@ fn set_default_backend_cloud(ruby: &Ruby, args: &[Value]) -> Result<(), Error> {
Some(url) => CloudBackend::new(url, &api_key),
None => CloudBackend::with_api_key(&api_key),
}
.map_err(|error| native_error(ruby, error))?;
.map_err(|error| core_error(ruby, error))?;
set_default_backend(backend);
remember_backend_selection(ruby, BackendSelection::Cloud { api_key, url })
}

fn set_default_backend_profile(ruby: &Ruby, name: String) -> Result<(), Error> {
let backend = CloudBackend::from_profile(&name).map_err(|error| native_error(ruby, error))?;
let backend = CloudBackend::from_profile(&name).map_err(|error| core_error(ruby, error))?;
set_default_backend(backend);
remember_backend_selection(ruby, BackendSelection::CloudProfile(name))
}
Expand Down
3 changes: 3 additions & 0 deletions sdk/ruby/lib/microsandbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
end
end

# The base Error is defined natively; the typed subclasses reopen it.
require_relative "microsandbox/errors"

module Microsandbox
class SandboxBuilder
%i[
Expand Down
Loading