diff --git a/sdk/ruby/README.md b/sdk/ruby/README.md index 2a90ba5b3..64272488f 100644 --- a/sdk/ruby/README.md +++ b/sdk/ruby/README.md @@ -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: @@ -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 diff --git a/sdk/ruby/ext/microsandbox/src/lib.rs b/sdk/ruby/ext/microsandbox/src/lib.rs index 636c3435d..c3a8bd5ba 100644 --- a/sdk/ruby/ext/microsandbox/src/lib.rs +++ b/sdk/ruby/ext/microsandbox/src/lib.rs @@ -1,6 +1,5 @@ use std::{ ffi::c_void, - fmt::Display, future::Future, mem::ManuallyDrop, panic::{AssertUnwindSafe, catch_unwind}, @@ -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, }, @@ -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()), @@ -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); } } @@ -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 +// ------------------------------------------------------------------------------------------------- + +/// 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", + MicrosandboxError::Io(_) => "IoError", + MicrosandboxError::MetricsDisabled(_) => "MetricsDisabledError", + MicrosandboxError::MetricsUnavailable(_) => "MetricsUnavailableError", + MicrosandboxError::AgentClient(AgentClientError::UnsupportedOperation { .. }) => { + "UnsupportedOperationError" + } + MicrosandboxError::Unsupported { .. } => "UnsupportedError", + _ => "Error", + } +} + +/// Look up `Microsandbox::`, 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 @@ -296,7 +414,7 @@ where F: Future> + 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)) } // ------------------------------------------------------------------------------------------------- @@ -410,7 +528,7 @@ fn restricted_network_policy(ruby: &Ruby, value: Value) -> Result 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)) } diff --git a/sdk/ruby/lib/microsandbox.rb b/sdk/ruby/lib/microsandbox.rb index 7802663c1..35cd549d8 100644 --- a/sdk/ruby/lib/microsandbox.rb +++ b/sdk/ruby/lib/microsandbox.rb @@ -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[ diff --git a/sdk/ruby/lib/microsandbox/errors.rb b/sdk/ruby/lib/microsandbox/errors.rb new file mode 100644 index 000000000..92311b168 --- /dev/null +++ b/sdk/ruby/lib/microsandbox/errors.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Microsandbox + # Base class for every error reported by a sandbox, image, volume, snapshot, + # or backend operation. Defined natively by the extension; reopened here to + # attach the stable, machine-readable code. Argument validation is outside + # this hierarchy: it keeps raising +ArgumentError+ / +TypeError+. + # + # The native layer raises the subclass matching the core error variant; + # variants without a dedicated class surface as +Error+ itself, so + # +rescue Microsandbox::Error+ keeps catching all of them. Class names and + # codes mirror the Python SDK (sdk/python/microsandbox/errors.py); the + # Snapshot*, ExecFailed, and VolumeAlreadyExists classes follow the Go SDK's + # finer per-variant coverage. + class Error + CODE = "microsandbox-error" + + # The stable, machine-readable error code for this class. + def self.code + const_get(:CODE) + end + + # The stable, machine-readable error code for this instance. + def code + self.class.code + end + end + + # Defines +Microsandbox::+ as a direct subclass of +Error+ carrying +code+. + def self.define_error(name, code) + klass = Class.new(Error) + klass.const_set(:CODE, code) + const_set(name, klass) + end + private_class_method :define_error + + # Configuration / validation errors -------------------------------------- + define_error(:InvalidConfigError, "invalid-config") + # exec_default/attach_default on an image whose ENTRYPOINT+CMD provide no + # executable command. + define_error(:NoDefaultCommandError, "no-default-command") + + # Lifecycle errors -------------------------------------------------------- + define_error(:SandboxNotFoundError, "sandbox-not-found") + define_error(:SandboxNotRunningError, "sandbox-not-running") + define_error(:SandboxAlreadyExistsError, "sandbox-already-exists") + define_error(:SandboxStillRunningError, "sandbox-still-running") + + # Execution errors -------------------------------------------------------- + define_error(:ExecTimeoutError, "exec-timeout") + # The command failed to spawn (binary not found, permission denied, ...), as + # opposed to exiting non-zero. + define_error(:ExecFailedError, "exec-failed") + + # Filesystem errors ------------------------------------------------------- + define_error(:FilesystemError, "filesystem-error") + # Reserved for parity with the Python SDK; no core variant maps here today + # (a missing guest path raises FilesystemError). + define_error(:PathNotFoundError, "path-not-found") + + # Volume / image errors --------------------------------------------------- + define_error(:VolumeNotFoundError, "volume-not-found") + define_error(:VolumeAlreadyExistsError, "volume-already-exists") + define_error(:ImageNotFoundError, "image-not-found") + define_error(:ImageInUseError, "image-in-use") + # Reserved for parity with the Python SDK; no core variant maps here today. + define_error(:ImagePullFailedError, "image-pull-failed") + + # Snapshot errors --------------------------------------------------------- + define_error(:SnapshotNotFoundError, "snapshot-not-found") + define_error(:SnapshotAlreadyExistsError, "snapshot-already-exists") + define_error(:SnapshotSandboxRunningError, "snapshot-sandbox-running") + define_error(:SnapshotImageMissingError, "snapshot-image-missing") + define_error(:SnapshotIntegrityError, "snapshot-integrity") + # The automatic adjacent-release snapshot migration was blocked. + define_error(:SnapshotMigrationError, "snapshot-migration") + + # Networking / secrets errors --------------------------------------------- + # Also carries the core's network policy build/validation error. + define_error(:NetworkPolicyError, "network-policy-error") + # Reserved for parity with the Python SDK; no core variant maps here today. + define_error(:SecretViolationError, "secret-violation") + # Reserved for parity with the Python SDK; no core variant maps here today. + define_error(:TlsError, "tls-error") + + # I/O --------------------------------------------------------------------- + define_error(:IoError, "io-error") + + # Metrics errors ---------------------------------------------------------- + define_error(:MetricsDisabledError, "metrics-disabled") + define_error(:MetricsUnavailableError, "metrics-unavailable") + + # Runtime compatibility --------------------------------------------------- + # The sandbox runtime is too old for the requested operation. + define_error(:UnsupportedOperationError, "unsupported-operation") + + # Cloud / backend routing errors ------------------------------------------ + define_error(:CloudHttpError, "cloud-http") + # The selected backend does not support the requested feature yet. Distinct + # from UnsupportedOperationError above. + define_error(:UnsupportedError, "unsupported") + + # The native layer renders the rejected operation and the remedy into the + # message ("sandbox.kill is not supported by this backend: use ...") and + # also attaches them as structured attributes, mirroring the Python SDK's + # +UnsupportedError.operation+ / +.hint+. + class UnsupportedError + # @return [String, nil] the rejected API in Ruby rendering, e.g. "sandbox.kill" + attr_reader :operation + # @return [String, nil] why it was rejected or what to use instead + attr_reader :hint + end +end diff --git a/sdk/ruby/test/integration_test.rb b/sdk/ruby/test/integration_test.rb index 4280bf5f0..6e0a6999c 100644 --- a/sdk/ruby/test/integration_test.rb +++ b/sdk/ruby/test/integration_test.rb @@ -122,6 +122,47 @@ def test_ssh_exec_accepts_inactivity_timeout sandbox&.stop end + def test_exec_timeout_raises_typed_error + sandbox = create_sandbox("exec-timeout") + + error = assert_raise(Microsandbox::ExecTimeoutError) { sandbox.exec("sleep", ["30"], timeout: 0.5) } + + assert_true Microsandbox::Error === error + assert_equal "exec-timeout", error.code + ensure + sandbox&.stop + end + + def test_missing_guest_path_raises_filesystem_error + sandbox = create_sandbox("fs-missing") + + error = assert_raise(Microsandbox::FilesystemError) { sandbox.fs.read("/does-not-exist-#{SecureRandom.hex(4)}") } + + assert_true Microsandbox::Error === error + assert_equal "filesystem-error", error.code + ensure + sandbox&.stop + end + + def test_missing_volume_raises_volume_not_found_error + error = assert_raise(Microsandbox::VolumeNotFoundError) { Microsandbox::Volume.get(unique_name("missing-volume")) } + + assert_true Microsandbox::Error === error + assert_equal "volume-not-found", error.code + end + + def test_removing_running_sandbox_raises_sandbox_still_running_error + sandbox = create_sandbox("remove-running") + handle = Microsandbox::Sandbox.get(@names.last) + + error = assert_raise(Microsandbox::SandboxStillRunningError) { handle.remove } + + assert_true Microsandbox::Error === error + assert_equal "sandbox-still-running", error.code + ensure + sandbox&.stop + end + def test_assert_eventually_enforces_timeout started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) diff --git a/sdk/ruby/test/microsandbox_test.rb b/sdk/ruby/test/microsandbox_test.rb index 9ed341e53..f6d25e2d9 100644 --- a/sdk/ruby/test/microsandbox_test.rb +++ b/sdk/ruby/test/microsandbox_test.rb @@ -1,10 +1,27 @@ # frozen_string_literal: true +require "rbconfig" require "test/unit" require "timeout" require_relative "../lib/microsandbox" class MicrosandboxTest < Test::Unit::TestCase + # Mirrors sdk/python/microsandbox/errors.py plus the Go SDK's snapshot, + # exec-failed, and volume-already-exists granularity. + ERROR_CLASSES = %i[ + InvalidConfigError NoDefaultCommandError + SandboxNotFoundError SandboxNotRunningError SandboxAlreadyExistsError SandboxStillRunningError + ExecTimeoutError ExecFailedError + FilesystemError PathNotFoundError + VolumeNotFoundError VolumeAlreadyExistsError ImageNotFoundError ImageInUseError ImagePullFailedError + SnapshotNotFoundError SnapshotAlreadyExistsError SnapshotSandboxRunningError + SnapshotImageMissingError SnapshotIntegrityError SnapshotMigrationError + NetworkPolicyError SecretViolationError TlsError + IoError + MetricsDisabledError MetricsUnavailableError + UnsupportedOperationError CloudHttpError UnsupportedError + ].freeze + def test_version_is_available assert_match(/\A\d+\.\d+\.\d+\z/, Microsandbox.version) end @@ -36,7 +53,107 @@ def test_unknown_create_keyword_is_rejected_before_runtime_start end def test_invalid_sandbox_name_is_reported_as_sdk_error - assert_raise(Microsandbox::Error) { Microsandbox::Sandbox.create("") } + error = assert_raise(Microsandbox::InvalidConfigError) { Microsandbox::Sandbox.create("") } + + assert_kind_of Microsandbox::Error, error + assert_equal "invalid-config", error.code + end + + def test_invalid_network_host_raises_network_policy_error + # The policy is built while parsing the create options, before any + # runtime call, so a malformed hostname fails without a sandbox. + error = assert_raise(Microsandbox::NetworkPolicyError) do + Microsandbox::Sandbox.create("ruby-test", network: { allowed_hosts: ["not a host!"], allowed_ports: [443] }) + end + + assert_kind_of Microsandbox::Error, error + assert_equal "network-policy-error", error.code + assert_match(/not a host!/, error.message) + end + + def test_typed_error_is_rescued_by_base_error + rescued = begin + Microsandbox::Sandbox.create("") + rescue Microsandbox::Error => error + error + end + + assert_instance_of Microsandbox::InvalidConfigError, rescued + end + + def test_base_error_code + assert_equal "microsandbox-error", Microsandbox::Error.code + assert_equal "microsandbox-error", Microsandbox::Error.new("boom").code + end + + def test_error_classes_are_direct_subclasses_of_error + defined_classes = Microsandbox.constants.select do |name| + constant = Microsandbox.const_get(name) + constant.is_a?(Class) && constant < Microsandbox::Error + end + + assert_equal ERROR_CLASSES.sort, defined_classes.sort + ERROR_CLASSES.each do |name| + assert_equal Microsandbox::Error, Microsandbox.const_get(name).superclass, name + end + end + + def test_error_codes_are_unique_kebab_case + codes = ERROR_CLASSES.map { |name| Microsandbox.const_get(name).code } + + codes.each { |code| assert_match(/\A[a-z]+(-[a-z]+)*\z/, code) } + assert_equal codes, codes.uniq + assert_not_include codes, Microsandbox::Error.code + assert_equal "exec-timeout", Microsandbox::ExecTimeoutError.new("boom").code + end + + def test_unsupported_error_attributes_default_to_nil + error = Microsandbox::UnsupportedError.new("boom") + + assert_nil error.operation + assert_nil error.hint + assert_equal "unsupported", error.code + end + + def test_missing_sandbox_raises_sandbox_not_found_error + # The local backend answers from its catalog without booting a VM. + name = "does-not-exist-#{Process.pid}-#{rand(1_000_000)}" + + error = assert_raise(Microsandbox::SandboxNotFoundError) { Microsandbox::Sandbox.get(name) } + + assert_kind_of Microsandbox::Error, error + assert_equal "sandbox-not-found", error.code + end + + def test_unsupported_error_carries_operation_and_hint + # The cloud backend rejects `replace:` while building the request, before + # any network access, so this needs neither credentials nor connectivity. + # Selecting a backend is process-global, so the probe runs in a separate + # Ruby process and this process keeps its backend selection. A fork would + # not do: on macOS the cloud client initializes Foundation classes on + # first use, which the Objective-C runtime refuses in a forked child. + backend_kind = Microsandbox.default_backend_kind + script = <<~RUBY + require "microsandbox" + Microsandbox.use_cloud_backend!("test-key", url: "http://127.0.0.1:9") + begin + Microsandbox::Sandbox.create("ruby-test", image: "alpine", replace: true) + puts "no error raised" + rescue Microsandbox::UnsupportedError => error + puts error.message, error.operation, error.hint + end + RUBY + lib = File.expand_path("../lib", __dir__) + + output = IO.popen([RbConfig.ruby, "-I", lib, "-e", script], &:read) + + assert_true $?.success? + assert_equal [ + "sandbox.create is not supported by this backend: the replace option is not accepted here", + "sandbox.create", + "the replace option is not accepted here" + ], output.lines(chomp: true) + assert_equal backend_kind, Microsandbox.default_backend_kind end def test_with_is_available