use std::path::PathBuf;
use serde::Serialize;
pub type MicrosandboxResult<T> = Result<T, MicrosandboxError>;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SnapshotArtifactKind {
Installed,
Archive,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct PublishedSnapshotArtifact {
pub kind: SnapshotArtifactKind,
pub path: PathBuf,
pub snapshot_id: String,
pub digest: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct SnapshotSourceRecoveryError {
pub source_sandbox: String,
pub checkpoint_id: String,
pub checkpoint_root: String,
pub checkpoint_path: PathBuf,
pub artifact: Option<PublishedSnapshotArtifact>,
pub detail: String,
pub publication_error: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum MicrosandboxError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[cfg(any(feature = "cloud", feature = "local"))]
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("cloud HTTP {status}: {message}")]
CloudHttp {
status: u16,
code: Option<String>,
message: String,
},
#[error("libkrunfw not found: {0}")]
LibkrunfwNotFound(String),
#[error("microsandbox runtime is not installed: {0}")]
RuntimeNotInstalled(String),
#[error("microsandbox runtime installation is incomplete: {0}")]
RuntimeIncomplete(String),
#[cfg(feature = "local")]
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("invalid config: {0}")]
InvalidConfig(String),
#[error(
"sandbox has no default command; configure an entrypoint or cmd, or execute a literal command"
)]
NoDefaultCommand,
#[error("sandbox not found: {0}")]
SandboxNotFound(String),
#[error("sandbox already exists: {0}")]
SandboxAlreadyExists(String),
#[error(
"sandbox {name:?} was replaced (expected identity {expected}, found {actual}); refusing stale lifecycle operation"
)]
SandboxReplaced {
name: String,
expected: String,
actual: String,
},
#[error("sandbox still running: {0}")]
SandboxStillRunning(String),
#[error("sandbox {0}")]
SandboxNotRunning(String),
#[error(
"timed out after {timeout:?} waiting for sandbox {name:?} to stop; the accepted stop may still complete"
)]
SandboxStopTimedOut {
name: String,
timeout: std::time::Duration,
},
#[error("runtime error: {0}")]
Runtime(String),
#[error(
"graceful stop of sandbox {name:?} ({identity}) timed out after {timeout:?} waiting for shutdown completion and runtime release; the shutdown request may still complete; no kill was requested"
)]
StopTimeout {
name: String,
identity: String,
timeout: std::time::Duration,
},
#[cfg(feature = "local")]
#[error("failed to start {name:?}: {}", .err.message)]
BootStart {
name: String,
err: microsandbox_runtime::boot_error::BootError,
},
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("protocol error: {0}")]
Protocol(#[from] microsandbox_protocol::ProtocolError),
#[error("agent client error: {0}")]
AgentClient(#[from] crate::agent::AgentClientError),
#[error("control client error: {0}")]
ControlClient(#[source] std::sync::Arc<microsandbox_control_client::ControlClientError>),
#[error(
"runtime or active configuration changed while recording a live control result; the live change may already have applied"
)]
ControlStateChanged,
#[error(
"secret update stopped after {applied_count} applied entries; failed index {failed_index}"
)]
ControlSecretBatch {
applied_count: u32,
failed_index: u32,
error: microsandbox_protocol::control::ControlError,
},
#[cfg(all(feature = "local", unix))]
#[error("nix error: {0}")]
Nix(#[from] nix::errno::Errno),
#[cfg(all(feature = "local", windows))]
#[error("{0}")]
WindowsHostSetup(#[from] crate::setup::WindowsHostSetupError),
#[error("exec timed out after {0:?}")]
ExecTimeout(std::time::Duration),
#[error("exec failed: {}", .0.message)]
ExecFailed(microsandbox_protocol::exec::ExecFailed),
#[error("terminal error: {0}")]
Terminal(String),
#[error("sandbox fs error: {0}")]
SandboxFsOps(String),
#[error("image not found: {0}")]
ImageNotFound(String),
#[error("image in use by sandbox(es): {0}")]
ImageInUse(String),
#[error("volume not found: {0}")]
VolumeNotFound(String),
#[error("volume already exists: {0}")]
VolumeAlreadyExists(String),
#[cfg(feature = "local")]
#[error("image error: {0}")]
Image(#[from] microsandbox_image::ImageError),
#[cfg(feature = "net")]
#[error("network builder: {0}")]
NetworkBuilder(#[from] microsandbox_network::policy::BuildError),
#[error("patch failed: {0}")]
PatchFailed(String),
#[error("snapshot not found: {0}")]
SnapshotNotFound(String),
#[error("snapshot already exists: {0}")]
SnapshotAlreadyExists(String),
#[error("snapshot source sandbox '{0}' is not stopped")]
SnapshotSandboxRunning(String),
#[error("snapshot image missing from cache: {0}")]
SnapshotImageMissing(String),
#[error("snapshot integrity check failed: {0}")]
SnapshotIntegrity(String),
#[error("{0}")]
SnapshotSourceRecovery(Box<SnapshotSourceRecoveryError>),
#[error("snapshot artifact migration failed for {artifact} during {phase}: {code}: {detail}")]
SnapshotMigration {
code: String,
phase: String,
artifact: String,
detail: String,
},
#[error("metrics disabled for sandbox: {0}")]
MetricsDisabled(String),
#[error("metrics unavailable for sandbox: {0}")]
MetricsUnavailable(String),
#[error("log stream missed rotation (dropped from offset {dropped_from_offset})")]
MissedRotation {
dropped_from_offset: u64,
},
#[error("invalid cursor: {0}")]
InvalidCursor(String),
#[error("{} is not supported by this backend: {}", .op.api_path(), .reason.hint())]
Unsupported {
op: Operation,
reason: UnsupportedReason,
},
#[error("{0}")]
Custom(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Operation {
SandboxCreate,
SandboxStart,
SandboxStop,
SandboxPause,
SandboxResume,
SandboxRemove,
SandboxRemovePersisted,
SandboxKill,
SandboxDrain,
SandboxPing,
SandboxTouch,
SandboxStopAndWait,
SandboxWait,
SandboxLogs,
SandboxLogStream,
SandboxLogStreamNoFollow,
SandboxLogStreamFollow,
SandboxFollowLogs,
SandboxLogger,
SandboxMetrics,
SandboxMetricsStream,
SandboxModify,
SandboxFs,
AllSandboxMetrics,
AgentConnect,
SandboxHandleConfig,
SandboxHandleConnect,
SandboxHandleMetrics,
SandboxHandleRemove,
SandboxHandleSnapshot,
SandboxHandleSnapshotTo,
SandboxFsOpenFile,
SandboxFsOpenDir,
SandboxFsCloseHandle,
SandboxFsReadHandle,
SandboxFsReadHandleStream,
SandboxFsWriteHandle,
SandboxFsWriteHandleStream,
SandboxFsReadDirHandle,
SandboxFsStatHandle,
SandboxFsSetStatHandle,
SandboxSshServer,
SshServerServe,
VolumeCreate,
VolumeGet,
VolumeGetDefault,
VolumeList,
VolumeRemove,
VolumePath,
VolumeFsRead,
VolumeFsReadToString,
VolumeFsWrite,
VolumeFsList,
VolumeFsStat,
VolumeFsMkdir,
VolumeFsRemove,
VolumeFsCopy,
VolumeFsRename,
VolumeFsExists,
VolumeFsReadStream,
VolumeFsWriteStream,
ImageGet,
ImageList,
ImageInspect,
ImageRemove,
ImagePrune,
ImageLoad,
ImageSave,
SnapshotOps,
Config,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnsupportedReason {
LocalOnly,
CloudOnly,
UseInstead(Operation),
RequiresUnixHost,
RequiresCrateFeature(&'static str),
ConfigField(&'static str),
MountIntoSandbox,
NotAvailable(String),
}
impl Operation {
pub fn api_path(&self) -> &'static str {
match self {
Operation::SandboxCreate => "Sandbox::create",
Operation::SandboxStart => "Sandbox::start",
Operation::SandboxStop => "Sandbox::stop",
Operation::SandboxPause => "Sandbox::pause",
Operation::SandboxResume => "Sandbox::resume",
Operation::SandboxRemove => "Sandbox::remove",
Operation::SandboxRemovePersisted => "Sandbox::remove_persisted",
Operation::SandboxKill => "Sandbox::kill",
Operation::SandboxDrain => "Sandbox::drain",
Operation::SandboxPing => "Sandbox::ping",
Operation::SandboxTouch => "Sandbox::touch",
Operation::SandboxStopAndWait => "Sandbox::stop_and_wait",
Operation::SandboxWait => "Sandbox::wait",
Operation::SandboxLogs => "Sandbox::logs",
Operation::SandboxLogStream => "Sandbox::log_stream",
Operation::SandboxLogStreamNoFollow => "Sandbox::log_stream(follow=false)",
Operation::SandboxLogStreamFollow => "Sandbox::log_stream(follow=true)",
Operation::SandboxFollowLogs => "Sandbox::follow_logs",
Operation::SandboxLogger => "Sandbox::logger",
Operation::SandboxMetrics => "Sandbox::metrics",
Operation::SandboxMetricsStream => "Sandbox::metrics_stream",
Operation::SandboxModify => "Sandbox::modify",
Operation::SandboxFs => "Sandbox::fs",
Operation::AllSandboxMetrics => "all_sandbox_metrics",
Operation::AgentConnect => "agent connections",
Operation::SandboxHandleConfig => "SandboxHandle::config",
Operation::SandboxHandleConnect => "SandboxHandle::connect",
Operation::SandboxHandleMetrics => "SandboxHandle::metrics",
Operation::SandboxHandleRemove => "SandboxHandle::remove",
Operation::SandboxHandleSnapshot => "SandboxHandle::snapshot",
Operation::SandboxHandleSnapshotTo => "SandboxHandle::snapshot_to",
Operation::SandboxFsOpenFile => "SandboxFsOps::open_file",
Operation::SandboxFsOpenDir => "SandboxFsOps::open_dir",
Operation::SandboxFsCloseHandle => "SandboxFsOps::close_handle",
Operation::SandboxFsReadHandle => "SandboxFsOps::read_handle",
Operation::SandboxFsReadHandleStream => "SandboxFsOps::read_handle_stream",
Operation::SandboxFsWriteHandle => "SandboxFsOps::write_handle",
Operation::SandboxFsWriteHandleStream => "SandboxFsOps::write_handle_stream",
Operation::SandboxFsReadDirHandle => "SandboxFsOps::read_dir_handle",
Operation::SandboxFsStatHandle => "SandboxFsOps::stat_handle",
Operation::SandboxFsSetStatHandle => "SandboxFsOps::set_stat_handle",
Operation::SandboxSshServer => "SandboxSshOps::server",
Operation::SshServerServe => "SshServer::serve",
Operation::VolumeCreate => "Volume::create",
Operation::VolumeGet => "Volume::get",
Operation::VolumeGetDefault => "Volume::get_default",
Operation::VolumeList => "Volume::list",
Operation::VolumeRemove => "Volume::remove",
Operation::VolumePath => "Volume::path",
Operation::VolumeFsRead => "VolumeFs::read",
Operation::VolumeFsReadToString => "VolumeFs::read_to_string",
Operation::VolumeFsWrite => "VolumeFs::write",
Operation::VolumeFsList => "VolumeFs::list",
Operation::VolumeFsStat => "VolumeFs::stat",
Operation::VolumeFsMkdir => "VolumeFs::mkdir",
Operation::VolumeFsRemove => "VolumeFs::remove",
Operation::VolumeFsCopy => "VolumeFs::copy",
Operation::VolumeFsRename => "VolumeFs::rename",
Operation::VolumeFsExists => "VolumeFs::exists",
Operation::VolumeFsReadStream => "VolumeFs::read_stream",
Operation::VolumeFsWriteStream => "VolumeFs::write_stream",
Operation::ImageGet => "Image::get",
Operation::ImageList => "Image::list",
Operation::ImageInspect => "Image::inspect",
Operation::ImageRemove => "Image::remove",
Operation::ImagePrune => "Image::prune",
Operation::ImageLoad => "Image::load",
Operation::ImageSave => "Image::save",
Operation::SnapshotOps => "snapshot operations",
Operation::Config => "config",
}
}
}
impl UnsupportedReason {
pub fn hint(&self) -> String {
match self {
UnsupportedReason::LocalOnly => "use a local backend".to_string(),
UnsupportedReason::CloudOnly => "use a cloud backend".to_string(),
UnsupportedReason::UseInstead(op) => format!("use {}", op.api_path()),
UnsupportedReason::RequiresUnixHost => "unix hosts only".to_string(),
UnsupportedReason::RequiresCrateFeature(feature) => {
format!("enable the {feature} feature")
}
UnsupportedReason::ConfigField(field) => {
format!("the {field} option is not accepted here")
}
UnsupportedReason::MountIntoSandbox => "mount the volume into a sandbox".to_string(),
UnsupportedReason::NotAvailable(reason) => reason.clone(),
}
}
}
impl MicrosandboxError {
pub fn unsupported(op: Operation, reason: UnsupportedReason) -> MicrosandboxError {
MicrosandboxError::Unsupported { op, reason }
}
pub fn local_only(op: Operation) -> MicrosandboxError {
Self::unsupported(op, UnsupportedReason::LocalOnly)
}
pub fn cloud_only(op: Operation) -> MicrosandboxError {
Self::unsupported(op, UnsupportedReason::CloudOnly)
}
}
impl std::fmt::Display for SnapshotSourceRecoveryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(artifact) = &self.artifact {
write!(
f,
"snapshot {} saved at {}; ",
artifact.snapshot_id,
artifact.path.display()
)?;
} else {
write!(
f,
"checkpoint {} retained at {}; requested snapshot publication is unconfirmed; ",
self.checkpoint_id,
self.checkpoint_path.display()
)?;
}
write!(
f,
"source sandbox {:?} requires recovery: {}",
self.source_sandbox, self.detail
)?;
if let Some(error) = &self.publication_error {
write!(f, "; snapshot publication failed: {error}")?;
}
Ok(())
}
}
impl From<microsandbox_types::TypesError> for MicrosandboxError {
fn from(value: microsandbox_types::TypesError) -> Self {
match value {
microsandbox_types::TypesError::InvalidConfig(message) => Self::InvalidConfig(message),
}
}
}
impl From<microsandbox_types::CommandResolutionError> for MicrosandboxError {
fn from(value: microsandbox_types::CommandResolutionError) -> Self {
match value {
microsandbox_types::CommandResolutionError::NoDefaultCommand => Self::NoDefaultCommand,
error => Self::InvalidConfig(error.to_string()),
}
}
}
impl From<microsandbox_types::SnapshotManifestError> for MicrosandboxError {
fn from(value: microsandbox_types::SnapshotManifestError) -> Self {
Self::SnapshotIntegrity(value.to_string())
}
}
#[cfg(feature = "local")]
impl microsandbox_db::retry::IsSqliteBusy for MicrosandboxError {
fn is_sqlite_busy(&self) -> bool {
matches!(self, MicrosandboxError::Database(db_err) if microsandbox_db::retry::is_sqlite_busy(db_err))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_source_recovery_details_keep_artifact_and_diagnostics_structured() {
let mut details = SnapshotSourceRecoveryError {
source_sandbox: "box".into(),
checkpoint_id: "checkpoint_test".into(),
checkpoint_root: "sha256:checkpoint".into(),
checkpoint_path: "/runtime/checkpoint_test".into(),
artifact: Some(PublishedSnapshotArtifact {
kind: SnapshotArtifactKind::Archive,
path: "/snapshots/saved.tar".into(),
snapshot_id: "snap_test".into(),
digest: "sha256:descriptor".into(),
}),
detail: "thaw timed out; re-pause failed".into(),
publication_error: None,
};
let json = serde_json::to_value(&details).unwrap();
assert_eq!(json["artifact"]["kind"], "archive");
assert_eq!(json["artifact"]["path"], "/snapshots/saved.tar");
assert_eq!(json["detail"], details.detail);
assert!(json["publication_error"].is_null());
assert!(
details
.to_string()
.contains("saved at /snapshots/saved.tar")
);
details.artifact = None;
details.publication_error = Some("destination fsync failed".into());
let json = serde_json::to_value(&details).unwrap();
assert!(json["artifact"].is_null());
let rendered = MicrosandboxError::SnapshotSourceRecovery(Box::new(details)).to_string();
assert!(rendered.contains("publication is unconfirmed"));
assert!(rendered.contains("thaw timed out; re-pause failed"));
assert!(rendered.contains("destination fsync failed"));
}
#[test]
fn unsupported_renders_operation_and_reason() {
let err = MicrosandboxError::unsupported(
Operation::SandboxKill,
UnsupportedReason::UseInstead(Operation::SandboxStop),
);
assert_eq!(
err.to_string(),
"Sandbox::kill is not supported by this backend: use Sandbox::stop"
);
let err = MicrosandboxError::local_only(Operation::ImagePrune);
assert_eq!(
err.to_string(),
"Image::prune is not supported by this backend: use a local backend"
);
let err = MicrosandboxError::unsupported(
Operation::SandboxCreate,
UnsupportedReason::ConfigField("ca_certs"),
);
assert_eq!(
err.to_string(),
"Sandbox::create is not supported by this backend: the ca_certs option is not accepted here"
);
}
#[test]
fn sandbox_stop_timeout_explains_that_shutdown_continues() {
let error = MicrosandboxError::SandboxStopTimedOut {
name: "cloud-sandbox".into(),
timeout: std::time::Duration::from_secs(360),
};
assert_eq!(
error.to_string(),
"timed out after 360s waiting for sandbox \"cloud-sandbox\" to stop; the accepted stop may still complete"
);
}
}