diff --git a/crates/cli/bin/main.rs b/crates/cli/bin/main.rs index 12797ae32..c0cc33e90 100644 --- a/crates/cli/bin/main.rs +++ b/crates/cli/bin/main.rs @@ -5,8 +5,8 @@ use std::io::IsTerminal; use clap::{CommandFactory, Parser, Subcommand}; use microsandbox_cli::{ commands::{ - create, exec, image, inspect, install, list, logs, metrics, ps, pull, registry, remove, - run, self_cmd, snapshot, start, stop, uninstall, volume, + create, exec, image, inspect, install, list, logs, metrics, profile, ps, pull, registry, + remove, run, self_cmd, snapshot, start, stop, uninstall, volume, }, log_args::{self, LogArgs}, sandbox_cmd::{self, SandboxArgs}, @@ -113,6 +113,9 @@ enum Commands { /// Manage the msb installation. #[command(name = "self")] Self_(self_cmd::SelfArgs), + + /// Manage SDK backend profiles (local vs cloud). + Profile(profile::ProfileArgs), } //-------------------------------------------------------------------------------------------------- @@ -291,6 +294,7 @@ fn run_async_command_anyhow( Commands::Install(args) => install::run(args).await, Commands::Uninstall(args) => uninstall::run(args).await, Commands::Self_(args) => self_cmd::run(args).await, + Commands::Profile(args) => profile::run(args).await, } }) } diff --git a/crates/cli/lib/commands/common.rs b/crates/cli/lib/commands/common.rs index d40a0485f..cfc4d34fe 100644 --- a/crates/cli/lib/commands/common.rs +++ b/crates/cli/lib/commands/common.rs @@ -1,12 +1,43 @@ //! Common sandbox configuration flags shared between commands. use std::path::PathBuf; +use std::sync::Arc; use clap::Args; +use microsandbox::backend::{Backend, LocalBackend}; use microsandbox::sandbox::SandboxBuilder; use crate::ui; +//-------------------------------------------------------------------------------------------------- +// Functions: Backend resolution +//-------------------------------------------------------------------------------------------------- + +/// Resolve the process-wide local backend exactly once at the CLI entry point. +/// +/// CLI commands always operate against the active default backend. Returns +/// an `Arc` plus a borrow of the `LocalBackend` inside it so +/// callers can dispatch through either the trait or the local-only APIs. +/// Errors when the resolved default backend isn't a local one (e.g. when +/// the user has installed a cloud profile but is running a local-only +/// command). +pub fn resolve_local_backend() -> anyhow::Result> { + let backend = microsandbox::backend::default_backend(); + if backend.as_local().is_none() { + anyhow::bail!( + "this command requires a local backend, but the active default is a cloud backend" + ); + } + Ok(backend) +} + +/// Borrow the `LocalBackend` inside the resolved default backend, or error. +pub fn local_backend_ref(backend: &Arc) -> anyhow::Result<&LocalBackend> { + backend + .as_local() + .ok_or_else(|| anyhow::anyhow!("this command requires a local backend")) +} + //-------------------------------------------------------------------------------------------------- // Types //-------------------------------------------------------------------------------------------------- diff --git a/crates/cli/lib/commands/image.rs b/crates/cli/lib/commands/image.rs index 13da80cbb..aac89d7da 100644 --- a/crates/cli/lib/commands/image.rs +++ b/crates/cli/lib/commands/image.rs @@ -129,8 +129,10 @@ async fn run_pull_inner( ) -> anyhow::Result<()> { let start = Instant::now(); - let global = microsandbox::config::config(); - let cache = microsandbox_image::GlobalCache::new(&global.cache_dir())?; + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; + let global = local.config(); + let cache = microsandbox_image::GlobalCache::new(&local.cache_dir())?; let platform = microsandbox_image::Platform::host_linux(); let image_ref: microsandbox_image::Reference = reference .parse() @@ -141,7 +143,7 @@ async fn run_pull_inner( if let Some((result, metadata)) = microsandbox_image::Registry::pull_cached(&cache, &image_ref, &options)? { - if let Err(e) = Image::persist(&reference, metadata).await { + if let Err(e) = Image::persist(local, &reference, metadata).await { tracing::warn!(error = %e, "failed to persist image metadata to database"); } @@ -231,10 +233,10 @@ async fn run_pull_inner( } // Persist to database. - let cache = microsandbox_image::GlobalCache::new(&global.cache_dir())?; + let cache = microsandbox_image::GlobalCache::new(&local.cache_dir())?; match cache.read_image_metadata(&image_ref) { Ok(Some(metadata)) => { - if let Err(e) = Image::persist(&reference, metadata).await { + if let Err(e) = Image::persist(local, &reference, metadata).await { tracing::warn!(error = %e, "failed to persist image metadata to database"); } } @@ -281,8 +283,9 @@ pub(crate) async fn pull_if_missing(reference: &str, quiet: bool) -> anyhow::Res return Ok(()); } - let global = microsandbox::config::config(); - let cache = microsandbox_image::GlobalCache::new(&global.cache_dir())?; + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; + let cache = microsandbox_image::GlobalCache::new(&local.cache_dir())?; let image_ref: microsandbox_image::Reference = reference .parse() .map_err(|e| anyhow::anyhow!("invalid image reference: {e}"))?; @@ -294,7 +297,7 @@ pub(crate) async fn pull_if_missing(reference: &str, quiet: bool) -> anyhow::Res if let Some((_, metadata)) = microsandbox_image::Registry::pull_cached(&cache, &image_ref, &options)? { - if let Err(e) = Image::persist(reference, metadata).await { + if let Err(e) = Image::persist(local, reference, metadata).await { tracing::warn!(error = %e, "failed to persist image metadata to database"); } return Ok(()); @@ -313,7 +316,9 @@ pub(crate) async fn pull_if_missing(reference: &str, quiet: bool) -> anyhow::Res /// Execute `msb image list` / `msb images`. pub async fn run_list(args: ImageListArgs) -> anyhow::Result<()> { - let images = Image::list().await?; + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; + let images = Image::list(local).await?; if args.format.as_deref() == Some("json") { let entries: Vec = images @@ -372,7 +377,9 @@ pub async fn run_list(args: ImageListArgs) -> anyhow::Result<()> { /// Execute `msb image inspect`. pub async fn run_inspect(args: ImageInspectArgs) -> anyhow::Result<()> { - let detail = Image::inspect(&args.reference).await?; + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; + let detail = Image::inspect(local, &args.reference).await?; if args.format.as_deref() == Some("json") { let layers_json: Vec = detail @@ -494,6 +501,8 @@ pub async fn run_inspect(args: ImageInspectArgs) -> anyhow::Result<()> { /// Execute `msb image rm` / `msb rmi`. pub async fn run_remove(args: ImageRemoveArgs) -> anyhow::Result<()> { + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; let mut failed = false; for reference in &args.references { @@ -503,7 +512,7 @@ pub async fn run_remove(args: ImageRemoveArgs) -> anyhow::Result<()> { ui::Spinner::start("Removing", reference) }; - match Image::remove(reference, args.force).await { + match Image::remove(local, reference, args.force).await { Ok(()) => { spinner.finish_success("Removed"); } diff --git a/crates/cli/lib/commands/inspect.rs b/crates/cli/lib/commands/inspect.rs index 0ba1db601..7ea9ef6fe 100644 --- a/crates/cli/lib/commands/inspect.rs +++ b/crates/cli/lib/commands/inspect.rs @@ -33,7 +33,7 @@ pub async fn run(args: InspectArgs) -> anyhow::Result<()> { serde_json::from_str(handle.config_json()).unwrap_or(serde_json::Value::Null); let json = serde_json::json!({ "name": handle.name(), - "status": format!("{:?}", handle.status()), + "status": format!("{:?}", handle.status_snapshot()), "config": config, "created_at": handle.created_at().map(|dt| ui::format_datetime(&dt)), "updated_at": handle.updated_at().map(|dt| ui::format_datetime(&dt)), @@ -42,7 +42,7 @@ pub async fn run(args: InspectArgs) -> anyhow::Result<()> { return Ok(()); } - let status = format!("{:?}", handle.status()); + let status = format!("{:?}", handle.status_snapshot()); ui::detail_kv("Name", handle.name()); ui::detail_kv("Status", &ui::format_status(&status)); diff --git a/crates/cli/lib/commands/install.rs b/crates/cli/lib/commands/install.rs index 937988f17..acc9ed4f2 100644 --- a/crates/cli/lib/commands/install.rs +++ b/crates/cli/lib/commands/install.rs @@ -5,7 +5,6 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use clap::Args; -use microsandbox::config; use crate::ui; @@ -131,7 +130,12 @@ pub async fn run(args: InstallArgs) -> anyhow::Result<()> { /// Resolve the bin directory for installed aliases. fn resolve_bin_dir() -> PathBuf { - config::config().home().join("bin") + let backend = microsandbox::backend::default_backend(); + let home = match backend.as_local() { + Some(local) => local.config().home(), + None => microsandbox_utils::resolve_home(), + }; + home.join("bin") } /// Validate that an alias name is safe to use as a filename in the bin directory. diff --git a/crates/cli/lib/commands/list.rs b/crates/cli/lib/commands/list.rs index cc8b760d2..d222e325f 100644 --- a/crates/cli/lib/commands/list.rs +++ b/crates/cli/lib/commands/list.rs @@ -41,9 +41,9 @@ pub async fn run(args: ListArgs) -> anyhow::Result<()> { .into_iter() .filter(|s| { if args.running { - s.status() == SandboxStatus::Running + s.status_snapshot() == SandboxStatus::Running } else if args.stopped { - s.status() == SandboxStatus::Stopped + s.status_snapshot() == SandboxStatus::Stopped } else { true } @@ -71,7 +71,7 @@ pub async fn run(args: ListArgs) -> anyhow::Result<()> { for s in &filtered { let image = extract_image(s.config_json()); - let status = format!("{:?}", s.status()); + let status = format!("{:?}", s.status_snapshot()); let created = s .created_at() .as_ref() @@ -110,7 +110,7 @@ fn print_json(sandboxes: &[SandboxHandle]) -> anyhow::Result<()> { .map(|s| { serde_json::json!({ "name": s.name(), - "status": format!("{:?}", s.status()), + "status": format!("{:?}", s.status_snapshot()), "created_at": s.created_at().map(|dt| ui::format_datetime(&dt)), "image": extract_image(s.config_json()), }) diff --git a/crates/cli/lib/commands/logs.rs b/crates/cli/lib/commands/logs.rs index 1202c38fe..ebc1dc911 100644 --- a/crates/cli/lib/commands/logs.rs +++ b/crates/cli/lib/commands/logs.rs @@ -161,7 +161,9 @@ struct LogEntry { /// Execute the `msb logs` command. pub async fn run(args: LogsArgs) -> anyhow::Result<()> { - let log_dir = microsandbox::sandbox::logs::log_dir_for(&args.name); + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; + let log_dir = microsandbox::sandbox::logs::log_dir_for(local, &args.name); if !log_dir.exists() { return Err(anyhow!( "no logs directory for sandbox {:?} (sandbox not found?)", diff --git a/crates/cli/lib/commands/metrics.rs b/crates/cli/lib/commands/metrics.rs index 169da1e39..83220692f 100644 --- a/crates/cli/lib/commands/metrics.rs +++ b/crates/cli/lib/commands/metrics.rs @@ -42,7 +42,9 @@ pub async fn run(args: MetricsArgs) -> anyhow::Result<()> { return Ok(()); } - let mut metrics = all_sandbox_metrics() + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; + let mut metrics = all_sandbox_metrics(local) .await? .into_iter() .collect::>(); diff --git a/crates/cli/lib/commands/mod.rs b/crates/cli/lib/commands/mod.rs index add249b3a..c6f3d1aaf 100644 --- a/crates/cli/lib/commands/mod.rs +++ b/crates/cli/lib/commands/mod.rs @@ -17,6 +17,7 @@ pub mod install; pub mod list; pub mod logs; pub mod metrics; +pub mod profile; pub mod ps; pub mod pull; pub mod registry; @@ -55,7 +56,7 @@ pub async fn maybe_stop(sandbox: &Sandbox) { pub async fn resolve_and_start(name: &str, quiet: bool) -> anyhow::Result { let handle = Sandbox::get(name).await?; - match handle.status() { + match handle.status_snapshot() { SandboxStatus::Running | SandboxStatus::Draining => { // Connect to the running sandbox process via the agent relay. Ok(handle.connect().await?) @@ -83,11 +84,11 @@ pub async fn resolve_and_start(name: &str, quiet: bool) -> anyhow::Result { + SandboxStatus::Created | SandboxStatus::Starting | SandboxStatus::Paused => { anyhow::bail!( "sandbox '{}' is in state {:?} and cannot be started", name, - handle.status() + handle.status_snapshot() ); } } diff --git a/crates/cli/lib/commands/profile.rs b/crates/cli/lib/commands/profile.rs new file mode 100644 index 000000000..d85ac9d82 --- /dev/null +++ b/crates/cli/lib/commands/profile.rs @@ -0,0 +1,220 @@ +//! `msb profile` command — manage SDK backend profiles. +//! +//! Profiles are stored in `~/.microsandbox/config.json` under the +//! `active_profile` + `profiles` keys. Each profile selects a backend (local +//! or cloud) and, for cloud, provides a URL + an `api_key_ref` (env / inline / +//! keyring — see `microsandbox::Profile`). +//! +//! The CLI inherits backend selection from the SDK; profile management is the +//! only CLI-side surface (`msb profile list / use / show`). + +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use microsandbox::{Profile, ProfileBackend, SdkConfig, load_sdk_config}; +use serde_json::Value; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Manage SDK backend profiles. +#[derive(Debug, Args)] +pub struct ProfileArgs { + /// Profile subcommand to run. + #[command(subcommand)] + pub command: ProfileCommands, +} + +/// Profile subcommands. +#[derive(Debug, Subcommand)] +pub enum ProfileCommands { + /// List configured profiles, marking the active one. + #[command(visible_alias = "ls")] + List(ProfileListArgs), + + /// Show details of a profile (does not print secrets — only the `api_key_ref` is shown). + Show(ProfileShowArgs), + + /// Set the active profile. + Use(ProfileUseArgs), +} + +/// Arguments for `msb profile list`. +#[derive(Debug, Args, Default)] +pub struct ProfileListArgs {} + +/// Arguments for `msb profile show`. +#[derive(Debug, Args)] +pub struct ProfileShowArgs { + /// Profile name. Defaults to the currently active profile. + pub name: Option, +} + +/// Arguments for `msb profile use`. +#[derive(Debug, Args)] +pub struct ProfileUseArgs { + /// Profile name to activate. Must exist in `~/.microsandbox/config.json`. + pub name: String, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Execute the `msb profile` command. +pub async fn run(args: ProfileArgs) -> Result<()> { + match args.command { + ProfileCommands::List(args) => run_list(args), + ProfileCommands::Show(args) => run_show(args), + ProfileCommands::Use(args) => run_use(args), + } +} + +fn run_list(_args: ProfileListArgs) -> Result<()> { + let cfg = load_sdk_config().context("load SDK config")?; + if cfg.profiles.is_empty() { + println!("no profiles configured"); + println!( + " add one to {} under \"profiles\":", + microsandbox::config::config_path().display() + ); + println!( + r#" {{ "active_profile": "prod", "profiles": {{ "prod": {{ "backend": "cloud", "url": "https://msb.example.com", "api_key_ref": "env:MSB_API_KEY" }} }} }}"# + ); + return Ok(()); + } + + let active = cfg.active_profile.as_deref(); + let mut names: Vec<&String> = cfg.profiles.keys().collect(); + names.sort(); + for name in names { + let profile = &cfg.profiles[name]; + let marker = if Some(name.as_str()) == active { + "*" + } else { + " " + }; + let kind = match profile.backend { + ProfileBackend::Local => "local".to_string(), + ProfileBackend::Cloud => match &profile.url { + Some(url) => format!("cloud {url}"), + None => "cloud (no url!)".to_string(), + }, + }; + println!("{marker} {name:20} {kind}"); + } + Ok(()) +} + +fn run_show(args: ProfileShowArgs) -> Result<()> { + let cfg = load_sdk_config().context("load SDK config")?; + let name = args + .name + .or(cfg.active_profile.clone()) + .ok_or_else(|| anyhow::anyhow!("no profile name given and no active profile is set"))?; + let profile = cfg + .profiles + .get(&name) + .ok_or_else(|| anyhow::anyhow!("profile {name:?} not found"))?; + print_profile(&name, profile, cfg.active_profile.as_deref() == Some(&name)); + Ok(()) +} + +fn run_use(args: ProfileUseArgs) -> Result<()> { + // Verify the profile exists before activating. + let cfg = load_sdk_config().context("load SDK config")?; + if !cfg.profiles.contains_key(&args.name) { + anyhow::bail!( + "profile {:?} not found in {}", + args.name, + microsandbox::config::config_path().display() + ); + } + set_active_profile(&args.name)?; + println!("Active profile set to {:?}.", args.name); + Ok(()) +} + +/// Read `~/.microsandbox/config.json` as raw JSON, set `active_profile`, +/// write it back. Preserves all other keys (including `LocalConfig` fields, +/// which live in the same file). +fn set_active_profile(name: &str) -> Result<()> { + let path = microsandbox::config::config_path(); + let mut value: Value = if path.exists() { + let raw = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&raw).with_context(|| format!("parse {} as JSON", path.display()))? + } else { + Value::Object(serde_json::Map::new()) + }; + + // Promote to object if it isn't already. + let obj = value + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("config file root must be a JSON object"))?; + obj.insert("active_profile".into(), Value::String(name.to_string())); + + // Ensure parent dir exists (matches save_persisted_config behaviour). + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create config dir {}", parent.display()))?; + } + let serialised = serde_json::to_string_pretty(&value).context("re-serialise config JSON")?; + std::fs::write(&path, serialised).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +fn print_profile(name: &str, profile: &Profile, is_active: bool) { + println!("{}{}", if is_active { "* " } else { " " }, name); + match profile.backend { + ProfileBackend::Local => println!(" backend local"), + ProfileBackend::Cloud => { + println!(" backend cloud"); + println!( + " url {}", + profile.url.as_deref().unwrap_or("(missing!)") + ); + println!( + " api_key_ref {}", + profile + .api_key_ref + .as_deref() + .map(redact_api_key_ref) + .unwrap_or("(missing!)") + ); + } + } +} + +fn redact_api_key_ref(api_key_ref: &str) -> &str { + if api_key_ref.trim_start().starts_with("inline:") { + return "inline:"; + } + api_key_ref +} + +// Suppress unused warnings — `SdkConfig` is re-exported for completeness even +// when its fields aren't read directly here. +const _: fn() -> SdkConfig = SdkConfig::default; + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redact_api_key_ref_hides_inline_secret() { + assert_eq!( + redact_api_key_ref("inline:msb_live_secret"), + "inline:" + ); + assert_eq!( + redact_api_key_ref(" inline:msb_live_secret"), + "inline:" + ); + assert_eq!(redact_api_key_ref("env:MSB_API_KEY"), "env:MSB_API_KEY"); + } +} diff --git a/crates/cli/lib/commands/ps.rs b/crates/cli/lib/commands/ps.rs index acdbf2b6d..3efd8d6eb 100644 --- a/crates/cli/lib/commands/ps.rs +++ b/crates/cli/lib/commands/ps.rs @@ -49,7 +49,8 @@ pub async fn run(args: PsArgs) -> anyhow::Result<()> { let mut sandboxes = Sandbox::list().await?; if !args.all { sandboxes.retain(|s| { - s.status() == SandboxStatus::Running || s.status() == SandboxStatus::Draining + s.status_snapshot() == SandboxStatus::Running + || s.status_snapshot() == SandboxStatus::Draining }); } sandboxes.sort_by(|left, right| left.name().cmp(right.name())); @@ -121,7 +122,7 @@ fn status_row(handle: &SandboxHandle) -> StatusRow { .as_ref() .map(format_ports) .unwrap_or_else(|| "-".to_string()); - let status = format!("{:?}", handle.status()); + let status = format!("{:?}", handle.status_snapshot()); StatusRow { name: handle.name().to_string(), @@ -134,7 +135,7 @@ fn status_row(handle: &SandboxHandle) -> StatusRow { fn status_json(handle: &SandboxHandle) -> serde_json::Value { let config = serde_json::from_str::(handle.config_json()).ok(); - let status = format!("{:?}", handle.status()); + let status = format!("{:?}", handle.status_snapshot()); serde_json::json!({ "name": handle.name(), diff --git a/crates/cli/lib/commands/snapshot.rs b/crates/cli/lib/commands/snapshot.rs index 066b6c89f..6b8e0ce04 100644 --- a/crates/cli/lib/commands/snapshot.rs +++ b/crates/cli/lib/commands/snapshot.rs @@ -338,9 +338,14 @@ async fn remove(args: SnapshotRemoveArgs) -> anyhow::Result<()> { } async fn reindex(args: SnapshotReindexArgs) -> anyhow::Result<()> { - let dir = args - .dir - .unwrap_or_else(|| microsandbox::config::config().snapshots_dir()); + let dir = match args.dir { + Some(d) => d, + None => { + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; + local.snapshots_dir() + } + }; let n = Snapshot::reindex(&dir).await?; println!("indexed {n} snapshot(s) from {}", dir.display()); Ok(()) diff --git a/crates/cli/lib/commands/uninstall.rs b/crates/cli/lib/commands/uninstall.rs index ee27aa7d2..61676f324 100644 --- a/crates/cli/lib/commands/uninstall.rs +++ b/crates/cli/lib/commands/uninstall.rs @@ -3,7 +3,6 @@ use std::fs; use clap::Args; -use microsandbox::config; use super::install::MARKER; use crate::ui; @@ -26,7 +25,12 @@ pub struct UninstallArgs { /// Execute the `msb uninstall` command. pub async fn run(args: UninstallArgs) -> anyhow::Result<()> { - let bin_dir = config::config().home().join("bin"); + let backend = microsandbox::backend::default_backend(); + let home = match backend.as_local() { + Some(local) => local.config().home(), + None => microsandbox_utils::resolve_home(), + }; + let bin_dir = home.join("bin"); let mut failed = false; diff --git a/crates/cli/lib/commands/volume.rs b/crates/cli/lib/commands/volume.rs index 99c88844f..5aa61513f 100644 --- a/crates/cli/lib/commands/volume.rs +++ b/crates/cli/lib/commands/volume.rs @@ -187,8 +187,9 @@ async fn inspect(args: VolumeInspectArgs) -> anyhow::Result<()> { .join(", ") }; - let volumes_dir = microsandbox::config::config().volumes_dir(); - let path = volumes_dir.join(handle.name()); + let backend = crate::commands::common::resolve_local_backend()?; + let local = crate::commands::common::local_backend_ref(&backend)?; + let path = local.volume_path(handle.name()); ui::detail_kv("Name", handle.name()); ui::detail_kv("Quota", "a); diff --git a/crates/cli/lib/ui.rs b/crates/cli/lib/ui.rs index 08ad3c410..822c88b32 100644 --- a/crates/cli/lib/ui.rs +++ b/crates/cli/lib/ui.rs @@ -323,6 +323,8 @@ pub fn success(verb: &str, target: &str) { /// Format a sandbox status with appropriate color. pub fn format_status(status: &str) -> String { match status { + "Created" => format!("{}", style("created").dim()), + "Starting" => format!("{}", style("starting").yellow().bold()), "Running" => format!("{}", style("running").green().bold()), "Stopped" => format!("{}", style("stopped").dim()), "Paused" => format!("{}", style("paused").yellow().bold()), diff --git a/crates/db/lib/entity/sandbox.rs b/crates/db/lib/entity/sandbox.rs index a9f4231c0..ca9776286 100644 --- a/crates/db/lib/entity/sandbox.rs +++ b/crates/db/lib/entity/sandbox.rs @@ -10,6 +10,20 @@ use sea_orm::entity::prelude::*; #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "Text")] pub enum SandboxStatus { + /// The sandbox has been created but not yet started. + /// + /// Cloud-only today: msb-cloud's create-without-start state. Local + /// sandboxes transition straight to `Running` after create. + #[sea_orm(string_value = "Created")] + Created, + + /// A start request has been submitted but the sandbox is not yet running. + /// + /// Cloud-only today: covers the gap between accepting a start request + /// and the runtime reporting the VM as live. + #[sea_orm(string_value = "Starting")] + Starting, + /// The sandbox is running. #[sea_orm(string_value = "Running")] Running, diff --git a/crates/microsandbox/lib/backend/cloud.rs b/crates/microsandbox/lib/backend/cloud.rs new file mode 100644 index 000000000..cd96bfe40 --- /dev/null +++ b/crates/microsandbox/lib/backend/cloud.rs @@ -0,0 +1,568 @@ +//! Cloud backend implementation — talks to an msb-cloud control plane over HTTP. +//! +//! Holds the (url, api_key) tuple and a `reqwest::Client`. Sub-trait +//! implementations (sandbox lifecycle, exec, volumes, …) land alongside the +//! `Backend` trait's sub-trait surface as that surface is filled in. +//! +//! Construction is URL + API key first; `from_env` and `from_profile` are sugar. +//! Auth is API-key-only — the same `msb_live_*` / `msb_test_*` tokens msb-cloud +//! issues today. No OAuth or session credentials are honored here. + +use std::sync::Arc; +use std::time::Duration; + +use reqwest::Response; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT}; + +use super::cloud_wire::{ + CloudCreateSandboxRequest, CloudErrorBody, CloudMessageResponse, CloudPaginated, CloudSandbox, +}; +use super::{Backend, BackendKind, SandboxBackend, VolumeBackend}; +use crate::{MicrosandboxError, MicrosandboxResult}; + +//-------------------------------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------------------------------- + +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Default User-Agent header value. +fn default_user_agent() -> String { + format!("microsandbox-sdk/{}", env!("CARGO_PKG_VERSION")) +} + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Cloud-runtime backend: talks to an msb-cloud control plane over HTTP. +/// +/// Holds the deployment URL and API key. The `(url, api_key)` pair determines +/// which org's view the backend sees: msb-cloud derives the org from the API +/// key, so there is no per-call org argument. +/// +/// Constructors: +/// - [`CloudBackend::new`] — primary; explicit URL + key. Works for hosted SaaS, +/// self-hosted, and on-prem deployments identically. +/// - [`CloudBackend::from_env`] — reads `MSB_API_URL` + `MSB_API_KEY`. +/// - [`CloudBackend::from_profile`] — reads a named profile from the SDK config. +/// - [`CloudBackend::builder`] — tuned construction (custom client, timeout, +/// user agent). +pub struct CloudBackend { + url: String, + #[allow(dead_code)] // referenced by HTTP layer + future sub-trait impls + api_key: String, + http: reqwest::Client, +} + +/// Fluent builder for `CloudBackend`. Use for tuned construction. +/// +/// ```ignore +/// let cloud = CloudBackend::builder() +/// .url("https://msb.example.com") +/// .api_key(key) +/// .request_timeout(Duration::from_secs(60)) +/// .build()?; +/// ``` +pub struct CloudBackendBuilder { + url: Option, + api_key: Option, + request_timeout: Duration, + user_agent: Option, + custom_client: Option, +} + +//-------------------------------------------------------------------------------------------------- +// Methods: CloudBackend +//-------------------------------------------------------------------------------------------------- + +impl CloudBackend { + /// Construct a `CloudBackend` with an explicit URL and API key. + /// + /// Primary constructor. Works identically for hosted msb-cloud, self-hosted + /// deployments, and on-prem installs — no constructor implies a specific + /// deployment shape. + pub fn new(url: impl Into, api_key: impl Into) -> MicrosandboxResult { + Self::builder().url(url).api_key(api_key).build() + } + + /// Construct from `MSB_API_URL` + `MSB_API_KEY` env vars. + /// + /// Returns `InvalidConfig` if either is missing or empty. + pub fn from_env() -> MicrosandboxResult { + let url = std::env::var("MSB_API_URL").map_err(|_| { + MicrosandboxError::InvalidConfig( + "MSB_API_URL not set — required for cloud backend".into(), + ) + })?; + let api_key = std::env::var("MSB_API_KEY").map_err(|_| { + MicrosandboxError::InvalidConfig( + "MSB_API_KEY not set — required for cloud backend".into(), + ) + })?; + Self::new(url.trim(), api_key.trim()) + } + + /// Construct from a named SDK profile in `~/.microsandbox/config.json`. + /// + /// Profiles are local SDK sugar over the primary `(url, api_key)` constructor; + /// msb-cloud does not receive or interpret profile names. + pub fn from_profile(name: &str) -> MicrosandboxResult { + super::profile::cloud_backend_from_profile(name) + } + + /// Start building a `CloudBackend` with custom options. Call `.build()` when done. + pub fn builder() -> CloudBackendBuilder { + CloudBackendBuilder::default() + } + + /// Configured msb-cloud endpoint URL (no trailing slash). + pub fn url(&self) -> &str { + &self.url + } +} + +//-------------------------------------------------------------------------------------------------- +// Methods: Sandbox lifecycle +// +// HTTP dispatch for the SDK's sandbox lifecycle ops, hitting msb-cloud's +// API-key-authenticated routes (`/v1/sandboxes/*` and `/v1/sandboxes/by-name/*`). +//-------------------------------------------------------------------------------------------------- + +impl CloudBackend { + /// `POST /v1/sandboxes` (optionally `?start=true`). + /// + /// Pass `start=true` to atomically create-and-start in a single round-trip + /// — mirrors the `POST /v1/sandboxes?start=true` shorthand on msb-cloud. + pub async fn create_sandbox( + &self, + req: &CloudCreateSandboxRequest, + start: bool, + ) -> MicrosandboxResult { + let path = if start { + "/v1/sandboxes?start=true" + } else { + "/v1/sandboxes" + }; + let url = format!("{}{}", self.url, path); + let resp = self + .http + .post(&url) + .json(req) + .send() + .await + .map_err(|e| cloud_io_error("POST /v1/sandboxes", e))?; + decode_json(resp, "POST /v1/sandboxes").await + } + + /// `GET /v1/sandboxes` — paginated. + pub async fn list_sandboxes( + &self, + cursor: Option<&str>, + limit: Option, + ) -> MicrosandboxResult> { + let mut url = format!("{}/v1/sandboxes", self.url); + let mut query = Vec::new(); + if let Some(c) = cursor { + query.push(format!("cursor={}", urlencoding(c))); + } + if let Some(l) = limit { + query.push(format!("limit={l}")); + } + if !query.is_empty() { + url.push('?'); + url.push_str(&query.join("&")); + } + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| cloud_io_error("GET /v1/sandboxes", e))?; + decode_json(resp, "GET /v1/sandboxes").await + } + + /// `GET /v1/sandboxes/by-name/:name`. + pub async fn get_sandbox(&self, name: &str) -> MicrosandboxResult { + let url = format!("{}/v1/sandboxes/by-name/{}", self.url, urlencoding(name)); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| cloud_io_error("GET /v1/sandboxes/by-name/:name", e))?; + decode_json(resp, "GET /v1/sandboxes/by-name/:name").await + } + + /// `POST /v1/sandboxes/by-name/:name/start`. + pub async fn start_sandbox(&self, name: &str) -> MicrosandboxResult { + let url = format!( + "{}/v1/sandboxes/by-name/{}/start", + self.url, + urlencoding(name) + ); + let resp = self + .http + .post(&url) + .json(&serde_json::json!({})) + .send() + .await + .map_err(|e| cloud_io_error("POST start", e))?; + decode_json(resp, "POST /v1/sandboxes/by-name/:name/start").await + } + + /// `POST /v1/sandboxes/by-name/:name/stop`. + pub async fn stop_sandbox(&self, name: &str) -> MicrosandboxResult { + let url = format!( + "{}/v1/sandboxes/by-name/{}/stop", + self.url, + urlencoding(name) + ); + let resp = self + .http + .post(&url) + .json(&serde_json::json!({})) + .send() + .await + .map_err(|e| cloud_io_error("POST stop", e))?; + decode_json(resp, "POST /v1/sandboxes/by-name/:name/stop").await + } + + /// `DELETE /v1/sandboxes/by-name/:name`. Returns the typed `MessageResponse` + /// msb-cloud emits. + pub async fn destroy_sandbox(&self, name: &str) -> MicrosandboxResult { + let url = format!("{}/v1/sandboxes/by-name/{}", self.url, urlencoding(name)); + let resp = self + .http + .delete(&url) + .send() + .await + .map_err(|e| cloud_io_error("DELETE /v1/sandboxes/by-name/:name", e))?; + decode_json(resp, "DELETE /v1/sandboxes/by-name/:name").await + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions: HTTP helpers +//-------------------------------------------------------------------------------------------------- + +/// Parse a JSON response into `T`, mapping HTTP errors to typed +/// `MicrosandboxError` variants. Tries to decode msb-cloud's typed error body +/// for richer messages on 4xx/5xx. +async fn decode_json( + resp: Response, + op: &str, +) -> MicrosandboxResult { + let status = resp.status(); + if status.is_success() { + return resp + .json::() + .await + .map_err(|e| MicrosandboxError::Custom(format!("{op}: failed to decode body: {e}"))); + } + let body_text = resp.text().await.unwrap_or_default(); + let typed: Option = serde_json::from_str(&body_text).ok(); + Err(cloud_http_error( + status.as_u16(), + typed.as_ref(), + &body_text, + op, + )) +} + +fn cloud_io_error(op: &str, e: reqwest::Error) -> MicrosandboxError { + tracing::debug!(operation = op, error = %e, "cloud backend transport error"); + MicrosandboxError::Http(e) +} + +fn cloud_http_error( + status: u16, + body: Option<&CloudErrorBody>, + raw_body: &str, + op: &str, +) -> MicrosandboxError { + let code = cloud_error_code(body).map(ToOwned::to_owned); + let summary = cloud_error_message(body) + .or_else(|| (!raw_body.trim().is_empty()).then_some(raw_body.trim())) + .unwrap_or("no response body"); + let message = format!("{op}: {summary}"); + + match code.as_deref() { + Some("sandbox_not_found") => return MicrosandboxError::SandboxNotFound(message), + Some("name_already_exists") => return MicrosandboxError::SandboxAlreadyExists(message), + Some("invalid_request") | Some("invalid_sandbox_config") => { + return MicrosandboxError::InvalidConfig(message); + } + Some("orchestrator_unreachable") | Some("nomad_job_failed") => { + return MicrosandboxError::Runtime(message); + } + _ => {} + } + + match status { + 400 | 422 => MicrosandboxError::InvalidConfig(message), + 404 => MicrosandboxError::SandboxNotFound(message), + 409 if op == "POST /v1/sandboxes" => MicrosandboxError::SandboxAlreadyExists(message), + 502 => MicrosandboxError::Runtime(message), + _ => MicrosandboxError::CloudHttp { + status, + code, + message, + }, + } +} + +fn cloud_error_code(body: Option<&CloudErrorBody>) -> Option<&str> { + body.and_then(|body| { + body.error + .as_ref() + .and_then(|err| err.code.as_deref()) + .or(body.code.as_deref()) + }) +} + +fn cloud_error_message(body: Option<&CloudErrorBody>) -> Option<&str> { + body.and_then(|body| { + body.error + .as_ref() + .and_then(|err| err.message.as_deref()) + .or(body.message.as_deref()) + }) +} + +/// Minimal percent-encoding for path segments. Avoids pulling in another crate +/// for one call site. Encodes characters outside the unreserved set per RFC +/// 3986 (`ALPHA / DIGIT / "-" / "." / "_" / "~"`). +fn urlencoding(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.as_bytes() { + match *b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(*b as char); + } + other => out.push_str(&format!("%{other:02X}")), + } + } + out +} + +//-------------------------------------------------------------------------------------------------- +// Methods: CloudBackendBuilder +//-------------------------------------------------------------------------------------------------- + +impl CloudBackendBuilder { + /// Set the msb-cloud endpoint URL. + pub fn url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + /// Set the API key (`msb_live_...` / `msb_test_...`). + pub fn api_key(mut self, key: impl Into) -> Self { + self.api_key = Some(key.into()); + self + } + + /// Set the per-request timeout for outbound HTTP calls. + pub fn request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + /// Override the default `User-Agent` header value. + pub fn user_agent(mut self, ua: impl Into) -> Self { + self.user_agent = Some(ua.into()); + self + } + + /// Provide a fully custom `reqwest::Client`. When set, `request_timeout` + /// and `user_agent` builder options are ignored — the supplied client owns + /// its own configuration. + pub fn client(mut self, client: reqwest::Client) -> Self { + self.custom_client = Some(client); + self + } + + /// Build the `CloudBackend`. Errors when URL or API key are missing, or + /// when the underlying HTTP client fails to construct. + pub fn build(self) -> MicrosandboxResult { + let url = self.url.ok_or_else(|| { + MicrosandboxError::InvalidConfig("CloudBackend requires a URL (call .url(...))".into()) + })?; + let url = url.trim(); + if url.is_empty() { + return Err(MicrosandboxError::InvalidConfig( + "CloudBackend URL must not be empty".into(), + )); + } + let api_key = self.api_key.ok_or_else(|| { + MicrosandboxError::InvalidConfig( + "CloudBackend requires an API key (call .api_key(...))".into(), + ) + })?; + let api_key = api_key.trim(); + if api_key.is_empty() { + return Err(MicrosandboxError::InvalidConfig( + "CloudBackend API key must not be empty".into(), + )); + } + // Normalise trailing slash so per-route construction can append cleanly. + let url = url.trim_end_matches('/').to_string(); + let api_key = api_key.to_string(); + + let http = if let Some(client) = self.custom_client { + client + } else { + let mut headers = HeaderMap::new(); + let bearer = format!("Bearer {api_key}"); + let mut auth_value = HeaderValue::from_str(&bearer).map_err(|e| { + MicrosandboxError::InvalidConfig(format!("invalid API key header value: {e}")) + })?; + auth_value.set_sensitive(true); + headers.insert(AUTHORIZATION, auth_value); + let ua = self.user_agent.unwrap_or_else(default_user_agent); + headers.insert( + USER_AGENT, + HeaderValue::from_str(&ua).map_err(|e| { + MicrosandboxError::InvalidConfig(format!("invalid user-agent value: {e}")) + })?, + ); + + reqwest::Client::builder() + .timeout(self.request_timeout) + .default_headers(headers) + .build() + .map_err(|e| { + MicrosandboxError::InvalidConfig(format!("failed to build HTTP client: {e}")) + })? + }; + + Ok(CloudBackend { url, api_key, http }) + } +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations +//-------------------------------------------------------------------------------------------------- + +impl Backend for CloudBackend { + fn kind(&self) -> BackendKind { + BackendKind::Cloud + } + + fn sandboxes(&self) -> &dyn SandboxBackend { + self + } + + fn volumes(&self) -> &dyn VolumeBackend { + self + } +} + +impl Default for CloudBackendBuilder { + fn default() -> Self { + Self { + url: None, + api_key: None, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + user_agent: None, + custom_client: None, + } + } +} + +impl From for Arc { + fn from(backend: CloudBackend) -> Self { + Arc::new(backend) + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_succeeds_with_url_and_key() { + let b = CloudBackend::new("https://msb.example.com", "msb_test_abc").unwrap(); + assert_eq!(b.kind(), BackendKind::Cloud); + assert_eq!(b.url(), "https://msb.example.com"); + } + + #[test] + fn new_strips_trailing_slash() { + let b = CloudBackend::new("https://msb.example.com/", "msb_test_abc").unwrap(); + assert_eq!(b.url(), "https://msb.example.com"); + } + + #[test] + fn builder_rejects_missing_url() { + assert!(CloudBackendBuilder::default().api_key("k").build().is_err()); + } + + #[test] + fn builder_rejects_missing_key() { + assert!( + CloudBackendBuilder::default() + .url("https://x") + .build() + .is_err() + ); + } + + #[test] + fn builder_rejects_empty_url() { + assert!(CloudBackend::new("", "k").is_err()); + } + + #[test] + fn builder_rejects_whitespace_url() { + assert!(CloudBackend::new(" ", "k").is_err()); + } + + #[test] + fn builder_rejects_empty_key() { + assert!(CloudBackend::new("https://x", "").is_err()); + } + + #[test] + fn builder_rejects_whitespace_key() { + assert!(CloudBackend::new("https://x", " ").is_err()); + } + + #[test] + fn from_env_errors_when_url_missing() { + // Note: this test can race with parallel tests setting env vars. Just + // verify the function returns an error when MSB_API_URL is clearly absent; + // we don't try to scrub env state. + unsafe { std::env::remove_var("MSB_API_URL") }; + assert!(CloudBackend::from_env().is_err()); + } + + #[test] + fn cloud_http_error_uses_nested_error_body() { + let body: CloudErrorBody = serde_json::from_str( + r#"{"error":{"code":"sandbox_not_found","message":"sandbox missing"}}"#, + ) + .unwrap(); + let err = cloud_http_error(404, Some(&body), "", "GET /v1/sandboxes/by-name/:name"); + assert!( + matches!(err, MicrosandboxError::SandboxNotFound(msg) if msg.contains("sandbox missing")) + ); + } + + #[test] + fn cloud_http_error_maps_create_conflict_to_already_exists() { + let body: CloudErrorBody = serde_json::from_str( + r#"{"error":{"code":"name_already_exists","message":"name taken"}}"#, + ) + .unwrap(); + let err = cloud_http_error(409, Some(&body), "", "POST /v1/sandboxes"); + assert!( + matches!(err, MicrosandboxError::SandboxAlreadyExists(msg) if msg.contains("name taken")) + ); + } +} diff --git a/crates/microsandbox/lib/backend/cloud_wire.rs b/crates/microsandbox/lib/backend/cloud_wire.rs new file mode 100644 index 000000000..dedc2ecbe --- /dev/null +++ b/crates/microsandbox/lib/backend/cloud_wire.rs @@ -0,0 +1,279 @@ +//! Wire types for the cloud backend's HTTP calls to msb-cloud. +//! +//! These mirror msb-cloud's `crates/msb-models/src/sandbox.rs` shape — name + +//! field set + JSON serialisation must match byte-for-byte. The shared +//! `task-config.golden.json` fixture on the msb-cloud side is the contract; +//! drift breaks the contract test there. +//! +//! Duplicated here (rather than depending on msb-cloud crates) to keep +//! microsandbox a single-repo build. If the duplication becomes painful, the +//! candidate is extracting to a shared `microsandbox-protocol` crate that both +//! projects pull in. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +//-------------------------------------------------------------------------------------------------- +// Types: Request +//-------------------------------------------------------------------------------------------------- + +/// Wire shape of `POST /v1/sandboxes` request body. +/// +/// **Must stay in sync** with msb-cloud's `CreateSandboxRequest` in +/// `crates/msb-models/src/sandbox.rs`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct CloudCreateSandboxRequest { + /// User-facing sandbox name. + pub name: String, + /// OCI image reference to run. + pub image: String, + /// Virtual CPU count. + pub vcpus: u8, + /// Guest memory in MiB. + pub memory_mib: u32, + /// Environment variables injected into the sandbox. + pub env: HashMap, + /// Whether the sandbox should be removed when its allocation terminates. + pub ephemeral: bool, + + // Optional config fields. + /// Working directory inside the guest. + #[serde(skip_serializing_if = "Option::is_none")] + pub workdir: Option, + /// Default shell inside the guest. + #[serde(skip_serializing_if = "Option::is_none")] + pub shell: Option, + /// OCI entrypoint override. + #[serde(skip_serializing_if = "Option::is_none")] + pub entrypoint: Option>, + /// Guest hostname override. + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option, + /// Guest user identity. + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + /// Runtime log verbosity. + #[serde(skip_serializing_if = "Option::is_none")] + pub log_level: Option, + /// Named scripts mounted into the guest. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub scripts: HashMap, + /// Hard sandbox lifetime cap in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_duration_secs: Option, + /// Idle timeout in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub idle_timeout_secs: Option, +} + +impl Default for CloudCreateSandboxRequest { + fn default() -> Self { + Self { + name: String::new(), + image: String::new(), + vcpus: 1, + memory_mib: 512, + env: HashMap::new(), + ephemeral: true, + workdir: None, + shell: None, + entrypoint: None, + hostname: None, + user: None, + log_level: None, + scripts: HashMap::new(), + max_duration_secs: None, + idle_timeout_secs: None, + } + } +} + +//-------------------------------------------------------------------------------------------------- +// Types: Response +//-------------------------------------------------------------------------------------------------- + +/// Wire shape of the `Sandbox` response from msb-cloud — what every sandbox +/// endpoint returns. Mirrors msb-cloud's `Sandbox` model with `skip_serializing` +/// fields excluded. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudSandbox { + /// Server-side UUID (as a string — avoids pulling in the `uuid` crate here). + pub id: String, + /// Owning org's UUID (as a string). + pub org_id: String, + /// User-facing sandbox name. + pub name: String, + /// Current lifecycle status. + pub status: CloudSandboxStatus, + /// Create request stored by msb-cloud. + pub config: CloudCreateSandboxRequest, + /// Whether the sandbox should be removed when its allocation terminates. + pub ephemeral: bool, + /// Creation timestamp. + pub created_at: DateTime, + /// Last start timestamp, when known. + #[serde(default)] + pub started_at: Option>, + /// Last stop timestamp, when known. + #[serde(default)] + pub stopped_at: Option>, + /// Last failure reason, when any. + #[serde(default)] + pub last_error: Option, +} + +/// Sandbox lifecycle status — must match msb-cloud's `SandboxStatus` enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CloudSandboxStatus { + /// Created in the database but not yet started. + Created, + /// Start request has been submitted. + Starting, + /// Sandbox is running. + Running, + /// Stop request has been submitted. + Stopping, + /// Sandbox is stopped. + Stopped, + /// Sandbox failed. + Failed, +} + +/// Wire shape of paginated list responses: `{ data: [...], next_cursor: "..." }`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudPaginated { + /// Page of response items. + pub data: Vec, + /// Cursor for the next page, when one exists. + #[serde(default)] + pub next_cursor: Option, +} + +/// Wire shape of the `MessageResponse` returned by `DELETE` endpoints. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudMessageResponse { + /// Human-readable response message. + pub message: String, +} + +/// Wire shape of the typed error body msb-cloud returns on 4xx/5xx. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudErrorBody { + /// Flat machine-readable error code, when returned in this shape. + #[serde(default)] + pub code: Option, + /// Flat human-readable error message, when returned in this shape. + #[serde(default)] + pub message: Option, + /// Nested error object returned by msb-cloud's `ApiError` responder. + #[serde(default)] + pub error: Option, +} + +/// Nested msb-cloud API error details. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudErrorDetails { + /// Machine-readable error code. + #[serde(default)] + pub code: Option, + /// Human-readable error message. + #[serde(default)] + pub message: Option, +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_request_serialises_minimal() { + let req = CloudCreateSandboxRequest { + name: "agent-1".into(), + image: "python:3.12".into(), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + // Required fields present. + assert_eq!(json["name"], "agent-1"); + assert_eq!(json["image"], "python:3.12"); + assert_eq!(json["vcpus"], 1); + assert_eq!(json["memory_mib"], 512); + assert_eq!(json["ephemeral"], true); + // Optional fields elided when unset. + assert!(json.get("workdir").is_none()); + assert!(json.get("entrypoint").is_none()); + assert!(json.get("max_duration_secs").is_none()); + } + + #[test] + fn create_request_serialises_full_d13() { + let mut req = CloudCreateSandboxRequest { + name: "agent-1".into(), + image: "python:3.12".into(), + workdir: Some("/app".into()), + shell: Some("/bin/bash".into()), + entrypoint: Some(vec!["python".into(), "-u".into()]), + hostname: Some("worker".into()), + user: Some("appuser".into()), + log_level: Some("info".into()), + max_duration_secs: Some(3600), + idle_timeout_secs: Some(600), + ..Default::default() + }; + req.scripts.insert("setup".into(), "echo hi".into()); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["workdir"], "/app"); + assert_eq!(json["shell"], "/bin/bash"); + assert_eq!(json["entrypoint"], serde_json::json!(["python", "-u"])); + assert_eq!(json["max_duration_secs"], 3600); + assert_eq!(json["scripts"]["setup"], "echo hi"); + } + + #[test] + fn sandbox_status_round_trips() { + for status in [ + CloudSandboxStatus::Created, + CloudSandboxStatus::Starting, + CloudSandboxStatus::Running, + CloudSandboxStatus::Stopping, + CloudSandboxStatus::Stopped, + CloudSandboxStatus::Failed, + ] { + let s = serde_json::to_string(&status).unwrap(); + let parsed: CloudSandboxStatus = serde_json::from_str(&s).unwrap(); + assert_eq!(status, parsed); + } + } + + #[test] + fn sandbox_status_serialises_snake_case() { + let s = serde_json::to_string(&CloudSandboxStatus::Starting).unwrap(); + assert_eq!(s, "\"starting\""); + } + + #[test] + fn sandbox_response_parses_typical() { + let json = r#"{ + "id": "00000000-0000-0000-0000-000000000002", + "org_id": "00000000-0000-0000-0000-000000000001", + "name": "agent-1", + "status": "created", + "config": { "name": "agent-1", "image": "python:3.12" }, + "ephemeral": true, + "created_at": "2026-05-17T12:00:00Z" + }"#; + let sb: CloudSandbox = serde_json::from_str(json).unwrap(); + assert_eq!(sb.name, "agent-1"); + assert_eq!(sb.status, CloudSandboxStatus::Created); + assert_eq!(sb.config.image, "python:3.12"); + assert!(sb.started_at.is_none()); + } +} diff --git a/crates/microsandbox/lib/backend/local.rs b/crates/microsandbox/lib/backend/local.rs new file mode 100644 index 000000000..f34fa2fd2 --- /dev/null +++ b/crates/microsandbox/lib/backend/local.rs @@ -0,0 +1,742 @@ +//! Local backend implementation — wraps today's libkrun + agentd path. +//! +//! Holds local-only state (DB pool, paths config, sandbox defaults, registry +//! config) as fields on a single struct, replacing the per-process global +//! config + DB pool statics that lived in `crate::config` / `crate::db`. Two +//! construction paths: +//! +//! - [`LocalBackend::lazy`] — sync ambient default. Initialises its DB pool + +//! config lazily on first access. Used by the ambient `default_backend()` +//! when no explicit backend is installed. +//! - [`LocalBackend::builder`] — programmatic config. `.build().await` +//! constructs eagerly with all DB pools + config resolved up front. +//! +//! Per D6.7 Layer 2a in the SDK local-cloud parity plan: this struct absorbs +//! the bulk of the old global config singleton plus the SQLite pool, so multiple +//! backends can hold different configurations for tests / migrations. + +use std::collections::HashMap; +use std::num::NonZero; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use microsandbox_db::pool::DbPools; +use microsandbox_migration::{Migrator, MigratorTrait}; +use tokio::sync::OnceCell; + +use super::{Backend, BackendKind, SandboxBackend, VolumeBackend}; +use crate::config::{DatabaseConfig, LocalConfig, RegistryEntry, load_persisted_config_or_default}; +use crate::{MicrosandboxError, MicrosandboxResult}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Local-runtime backend: spawns microVMs via libkrun on the calling host. +/// +/// Owns the persisted [`LocalConfig`] (paths, sandbox defaults, registry +/// settings, database tuning) and the SQLite [`DbPools`] for this instance. +/// Built via either [`LocalBackend::lazy`] (no-explicit-setup ambient +/// default, lazily initialised) or [`LocalBackend::builder`] (programmatic). +pub struct LocalBackend { + config: Arc, + db: OnceCell, +} + +/// Fluent builder for [`LocalBackend`]. Construct via [`LocalBackend::builder`]. +/// +/// All fields are optional. [`build`](Self::build)`.await` produces a +/// `LocalBackend` whose DB pool has already been opened and migrated. +/// +/// `build` overlays the builder's overrides on top of the persisted +/// `~/.microsandbox/config.json` (honouring `MSB_CONFIG_PATH`). Persisted +/// values fill in everything the builder didn't set; builder overrides win. +/// Override the `home()` setter to point the merge at a different config +/// file (the underlying loader still respects `MSB_CONFIG_PATH`). +#[derive(Default)] +pub struct LocalBackendBuilder { + home: Option, + sandboxes_dir: Option, + volumes_dir: Option, + snapshots_dir: Option, + cache_dir: Option, + logs_dir: Option, + secrets_dir: Option, + max_connections: Option, + connect_timeout_secs: Option, + busy_timeout_secs: Option, + default_cpus: Option, + default_memory_mib: Option, + shell: Option, + workdir: Option, + metrics_sample_interval_ms: Option>>, + disable_metrics_sample: Option, + ca_certs: Option>, + registry_hosts: Option>, + log_level: Option, +} + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl LocalBackend { + /// Construct a `LocalBackend` whose DB pool initialises on first access. + /// + /// The config is read from `~/.microsandbox/config.json` (honouring + /// `MSB_CONFIG_PATH`) at construction; a missing file resolves to the + /// hard-coded defaults. The DB pool is created (and migrations applied) + /// on first call to [`Self::db`]. + /// + /// Process-wide singleton access goes through + /// [`default_backend()`](super::default_backend) + + /// [`Backend::as_local`]; the process default lazy-initialises a single + /// `LocalBackend` instance, so callers never end up with two backends + /// racing on the same SQLite file. + pub fn lazy() -> Self { + let config = Arc::new(load_persisted_config_or_default().unwrap_or_default()); + Self { + config, + db: OnceCell::new(), + } + } + + /// Eagerly construct a `LocalBackend` from `~/.microsandbox/config.json`, + /// opening (and migrating) the DB pool up front. + pub async fn new() -> MicrosandboxResult { + let backend = Self::lazy(); + let _ = backend.db().await?; + Ok(backend) + } + + /// Start a builder for programmatic configuration. + pub fn builder() -> LocalBackendBuilder { + LocalBackendBuilder::default() + } + + /// Access the open DB pool, initialising it (and applying migrations) on + /// first call. + pub async fn db(&self) -> MicrosandboxResult<&DbPools> { + self.db + .get_or_try_init(|| async { + let db_dir = self.config.home().join(microsandbox_utils::DB_SUBDIR); + connect_and_migrate(&db_dir, &self.config.database).await + }) + .await + } + + /// Borrow this backend's [`LocalConfig`]. + pub fn config(&self) -> &LocalConfig { + &self.config + } + + /// Host-side directory rooted at `volumes_dir/` for a named volume. + /// + /// Non-trait helper used by [`VolumeFs`](crate::volume::VolumeFs) + /// streaming methods and FFI shims that need a path before any backend + /// trait call. + pub fn volume_path(&self, name: &str) -> PathBuf { + self.config.volumes_dir().join(name) + } + + /// Resolved sandboxes directory. + pub fn sandboxes_dir(&self) -> PathBuf { + self.config.sandboxes_dir() + } + + /// Resolved volumes directory. + pub fn volumes_dir(&self) -> PathBuf { + self.config.volumes_dir() + } + + /// Resolved snapshots directory. + pub fn snapshots_dir(&self) -> PathBuf { + self.config.snapshots_dir() + } + + /// Resolved cache directory. + pub fn cache_dir(&self) -> PathBuf { + self.config.cache_dir() + } + + /// Resolved logs directory. + pub fn logs_dir(&self) -> PathBuf { + self.config.logs_dir() + } + + /// Resolved secrets directory. + pub fn secrets_dir(&self) -> PathBuf { + self.config.secrets_dir() + } +} + +impl LocalBackendBuilder { + /// Override the home directory (default: `~/.microsandbox`). + pub fn home(mut self, path: impl Into) -> Self { + self.home = Some(path.into()); + self + } + + /// Override the sandboxes directory. + pub fn sandboxes_dir(mut self, path: impl Into) -> Self { + self.sandboxes_dir = Some(path.into()); + self + } + + /// Override the volumes directory. + pub fn volumes_dir(mut self, path: impl Into) -> Self { + self.volumes_dir = Some(path.into()); + self + } + + /// Override the snapshots directory. + pub fn snapshots_dir(mut self, path: impl Into) -> Self { + self.snapshots_dir = Some(path.into()); + self + } + + /// Override the cache directory. + pub fn cache_dir(mut self, path: impl Into) -> Self { + self.cache_dir = Some(path.into()); + self + } + + /// Override the logs directory. + pub fn logs_dir(mut self, path: impl Into) -> Self { + self.logs_dir = Some(path.into()); + self + } + + /// Override the secrets directory. + pub fn secrets_dir(mut self, path: impl Into) -> Self { + self.secrets_dir = Some(path.into()); + self + } + + /// Override the DB max connections (default: 5). + pub fn max_connections(mut self, n: u32) -> Self { + self.max_connections = Some(n); + self + } + + /// Override the DB connect timeout in seconds. + pub fn connect_timeout_secs(mut self, secs: u64) -> Self { + self.connect_timeout_secs = Some(secs); + self + } + + /// Override SQLite's `busy_timeout` in seconds. + pub fn busy_timeout_secs(mut self, secs: u64) -> Self { + self.busy_timeout_secs = Some(secs); + self + } + + /// Override the default sandbox vCPU count. + pub fn default_cpus(mut self, cpus: u8) -> Self { + self.default_cpus = Some(cpus); + self + } + + /// Override the default sandbox guest memory (MiB). + pub fn default_memory_mib(mut self, mib: u32) -> Self { + self.default_memory_mib = Some(mib); + self + } + + /// Override the default shell used for interactive sessions and scripts. + pub fn shell(mut self, shell: impl Into) -> Self { + self.shell = Some(shell.into()); + self + } + + /// Override the default working directory inside sandboxes. + pub fn workdir(mut self, workdir: impl Into) -> Self { + self.workdir = Some(workdir.into()); + self + } + + /// Override the sandbox metrics sampling interval. Pass `0` to disable + /// sampling globally. + pub fn metrics_sample_interval_ms(mut self, ms: u64) -> Self { + self.metrics_sample_interval_ms = Some(NonZero::new(ms)); + self + } + + /// Force-disable sandbox metrics sampling regardless of the configured + /// interval. + pub fn disable_metrics_sample(mut self, disable: bool) -> Self { + self.disable_metrics_sample = Some(disable); + self + } + + /// Override the path to additional CA root certificates trusted by + /// registry connections. Pass `None` to clear a persisted value. + pub fn ca_certs(mut self, path: Option) -> Self { + self.ca_certs = Some(path); + self + } + + /// Replace the per-registry hosts map. The provided map fully replaces + /// any persisted `registries.hosts` — additive merging isn't supported + /// by the builder (use a persisted config file for incremental edits). + pub fn registry_hosts(mut self, hosts: HashMap) -> Self { + self.registry_hosts = Some(hosts); + self + } + + /// Override the runtime log level applied to SDK-spawned sandboxes. + pub fn log_level(mut self, level: microsandbox_runtime::logging::LogLevel) -> Self { + self.log_level = Some(level); + self + } + + /// Build the `LocalBackend`. Opens the DB pool and applies migrations. + /// + /// Reads `~/.microsandbox/config.json` (or `MSB_CONFIG_PATH`) and + /// overlays the builder's overrides on top. Builder values win; + /// anything the builder didn't set falls through to the persisted + /// config (or the hard-coded defaults if no config file exists). + pub async fn build(self) -> MicrosandboxResult { + let persisted = load_persisted_config_or_default().unwrap_or_default(); + let config = self.merge_into(persisted); + let backend = LocalBackend { + config: Arc::new(config), + db: OnceCell::new(), + }; + let _ = backend.db().await?; + Ok(backend) + } + + /// Overlay the builder's overrides on top of `base`. Builder values win; + /// `None` builder fields fall through to `base`. + fn merge_into(self, mut base: LocalConfig) -> LocalConfig { + let LocalBackendBuilder { + home, + sandboxes_dir, + volumes_dir, + snapshots_dir, + cache_dir, + logs_dir, + secrets_dir, + max_connections, + connect_timeout_secs, + busy_timeout_secs, + default_cpus, + default_memory_mib, + shell, + workdir, + metrics_sample_interval_ms, + disable_metrics_sample, + ca_certs, + registry_hosts, + log_level, + } = self; + + if let Some(home) = home { + base.home = Some(home); + } + if let Some(level) = log_level { + base.log_level = Some(level); + } + + if let Some(v) = max_connections { + base.database.max_connections = v; + } + if let Some(v) = connect_timeout_secs { + base.database.connect_timeout_secs = v; + } + if let Some(v) = busy_timeout_secs { + base.database.busy_timeout_secs = v; + } + + if let Some(p) = cache_dir { + base.paths.cache = Some(p); + } + if let Some(p) = sandboxes_dir { + base.paths.sandboxes = Some(p); + } + if let Some(p) = volumes_dir { + base.paths.volumes = Some(p); + } + if let Some(p) = snapshots_dir { + base.paths.snapshots = Some(p); + } + if let Some(p) = logs_dir { + base.paths.logs = Some(p); + } + if let Some(p) = secrets_dir { + base.paths.secrets = Some(p); + } + + if let Some(v) = default_cpus { + base.sandbox_defaults.cpus = v; + } + if let Some(v) = default_memory_mib { + base.sandbox_defaults.memory_mib = v; + } + if let Some(v) = shell { + base.sandbox_defaults.shell = v; + } + if let Some(v) = workdir { + base.sandbox_defaults.workdir = Some(v); + } + if let Some(v) = metrics_sample_interval_ms { + base.sandbox_defaults.metrics_sample_interval_ms = v; + } + if let Some(v) = disable_metrics_sample { + base.sandbox_defaults.disable_metrics_sample = v; + } + + if let Some(v) = ca_certs { + base.registries.ca_certs = v; + } + if let Some(v) = registry_hosts { + base.registries.hosts = v; + } + + base + } +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations +//-------------------------------------------------------------------------------------------------- + +impl Backend for LocalBackend { + fn kind(&self) -> BackendKind { + BackendKind::Local + } + + fn sandboxes(&self) -> &dyn SandboxBackend { + self + } + + fn volumes(&self) -> &dyn VolumeBackend { + self + } + + fn as_local(&self) -> Option<&LocalBackend> { + Some(self) + } +} + +impl Default for LocalBackend { + fn default() -> Self { + Self::lazy() + } +} + +impl From for Arc { + fn from(backend: LocalBackend) -> Self { + Arc::new(backend) + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions: Helpers +//-------------------------------------------------------------------------------------------------- + +/// Open both pools for `db_dir/msb.db` and run migrations on the writer. +/// +/// The write pool connects first so WAL mode (persisted in the database +/// header) is set before the read pool opens. +async fn connect_and_migrate( + db_dir: &Path, + database: &DatabaseConfig, +) -> MicrosandboxResult { + tokio::fs::create_dir_all(db_dir).await?; + + let db_path = db_dir.join(microsandbox_utils::DB_FILENAME); + let pools = DbPools::open( + &db_path, + database.max_connections, + Duration::from_secs(database.connect_timeout_secs), + Duration::from_secs(database.busy_timeout_secs), + ) + .await + .map_err(|e| MicrosandboxError::Custom(format!("connect to {}: {e}", db_path.display())))?; + + Migrator::up(pools.write().inner(), None).await?; + + Ok(pools) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement}; + + use super::*; + use crate::backend::{VolumeBackend, with_backend}; + use crate::volume::VolumeConfig; + + #[tokio::test] + async fn test_connect_and_migrate_creates_db_and_tables() { + let tmp = tempfile::tempdir().unwrap(); + let db_dir = tmp.path().join("db"); + let database = DatabaseConfig::default(); + + let pools = connect_and_migrate(&db_dir, &database).await.unwrap(); + let conn = pools.read(); + + // DB file should exist on disk. + assert!(db_dir.join(microsandbox_utils::DB_FILENAME).exists()); + + // All 12 tables should be present. + let rows = conn + .query_all(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'seaql_%' AND name != 'sqlite_sequence' ORDER BY name", + )) + .await + .unwrap(); + + let table_names: Vec = rows + .iter() + .map(|r| r.try_get_by_index::(0).unwrap()) + .collect(); + + let expected = vec![ + "config", + "image_ref", + "layer", + "manifest", + "manifest_layer", + "run", + "sandbox", + "sandbox_metric", + "sandbox_rootfs", + "snapshot_index", + "volume", + ]; + + assert_eq!(table_names, expected); + } + + #[tokio::test] + async fn test_connect_and_migrate_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let db_dir = tmp.path().join("db"); + let database = DatabaseConfig::default(); + + let pools = connect_and_migrate(&db_dir, &database).await.unwrap(); + + // Running migrations again on the same DB should succeed. + Migrator::up(pools.write().inner(), None).await.unwrap(); + } + + #[tokio::test] + async fn test_connect_and_migrate_recovers_from_partial_storage_migration() { + let tmp = tempfile::tempdir().unwrap(); + let db_dir = tmp.path().join("db"); + tokio::fs::create_dir_all(&db_dir).await.unwrap(); + + let db_path = db_dir.join(microsandbox_utils::DB_FILENAME); + let db_url = format!("sqlite://{}?mode=rwc", db_path.display()); + + let conn = Database::connect(&db_url).await.unwrap(); + + conn.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "PRAGMA foreign_keys = ON;", + )) + .await + .unwrap(); + + // Apply only migrations 1 and 2 so migration 3 is still pending. + Migrator::up(&conn, Some(2)).await.unwrap(); + + // Simulate a half-applied migration 3. + conn.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "CREATE TABLE IF NOT EXISTS volume ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + quota_mib INTEGER, + size_bytes BIGINT, + labels TEXT, + created_at DATETIME, + updated_at DATETIME + )", + )) + .await + .unwrap(); + + conn.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "CREATE TABLE IF NOT EXISTS snapshot ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sandbox_id INTEGER, + size_bytes BIGINT, + description TEXT, + created_at DATETIME, + FOREIGN KEY (sandbox_id) REFERENCES sandbox(id) ON DELETE SET NULL + )", + )) + .await + .unwrap(); + + conn.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "CREATE UNIQUE INDEX idx_snapshots_name_sandbox_unique ON snapshot (name, sandbox_id)", + )) + .await + .unwrap(); + + drop(conn); + + let database = DatabaseConfig::default(); + let recovered = connect_and_migrate(&db_dir, &database).await.unwrap(); + + let migration_row_count = recovered + .read() + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT COUNT(*) FROM seaql_migrations WHERE version = 'm20260305_000003_create_storage_tables'", + )) + .await + .unwrap() + .unwrap() + .try_get_by_index::(0) + .unwrap(); + assert_eq!(migration_row_count, 1); + } + + /// Regression test for Defect 1: under `with_backend(custom_local, ...)`, + /// volume FS ops must route to the custom backend's `volumes_dir`, not + /// the resolved default's. + /// + /// Pre-fix, `volume::fs::local::*` reached into `LocalBackend::ambient()` + /// so a `with_backend` scope would write the DB row to the custom but + /// route filesystem ops to the ambient default. Two backends with + /// distinct `volumes_dir`s make the leak observable: writes through B's + /// trait impl must land under B's `volumes_dir`, not under A's. + #[tokio::test] + async fn with_backend_scope_isolates_volume_fs_paths() { + let home_a = tempfile::tempdir().unwrap(); + let home_b = tempfile::tempdir().unwrap(); + + let backend_a: Arc = Arc::new( + LocalBackend::builder() + .home(home_a.path()) + .build() + .await + .unwrap(), + ); + let backend_b: Arc = Arc::new( + LocalBackend::builder() + .home(home_b.path()) + .build() + .await + .unwrap(), + ); + + // Create the volume in backend B only. + backend_b + .volumes() + .create( + backend_b.clone(), + VolumeConfig { + name: "vol".into(), + quota_mib: None, + labels: Vec::new(), + }, + ) + .await + .unwrap(); + + // Inside a `with_backend(B)` scope, write a file through B's trait. + // Without the fix, `fs_write` would re-resolve via the ambient + // `LocalBackend` and write to A (or to the global ambient). + let backend_b_clone = backend_b.clone(); + with_backend(backend_a.clone(), async move { + backend_b_clone + .volumes() + .fs_write("vol", "hello.txt", b"world".to_vec()) + .await + .unwrap(); + }) + .await; + + // Expect the file under B's volumes_dir, not A's. + let expected_path = backend_b + .as_local() + .unwrap() + .volume_path("vol") + .join("hello.txt"); + let unexpected_path = backend_a + .as_local() + .unwrap() + .volumes_dir() + .join("vol") + .join("hello.txt"); + + let contents = tokio::fs::read_to_string(&expected_path) + .await + .expect("file should exist under backend B's volumes_dir"); + assert_eq!(contents, "world"); + assert!( + !unexpected_path.exists(), + "file must NOT appear under backend A's volumes_dir; \ + ambient() leak regressed" + ); + } + + /// `LocalBackendBuilder::build()` overlays builder overrides on top of + /// the persisted config — values the builder didn't set must be + /// preserved from the base. This test runs `merge_into` directly so it + /// doesn't have to mutate `MSB_CONFIG_PATH` (which races other tests). + #[test] + fn builder_merge_preserves_persisted_fields_when_not_overridden() { + // Persisted base: a fully-populated config the user supposedly + // wrote to ~/.microsandbox/config.json. + let base = LocalConfig { + log_level: Some(microsandbox_runtime::logging::LogLevel::Debug), + database: DatabaseConfig { + url: None, + max_connections: 9, + connect_timeout_secs: 17, + busy_timeout_secs: 23, + }, + sandbox_defaults: crate::config::SandboxDefaults { + cpus: 4, + memory_mib: 2048, + shell: "/bin/zsh".into(), + workdir: Some("/work".into()), + metrics_sample_interval_ms: NonZero::new(750), + disable_metrics_sample: true, + }, + ..Default::default() + }; + + // Builder overrides only one knob — vCPU count. + let merged = LocalBackend::builder().default_cpus(2).merge_into(base); + + // The overridden field reflects the builder. + assert_eq!(merged.sandbox_defaults.cpus, 2); + + // Everything else must survive from the persisted base. + assert_eq!(merged.sandbox_defaults.memory_mib, 2048); + assert_eq!(merged.sandbox_defaults.shell, "/bin/zsh"); + assert_eq!(merged.sandbox_defaults.workdir, Some("/work".into())); + assert_eq!( + merged.sandbox_defaults.metrics_sample_interval_ms, + NonZero::new(750) + ); + assert!(merged.sandbox_defaults.disable_metrics_sample); + + assert_eq!(merged.database.max_connections, 9); + assert_eq!(merged.database.connect_timeout_secs, 17); + assert_eq!(merged.database.busy_timeout_secs, 23); + + assert_eq!( + merged.log_level, + Some(microsandbox_runtime::logging::LogLevel::Debug) + ); + } +} diff --git a/crates/microsandbox/lib/backend/mod.rs b/crates/microsandbox/lib/backend/mod.rs new file mode 100644 index 000000000..573ff401d --- /dev/null +++ b/crates/microsandbox/lib/backend/mod.rs @@ -0,0 +1,206 @@ +//! Backend abstraction: routes SDK calls to either a local libkrun runtime or +//! a remote msb-cloud control plane. +//! +//! The [`Backend`] trait + its sub-traits ([`SandboxBackend`], `VolumeBackend`, +//! `SnapshotBackend`) are the dispatch surface every SDK handle (`Sandbox`, +//! `Volume`, `ExecHandle`, …) routes through. Two implementations are planned: +//! [`LocalBackend`] (wraps today's libkrun + agentd path) and `CloudBackend` +//! (HTTP to msb-cloud, lives in this crate once the cloud-side wire surface is +//! complete). +//! +//! ## Ambient default +//! +//! [`default_backend`] returns the process-wide default. [`set_default_backend`] +//! installs one; if never called, the first access lazy-initialises to +//! [`LocalBackend::lazy`]. [`with_backend`] scopes an override to one async +//! future (and any tasks it spawns) via `tokio::task_local!`. +//! +//! See `planning/microsandbox/design/api/local-cloud-backend.md` for the +//! full trait-surface spec, and `planning/microsandbox/design/api/ambient-backend.md` +//! for the resolution ladder + process-level config story. + +mod cloud; +mod cloud_wire; +mod local; +mod profile; +pub(crate) mod sandbox; +pub(crate) mod volume; + +pub use cloud::{CloudBackend, CloudBackendBuilder}; +pub use cloud_wire::{ + CloudCreateSandboxRequest, CloudErrorBody, CloudMessageResponse, CloudPaginated, CloudSandbox, + CloudSandboxStatus, +}; +pub use local::{LocalBackend, LocalBackendBuilder}; +pub use profile::{Profile, ProfileBackend, SdkConfig, load_sdk_config, resolve_default_backend}; +pub use sandbox::{ + SandboxBackend, SandboxCloudState, SandboxHandleCloudState, SandboxHandleInner, + SandboxHandleLocalState, SandboxInner, SandboxList, SandboxLocalState, +}; +pub use volume::{ + VolumeBackend, VolumeCloudState, VolumeHandleCloudState, VolumeHandleInner, + VolumeHandleLocalState, VolumeInner, VolumeLocalState, +}; + +use std::sync::{Arc, OnceLock, RwLock}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Which backend variant a [`Backend`] implementation represents. Returned by +/// [`Backend::kind`] for runtime introspection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendKind { + /// Local libkrun + agentd backend. Spawns microVMs on the calling host. + Local, + /// Remote backend talking to an msb-cloud control plane over HTTP. + Cloud, +} + +/// Top-level routing trait for SDK dispatch. Implementations route to +/// resource-specific sub-traits (sandboxes, volumes, snapshots) via accessor +/// methods. +/// +/// Object-safe — handles hold an `Arc`. Sub-trait accessors stay +/// off this trait until each sub-trait's surface is finalised, which lets the +/// scaffolding land without committing to method signatures that will change. +pub trait Backend: Send + Sync + 'static { + /// Return the kind of backend this is (`Local` or `Cloud`). + fn kind(&self) -> BackendKind; + + /// Return the sandbox lifecycle backend. + fn sandboxes(&self) -> &dyn SandboxBackend; + + /// Return the volume lifecycle backend. + fn volumes(&self) -> &dyn VolumeBackend; + + /// Downcast to a concrete `&LocalBackend` when this backend is local. + /// + /// Used by helpers that need access to local-only state (DB pool, config + /// paths) without keeping a separate `Arc` alongside the + /// `Arc`. Returns `None` for cloud backends. + fn as_local(&self) -> Option<&LocalBackend> { + None + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions: Ambient default +//-------------------------------------------------------------------------------------------------- + +/// Process-wide default backend. Lazy-initialised to `LocalBackend::lazy()` +/// on first access if `set_default_backend` has not been called. +static DEFAULT: OnceLock>> = OnceLock::new(); + +/// Install a process-wide default backend. +/// +/// Replaces any previously installed default. Subsequent calls to +/// [`default_backend`] (in this process) return this backend unless a +/// [`with_backend`] scope is active on the current task. +/// +/// Call this once at process startup, typically right after argument parsing +/// and before any SDK operations. Existing user code that never calls this +/// gets `LocalBackend` automatically on first access. +pub fn set_default_backend(backend: impl Into>) { + let cell = default_cell(); + *cell.write().expect("DEFAULT backend RwLock poisoned") = backend.into(); +} + +/// Return the active default backend. +/// +/// Resolution order: +/// 1. A [`with_backend`] scope on the current task, if any. +/// 2. The backend installed via [`set_default_backend`], if any. +/// 3. Lazy-initialised `LocalBackend` (matches today's behaviour). +pub fn default_backend() -> Arc { + if let Ok(scoped) = SCOPED_BACKEND.try_with(|b| b.clone()) { + return scoped; + } + default_cell() + .read() + .expect("DEFAULT backend RwLock poisoned") + .clone() +} + +/// Run `future` with `backend` installed as the default for the duration of +/// the future and any tasks it spawns. Useful for libraries that need to talk +/// to a non-default backend (e.g. tests using a mock, or multi-backend tools) +/// without globally swapping the default. +/// +/// Implemented via `tokio::task_local!`, so spawned tasks inherit the override +/// only if launched within `future`. Tasks launched before the scope began +/// see the global default. +pub async fn with_backend(backend: impl Into>, future: F) -> T +where + F: std::future::Future, +{ + SCOPED_BACKEND.scope(backend.into(), future).await +} + +/// Lazy-init the OnceLock by consulting the Q1 resolution ladder +/// ([`resolve_default_backend`]). Falls back to `LocalBackend::lazy` if the +/// resolver itself errors (e.g. malformed config file) — error gets logged +/// rather than panicking, so `default_backend()` never fails. +fn default_cell() -> &'static RwLock> { + DEFAULT.get_or_init(|| { + let resolved = profile::resolve_default_backend().unwrap_or_else(|e| { + tracing::warn!( + error = %e, + "default backend resolution failed; falling back to LocalBackend" + ); + Arc::new(LocalBackend::lazy()) + }); + RwLock::new(resolved) + }) +} + +tokio::task_local! { + /// Task-local override installed by [`with_backend`]. + static SCOPED_BACKEND: Arc; +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_backend_resolves_to_local_when_unset() { + // Each `cargo test` run is its own process, but other tests in the + // same binary may install a different default. Be tolerant: just + // check the kind is one of the known variants. + let b = default_backend(); + assert!(matches!(b.kind(), BackendKind::Local | BackendKind::Cloud)); + } + + #[tokio::test] + async fn with_backend_overrides_for_scope() { + struct Fake(BackendKind); + impl Backend for Fake { + fn kind(&self) -> BackendKind { + self.0 + } + + fn sandboxes(&self) -> &dyn SandboxBackend { + unimplemented!("fake backend only tests kind routing") + } + + fn volumes(&self) -> &dyn VolumeBackend { + unimplemented!("fake backend only tests kind routing") + } + } + let fake: Arc = Arc::new(Fake(BackendKind::Cloud)); + let observed = with_backend(fake, async { default_backend().kind() }).await; + assert_eq!(observed, BackendKind::Cloud); + + // Outside the scope, the default is whatever it was before — but at + // least it's not the fake we just installed (since we didn't call + // `set_default_backend`). + let outside = default_backend().kind(); + assert!(matches!(outside, BackendKind::Local | BackendKind::Cloud)); + } +} diff --git a/crates/microsandbox/lib/backend/profile.rs b/crates/microsandbox/lib/backend/profile.rs new file mode 100644 index 000000000..12aa88db3 --- /dev/null +++ b/crates/microsandbox/lib/backend/profile.rs @@ -0,0 +1,438 @@ +//! Backend selection: profile + env + config-file resolution. +//! +//! Precedence ladder (each tier wins over the one below): +//! +//! 1. Programmatic: explicit `.backend(b)` on a builder or +//! `microsandbox::set_default_backend(...)` — handled by the caller, not here. +//! 2. Env: `MSB_BACKEND=local` → local, `MSB_API_URL` + `MSB_API_KEY` → cloud. +//! 3. Env: `MSB_PROFILE=` → look up that profile in the config file. +//! 4. Config: `active_profile` field → use that profile. +//! 5. Fallback: `LocalBackend`. +//! +//! The SDK-level config lives at `~/.microsandbox/config.json` alongside the +//! existing [`LocalConfig`](crate::config::LocalConfig) (paths, DB url, +//! sandbox defaults, …). The two are orthogonal sections of the same file; +//! this module only touches `active_profile` + `profiles`. + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use super::{Backend, CloudBackend, LocalBackend}; +use crate::{MicrosandboxError, MicrosandboxResult}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// SDK-level configuration loaded from `~/.microsandbox/config.json`. +/// +/// `serde(default)` everywhere — a missing file or missing keys are equivalent +/// to defaults. Coexists with [`LocalConfig`](crate::config::LocalConfig) in +/// the same JSON document; serde ignores fields it doesn't know. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct SdkConfig { + /// Profile to use when none is named explicitly. Resolved against + /// [`SdkConfig::profiles`]. Empty / missing → no active profile (falls + /// through to local fallback). + pub active_profile: Option, + + /// Named profiles. Each profile selects a backend and (for cloud) provides + /// the URL + a credential reference. + pub profiles: HashMap, +} + +/// A single named profile. Either local (no extra config) or cloud (URL + key +/// reference). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + /// Which backend this profile selects. + pub backend: ProfileBackend, + + /// Cloud-only: the msb-cloud endpoint URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// Cloud-only: how to find the API key. + /// + /// Forms: + /// - `keyring::` — fetched from the OS keychain (requires `keyring` feature). + /// - `env:` — read from the named env var at resolution time. + /// - `inline:msb_live_…` — plaintext in the config file. Dev / CI only; + /// logged as a warning on load. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_ref: Option, +} + +/// Which backend a [`Profile`] selects. String-tagged for human-friendly JSON. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProfileBackend { + /// Local libkrun + agentd backend on the calling host. + Local, + /// Remote msb-cloud control plane. + Cloud, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Load `SdkConfig` from the config file at `~/.microsandbox/config.json`. +/// +/// Missing file → `Ok(SdkConfig::default())`. Malformed JSON → `Err`. +/// Honours `MSB_CONFIG_PATH` env override for the file path. +pub fn load_sdk_config() -> MicrosandboxResult { + let path = sdk_config_path(); + if !path.exists() { + return Ok(SdkConfig::default()); + } + let raw = fs::read_to_string(&path).map_err(|e| { + MicrosandboxError::InvalidConfig(format!( + "failed to read SDK config at {}: {e}", + path.display() + )) + })?; + // Parse with serde's permissive shape — `serde(default)` on SdkConfig means + // a JSON document that only contains LocalConfig fields produces an empty + // SdkConfig without error. + let cfg: SdkConfig = serde_json::from_str(&raw).map_err(|e| { + MicrosandboxError::InvalidConfig(format!( + "failed to parse SDK config at {}: {e}", + path.display() + )) + })?; + Ok(cfg) +} + +/// Resolve the default backend according to the Q1 precedence ladder. +/// +/// Tiers 2–5 of the ladder (env → profile env → config → local fallback). Tier +/// 1 (programmatic) is handled by `set_default_backend` / per-call `.backend(b)`, +/// not here. +pub fn resolve_default_backend() -> MicrosandboxResult> { + // Tier 2a: explicit backend kind via env. + if let Ok(kind) = std::env::var("MSB_BACKEND") { + match kind.trim().to_ascii_lowercase().as_str() { + "local" => return Ok(Arc::new(LocalBackend::lazy())), + "cloud" => { + // Cloud without explicit URL/key — fall through to profile lookup, + // since the user may have set MSB_PROFILE separately. + } + other => { + return Err(MicrosandboxError::InvalidConfig(format!( + "MSB_BACKEND must be 'local' or 'cloud', got {other:?}" + ))); + } + } + } + + // Tier 2b: direct env (MSB_API_URL + MSB_API_KEY) — explicit cloud override. + if let (Ok(url), Ok(key)) = (std::env::var("MSB_API_URL"), std::env::var("MSB_API_KEY")) { + let url = url.trim(); + let key = key.trim(); + if !url.is_empty() && !key.is_empty() { + let cloud = CloudBackend::new(url, key)?; + return Ok(Arc::new(cloud)); + } + } + + // Tier 3 / 4: profile selection via env or config file. + let cfg = load_sdk_config()?; + let profile_name = std::env::var("MSB_PROFILE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| cfg.active_profile.clone()); + + if let Some(name) = profile_name { + let profile = cfg.profiles.get(&name).ok_or_else(|| { + MicrosandboxError::InvalidConfig(format!( + "active profile {name:?} not found in SDK config" + )) + })?; + return backend_from_profile(&name, profile); + } + + // Tier 5: local fallback. + Ok(Arc::new(LocalBackend::lazy())) +} + +/// Build a backend instance from a named profile. +fn backend_from_profile(name: &str, profile: &Profile) -> MicrosandboxResult> { + match profile.backend { + ProfileBackend::Local => Ok(Arc::new(LocalBackend::lazy())), + ProfileBackend::Cloud => Ok(Arc::new(cloud_backend_from_profile_parts(name, profile)?)), + } +} + +pub(crate) fn cloud_backend_from_profile(name: &str) -> MicrosandboxResult { + let cfg = load_sdk_config()?; + let profile = cfg.profiles.get(name).ok_or_else(|| { + MicrosandboxError::InvalidConfig(format!("profile {name:?} not found in SDK config")) + })?; + cloud_backend_from_profile_parts(name, profile) +} + +fn cloud_backend_from_profile_parts( + name: &str, + profile: &Profile, +) -> MicrosandboxResult { + if profile.backend != ProfileBackend::Cloud { + return Err(MicrosandboxError::InvalidConfig(format!( + "profile {name:?} is not a cloud profile" + ))); + } + + let url = profile.url.as_ref().ok_or_else(|| { + MicrosandboxError::InvalidConfig(format!( + "profile {name:?} backend=cloud requires a 'url' field" + )) + })?; + let key_ref = profile.api_key_ref.as_ref().ok_or_else(|| { + MicrosandboxError::InvalidConfig(format!( + "profile {name:?} backend=cloud requires an 'api_key_ref' field" + )) + })?; + let api_key = resolve_api_key_ref(name, key_ref)?; + CloudBackend::new(url.as_str(), api_key) +} + +/// Resolve an `api_key_ref` string (`keyring:…` / `env:VAR` / `inline:msb_…`) +/// to the actual API key value. +fn resolve_api_key_ref(profile: &str, key_ref: &str) -> MicrosandboxResult { + if let Some(rest) = key_ref.strip_prefix("env:") { + let var = rest.trim(); + if var.is_empty() { + return Err(MicrosandboxError::InvalidConfig(format!( + "profile {profile:?}: api_key_ref 'env:' must name an env var" + ))); + } + let value = std::env::var(var).map_err(|_| { + MicrosandboxError::InvalidConfig(format!( + "profile {profile:?}: env var {var:?} not set" + )) + })?; + let value = value.trim(); + if value.is_empty() { + return Err(MicrosandboxError::InvalidConfig(format!( + "profile {profile:?}: env var {var:?} must not be empty" + ))); + } + return Ok(value.to_string()); + } + if let Some(rest) = key_ref.strip_prefix("inline:") { + let api_key = rest.trim(); + if api_key.is_empty() { + return Err(MicrosandboxError::InvalidConfig(format!( + "profile {profile:?}: api_key_ref 'inline:' must include an API key" + ))); + } + tracing::warn!( + profile = %profile, + "API key stored inline in SDK config — dev/CI only; prefer keyring: or env:" + ); + return Ok(api_key.to_string()); + } + if let Some(rest) = key_ref.strip_prefix("keyring:") { + // Format: keyring:: + let mut parts = rest.splitn(2, ':'); + let _service = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| { + MicrosandboxError::InvalidConfig(format!( + "profile {profile:?}: api_key_ref 'keyring:' requires :" + )) + })?; + let _entry = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| { + MicrosandboxError::InvalidConfig(format!( + "profile {profile:?}: api_key_ref 'keyring::' requires " + )) + })?; + // Keyring lookup is gated by the `keyring` feature on the microsandbox + // crate. When the feature is enabled, integrate with the existing + // keyring path (see `crate::config::get_registry_keyring_auth` for the + // analogous registry-auth code). + return Err(MicrosandboxError::InvalidConfig(format!( + "profile {profile:?}: api_key_ref 'keyring:' resolution is not yet wired \ + — use 'env:' or 'inline:' for now" + ))); + } + Err(MicrosandboxError::InvalidConfig(format!( + "profile {profile:?}: api_key_ref must start with 'env:', 'inline:', or 'keyring:' — got {key_ref:?}" + ))) +} + +/// Return the SDK config file path. Delegates to [`crate::config::config_path`] +/// so the SDK config and the [`LocalConfig`](crate::config::LocalConfig) +/// always agree on the path (they live in the same JSON document). Honours +/// `MSB_CONFIG_PATH` via that. +fn sdk_config_path() -> PathBuf { + crate::config::config_path() +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sdk_config_parses_minimal() { + let json = r#"{ + "active_profile": "prod", + "profiles": { + "prod": { "backend": "cloud", "url": "https://msb.example.com", "api_key_ref": "env:MSB_API_KEY" } + } + }"#; + let cfg: SdkConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.active_profile.as_deref(), Some("prod")); + assert_eq!(cfg.profiles.len(), 1); + let prod = cfg.profiles.get("prod").unwrap(); + assert_eq!(prod.backend, ProfileBackend::Cloud); + assert_eq!(prod.url.as_deref(), Some("https://msb.example.com")); + assert_eq!(prod.api_key_ref.as_deref(), Some("env:MSB_API_KEY")); + } + + #[test] + fn sdk_config_ignores_unknown_keys() { + // LocalConfig fields (home, log_level, paths, ...) coexist in the same file. + let json = r#"{ + "home": "/opt/microsandbox", + "log_level": "info", + "active_profile": "local-only", + "profiles": { "local-only": { "backend": "local" } } + }"#; + let cfg: SdkConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.active_profile.as_deref(), Some("local-only")); + } + + #[test] + fn sdk_config_handles_empty_object() { + let cfg: SdkConfig = serde_json::from_str("{}").unwrap(); + assert!(cfg.active_profile.is_none()); + assert!(cfg.profiles.is_empty()); + } + + #[test] + fn api_key_ref_inline() { + let key = resolve_api_key_ref("p", "inline:msb_live_abc").unwrap(); + assert_eq!(key, "msb_live_abc"); + } + + #[test] + fn api_key_ref_inline_trims_and_rejects_empty() { + let key = resolve_api_key_ref("p", "inline: msb_live_abc ").unwrap(); + assert_eq!(key, "msb_live_abc"); + assert!(resolve_api_key_ref("p", "inline: ").is_err()); + } + + #[test] + fn api_key_ref_env_when_set() { + unsafe { std::env::set_var("MSB_TEST_RESOLVE_API_KEY", " msb_test_xyz ") }; + let key = resolve_api_key_ref("p", "env:MSB_TEST_RESOLVE_API_KEY").unwrap(); + assert_eq!(key, "msb_test_xyz"); + unsafe { std::env::remove_var("MSB_TEST_RESOLVE_API_KEY") }; + } + + #[test] + fn api_key_ref_env_rejects_empty_value() { + unsafe { std::env::set_var("MSB_TEST_EMPTY_API_KEY", " ") }; + assert!(resolve_api_key_ref("p", "env:MSB_TEST_EMPTY_API_KEY").is_err()); + unsafe { std::env::remove_var("MSB_TEST_EMPTY_API_KEY") }; + } + + #[test] + fn api_key_ref_env_missing() { + unsafe { std::env::remove_var("MSB_TEST_DEFINITELY_NOT_SET") }; + assert!(resolve_api_key_ref("p", "env:MSB_TEST_DEFINITELY_NOT_SET").is_err()); + } + + #[test] + fn api_key_ref_rejects_unknown_scheme() { + assert!(resolve_api_key_ref("p", "vault:foo").is_err()); + assert!(resolve_api_key_ref("p", "plaintext").is_err()); + } + + #[test] + fn api_key_ref_keyring_returns_explicit_error_for_now() { + // Keyring path is parsed (validates the format) but signals "not yet wired". + let err = resolve_api_key_ref("p", "keyring:msb:prod").unwrap_err(); + assert!(err.to_string().contains("not yet wired")); + } + + #[test] + fn backend_from_local_profile() { + let p = Profile { + backend: ProfileBackend::Local, + url: None, + api_key_ref: None, + }; + let b = backend_from_profile("local", &p).unwrap(); + assert_eq!(b.kind(), super::super::BackendKind::Local); + } + + #[test] + fn backend_from_cloud_profile_inline_key() { + let p = Profile { + backend: ProfileBackend::Cloud, + url: Some("https://msb.example.com".into()), + api_key_ref: Some("inline:msb_live_abc".into()), + }; + let b = backend_from_profile("prod", &p).unwrap(); + assert_eq!(b.kind(), super::super::BackendKind::Cloud); + } + + #[test] + fn cloud_backend_from_profile_parts_rejects_local_profile() { + let p = Profile { + backend: ProfileBackend::Local, + url: None, + api_key_ref: None, + }; + assert!(cloud_backend_from_profile_parts("local", &p).is_err()); + } + + #[test] + fn resolve_default_backend_honors_explicit_local_over_cloud_env() { + unsafe { + std::env::set_var("MSB_BACKEND", " local "); + std::env::set_var("MSB_API_URL", "https://msb.example.com"); + std::env::set_var("MSB_API_KEY", "msb_live_abc"); + } + + let b = resolve_default_backend().unwrap(); + + unsafe { + std::env::remove_var("MSB_BACKEND"); + std::env::remove_var("MSB_API_URL"); + std::env::remove_var("MSB_API_KEY"); + } + + assert_eq!(b.kind(), super::super::BackendKind::Local); + } + + #[test] + fn backend_from_cloud_profile_missing_url() { + let p = Profile { + backend: ProfileBackend::Cloud, + url: None, + api_key_ref: Some("inline:msb_live_abc".into()), + }; + assert!(backend_from_profile("prod", &p).is_err()); + } + + #[test] + fn backend_from_cloud_profile_missing_key_ref() { + let p = Profile { + backend: ProfileBackend::Cloud, + url: Some("https://msb.example.com".into()), + api_key_ref: None, + }; + assert!(backend_from_profile("prod", &p).is_err()); + } +} diff --git a/crates/microsandbox/lib/backend/sandbox.rs b/crates/microsandbox/lib/backend/sandbox.rs new file mode 100644 index 000000000..eda4b809d --- /dev/null +++ b/crates/microsandbox/lib/backend/sandbox.rs @@ -0,0 +1,1532 @@ +//! Sandbox lifecycle backend trait. +//! +//! Per the SDK local-cloud parity plan (D6.4): `Sandbox` and `SandboxHandle` +//! stay single types with no variants. They hold `Arc` plus a +//! backend-private `*Inner` enum that the outer types never expose directly. +//! The trait returns the outer types — the local/cloud `Inner` variants are +//! constructed inside each backend's trait impl and wrapped with the +//! `Arc` the caller passes in. + +use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use futures::Stream; +use futures::future::BoxFuture; + +use super::cloud_wire::{CloudCreateSandboxRequest, CloudSandbox, CloudSandboxStatus}; +use super::{Backend, CloudBackend, LocalBackend}; +use crate::agent::AgentClient; +use crate::runtime::{ProcessHandle, SpawnMode}; +use crate::sandbox::exec::{ExecHandle, ExecOptions, ExecOutput}; +use crate::sandbox::fs::{FsEntry, FsMetadata, FsReadStream, FsWriteSink}; +use crate::sandbox::logs::{LogEntry, LogOptions}; +use crate::sandbox::metrics::SandboxMetrics; +use crate::sandbox::{RootfsSource, Sandbox, SandboxConfig, SandboxHandle, SandboxStatus}; +use crate::{MicrosandboxError, MicrosandboxResult}; + +//-------------------------------------------------------------------------------------------------- +// Type Aliases +//-------------------------------------------------------------------------------------------------- + +/// Boxed stream of metrics samples returned by [`SandboxBackend::metrics_stream`]. +pub type MetricsStream = + Pin> + Send + 'static>>; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Backend-private state behind [`Sandbox`]. +/// +/// Users never see this enum directly — they get the outer `Sandbox` and reach +/// variant-specific data through the [`Sandbox::local`](crate::sandbox::Sandbox::local) +/// / [`Sandbox::cloud`](crate::sandbox::Sandbox::cloud) accessors. +pub enum SandboxInner { + /// Local libkrun-backed sandbox state. + Local(SandboxLocalState), + /// Cloud msb-cloud-backed sandbox state. + Cloud(SandboxCloudState), +} + +/// Local libkrun-backed sandbox state held inside [`SandboxInner::Local`]. +pub struct SandboxLocalState { + /// SQLite row id for this sandbox. + pub db_id: i32, + /// Owned libkrun process handle, when this `Sandbox` owns the lifecycle. + pub handle: Option>>, + /// UDS connection to the in-VM agentd relay. + pub client: Arc, +} + +/// Cloud msb-cloud-backed sandbox state held inside [`SandboxInner::Cloud`]. +pub struct SandboxCloudState { + /// Server-side UUID (kept as a string to match the cloud wire format). + pub id: String, + /// Owning org's UUID. + pub org_id: String, + /// Creation timestamp returned by msb-cloud. + pub created_at: DateTime, +} + +/// Backend-private state behind [`SandboxHandle`] — the lightweight DB-row view. +pub enum SandboxHandleInner { + /// Local persisted sandbox handle. + Local(SandboxHandleLocalState), + /// Cloud msb-cloud sandbox handle. + Cloud(SandboxHandleCloudState), +} + +/// Local handle state. Snapshot of the database row + active PID, if any. +pub struct SandboxHandleLocalState { + /// SQLite row id for this sandbox. + pub db_id: i32, + /// Sandbox lifecycle status at handle-creation time. + pub status: SandboxStatus, + /// Serialized `SandboxConfig` as stored in the database. + pub config_json: String, + /// When this sandbox was first created, if recorded. + pub created_at: Option>, + /// When this sandbox's database record was last modified. + pub updated_at: Option>, + /// Active sandbox process PID, if any. + pub pid: Option, +} + +/// Cloud handle state. Captures the snapshot msb-cloud returned at fetch time. +pub struct SandboxHandleCloudState { + /// Server-side UUID. + pub id: String, + /// Owning org's UUID. + pub org_id: String, + /// Lifecycle status mapped from msb-cloud's [`CloudSandboxStatus`]. + pub status: SandboxStatus, + /// Serialized [`CloudCreateSandboxRequest`] returned by msb-cloud. + pub config_json: String, + /// Creation timestamp returned by msb-cloud. + pub created_at: Option>, + /// Last start timestamp, when known. + pub started_at: Option>, + /// Last stop timestamp, when known. + pub stopped_at: Option>, + /// Last failure reason, when any. + pub last_error: Option, +} + +/// Paginated sandbox list result returned by [`SandboxBackend::list`]. +pub struct SandboxList { + /// Returned sandbox records. + pub sandboxes: Vec, + /// Cursor for the next page, when one exists. + pub next_cursor: Option, +} + +/// Resource-specific backend for sandbox lifecycle operations. +/// +/// Trait methods take the [`Arc`] that they should wrap any +/// returned [`Sandbox`] / [`SandboxHandle`] with. Callers (e.g. +/// `Sandbox::create`) resolve the backend via +/// [`default_backend`](super::default_backend) and forward it through. +pub trait SandboxBackend: Send + Sync { + /// Create a sandbox. The returned outer [`Sandbox`] carries the supplied + /// `backend` Arc and the variant-specific state inside `SandboxInner`. + /// + /// `start` controls whether the sandbox is booted as part of create. + /// **Cloud honours `start`** (forwards it as `?start=true|false` on the + /// create request). **Local always boots immediately** — the local impl + /// ignores the flag, because libkrun has no equivalent "create-without- + /// start" state. This asymmetry is intentional per the SDK parity plan + /// (D6.4); callers that need a stopped local sandbox should create then + /// `stop()` it explicitly. + fn create<'a>( + &'a self, + backend: Arc, + config: SandboxConfig, + start: bool, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Create a sandbox that must survive after the creating process exits. + fn create_detached<'a>( + &'a self, + backend: Arc, + config: SandboxConfig, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Start a stopped sandbox by name. + fn start<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Start a stopped sandbox by name in detached mode. + fn start_detached<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Get a sandbox handle by name. + fn get<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// List sandboxes. Local ignores pagination; cloud passes it through. + fn list<'a>( + &'a self, + backend: Arc, + cursor: Option<&'a str>, + limit: Option, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Remove/destroy a sandbox by name. + fn remove<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Stop a running sandbox by name (graceful). + fn stop<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Kill a running sandbox by name (SIGKILL). + fn kill<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Trigger a graceful drain on a sandbox by name. + fn drain<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + // ============================================================ + // Exec + // ============================================================ + + /// Execute a command inside the named sandbox and wait for it to complete. + fn exec<'a>( + &'a self, + backend: Arc, + name: &'a str, + config: &'a SandboxConfig, + cmd: String, + opts: ExecOptions, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Execute a command and return a streaming [`ExecHandle`]. + fn exec_stream<'a>( + &'a self, + backend: Arc, + name: &'a str, + config: &'a SandboxConfig, + cmd: String, + opts: ExecOptions, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Attach the host terminal to a PTY session in the named sandbox. + /// + /// Returns the exit code. Local routes through libkrun + agentd; cloud + /// returns [`MicrosandboxError::Unsupported`] until cloud attach lands. + fn attach<'a>( + &'a self, + backend: Arc, + name: &'a str, + config: &'a SandboxConfig, + cmd: String, + opts: crate::sandbox::AttachOptionsBuilder, + ) -> BoxFuture<'a, MicrosandboxResult>; + + // ============================================================ + // Logs / metrics + // ============================================================ + + /// Read captured output for the named sandbox. + fn logs<'a>( + &'a self, + backend: Arc, + name: &'a str, + opts: &'a LogOptions, + ) -> BoxFuture<'a, MicrosandboxResult>>; + + /// Latest metrics sample for the named sandbox. + fn metrics<'a>( + &'a self, + backend: Arc, + name: &'a str, + config: &'a SandboxConfig, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Streaming metrics samples at `interval`. Local opens a DB poll loop; + /// cloud returns a stream that yields a single [`MicrosandboxError::Unsupported`]. + fn metrics_stream( + &self, + backend: Arc, + name: String, + config: SandboxConfig, + interval: Duration, + ) -> MetricsStream; + + // ============================================================ + // Guest FS (sandbox.fs() surface) + // ============================================================ + + /// Read an entire guest file into memory. + fn fs_read<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Stream a guest file. Returns a [`FsReadStream`] yielding chunks. + fn fs_read_stream<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Write `data` to a guest file (overwriting if it exists). + fn fs_write<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + data: Vec, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Open a streaming writer for a guest file. + fn fs_write_stream<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// List immediate children of a guest directory. + fn fs_list<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>>; + + /// Get file/directory metadata. + fn fs_stat<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Create a directory (and parents). + fn fs_mkdir<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Remove a file or (when `recursive`) directory. + fn fs_remove<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + recursive: bool, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Copy a guest file from `from` to `to`. + fn fs_copy<'a>( + &'a self, + backend: Arc, + name: &'a str, + from: &'a str, + to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Rename/move a guest file or directory. + fn fs_rename<'a>( + &'a self, + backend: Arc, + name: &'a str, + from: &'a str, + to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Check whether a guest path exists. + fn fs_exists<'a>( + &'a self, + backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Copy a host file into the guest sandbox. + fn fs_copy_from_host<'a>( + &'a self, + backend: Arc, + name: &'a str, + host: &'a Path, + guest: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Copy a guest file out to the host. + fn fs_copy_to_host<'a>( + &'a self, + backend: Arc, + name: &'a str, + guest: &'a str, + host: &'a Path, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations: LocalBackend +//-------------------------------------------------------------------------------------------------- + +impl SandboxBackend for LocalBackend { + fn create<'a>( + &'a self, + backend: Arc, + config: SandboxConfig, + _start: bool, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + // Local backend always boots immediately — `start` only differs + // for cloud where create-without-start is a distinct state. + crate::sandbox::create_local(backend, config, SpawnMode::Attached, None).await + }) + } + + fn create_detached<'a>( + &'a self, + backend: Arc, + config: SandboxConfig, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + crate::sandbox::create_local(backend, config, SpawnMode::Detached, None).await + }) + } + + fn start<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin( + async move { crate::sandbox::start_local(backend, name, SpawnMode::Attached).await }, + ) + } + + fn start_detached<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin( + async move { crate::sandbox::start_local(backend, name, SpawnMode::Detached).await }, + ) + } + + fn get<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + let (model, pid) = crate::sandbox::get_local_handle_state(self, name).await?; + Ok(SandboxHandle::from_local_model(backend, model, pid)) + }) + } + + fn list<'a>( + &'a self, + backend: Arc, + _cursor: Option<&'a str>, + _limit: Option, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + let rows = crate::sandbox::list_local_handle_state(self).await?; + let sandboxes = rows + .into_iter() + .map(|(model, pid)| SandboxHandle::from_local_model(backend.clone(), model, pid)) + .collect(); + Ok(SandboxList { + sandboxes, + next_cursor: None, + }) + }) + } + + fn remove<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::sandbox::remove_local(backend, name).await }) + } + + fn stop<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::sandbox::stop_local(backend, name).await }) + } + + fn kill<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::sandbox::kill_local(backend, name).await }) + } + + fn drain<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::sandbox::drain_local(backend, name).await }) + } + + fn exec<'a>( + &'a self, + _backend: Arc, + name: &'a str, + config: &'a SandboxConfig, + cmd: String, + opts: ExecOptions, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin( + async move { crate::sandbox::exec::local::exec(self, name, config, cmd, opts).await }, + ) + } + + fn exec_stream<'a>( + &'a self, + _backend: Arc, + name: &'a str, + config: &'a SandboxConfig, + cmd: String, + opts: ExecOptions, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + crate::sandbox::exec::local::exec_stream(self, name, config, cmd, opts).await + }) + } + + fn attach<'a>( + &'a self, + _backend: Arc, + name: &'a str, + config: &'a SandboxConfig, + cmd: String, + opts: crate::sandbox::AttachOptionsBuilder, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + crate::sandbox::attach::local::attach(self, name, config, cmd, opts).await + }) + } + + fn logs<'a>( + &'a self, + _backend: Arc, + name: &'a str, + opts: &'a LogOptions, + ) -> BoxFuture<'a, MicrosandboxResult>> { + Box::pin(async move { crate::sandbox::logs::read_logs(self, name, opts) }) + } + + fn metrics<'a>( + &'a self, + _backend: Arc, + name: &'a str, + config: &'a SandboxConfig, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::sandbox::metrics::local_metrics(self, name, config).await }) + } + + fn metrics_stream( + &self, + backend: Arc, + name: String, + config: SandboxConfig, + interval: Duration, + ) -> MetricsStream { + crate::sandbox::metrics::local_metrics_stream(backend, name, config, interval) + } + + fn fs_read<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::sandbox::fs::local::read(self, name, path).await }) + } + + fn fs_read_stream<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::sandbox::fs::local::read_stream(self, name, path).await }) + } + + fn fs_write<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + data: Vec, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::sandbox::fs::local::write(self, name, path, data).await }) + } + + fn fs_write_stream<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::sandbox::fs::local::write_stream(self, name, path).await }) + } + + fn fs_list<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>> { + Box::pin(async move { crate::sandbox::fs::local::list(self, name, path).await }) + } + + fn fs_stat<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::sandbox::fs::local::stat(self, name, path).await }) + } + + fn fs_mkdir<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::sandbox::fs::local::mkdir(self, name, path).await }) + } + + fn fs_remove<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + recursive: bool, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin( + async move { crate::sandbox::fs::local::remove(self, name, path, recursive).await }, + ) + } + + fn fs_copy<'a>( + &'a self, + _backend: Arc, + name: &'a str, + from: &'a str, + to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::sandbox::fs::local::copy(self, name, from, to).await }) + } + + fn fs_rename<'a>( + &'a self, + _backend: Arc, + name: &'a str, + from: &'a str, + to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::sandbox::fs::local::rename(self, name, from, to).await }) + } + + fn fs_exists<'a>( + &'a self, + _backend: Arc, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::sandbox::fs::local::exists(self, name, path).await }) + } + + fn fs_copy_from_host<'a>( + &'a self, + _backend: Arc, + name: &'a str, + host: &'a Path, + guest: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin( + async move { crate::sandbox::fs::local::copy_from_host(self, name, host, guest).await }, + ) + } + + fn fs_copy_to_host<'a>( + &'a self, + _backend: Arc, + name: &'a str, + guest: &'a str, + host: &'a Path, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin( + async move { crate::sandbox::fs::local::copy_to_host(self, name, guest, host).await }, + ) + } +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations: CloudBackend +//-------------------------------------------------------------------------------------------------- + +impl SandboxBackend for CloudBackend { + fn create<'a>( + &'a self, + backend: Arc, + config: SandboxConfig, + start: bool, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + let req = cloud_create_request_from_config(config.clone())?; + let cloud = CloudBackend::create_sandbox(self, &req, start).await?; + Ok(Sandbox::from_cloud(backend, cloud, config)) + }) + } + + fn create_detached<'a>( + &'a self, + backend: Arc, + config: SandboxConfig, + ) -> BoxFuture<'a, MicrosandboxResult> { + // Cloud has no notion of "detached" — the sandbox lifecycle is owned + // by msb-cloud, not by this process. Reuse the eager-start path. + Box::pin(async move { + let req = cloud_create_request_from_config(config.clone())?; + let cloud = CloudBackend::create_sandbox(self, &req, true).await?; + Ok(Sandbox::from_cloud(backend, cloud, config)) + }) + } + + fn start<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + let cloud = CloudBackend::start_sandbox(self, name).await?; + let config = sandbox_config_from_cloud(&cloud); + Ok(Sandbox::from_cloud(backend, cloud, config)) + }) + } + + fn start_detached<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + // Cloud start is detached by definition — the sandbox keeps running + // after this process exits. Same code path as `start`. + Box::pin(async move { + let cloud = CloudBackend::start_sandbox(self, name).await?; + let config = sandbox_config_from_cloud(&cloud); + Ok(Sandbox::from_cloud(backend, cloud, config)) + }) + } + + fn get<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + let cloud = CloudBackend::get_sandbox(self, name).await?; + SandboxHandle::from_cloud(backend, cloud) + }) + } + + fn list<'a>( + &'a self, + backend: Arc, + cursor: Option<&'a str>, + limit: Option, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { + let page = CloudBackend::list_sandboxes(self, cursor, limit).await?; + let sandboxes = page + .data + .into_iter() + .map(|sb| SandboxHandle::from_cloud(backend.clone(), sb)) + .collect::>>()?; + Ok(SandboxList { + sandboxes, + next_cursor: page.next_cursor, + }) + }) + } + + fn remove<'a>( + &'a self, + _backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { + CloudBackend::destroy_sandbox(self, name).await?; + Ok(()) + }) + } + + fn stop<'a>( + &'a self, + _backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { + CloudBackend::stop_sandbox(self, name).await?; + Ok(()) + }) + } + + fn kill<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { + Err(unsupported( + "cloud sandbox kill", + "when cloud forced-stop lands", + )) + }) + } + + fn drain<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { + Err(unsupported( + "cloud sandbox drain", + "when cloud graceful-drain lands", + )) + }) + } + + fn exec<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _config: &'a SandboxConfig, + _cmd: String, + _opts: ExecOptions, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_exec("Sandbox::exec")) }) + } + + fn exec_stream<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _config: &'a SandboxConfig, + _cmd: String, + _opts: ExecOptions, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_exec("Sandbox::exec_stream")) }) + } + + fn attach<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _config: &'a SandboxConfig, + _cmd: String, + _opts: crate::sandbox::AttachOptionsBuilder, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_exec("Sandbox::attach")) }) + } + + fn logs<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _opts: &'a LogOptions, + ) -> BoxFuture<'a, MicrosandboxResult>> { + Box::pin(async move { Err(unsupported_logs("Sandbox::logs")) }) + } + + fn metrics<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _config: &'a SandboxConfig, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_metrics("Sandbox::metrics")) }) + } + + fn metrics_stream( + &self, + _backend: Arc, + _name: String, + _config: SandboxConfig, + _interval: Duration, + ) -> MetricsStream { + Box::pin(futures::stream::once(async { + Err(unsupported_metrics("Sandbox::metrics_stream")) + })) + } + + fn fs_read<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::read")) }) + } + + fn fs_read_stream<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::read_stream")) }) + } + + fn fs_write<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + _data: Vec, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::write")) }) + } + + fn fs_write_stream<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::write_stream")) }) + } + + fn fs_list<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::list")) }) + } + + fn fs_stat<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::stat")) }) + } + + fn fs_mkdir<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::mkdir")) }) + } + + fn fs_remove<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + _recursive: bool, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::remove")) }) + } + + fn fs_copy<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _from: &'a str, + _to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::copy")) }) + } + + fn fs_rename<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _from: &'a str, + _to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::rename")) }) + } + + fn fs_exists<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::exists")) }) + } + + fn fs_copy_from_host<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _host: &'a Path, + _guest: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::copy_from_host")) }) + } + + fn fs_copy_to_host<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + _guest: &'a str, + _host: &'a Path, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported_fs("SandboxFs::copy_to_host")) }) + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Map [`CloudSandboxStatus`] to the SDK's [`SandboxStatus`] enum. +/// +/// `Stopping` collapses to `Draining` (microsandbox uses `Draining` for +/// the graceful-stop state); `Failed` collapses to `Crashed`. All other +/// variants map 1:1. +pub(crate) fn cloud_status_to_sandbox_status(s: CloudSandboxStatus) -> SandboxStatus { + match s { + CloudSandboxStatus::Created => SandboxStatus::Created, + CloudSandboxStatus::Starting => SandboxStatus::Starting, + CloudSandboxStatus::Running => SandboxStatus::Running, + CloudSandboxStatus::Stopping => SandboxStatus::Draining, + CloudSandboxStatus::Stopped => SandboxStatus::Stopped, + CloudSandboxStatus::Failed => SandboxStatus::Crashed, + } +} + +/// Synthesize a [`SandboxConfig`] from a [`CloudSandbox`] response. Used when +/// the SDK didn't drive the create call (e.g. `start(name)` returns a +/// `Sandbox` for a sandbox the cloud created earlier). +/// +/// Maps every field that exists on the cloud wire shape +/// ([`CloudCreateSandboxRequest`]). Fields with no cloud counterpart are +/// filled from [`SandboxConfig::default()`] via the `..Default::default()` +/// spread — see inline comments for the synthesized defaults so a caller +/// inspecting `sb.config()` after `Sandbox::start(name)` can reason about +/// which fields are "live" vs. "synthesized stub". +fn sandbox_config_from_cloud(cloud: &CloudSandbox) -> SandboxConfig { + let mut config = SandboxConfig { + // --- Mapped from cloud wire (CloudCreateSandboxRequest) --- + name: cloud.config.name.clone(), + image: RootfsSource::Oci(cloud.config.image.clone()), + cpus: cloud.config.vcpus, + memory_mib: cloud.config.memory_mib, + env: cloud.config.env.clone().into_iter().collect(), + workdir: cloud.config.workdir.clone(), + shell: cloud.config.shell.clone(), + entrypoint: cloud.config.entrypoint.clone(), + hostname: cloud.config.hostname.clone(), + user: cloud.config.user.clone(), + scripts: cloud.config.scripts.clone(), + log_level: cloud + .config + .log_level + .as_deref() + .and_then(cloud_log_level_to_local), + // --- Synthesized defaults: no cloud counterpart yet --- + // The fields below are filled from `SandboxConfig::default()` via the + // `..Default::default()` spread because the cloud wire shape doesn't + // carry them (D13). Surfacing them as defaults rather than panicking + // keeps `sb.config()` total, but callers should not treat these as + // authoritative for a cloud sandbox: + // - metrics_sample_interval_ms / disable_metrics_sample (cloud + // metrics are not yet exposed through the SDK config surface) + // - mounts / patches / rlimits (cloud volumes deferred) + // - cmd (cloud cmd override deferred) + // - init (cloud handoff init deferred) + // - pull_policy / registry_auth / insecure / ca_certs (cloud + // manages image pulls server-side) + // - replace_existing / replace_with_grace (operation flags, not + // persisted state) + // - network (cloud networking deferred) + ..Default::default() + }; + // policy lives in a sub-struct; spread above seeded a default policy, + // overlay the two timeout fields the cloud DOES expose. + config.policy.max_duration_secs = cloud.config.max_duration_secs; + config.policy.idle_timeout_secs = cloud.config.idle_timeout_secs; + config +} + +/// Parse the cloud wire `log_level` string back into a local [`LogLevel`]. +/// Unknown values map to `None` — better to drop than to misclassify. +fn cloud_log_level_to_local(level: &str) -> Option { + use microsandbox_runtime::logging::LogLevel; + match level { + "error" => Some(LogLevel::Error), + "warn" => Some(LogLevel::Warn), + "info" => Some(LogLevel::Info), + "debug" => Some(LogLevel::Debug), + "trace" => Some(LogLevel::Trace), + _ => None, + } +} + +pub(super) fn cloud_create_request_from_config( + config: SandboxConfig, +) -> MicrosandboxResult { + reject_cloud_deferred( + !config.mounts.is_empty(), + "mounts", + "when cloud volumes ship", + )?; + reject_cloud_deferred( + !config.patches.is_empty(), + "patches", + "when cloud volumes ship", + )?; + reject_cloud_deferred( + !config.rlimits.is_empty(), + "rlimits", + "when rlimits land on the cloud API", + )?; + reject_cloud_deferred( + config.cmd.is_some(), + "cmd", + "when cmd lands on the cloud API", + )?; + reject_cloud_deferred( + config.replace_existing, + ".replace()", + "when cloud sandbox replace semantics land", + )?; + reject_cloud_deferred( + config.init.is_some(), + "init", + "when cloud init wrapper lands", + )?; + reject_cloud_deferred( + config.pull_policy != microsandbox_image::PullPolicy::IfMissing, + "pull_policy", + "when cloud pull policy lands", + )?; + reject_cloud_deferred( + config.registry_auth.is_some(), + "registry_auth", + "when cloud registry auth lands", + )?; + reject_cloud_deferred( + config.insecure, + "insecure registries", + "when cloud insecure-registry support lands", + )?; + reject_cloud_deferred( + !config.ca_certs.is_empty(), + "ca_certs", + "when cloud custom CA certs land", + )?; + #[cfg(feature = "net")] + { + // Only flag user-set opt-in fields. The default `NetworkConfig` + // ships with a baseline policy (`public_only`) and built-in DNS + // settings, so comparing those would always trigger; instead we + // catch the explicit-add fields (ports, secrets, custom DNS + // resolvers, host-CA trust). + let net = &config.network; + let has_custom_network = !net.ports.is_empty() + || !net.secrets.secrets.is_empty() + || !net.dns.nameservers.is_empty() + || net.trust_host_cas; + reject_cloud_deferred( + has_custom_network, + "network policy / ports / secrets", + "when cloud networking ships", + )?; + } + + let image = match config.image { + RootfsSource::Oci(image) => image, + RootfsSource::Bind(_) => { + return Err(unsupported( + "image-from-host-dir", + "when cloud volumes ship", + )); + } + RootfsSource::DiskImage { .. } => { + return Err(unsupported("disk-image rootfs", "never on cloud")); + } + }; + + Ok(CloudCreateSandboxRequest { + name: config.name, + image, + vcpus: config.cpus, + memory_mib: config.memory_mib, + env: config.env.into_iter().collect(), + ephemeral: true, + workdir: config.workdir, + shell: config.shell, + entrypoint: config.entrypoint, + hostname: config.hostname, + user: config.user, + log_level: config.log_level.map(log_level_to_cloud), + scripts: config.scripts, + max_duration_secs: config.policy.max_duration_secs, + idle_timeout_secs: config.policy.idle_timeout_secs, + }) +} + +fn reject_cloud_deferred( + present: bool, + feature: &'static str, + available_when: &'static str, +) -> MicrosandboxResult<()> { + if present { + return Err(unsupported(feature, available_when)); + } + Ok(()) +} + +fn unsupported(feature: &'static str, available_when: &'static str) -> MicrosandboxError { + MicrosandboxError::Unsupported { + feature: feature.into(), + available_when: available_when.into(), + } +} + +fn unsupported_exec(feature: &'static str) -> MicrosandboxError { + MicrosandboxError::Unsupported { + feature: feature.into(), + available_when: "when cloud exec lands".into(), + } +} + +fn unsupported_fs(feature: &'static str) -> MicrosandboxError { + MicrosandboxError::Unsupported { + feature: feature.into(), + available_when: "when cloud guest fs lands".into(), + } +} + +fn unsupported_logs(feature: &'static str) -> MicrosandboxError { + MicrosandboxError::Unsupported { + feature: feature.into(), + available_when: "when cloud logs land".into(), + } +} + +fn unsupported_metrics(feature: &'static str) -> MicrosandboxError { + MicrosandboxError::Unsupported { + feature: feature.into(), + available_when: "when cloud metrics land".into(), + } +} + +fn log_level_to_cloud(level: microsandbox_runtime::logging::LogLevel) -> String { + match level { + microsandbox_runtime::logging::LogLevel::Error => "error", + microsandbox_runtime::logging::LogLevel::Warn => "warn", + microsandbox_runtime::logging::LogLevel::Info => "info", + microsandbox_runtime::logging::LogLevel::Debug => "debug", + microsandbox_runtime::logging::LogLevel::Trace => "trace", + } + .to_string() +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::sandbox::SandboxBuilder; + + #[tokio::test] + async fn cloud_create_request_maps_common_fields() { + let config = SandboxBuilder::new("agent-1") + .image("python:3.12") + .cpus(2) + .memory(1024) + .env("A", "B") + .workdir("/app") + .shell("/bin/bash") + .entrypoint(["python", "-u"]) + .build() + .await + .unwrap(); + + let req = cloud_create_request_from_config(config).unwrap(); + + assert_eq!(req.name, "agent-1"); + assert_eq!(req.image, "python:3.12"); + assert_eq!(req.vcpus, 2); + assert_eq!(req.memory_mib, 1024); + assert_eq!(req.env["A"], "B"); + assert_eq!(req.workdir.as_deref(), Some("/app")); + assert_eq!(req.shell.as_deref(), Some("/bin/bash")); + assert_eq!( + req.entrypoint, + Some(vec!["python".to_string(), "-u".to_string()]) + ); + } + + #[tokio::test] + async fn cloud_create_request_rejects_disk_image_rootfs() { + let config = SandboxConfig { + name: "agent-1".into(), + image: RootfsSource::DiskImage { + path: "rootfs.img".into(), + format: crate::sandbox::DiskImageFormat::Raw, + fstype: None, + }, + ..Default::default() + }; + + let err = cloud_create_request_from_config(config).unwrap_err(); + assert!(matches!(err, MicrosandboxError::Unsupported { .. })); + } + + /// Build a minimal OCI-backed [`SandboxConfig`] suitable for the + /// cloud-reject tests. Each test then mutates one field and asserts + /// the resulting request errors with `Unsupported`. + fn base_cloud_config() -> SandboxConfig { + SandboxConfig { + name: "agent-1".into(), + image: RootfsSource::Oci("python:3.12".into()), + ..Default::default() + } + } + + #[test] + fn cloud_create_request_rejects_replace_existing() { + let mut config = base_cloud_config(); + config.replace_existing = true; + let err = cloud_create_request_from_config(config).unwrap_err(); + assert!(matches!(err, MicrosandboxError::Unsupported { .. })); + } + + #[test] + fn cloud_create_request_rejects_init() { + let mut config = base_cloud_config(); + config.init = Some(crate::sandbox::HandoffInit { + cmd: "/sbin/init".into(), + args: Vec::new(), + env: Vec::new(), + }); + let err = cloud_create_request_from_config(config).unwrap_err(); + assert!(matches!(err, MicrosandboxError::Unsupported { .. })); + } + + #[test] + fn cloud_create_request_rejects_non_default_pull_policy() { + let mut config = base_cloud_config(); + config.pull_policy = microsandbox_image::PullPolicy::Always; + let err = cloud_create_request_from_config(config).unwrap_err(); + assert!(matches!(err, MicrosandboxError::Unsupported { .. })); + } + + #[test] + fn cloud_create_request_rejects_registry_auth() { + let mut config = base_cloud_config(); + config.registry_auth = Some(microsandbox_image::RegistryAuth::Basic { + username: "u".into(), + password: "p".into(), + }); + let err = cloud_create_request_from_config(config).unwrap_err(); + assert!(matches!(err, MicrosandboxError::Unsupported { .. })); + } + + #[test] + fn cloud_create_request_rejects_insecure() { + let mut config = base_cloud_config(); + config.insecure = true; + let err = cloud_create_request_from_config(config).unwrap_err(); + assert!(matches!(err, MicrosandboxError::Unsupported { .. })); + } + + #[test] + fn cloud_create_request_rejects_ca_certs() { + let mut config = base_cloud_config(); + config + .ca_certs + .push(b"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----".to_vec()); + let err = cloud_create_request_from_config(config).unwrap_err(); + assert!(matches!(err, MicrosandboxError::Unsupported { .. })); + } + + #[cfg(feature = "net")] + #[test] + fn cloud_create_request_rejects_published_ports() { + let mut config = base_cloud_config(); + config + .network + .ports + .push(microsandbox_network::config::PublishedPort { + host_port: 8080, + guest_port: 80, + protocol: microsandbox_network::config::PortProtocol::Tcp, + host_bind: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + }); + let err = cloud_create_request_from_config(config).unwrap_err(); + assert!(matches!(err, MicrosandboxError::Unsupported { .. })); + } + + #[test] + fn sandbox_config_from_cloud_round_trips_d13_fields() { + let cloud = CloudSandbox { + id: "00000000-0000-0000-0000-000000000002".into(), + org_id: "00000000-0000-0000-0000-000000000001".into(), + name: "agent-1".into(), + status: CloudSandboxStatus::Running, + config: CloudCreateSandboxRequest { + name: "agent-1".into(), + image: "python:3.12".into(), + vcpus: 4, + memory_mib: 2048, + env: [("A".to_string(), "B".to_string())].into_iter().collect(), + ephemeral: true, + workdir: Some("/app".into()), + shell: Some("/bin/bash".into()), + entrypoint: Some(vec!["python".into(), "-u".into()]), + hostname: Some("worker".into()), + user: Some("appuser".into()), + log_level: Some("debug".into()), + scripts: [("setup".to_string(), "echo hi".to_string())] + .into_iter() + .collect(), + max_duration_secs: Some(3600), + idle_timeout_secs: Some(600), + }, + ephemeral: true, + created_at: chrono::Utc::now(), + started_at: None, + stopped_at: None, + last_error: None, + }; + + let config = sandbox_config_from_cloud(&cloud); + + assert_eq!(config.name, "agent-1"); + assert!(matches!(config.image, RootfsSource::Oci(ref s) if s == "python:3.12")); + assert_eq!(config.cpus, 4); + assert_eq!(config.memory_mib, 2048); + assert_eq!( + config.env, + vec![("A".to_string(), "B".to_string())], + "env round-trip" + ); + assert_eq!(config.workdir.as_deref(), Some("/app")); + assert_eq!(config.shell.as_deref(), Some("/bin/bash")); + assert_eq!( + config.entrypoint, + Some(vec!["python".to_string(), "-u".to_string()]) + ); + assert_eq!(config.hostname.as_deref(), Some("worker")); + assert_eq!(config.user.as_deref(), Some("appuser")); + assert_eq!( + config.log_level, + Some(microsandbox_runtime::logging::LogLevel::Debug), + "log_level should round-trip via string mapping", + ); + assert_eq!(config.scripts.get("setup"), Some(&"echo hi".to_string())); + assert_eq!(config.policy.max_duration_secs, Some(3600)); + assert_eq!(config.policy.idle_timeout_secs, Some(600)); + } + + #[test] + fn sandbox_config_from_cloud_drops_unknown_log_level() { + let cloud = CloudSandbox { + id: "00000000-0000-0000-0000-000000000002".into(), + org_id: "00000000-0000-0000-0000-000000000001".into(), + name: "agent-1".into(), + status: CloudSandboxStatus::Running, + config: CloudCreateSandboxRequest { + name: "agent-1".into(), + image: "python:3.12".into(), + log_level: Some("verbose".into()), + ..Default::default() + }, + ephemeral: true, + created_at: chrono::Utc::now(), + started_at: None, + stopped_at: None, + last_error: None, + }; + + let config = sandbox_config_from_cloud(&cloud); + assert!( + config.log_level.is_none(), + "unknown log_level should map to None" + ); + } + + #[test] + fn cloud_status_maps_created_and_starting_one_to_one() { + assert_eq!( + cloud_status_to_sandbox_status(CloudSandboxStatus::Created), + SandboxStatus::Created, + ); + assert_eq!( + cloud_status_to_sandbox_status(CloudSandboxStatus::Starting), + SandboxStatus::Starting, + ); + assert_eq!( + cloud_status_to_sandbox_status(CloudSandboxStatus::Running), + SandboxStatus::Running, + ); + assert_eq!( + cloud_status_to_sandbox_status(CloudSandboxStatus::Stopping), + SandboxStatus::Draining, + ); + assert_eq!( + cloud_status_to_sandbox_status(CloudSandboxStatus::Stopped), + SandboxStatus::Stopped, + ); + assert_eq!( + cloud_status_to_sandbox_status(CloudSandboxStatus::Failed), + SandboxStatus::Crashed, + ); + } +} diff --git a/crates/microsandbox/lib/backend/volume.rs b/crates/microsandbox/lib/backend/volume.rs new file mode 100644 index 000000000..48b5dde51 --- /dev/null +++ b/crates/microsandbox/lib/backend/volume.rs @@ -0,0 +1,594 @@ +//! Volume lifecycle backend trait. +//! +//! Per the SDK local-cloud parity plan (D6.4): `Volume` / `VolumeHandle` / +//! `VolumeFs` stay single types with no public variants. They hold +//! `Arc` plus a backend-private `VolumeInner` / `VolumeHandleInner` +//! enum. The trait returns the outer types — variant state is constructed +//! inside each backend's trait impl and wrapped with the `Arc` +//! the caller passes in. +//! +//! Cloud-side volume ops route to `Unsupported` until Phase 6 — see the plan's +//! D14 table. + +use std::path::PathBuf; +use std::sync::Arc; + +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use futures::future::BoxFuture; + +use super::{Backend, CloudBackend, LocalBackend}; +use crate::sandbox::fs::{FsEntry, FsMetadata}; +use crate::volume::{Volume, VolumeConfig, VolumeFsReadStream, VolumeFsWriteSink, VolumeHandle}; +use crate::{MicrosandboxError, MicrosandboxResult}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Backend-private state behind [`Volume`]. +/// +/// Users never see this enum directly — they get the outer `Volume` and reach +/// variant-specific data through the [`Volume::local`](crate::volume::Volume::local) +/// / [`Volume::cloud`](crate::volume::Volume::cloud) accessors. +pub enum VolumeInner { + /// Local-disk-backed volume state. + Local(VolumeLocalState), + /// Cloud msb-cloud-backed volume state. + Cloud(VolumeCloudState), +} + +/// Local-disk-backed volume state held inside [`VolumeInner::Local`]. +pub struct VolumeLocalState { + /// Host directory rooted at `volumes_dir/`. + pub path: PathBuf, +} + +/// Cloud msb-cloud-backed volume state held inside [`VolumeInner::Cloud`]. +/// +/// Placeholder shape — populated when cloud volumes ship in Phase 6. +pub struct VolumeCloudState { + /// Server-side UUID. + pub id: String, + /// Owning org's UUID. + pub org_id: String, +} + +/// Backend-private state behind [`VolumeHandle`] — the lightweight DB-row view. +pub enum VolumeHandleInner { + /// Local persisted volume handle. + Local(VolumeHandleLocalState), + /// Cloud msb-cloud volume handle. + Cloud(VolumeHandleCloudState), +} + +/// Local handle state. Snapshot of the database row. +pub struct VolumeHandleLocalState { + /// SQLite row id for this volume. + pub db_id: i32, + /// Host directory rooted at `volumes_dir/`. + pub path: PathBuf, + /// Configured quota in MiB, when set. + pub quota_mib: Option, + /// Disk usage snapshot at handle-creation time. + pub used_bytes: u64, + /// Key-value labels associated with the volume. + pub labels: Vec<(String, String)>, + /// When this volume was first recorded, if known. + pub created_at: Option>, +} + +/// Cloud handle state. Captures the snapshot msb-cloud returned at fetch time. +/// +/// Placeholder shape — populated when cloud volumes ship in Phase 6. +pub struct VolumeHandleCloudState { + /// Server-side UUID. + pub id: String, + /// Owning org's UUID. + pub org_id: String, + /// Configured quota in MiB, when set. + pub quota_mib: Option, + /// Disk usage snapshot at handle-fetch time. + pub used_bytes: u64, + /// Key-value labels associated with the volume. + pub labels: Vec<(String, String)>, + /// When this volume was first recorded, if known. + pub created_at: Option>, +} + +/// Resource-specific backend for volume lifecycle + host-side filesystem ops. +/// +/// Trait methods take the [`Arc`] that they should wrap any +/// returned [`Volume`] / [`VolumeHandle`] with. Callers (e.g. `Volume::create`) +/// resolve the backend via [`default_backend`](super::default_backend) and +/// forward it through. +/// +/// Cloud-side `fs_*` ops ultimately route through msb-cloud HTTP per the plan's +/// D9, but in this commit cloud returns [`MicrosandboxError::Unsupported`] for +/// every method — cloud volumes ship in Phase 6. +pub trait VolumeBackend: Send + Sync { + /// Create a volume. The returned outer [`Volume`] carries the supplied + /// `backend` Arc and the variant-specific state inside [`VolumeInner`]. + fn create<'a>( + &'a self, + backend: Arc, + config: VolumeConfig, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Get a volume handle by name. + fn get<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// List all volumes. + fn list<'a>( + &'a self, + backend: Arc, + ) -> BoxFuture<'a, MicrosandboxResult>>; + + /// Remove a volume by name. + fn remove<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Read an entire file into memory as raw bytes. + fn fs_read<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Read an entire file into memory as a UTF-8 string. + fn fs_read_to_string<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Write data to a file, creating parent directories as needed. + fn fs_write<'a>( + &'a self, + name: &'a str, + path: &'a str, + data: Vec, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// List the immediate children of a directory (non-recursive). + fn fs_list<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>>; + + /// Get file/directory metadata. + fn fs_stat<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Create a directory (and parents). + fn fs_mkdir<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Remove a file or directory. + /// + /// When `recursive` is `false` only single files are removed; pointing at + /// a directory yields an OS-level "is a directory" error. When `recursive` + /// is `true` the path is removed along with all of its contents. + fn fs_remove<'a>( + &'a self, + name: &'a str, + path: &'a str, + recursive: bool, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Copy a file within the volume. + fn fs_copy<'a>( + &'a self, + name: &'a str, + from: &'a str, + to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Rename / move a file or directory. + fn fs_rename<'a>( + &'a self, + name: &'a str, + from: &'a str, + to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>>; + + /// Check whether a path exists. + fn fs_exists<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Open a streaming reader for a volume file. + fn fs_read_stream<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; + + /// Open a streaming writer for a volume file. Creates parent dirs. + fn fs_write_stream<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>; +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations: LocalBackend +//-------------------------------------------------------------------------------------------------- + +impl VolumeBackend for LocalBackend { + fn create<'a>( + &'a self, + backend: Arc, + config: VolumeConfig, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::volume::create_local(backend, config).await }) + } + + fn get<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::volume::get_local(backend, name).await }) + } + + fn list<'a>( + &'a self, + backend: Arc, + ) -> BoxFuture<'a, MicrosandboxResult>> { + Box::pin(async move { crate::volume::list_local(backend).await }) + } + + fn remove<'a>( + &'a self, + backend: Arc, + name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::volume::remove_local(backend, name).await }) + } + + fn fs_read<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::volume::fs::local::read(self, name, path).await }) + } + + fn fs_read_to_string<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::volume::fs::local::read_to_string(self, name, path).await }) + } + + fn fs_write<'a>( + &'a self, + name: &'a str, + path: &'a str, + data: Vec, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::volume::fs::local::write(self, name, path, &data).await }) + } + + fn fs_list<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>> { + Box::pin(async move { crate::volume::fs::local::list(self, name, path).await }) + } + + fn fs_stat<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::volume::fs::local::stat(self, name, path).await }) + } + + fn fs_mkdir<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::volume::fs::local::mkdir(self, name, path).await }) + } + + fn fs_remove<'a>( + &'a self, + name: &'a str, + path: &'a str, + recursive: bool, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::volume::fs::local::remove(self, name, path, recursive).await }) + } + + fn fs_copy<'a>( + &'a self, + name: &'a str, + from: &'a str, + to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::volume::fs::local::copy(self, name, from, to).await }) + } + + fn fs_rename<'a>( + &'a self, + name: &'a str, + from: &'a str, + to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { crate::volume::fs::local::rename(self, name, from, to).await }) + } + + fn fs_exists<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::volume::fs::local::exists(self, name, path).await }) + } + + fn fs_read_stream<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::volume::fs::local::read_stream(self, name, path).await }) + } + + fn fs_write_stream<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { crate::volume::fs::local::write_stream(self, name, path).await }) + } +} + +//-------------------------------------------------------------------------------------------------- +// Trait Implementations: CloudBackend +//-------------------------------------------------------------------------------------------------- + +impl VolumeBackend for CloudBackend { + fn create<'a>( + &'a self, + _backend: Arc, + _config: VolumeConfig, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported("Volume::create")) }) + } + + fn get<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported("Volume::get")) }) + } + + fn list<'a>( + &'a self, + _backend: Arc, + ) -> BoxFuture<'a, MicrosandboxResult>> { + Box::pin(async move { Err(unsupported("Volume::list")) }) + } + + fn remove<'a>( + &'a self, + _backend: Arc, + _name: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported("Volume::remove")) }) + } + + fn fs_read<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported("VolumeFs::read")) }) + } + + fn fs_read_to_string<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported("VolumeFs::read_to_string")) }) + } + + fn fs_write<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + _data: Vec, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported("VolumeFs::write")) }) + } + + fn fs_list<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult>> { + Box::pin(async move { Err(unsupported("VolumeFs::list")) }) + } + + fn fs_stat<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported("VolumeFs::stat")) }) + } + + fn fs_mkdir<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported("VolumeFs::mkdir")) }) + } + + fn fs_remove<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + _recursive: bool, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported("VolumeFs::remove")) }) + } + + fn fs_copy<'a>( + &'a self, + _name: &'a str, + _from: &'a str, + _to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported("VolumeFs::copy")) }) + } + + fn fs_rename<'a>( + &'a self, + _name: &'a str, + _from: &'a str, + _to: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult<()>> { + Box::pin(async move { Err(unsupported("VolumeFs::rename")) }) + } + + fn fs_exists<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported("VolumeFs::exists")) }) + } + + fn fs_read_stream<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported("VolumeFs::read_stream")) }) + } + + fn fs_write_stream<'a>( + &'a self, + _name: &'a str, + _path: &'a str, + ) -> BoxFuture<'a, MicrosandboxResult> { + Box::pin(async move { Err(unsupported("VolumeFs::write_stream")) }) + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Build a uniform `Unsupported` error for cloud volume ops — all of them are +/// gated behind Phase 6. +fn unsupported(feature: &str) -> MicrosandboxError { + MicrosandboxError::Unsupported { + feature: feature.into(), + available_when: "when cloud volumes ship".into(), + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::{LocalBackend, set_default_backend}; + use crate::volume::VolumeConfig; + + /// Regression test for the asymmetric-signature P1: `LocalBackend::remove` + /// must operate on the passed-in `backend` Arc, not on the process-wide + /// `default_backend()`. Two `LocalBackend` instances live in separate + /// home dirs (so separate SQLite DBs). The default is installed on + /// backend A; backend B's trait impl is invoked directly. The remove + /// must hit B's DB (and fail because the volume doesn't exist there), + /// not silently succeed by re-resolving the default. + #[tokio::test] + async fn local_backend_remove_uses_passed_backend_not_global_default() { + let home_a = tempfile::tempdir().unwrap(); + let home_b = tempfile::tempdir().unwrap(); + + let backend_a: Arc = Arc::new( + LocalBackend::builder() + .home(home_a.path()) + .build() + .await + .unwrap(), + ); + let backend_b: Arc = Arc::new( + LocalBackend::builder() + .home(home_b.path()) + .build() + .await + .unwrap(), + ); + + // Create a volume only in backend A. + backend_a + .volumes() + .create( + backend_a.clone(), + VolumeConfig { + name: "shared-name".into(), + quota_mib: None, + labels: Vec::new(), + }, + ) + .await + .unwrap(); + + // Install A as the process default. If the trait impl re-resolved + // `default_backend()` (the bug we just fixed) it would find A and + // successfully delete A's volume — even though we asked B. + set_default_backend(backend_a.clone()); + + // Call remove via backend B. Backend B has no such volume, so this + // must error with `VolumeNotFound`. + let err = backend_b + .volumes() + .remove(backend_b.clone(), "shared-name") + .await + .expect_err("remove should fail: volume does not exist in backend B"); + assert!( + matches!(err, MicrosandboxError::VolumeNotFound(_)), + "expected VolumeNotFound, got: {err:?}" + ); + + // Sanity check: A's volume is still there — the misrouted remove + // would have deleted it. + let handles = backend_a.volumes().list(backend_a.clone()).await.unwrap(); + assert!( + handles.iter().any(|h| h.name() == "shared-name"), + "backend A's volume should still exist after the (correctly-routed) B remove" + ); + } +} diff --git a/crates/microsandbox/lib/config/mod.rs b/crates/microsandbox/lib/config/mod.rs index 7987427a7..7505cfd0f 100644 --- a/crates/microsandbox/lib/config/mod.rs +++ b/crates/microsandbox/lib/config/mod.rs @@ -1,7 +1,14 @@ -//! Global configuration for the microsandbox library. +//! Configuration schema for the microsandbox library. //! -//! Configuration is loaded from `~/.microsandbox/config.json` on first access. -//! All fields have sensible defaults — a missing config file is equivalent to `{}`. +//! [`LocalConfig`] is the persisted schema for `~/.microsandbox/config.json`. +//! It is owned by [`LocalBackend`](crate::backend::LocalBackend); accessors +//! live on the backend, not on a process-wide static. See D6.7 Layer 2a in +//! `planning/microsandbox/design/api/local-cloud-backend.md`. +//! +//! Layer 1 process-wide knobs ([`set_sdk_msb_path`], +//! [`set_sdk_libkrunfw_path`]) stay in this module — they are documented as +//! process-singleton-by-physics (one dylib per process address space, +//! one resolved `msb` binary). use std::{ collections::HashMap, @@ -22,10 +29,10 @@ use crate::{MicrosandboxError, MicrosandboxResult}; //-------------------------------------------------------------------------------------------------- /// Default number of vCPUs per sandbox. -const DEFAULT_CPUS: u8 = 1; +pub(crate) const DEFAULT_CPUS: u8 = 1; /// Default guest memory in MiB. -const DEFAULT_MEMORY_MIB: u32 = 512; +pub(crate) const DEFAULT_MEMORY_MIB: u32 = 512; /// Default database max connections. pub(crate) const DEFAULT_MAX_CONNECTIONS: u32 = 5; @@ -62,15 +69,34 @@ pub(crate) mod metrics_interval_serde { ))] const REGISTRY_KEYRING_SERVICE: &str = "dev.microsandbox.registry"; +//-------------------------------------------------------------------------------------------------- +// Statics: Layer 1 (process-level) +//-------------------------------------------------------------------------------------------------- + +/// SDK-provided path to the bundled `msb` binary. Set via [`set_sdk_msb_path`] +/// by FFI bindings that ship a binary inside their language package and need +/// an in-process channel that doesn't fight user env. Tier 2 of the +/// resolution ladder (below `MSB_PATH` env, above config + filesystem +/// fallbacks). +static SDK_MSB_PATH: OnceLock = OnceLock::new(); + +/// SDK-provided path to the bundled `libkrunfw` dylib. Set via +/// [`set_sdk_libkrunfw_path`]. Tier 2 of the libkrunfw resolution ladder. +static SDK_LIBKRUNFW_PATH: OnceLock = OnceLock::new(); + //-------------------------------------------------------------------------------------------------- // Types //-------------------------------------------------------------------------------------------------- -/// Global configuration for the microsandbox library. +/// Configuration owned by a [`LocalBackend`](crate::backend::LocalBackend). +/// +/// Built from `~/.microsandbox/config.json` by default, or programmatically +/// via [`LocalBackend::builder`](crate::backend::LocalBackend::builder). +/// Bound to one backend instance — not a process-wide singleton. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] #[derive(Default)] -pub struct GlobalConfig { +pub struct LocalConfig { /// Root directory for all microsandbox data. pub home: Option, @@ -247,10 +273,7 @@ struct KeyringRegistryCredential { // Methods //-------------------------------------------------------------------------------------------------- -static CONFIG: OnceLock = OnceLock::new(); -static SDK_MSB_PATH: OnceLock = OnceLock::new(); - -impl GlobalConfig { +impl LocalConfig { /// Get the resolved home directory. pub fn home(&self) -> PathBuf { self.home.clone().unwrap_or_else(resolve_default_home) @@ -273,10 +296,6 @@ impl GlobalConfig { } /// Resolve the `snapshots` directory. - /// - /// Snapshot artifacts are stored under this directory by name. The - /// directory is the source of truth; the local DB index is just a - /// cache rebuildable from a directory walk. pub fn snapshots_dir(&self) -> PathBuf { self.paths .snapshots @@ -341,7 +360,7 @@ impl GlobalConfig { /// /// Resolution order: /// 1. OS keyring (interactive CLI login, when the `keyring` feature is enabled) - /// 2. `registries..auth` in global config + /// 2. `registries..auth` in this config /// 3. Docker credential store/config /// 4. Anonymous /// @@ -538,28 +557,29 @@ fn docker_credential_servers(hostname: &str) -> Vec { servers } -/// Get the global configuration (lazy-loaded from disk on first call). -pub fn config() -> &'static GlobalConfig { - CONFIG.get_or_init(|| load_config().unwrap_or_default()) -} - -/// Resolve the path to the persisted global config file. +/// Resolve the path to the persisted local config file. pub fn config_path() -> PathBuf { + // Honour MSB_CONFIG_PATH if set — same env var the SDK config loader + // checks. The LocalConfig and the SdkConfig live in the same JSON + // document, so both layers must agree on the path. + if let Ok(p) = std::env::var("MSB_CONFIG_PATH") { + return PathBuf::from(p); + } resolve_default_home().join(microsandbox_utils::CONFIG_FILENAME) } /// Load the persisted config file or return the default config if it does not exist. -pub fn load_persisted_config_or_default() -> MicrosandboxResult { +pub fn load_persisted_config_or_default() -> MicrosandboxResult { let path = config_path(); if !path.exists() { - return Ok(GlobalConfig::default()); + return Ok(LocalConfig::default()); } read_config_from(&path) } -/// Persist the provided global config to disk as pretty JSON. -pub fn save_persisted_config(config: &GlobalConfig) -> MicrosandboxResult<()> { +/// Persist the provided local config to disk as pretty JSON. +pub fn save_persisted_config(config: &LocalConfig) -> MicrosandboxResult<()> { let path = config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| { @@ -598,15 +618,6 @@ pub fn delete_registry_keyring_auth(hostname: &str) -> MicrosandboxResult<()> { remove_registry_keyring_auth(hostname).map_err(MicrosandboxError::Custom) } -/// Override the global configuration programmatically. -/// -/// Must be called before the first call to [`config()`]. Returns `Err` with the -/// provided config if the global has already been initialized. -#[allow(clippy::result_large_err)] -pub fn set_config(config: GlobalConfig) -> Result<(), GlobalConfig> { - CONFIG.set(config) -} - /// Set the `msb` binary path resolved by an SDK package. /// /// This is an internal SDK bridge for runtimes where mutating `process.env` @@ -616,19 +627,19 @@ pub fn set_sdk_msb_path(path: impl Into) { let _ = SDK_MSB_PATH.set(path.into()); } -/// Resolve the path to the `msb` binary. +/// Resolve the path to the `msb` binary against the supplied [`LocalConfig`]. /// /// Resolution order: /// 1. `MSB_PATH` environment variable /// 2. SDK-provided runtime path -/// 3. `config().paths.msb` +/// 3. `config.paths.msb` /// 4. workspace-local `build/msb` or `target/debug/msb` (debug builds only) /// 5. `~/.microsandbox/bin/msb` /// 6. `which::which("msb")` -pub fn resolve_msb_path() -> MicrosandboxResult { +pub fn resolve_msb_path(config: &LocalConfig) -> MicrosandboxResult { let env_msb = std::env::var("MSB_PATH").ok(); let sdk_msb = SDK_MSB_PATH.get().cloned(); - let config_msb = config().paths.msb.clone(); + let config_msb = config.paths.msb.clone(); let debug_probe = || -> Option { // Only probe workspace-local dev builds in debug builds to prevent @@ -654,7 +665,7 @@ pub fn resolve_msb_path() -> MicrosandboxResult { }; let home_probe = || -> Option { - let home_bin = config() + let home_bin = config .home() .join(microsandbox_utils::BIN_SUBDIR) .join(microsandbox_utils::MSB_BINARY); @@ -714,15 +725,49 @@ fn resolve_msb_path_from( )) } -/// Resolve the path to `libkrunfw`. +/// Set the `libkrunfw` path resolved by an SDK package (e.g. one that ships a +/// bundled libkrunfw dylib inside its language-package wheel/npm-package). /// -/// Resolution order: -/// 1. `config().paths.libkrunfw` -/// 2. A sibling of the resolved `msb` binary (for `build/msb`) -/// 3. `../lib/` next to the resolved `msb` binary (for installed layouts) -/// 4. `{home}/lib/libkrunfw.{so,dylib}` -pub fn resolve_libkrunfw_path() -> MicrosandboxResult { - if let Some(path) = &config().paths.libkrunfw { +/// Set-once: subsequent calls are ignored. Sits at tier 2 of +/// [`resolve_libkrunfw_path`] — below user env (`MSB_LIBKRUNFW_PATH`) so a user +/// override always wins, above the config + filesystem fallbacks. +/// +/// Mirrors [`set_sdk_msb_path`]; both share the same precedence shape. +pub fn set_sdk_libkrunfw_path(path: impl Into) { + let _ = SDK_LIBKRUNFW_PATH.set(path.into()); +} + +/// Resolve the path to `libkrunfw` against the supplied [`LocalConfig`]. +/// +/// Resolution order (highest first): +/// 1. `MSB_LIBKRUNFW_PATH` environment variable (user-facing override). +/// 2. SDK-provided runtime path (set via [`set_sdk_libkrunfw_path`], used by +/// FFI bindings that ship a bundled dylib). +/// 3. `config.paths.libkrunfw`. +/// 4. A sibling of the resolved `msb` binary (for `build/msb`). +/// 5. `../lib/` next to the resolved `msb` binary (for installed layouts). +/// 6. `{home}/lib/libkrunfw.{so,dylib}`. +pub fn resolve_libkrunfw_path(config: &LocalConfig) -> MicrosandboxResult { + if let Ok(env_path) = std::env::var("MSB_LIBKRUNFW_PATH") { + let path = PathBuf::from(env_path); + if path.is_file() { + tracing::debug!(path = %path.display(), source = "MSB_LIBKRUNFW_PATH env", "resolved libkrunfw"); + return Ok(path); + } + return Err(MicrosandboxError::LibkrunfwNotFound(format!( + "MSB_LIBKRUNFW_PATH points to non-file: {}", + path.display() + ))); + } + if let Some(sdk_path) = SDK_LIBKRUNFW_PATH.get() { + if sdk_path.is_file() { + tracing::debug!(path = %sdk_path.display(), source = "SDK runtime path", "resolved libkrunfw"); + return Ok(sdk_path.clone()); + } + // SDK path set but missing — fall through to config + fallbacks rather than error. + tracing::warn!(path = %sdk_path.display(), "SDK_LIBKRUNFW_PATH points to non-file; falling through to config + filesystem fallbacks"); + } + if let Some(path) = &config.paths.libkrunfw { if path.is_file() { return Ok(path.clone()); } @@ -738,13 +783,13 @@ pub fn resolve_libkrunfw_path() -> MicrosandboxResult { "linux" }; let filename = microsandbox_utils::libkrunfw_filename(os); - let home_fallback = config() + let home_fallback = config .home() .join(microsandbox_utils::LIB_SUBDIR) .join(&filename); let mut candidates = Vec::new(); - if let Ok(msb_path) = resolve_msb_path() { + if let Ok(msb_path) = resolve_msb_path(config) { candidates.extend(libkrunfw_candidates_from_msb(&msb_path, &filename)); } candidates.push(home_fallback); @@ -822,7 +867,7 @@ fn dedupe_strings(values: &mut Vec) { *values = deduped; } -fn read_config_from(path: &Path) -> MicrosandboxResult { +fn read_config_from(path: &Path) -> MicrosandboxResult { let content = std::fs::read_to_string(path).map_err(|e| { MicrosandboxError::Custom(format!("failed to read config `{}`: {e}", path.display())) })?; @@ -840,18 +885,6 @@ fn resolve_default_home() -> PathBuf { microsandbox_utils::resolve_home() } -/// Load config from the default config file path. -fn load_config() -> Option { - let path = config_path(); - load_config_from(&path) -} - -/// Load config from a specific file path. -fn load_config_from(path: &Path) -> Option { - let content = std::fs::read_to_string(path).ok()?; - serde_json::from_str(&content).ok() -} - #[cfg(all( feature = "keyring", any(target_os = "linux", target_os = "macos", target_os = "windows") @@ -977,7 +1010,7 @@ mod tests { #[test] fn test_default_config() { - let cfg = GlobalConfig::default(); + let cfg = LocalConfig::default(); assert_eq!(cfg.sandbox_defaults.cpus, 1); assert_eq!(cfg.sandbox_defaults.memory_mib, 512); assert_eq!(cfg.sandbox_defaults.shell, "/bin/sh"); @@ -993,7 +1026,7 @@ mod tests { #[test] fn test_deserialize_empty_json() { - let cfg: GlobalConfig = serde_json::from_str("{}").unwrap(); + let cfg: LocalConfig = serde_json::from_str("{}").unwrap(); assert_eq!(cfg.sandbox_defaults.cpus, 1); assert!(cfg.home.is_none()); } @@ -1001,7 +1034,7 @@ mod tests { #[test] fn test_deserialize_partial_json() { let json = r#"{"sandbox_defaults": {"cpus": 4}}"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert_eq!(cfg.sandbox_defaults.cpus, 4); assert_eq!(cfg.sandbox_defaults.memory_mib, 512); } @@ -1009,7 +1042,7 @@ mod tests { #[test] fn test_deserialize_metrics_interval_missing_uses_default() { let json = r#"{"sandbox_defaults": {}}"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert_eq!( cfg.sandbox_defaults.metrics_sample_interval_ms, NonZero::new(DEFAULT_METRICS_SAMPLE_INTERVAL_MS) @@ -1019,14 +1052,14 @@ mod tests { #[test] fn test_deserialize_metrics_interval_zero_disables() { let json = r#"{"sandbox_defaults": {"metrics_sample_interval_ms": 0}}"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert!(cfg.sandbox_defaults.metrics_sample_interval_ms.is_none()); } #[test] fn test_deserialize_metrics_interval_positive() { let json = r#"{"sandbox_defaults": {"metrics_sample_interval_ms": 2500}}"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert_eq!( cfg.sandbox_defaults.metrics_sample_interval_ms, NonZero::new(2500) @@ -1035,34 +1068,34 @@ mod tests { #[test] fn test_serialize_metrics_interval_disabled_round_trips() { - let mut cfg = GlobalConfig::default(); + let mut cfg = LocalConfig::default(); cfg.sandbox_defaults.metrics_sample_interval_ms = None; let json = serde_json::to_string(&cfg).unwrap(); assert!( json.contains("\"metrics_sample_interval_ms\":0"), "expected `0` serialization, got: {json}" ); - let round: GlobalConfig = serde_json::from_str(&json).unwrap(); + let round: LocalConfig = serde_json::from_str(&json).unwrap(); assert!(round.sandbox_defaults.metrics_sample_interval_ms.is_none()); } #[test] fn test_deserialize_disable_metrics_sample_default_false() { - let cfg: GlobalConfig = serde_json::from_str("{}").unwrap(); + let cfg: LocalConfig = serde_json::from_str("{}").unwrap(); assert!(!cfg.sandbox_defaults.disable_metrics_sample); } #[test] fn test_deserialize_disable_metrics_sample_true() { let json = r#"{"sandbox_defaults": {"disable_metrics_sample": true}}"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert!(cfg.sandbox_defaults.disable_metrics_sample); } #[test] fn test_deserialize_log_level() { let json = r#"{"log_level":"debug"}"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert_eq!(cfg.log_level, Some(LogLevel::Debug)); } @@ -1075,7 +1108,7 @@ mod tests { "busy_timeout_secs": 12 } }"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert_eq!(cfg.database.max_connections, 9); assert_eq!(cfg.database.connect_timeout_secs, 7); assert_eq!(cfg.database.busy_timeout_secs, 12); @@ -1083,7 +1116,7 @@ mod tests { #[test] fn test_home_resolution() { - let cfg = GlobalConfig { + let cfg = LocalConfig { home: Some(PathBuf::from("/custom/home")), ..Default::default() }; @@ -1092,7 +1125,7 @@ mod tests { #[test] fn test_sandboxes_dir_override() { - let cfg = GlobalConfig { + let cfg = LocalConfig { paths: PathsConfig { sandboxes: Some(PathBuf::from("/custom/sandboxes")), ..Default::default() @@ -1104,8 +1137,8 @@ mod tests { #[test] fn test_load_config_from_missing_file() { - let result = load_config_from(Path::new("/nonexistent/config.json")); - assert!(result.is_none()); + let result = read_config_from(Path::new("/nonexistent/config.json")); + assert!(result.is_err()); } /// Helper to build a `RegistriesConfig` from a list of `(hostname, RegistryEntry)` pairs. @@ -1134,7 +1167,7 @@ mod tests { } }"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); let entry = cfg .registries .hosts @@ -1154,7 +1187,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("config.json"); - let cfg = GlobalConfig { + let cfg = LocalConfig { registries: registries(vec![( "ghcr.io", RegistryEntry { @@ -1226,7 +1259,7 @@ mod tests { std::fs::create_dir_all(&secret_dir).unwrap(); std::fs::write(secret_dir.join("ghcr-token"), "secret-token\n").unwrap(); - let cfg = GlobalConfig { + let cfg = LocalConfig { home: Some(temp.path().to_path_buf()), paths: PathsConfig { secrets: Some(temp.path().to_path_buf()), @@ -1259,7 +1292,7 @@ mod tests { #[test] fn test_resolve_configured_registry_auth_rejects_multiple_sources() { - let cfg = GlobalConfig { + let cfg = LocalConfig { registries: registries(vec![( "ghcr.io", RegistryEntry { @@ -1283,28 +1316,6 @@ mod tests { ); } - #[cfg(not(feature = "keyring"))] - #[test] - fn test_resolve_configured_registry_auth_reports_disabled_keyring() { - let cfg = GlobalConfig { - registries: RegistriesConfig { - auth: HashMap::from([( - "ghcr.io".to_string(), - RegistryAuthEntry { - username: "user".to_string(), - store: Some(RegistryCredentialStore::Keyring), - password_env: None, - secret_name: None, - }, - )]), - }, - ..Default::default() - }; - - let error = cfg.resolve_configured_registry_auth("ghcr.io").unwrap_err(); - assert!(error.to_string().contains("enable the `keyring` feature")); - } - #[test] fn test_resolve_registry_auth_with_lookup_prefers_exact_hostname() { let auth = resolve_registry_auth_with_lookup("ghcr.io", |server| match server { @@ -1380,7 +1391,7 @@ mod tests { } }"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); let entry = cfg.registries.hosts.get("localhost:5050").unwrap(); assert!(entry.insecure); assert!(entry.auth.is_none()); @@ -1394,7 +1405,7 @@ mod tests { } }"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert_eq!( cfg.registries.ca_certs, Some(PathBuf::from("/path/to/ca.pem")) @@ -1418,7 +1429,7 @@ mod tests { } }"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert_eq!( cfg.registries.ca_certs, Some(PathBuf::from("/path/to/ca.pem")) @@ -1433,7 +1444,7 @@ mod tests { #[test] fn test_deserialize_empty_registries() { let json = r#"{"registries": {}}"#; - let cfg: GlobalConfig = serde_json::from_str(json).unwrap(); + let cfg: LocalConfig = serde_json::from_str(json).unwrap(); assert!(cfg.registries.hosts.is_empty()); assert!(cfg.registries.ca_certs.is_none()); } @@ -1445,7 +1456,7 @@ mod tests { let pem_data = b"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"; std::fs::write(&pem_path, pem_data).unwrap(); - let cfg = GlobalConfig { + let cfg = LocalConfig { registries: RegistriesConfig { ca_certs: Some(pem_path), ..Default::default() @@ -1460,7 +1471,7 @@ mod tests { #[tokio::test] async fn test_resolve_ca_certs_missing_file_errors() { - let cfg = GlobalConfig { + let cfg = LocalConfig { registries: RegistriesConfig { ca_certs: Some(PathBuf::from("/nonexistent/ca.pem")), ..Default::default() @@ -1474,14 +1485,14 @@ mod tests { #[tokio::test] async fn test_resolve_ca_certs_none_returns_empty() { - let cfg = GlobalConfig::default(); + let cfg = LocalConfig::default(); let certs = cfg.resolve_ca_certs().await.unwrap(); assert!(certs.is_empty()); } #[test] fn test_insecure_registries() { - let cfg = GlobalConfig { + let cfg = LocalConfig { registries: registries(vec![ ( "localhost:5050", diff --git a/crates/microsandbox/lib/db/mod.rs b/crates/microsandbox/lib/db/mod.rs index 5177be791..4f67302c1 100644 --- a/crates/microsandbox/lib/db/mod.rs +++ b/crates/microsandbox/lib/db/mod.rs @@ -1,269 +1,11 @@ -//! Global database connection pool init and accessor. +//! Database entity + pool type re-exports. //! -//! Opens both pools (read + write) for `~/.microsandbox/db/msb.db` and -//! runs migrations on the writer. Returns [`DbPools`] from -//! `microsandbox-db`; callers pick `pools.read()` (a [`DbReadConnection`]) -//! or `pools.write()` (a [`DbWriteConnection`]) based on the operation. -//! The type system blocks accidental writes against the read pool. -//! -//! [`DbReadConnection`]: microsandbox_db::DbReadConnection -//! [`DbWriteConnection`]: microsandbox_db::DbWriteConnection +//! The actual `DbPools` instance is owned by [`LocalBackend`](crate::backend::LocalBackend) +//! per D6.7. This module just re-exports the entity types and pool aliases so +//! the rest of the crate has one place to import them from. pub use microsandbox_db::entity; - -use std::{path::Path, time::Duration}; - -use microsandbox_db::pool::DbPools; -use microsandbox_migration::{Migrator, MigratorTrait}; -use tokio::sync::OnceCell; - -use crate::{MicrosandboxError, MicrosandboxResult}; - -//-------------------------------------------------------------------------------------------------- -// Statics -//-------------------------------------------------------------------------------------------------- - -static GLOBAL_POOL: OnceCell = OnceCell::const_new(); - -//-------------------------------------------------------------------------------------------------- -// Functions -//-------------------------------------------------------------------------------------------------- - -/// Initialize the global database pools at `~/.microsandbox/db/msb.db`. -/// -/// Migrations are applied automatically. Idempotent — repeat calls -/// return the existing pools. All tuning (max_connections, -/// connect_timeout, busy_timeout) is read from `~/.microsandbox/config.json`. -pub async fn init_global() -> MicrosandboxResult<&'static DbPools> { - GLOBAL_POOL - .get_or_try_init(|| async { - let db_dir = microsandbox_utils::resolve_home().join(microsandbox_utils::DB_SUBDIR); - - connect_and_migrate(&db_dir).await - }) - .await -} - -/// Get the global pools, or `None` if [`init_global`] has not run. -pub fn global() -> Option<&'static DbPools> { - GLOBAL_POOL.get() -} - -//-------------------------------------------------------------------------------------------------- -// Functions: Helpers -//-------------------------------------------------------------------------------------------------- - -/// Open both pools for `db_dir/msb.db` and run migrations on the writer. -/// -/// The write pool connects first so WAL mode (persisted in the database -/// header) is set before the read pool opens. Tuning is read from the -/// global config. -async fn connect_and_migrate(db_dir: &Path) -> MicrosandboxResult { - tokio::fs::create_dir_all(db_dir).await?; - - let database = &crate::config::config().database; - let db_path = db_dir.join(microsandbox_utils::DB_FILENAME); - let pools = DbPools::open( - &db_path, - database.max_connections, - Duration::from_secs(database.connect_timeout_secs), - Duration::from_secs(database.busy_timeout_secs), - ) - .await - .map_err(|e| MicrosandboxError::Custom(format!("connect to {}: {e}", db_path.display())))?; - - Migrator::up(pools.write().inner(), None).await?; - - Ok(pools) -} - -//-------------------------------------------------------------------------------------------------- -// Tests -//-------------------------------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement}; - - use super::*; - - #[tokio::test] - async fn test_connect_and_migrate_creates_db_and_tables() { - let tmp = tempfile::tempdir().unwrap(); - let db_dir = tmp.path().join("db"); - - let pools = connect_and_migrate(&db_dir).await.unwrap(); - let conn = pools.read(); - - // DB file should exist on disk. - assert!(db_dir.join(microsandbox_utils::DB_FILENAME).exists()); - - // All 12 tables should be present. - let rows = conn - .query_all(Statement::from_string( - sea_orm::DatabaseBackend::Sqlite, - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'seaql_%' AND name != 'sqlite_sequence' ORDER BY name", - )) - .await - .unwrap(); - - let table_names: Vec = rows - .iter() - .map(|r| r.try_get_by_index::(0).unwrap()) - .collect(); - - let expected = vec![ - "config", - "image_ref", - "layer", - "manifest", - "manifest_layer", - "run", - "sandbox", - "sandbox_metric", - "sandbox_rootfs", - "snapshot_index", - "volume", - ]; - - assert_eq!(table_names, expected); - } - - #[tokio::test] - async fn test_connect_and_migrate_is_idempotent() { - let tmp = tempfile::tempdir().unwrap(); - let db_dir = tmp.path().join("db"); - - let pools = connect_and_migrate(&db_dir).await.unwrap(); - - // Running migrations again on the same DB should succeed. - Migrator::up(pools.write().inner(), None).await.unwrap(); - } - - #[tokio::test] - async fn test_connect_and_migrate_recovers_from_partial_storage_migration() { - let tmp = tempfile::tempdir().unwrap(); - let db_dir = tmp.path().join("db"); - tokio::fs::create_dir_all(&db_dir).await.unwrap(); - - let db_path = db_dir.join(microsandbox_utils::DB_FILENAME); - let db_url = format!("sqlite://{}?mode=rwc", db_path.display()); - - let conn = Database::connect(&db_url).await.unwrap(); - - conn.execute(Statement::from_string( - DatabaseBackend::Sqlite, - "PRAGMA foreign_keys = ON;", - )) - .await - .unwrap(); - - // Apply only migrations 1 and 2 so migration 3 is still pending. - Migrator::up(&conn, Some(2)).await.unwrap(); - - // Simulate a half-applied migration 3: the storage tables and the first - // snapshot index exist, but migration 3 itself was never recorded. - conn.execute(Statement::from_string( - DatabaseBackend::Sqlite, - "CREATE TABLE IF NOT EXISTS volume ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - quota_mib INTEGER, - size_bytes BIGINT, - labels TEXT, - created_at DATETIME, - updated_at DATETIME - )", - )) - .await - .unwrap(); - - conn.execute(Statement::from_string( - DatabaseBackend::Sqlite, - "CREATE TABLE IF NOT EXISTS snapshot ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - sandbox_id INTEGER, - size_bytes BIGINT, - description TEXT, - created_at DATETIME, - FOREIGN KEY (sandbox_id) REFERENCES sandbox(id) ON DELETE SET NULL - )", - )) - .await - .unwrap(); - - conn.execute(Statement::from_string( - DatabaseBackend::Sqlite, - "CREATE UNIQUE INDEX idx_snapshots_name_sandbox_unique ON snapshot (name, sandbox_id)", - )) - .await - .unwrap(); - - let pending_before = conn - .query_one(Statement::from_string( - DatabaseBackend::Sqlite, - "SELECT COUNT(*) FROM seaql_migrations WHERE version = 'm20260305_000003_create_storage_tables'", - )) - .await - .unwrap() - .unwrap() - .try_get_by_index::(0) - .unwrap(); - assert_eq!(pending_before, 0); - - drop(conn); - - let recovered = connect_and_migrate(&db_dir).await.unwrap(); - - let migration_row_count = recovered - .read() - .query_one(Statement::from_string( - DatabaseBackend::Sqlite, - "SELECT COUNT(*) FROM seaql_migrations WHERE version = 'm20260305_000003_create_storage_tables'", - )) - .await - .unwrap() - .unwrap() - .try_get_by_index::(0) - .unwrap(); - assert_eq!(migration_row_count, 1); - - let legacy_index_count = recovered - .read() - .query_one(Statement::from_string( - DatabaseBackend::Sqlite, - "SELECT COUNT(*) FROM sqlite_master - WHERE type = 'index' - AND name IN ( - 'idx_snapshots_name_sandbox_unique', - 'idx_snapshots_name_unique_no_sandbox' - )", - )) - .await - .unwrap() - .unwrap() - .try_get_by_index::(0) - .unwrap(); - assert_eq!(legacy_index_count, 0); - - let new_index_count = recovered - .read() - .query_one(Statement::from_string( - DatabaseBackend::Sqlite, - "SELECT COUNT(*) FROM sqlite_master - WHERE type = 'index' - AND name IN ( - 'idx_snapshot_index_name', - 'idx_snapshot_index_parent', - 'idx_snapshot_index_image' - )", - )) - .await - .unwrap() - .unwrap() - .try_get_by_index::(0) - .unwrap(); - assert_eq!(new_index_count, 3); - } -} +#[allow(unused_imports)] +pub use microsandbox_db::pool::DbPools; +#[allow(unused_imports)] +pub use microsandbox_db::{DbReadConnection, DbWriteConnection}; diff --git a/crates/microsandbox/lib/error.rs b/crates/microsandbox/lib/error.rs index 58e89b703..3cce8e46b 100644 --- a/crates/microsandbox/lib/error.rs +++ b/crates/microsandbox/lib/error.rs @@ -18,6 +18,17 @@ pub enum MicrosandboxError { #[error("http error: {0}")] Http(#[from] reqwest::Error), + /// A cloud control-plane request failed with an HTTP status. + #[error("cloud HTTP {status}: {message}")] + CloudHttp { + /// HTTP status code returned by msb-cloud. + status: u16, + /// Machine-readable msb-cloud error code, when present. + code: Option, + /// Human-readable msb-cloud error message. + message: String, + }, + /// The libkrunfw library was not found at the expected location. #[error("libkrunfw not found: {0}")] LibkrunfwNotFound(String), @@ -150,6 +161,16 @@ pub enum MicrosandboxError { #[error("metrics disabled for sandbox: {0}")] MetricsDisabled(String), + /// A backend does not support a requested SDK feature yet. + #[error("{feature} is not supported by this backend yet; available {available_when}")] + Unsupported { + /// Feature requested by the caller. + feature: String, + /// Human-readable note describing what unlocks support (e.g. "when cloud + /// volumes ship", "when the rlimits API lands on the cloud"). + available_when: String, + }, + /// A custom error message. #[error("{0}")] Custom(String), diff --git a/crates/microsandbox/lib/image/mod.rs b/crates/microsandbox/lib/image/mod.rs index 95fb3835a..caaa8fb38 100644 --- a/crates/microsandbox/lib/image/mod.rs +++ b/crates/microsandbox/lib/image/mod.rs @@ -16,13 +16,11 @@ use microsandbox_image::{ use crate::{ MicrosandboxError, MicrosandboxResult, - db::{ - self, - entity::{ - config as config_entity, image_ref as image_ref_entity, layer as layer_entity, - manifest as manifest_entity, manifest_layer as manifest_layer_entity, - sandbox_rootfs as sandbox_rootfs_entity, - }, + backend::LocalBackend, + db::entity::{ + config as config_entity, image_ref as image_ref_entity, layer as layer_entity, + manifest as manifest_entity, manifest_layer as manifest_layer_entity, + sandbox_rootfs as sandbox_rootfs_entity, }, }; @@ -162,10 +160,11 @@ impl Image { /// This avoids ~25–30 redundant write statements per cached create /// and keeps SQLite's single-writer lock free for other work. pub async fn persist( + local: &LocalBackend, reference: &str, metadata: CachedImageMetadata, ) -> MicrosandboxResult { - let pools = db::init_global().await?; + let pools = local.db().await?; let db = pools.write(); let reference = reference.to_string(); @@ -234,8 +233,8 @@ impl Image { } /// Get an image handle by reference. - pub async fn get(reference: &str) -> MicrosandboxResult { - let db = db::init_global().await?.read(); + pub async fn get(local: &LocalBackend, reference: &str) -> MicrosandboxResult { + let db = local.db().await?.read(); let (image_ref_model, manifest) = image_ref_entity::Entity::find() .filter(image_ref_entity::Column::Reference.eq(reference)) @@ -252,8 +251,8 @@ impl Image { } /// List all cached images, ordered by creation time (newest first). - pub async fn list() -> MicrosandboxResult> { - let db = db::init_global().await?.read(); + pub async fn list(local: &LocalBackend) -> MicrosandboxResult> { + let db = local.db().await?.read(); let models = image_ref_entity::Entity::find() .order_by_desc(image_ref_entity::Column::CreatedAt) @@ -269,8 +268,8 @@ impl Image { } /// Get full detail for an image (config + layers). - pub async fn inspect(reference: &str) -> MicrosandboxResult { - let db = db::init_global().await?.read(); + pub async fn inspect(local: &LocalBackend, reference: &str) -> MicrosandboxResult { + let db = local.db().await?.read(); let image_ref_model = image_ref_entity::Entity::find() .filter(image_ref_entity::Column::Reference.eq(reference)) @@ -363,8 +362,12 @@ impl Image { /// /// If `force` is false and the image is referenced by any sandbox, returns /// [`MicrosandboxError::ImageInUse`]. - pub async fn remove(reference: &str, force: bool) -> MicrosandboxResult<()> { - let pools = db::init_global().await?; + pub async fn remove( + local: &LocalBackend, + reference: &str, + force: bool, + ) -> MicrosandboxResult<()> { + let pools = local.db().await?; let db = pools.write(); let image_ref_model = image_ref_entity::Entity::find() @@ -455,7 +458,7 @@ impl Image { .await?; // Best-effort on-disk cleanup (outside transaction). - let cache_dir = crate::config::config().cache_dir(); + let cache_dir = local.cache_dir(); if let Ok(cache) = GlobalCache::new(&cache_dir) { for diff_id_str in &layer_diff_ids { if let Ok(diff_id) = diff_id_str.parse::() { @@ -485,8 +488,8 @@ impl Image { /// referenced by any manifest. /// /// Returns the number of layers removed. - pub async fn gc_layers() -> MicrosandboxResult { - let pools = db::init_global().await?; + pub async fn gc_layers(local: &LocalBackend) -> MicrosandboxResult { + let pools = local.db().await?; // Find layers with zero manifest_layer references. let orphans: Vec = layer_entity::Entity::find() @@ -495,7 +498,7 @@ impl Image { .all(pools.read()) .await?; - let cache_dir = crate::config::config().cache_dir(); + let cache_dir = local.cache_dir(); let cache = GlobalCache::new(&cache_dir).ok(); let mut removed = 0u32; @@ -518,8 +521,8 @@ impl Image { } /// Run full garbage collection: orphaned layers. - pub async fn gc() -> MicrosandboxResult { - Self::gc_layers().await + pub async fn gc(local: &LocalBackend) -> MicrosandboxResult { + Self::gc_layers(local).await } } diff --git a/crates/microsandbox/lib/lib.rs b/crates/microsandbox/lib/lib.rs index 24b925800..718e431aa 100644 --- a/crates/microsandbox/lib/lib.rs +++ b/crates/microsandbox/lib/lib.rs @@ -10,6 +10,7 @@ mod error; //-------------------------------------------------------------------------------------------------- pub mod agent; +pub mod backend; pub mod config; #[allow(dead_code)] pub(crate) mod db; @@ -20,6 +21,17 @@ pub mod setup; pub mod snapshot; pub mod volume; +pub use backend::{ + Backend, BackendKind, CloudBackend, CloudBackendBuilder, CloudCreateSandboxRequest, + CloudErrorBody, CloudMessageResponse, CloudPaginated, CloudSandbox, CloudSandboxStatus, + LocalBackend, LocalBackendBuilder, Profile, ProfileBackend, SandboxBackend, SandboxCloudState, + SandboxHandleCloudState, SandboxHandleInner, SandboxHandleLocalState, SandboxInner, + SandboxList, SandboxLocalState, SdkConfig, VolumeBackend, VolumeCloudState, + VolumeHandleCloudState, VolumeHandleInner, VolumeHandleLocalState, VolumeInner, + VolumeLocalState, default_backend, load_sdk_config, resolve_default_backend, + set_default_backend, with_backend, +}; +pub use config::set_sdk_libkrunfw_path as set_libkrunfw_path; pub use error::*; pub use image::{Image, ImageConfigDetail, ImageDetail, ImageHandle, ImageLayerDetail}; pub use microsandbox_image::RegistryAuth; @@ -33,4 +45,4 @@ pub use snapshot::{ Snapshot, SnapshotBuilder, SnapshotConfig, SnapshotDestination, SnapshotFormat, SnapshotHandle, SnapshotVerifyReport, UpperIntegrity, UpperVerifyStatus, }; -pub use volume::Volume; +pub use volume::{Volume, VolumeHandle}; diff --git a/crates/microsandbox/lib/runtime/spawn.rs b/crates/microsandbox/lib/runtime/spawn.rs index b27b664c3..d17714071 100644 --- a/crates/microsandbox/lib/runtime/spawn.rs +++ b/crates/microsandbox/lib/runtime/spawn.rs @@ -29,7 +29,9 @@ use microsandbox_protocol::{ use microsandbox_utils::{DB_FILENAME, DB_SUBDIR}; use crate::{ - MicrosandboxResult, config, + MicrosandboxResult, + backend::LocalBackend, + config, runtime::handle::ProcessHandle, sandbox::{DiskImageFormat, Rlimit, RootfsSource, SandboxConfig, VolumeMount}, }; @@ -69,17 +71,18 @@ pub enum SpawnMode { /// 4. Spawns the hidden `msb sandbox` process with `--agent-sock` for the relay /// 5. Reads startup JSON from stdout to get child PIDs pub async fn spawn_sandbox( + local: &LocalBackend, config: &SandboxConfig, sandbox_id: i32, mode: SpawnMode, ) -> MicrosandboxResult<(ProcessHandle, PathBuf)> { - // Resolve paths. Per-sandbox `libkrunfw_path` takes precedence over the - // global resolver so SDK callers can point at a custom firmware bundle. - let msb_path = config::resolve_msb_path()?; - let libkrunfw_path = match &config.libkrunfw_path { - Some(path) => path.clone(), - None => config::resolve_libkrunfw_path()?, - }; + // libkrunfw is process-level (one dylib per process address space). The + // resolver consults MSB_LIBKRUNFW_PATH env, then SDK_LIBKRUNFW_PATH static, + // then config.paths.libkrunfw, then filesystem fallbacks — see + // `config::resolve_libkrunfw_path` for the full precedence ladder. + let global = local.config(); + let msb_path = config::resolve_msb_path(global)?; + let libkrunfw_path = config::resolve_libkrunfw_path(global)?; tracing::debug!( msb = %msb_path.display(), libkrunfw = %libkrunfw_path.display(), @@ -90,7 +93,6 @@ pub async fn spawn_sandbox( "spawn_sandbox: resolved paths" ); - let global = config::config(); let sandbox_dir = global.sandboxes_dir().join(&config.name); let log_dir = sandbox_dir.join("logs"); let runtime_dir = sandbox_dir.join("runtime"); @@ -127,6 +129,7 @@ pub async fn spawn_sandbox( // Build the command. let mut cmd = Command::new(&msb_path); cmd.args(sandbox_cli_args( + local, config, sandbox_id, &db_path, @@ -488,6 +491,7 @@ fn guest_mount_tag(guest_path: &str) -> String { /// Build the `msb sandbox` CLI args for a sandbox. #[allow(clippy::too_many_arguments)] fn sandbox_cli_args( + local: &LocalBackend, config: &SandboxConfig, sandbox_id: i32, db_path: &Path, @@ -551,12 +555,12 @@ fn sandbox_cli_args( RootfsSource::Oci(_) => { // Derive VMDK + upper paths from the stored manifest digest. if let Some(ref digest_str) = config.manifest_digest { - let cache_dir = config::config().cache_dir(); + let cache_dir = local.cache_dir(); let cache = GlobalCache::new(&cache_dir).expect("cache init"); let digest: Digest = digest_str.parse().expect("invalid manifest digest"); let vmdk_path = cache.vmdk_path(&digest); - let sandbox_dir = config::config().sandboxes_dir().join(&config.name); + let sandbox_dir = local.sandboxes_dir().join(&config.name); let upper_path = sandbox_dir.join("upper.ext4"); // VMDK (fsmeta + layers) as read-only block device. @@ -625,7 +629,7 @@ fn sandbox_cli_args( guest, readonly, } => { - let vol_path = config::config().volumes_dir().join(name); + let vol_path = local.volume_path(name); push_dir_mount_arg(&mut args, guest, &vol_path.display(), *readonly); push_dir_mounts_spec(&mut dir_mounts_val, guest, *readonly); } @@ -783,6 +787,7 @@ mod tests { use super::sandbox_cli_args; use crate::{ LogLevel, + backend::LocalBackend, sandbox::{ DiskImageFormat, Rlimit, RlimitResource, RootfsSource, SandboxBuilder, SandboxConfig, }, @@ -792,8 +797,17 @@ mod tests { // Functions: Helpers //---------------------------------------------------------------------------------------------- + /// Build a `LocalBackend` for tests. Uses `lazy()` since these tests only + /// exercise the pure-rendering `sandbox_cli_args` path — no DB / FS + /// touches. + fn test_local_backend() -> LocalBackend { + LocalBackend::lazy() + } + fn render_args(config: &SandboxConfig) -> Vec { + let local = test_local_backend(); sandbox_cli_args( + &local, config, 42, Path::new("/tmp/msb.db"), @@ -813,7 +827,9 @@ mod tests { config: &SandboxConfig, staged_file_mounts: &HashMap, ) -> Vec { + let local = test_local_backend(); sandbox_cli_args( + &local, config, 42, Path::new("/tmp/msb.db"), @@ -887,7 +903,9 @@ mod tests { .await .unwrap(); + let local = test_local_backend(); let args = sandbox_cli_args( + &local, &config, 42, Path::new("/tmp/msb.db"), diff --git a/crates/microsandbox/lib/sandbox/attach.rs b/crates/microsandbox/lib/sandbox/attach.rs index a1ced797c..101867f67 100644 --- a/crates/microsandbox/lib/sandbox/attach.rs +++ b/crates/microsandbox/lib/sandbox/attach.rs @@ -210,6 +210,220 @@ impl DetachKeys { } } +//-------------------------------------------------------------------------------------------------- +// Module: local (free fn impls called by LocalBackend's SandboxBackend impl) +//-------------------------------------------------------------------------------------------------- + +pub(crate) mod local { + //! Local attach impl: bridges the host TTY to a PTY exec session in the + //! named sandbox. Owns the host terminal's raw mode for the duration. + + use std::os::fd::AsRawFd; + use std::sync::Arc; + + use microsandbox_protocol::{ + exec::{ExecExited, ExecResize, ExecStdin, ExecStdout}, + message::{Message, MessageType}, + }; + use tokio::io::{AsyncWriteExt, unix::AsyncFd}; + + use crate::{ + MicrosandboxResult, + backend::LocalBackend, + sandbox::{ + AttachOptionsBuilder, SandboxConfig, build_exec_request, + open_nonblocking_terminal_input, read_from_fd, terminal_path_for_fd, + }, + }; + + use super::DetachKeys; + + pub(crate) async fn attach( + local: &LocalBackend, + name: &str, + config: &SandboxConfig, + cmd: String, + opts_builder: AttachOptionsBuilder, + ) -> MicrosandboxResult { + let opts = opts_builder.build()?; + + let client = Arc::new(super::super::fs::local::connect_agent(local, name).await?); + + let detach_keys = match &opts.detach_keys { + Some(spec) => DetachKeys::parse(spec)?, + None => DetachKeys::default_keys(), + }; + + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + + let id = client.next_id(); + let mut rx = client.subscribe(id).await; + + let req = build_exec_request( + config, + cmd, + opts.args, + opts.cwd, + opts.user, + &opts.env, + &opts.rlimits, + true, + rows, + cols, + ); + let msg = Message::with_payload(MessageType::ExecRequest, id, &req)?; + client.send(&msg).await?; + + crossterm::terminal::enable_raw_mode() + .map_err(|e| crate::MicrosandboxError::Terminal(e.to_string()))?; + let _raw_guard = scopeguard::guard((), |_| { + let _ = crossterm::terminal::disable_raw_mode(); + }); + + let tty_input_path = terminal_path_for_fd(std::io::stdin().as_raw_fd()) + .map_err(|e| crate::MicrosandboxError::Terminal(format!("resolve tty path: {e}")))?; + let tty_input = open_nonblocking_terminal_input(&tty_input_path) + .map_err(|e| crate::MicrosandboxError::Terminal(format!("open tty input: {e}")))?; + let stdin_async = AsyncFd::new(tty_input) + .map_err(|e| crate::MicrosandboxError::Terminal(format!("async tty input: {e}")))?; + + let mut stdout = tokio::io::stdout(); + let mut sigwinch = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()) + .map_err(|e| crate::MicrosandboxError::Runtime(format!("sigwinch: {e}")))?; + + let mut exit_code: i32 = -1; + let mut spawn_failure: Option = None; + let detach_seq = detach_keys.sequence(); + let mut match_pos = 0usize; + + loop { + tokio::select! { + result = stdin_async.readable() => { + let mut guard = match result { + Ok(g) => g, + Err(_) => break, + }; + + let mut input_buf = [0u8; 1024]; + match guard.try_io(|inner| { + read_from_fd(inner.get_ref().as_raw_fd(), &mut input_buf) + }) { + Ok(Ok(0)) => break, + Ok(Ok(n)) => { + let data = &input_buf[..n]; + + let mut detached = false; + for &b in data { + if b == detach_seq[match_pos] { + match_pos += 1; + if match_pos == detach_seq.len() { + detached = true; + break; + } + } else { + match_pos = 0; + if b == detach_seq[0] { + match_pos = 1; + } + } + } + + if detached { + break; + } + + let payload = ExecStdin { data: data.to_vec() }; + if let Ok(msg) = Message::with_payload(MessageType::ExecStdin, id, &payload) { + let _ = client.send(&msg).await; + } + } + Ok(Err(e)) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Ok(Err(_)) => break, + Err(_would_block) => continue, + } + } + + Some(msg) = rx.recv() => { + let mut should_break = false; + + match msg.t { + MessageType::ExecStdout => { + if let Ok(out) = msg.payload::() { + let _ = stdout.write_all(&out.data).await; + } + } + MessageType::ExecExited => { + if let Ok(exited) = msg.payload::() { + exit_code = exited.code; + } + should_break = true; + } + MessageType::ExecFailed => { + if let Ok(failed) = + msg.payload::() + { + spawn_failure = Some(failed); + } + should_break = true; + } + _ => {} + } + + if !should_break { + while let Ok(next) = rx.try_recv() { + match next.t { + MessageType::ExecStdout => { + if let Ok(out) = next.payload::() { + let _ = stdout.write_all(&out.data).await; + } + } + MessageType::ExecExited => { + if let Ok(exited) = next.payload::() { + exit_code = exited.code; + } + should_break = true; + break; + } + MessageType::ExecFailed => { + if let Ok(failed) = next + .payload::() + { + spawn_failure = Some(failed); + } + should_break = true; + break; + } + _ => {} + } + } + } + + let _ = stdout.flush().await; + + if should_break { + break; + } + } + + _ = sigwinch.recv() => { + if let Ok((new_cols, new_rows)) = crossterm::terminal::size() { + let payload = ExecResize { rows: new_rows, cols: new_cols }; + if let Ok(msg) = Message::with_payload(MessageType::ExecResize, id, &payload) { + let _ = client.send(&msg).await; + } + } + } + } + } + + if let Some(failure) = spawn_failure { + return Err(crate::MicrosandboxError::ExecFailed(failure)); + } + Ok(exit_code) + } +} + //-------------------------------------------------------------------------------------------------- // Tests //-------------------------------------------------------------------------------------------------- diff --git a/crates/microsandbox/lib/sandbox/builder.rs b/crates/microsandbox/lib/sandbox/builder.rs index 61e925b22..f5a4a6e98 100644 --- a/crates/microsandbox/lib/sandbox/builder.rs +++ b/crates/microsandbox/lib/sandbox/builder.rs @@ -321,19 +321,6 @@ impl SandboxBuilder { self } - /// Override the libkrunfw shared library for this sandbox. - /// - /// By default, microsandbox resolves libkrunfw via the global config or - /// finds it next to the `msb` binary. Use this to point at a specific - /// libkrunfw build — for example, an unreleased firmware during - /// development. - /// - /// The path is validated at [`build`](Self::build) time. - pub fn libkrunfw_path(mut self, path: impl Into) -> Self { - self.config.libkrunfw_path = Some(path.into()); - self - } - /// Set the user identity inside the sandbox (e.g., `"1000"`, `"appuser"`, `"1000:1000"`). pub fn user(mut self, user: impl Into) -> Self { self.config.user = Some(user.into()); @@ -735,12 +722,25 @@ impl SandboxBuilder { let (handle, sender) = microsandbox_image::progress_channel(); let task = tokio::spawn(async move { let config = self.build().await?; - super::Sandbox::create_with_mode( - config, - crate::runtime::SpawnMode::Attached, - Some(sender), - ) - .await + let backend = crate::backend::default_backend(); + match backend.kind() { + crate::backend::BackendKind::Local => { + crate::sandbox::create_local( + backend, + config, + crate::runtime::SpawnMode::Attached, + Some(sender), + ) + .await + } + crate::backend::BackendKind::Cloud => { + drop(sender); + backend + .sandboxes() + .create(backend.clone(), config, true) + .await + } + } }); Ok((handle, task)) } @@ -756,12 +756,25 @@ impl SandboxBuilder { let (handle, sender) = microsandbox_image::progress_channel(); let task = tokio::spawn(async move { let config = self.build().await?; - super::Sandbox::create_with_mode( - config, - crate::runtime::SpawnMode::Detached, - Some(sender), - ) - .await + let backend = crate::backend::default_backend(); + match backend.kind() { + crate::backend::BackendKind::Local => { + crate::sandbox::create_local( + backend, + config, + crate::runtime::SpawnMode::Detached, + Some(sender), + ) + .await + } + crate::backend::BackendKind::Cloud => { + drop(sender); + backend + .sandboxes() + .create_detached(backend.clone(), config) + .await + } + } }); Ok((handle, task)) } @@ -774,11 +787,7 @@ impl SandboxBuilder { return Err(err); } - if self.config.name.is_empty() { - return Err(crate::MicrosandboxError::InvalidConfig( - "sandbox name is required".into(), - )); - } + validate_sandbox_name(&self.config.name)?; // Check that image is set (non-empty OCI string or Bind path). match &self.config.image { @@ -795,15 +804,6 @@ impl SandboxBuilder { _ => {} } - if let Some(path) = &self.config.libkrunfw_path - && !path.is_file() - { - return Err(crate::MicrosandboxError::InvalidConfig(format!( - "libkrunfw_path does not exist: {}", - path.display() - ))); - } - for rlimit in &self.config.rlimits { if rlimit.soft > rlimit.hard { return Err(crate::MicrosandboxError::InvalidConfig(format!( @@ -850,6 +850,48 @@ impl SandboxBuilder { } } +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Maximum length of a sandbox name. +/// Must stay in sync with msb-cloud's `MAX_SANDBOX_NAME_LEN` in `msb-api/src/validators/sandbox.rs`. +const MAX_SANDBOX_NAME_LEN: usize = 128; + +/// Validate that a sandbox name is safe: alphanumeric / dot / hyphen / underscore, +/// 1..=128 chars, must start alphanumeric. +/// +/// **Must stay in sync with msb-cloud's `validate_sandbox_name`.** The rule lives +/// in two places; if it changes here, change it there too. +pub fn validate_sandbox_name(name: &str) -> MicrosandboxResult<()> { + if name.is_empty() { + return Err(crate::MicrosandboxError::InvalidConfig( + "sandbox name must not be empty".into(), + )); + } + if name.len() > MAX_SANDBOX_NAME_LEN { + return Err(crate::MicrosandboxError::InvalidConfig(format!( + "sandbox name must be at most {MAX_SANDBOX_NAME_LEN} characters: got {}", + name.len() + ))); + } + let first_alphanumeric = name + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()); + let charset_ok = name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_'); + + if !first_alphanumeric || !charset_ok { + return Err(crate::MicrosandboxError::InvalidConfig(format!( + "sandbox name must start with an alphanumeric and contain only \ + alphanumeric, dots, hyphens, and underscores: {name}" + ))); + } + Ok(()) +} + //-------------------------------------------------------------------------------------------------- // Trait Implementations //-------------------------------------------------------------------------------------------------- @@ -1169,4 +1211,80 @@ mod tests { .contains("disk image host path does not exist") ); } + + //---------------------------------------------------------------------------------------------- + // Sandbox name validation + //---------------------------------------------------------------------------------------------- + + use super::{MAX_SANDBOX_NAME_LEN, validate_sandbox_name}; + + #[test] + fn sandbox_name_accepts_typical() { + for name in [ + "foo", + "foo-bar", + "foo.bar", + "foo_bar", + "FooBar", + "abc123", + "a", + "0", + "agent-1", + "my.app_2026", + ] { + assert!( + validate_sandbox_name(name).is_ok(), + "expected {name:?} to be accepted" + ); + } + } + + #[test] + fn sandbox_name_rejects_empty() { + assert!(validate_sandbox_name("").is_err()); + } + + #[test] + fn sandbox_name_rejects_too_long() { + let long = "a".repeat(MAX_SANDBOX_NAME_LEN + 1); + assert!(validate_sandbox_name(&long).is_err()); + } + + #[test] + fn sandbox_name_accepts_at_max_length() { + let max = "a".repeat(MAX_SANDBOX_NAME_LEN); + assert!(validate_sandbox_name(&max).is_ok()); + } + + #[test] + fn sandbox_name_rejects_disallowed_chars() { + for name in [ + "foo bar", "foo/bar", "foo:bar", "foo!", "foo@bar", "foo#1", "✨", + ] { + assert!( + validate_sandbox_name(name).is_err(), + "expected {name:?} to be rejected" + ); + } + } + + #[test] + fn sandbox_name_rejects_non_alphanumeric_start() { + for name in [".foo", "-foo", "_foo"] { + assert!( + validate_sandbox_name(name).is_err(), + "expected {name:?} to be rejected (non-alphanumeric start)" + ); + } + } + + #[tokio::test] + async fn builder_validate_rejects_bad_name() { + let err = SandboxBuilder::new("bad name!") + .image("alpine") + .build() + .await + .unwrap_err(); + assert!(err.to_string().contains("alphanumeric"), "got: {err}"); + } } diff --git a/crates/microsandbox/lib/sandbox/config.rs b/crates/microsandbox/lib/sandbox/config.rs index b05951cb2..5e63728ed 100644 --- a/crates/microsandbox/lib/sandbox/config.rs +++ b/crates/microsandbox/lib/sandbox/config.rs @@ -23,28 +23,32 @@ const DEFAULT_OCI_TMPFS_PATH: &str = "/tmp"; const DEFAULT_OCI_TMPFS_MAX_SIZE_MIB: u32 = 512; const DEFAULT_OCI_TMPFS_MEMORY_DIVISOR: u32 = 4; +// Compile-time defaults for `SandboxConfig` serde. Serde's `#[serde(default +// = "fn")]` attribute can't take parameters, so these can't consult a +// `LocalBackend`. They intentionally mirror `LocalConfig::default()` / +// `SandboxDefaults::default()` for the same fields, so DB-row +// deserialization (and `sandbox_config_from_cloud`) are side-effect-free. +// A `LocalBackend` with non-default sandbox defaults applies them through +// `SandboxBuilder` at create time, not via serde. + fn default_cpus() -> u8 { - crate::config::config().sandbox_defaults.cpus + crate::config::DEFAULT_CPUS } fn default_memory_mib() -> u32 { - crate::config::config().sandbox_defaults.memory_mib + crate::config::DEFAULT_MEMORY_MIB } fn default_log_level() -> Option { - crate::config::config().log_level + None } fn default_metrics_sample_interval_ms() -> Option> { - crate::config::config() - .sandbox_defaults - .metrics_sample_interval_ms + crate::config::default_metrics_sample_interval() } fn default_disable_metrics_sample() -> bool { - crate::config::config() - .sandbox_defaults - .disable_metrics_sample + false } //-------------------------------------------------------------------------------------------------- @@ -173,15 +177,6 @@ pub struct SandboxConfig { #[serde(default, skip_serializing)] pub registry_auth: Option, - /// Override the libkrunfw shared library path for this sandbox. - /// - /// When `None`, resolution falls back to the global config path, a sibling - /// of the `msb` binary, or `~/.microsandbox/lib/` (in that order). - /// - /// Not persisted — libkrunfw is a host-side resource, not sandbox state. - #[serde(skip)] - pub libkrunfw_path: Option, - /// Access the registry over plain HTTP (SDK override). #[serde(skip)] pub(crate) insecure: bool, @@ -384,7 +379,6 @@ impl Default for SandboxConfig { pull_policy: PullPolicy::default(), policy: SandboxPolicy::default(), registry_auth: None, - libkrunfw_path: None, insecure: false, ca_certs: Vec::new(), replace_existing: false, @@ -401,8 +395,6 @@ impl Default for SandboxConfig { #[cfg(test)] mod tests { - use std::collections::HashMap; - use microsandbox_image::ImageConfig; use crate::sandbox::{RootfsSource, VolumeMount}; diff --git a/crates/microsandbox/lib/sandbox/exec.rs b/crates/microsandbox/lib/sandbox/exec.rs index c888f97da..444cced07 100644 --- a/crates/microsandbox/lib/sandbox/exec.rs +++ b/crates/microsandbox/lib/sandbox/exec.rs @@ -438,3 +438,170 @@ impl ExecSink { self.client.send(&msg).await } } + +//-------------------------------------------------------------------------------------------------- +// Module: local (free fn impls called by LocalBackend's SandboxBackend impl) +//-------------------------------------------------------------------------------------------------- + +pub(crate) mod local { + //! Local exec dispatch keyed by `(sandbox_name, cmd, opts)`. + //! + //! Opens a fresh agent UDS each call (option A in the parity plan). + + use std::sync::Arc; + + use bytes::Bytes; + use microsandbox_protocol::{ + exec::{ExecExited, ExecStarted, ExecStderr, ExecStdin, ExecStdout}, + message::{Message, MessageType}, + }; + use tokio::sync::mpsc; + + use crate::{ + MicrosandboxError, MicrosandboxResult, + backend::LocalBackend, + sandbox::{SandboxConfig, build_exec_request}, + }; + + use super::{ExecEvent, ExecHandle, ExecOptions, ExecOutput, ExecSink, ExitStatus, StdinMode}; + + pub(crate) async fn exec_stream( + local: &LocalBackend, + name: &str, + config: &SandboxConfig, + cmd: String, + opts: ExecOptions, + ) -> MicrosandboxResult { + let client = Arc::new(super::super::fs::local::connect_agent(local, name).await?); + let ExecOptions { + args, + cwd, + user, + env, + rlimits, + tty, + stdin: stdin_mode, + timeout: _, + } = opts; + + tracing::debug!( + sandbox = %name, + cmd = %cmd, + args = ?args, + cwd = ?cwd, + tty, + "exec_stream" + ); + + let id = client.next_id(); + let rx = client.subscribe(id).await; + + let req = build_exec_request(config, cmd, args, cwd, user, &env, &rlimits, tty, 24, 80); + let msg = Message::with_payload(MessageType::ExecRequest, id, &req)?; + client.send(&msg).await?; + + let stdin = match &stdin_mode { + StdinMode::Pipe => Some(ExecSink::new(id, Arc::clone(&client))), + _ => None, + }; + + if let StdinMode::Bytes(ref data) = stdin_mode { + let data = data.clone(); + let bridge = Arc::clone(&client); + tokio::spawn(async move { + let payload = ExecStdin { data }; + if let Ok(msg) = Message::with_payload(MessageType::ExecStdin, id, &payload) { + let _ = bridge.send(&msg).await; + } + let close = ExecStdin { data: Vec::new() }; + if let Ok(msg) = Message::with_payload(MessageType::ExecStdin, id, &close) { + let _ = bridge.send(&msg).await; + } + }); + } + + let (event_tx, event_rx) = mpsc::unbounded_channel(); + tokio::spawn(event_mapper_task(rx, event_tx)); + + Ok(ExecHandle::new(id, event_rx, stdin, client)) + } + + pub(crate) async fn exec( + local: &LocalBackend, + name: &str, + config: &SandboxConfig, + cmd: String, + opts: ExecOptions, + ) -> MicrosandboxResult { + let timeout_duration = opts.timeout; + let mut handle = exec_stream(local, name, config, cmd, opts).await?; + + match timeout_duration { + Some(duration) => match tokio::time::timeout(duration, handle.collect()).await { + Ok(result) => result, + Err(_) => { + let _ = handle.kill().await; + let _ = + tokio::time::timeout(std::time::Duration::from_secs(5), handle.collect()) + .await; + Err(MicrosandboxError::ExecTimeout(duration)) + } + }, + None => handle.collect().await, + } + } + + /// Background task that converts raw protocol messages into [`ExecEvent`]s. + async fn event_mapper_task( + mut rx: mpsc::UnboundedReceiver, + tx: mpsc::UnboundedSender, + ) { + while let Some(msg) = rx.recv().await { + let event = match msg.t { + MessageType::ExecStarted => match msg.payload::() { + Ok(started) => ExecEvent::Started { pid: started.pid }, + Err(_) => continue, + }, + MessageType::ExecStdout => match msg.payload::() { + Ok(out) => ExecEvent::Stdout(Bytes::from(out.data)), + Err(_) => continue, + }, + MessageType::ExecStderr => match msg.payload::() { + Ok(err) => ExecEvent::Stderr(Bytes::from(err.data)), + Err(_) => continue, + }, + MessageType::ExecExited => { + if let Ok(exited) = msg.payload::() { + let _ = tx.send(ExecEvent::Exited { code: exited.code }); + } + break; + } + MessageType::ExecFailed => { + if let Ok(failed) = msg.payload::() { + let _ = tx.send(ExecEvent::Failed(failed)); + } + break; + } + MessageType::ExecStdinError => { + match msg.payload::() { + Ok(payload) => ExecEvent::StdinError(payload), + Err(_) => continue, + } + } + _ => continue, + }; + if tx.send(event).is_err() { + break; + } + } + } + + // Re-export so backend trait impl can also use ExitStatus for typing. + #[allow(dead_code)] + pub(crate) fn _exit_status(code: i32) -> ExitStatus { + ExitStatus { + code, + success: code == 0, + } + } +} diff --git a/crates/microsandbox/lib/sandbox/fs.rs b/crates/microsandbox/lib/sandbox/fs.rs index ae012f89a..146e35807 100644 --- a/crates/microsandbox/lib/sandbox/fs.rs +++ b/crates/microsandbox/lib/sandbox/fs.rs @@ -1,18 +1,21 @@ //! Filesystem operations on a running sandbox. //! //! [`SandboxFs`] provides methods to read, write, list, and manipulate files -//! inside a running sandbox via the `core.fs.*` protocol messages. +//! inside a running sandbox. The handle is a thin façade that dispatches each +//! op through the [`SandboxBackend`](crate::backend::SandboxBackend) trait, so +//! local routes through agentd's `core.fs.*` messages and cloud returns +//! per-method `Unsupported` until cloud guest-fs lands. use std::{path::Path, sync::Arc}; use bytes::Bytes; use microsandbox_protocol::{ - fs::{FS_CHUNK_SIZE, FsData, FsEntryInfo, FsOp, FsRequest, FsResponse, FsResponseData}, + fs::{FsData, FsEntryInfo, FsResponse}, message::{Message, MessageType}, }; use tokio::sync::mpsc; -use crate::{MicrosandboxError, MicrosandboxResult, agent::AgentClient}; +use crate::{MicrosandboxError, MicrosandboxResult, agent::AgentClient, backend::Backend}; //-------------------------------------------------------------------------------------------------- // Types @@ -20,10 +23,13 @@ use crate::{MicrosandboxError, MicrosandboxResult, agent::AgentClient}; /// Filesystem operations handle for a running sandbox. /// -/// All operations go through the agent protocol (`core.fs.*` messages), -/// which are handled by agentd inside the guest VM. +/// Borrows the parent [`Sandbox`](super::Sandbox)'s `Arc` + name +/// and dispatches each op through the +/// [`SandboxBackend`](crate::backend::SandboxBackend) trait. Local routes to +/// `core.fs.*` agent messages; cloud returns `Unsupported` per-method. pub struct SandboxFs<'a> { - client: &'a Arc, + backend: Arc, + name: &'a str, } /// A filesystem entry returned from listing or stat operations. @@ -86,6 +92,10 @@ pub struct FsMetadata { /// A streaming reader for file data from the sandbox. pub struct FsReadStream { rx: mpsc::UnboundedReceiver, + // Holds the per-call agent client alive for the duration of the stream. + // Without this the AgentClient's reader task would be dropped after + // `fs_read_stream` returns and `rx` would receive nothing. + _client: Option>, } /// A streaming writer for file data to the sandbox. @@ -100,9 +110,15 @@ pub struct FsWriteSink { //-------------------------------------------------------------------------------------------------- impl<'a> SandboxFs<'a> { - /// Create a new filesystem handle. - pub fn new(client: &'a Arc) -> Self { - Self { client } + /// Create a new filesystem handle bound to the supplied backend + sandbox name. + pub(crate) fn new(backend: Arc, name: &'a str) -> Self { + Self { backend, name } + } + + /// Public constructor for FFI shims that re-assemble a `SandboxFs` per + /// FFI call. Most callers should use [`Sandbox::fs`](super::Sandbox::fs). + pub fn with_backend(backend: Arc, name: &'a str) -> Self { + Self { backend, name } } //---------------------------------------------------------------------------------------------- @@ -111,39 +127,10 @@ impl<'a> SandboxFs<'a> { /// Read an entire file from the guest filesystem into memory. pub async fn read(&self, path: &str) -> MicrosandboxResult { - let id = self.client.next_id(); - let mut rx = self.client.subscribe(id).await; - - let req = FsRequest { - op: FsOp::Read { - path: path.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, id, &req)?; - self.client.send(&msg).await?; - - // Collect FsData chunks until FsResponse (terminal). - let mut data = Vec::new(); - while let Some(msg) = rx.recv().await { - match msg.t { - MessageType::FsData => { - let chunk: FsData = msg.payload()?; - data.extend_from_slice(&chunk.data); - } - MessageType::FsResponse => { - let resp: FsResponse = msg.payload()?; - if !resp.ok { - return Err(MicrosandboxError::SandboxFs( - resp.error.unwrap_or_else(|| "unknown error".into()), - )); - } - break; - } - _ => {} - } - } - - Ok(Bytes::from(data)) + self.backend + .sandboxes() + .fs_read(self.backend.clone(), self.name, path) + .await } /// Read an entire file from the guest filesystem as a UTF-8 string. @@ -157,18 +144,10 @@ impl<'a> SandboxFs<'a> { /// /// Returns an [`FsReadStream`] that yields chunks of data as they arrive. pub async fn read_stream(&self, path: &str) -> MicrosandboxResult { - let id = self.client.next_id(); - let rx = self.client.subscribe(id).await; - - let req = FsRequest { - op: FsOp::Read { - path: path.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, id, &req)?; - self.client.send(&msg).await?; - - Ok(FsReadStream { rx }) + self.backend + .sandboxes() + .fs_read_stream(self.backend.clone(), self.name, path) + .await } //---------------------------------------------------------------------------------------------- @@ -177,36 +156,15 @@ impl<'a> SandboxFs<'a> { /// Write data to a file in the guest, creating it if it doesn't exist. pub async fn write(&self, path: &str, data: impl AsRef<[u8]>) -> MicrosandboxResult<()> { - let data = data.as_ref(); - let id = self.client.next_id(); - let mut rx = self.client.subscribe(id).await; - - // Send write request. - let req = FsRequest { - op: FsOp::Write { - path: path.to_string(), - mode: None, - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, id, &req)?; - self.client.send(&msg).await?; - - // Send data chunks. - for chunk in data.chunks(FS_CHUNK_SIZE) { - let fs_data = FsData { - data: chunk.to_vec(), - }; - let msg = Message::with_payload(MessageType::FsData, id, &fs_data)?; - self.client.send(&msg).await?; - } - - // Send EOF. - let eof = FsData { data: Vec::new() }; - let msg = Message::with_payload(MessageType::FsData, id, &eof)?; - self.client.send(&msg).await?; - - // Wait for terminal response. - wait_for_ok_response(&mut rx).await + self.backend + .sandboxes() + .fs_write( + self.backend.clone(), + self.name, + path, + data.as_ref().to_vec(), + ) + .await } /// Write with streaming. @@ -214,25 +172,10 @@ impl<'a> SandboxFs<'a> { /// Returns an [`FsWriteSink`] for writing data in chunks. Call /// [`FsWriteSink::close`] when done writing. pub async fn write_stream(&self, path: &str) -> MicrosandboxResult { - let id = self.client.next_id(); - - // Subscribe before sending to avoid race. - let rx = self.client.subscribe(id).await; - - let req = FsRequest { - op: FsOp::Write { - path: path.to_string(), - mode: None, - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, id, &req)?; - self.client.send(&msg).await?; - - Ok(FsWriteSink { - id, - client: Arc::clone(self.client), - rx, - }) + self.backend + .sandboxes() + .fs_write_stream(self.backend.clone(), self.name, path) + .await } //---------------------------------------------------------------------------------------------- @@ -241,51 +184,26 @@ impl<'a> SandboxFs<'a> { /// List the immediate children of a directory in the guest (non-recursive). pub async fn list(&self, path: &str) -> MicrosandboxResult> { - let req = FsRequest { - op: FsOp::List { - path: path.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; - let resp_msg = self.client.request(msg).await?; - let resp: FsResponse = resp_msg.payload()?; - - if !resp.ok { - return Err(MicrosandboxError::SandboxFs( - resp.error.unwrap_or_else(|| "unknown error".into()), - )); - } - - match resp.data { - Some(FsResponseData::List(entries)) => { - Ok(entries.into_iter().map(entry_info_to_fs_entry).collect()) - } - _ => Ok(Vec::new()), - } + self.backend + .sandboxes() + .fs_list(self.backend.clone(), self.name, path) + .await } /// Create a directory (and parents). pub async fn mkdir(&self, path: &str) -> MicrosandboxResult<()> { - let req = FsRequest { - op: FsOp::Mkdir { - path: path.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; - let resp_msg = self.client.request(msg).await?; - check_response(resp_msg) + self.backend + .sandboxes() + .fs_mkdir(self.backend.clone(), self.name, path) + .await } /// Remove a directory recursively. pub async fn remove_dir(&self, path: &str) -> MicrosandboxResult<()> { - let req = FsRequest { - op: FsOp::RemoveDir { - path: path.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; - let resp_msg = self.client.request(msg).await?; - check_response(resp_msg) + self.backend + .sandboxes() + .fs_remove(self.backend.clone(), self.name, path, true) + .await } //---------------------------------------------------------------------------------------------- @@ -294,40 +212,26 @@ impl<'a> SandboxFs<'a> { /// Delete a single file. Use [`remove_dir`](Self::remove_dir) for directories. pub async fn remove(&self, path: &str) -> MicrosandboxResult<()> { - let req = FsRequest { - op: FsOp::Remove { - path: path.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; - let resp_msg = self.client.request(msg).await?; - check_response(resp_msg) + self.backend + .sandboxes() + .fs_remove(self.backend.clone(), self.name, path, false) + .await } /// Copy a file within the sandbox. pub async fn copy(&self, from: &str, to: &str) -> MicrosandboxResult<()> { - let req = FsRequest { - op: FsOp::Copy { - src: from.to_string(), - dst: to.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; - let resp_msg = self.client.request(msg).await?; - check_response(resp_msg) + self.backend + .sandboxes() + .fs_copy(self.backend.clone(), self.name, from, to) + .await } /// Rename/move a file or directory. pub async fn rename(&self, from: &str, to: &str) -> MicrosandboxResult<()> { - let req = FsRequest { - op: FsOp::Rename { - src: from.to_string(), - dst: to.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; - let resp_msg = self.client.request(msg).await?; - check_response(resp_msg) + self.backend + .sandboxes() + .fs_rename(self.backend.clone(), self.name, from, to) + .await } //---------------------------------------------------------------------------------------------- @@ -336,36 +240,18 @@ impl<'a> SandboxFs<'a> { /// Get file/directory metadata. pub async fn stat(&self, path: &str) -> MicrosandboxResult { - let req = FsRequest { - op: FsOp::Stat { - path: path.to_string(), - }, - }; - let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; - let resp_msg = self.client.request(msg).await?; - let resp: FsResponse = resp_msg.payload()?; - - if !resp.ok { - return Err(MicrosandboxError::SandboxFs( - resp.error.unwrap_or_else(|| "unknown error".into()), - )); - } - - match resp.data { - Some(FsResponseData::Stat(info)) => Ok(entry_info_to_metadata(&info)), - _ => Err(MicrosandboxError::SandboxFs( - "unexpected response data for stat".into(), - )), - } + self.backend + .sandboxes() + .fs_stat(self.backend.clone(), self.name, path) + .await } /// Check whether a file or directory exists at the given path in the guest. pub async fn exists(&self, path: &str) -> MicrosandboxResult { - match self.stat(path).await { - Ok(_) => Ok(true), - Err(MicrosandboxError::SandboxFs(_)) => Ok(false), - Err(e) => Err(e), - } + self.backend + .sandboxes() + .fs_exists(self.backend.clone(), self.name, path) + .await } //---------------------------------------------------------------------------------------------- @@ -378,19 +264,15 @@ impl<'a> SandboxFs<'a> { host_path: impl AsRef, guest_path: &str, ) -> MicrosandboxResult<()> { - use tokio::io::AsyncReadExt; - - let mut file = tokio::fs::File::open(host_path.as_ref()).await?; - let sink = self.write_stream(guest_path).await?; - let mut buf = vec![0u8; FS_CHUNK_SIZE]; - loop { - let n = file.read(&mut buf).await?; - if n == 0 { - break; - } - sink.write(&buf[..n]).await?; - } - sink.close().await + self.backend + .sandboxes() + .fs_copy_from_host( + self.backend.clone(), + self.name, + host_path.as_ref(), + guest_path, + ) + .await } /// Copy a file from the sandbox to the host. @@ -399,9 +281,15 @@ impl<'a> SandboxFs<'a> { guest_path: &str, host_path: impl AsRef, ) -> MicrosandboxResult<()> { - let data = self.read(guest_path).await?; - tokio::fs::write(host_path.as_ref(), &data).await?; - Ok(()) + self.backend + .sandboxes() + .fs_copy_to_host( + self.backend.clone(), + self.name, + guest_path, + host_path.as_ref(), + ) + .await } } @@ -410,6 +298,18 @@ impl<'a> SandboxFs<'a> { //-------------------------------------------------------------------------------------------------- impl FsReadStream { + /// Construct a read stream that pins an [`AgentClient`] alive for the + /// duration of the stream. **Local impl only.** + pub(crate) fn with_client( + rx: mpsc::UnboundedReceiver, + client: Arc, + ) -> Self { + Self { + rx, + _client: Some(client), + } + } + /// Receive the next chunk of data. /// /// Returns `None` when the stream is complete (after `FsResponse`). @@ -453,6 +353,15 @@ impl FsReadStream { //-------------------------------------------------------------------------------------------------- impl FsWriteSink { + /// Construct a write sink from raw protocol state. **Local impl only.** + pub(crate) fn new( + id: u32, + client: Arc, + rx: mpsc::UnboundedReceiver, + ) -> Self { + Self { id, client, rx } + } + /// Write a chunk of data. pub async fn write(&self, data: impl AsRef<[u8]>) -> MicrosandboxResult<()> { let fs_data = FsData { @@ -541,3 +450,339 @@ async fn wait_for_ok_response(rx: &mut mpsc::UnboundedReceiver) -> Micr "channel closed before response".into(), )) } + +//-------------------------------------------------------------------------------------------------- +// Module: local (free fn impls called by LocalBackend's SandboxBackend impl) +//-------------------------------------------------------------------------------------------------- + +pub(crate) mod local { + //! Local guest-FS ops keyed by `(sandbox_name, path)`. + //! + //! Each function opens a fresh agent UDS connection (option A per the + //! parity plan). The per-call overhead is small relative to the + //! cross-VM I/O these calls drive and keeps the trait dispatch path + //! stateless. + + use std::path::Path; + use std::sync::Arc; + + use bytes::Bytes; + use microsandbox_protocol::{ + fs::{FS_CHUNK_SIZE, FsData, FsOp, FsRequest, FsResponse, FsResponseData}, + message::{Message, MessageType}, + }; + use tokio::io::AsyncReadExt; + + use crate::{MicrosandboxError, MicrosandboxResult, agent::AgentClient, backend::LocalBackend}; + + use super::{ + FsEntry, FsMetadata, FsReadStream, FsWriteSink, check_response, entry_info_to_fs_entry, + entry_info_to_metadata, wait_for_ok_response, + }; + + /// Open a fresh agent UDS connection for the named sandbox. + pub(crate) async fn connect_agent( + local: &LocalBackend, + name: &str, + ) -> MicrosandboxResult { + let sock_path = local + .sandboxes_dir() + .join(name) + .join("runtime") + .join("agent.sock"); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + AgentClient::connect(&sock_path, deadline).await + } + + pub(crate) async fn read( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let client = connect_agent(local, name).await?; + let id = client.next_id(); + let mut rx = client.subscribe(id).await; + + let req = FsRequest { + op: FsOp::Read { + path: path.to_string(), + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, id, &req)?; + client.send(&msg).await?; + + let mut data = Vec::new(); + while let Some(msg) = rx.recv().await { + match msg.t { + MessageType::FsData => { + let chunk: FsData = msg.payload()?; + data.extend_from_slice(&chunk.data); + } + MessageType::FsResponse => { + let resp: FsResponse = msg.payload()?; + if !resp.ok { + return Err(MicrosandboxError::SandboxFs( + resp.error.unwrap_or_else(|| "unknown error".into()), + )); + } + break; + } + _ => {} + } + } + + Ok(Bytes::from(data)) + } + + pub(crate) async fn read_stream( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let client = Arc::new(connect_agent(local, name).await?); + let id = client.next_id(); + let rx = client.subscribe(id).await; + + let req = FsRequest { + op: FsOp::Read { + path: path.to_string(), + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, id, &req)?; + client.send(&msg).await?; + + // Pin the AgentClient alive inside the stream — without it the + // reader task would drop after this fn returns and `rx` would + // never receive any messages. + Ok(FsReadStream::with_client(rx, client)) + } + + pub(crate) async fn write( + local: &LocalBackend, + name: &str, + path: &str, + data: Vec, + ) -> MicrosandboxResult<()> { + let client = connect_agent(local, name).await?; + let id = client.next_id(); + let mut rx = client.subscribe(id).await; + + let req = FsRequest { + op: FsOp::Write { + path: path.to_string(), + mode: None, + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, id, &req)?; + client.send(&msg).await?; + + for chunk in data.chunks(FS_CHUNK_SIZE) { + let fs_data = FsData { + data: chunk.to_vec(), + }; + let msg = Message::with_payload(MessageType::FsData, id, &fs_data)?; + client.send(&msg).await?; + } + + let eof = FsData { data: Vec::new() }; + let msg = Message::with_payload(MessageType::FsData, id, &eof)?; + client.send(&msg).await?; + + wait_for_ok_response(&mut rx).await + } + + pub(crate) async fn write_stream( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let client = Arc::new(connect_agent(local, name).await?); + let id = client.next_id(); + let rx = client.subscribe(id).await; + + let req = FsRequest { + op: FsOp::Write { + path: path.to_string(), + mode: None, + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, id, &req)?; + client.send(&msg).await?; + + Ok(FsWriteSink::new(id, client, rx)) + } + + pub(crate) async fn list( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult> { + let client = connect_agent(local, name).await?; + let req = FsRequest { + op: FsOp::List { + path: path.to_string(), + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; + let resp_msg = client.request(msg).await?; + let resp: FsResponse = resp_msg.payload()?; + + if !resp.ok { + return Err(MicrosandboxError::SandboxFs( + resp.error.unwrap_or_else(|| "unknown error".into()), + )); + } + + match resp.data { + Some(FsResponseData::List(entries)) => { + Ok(entries.into_iter().map(entry_info_to_fs_entry).collect()) + } + _ => Ok(Vec::new()), + } + } + + pub(crate) async fn mkdir( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult<()> { + let client = connect_agent(local, name).await?; + let req = FsRequest { + op: FsOp::Mkdir { + path: path.to_string(), + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; + let resp_msg = client.request(msg).await?; + check_response(resp_msg) + } + + pub(crate) async fn remove( + local: &LocalBackend, + name: &str, + path: &str, + recursive: bool, + ) -> MicrosandboxResult<()> { + let client = connect_agent(local, name).await?; + let op = if recursive { + FsOp::RemoveDir { + path: path.to_string(), + } + } else { + FsOp::Remove { + path: path.to_string(), + } + }; + let req = FsRequest { op }; + let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; + let resp_msg = client.request(msg).await?; + check_response(resp_msg) + } + + pub(crate) async fn copy( + local: &LocalBackend, + name: &str, + from: &str, + to: &str, + ) -> MicrosandboxResult<()> { + let client = connect_agent(local, name).await?; + let req = FsRequest { + op: FsOp::Copy { + src: from.to_string(), + dst: to.to_string(), + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; + let resp_msg = client.request(msg).await?; + check_response(resp_msg) + } + + pub(crate) async fn rename( + local: &LocalBackend, + name: &str, + from: &str, + to: &str, + ) -> MicrosandboxResult<()> { + let client = connect_agent(local, name).await?; + let req = FsRequest { + op: FsOp::Rename { + src: from.to_string(), + dst: to.to_string(), + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; + let resp_msg = client.request(msg).await?; + check_response(resp_msg) + } + + pub(crate) async fn stat( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let client = connect_agent(local, name).await?; + let req = FsRequest { + op: FsOp::Stat { + path: path.to_string(), + }, + }; + let msg = Message::with_payload(MessageType::FsRequest, 0, &req)?; + let resp_msg = client.request(msg).await?; + let resp: FsResponse = resp_msg.payload()?; + + if !resp.ok { + return Err(MicrosandboxError::SandboxFs( + resp.error.unwrap_or_else(|| "unknown error".into()), + )); + } + + match resp.data { + Some(FsResponseData::Stat(info)) => Ok(entry_info_to_metadata(&info)), + _ => Err(MicrosandboxError::SandboxFs( + "unexpected response data for stat".into(), + )), + } + } + + pub(crate) async fn exists( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + match stat(local, name, path).await { + Ok(_) => Ok(true), + Err(MicrosandboxError::SandboxFs(_)) => Ok(false), + Err(e) => Err(e), + } + } + + pub(crate) async fn copy_from_host( + local: &LocalBackend, + name: &str, + host_path: &Path, + guest_path: &str, + ) -> MicrosandboxResult<()> { + let mut file = tokio::fs::File::open(host_path).await?; + let sink = write_stream(local, name, guest_path).await?; + let mut buf = vec![0u8; FS_CHUNK_SIZE]; + loop { + let n = file.read(&mut buf).await?; + if n == 0 { + break; + } + sink.write(&buf[..n]).await?; + } + sink.close().await + } + + pub(crate) async fn copy_to_host( + local: &LocalBackend, + name: &str, + guest_path: &str, + host_path: &Path, + ) -> MicrosandboxResult<()> { + let data = read(local, name, guest_path).await?; + tokio::fs::write(host_path, &data).await?; + Ok(()) + } +} diff --git a/crates/microsandbox/lib/sandbox/handle.rs b/crates/microsandbox/lib/sandbox/handle.rs index fac1517d6..3467324cb 100644 --- a/crates/microsandbox/lib/sandbox/handle.rs +++ b/crates/microsandbox/lib/sandbox/handle.rs @@ -1,12 +1,22 @@ //! Lightweight sandbox handle for metadata and signal-based lifecycle management. - -use sea_orm::EntityTrait; +//! +//! Per the SDK local-cloud parity plan (D6.4) `SandboxHandle` stays a single +//! type regardless of backend. It carries an `Arc` plus a +//! backend-private [`SandboxHandleInner`](crate::backend::SandboxHandleInner) +//! enum. Users reach variant-specific data via [`SandboxHandle::local`] / +//! [`SandboxHandle::cloud`]. use std::sync::Arc; +use sea_orm::EntityTrait; + use crate::{ - MicrosandboxResult, agent::AgentClient, db::entity::sandbox as sandbox_entity, - runtime::SpawnMode, + MicrosandboxResult, + agent::AgentClient, + backend::{ + Backend, CloudSandbox, SandboxHandleCloudState, SandboxHandleInner, SandboxHandleLocalState, + }, + db::entity::sandbox as sandbox_entity, }; use super::{Sandbox, SandboxConfig, SandboxStatus}; @@ -15,23 +25,18 @@ use super::{Sandbox, SandboxConfig, SandboxStatus}; // Types //-------------------------------------------------------------------------------------------------- -/// A lightweight handle to a sandbox from the database. +/// A lightweight handle to a sandbox. /// -/// Provides metadata access and signal-based lifecycle management (stop, kill) -/// without requiring a live agent bridge. Obtained via [`Sandbox::get`] or -/// [`Sandbox::list`]. +/// Provides metadata access and signal-based lifecycle management (stop, kill, +/// remove) without requiring a live agent bridge. Obtained via +/// [`Sandbox::get`] or [`Sandbox::list`]. /// /// For full runtime capabilities (exec, shell, fs), call [`start`](SandboxHandle::start) /// to boot the sandbox and obtain a live [`Sandbox`] handle. -#[derive(Debug)] pub struct SandboxHandle { - db_id: i32, + backend: Arc, + inner: SandboxHandleInner, name: String, - status: SandboxStatus, - config_json: String, - created_at: Option>, - updated_at: Option>, - pid: Option, } //-------------------------------------------------------------------------------------------------- @@ -39,66 +44,194 @@ pub struct SandboxHandle { //-------------------------------------------------------------------------------------------------- impl SandboxHandle { - /// Create a handle from a database entity model and its resolved process PID. - pub(super) fn new(model: sandbox_entity::Model, pid: Option) -> Self { + /// Build a handle from a local sandbox DB row + active PID. + pub(crate) fn from_local_model( + backend: Arc, + model: sandbox_entity::Model, + pid: Option, + ) -> Self { + let name = model.name.clone(); Self { - db_id: model.id, - name: model.name, - status: model.status, - config_json: model.config, - created_at: model.created_at.map(|dt| dt.and_utc()), - updated_at: model.updated_at.map(|dt| dt.and_utc()), - pid, + backend, + inner: SandboxHandleInner::Local(SandboxHandleLocalState { + db_id: model.id, + status: model.status, + config_json: model.config, + created_at: model.created_at.map(|dt| dt.and_utc()), + updated_at: model.updated_at.map(|dt| dt.and_utc()), + pid, + }), + name, } } + /// Build a handle from a [`CloudSandbox`] HTTP response. + /// + /// Returns an error if `cloud.config` cannot be re-serialised to JSON for + /// the `config_json()` view. Silent fallback to an empty string here would + /// surface later as a confusing `serde_json::Error` ("EOF while parsing") + /// out of [`config()`](Self::config) / [`config_json()`](Self::config_json). + pub(crate) fn from_cloud( + backend: Arc, + cloud: CloudSandbox, + ) -> MicrosandboxResult { + let status = crate::backend::sandbox::cloud_status_to_sandbox_status(cloud.status); + let config_json = serde_json::to_string(&cloud.config)?; + let name = cloud.name.clone(); + Ok(Self { + backend, + inner: SandboxHandleInner::Cloud(SandboxHandleCloudState { + id: cloud.id, + org_id: cloud.org_id, + status, + config_json, + created_at: Some(cloud.created_at), + started_at: cloud.started_at, + stopped_at: cloud.stopped_at, + last_error: cloud.last_error, + }), + name, + }) + } + /// Unique name identifying this sandbox. pub fn name(&self) -> &str { &self.name } - /// Snapshot of sandbox status from when this handle was created. - /// Not live — call [`Sandbox::get`] again for a fresh reading. - pub fn status(&self) -> SandboxStatus { - self.status + /// Which backend variant this handle is bound to. + pub fn backend_kind(&self) -> crate::backend::BackendKind { + self.backend.kind() + } + + /// Local-only handle state. Returns `Some` for local-backed handles. + pub fn local(&self) -> Option<&SandboxHandleLocalState> { + match &self.inner { + SandboxHandleInner::Local(s) => Some(s), + SandboxHandleInner::Cloud(_) => None, + } + } + + /// Cloud-only handle state. Returns `Some` for cloud-backed handles. + pub fn cloud(&self) -> Option<&SandboxHandleCloudState> { + match &self.inner { + SandboxHandleInner::Cloud(s) => Some(s), + SandboxHandleInner::Local(_) => None, + } } - /// The serialized sandbox configuration as stored in the database. - /// Use [`config()`](Self::config) for a deserialized version. + /// Snapshot of sandbox status captured when this handle was created. + /// + /// **Not live** — call [`Sandbox::status`](super::Sandbox::status) on the + /// live `Sandbox` (or re-fetch via [`Sandbox::get`](super::Sandbox::get)) + /// for a fresh reading. The `_snapshot` suffix is deliberate to avoid + /// confusion with `Sandbox::status()` which is async + fetch-live. + /// + /// # Example + /// + /// ```ignore + /// let handle = Sandbox::get("agent-1").await?; + /// // Cheap, in-memory; reflects state at handle-creation time. + /// let snap = handle.status_snapshot(); + /// + /// // For a fresh reading, drive through the live Sandbox: + /// let sb = handle.start().await?; + /// let live = sb.status().await?; + /// ``` + pub fn status_snapshot(&self) -> SandboxStatus { + match &self.inner { + SandboxHandleInner::Local(s) => s.status, + SandboxHandleInner::Cloud(s) => s.status, + } + } + + /// Snapshot of the cloud `last_error`, if any. Returns `None` for local + /// handles (local error reporting flows through the typed error stack). + pub fn last_error_snapshot(&self) -> Option { + match &self.inner { + SandboxHandleInner::Cloud(s) => s.last_error.clone(), + SandboxHandleInner::Local(_) => None, + } + } + + /// The serialized sandbox configuration as stored in the database (local) + /// or returned by msb-cloud (cloud). Use [`config()`](Self::config) for a + /// deserialized [`SandboxConfig`]. pub fn config_json(&self) -> &str { - &self.config_json + match &self.inner { + SandboxHandleInner::Local(s) => &s.config_json, + SandboxHandleInner::Cloud(s) => &s.config_json, + } } /// Parse the stored configuration. Returns an error if the JSON /// is malformed (e.g., schema changed since the sandbox was created). + /// + /// For local handles this deserializes the persisted [`SandboxConfig`]. + /// For cloud handles this returns an `Unsupported` error: the cloud wire + /// shape is [`CloudCreateSandboxRequest`](crate::backend::CloudCreateSandboxRequest), + /// not `SandboxConfig`. Use [`config_json`](Self::config_json) to read the + /// raw JSON, or [`cloud`](Self::cloud) to access the typed cloud state. pub fn config(&self) -> MicrosandboxResult { - Ok(serde_json::from_str(&self.config_json)?) + match &self.inner { + SandboxHandleInner::Local(s) => Ok(serde_json::from_str(&s.config_json)?), + SandboxHandleInner::Cloud(_) => Err(crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::config on cloud".into(), + available_when: "when SandboxConfig is the cloud wire shape".into(), + }), + } } /// When this sandbox was first created, if recorded. pub fn created_at(&self) -> Option> { - self.created_at + match &self.inner { + SandboxHandleInner::Local(s) => s.created_at, + SandboxHandleInner::Cloud(s) => s.created_at, + } } - /// When this sandbox's database record was last modified. + /// Best-effort "last activity" timestamp. + /// + /// - Local: the database row's `updated_at` (modification time of the + /// persisted record). + /// - Cloud: the most recent of `stopped_at` / `started_at` / `created_at` + /// from the msb-cloud response. msb-cloud has no dedicated + /// `updated_at` column, so this is synthesised on the client. pub fn updated_at(&self) -> Option> { - self.updated_at + match &self.inner { + SandboxHandleInner::Local(s) => s.updated_at, + SandboxHandleInner::Cloud(s) => s.stopped_at.or(s.started_at).or(s.created_at), + } } /// Read captured output from `exec.log` for this sandbox. /// /// Same backing data as [`Sandbox::logs`](super::Sandbox::logs). - /// Works without starting the sandbox. + /// Works without starting the sandbox. **Local handles only**. pub fn logs(&self, opts: &super::LogOptions) -> MicrosandboxResult> { - super::logs::read_logs(&self.name, opts) + let local_backend = + self.backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::logs on cloud".into(), + available_when: "when cloud logs land".into(), + })?; + super::logs::read_logs(local_backend, &self.name, opts) } - /// Get the latest metrics snapshot for this sandbox. + /// Get the latest metrics snapshot for this sandbox. **Local handles only**. pub async fn metrics(&self) -> MicrosandboxResult { - if self.status != SandboxStatus::Running && self.status != SandboxStatus::Draining { + let local = self + .local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::metrics on cloud".into(), + available_when: "when cloud metrics land".into(), + })?; + + if local.status != SandboxStatus::Running && local.status != SandboxStatus::Draining { return Err(crate::MicrosandboxError::Custom(format!( "sandbox '{}' is not running (status: {:?})", - self.name, self.status + self.name, local.status ))); } @@ -107,10 +240,17 @@ impl SandboxHandle { return Err(crate::MicrosandboxError::MetricsDisabled(self.name.clone())); } - let db = crate::db::init_global().await?.read(); + let local_backend = + self.backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::metrics on cloud".into(), + available_when: "when cloud metrics land".into(), + })?; + let db = local_backend.db().await?.read(); super::metrics::metrics_for_sandbox( db, - self.db_id, + local.db_id, u64::from(config.memory_mib) * 1024 * 1024, ) .await @@ -118,34 +258,51 @@ impl SandboxHandle { /// Start this sandbox and return a live handle. /// - /// Boots the VM using the persisted configuration and pinned rootfs state. - /// The handle remains usable if start fails. + /// Boots the VM using the persisted configuration and pinned rootfs state + /// for local; routes through `POST /v1/sandboxes/by-name/:name/start` for + /// cloud. The handle remains usable if start fails. pub async fn start(&self) -> MicrosandboxResult { - Sandbox::start_with_mode(&self.name, SpawnMode::Attached).await + self.backend + .sandboxes() + .start(self.backend.clone(), &self.name) + .await } /// Start this sandbox in detached/background mode. /// /// The handle remains usable if start fails. pub async fn start_detached(&self) -> MicrosandboxResult { - Sandbox::start_with_mode(&self.name, SpawnMode::Detached).await + self.backend + .sandboxes() + .start_detached(self.backend.clone(), &self.name) + .await } - /// Connect to a running sandbox via the agent relay socket. - /// - /// Returns a [`Sandbox`] handle that communicates through the relay - /// without owning the process lifecycle. The sandbox will continue - /// running after this handle is dropped. + /// Connect to a running sandbox via the agent relay socket. **Local + /// handles only** — cloud sandbox attach is HTTP/WS and not wired up in + /// this delegation. pub async fn connect(&self) -> MicrosandboxResult { - if self.status != SandboxStatus::Running && self.status != SandboxStatus::Draining { + let local = self + .local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::connect on cloud".into(), + available_when: "when cloud attach lands".into(), + })?; + if local.status != SandboxStatus::Running && local.status != SandboxStatus::Draining { return Err(crate::MicrosandboxError::Custom(format!( "sandbox '{}' is not running (status: {:?})", - self.name, self.status + self.name, local.status ))); } - let global = crate::config::config(); - let sock_path = global + let local_backend = + self.backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::connect on cloud".into(), + available_when: "when cloud attach lands".into(), + })?; + let sock_path = local_backend .sandboxes_dir() .join(&self.name) .join("runtime") @@ -157,27 +314,35 @@ impl SandboxHandle { // see a timeout instead of hanging forever. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); let client = AgentClient::connect(&sock_path, deadline).await?; - let config: SandboxConfig = serde_json::from_str(&self.config_json)?; - - Ok(Sandbox { - db_id: self.db_id, + let config: SandboxConfig = serde_json::from_str(&local.config_json)?; + + Ok(Sandbox::from_local( + self.backend.clone(), + crate::backend::SandboxLocalState { + db_id: local.db_id, + handle: None, + client: Arc::new(client), + }, config, - handle: None, - client: Arc::new(client), - }) + )) } /// Snapshot this sandbox to a bare name under the default snapshots /// directory (`~/.microsandbox/snapshots//`). /// /// The sandbox must be stopped (or crashed); running sandboxes are - /// rejected with `MicrosandboxError::SnapshotSandboxRunning`. For - /// an explicit filesystem destination, see - /// [`snapshot_to`](Self::snapshot_to). + /// rejected with `MicrosandboxError::SnapshotSandboxRunning`. **Local + /// handles only** — cloud snapshot semantics are deferred. pub async fn snapshot( &self, name: &str, ) -> MicrosandboxResult { + if self.local().is_none() { + return Err(crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::snapshot on cloud".into(), + available_when: "when cloud snapshots land".into(), + }); + } use super::super::snapshot::{Snapshot, SnapshotDestination}; Snapshot::builder(&self.name) .destination(SnapshotDestination::Name(name.to_string())) @@ -185,16 +350,17 @@ impl SandboxHandle { .await } - /// Snapshot this sandbox to an explicit filesystem path. - /// - /// The sandbox must be stopped (or crashed); running sandboxes are - /// rejected with `MicrosandboxError::SnapshotSandboxRunning`. For - /// the common case of writing under the default snapshots - /// directory, see [`snapshot`](Self::snapshot). + /// Snapshot this sandbox to an explicit filesystem path. **Local handles only.** pub async fn snapshot_to( &self, path: impl AsRef, ) -> MicrosandboxResult { + if self.local().is_none() { + return Err(crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::snapshot_to on cloud".into(), + available_when: "when cloud snapshots land".into(), + }); + } use super::super::snapshot::{Snapshot, SnapshotDestination}; Snapshot::builder(&self.name) .destination(SnapshotDestination::Path(path.as_ref().to_path_buf())) @@ -202,95 +368,91 @@ impl SandboxHandle { .await } - /// Stop the sandbox gracefully (SIGTERM). + /// Stop the sandbox gracefully. + /// + /// Routes through the backend trait. On local the trait impl connects + /// to the agent UDS and sends `core.shutdown` (clean ext4 unmount via + /// in-guest `sync()` + `reboot(RB_POWER_OFF)`), falling back to SIGTERM + /// via PID if the socket is unreachable. On cloud it issues + /// `POST /v1/sandboxes/by-name/:name/stop`. pub async fn stop(&self) -> MicrosandboxResult<()> { - if self.status != SandboxStatus::Running && self.status != SandboxStatus::Draining { - return Ok(()); - } - - signal_pid(self.pid, nix::sys::signal::Signal::SIGTERM)?; - Ok(()) + self.backend + .sandboxes() + .stop(self.backend.clone(), &self.name) + .await } /// Kill the sandbox immediately (SIGKILL). /// - /// Waits for the process to exit (up to 5 seconds) and marks the - /// sandbox as `Stopped`. + /// Routes through the backend trait. On local the trait impl signals + /// SIGKILL to the libkrun PID, waits briefly for exit, and marks the + /// DB row Stopped once dead. Updates the cached status snapshot on this + /// handle to match. Cloud handles currently return `Unsupported`. pub async fn kill(&mut self) -> MicrosandboxResult<()> { - if self.status != SandboxStatus::Running && self.status != SandboxStatus::Draining { - return Ok(()); - } - - let pids = signal_pid(self.pid, nix::sys::signal::Signal::SIGKILL)?; - - if !pids.is_empty() { - wait_for_exit(&pids, std::time::Duration::from_secs(5)).await; - } - - // Mark stopped if all processes are confirmed dead (or were already gone). - let all_dead = pids.is_empty() || pids.iter().all(|pid| !super::pid_is_alive(*pid)); - - if all_dead { - let db = crate::db::init_global().await?.write(); - if let Err(e) = - super::update_sandbox_status(db, self.db_id, SandboxStatus::Stopped).await - { - tracing::warn!(sandbox = %self.name, error = %e, "failed to update sandbox status after kill"); - } - self.status = SandboxStatus::Stopped; + self.backend + .sandboxes() + .kill(self.backend.clone(), &self.name) + .await?; + // Mirror the DB update onto the cached snapshot held by this handle. + if let SandboxHandleInner::Local(local) = &mut self.inner + && local.pid.is_none_or(|p| !super::pid_is_alive(p)) + { + local.status = SandboxStatus::Stopped; } - Ok(()) } - /// Remove this sandbox from the database and filesystem. + /// Remove this sandbox. /// - /// The sandbox must be stopped first. Use [`stop`](SandboxHandle::stop) or - /// [`kill`](SandboxHandle::kill) to stop it before removing. + /// The sandbox must be stopped first. Use [`stop`](Self::stop) or + /// [`kill`](Self::kill) to stop it before removing. Routes through the + /// backend trait so cloud handles hit `DELETE /v1/sandboxes/by-name/:name`. pub async fn remove(&self) -> MicrosandboxResult<()> { - if self.status == SandboxStatus::Running || self.status == SandboxStatus::Draining { - return Err(crate::MicrosandboxError::SandboxStillRunning(format!( - "cannot remove sandbox '{}': still running", - self.name - ))); + match &self.inner { + SandboxHandleInner::Local(local) => { + if local.status == SandboxStatus::Running || local.status == SandboxStatus::Draining + { + return Err(crate::MicrosandboxError::SandboxStillRunning(format!( + "cannot remove sandbox '{}': still running", + self.name + ))); + } + + let local_backend = self.backend.as_local().ok_or_else(|| { + crate::MicrosandboxError::Unsupported { + feature: "SandboxHandle::remove on cloud".into(), + available_when: "wired via Cloud variant".into(), + } + })?; + let pools = local_backend.db().await?; + + super::remove_dir_if_exists(&local_backend.sandboxes_dir().join(&self.name))?; + sandbox_entity::Entity::delete_by_id(local.db_id) + .exec(pools.write()) + .await?; + + Ok(()) + } + SandboxHandleInner::Cloud(_) => { + self.backend + .sandboxes() + .remove(self.backend.clone(), &self.name) + .await + } } - - let pools = crate::db::init_global().await?; - - super::remove_dir_if_exists(&crate::config::config().sandboxes_dir().join(&self.name))?; - sandbox_entity::Entity::delete_by_id(self.db_id) - .exec(pools.write()) - .await?; - - Ok(()) } } //-------------------------------------------------------------------------------------------------- -// Functions +// Trait Implementations //-------------------------------------------------------------------------------------------------- -/// Send a signal to the sandbox process. -/// -/// Returns the PIDs that were signalled. -fn signal_pid(pid: Option, signal: nix::sys::signal::Signal) -> MicrosandboxResult> { - if let Some(pid) = pid.filter(|pid| super::pid_is_alive(*pid)) { - nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), signal)?; - return Ok(vec![pid]); - } - - Ok(vec![]) -} - -/// Poll until all PIDs have exited or the timeout is reached. -async fn wait_for_exit(pids: &[i32], timeout: std::time::Duration) { - let start = std::time::Instant::now(); - let poll_interval = std::time::Duration::from_millis(50); - - while start.elapsed() < timeout { - if pids.iter().all(|pid| !super::pid_is_alive(*pid)) { - return; - } - tokio::time::sleep(poll_interval).await; +impl std::fmt::Debug for SandboxHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SandboxHandle") + .field("name", &self.name) + .field("backend_kind", &self.backend.kind()) + .field("status", &self.status_snapshot()) + .finish() } } diff --git a/crates/microsandbox/lib/sandbox/logs.rs b/crates/microsandbox/lib/sandbox/logs.rs index c1c57fac1..ff1b1491b 100644 --- a/crates/microsandbox/lib/sandbox/logs.rs +++ b/crates/microsandbox/lib/sandbox/logs.rs @@ -15,7 +15,7 @@ use chrono::{DateTime, Utc}; use microsandbox_utils::log_text::{base64_decode, split_leading_timestamp, strip_ansi}; use serde::Deserialize; -use crate::{MicrosandboxError, MicrosandboxResult}; +use crate::{MicrosandboxError, MicrosandboxResult, backend::LocalBackend}; //-------------------------------------------------------------------------------------------------- // Types @@ -103,8 +103,12 @@ struct RawEntry { /// /// Returns an empty vector if the sandbox has no `exec.log` yet (i.e. /// has been created but never opened a primary exec session). -pub fn read_logs(name: &str, opts: &LogOptions) -> MicrosandboxResult> { - let log_dir = log_dir_for(name); +pub fn read_logs( + local: &LocalBackend, + name: &str, + opts: &LogOptions, +) -> MicrosandboxResult> { + let log_dir = log_dir_for(local, name); if !log_dir.exists() { return Err(MicrosandboxError::SandboxNotFound(name.to_string())); } @@ -117,12 +121,10 @@ pub fn read_logs(name: &str, opts: &LogOptions) -> MicrosandboxResult PathBuf { - crate::config::config() - .sandboxes_dir() - .join(name) - .join("logs") +/// Compute the on-disk log directory for a sandbox name against the +/// supplied `local` backend's sandboxes directory. +pub fn log_dir_for(local: &LocalBackend, name: &str) -> PathBuf { + local.sandboxes_dir().join(name).join("logs") } //-------------------------------------------------------------------------------------------------- diff --git a/crates/microsandbox/lib/sandbox/metrics.rs b/crates/microsandbox/lib/sandbox/metrics.rs index 53887245c..55f70c9a9 100644 --- a/crates/microsandbox/lib/sandbox/metrics.rs +++ b/crates/microsandbox/lib/sandbox/metrics.rs @@ -1,6 +1,7 @@ //! Sandbox metrics APIs backed by persisted runtime samples. use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use futures::stream; @@ -9,6 +10,7 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use crate::{ MicrosandboxError, MicrosandboxResult, + backend::{Backend, LocalBackend, sandbox::MetricsStream}, db::entity::{sandbox as sandbox_entity, sandbox_metric as sandbox_metric_entity}, }; @@ -50,50 +52,103 @@ impl Sandbox { /// /// Returns [`MicrosandboxError::MetricsDisabled`] when the sandbox /// was created with metrics sampling disabled - /// (`metrics_sample_interval_ms == None`). + /// (`metrics_sample_interval_ms == None`). **Local backend only** — + /// cloud routes return [`MicrosandboxError::Unsupported`]. pub async fn metrics(&self) -> MicrosandboxResult { - if self.config.effective_metrics_interval().is_none() { - return Err(MicrosandboxError::MetricsDisabled(self.config.name.clone())); - } - let db = crate::db::init_global().await?.read(); - metrics_for_sandbox(db, self.db_id, memory_limit_bytes(&self.config)).await + self.backend() + .sandboxes() + .metrics(self.backend().clone(), self.name(), self.config()) + .await } - /// Stream metrics snapshots at the requested interval. + /// Stream metrics snapshots at the requested interval. **Local backend only**. + /// Cloud routes yield a single [`MicrosandboxError::Unsupported`]. pub fn metrics_stream( &self, interval: Duration, ) -> impl futures::Stream> + Send + 'static { - use futures::StreamExt; + self.backend().sandboxes().metrics_stream( + self.backend().clone(), + self.name().to_string(), + self.config().clone(), + interval, + ) + } +} - if self.config.effective_metrics_interval().is_none() { - let name = self.config.name.clone(); - return stream::once(async move { Err(MicrosandboxError::MetricsDisabled(name)) }) - .left_stream(); - } +//-------------------------------------------------------------------------------------------------- +// Functions: backend-trait dispatch +//-------------------------------------------------------------------------------------------------- - let db_id = self.db_id; - let memory_limit_bytes = memory_limit_bytes(&self.config); - let interval = if interval.is_zero() { - Duration::from_millis(1) - } else { - interval - }; - - stream::unfold( - tokio::time::interval(interval), - move |mut ticker| async move { - ticker.tick().await; - let pools = crate::db::init_global().await; - let item = match pools { - Ok(pools) => metrics_for_sandbox(pools.read(), db_id, memory_limit_bytes).await, - Err(err) => Err(err), - }; - Some((item, ticker)) - }, - ) - .right_stream() +/// Local-backend metrics fetch keyed by sandbox name. Called from the +/// [`SandboxBackend::metrics`](crate::backend::SandboxBackend::metrics) impl on +/// [`LocalBackend`](crate::backend::LocalBackend). +pub(crate) async fn local_metrics( + local: &LocalBackend, + name: &str, + config: &SandboxConfig, +) -> MicrosandboxResult { + if config.effective_metrics_interval().is_none() { + return Err(MicrosandboxError::MetricsDisabled(name.to_string())); + } + let pools = local.db().await?; + let model = sandbox_entity::Entity::find() + .filter(sandbox_entity::Column::Name.eq(name)) + .one(pools.read()) + .await? + .ok_or_else(|| MicrosandboxError::SandboxNotFound(name.to_string()))?; + metrics_for_sandbox(pools.read(), model.id, memory_limit_bytes(config)).await +} + +/// Local-backend streaming metrics. Called from the +/// [`SandboxBackend::metrics_stream`](crate::backend::SandboxBackend::metrics_stream) +/// impl on [`LocalBackend`](crate::backend::LocalBackend). +pub(crate) fn local_metrics_stream( + backend: Arc, + name: String, + config: SandboxConfig, + interval: Duration, +) -> MetricsStream { + if config.effective_metrics_interval().is_none() { + return Box::pin(stream::once(async move { + Err(MicrosandboxError::MetricsDisabled(name)) + })); } + + let memory_limit_bytes = memory_limit_bytes(&config); + let interval = if interval.is_zero() { + Duration::from_millis(1) + } else { + interval + }; + + Box::pin(stream::unfold( + (tokio::time::interval(interval), backend, name), + move |(mut ticker, backend, name)| async move { + ticker.tick().await; + let item = match backend.as_local() { + Some(local) => match local.db().await { + Ok(pools) => match sandbox_entity::Entity::find() + .filter(sandbox_entity::Column::Name.eq(&name)) + .one(pools.read()) + .await + { + Ok(Some(model)) => { + metrics_for_sandbox(pools.read(), model.id, memory_limit_bytes).await + } + Ok(None) => Err(MicrosandboxError::SandboxNotFound(name.clone())), + Err(e) => Err(e.into()), + }, + Err(err) => Err(err), + }, + None => Err(MicrosandboxError::Unsupported { + feature: "Sandbox::metrics_stream on cloud".into(), + available_when: "when cloud metrics land".into(), + }), + }; + Some((item, (ticker, backend, name))) + }, + )) } //-------------------------------------------------------------------------------------------------- @@ -101,8 +156,10 @@ impl Sandbox { //-------------------------------------------------------------------------------------------------- /// Get the latest metrics snapshot for every running sandbox. -pub async fn all_sandbox_metrics() -> MicrosandboxResult> { - let pools = crate::db::init_global().await?; +pub async fn all_sandbox_metrics( + local: &LocalBackend, +) -> MicrosandboxResult> { + let pools = local.db().await?; let db = pools.read(); let sandboxes = sandbox_entity::Entity::find() .filter( diff --git a/crates/microsandbox/lib/sandbox/mod.rs b/crates/microsandbox/lib/sandbox/mod.rs index 24866f767..b9bd63251 100644 --- a/crates/microsandbox/lib/sandbox/mod.rs +++ b/crates/microsandbox/lib/sandbox/mod.rs @@ -5,7 +5,7 @@ //! methods (stop, kill, drain, wait) and access to the [`AgentClient`] //! for guest communication. -mod attach; +pub(crate) mod attach; mod builder; mod config; pub mod exec; @@ -13,24 +13,23 @@ pub mod fs; mod handle; pub mod init; pub mod logs; -mod metrics; +pub(crate) mod metrics; mod patch; mod types; use std::{collections::HashMap, path::Path, process::ExitStatus, sync::Arc}; -use bytes::Bytes; use microsandbox_db::pool::DbPools; use microsandbox_db::{DbReadConnection, DbWriteConnection}; use microsandbox_image::Registry; use microsandbox_protocol::{ - exec::{ExecExited, ExecRequest, ExecRlimit, ExecStarted, ExecStderr, ExecStdin, ExecStdout}, + exec::{ExecRequest, ExecRlimit}, message::{Message, MessageType}, }; use sea_orm::{ ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set, sea_query::Expr, }; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::Mutex; use microsandbox_image::{ Digest, GlobalCache, PullOptions, PullProgressSender, PullResult, Reference, ext4, filetree, @@ -40,17 +39,13 @@ use microsandbox_image::{ use crate::{ MicrosandboxResult, agent::AgentClient, - db::{ - self, - entity::{ - run as run_entity, sandbox as sandbox_entity, sandbox_rootfs as sandbox_rootfs_entity, - }, + db::entity::{ + run as run_entity, sandbox as sandbox_entity, sandbox_rootfs as sandbox_rootfs_entity, }, runtime::{ProcessHandle, SpawnMode, spawn_sandbox}, }; -use self::attach::AttachOptions; -use self::exec::{ExecEvent, ExecHandle, ExecOptions, ExecSink, StdinMode}; +use self::exec::{ExecHandle, ExecOptions}; //-------------------------------------------------------------------------------------------------- // Re-Exports @@ -94,12 +89,18 @@ pub(crate) struct RegistryOverrides { /// /// Created via [`Sandbox::builder`] or [`Sandbox::create`]. Provides /// lifecycle management and access to the agent bridge for guest communication. +/// +/// Per the SDK local-cloud parity plan (D6.4) `Sandbox` is a single type +/// regardless of backend. It holds an [`Arc`](crate::backend::Backend) +/// to route lifecycle ops through, and a backend-private +/// [`SandboxInner`](crate::backend::SandboxInner) enum carrying variant-specific +/// state. Users reach variant data via [`Sandbox::local`] / [`Sandbox::cloud`]. #[derive(Clone)] pub struct Sandbox { - db_id: i32, + backend: Arc, + inner: Arc, + name: String, config: SandboxConfig, - handle: Option>>, - client: Arc, } //-------------------------------------------------------------------------------------------------- @@ -114,26 +115,40 @@ impl Sandbox { /// Create a sandbox from a config. /// - /// Boots the VM with agentd ready to accept commands. Does not run - /// any user workload — use `exec()`, `shell()`, etc. afterward. + /// Routes through the ambient [`default_backend`](crate::backend::default_backend) + /// so a cloud profile will dispatch to `CloudBackend` instead of the local + /// libkrun runtime. The returned [`Sandbox`] always carries the backend it + /// was created on; subsequent method calls keep using that backend. pub async fn create(config: SandboxConfig) -> MicrosandboxResult { - Self::create_with_mode(config, SpawnMode::Attached, None).await + let backend = crate::backend::default_backend(); + backend + .sandboxes() + .create(backend.clone(), config, true) + .await } /// Create a sandbox that must survive after the creating process exits. /// /// This is intended for detached CLI workflows such as `msb create` and /// `msb run --detach`, where the sandbox should keep running in the - /// background after the command returns. + /// background after the command returns. Routes through the ambient + /// [`default_backend`](crate::backend::default_backend). pub async fn create_detached(config: SandboxConfig) -> MicrosandboxResult { - Self::create_with_mode(config, SpawnMode::Detached, None).await + let backend = crate::backend::default_backend(); + backend + .sandboxes() + .create_detached(backend.clone(), config) + .await } /// Create a sandbox with pull progress reporting. /// /// Returns a progress handle for per-layer pull events and a task handle /// for the sandbox creation result. The caller should consume progress - /// events until the channel closes, then await the task. + /// events until the channel closes, then await the task. **Local backend + /// only** — pull progress is a local concept (cloud workers handle image + /// pulls server-side); on a cloud backend this falls back to a no-progress + /// create with an immediately-closed channel. pub fn create_with_pull_progress( config: SandboxConfig, ) -> ( @@ -164,292 +179,583 @@ impl Sandbox { tokio::task::JoinHandle>, ) { let (handle, sender) = progress_channel(); - let task = - tokio::spawn(async move { Self::create_with_mode(config, mode, Some(sender)).await }); + let task = tokio::spawn(async move { + // Pull progress is local-only; ignore the channel on non-local + // backends and dispatch through the trait without progress events. + let backend = crate::backend::default_backend(); + match backend.kind() { + crate::backend::BackendKind::Local => { + create_local(backend, config, mode, Some(sender)).await + } + crate::backend::BackendKind::Cloud => { + drop(sender); // close the channel — no per-layer events for cloud. + backend + .sandboxes() + .create(backend.clone(), config, true) + .await + } + } + }); (handle, task) } /// Start an existing stopped sandbox from persisted state. /// /// Reuses the serialized sandbox config and pinned rootfs state without - /// re-resolving the original OCI reference. + /// re-resolving the original OCI reference. Routes through the ambient + /// [`default_backend`](crate::backend::default_backend). pub async fn start(name: &str) -> MicrosandboxResult { - Self::start_with_mode(name, SpawnMode::Attached).await + let backend = crate::backend::default_backend(); + backend.sandboxes().start(backend.clone(), name).await } /// Start an existing sandbox in detached/background mode. pub async fn start_detached(name: &str) -> MicrosandboxResult { - Self::start_with_mode(name, SpawnMode::Detached).await + let backend = crate::backend::default_backend(); + backend + .sandboxes() + .start_detached(backend.clone(), name) + .await } - pub(crate) async fn create_with_mode( - mut config: SandboxConfig, - mode: SpawnMode, - progress: Option, - ) -> MicrosandboxResult { - tracing::debug!( - sandbox = %config.name, - image = ?config.image, - mode = ?mode, - cpus = config.cpus, - memory_mib = config.memory_mib, - "create_with_mode: starting" - ); + /// Get a sandbox handle by name. Routes through the ambient + /// [`default_backend`](crate::backend::default_backend). + pub async fn get(name: &str) -> MicrosandboxResult { + let backend = crate::backend::default_backend(); + backend.sandboxes().get(backend.clone(), name).await + } - let mut pinned_manifest_digest: Option = None; - let mut pinned_reference: Option = None; + /// List sandboxes via the ambient + /// [`default_backend`](crate::backend::default_backend). Pagination args + /// are forwarded to cloud; local backends ignore them. + pub async fn list() -> MicrosandboxResult> { + let backend = crate::backend::default_backend(); + let page = backend + .sandboxes() + .list(backend.clone(), None, None) + .await?; + Ok(page.sandboxes) + } - config.apply_runtime_defaults(); - validate_rootfs_source(&config.image)?; + /// Remove a stopped sandbox by name via the ambient + /// [`default_backend`](crate::backend::default_backend). + pub async fn remove(name: &str) -> MicrosandboxResult<()> { + let backend = crate::backend::default_backend(); + backend.sandboxes().remove(backend.clone(), name).await + } +} - // Initialize the database before any expensive image pull so we can - // fail fast on conflicting persisted sandbox state. - let db = db::init_global().await?; - let sandbox_dir = crate::config::config().sandboxes_dir().join(&config.name); - prepare_create_target(db, &config, &sandbox_dir).await?; +//-------------------------------------------------------------------------------------------------- +// Methods: Construction helpers +//-------------------------------------------------------------------------------------------------- - // Resolve OCI images before spawning the sandbox process. - if let RootfsSource::Oci(reference) = config.image.clone() { - let overrides = RegistryOverrides { - auth: config.registry_auth.clone(), - insecure: config.insecure, - ca_certs: config.ca_certs.clone(), - }; - let pull_result = - pull_oci_image(&reference, config.pull_policy, overrides, progress).await?; +impl Sandbox { + /// Build an outer `Sandbox` from local-variant inner state. + pub(crate) fn from_local( + backend: Arc, + local: crate::backend::SandboxLocalState, + config: SandboxConfig, + ) -> Self { + Self { + backend, + inner: Arc::new(crate::backend::SandboxInner::Local(local)), + name: config.name.clone(), + config, + } + } - // Merge image config defaults under user-provided config. - config.merge_image_defaults(&pull_result.config); + /// Build an outer `Sandbox` from a [`CloudSandbox`](crate::backend::CloudSandbox) + /// HTTP response plus the originating [`SandboxConfig`]. + pub(crate) fn from_cloud( + backend: Arc, + cloud: crate::backend::CloudSandbox, + config: SandboxConfig, + ) -> Self { + Self { + backend, + inner: Arc::new(crate::backend::SandboxInner::Cloud( + crate::backend::SandboxCloudState { + id: cloud.id, + org_id: cloud.org_id, + created_at: cloud.created_at, + }, + )), + name: cloud.name, + config, + } + } +} - pinned_manifest_digest = Some(pull_result.manifest_digest.to_string()); - pinned_reference = Some(reference.clone()); +//-------------------------------------------------------------------------------------------------- +// Functions: Local lifecycle (called from the LocalBackend SandboxBackend impl) +//-------------------------------------------------------------------------------------------------- - // Verify VMDK exists in the global cache. - let cache_dir = crate::config::config().cache_dir(); - let cache = GlobalCache::new_async(&cache_dir).await?; +/// Local create path. Returns a complete [`Sandbox`] wrapping the supplied +/// backend Arc. Called from the [`SandboxBackend`](crate::backend::SandboxBackend) +/// trait impl on [`LocalBackend`](crate::backend::LocalBackend) and from the +/// local pull-progress shim on [`Sandbox`]. +pub(crate) async fn create_local( + backend: Arc, + mut config: SandboxConfig, + mode: SpawnMode, + progress: Option, +) -> MicrosandboxResult { + tracing::debug!( + sandbox = %config.name, + image = ?config.image, + mode = ?mode, + cpus = config.cpus, + memory_mib = config.memory_mib, + "create_local: starting" + ); - let vmdk_path = cache.vmdk_path(&pull_result.manifest_digest); - if tokio::fs::metadata(&vmdk_path).await.is_err() { - return Err(crate::MicrosandboxError::Custom(format!( - "VMDK not materialized: {}", - vmdk_path.display() - ))); - } + let local_backend = + backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "create_local".into(), + available_when: "with a LocalBackend".into(), + })?; + + let mut pinned_manifest_digest: Option = None; + let mut pinned_reference: Option = None; + + config.apply_runtime_defaults(); + validate_rootfs_source(&config.image)?; + + // Initialize the database before any expensive image pull so we can + // fail fast on conflicting persisted sandbox state. + let db = local_backend.db().await?; + let sandbox_dir = local_backend.sandboxes_dir().join(&config.name); + prepare_create_target(db, &config, &sandbox_dir).await?; + + // Resolve OCI images before spawning the sandbox process. + if let RootfsSource::Oci(reference) = config.image.clone() { + let overrides = RegistryOverrides { + auth: config.registry_auth.clone(), + insecure: config.insecure, + ca_certs: config.ca_certs.clone(), + }; + let pull_result = pull_oci_image( + local_backend, + &reference, + config.pull_policy, + overrides, + progress, + ) + .await?; - // For patches, pass per-layer EROFS paths. - let layer_erofs_paths: Vec = pull_result - .layer_diff_ids - .iter() - .map(|d| cache.layer_erofs_path(d)) - .collect(); - - let upper_tree = if !config.patches.is_empty() { - Some(patch::build_upper_tree(&config.patches, &layer_erofs_paths).await?) - } else { - None - }; + // Merge image config defaults under user-provided config. + config.merge_image_defaults(&pull_result.config); - // Create upper.ext4 for the writable overlay upper layer. - tokio::fs::create_dir_all(&sandbox_dir).await?; - let upper_path = sandbox_dir.join("upper.ext4"); - if let Some(snap_upper) = config.snapshot_upper_source.take() { - // Booting from a snapshot: copy the captured upper into - // place, preserving sparseness. Patches are not - // compatible with this path because they'd need to be - // re-baked into the snapshot's upper, which we don't do. - if upper_tree.is_some() { - return Err(crate::MicrosandboxError::InvalidConfig( - "patches cannot be combined with from_snapshot".into(), - )); - } - let dst = upper_path.clone(); - tokio::task::spawn_blocking(move || { - microsandbox_utils::copy::fast_copy(&snap_upper, &dst) - }) - .await - .map_err(|e| { - crate::MicrosandboxError::Custom(format!("snapshot copy task: {e}")) - })??; - } else if !upper_path.exists() || upper_tree.is_some() { - create_upper_ext4(&upper_path, upper_tree).await?; + pinned_manifest_digest = Some(pull_result.manifest_digest.to_string()); + pinned_reference = Some(reference.clone()); + + // Verify VMDK exists in the global cache. + let cache_dir = local_backend.cache_dir(); + let cache = GlobalCache::new_async(&cache_dir).await?; + + let vmdk_path = cache.vmdk_path(&pull_result.manifest_digest); + if tokio::fs::metadata(&vmdk_path).await.is_err() { + return Err(crate::MicrosandboxError::Custom(format!( + "VMDK not materialized: {}", + vmdk_path.display() + ))); + } + + // For patches, pass per-layer EROFS paths. + let layer_erofs_paths: Vec = pull_result + .layer_diff_ids + .iter() + .map(|d| cache.layer_erofs_path(d)) + .collect(); + + let upper_tree = if !config.patches.is_empty() { + Some(patch::build_upper_tree(&config.patches, &layer_erofs_paths).await?) + } else { + None + }; + + // Create upper.ext4 for the writable overlay upper layer. + tokio::fs::create_dir_all(&sandbox_dir).await?; + let upper_path = sandbox_dir.join("upper.ext4"); + if let Some(snap_upper) = config.snapshot_upper_source.take() { + // Booting from a snapshot: copy the captured upper into + // place, preserving sparseness. Patches are not + // compatible with this path because they'd need to be + // re-baked into the snapshot's upper, which we don't do. + if upper_tree.is_some() { + return Err(crate::MicrosandboxError::InvalidConfig( + "patches cannot be combined with from_snapshot".into(), + )); } + let dst = upper_path.clone(); + tokio::task::spawn_blocking(move || { + microsandbox_utils::copy::fast_copy(&snap_upper, &dst) + }) + .await + .map_err(|e| crate::MicrosandboxError::Custom(format!("snapshot copy task: {e}")))??; + } else if !upper_path.exists() || upper_tree.is_some() { + create_upper_ext4(&upper_path, upper_tree).await?; + } - // Store manifest digest for spawn to derive paths. - config.manifest_digest = Some(pull_result.manifest_digest.to_string()); - - // Persist full image metadata to database. - if let Ok(image_ref) = reference.parse::() { - match cache.read_image_metadata_async(&image_ref).await { - Ok(Some(metadata)) => { - if let Err(e) = crate::image::Image::persist(&reference, metadata).await { - tracing::warn!( - error = %e, - "failed to persist image metadata to database" - ); - } - } - Ok(None) => {} - Err(e) => { - tracing::warn!(error = %e, "failed to read cached image metadata"); + // Store manifest digest for spawn to derive paths. + config.manifest_digest = Some(pull_result.manifest_digest.to_string()); + + // Persist full image metadata to database. + if let Ok(image_ref) = reference.parse::() { + match cache.read_image_metadata_async(&image_ref).await { + Ok(Some(metadata)) => { + if let Err(e) = + crate::image::Image::persist(local_backend, &reference, metadata).await + { + tracing::warn!( + error = %e, + "failed to persist image metadata to database" + ); } } + Ok(None) => {} + Err(e) => { + tracing::warn!(error = %e, "failed to read cached image metadata"); + } } } + } - // Apply rootfs patches before VM start (bind mounts only — OCI patches - // are baked into upper.ext4 above). - if !config.patches.is_empty() && !matches!(config.image, RootfsSource::Oci(_)) { - patch::apply_patches(&config.image, &config.patches).await?; - } + // Apply rootfs patches before VM start (bind mounts only — OCI patches + // are baked into upper.ext4 above). + if !config.patches.is_empty() && !matches!(config.image, RootfsSource::Oci(_)) { + patch::apply_patches(&config.image, &config.patches).await?; + } - // Insert the sandbox record and keep its stable database ID. - let write_db = db.write(); - let sandbox_id = insert_sandbox_record(write_db, &config).await?; - tracing::debug!(sandbox_id, sandbox = %config.name, "create_with_mode: db record inserted"); + // Insert the sandbox record and keep its stable database ID. + let write_db = db.write(); + let sandbox_id = insert_sandbox_record(write_db, &config).await?; + tracing::debug!(sandbox_id, sandbox = %config.name, "create_local: db record inserted"); - // Spawn the sandbox process and create the bridge. On failure, mark the sandbox - // as stopped so it doesn't appear as a phantom "Running" entry. - let sandbox = match Self::create_inner(config, sandbox_id, mode).await { - Ok(sandbox) => sandbox, + // Spawn the sandbox process and create the bridge. On failure, mark the sandbox + // as stopped so it doesn't appear as a phantom "Running" entry. + let (local_state, returned_config) = + match create_inner_local(local_backend, config, sandbox_id, mode).await { + Ok(pair) => pair, Err(e) => { let _ = update_sandbox_status(write_db, sandbox_id, SandboxStatus::Stopped).await; return Err(e); } }; + let sandbox = Sandbox::from_local(backend.clone(), local_state, returned_config); - if let (Some(_reference), Some(manifest_digest)) = ( - pinned_reference.as_deref(), - pinned_manifest_digest.as_deref(), - ) && let Err(err) = persist_oci_manifest_pin(write_db, sandbox_id, manifest_digest).await - { - let _ = sandbox.stop().await; - let _ = update_sandbox_status(write_db, sandbox_id, SandboxStatus::Stopped).await; - return Err(err); - } + if let (Some(_reference), Some(manifest_digest)) = ( + pinned_reference.as_deref(), + pinned_manifest_digest.as_deref(), + ) && let Err(err) = persist_oci_manifest_pin(write_db, sandbox_id, manifest_digest).await + { + let _ = sandbox.stop().await; + let _ = update_sandbox_status(write_db, sandbox_id, SandboxStatus::Stopped).await; + return Err(err); + } - // Validate that the configured workdir exists inside the guest. - if let Some(ref workdir) = sandbox.config.workdir - && !sandbox.fs().exists(workdir).await.unwrap_or(false) - { - let _ = sandbox.stop().await; - let _ = update_sandbox_status(write_db, sandbox_id, SandboxStatus::Stopped).await; - return Err(crate::MicrosandboxError::InvalidConfig(format!( - "workdir does not exist in guest: {workdir}" - ))); - } + // Validate that the configured workdir exists inside the guest. + if let Some(ref workdir) = sandbox.config.workdir + && !sandbox.fs().exists(workdir).await.unwrap_or(false) + { + let _ = sandbox.stop().await; + let _ = update_sandbox_status(write_db, sandbox_id, SandboxStatus::Stopped).await; + return Err(crate::MicrosandboxError::InvalidConfig(format!( + "workdir does not exist in guest: {workdir}" + ))); + } + + Ok(sandbox) +} - Ok(sandbox) +/// Local start path. Returns a complete [`Sandbox`] wrapping the supplied +/// backend Arc. +pub(crate) async fn start_local( + backend: Arc, + name: &str, + mode: SpawnMode, +) -> MicrosandboxResult { + tracing::debug!(sandbox = name, ?mode, "start_local: loading record"); + let local_backend = + backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "start_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let pools = local_backend.db().await?; + let write_db = pools.write(); + let model = load_sandbox_record_reconciled(pools, name).await?; + tracing::debug!(sandbox = name, status = ?model.status, "start_local: current status"); + + if model.status == SandboxStatus::Running || model.status == SandboxStatus::Draining { + return Err(crate::MicrosandboxError::SandboxStillRunning(format!( + "cannot start sandbox '{name}': already running" + ))); } - pub(super) async fn start_with_mode(name: &str, mode: SpawnMode) -> MicrosandboxResult { - tracing::debug!(sandbox = name, ?mode, "start_with_mode: loading record"); - let pools = db::init_global().await?; - let write_db = pools.write(); - let model = load_sandbox_record_reconciled(pools, name).await?; - tracing::debug!(sandbox = name, status = ?model.status, "start_with_mode: current status"); + if model.status != SandboxStatus::Stopped && model.status != SandboxStatus::Crashed { + return Err(crate::MicrosandboxError::Custom(format!( + "cannot start sandbox '{name}': status is {:?} (expected Stopped or Crashed)", + model.status + ))); + } - if model.status == SandboxStatus::Running || model.status == SandboxStatus::Draining { - return Err(crate::MicrosandboxError::SandboxStillRunning(format!( - "cannot start sandbox '{name}': already running" - ))); - } + let mut config: SandboxConfig = serde_json::from_str(&model.config)?; + config.apply_runtime_defaults(); + validate_rootfs_source(&config.image)?; + validate_start_state( + local_backend, + &config, + &local_backend.sandboxes_dir().join(name), + )?; + update_sandbox_status(write_db, model.id, SandboxStatus::Running).await?; - if model.status != SandboxStatus::Stopped && model.status != SandboxStatus::Crashed { - return Err(crate::MicrosandboxError::Custom(format!( - "cannot start sandbox '{name}': status is {:?} (expected Stopped or Crashed)", - model.status - ))); + match create_inner_local(local_backend, config, model.id, mode).await { + Ok((local_state, returned_config)) => { + Ok(Sandbox::from_local(backend, local_state, returned_config)) } - - let mut config: SandboxConfig = serde_json::from_str(&model.config)?; - config.apply_runtime_defaults(); - validate_rootfs_source(&config.image)?; - validate_start_state(&config, &crate::config::config().sandboxes_dir().join(name))?; - update_sandbox_status(write_db, model.id, SandboxStatus::Running).await?; - - match Self::create_inner(config, model.id, mode).await { - Ok(sandbox) => Ok(sandbox), - Err(err) => { - let _ = update_sandbox_status(write_db, model.id, SandboxStatus::Stopped).await; - Err(err) - } + Err(err) => { + let _ = update_sandbox_status(write_db, model.id, SandboxStatus::Stopped).await; + Err(err) } } +} - /// Inner create logic separated for error-cleanup wrapper. - async fn create_inner( - config: SandboxConfig, - sandbox_id: i32, - mode: SpawnMode, - ) -> MicrosandboxResult { - let (mut handle, agent_sock_path) = spawn_sandbox(&config, sandbox_id, mode).await?; - - // Wait for the relay socket to become available. - let client = wait_for_relay(&agent_sock_path, &mut handle, &config.name).await?; - - let ready = client.ready(); - tracing::info!( - boot_time_ms = ready.boot_time_ns / 1_000_000, - init_time_ms = ready.init_time_ns / 1_000_000, - ready_time_ms = ready.ready_time_ns / 1_000_000, - "sandbox ready", - ); - Ok(Self { +/// Inner local create logic separated for error-cleanup wrapper. Returns +/// the local-variant state plus the (possibly mutated) config. +async fn create_inner_local( + local: &crate::backend::LocalBackend, + config: SandboxConfig, + sandbox_id: i32, + mode: SpawnMode, +) -> MicrosandboxResult<(crate::backend::SandboxLocalState, SandboxConfig)> { + let (mut handle, agent_sock_path) = spawn_sandbox(local, &config, sandbox_id, mode).await?; + + // Wait for the relay socket to become available. + let client = wait_for_relay(&agent_sock_path, &mut handle, &config.name).await?; + + let ready = client.ready(); + tracing::info!( + boot_time_ms = ready.boot_time_ns / 1_000_000, + init_time_ms = ready.init_time_ns / 1_000_000, + ready_time_ms = ready.ready_time_ns / 1_000_000, + "sandbox ready", + ); + Ok(( + crate::backend::SandboxLocalState { db_id: sandbox_id, - config, handle: Some(Arc::new(Mutex::new(handle))), client: Arc::new(client), - }) - } + }, + config, + )) +} - /// Get a sandbox handle by name from the database. - pub async fn get(name: &str) -> MicrosandboxResult { - let pools = db::init_global().await?; +/// Load the local DB row + active PID for a sandbox handle. Called from the +/// `SandboxBackend::get` impl on `LocalBackend`. +pub(crate) async fn get_local_handle_state( + local_backend: &crate::backend::LocalBackend, + name: &str, +) -> MicrosandboxResult<(sandbox_entity::Model, Option)> { + let pools = local_backend.db().await?; + let model = sandbox_entity::Entity::find() + .filter(sandbox_entity::Column::Name.eq(name)) + .one(pools.read()) + .await? + .ok_or_else(|| crate::MicrosandboxError::SandboxNotFound(name.into()))?; + let model = reconcile_sandbox_runtime_state(pools, model).await?; + let run = load_active_run(pools.read(), model.id).await?; + let pid = pid_from_run(run.as_ref()); + Ok((model, pid)) +} - let model = sandbox_entity::Entity::find() - .filter(sandbox_entity::Column::Name.eq(name)) - .one(pools.read()) - .await? - .ok_or_else(|| crate::MicrosandboxError::SandboxNotFound(name.into()))?; +/// Load all local DB rows + their active PIDs. Called from the +/// `SandboxBackend::list` impl on `LocalBackend`. +pub(crate) async fn list_local_handle_state( + local_backend: &crate::backend::LocalBackend, +) -> MicrosandboxResult)>> { + let pools = local_backend.db().await?; + let sandboxes = sandbox_entity::Entity::find() + .order_by_desc(sandbox_entity::Column::CreatedAt) + .all(pools.read()) + .await?; - let model = reconcile_sandbox_runtime_state(pools, model).await?; - build_handle(pools.read(), model).await + let mut reconciled = Vec::with_capacity(sandboxes.len()); + for sandbox in sandboxes { + let model = reconcile_sandbox_runtime_state(pools, sandbox).await?; + reconciled.push(model); } - /// List all sandboxes from the database. - pub async fn list() -> MicrosandboxResult> { - let pools = db::init_global().await?; + let sandbox_ids: Vec = reconciled.iter().map(|sandbox| sandbox.id).collect(); + let active_pids = load_active_pids(pools.read(), &sandbox_ids).await?; + let mut out = Vec::with_capacity(reconciled.len()); + for sandbox in reconciled { + let pid = active_pids.get(&sandbox.id).copied(); + out.push((sandbox, pid)); + } + Ok(out) +} - let sandboxes = sandbox_entity::Entity::find() - .order_by_desc(sandbox_entity::Column::CreatedAt) - .all(pools.read()) - .await?; +/// Local lifecycle: remove a stopped sandbox by name. +pub(crate) async fn remove_local( + backend: Arc, + name: &str, +) -> MicrosandboxResult<()> { + let local_backend = + backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "remove_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let (model, pid) = get_local_handle_state(local_backend, name).await?; + let handle = SandboxHandle::from_local_model(backend, model, pid); + handle.remove().await +} + +/// Local lifecycle: stop a sandbox by name. +/// +/// Prefers the agent UDS at `sandboxes_dir//runtime/agent.sock`: +/// connects, sends `MessageType::Shutdown`, and lets agentd run an in-guest +/// `sync()` + `reboot(RB_POWER_OFF)` so ext4 unmounts cleanly (no journal +/// replay on next boot). Falls back to SIGTERM via PID if the socket is +/// unreachable (agentd wedged, sandbox just transitioning, etc.). +/// +/// No-op when the sandbox isn't in Running/Draining. +pub(crate) async fn stop_local( + backend: Arc, + name: &str, +) -> MicrosandboxResult<()> { + let local_backend = + backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "stop_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let (model, pid) = get_local_handle_state(local_backend, name).await?; + if model.status != SandboxStatus::Running && model.status != SandboxStatus::Draining { + return Ok(()); + } - let mut reconciled = Vec::with_capacity(sandboxes.len()); - for sandbox in sandboxes { - let model = reconcile_sandbox_runtime_state(pools, sandbox).await?; - reconciled.push(model); + // Try the clean-shutdown path: connect to the agent relay UDS and send + // `core.shutdown`. agentd runs `sync()` + `reboot(RB_POWER_OFF)` so + // block-root filesystems unmount cleanly. + let sock_path = local_backend + .sandboxes_dir() + .join(name) + .join("runtime") + .join("agent.sock"); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + match AgentClient::connect(&sock_path, deadline).await { + Ok(client) => { + let msg = Message::new(MessageType::Shutdown, 0, Vec::new()); + client.send(&msg).await?; + Ok(()) } + Err(e) => { + // Graceful degradation: agent UDS unreachable (socket missing, + // ECONNREFUSED, handshake timeout). Fall back to SIGTERM via PID + // so we still attempt a stop — at the cost of skipping the + // in-guest sync(). The reaper updates DB status on PID exit. + tracing::warn!( + sandbox = %name, + sock = %sock_path.display(), + error = %e, + "stop_local: agent UDS unreachable; falling back to SIGTERM", + ); + if let Some(pid) = pid.filter(|p| pid_is_alive(*p)) { + nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid), + nix::sys::signal::Signal::SIGTERM, + )?; + } + Ok(()) + } + } +} - let sandbox_ids: Vec = reconciled.iter().map(|sandbox| sandbox.id).collect(); - let active_pids = load_active_pids(pools.read(), &sandbox_ids).await?; - let mut handles = Vec::with_capacity(reconciled.len()); - for sandbox in reconciled { - handles.push(build_handle_with_pid( - sandbox.clone(), - active_pids.get(&sandbox.id).copied(), - )); +/// Local lifecycle: kill a sandbox by name (SIGKILL). +/// +/// Destructive by design — no clean-shutdown path. Signals SIGKILL to the +/// libkrun PID, waits briefly for the process to exit, then marks the DB +/// row Stopped if all signalled PIDs are confirmed dead. +pub(crate) async fn kill_local( + backend: Arc, + name: &str, +) -> MicrosandboxResult<()> { + let local_backend = + backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "kill_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let (model, pid) = get_local_handle_state(local_backend, name).await?; + if model.status != SandboxStatus::Running && model.status != SandboxStatus::Draining { + return Ok(()); + } + + let mut pids = Vec::new(); + if let Some(pid) = pid.filter(|p| pid_is_alive(*p)) { + nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid), + nix::sys::signal::Signal::SIGKILL, + )?; + pids.push(pid); + } + + if !pids.is_empty() { + let timeout = std::time::Duration::from_secs(5); + let start = std::time::Instant::now(); + let poll_interval = std::time::Duration::from_millis(50); + while start.elapsed() < timeout { + if pids.iter().all(|pid| !pid_is_alive(*pid)) { + break; + } + tokio::time::sleep(poll_interval).await; } + } - Ok(handles) + let all_dead = pids.is_empty() || pids.iter().all(|pid| !pid_is_alive(*pid)); + if all_dead { + let db = local_backend.db().await?.write(); + if let Err(e) = update_sandbox_status(db, model.id, SandboxStatus::Stopped).await { + tracing::warn!(sandbox = %name, error = %e, "failed to update sandbox status after kill"); + } } - /// Remove a stopped sandbox from the database. - /// - /// Convenience method equivalent to `Sandbox::get(name).await?.remove().await`. - pub async fn remove(name: &str) -> MicrosandboxResult<()> { - Self::get(name).await?.remove().await + Ok(()) +} + +/// Local lifecycle: drain a running sandbox by name (SIGUSR1 to the +/// libkrun process). +/// +/// The agent protocol has no `Drain` message type — drain is purely +/// signal-based. The libkrun signal handler catches SIGUSR1, writes to the +/// exit event fd, exit observers run, and the process terminates. +pub(crate) async fn drain_local( + backend: Arc, + name: &str, +) -> MicrosandboxResult<()> { + let local_backend = + backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "drain_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let (_, pid) = get_local_handle_state(local_backend, name).await?; + if let Some(pid) = pid.filter(|p| pid_is_alive(*p)) { + nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid), + nix::sys::signal::Signal::SIGUSR1, + )?; } + Ok(()) } //-------------------------------------------------------------------------------------------------- @@ -458,15 +764,28 @@ impl Sandbox { impl Sandbox { /// Remove this sandbox's persisted state after it has fully stopped. - pub async fn remove_persisted(self) -> MicrosandboxResult<()> { - let pools = db::init_global().await?; - - remove_dir_if_exists( - &crate::config::config() - .sandboxes_dir() - .join(&self.config.name), - )?; - sandbox_entity::Entity::delete_by_id(self.db_id) + /// + /// Local backend only. Cloud sandboxes are removed via + /// [`Sandbox::remove`] / the backend trait's `remove` method (calling + /// this on a cloud sandbox returns `Unsupported` without performing any + /// work). + /// + /// Takes `&self` so the caller retains ownership across an + /// `Unsupported` error on cloud — the previous `self`-by-value + /// signature consumed the sandbox even on the failing path. + pub async fn remove_persisted(&self) -> MicrosandboxResult<()> { + let local = self.require_local("remove_persisted")?; + let local_backend = + self.backend + .as_local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: "Sandbox::remove_persisted on cloud".into(), + available_when: "never — cloud sandboxes are removed via the API".into(), + })?; + let pools = local_backend.db().await?; + + remove_dir_if_exists(&local_backend.sandboxes_dir().join(&self.name))?; + sandbox_entity::Entity::delete_by_id(local.db_id) .exec(pools.write()) .await?; @@ -475,7 +794,7 @@ impl Sandbox { /// Unique name identifying this sandbox. pub fn name(&self) -> &str { - &self.config.name + &self.name } /// The full configuration this sandbox was created with (image, cpus, @@ -484,59 +803,165 @@ impl Sandbox { &self.config } + /// Which backend variant this sandbox is bound to. Returns `Local` or + /// `Cloud` depending on how it was created. + pub fn backend_kind(&self) -> crate::backend::BackendKind { + self.backend.kind() + } + + /// The `Arc` this sandbox routes through. Useful when + /// invoking other backend resources (e.g. volumes) from a sandbox + /// reference. + pub fn backend(&self) -> &Arc { + &self.backend + } + + /// Local-only state accessor. Returns `Some` when this `Sandbox` was + /// created by the local libkrun backend. + pub fn local(&self) -> Option<&crate::backend::SandboxLocalState> { + match self.inner.as_ref() { + crate::backend::SandboxInner::Local(s) => Some(s), + crate::backend::SandboxInner::Cloud(_) => None, + } + } + + /// Cloud-only state accessor. Returns `Some` when this `Sandbox` was + /// created by the cloud backend. + pub fn cloud(&self) -> Option<&crate::backend::SandboxCloudState> { + match self.inner.as_ref() { + crate::backend::SandboxInner::Cloud(s) => Some(s), + crate::backend::SandboxInner::Local(_) => None, + } + } + + /// Same as [`Sandbox::local`] but returns a typed `Unsupported` error + /// for cloud sandboxes. Used by methods that have no cloud equivalent yet. + fn require_local( + &self, + method: &'static str, + ) -> MicrosandboxResult<&crate::backend::SandboxLocalState> { + self.local() + .ok_or_else(|| crate::MicrosandboxError::Unsupported { + feature: format!("Sandbox::{method}"), + available_when: "when cloud exec/fs/logs/metrics land".into(), + }) + } + + /// Live status from the backend. Always hits `backend.sandboxes().get(name)` + /// — there is no cached status on the outer struct, per the D6.4 + /// "fetch-live" policy. + /// + /// Each call is a separate round-trip (DB read for local, HTTP GET for + /// cloud). If you need to read multiple fields together (e.g. status + + /// last_error), call [`Sandbox::get`](Self::get) once and read off the + /// returned [`SandboxHandle`]'s `*_snapshot` accessors instead. + pub async fn status(&self) -> MicrosandboxResult { + let handle = self + .backend + .sandboxes() + .get(self.backend.clone(), &self.name) + .await?; + Ok(handle.status_snapshot()) + } + + /// Live last-error string from the backend, when any. Always hits the + /// backend, never reads a cached field. + /// + /// Each call is a separate round-trip. If you need this alongside + /// `status()`, fetch a fresh [`SandboxHandle`] via + /// [`Sandbox::get`](Self::get) once and read both off the snapshot. + pub async fn last_error(&self) -> MicrosandboxResult> { + let handle = self + .backend + .sandboxes() + .get(self.backend.clone(), &self.name) + .await?; + Ok(handle.last_error_snapshot()) + } + /// Read captured output from `exec.log` for this sandbox. /// - /// Backed by the on-disk JSON Lines file the runtime writes via the - /// relay tap (see `crates/runtime/lib/exec_log.rs`). Works on - /// running and stopped sandboxes alike — there is no protocol - /// traffic. Pass `LogOptions::default()` for "everything, - /// stdout+stderr". - pub fn logs(&self, opts: &LogOptions) -> MicrosandboxResult> { - logs::read_logs(self.name(), opts) - } - - /// Low-level access to the guest agent client. Use this for custom - /// extensions — prefer [`exec`](Self::exec), [`shell`](Self::shell), - /// and [`fs`](Self::fs) for standard operations. + /// Routes through the [`SandboxBackend`](crate::backend::SandboxBackend) + /// trait. Local reads the on-disk JSON Lines file the runtime writes via + /// the relay tap (`crates/runtime/lib/exec_log.rs`); cloud returns + /// `Unsupported` until cloud logs land. + pub async fn logs(&self, opts: &LogOptions) -> MicrosandboxResult> { + self.backend + .sandboxes() + .logs(self.backend.clone(), &self.name, opts) + .await + } + + /// Low-level access to the guest agent client. + /// + /// **Local-only**: panics if called on a cloud sandbox. Use + /// [`local()`](Self::local) to check first when calling from generic + /// code. The cloud variant has no `AgentClient` — the cloud worker owns + /// the in-VM bridge — so there is nothing to return. pub fn client(&self) -> &AgentClient { - &self.client + match self.local() { + Some(local) => &local.client, + None => { + panic!("Sandbox::client called on cloud sandbox — use sb.local() to check first") + } + } } /// Get a cloneable reference to the agent client. + /// + /// **Local-only**: panics if called on a cloud sandbox. Mirrors + /// [`client`](Self::client). pub fn client_arc(&self) -> Arc { - Arc::clone(&self.client) + match self.local() { + Some(local) => Arc::clone(&local.client), + None => panic!( + "Sandbox::client_arc called on cloud sandbox — use sb.local() to check first" + ), + } } /// Returns `true` if this sandbox handle owns the process lifecycle. /// /// When `true`, dropping this handle or calling [`stop`](Self::stop) - /// will terminate the sandbox. When `false`, the sandbox was created by - /// another process and will continue running after disconnect. + /// will terminate the sandbox. Cloud sandboxes never own a host process + /// — the cloud worker does — so this returns `false` for them. pub fn owns_lifecycle(&self) -> bool { - self.handle.is_some() + self.local().map(|s| s.handle.is_some()).unwrap_or(false) } /// Read, write, and manage files inside the running sandbox. - /// Operations go through the guest agent (agentd). + /// + /// Routes through the [`SandboxBackend`](crate::backend::SandboxBackend) + /// trait per-method, so this constructor is infallible. On cloud each + /// op returns `Unsupported` until cloud guest-fs lands; on local each + /// op routes through the agent protocol (`core.fs.*`). pub fn fs(&self) -> fs::SandboxFs<'_> { - fs::SandboxFs::new(&self.client) + fs::SandboxFs::new(self.backend.clone(), &self.name) } - /// Stop the sandbox gracefully by sending `core.shutdown` to agentd. + /// Stop the sandbox gracefully. + /// + /// Routes through the backend trait. On local this connects to the + /// agent UDS and sends `core.shutdown` (agentd runs `sync()` + + /// `reboot(RB_POWER_OFF)` for a clean ext4 unmount), falling back to + /// SIGTERM via PID if the socket is unreachable. On cloud this issues + /// `POST /v1/sandboxes/by-name/:name/stop`. pub async fn stop(&self) -> MicrosandboxResult<()> { - tracing::debug!(sandbox = %self.config.name, "stop: sending shutdown"); - let msg = Message::new(MessageType::Shutdown, 0, Vec::new()); - self.client.send(&msg).await + tracing::debug!(sandbox = %self.name, "stop: dispatching"); + self.backend + .sandboxes() + .stop(self.backend.clone(), &self.name) + .await } /// Stop the sandbox gracefully and wait for the process to exit. /// - /// If this handle does not own the lifecycle (connected to an existing - /// sandbox), only the stop signal is sent — wait is skipped since we - /// don't have a process handle to wait on. + /// **Local backend only.** Cloud sandboxes have no host process to wait + /// on; use [`stop`](Self::stop) and poll [`status`](Self::status) instead. pub async fn stop_and_wait(&self) -> MicrosandboxResult { + let local = self.require_local("stop_and_wait")?; let stop_result = self.stop().await; - if self.handle.is_none() { + if local.handle.is_none() { stop_result?; // No handle to wait on — return a synthetic success status. return Ok(std::process::ExitStatus::default()); @@ -547,28 +972,31 @@ impl Sandbox { } /// Kill the sandbox immediately (SIGKILL). + /// + /// Routes through the backend trait. On local the trait impl looks the + /// PID up from the DB and signals SIGKILL, then marks the row Stopped + /// once the process is confirmed dead. Cloud currently returns + /// `Unsupported`. pub async fn kill(&self) -> MicrosandboxResult<()> { - match &self.handle { - Some(h) => h.lock().await.kill(), - None => Err(crate::MicrosandboxError::Runtime( - "cannot kill: not the lifecycle owner".into(), - )), - } + self.backend + .sandboxes() + .kill(self.backend.clone(), &self.name) + .await } - /// Trigger a graceful drain (SIGUSR1). + /// Trigger a graceful drain (SIGUSR1 to the libkrun PID on local). + /// Cloud sandboxes currently return `Unsupported`. pub async fn drain(&self) -> MicrosandboxResult<()> { - match &self.handle { - Some(h) => h.lock().await.drain(), - None => Err(crate::MicrosandboxError::Runtime( - "cannot drain: not the lifecycle owner".into(), - )), - } + self.backend + .sandboxes() + .drain(self.backend.clone(), &self.name) + .await } - /// Wait for the sandbox process to exit. + /// Wait for the sandbox process to exit. **Local backend only.** pub async fn wait(&self) -> MicrosandboxResult { - match &self.handle { + let local = self.require_local("wait")?; + match &local.handle { Some(h) => h.lock().await.wait().await, None => Err(crate::MicrosandboxError::Runtime( "cannot wait: not the lifecycle owner".into(), @@ -580,9 +1008,12 @@ impl Sandbox { /// /// Disarms the SIGTERM safety net so the sandbox keeps running after /// this handle is dropped. Intended for CLI flows like `create`, `start`, - /// and `run --detach`. + /// and `run --detach`. No-op for cloud sandboxes (the cloud worker owns + /// the lifecycle regardless of this process). pub async fn detach(self) { - if let Some(h) = &self.handle { + if let crate::backend::SandboxInner::Local(local) = self.inner.as_ref() + && let Some(h) = &local.handle + { h.lock().await.disarm(); } // Normal drop runs — client reader task is aborted and @@ -609,7 +1040,16 @@ impl Sandbox { args: args.into_iter().map(Into::into).collect(), ..Default::default() }; - self.exec_stream_inner(cmd.into(), opts).await + self.backend + .sandboxes() + .exec_stream( + self.backend.clone(), + &self.name, + &self.config, + cmd.into(), + opts, + ) + .await } /// Execute a command with full options and return a streaming handle. @@ -623,86 +1063,16 @@ impl Sandbox { f: impl FnOnce(ExecOptionsBuilder) -> ExecOptionsBuilder, ) -> MicrosandboxResult { let opts = f(ExecOptionsBuilder::default()).build()?; - self.exec_stream_inner(cmd.into(), opts).await - } - - async fn exec_stream_inner( - &self, - cmd: String, - opts: ExecOptions, - ) -> MicrosandboxResult { - let ExecOptions { - args, - cwd, - user, - env, - rlimits, - tty, - stdin: stdin_mode, - timeout: _, - } = opts; - - tracing::debug!( - sandbox = %self.config.name, - cmd = %cmd, - args = ?args, - cwd = ?cwd, - tty, - "exec_stream" - ); - - // Allocate correlation ID and subscribe BEFORE sending. - let id = self.client.next_id(); - let rx = self.client.subscribe(id).await; - - let req = build_exec_request( - &self.config, - cmd, - args, - cwd, - user, - &env, - &rlimits, - tty, - 24, - 80, - ); - let msg = Message::with_payload(MessageType::ExecRequest, id, &req)?; - self.client.send(&msg).await?; - - // Build stdin sink (if Pipe mode). - let stdin = match &stdin_mode { - StdinMode::Pipe => Some(ExecSink::new(id, Arc::clone(&self.client))), - _ => None, - }; - - // Handle StdinMode::Bytes — send bytes then close. - if let StdinMode::Bytes(ref data) = stdin_mode { - let data = data.clone(); - let bridge = Arc::clone(&self.client); - tokio::spawn(async move { - let payload = ExecStdin { data }; - if let Ok(msg) = Message::with_payload(MessageType::ExecStdin, id, &payload) { - let _ = bridge.send(&msg).await; - } - // Send empty to signal EOF. - let close = ExecStdin { data: Vec::new() }; - if let Ok(msg) = Message::with_payload(MessageType::ExecStdin, id, &close) { - let _ = bridge.send(&msg).await; - } - }); - } - - // Transform raw protocol messages into ExecEvents. - let (event_tx, event_rx) = mpsc::unbounded_channel(); - tokio::spawn(event_mapper_task(rx, event_tx)); - - Ok(ExecHandle::new( - id, - event_rx, - stdin, - Arc::clone(&self.client), - )) + self.backend + .sandboxes() + .exec_stream( + self.backend.clone(), + &self.name, + &self.config, + cmd.into(), + opts, + ) + .await } /// Execute a command and wait for completion. @@ -719,7 +1089,16 @@ impl Sandbox { args: args.into_iter().map(Into::into).collect(), ..Default::default() }; - self.exec_with_opts(cmd.into(), opts).await + self.backend + .sandboxes() + .exec( + self.backend.clone(), + &self.name, + &self.config, + cmd.into(), + opts, + ) + .await } /// Execute a command with full options and wait for completion. @@ -733,36 +1112,16 @@ impl Sandbox { f: impl FnOnce(ExecOptionsBuilder) -> ExecOptionsBuilder, ) -> MicrosandboxResult { let opts = f(ExecOptionsBuilder::default()).build()?; - self.exec_with_opts(cmd.into(), opts).await - } - - /// Shared implementation for exec and exec_with. - async fn exec_with_opts( - &self, - cmd: String, - opts: ExecOptions, - ) -> MicrosandboxResult { - let timeout_duration = opts.timeout; - let mut handle = self.exec_stream_inner(cmd, opts).await?; - - match timeout_duration { - Some(duration) => { - match tokio::time::timeout(duration, handle.collect()).await { - Ok(result) => result, - Err(_) => { - // Timed out — kill the process and drain remaining events. - let _ = handle.kill().await; - let _ = tokio::time::timeout( - std::time::Duration::from_secs(5), - handle.collect(), - ) - .await; - Err(crate::MicrosandboxError::ExecTimeout(duration)) - } - } - } - None => handle.collect().await, - } + self.backend + .sandboxes() + .exec( + self.backend.clone(), + &self.name, + &self.config, + cmd.into(), + opts, + ) + .await } /// Run a shell command and wait for completion. @@ -773,8 +1132,20 @@ impl Sandbox { /// - `sandbox.shell("echo hello")` /// - `sandbox.shell("ENV=val cmd | other_cmd")` pub async fn shell(&self, script: impl Into) -> MicrosandboxResult { - let mut handle = self.shell_stream(script).await?; - handle.collect().await + let shell = self + .config + .shell + .as_deref() + .unwrap_or("/bin/sh") + .to_string(); + let opts = ExecOptions { + args: vec!["-c".to_string(), script.into()], + ..Default::default() + }; + self.backend + .sandboxes() + .exec(self.backend.clone(), &self.name, &self.config, shell, opts) + .await } /// Run a shell command with streaming I/O. @@ -782,12 +1153,20 @@ impl Sandbox { /// Like [`shell`](Self::shell) but returns a streaming [`ExecHandle`] /// instead of waiting for completion. pub async fn shell_stream(&self, script: impl Into) -> MicrosandboxResult { - let shell = self.config.shell.as_deref().unwrap_or("/bin/sh"); + let shell = self + .config + .shell + .as_deref() + .unwrap_or("/bin/sh") + .to_string(); let opts = ExecOptions { args: vec!["-c".to_string(), script.into()], ..Default::default() }; - self.exec_stream_inner(shell.to_string(), opts).await + self.backend + .sandboxes() + .exec_stream(self.backend.clone(), &self.name, &self.config, shell, opts) + .await } } @@ -806,11 +1185,20 @@ impl Sandbox { cmd: impl Into, args: impl IntoIterator>, ) -> MicrosandboxResult { - let opts = AttachOptions { - args: args.into_iter().map(Into::into).collect(), - ..Default::default() - }; - self.attach_inner(cmd.into(), opts).await + let mut builder = AttachOptionsBuilder::default(); + for arg in args { + builder = builder.arg(arg); + } + self.backend + .sandboxes() + .attach( + self.backend.clone(), + &self.name, + &self.config, + cmd.into(), + builder, + ) + .await } /// Attach to the sandbox with full options. @@ -823,225 +1211,38 @@ impl Sandbox { cmd: impl Into, f: impl FnOnce(AttachOptionsBuilder) -> AttachOptionsBuilder, ) -> MicrosandboxResult { - let opts = f(AttachOptionsBuilder::default()).build()?; - self.attach_inner(cmd.into(), opts).await - } - - /// Shared implementation for attach and attach_with. - async fn attach_inner(&self, cmd: String, opts: AttachOptions) -> MicrosandboxResult { - use std::os::fd::AsRawFd; - - use microsandbox_protocol::exec::ExecResize; - use tokio::io::{AsyncWriteExt, unix::AsyncFd}; - - let detach_keys = match &opts.detach_keys { - Some(spec) => attach::DetachKeys::parse(spec)?, - None => attach::DetachKeys::default_keys(), - }; - - // Get terminal size. - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - - // Allocate ID and subscribe. - let id = self.client.next_id(); - let mut rx = self.client.subscribe(id).await; - - // Build ExecRequest with tty=true. - let req = build_exec_request( - &self.config, - cmd, - opts.args, - opts.cwd, - opts.user, - &opts.env, - &opts.rlimits, - true, - rows, - cols, - ); - let msg = Message::with_payload(MessageType::ExecRequest, id, &req)?; - self.client.send(&msg).await?; - - // Enter raw mode. - crossterm::terminal::enable_raw_mode() - .map_err(|e| crate::MicrosandboxError::Terminal(e.to_string()))?; - let _raw_guard = scopeguard::guard((), |_| { - let _ = crossterm::terminal::disable_raw_mode(); - }); - - // Re-open the controlling terminal for input and set only that fresh - // fd non-blocking. Toggling O_NONBLOCK on fd 0 would also affect - // stdout/stderr when all three stdio fds share the same TTY open file - // description, which truncates large terminal writes. - let tty_input_path = terminal_path_for_fd(std::io::stdin().as_raw_fd()) - .map_err(|e| crate::MicrosandboxError::Terminal(format!("resolve tty path: {e}")))?; - let tty_input = open_nonblocking_terminal_input(&tty_input_path) - .map_err(|e| crate::MicrosandboxError::Terminal(format!("open tty input: {e}")))?; - let stdin_async = AsyncFd::new(tty_input) - .map_err(|e| crate::MicrosandboxError::Terminal(format!("async tty input: {e}")))?; - - // Set up async I/O. - let mut stdout = tokio::io::stdout(); - let mut sigwinch = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()) - .map_err(|e| crate::MicrosandboxError::Runtime(format!("sigwinch: {e}")))?; - - let mut exit_code: i32 = -1; - let mut spawn_failure: Option = None; - let detach_seq = detach_keys.sequence(); - let mut match_pos = 0usize; - - loop { - tokio::select! { - // Read stdin from host terminal (non-blocking fd). - result = stdin_async.readable() => { - let mut guard = match result { - Ok(g) => g, - Err(_) => break, - }; - - let mut input_buf = [0u8; 1024]; - match guard.try_io(|inner| { - read_from_fd(inner.get_ref().as_raw_fd(), &mut input_buf) - }) { - Ok(Ok(0)) => break, // EOF - Ok(Ok(n)) => { - let data = &input_buf[..n]; - - // Check for detach key sequence. - let mut detached = false; - for &b in data { - if b == detach_seq[match_pos] { - match_pos += 1; - if match_pos == detach_seq.len() { - detached = true; - break; - } - } else { - match_pos = 0; - if b == detach_seq[0] { - match_pos = 1; - } - } - } - - if detached { - break; - } - - // Forward to guest. - let payload = ExecStdin { data: data.to_vec() }; - if let Ok(msg) = Message::with_payload(MessageType::ExecStdin, id, &payload) { - let _ = self.client.send(&msg).await; - } - } - Ok(Err(e)) if e.kind() == std::io::ErrorKind::Interrupted => continue, - Ok(Err(_)) => break, - Err(_would_block) => continue, - } - } - - // Receive output from guest. - // - // TUI apps (e.g. Ink-based CLIs) write a full re-render as one - // write(), but the guest PTY reader chunks it into ~4 KB - // ExecStdout messages. Writing each chunk to the host terminal - // separately lets the terminal emulator render intermediate - // states — partial cursor movements, partially overwritten - // lines — producing visible afterimage artifacts. - // - // Fix: after receiving the first message, drain all immediately - // available ExecStdout messages and batch their data into a - // single write. This coalesces the output so the terminal - // processes each re-render atomically. - Some(msg) = rx.recv() => { - let mut should_break = false; - - match msg.t { - MessageType::ExecStdout => { - if let Ok(out) = msg.payload::() { - let _ = stdout.write_all(&out.data).await; - } - } - MessageType::ExecExited => { - if let Ok(exited) = msg.payload::() { - exit_code = exited.code; - } - should_break = true; - } - MessageType::ExecFailed => { - if let Ok(failed) = - msg.payload::() - { - spawn_failure = Some(failed); - } - should_break = true; - } - _ => {} - } - - // Drain all buffered messages before flushing. - if !should_break { - while let Ok(next) = rx.try_recv() { - match next.t { - MessageType::ExecStdout => { - if let Ok(out) = next.payload::() { - let _ = stdout.write_all(&out.data).await; - } - } - MessageType::ExecExited => { - if let Ok(exited) = next.payload::() { - exit_code = exited.code; - } - should_break = true; - break; - } - MessageType::ExecFailed => { - if let Ok(failed) = next - .payload::() - { - spawn_failure = Some(failed); - } - should_break = true; - break; - } - _ => {} - } - } - } - - let _ = stdout.flush().await; - - if should_break { - break; - } - } - - // Terminal resize. - _ = sigwinch.recv() => { - if let Ok((new_cols, new_rows)) = crossterm::terminal::size() { - let payload = ExecResize { rows: new_rows, cols: new_cols }; - if let Ok(msg) = Message::with_payload(MessageType::ExecResize, id, &payload) { - let _ = self.client.send(&msg).await; - } - } - } - } - } - - // Guards restore: non-blocking → blocking, raw mode → cooked. - if let Some(failure) = spawn_failure { - return Err(crate::MicrosandboxError::ExecFailed(failure)); - } - Ok(exit_code) + let builder = f(AttachOptionsBuilder::default()); + self.backend + .sandboxes() + .attach( + self.backend.clone(), + &self.name, + &self.config, + cmd.into(), + builder, + ) + .await } /// Attach to the sandbox's default shell. /// /// Uses the sandbox's configured shell (default: `/bin/sh`). pub async fn attach_shell(&self) -> MicrosandboxResult { - let shell = self.config.shell.as_deref().unwrap_or("/bin/sh"); - self.attach_inner(shell.into(), AttachOptions::default()) + let shell = self + .config + .shell + .as_deref() + .unwrap_or("/bin/sh") + .to_string(); + self.backend + .sandboxes() + .attach( + self.backend.clone(), + &self.name, + &self.config, + shell, + AttachOptionsBuilder::default(), + ) .await } } @@ -1166,18 +1367,9 @@ fn read_boot_error( .flatten() } -/// Build a [`SandboxHandle`] by eagerly loading the microVM PID. -async fn build_handle( - db: &DbReadConnection, - model: sandbox_entity::Model, -) -> MicrosandboxResult { - let run = load_active_run(db, model.id).await?; - Ok(build_handle_with_pid(model, pid_from_run(run.as_ref()))) -} - /// Build an `ExecRequest` by merging sandbox config with caller-provided overrides. #[allow(clippy::too_many_arguments)] -fn build_exec_request( +pub(crate) fn build_exec_request( config: &SandboxConfig, cmd: String, args: Vec, @@ -1232,7 +1424,7 @@ fn select_tty_term(term: Option<&str>) -> String { } } -fn terminal_path_for_fd(fd: std::os::fd::RawFd) -> std::io::Result { +pub(crate) fn terminal_path_for_fd(fd: std::os::fd::RawFd) -> std::io::Result { let mut buf = [0u8; 1024]; let rc = unsafe { libc::ttyname_r(fd, buf.as_mut_ptr().cast(), buf.len()) }; if rc != 0 { @@ -1254,7 +1446,9 @@ fn terminal_path_for_fd(fd: std::os::fd::RawFd) -> std::io::Result std::io::Result { +pub(crate) fn open_nonblocking_terminal_input( + path: &std::path::Path, +) -> std::io::Result { use std::os::fd::AsRawFd; let file = std::fs::File::open(path)?; @@ -1269,7 +1463,7 @@ fn open_nonblocking_terminal_input(path: &std::path::Path) -> std::io::Result std::io::Result { +pub(crate) fn read_from_fd(fd: std::os::fd::RawFd, buf: &mut [u8]) -> std::io::Result { let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; if n < 0 { Err(std::io::Error::last_os_error()) @@ -1278,61 +1472,6 @@ fn read_from_fd(fd: std::os::fd::RawFd, buf: &mut [u8]) -> std::io::Result, - tx: mpsc::UnboundedSender, -) { - while let Some(msg) = rx.recv().await { - let event = match msg.t { - MessageType::ExecStarted => { - if let Ok(started) = msg.payload::() { - ExecEvent::Started { pid: started.pid } - } else { - continue; - } - } - MessageType::ExecStdout => { - if let Ok(out) = msg.payload::() { - ExecEvent::Stdout(Bytes::from(out.data)) - } else { - continue; - } - } - MessageType::ExecStderr => { - if let Ok(err) = msg.payload::() { - ExecEvent::Stderr(Bytes::from(err.data)) - } else { - continue; - } - } - MessageType::ExecExited => { - if let Ok(exited) = msg.payload::() { - let _ = tx.send(ExecEvent::Exited { code: exited.code }); - } - break; - } - MessageType::ExecFailed => { - if let Ok(failed) = msg.payload::() { - let _ = tx.send(ExecEvent::Failed(failed)); - } - break; - } - MessageType::ExecStdinError => { - if let Ok(payload) = msg.payload::() { - ExecEvent::StdinError(payload) - } else { - continue; - } - } - _ => continue, - }; - if tx.send(event).is_err() { - break; - } - } -} - /// Update the sandbox status in the database. pub(super) async fn update_sandbox_status( db: &DbWriteConnection, @@ -1369,7 +1508,13 @@ pub(super) async fn update_sandbox_status( /// from updating the database on exit are cleaned up without blocking the /// main path. pub async fn reap_stale_sandboxes() -> MicrosandboxResult<()> { - let pools = db::init_global().await?; + let backend = crate::backend::default_backend(); + let local = match backend.as_local() { + Some(local) => local, + // No local backend installed — nothing to reap on this process. + None => return Ok(()), + }; + let pools = local.db().await?; let stale = sandbox_entity::Entity::find() .filter( @@ -1496,10 +1641,6 @@ async fn load_active_pids( Ok(pids) } -fn build_handle_with_pid(model: sandbox_entity::Model, pid: Option) -> SandboxHandle { - SandboxHandle::new(model, pid) -} - fn pid_from_run(run: Option<&run_entity::Model>) -> Option { run.and_then(|model| model.pid) .filter(|pid| pid_is_alive(*pid)) @@ -1574,13 +1715,14 @@ pub(super) fn pid_is_alive(pid: i32) -> bool { /// When `progress` is `Some`, uses `pull_with_sender()` to emit per-layer /// progress events. The caller must consume the corresponding `PullProgressHandle`. async fn pull_oci_image( + local_backend: &crate::backend::LocalBackend, reference: &str, pull_policy: PullPolicy, registry_overrides: RegistryOverrides, progress: Option, ) -> MicrosandboxResult { - let global = crate::config::config(); - let cache = GlobalCache::new(&global.cache_dir())?; + let global = local_backend.config(); + let cache = GlobalCache::new(&local_backend.cache_dir())?; let platform = microsandbox_image::Platform::host_linux(); let image_ref: Reference = reference.parse().map_err(|e| { crate::MicrosandboxError::InvalidConfig(format!("invalid image reference: {e}")) @@ -1866,7 +2008,11 @@ async fn wait_for_pids_to_exit(pids: &[i32], timeout: std::time::Duration) { } } -fn validate_start_state(config: &SandboxConfig, sandbox_dir: &Path) -> MicrosandboxResult<()> { +fn validate_start_state( + local_backend: &crate::backend::LocalBackend, + config: &SandboxConfig, + sandbox_dir: &Path, +) -> MicrosandboxResult<()> { if !sandbox_dir.exists() { return Err(crate::MicrosandboxError::Custom(format!( "sandbox state missing for '{}': {}", @@ -1878,7 +2024,7 @@ fn validate_start_state(config: &SandboxConfig, sandbox_dir: &Path) -> Microsand if let RootfsSource::Oci(_) = &config.image && let Some(ref digest_str) = config.manifest_digest { - let cache_dir = crate::config::config().cache_dir(); + let cache_dir = local_backend.cache_dir(); if let Ok(cache) = GlobalCache::new(&cache_dir) && let Ok(digest) = digest_str.parse::() { @@ -2525,7 +2671,8 @@ mod tests { ..Default::default() }; - let err = super::validate_start_state(&config, &sandbox_dir).unwrap_err(); + let backend = crate::backend::LocalBackend::lazy(); + let err = super::validate_start_state(&backend, &config, &sandbox_dir).unwrap_err(); assert!(err.to_string().contains("sandbox state missing")); } @@ -2546,7 +2693,8 @@ mod tests { // which depends on the global config. In unit tests without a real // config, it succeeds because the cache init may fail gracefully. // The key thing is it doesn't panic. - let _ = super::validate_start_state(&config, &sandbox_dir); + let backend = crate::backend::LocalBackend::lazy(); + let _ = super::validate_start_state(&backend, &config, &sandbox_dir); } /// Simulates the reaper sweep: queries all Running/Draining sandboxes and diff --git a/crates/microsandbox/lib/snapshot/archive.rs b/crates/microsandbox/lib/snapshot/archive.rs index a4aab503c..626f7f45f 100644 --- a/crates/microsandbox/lib/snapshot/archive.rs +++ b/crates/microsandbox/lib/snapshot/archive.rs @@ -13,15 +13,16 @@ use microsandbox_image::snapshot::MANIFEST_FILENAME; use tokio::io::BufReader; use tokio_tar::{Archive, Builder}; +use crate::backend::LocalBackend; use crate::{MicrosandboxError, MicrosandboxResult}; -use super::{Snapshot, SnapshotHandle, store}; +use super::{SnapshotHandle, store}; //-------------------------------------------------------------------------------------------------- // Types //-------------------------------------------------------------------------------------------------- -/// Options for [`Snapshot::export`]. +/// Options for [`super::Snapshot::export`]. #[derive(Debug, Clone, Default)] pub struct ExportOpts { /// Walk parent chain and include each ancestor in the archive. @@ -40,13 +41,14 @@ pub struct ExportOpts { /// Bundle a snapshot artifact (and optionally its ancestors / image /// cache) into an archive at `out`. pub(super) async fn export_snapshot( + local: &LocalBackend, name_or_path: &str, out: &Path, opts: ExportOpts, ) -> MicrosandboxResult<()> { // Collect the artifact dirs we need to ship: the head snapshot // and (optionally) all ancestors via parent_digest. - let head = Snapshot::open(name_or_path).await?; + let head = store::open_snapshot(local, name_or_path).await?; head.verify().await?; let mut dirs: Vec<(PathBuf, String)> = Vec::new(); let head_prefix = digest_prefix(head.digest()); @@ -55,8 +57,9 @@ pub(super) async fn export_snapshot( if opts.with_parents { let mut current = head.manifest().parent.clone(); while let Some(parent_digest) = current { - let parent_path = resolve_parent_artifact(&parent_digest).await?; - let parent = Snapshot::open(parent_path.to_string_lossy().as_ref()).await?; + let parent_path = resolve_parent_artifact(local, &parent_digest).await?; + let parent = + store::open_snapshot(local, parent_path.to_string_lossy().as_ref()).await?; parent.verify().await?; let prefix = digest_prefix(parent.digest()); dirs.push((parent.path().to_path_buf(), prefix)); @@ -67,7 +70,7 @@ pub(super) async fn export_snapshot( // Optional image cache bundling. let mut cache_files: Vec<(PathBuf, String)> = Vec::new(); if opts.with_image { - let cache_dir = crate::config::config().cache_dir(); + let cache_dir = local.cache_dir(); let img_digest_str = head.manifest().image.manifest_digest.clone(); let img_digest: microsandbox_image::Digest = img_digest_str .parse() @@ -137,15 +140,16 @@ pub(super) async fn export_snapshot( /// dir). Image-cache entries (`cache/...`) are routed into the global /// cache. Returns a handle for the head (last-listed) snapshot. pub(super) async fn import_snapshot( + local: &LocalBackend, archive: &Path, dest: Option<&Path>, ) -> MicrosandboxResult { let snapshots_dir = match dest { Some(d) => d.to_path_buf(), - None => crate::config::config().snapshots_dir(), + None => local.snapshots_dir(), }; tokio::fs::create_dir_all(&snapshots_dir).await?; - let cache_dir = crate::config::config().cache_dir(); + let cache_dir = local.cache_dir(); // Stream rather than slurp — archives carry the full upper layer and are // routinely multi-GB. @@ -173,11 +177,11 @@ pub(super) async fn import_snapshot( let head_path = head_dir.ok_or_else(|| { MicrosandboxError::Custom("archive contained no snapshot manifest".into()) })?; - let snap = Snapshot::open(head_path.to_string_lossy().as_ref()).await?; + let snap = store::open_snapshot(local, head_path.to_string_lossy().as_ref()).await?; snap.verify().await?; // Index this and any sibling artifacts that landed in the dest dir. - let _ = Snapshot::reindex(&snapshots_dir).await; + let _ = store::reindex_dir(local, &snapshots_dir).await; let format = match snap.manifest().format { microsandbox_image::snapshot::SnapshotFormat::Raw => super::SnapshotFormat::Raw, @@ -331,8 +335,11 @@ fn file_name_str(p: &Path) -> MicrosandboxResult { }) } -async fn resolve_parent_artifact(parent_digest: &str) -> MicrosandboxResult { - if let Some(handle) = store::lookup_by_digest(parent_digest).await? { +async fn resolve_parent_artifact( + local: &LocalBackend, + parent_digest: &str, +) -> MicrosandboxResult { + if let Some(handle) = store::lookup_by_digest(local, parent_digest).await? { return Ok(handle.artifact_path); } Err(MicrosandboxError::SnapshotNotFound(format!( diff --git a/crates/microsandbox/lib/snapshot/create.rs b/crates/microsandbox/lib/snapshot/create.rs index d5f89af6a..3a0b845b3 100644 --- a/crates/microsandbox/lib/snapshot/create.rs +++ b/crates/microsandbox/lib/snapshot/create.rs @@ -10,6 +10,7 @@ use microsandbox_image::snapshot::{ }; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use crate::backend::LocalBackend; use crate::db::entity::sandbox as sandbox_entity; use crate::sandbox::{SandboxConfig, SandboxStatus}; use crate::{MicrosandboxError, MicrosandboxResult}; @@ -21,7 +22,10 @@ use super::{Snapshot, SnapshotConfig, SnapshotDestination}; // Functions //-------------------------------------------------------------------------------------------------- -pub(super) async fn create_snapshot(config: SnapshotConfig) -> MicrosandboxResult { +pub(super) async fn create_snapshot( + local: &LocalBackend, + config: SnapshotConfig, +) -> MicrosandboxResult { let SnapshotConfig { source_sandbox, destination, @@ -30,7 +34,7 @@ pub(super) async fn create_snapshot(config: SnapshotConfig) -> MicrosandboxResul record_integrity, } = config; - let db = crate::db::init_global().await?.read(); + let db = local.db().await?.read(); // Look up the sandbox row + parse its persisted config. let model = sandbox_entity::Entity::find() @@ -60,9 +64,7 @@ pub(super) async fn create_snapshot(config: SnapshotConfig) -> MicrosandboxResul let image_reference = oci_reference_string(&sandbox_config)?; // Resolve source upper.ext4 path from the canonical sandbox layout. - let sandbox_dir = crate::config::config() - .sandboxes_dir() - .join(&source_sandbox); + let sandbox_dir = local.sandboxes_dir().join(&source_sandbox); let src_upper = sandbox_dir.join("upper.ext4"); if !src_upper.exists() { return Err(MicrosandboxError::Custom(format!( @@ -72,7 +74,7 @@ pub(super) async fn create_snapshot(config: SnapshotConfig) -> MicrosandboxResul } // Resolve and prepare the destination directory. - let dest_dir = resolve_destination(&destination)?; + let dest_dir = resolve_destination(local, &destination)?; if dest_dir.exists() { if !force { return Err(MicrosandboxError::SnapshotAlreadyExists( @@ -147,7 +149,7 @@ pub(super) async fn create_snapshot(config: SnapshotConfig) -> MicrosandboxResul // Best-effort index upsert. Failures are logged, not propagated — // the artifact on disk is the source of truth. - if let Err(e) = index_upsert(&dest_dir, &digest, &manifest).await { + if let Err(e) = index_upsert(local, &dest_dir, &digest, &manifest).await { tracing::warn!(error = %e, snapshot = %digest, "snapshot_index upsert failed"); } @@ -168,7 +170,10 @@ fn oci_reference_string(config: &SandboxConfig) -> MicrosandboxResult { } } -fn resolve_destination(dest: &SnapshotDestination) -> MicrosandboxResult { +fn resolve_destination( + local: &LocalBackend, + dest: &SnapshotDestination, +) -> MicrosandboxResult { match dest { SnapshotDestination::Path(p) => Ok(p.clone()), SnapshotDestination::Name(name) => { @@ -182,7 +187,7 @@ fn resolve_destination(dest: &SnapshotDestination) -> MicrosandboxResult MicrosandboxResult { - create::create_snapshot(config).await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + create::create_snapshot(local, config).await } /// Open an existing snapshot artifact by path or bare name. @@ -112,7 +114,9 @@ impl Snapshot { /// upper file exists with the recorded size. It does not read the /// full upper contents. pub async fn open(path_or_name: impl AsRef) -> MicrosandboxResult { - store::open_snapshot(path_or_name.as_ref()).await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + store::open_snapshot(local, path_or_name.as_ref()).await } /// Verify recorded content integrity for this snapshot, if present. @@ -143,7 +147,9 @@ impl Snapshot { /// Get a handle by digest, name, or path from the local index. pub async fn get(name_or_digest: &str) -> MicrosandboxResult { - store::get_handle(name_or_digest).await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + store::get_handle(local, name_or_digest).await } /// List indexed snapshots from the local DB cache. @@ -152,14 +158,18 @@ impl Snapshot { /// index and won't appear here; use [`list_dir`](Self::list_dir) /// to enumerate artifacts on disk directly. pub async fn list() -> MicrosandboxResult> { - store::list_indexed().await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + store::list_indexed(local).await } /// Walk a directory and parse each subdirectory's manifest. Does /// not touch the index. Skips entries that don't look like /// snapshot artifacts. pub async fn list_dir(dir: impl AsRef) -> MicrosandboxResult> { - store::list_dir(dir.as_ref()).await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + store::list_dir(local, dir.as_ref()).await } /// Remove a snapshot artifact (by path or name) and its index row. @@ -167,13 +177,17 @@ impl Snapshot { /// Refuses if the snapshot has indexed children, unless `force` /// is set. The artifact directory is deleted on success. pub async fn remove(path_or_name: &str, force: bool) -> MicrosandboxResult<()> { - store::remove_snapshot(path_or_name, force).await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + store::remove_snapshot(local, path_or_name, force).await } /// Rebuild the local index from the artifacts in `dir`. Returns /// the number of artifacts indexed. pub async fn reindex(dir: impl AsRef) -> MicrosandboxResult { - store::reindex_dir(dir.as_ref()).await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + store::reindex_dir(local, dir.as_ref()).await } /// Bundle a snapshot into a `.tar.zst` archive. @@ -182,7 +196,9 @@ impl Snapshot { out: &Path, opts: archive::ExportOpts, ) -> MicrosandboxResult<()> { - archive::export_snapshot(name_or_path, out, opts).await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + archive::export_snapshot(local, name_or_path, out, opts).await } /// Unpack a snapshot archive (`.tar.zst` or `.tar`) into the @@ -191,7 +207,18 @@ impl Snapshot { archive_path: &Path, dest: Option<&Path>, ) -> MicrosandboxResult { - archive::import_snapshot(archive_path, dest).await + let backend = crate::backend::default_backend(); + let local = backend.as_local().ok_or_else(snapshots_require_local)?; + archive::import_snapshot(local, archive_path, dest).await + } +} + +/// Build an `Unsupported` error for snapshot ops that aren't wired through +/// the cloud trait yet. Snapshots are local-only today. +fn snapshots_require_local() -> MicrosandboxError { + MicrosandboxError::Unsupported { + feature: "Snapshot operations".into(), + available_when: "when cloud snapshots land".into(), } } diff --git a/crates/microsandbox/lib/snapshot/store.rs b/crates/microsandbox/lib/snapshot/store.rs index aedfd276a..1ce4e368b 100644 --- a/crates/microsandbox/lib/snapshot/store.rs +++ b/crates/microsandbox/lib/snapshot/store.rs @@ -9,6 +9,7 @@ use sea_orm::{ QueryOrder, }; +use crate::backend::LocalBackend; use crate::db::entity::snapshot as snapshot_entity; use crate::{MicrosandboxError, MicrosandboxResult}; @@ -22,8 +23,11 @@ use super::{Snapshot, SnapshotFormat, SnapshotHandle}; /// /// `path_or_name` is treated as a path if it contains `/` or starts /// with `.` or `~`; otherwise as a bare name resolved under the -/// default snapshots directory. -pub(super) async fn open_snapshot(path_or_name: &str) -> MicrosandboxResult { +/// passed-in `local` backend's snapshots directory. +pub(super) async fn open_snapshot( + local: &LocalBackend, + path_or_name: &str, +) -> MicrosandboxResult { if path_or_name.is_empty() { return Err(MicrosandboxError::InvalidConfig( "snapshot path or name must not be empty".into(), @@ -33,7 +37,7 @@ pub(super) async fn open_snapshot(path_or_name: &str) -> MicrosandboxResult MicrosandboxResult MicrosandboxResult MicrosandboxResult<()> { - let db = crate::db::init_global().await?.write(); + let db = local.db().await?.write(); let created_at = chrono::DateTime::parse_from_rfc3339(&manifest.created_at) .map(|d| d.naive_utc()) @@ -173,8 +178,8 @@ fn looks_like_path(s: &str) -> bool { s.contains('/') || s.starts_with('.') || s.starts_with('~') } -pub(super) async fn list_indexed() -> MicrosandboxResult> { - let db = crate::db::init_global().await?.read(); +pub(super) async fn list_indexed(local: &LocalBackend) -> MicrosandboxResult> { + let db = local.db().await?.read(); let rows = snapshot_entity::Entity::find() .order_by_desc(snapshot_entity::Column::CreatedAt) .all(db) @@ -182,7 +187,10 @@ pub(super) async fn list_indexed() -> MicrosandboxResult> { Ok(rows.into_iter().map(handle_from_model).collect()) } -pub(super) async fn list_dir(dir: &Path) -> MicrosandboxResult> { +pub(super) async fn list_dir( + local: &LocalBackend, + dir: &Path, +) -> MicrosandboxResult> { if !dir.exists() { return Ok(Vec::new()); } @@ -196,7 +204,7 @@ pub(super) async fn list_dir(dir: &Path) -> MicrosandboxResult> { if !path.join(MANIFEST_FILENAME).exists() { continue; } - match open_snapshot(path.to_string_lossy().as_ref()).await { + match open_snapshot(local, path.to_string_lossy().as_ref()).await { Ok(s) => out.push(s), Err(e) => { tracing::warn!(path = %path.display(), error = %e, "skipping malformed snapshot artifact") @@ -206,8 +214,12 @@ pub(super) async fn list_dir(dir: &Path) -> MicrosandboxResult> { Ok(out) } -pub(super) async fn remove_snapshot(path_or_name: &str, force: bool) -> MicrosandboxResult<()> { - let pools = crate::db::init_global().await?; +pub(super) async fn remove_snapshot( + local: &LocalBackend, + path_or_name: &str, + force: bool, +) -> MicrosandboxResult<()> { + let pools = local.db().await?; let read_db = pools.read(); let write_db = pools.write(); @@ -221,7 +233,7 @@ pub(super) async fn remove_snapshot(path_or_name: &str, force: bool) -> Microsan (row.digest.clone(), PathBuf::from(row.artifact_path)) } else if looks_like_path(path_or_name) { // Path: open to read the digest, then drop both row and dir. - let snap = open_snapshot(path_or_name).await?; + let snap = open_snapshot(local, path_or_name).await?; (snap.digest.clone(), snap.path.clone()) } else { // Bare name: prefer the index lookup; fall back to default-dir resolution. @@ -232,8 +244,8 @@ pub(super) async fn remove_snapshot(path_or_name: &str, force: bool) -> Microsan if let Some(row) = row { (row.digest.clone(), PathBuf::from(row.artifact_path)) } else { - let dir = crate::config::config().snapshots_dir().join(path_or_name); - let snap = open_snapshot(dir.to_string_lossy().as_ref()).await?; + let dir = local.snapshots_dir().join(path_or_name); + let snap = open_snapshot(local, dir.to_string_lossy().as_ref()).await?; (snap.digest.clone(), snap.path.clone()) } }; @@ -273,11 +285,11 @@ pub(super) async fn remove_snapshot(path_or_name: &str, force: bool) -> Microsan Ok(()) } -pub(super) async fn reindex_dir(dir: &Path) -> MicrosandboxResult { - let snapshots = list_dir(dir).await?; +pub(super) async fn reindex_dir(local: &LocalBackend, dir: &Path) -> MicrosandboxResult { + let snapshots = list_dir(local, dir).await?; let mut indexed = 0usize; for snap in &snapshots { - if let Err(e) = index_upsert(&snap.path, &snap.digest, &snap.manifest).await { + if let Err(e) = index_upsert(local, &snap.path, &snap.digest, &snap.manifest).await { tracing::warn!(path = %snap.path.display(), error = %e, "reindex: upsert failed"); continue; } @@ -285,7 +297,7 @@ pub(super) async fn reindex_dir(dir: &Path) -> MicrosandboxResult { } // After upserts, recompute child_count from parent edges in one pass // to keep the cache honest about the current set of artifacts. - let db = crate::db::init_global().await?.write(); + let db = local.db().await?.write(); db.execute_unprepared( "UPDATE snapshot_index SET child_count = (\ SELECT COUNT(*) FROM snapshot_index AS c \ @@ -296,8 +308,11 @@ pub(super) async fn reindex_dir(dir: &Path) -> MicrosandboxResult { } /// Look up a snapshot by digest, name, or path in the local index. -pub(super) async fn get_handle(needle: &str) -> MicrosandboxResult { - let db = crate::db::init_global().await?.read(); +pub(super) async fn get_handle( + local: &LocalBackend, + needle: &str, +) -> MicrosandboxResult { + let db = local.db().await?.read(); let row = if needle.starts_with("sha256:") || needle.starts_with("sha512:") { snapshot_entity::Entity::find_by_id(needle.to_string()) @@ -324,8 +339,11 @@ pub(super) async fn get_handle(needle: &str) -> MicrosandboxResult MicrosandboxResult> { - let db = crate::db::init_global().await?.read(); +pub(super) async fn lookup_by_digest( + local: &LocalBackend, + digest: &str, +) -> MicrosandboxResult> { + let db = local.db().await?.read(); let row = snapshot_entity::Entity::find_by_id(digest.to_string()) .one(db) .await?; diff --git a/crates/microsandbox/lib/volume/fs.rs b/crates/microsandbox/lib/volume/fs.rs index ffa9ca59d..128eae8ce 100644 --- a/crates/microsandbox/lib/volume/fs.rs +++ b/crates/microsandbox/lib/volume/fs.rs @@ -1,73 +1,104 @@ -//! Direct host-side filesystem operations on a named volume. +//! Host-side filesystem operations on a named volume. //! -//! Unlike [`SandboxFs`](crate::sandbox::fs::SandboxFs) which operates through the -//! agent protocol, [`VolumeFs`] operates directly on the host-side volume -//! directory using `tokio::fs`. +//! Unlike [`SandboxFs`](crate::sandbox::fs::SandboxFs) which goes through the +//! agent protocol, [`VolumeFs`] reads + writes a volume's bytes directly. For +//! the local backend that is `tokio::fs` against `volumes_dir//`; for +//! cloud (Phase 6) it routes through msb-cloud HTTP. Today every cloud op +//! returns [`crate::MicrosandboxError::Unsupported`]. +//! +//! `VolumeFs` is a single type per D6.4 — no public variants. It borrows the +//! parent volume's `Arc` + name and dispatches through the +//! [`VolumeBackend`](crate::backend::VolumeBackend) trait. -use std::path::{Path, PathBuf}; +use std::sync::Arc; use bytes::Bytes; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use crate::backend::Backend; use crate::{ - MicrosandboxError, MicrosandboxResult, - sandbox::fs::{FsEntry, FsEntryKind, FsMetadata}, + MicrosandboxResult, + sandbox::fs::{FsEntry, FsMetadata}, }; +//-------------------------------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------------------------------- + +/// Chunk size for streaming volume reads (64 KiB). +const STREAM_CHUNK_SIZE: usize = 64 * 1024; + //-------------------------------------------------------------------------------------------------- // Types //-------------------------------------------------------------------------------------------------- -/// Filesystem operations on a volume's host-side directory. +/// Filesystem operations on a volume. +/// +/// Borrows the parent volume's `Arc` + name and dispatches every +/// op through the [`VolumeBackend`](crate::backend::VolumeBackend) trait. +/// Local routes to `tokio::fs`; cloud returns `Unsupported` until Phase 6. pub struct VolumeFs<'a> { - root: VolumeRoot<'a>, -} - -/// Internal path storage — borrowed from a `Volume` or owned from a `VolumeHandle`. -enum VolumeRoot<'a> { - Borrowed(&'a Path), - Owned(PathBuf), + backend: Arc, + name: &'a str, } -/// Chunk size for streaming volume reads (64 KiB). -const STREAM_CHUNK_SIZE: usize = 64 * 1024; - /// A streaming reader for file data from a volume's host-side directory. +/// +/// **Local backend only** — opened from a host path. Cloud streaming flows +/// through `VolumeFs::read_stream` will land alongside the cloud HTTP routes +/// in Phase 6. pub struct VolumeFsReadStream { file: tokio::fs::File, buf: Vec, } +impl VolumeFsReadStream { + /// Construct from an already-opened file. Local impl only. + pub(crate) fn from_file(file: tokio::fs::File) -> Self { + Self { + file, + buf: vec![0u8; STREAM_CHUNK_SIZE], + } + } +} + /// A streaming writer for file data to a volume's host-side directory. +/// +/// **Local backend only** — opened against a host path. Cloud streaming will +/// land alongside the cloud HTTP routes in Phase 6. pub struct VolumeFsWriteSink { file: tokio::fs::File, } +impl VolumeFsWriteSink { + /// Construct from an already-opened file. Local impl only. + pub(crate) fn from_file(file: tokio::fs::File) -> Self { + Self { file } + } +} + //-------------------------------------------------------------------------------------------------- -// Methods +// Methods: VolumeFs //-------------------------------------------------------------------------------------------------- impl<'a> VolumeFs<'a> { - /// Create a volume filesystem handle from a borrowed path. - pub(crate) fn from_path_ref(path: &'a Path) -> Self { - Self { - root: VolumeRoot::Borrowed(path), - } - } - - /// Create a volume filesystem handle from an owned path. - pub fn from_path(path: PathBuf) -> Self { - Self { - root: VolumeRoot::Owned(path), - } + /// Construct a volume FS handle for the named volume. + /// + /// Called by [`Volume::fs`](super::Volume::fs) and + /// [`VolumeHandle::fs`](super::VolumeHandle::fs) — those are the public + /// entry points; this constructor itself is crate-private. + pub(crate) fn new(backend: Arc, name: &'a str) -> Self { + Self { backend, name } } - /// Get the root path of the volume. - fn root_path(&self) -> &Path { - match &self.root { - VolumeRoot::Borrowed(p) => p, - VolumeRoot::Owned(p) => p, - } + /// Public constructor for FFI shims that don't hold a [`Volume`](super::Volume) / + /// [`VolumeHandle`](super::VolumeHandle) directly. + /// + /// Most callers should use [`Volume::fs`](super::Volume::fs) / + /// [`VolumeHandle::fs`](super::VolumeHandle::fs); this is here for the + /// language bindings that re-assemble a `VolumeFs` per FFI call. + pub fn with_backend(backend: Arc, name: &'a str) -> Self { + Self { backend, name } } //---------------------------------------------------------------------------------------------- @@ -76,27 +107,25 @@ impl<'a> VolumeFs<'a> { /// Read an entire file into memory as raw bytes. pub async fn read(&self, path: &str) -> MicrosandboxResult { - let full = self.resolve(path)?; - let data = tokio::fs::read(&full).await?; - Ok(Bytes::from(data)) + self.backend.volumes().fs_read(self.name, path).await } /// Read an entire file into memory as a UTF-8 string. pub async fn read_to_string(&self, path: &str) -> MicrosandboxResult { - let full = self.resolve(path)?; - let data = tokio::fs::read_to_string(&full).await?; - Ok(data) + self.backend + .volumes() + .fs_read_to_string(self.name, path) + .await } /// Read a file with streaming. Returns a [`VolumeFsReadStream`] that /// yields chunks of bytes. + /// + /// Routes through the [`VolumeBackend`](crate::backend::VolumeBackend) + /// trait — cloud routes return [`crate::MicrosandboxError::Unsupported`] + /// until cloud volumes ship. pub async fn read_stream(&self, path: &str) -> MicrosandboxResult { - let full = self.resolve(path)?; - let file = tokio::fs::File::open(&full).await?; - Ok(VolumeFsReadStream { - file, - buf: vec![0u8; STREAM_CHUNK_SIZE], - }) + self.backend.volumes().fs_read_stream(self.name, path).await } //---------------------------------------------------------------------------------------------- @@ -106,118 +135,65 @@ impl<'a> VolumeFs<'a> { /// Write data to a file, creating parent directories as needed. /// Overwrites if the file already exists. pub async fn write(&self, path: &str, data: impl AsRef<[u8]>) -> MicrosandboxResult<()> { - let full = self.resolve(path)?; - - // Ensure parent directory exists. - if let Some(parent) = full.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - tokio::fs::write(&full, data.as_ref()).await?; - Ok(()) + let bytes = data.as_ref().to_vec(); + self.backend + .volumes() + .fs_write(self.name, path, bytes) + .await } /// Write to a file with streaming. Returns a [`VolumeFsWriteSink`] that /// accepts chunks of bytes. Creates parent directories as needed. + /// + /// Routes through the [`VolumeBackend`](crate::backend::VolumeBackend) + /// trait — cloud routes return [`crate::MicrosandboxError::Unsupported`] + /// until cloud volumes ship. pub async fn write_stream(&self, path: &str) -> MicrosandboxResult { - let full = self.resolve(path)?; - - if let Some(parent) = full.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - let file = tokio::fs::File::create(&full).await?; - Ok(VolumeFsWriteSink { file }) + self.backend + .volumes() + .fs_write_stream(self.name, path) + .await } //---------------------------------------------------------------------------------------------- - // Directory Operations + // Directory + File Operations //---------------------------------------------------------------------------------------------- /// List the immediate children of a directory (non-recursive). /// Each entry includes the path, kind, size, permissions, and modification time. pub async fn list(&self, path: &str) -> MicrosandboxResult> { - let full = self.resolve(path)?; - let mut dir = tokio::fs::read_dir(&full).await?; - let mut entries = Vec::new(); - - while let Some(entry) = dir.next_entry().await? { - let entry_path = entry.path(); - let rel_path = entry_path - .strip_prefix(self.root_path()) - .unwrap_or(&entry_path); - - match entry.metadata().await { - Ok(meta) => { - entries.push(metadata_to_entry( - &format!("/{}", rel_path.display()), - &meta, - )); - } - Err(_) => { - entries.push(FsEntry { - path: format!("/{}", rel_path.display()), - kind: FsEntryKind::Other, - size: 0, - mode: 0, - modified: None, - }); - } - } - } - - Ok(entries) + self.backend.volumes().fs_list(self.name, path).await } /// Create a directory (and parents). pub async fn mkdir(&self, path: &str) -> MicrosandboxResult<()> { - let full = self.resolve(path)?; - tokio::fs::create_dir_all(&full).await?; - Ok(()) + self.backend.volumes().fs_mkdir(self.name, path).await } /// Remove a directory recursively. pub async fn remove_dir(&self, path: &str) -> MicrosandboxResult<()> { - let full = self.resolve(path)?; - tokio::fs::remove_dir_all(&full).await?; - Ok(()) + self.backend + .volumes() + .fs_remove(self.name, path, true) + .await } - //---------------------------------------------------------------------------------------------- - // File Operations - //---------------------------------------------------------------------------------------------- - /// Delete a single file. Use [`remove_dir`](Self::remove_dir) for directories. pub async fn remove(&self, path: &str) -> MicrosandboxResult<()> { - let full = self.resolve(path)?; - tokio::fs::remove_file(&full).await?; - Ok(()) + self.backend + .volumes() + .fs_remove(self.name, path, false) + .await } /// Copy a file within the volume. pub async fn copy(&self, from: &str, to: &str) -> MicrosandboxResult<()> { - let src = self.resolve(from)?; - let dst = self.resolve(to)?; - - if let Some(parent) = dst.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - tokio::fs::copy(&src, &dst).await?; - Ok(()) + self.backend.volumes().fs_copy(self.name, from, to).await } /// Rename/move a file or directory. pub async fn rename(&self, from: &str, to: &str) -> MicrosandboxResult<()> { - let src = self.resolve(from)?; - let dst = self.resolve(to)?; - - if let Some(parent) = dst.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - tokio::fs::rename(&src, &dst).await?; - Ok(()) + self.backend.volumes().fs_rename(self.name, from, to).await } //---------------------------------------------------------------------------------------------- @@ -226,28 +202,93 @@ impl<'a> VolumeFs<'a> { /// Get file/directory metadata. pub async fn stat(&self, path: &str) -> MicrosandboxResult { - let full = self.resolve(path)?; - let meta = tokio::fs::symlink_metadata(&full).await?; - Ok(std_metadata_to_fs(&meta)) + self.backend.volumes().fs_stat(self.name, path).await } /// Check whether a file or directory exists at the given path. /// Returns `false` (not an error) if the path is absent. pub async fn exists(&self, path: &str) -> MicrosandboxResult { - let full = self.resolve(path)?; - Ok(tokio::fs::try_exists(&full).await.unwrap_or(false)) + self.backend.volumes().fs_exists(self.name, path).await } } //-------------------------------------------------------------------------------------------------- -// Methods: Helpers +// Methods: VolumeFsReadStream //-------------------------------------------------------------------------------------------------- -impl VolumeFs<'_> { - /// Resolve a relative path against the volume root, preventing path traversal. - fn resolve(&self, path: &str) -> MicrosandboxResult { - let root = self.root_path(); +impl VolumeFsReadStream { + /// Receive the next chunk of file data. + /// + /// Returns `None` at EOF. + pub async fn recv(&mut self) -> MicrosandboxResult> { + let n = self.file.read(&mut self.buf).await?; + if n == 0 { + Ok(None) + } else { + Ok(Some(Bytes::copy_from_slice(&self.buf[..n]))) + } + } + /// Read the remaining file data into a single `Bytes` buffer. + pub async fn collect(mut self) -> MicrosandboxResult { + let mut data = Vec::new(); + let mut buf = vec![0u8; STREAM_CHUNK_SIZE]; + loop { + let n = self.file.read(&mut buf).await?; + if n == 0 { + break; + } + data.extend_from_slice(&buf[..n]); + } + Ok(Bytes::from(data)) + } +} + +//-------------------------------------------------------------------------------------------------- +// Methods: VolumeFsWriteSink +//-------------------------------------------------------------------------------------------------- + +impl VolumeFsWriteSink { + /// Write a chunk of data to the file. + pub async fn write(&mut self, data: impl AsRef<[u8]>) -> MicrosandboxResult<()> { + self.file.write_all(data.as_ref()).await?; + Ok(()) + } + + /// Flush and close the file. + pub async fn close(mut self) -> MicrosandboxResult<()> { + self.file.flush().await?; + Ok(()) + } +} + +//-------------------------------------------------------------------------------------------------- +// Module: local (free fn impls called by LocalBackend's VolumeBackend impl) +//-------------------------------------------------------------------------------------------------- + +pub(crate) mod local { + //! Local FS ops keyed by `(volume_name, rel_path)`. + //! + //! Lives in a sub-module so the `LocalBackend` trait impl in + //! `backend/volume.rs` can call into one place. Each function takes + //! the `&LocalBackend` whose `volumes_dir` it should resolve against, + //! so `with_backend` scoping and explicit `LocalBackend::builder()` + //! constructions correctly route to the right host directory. + + use std::path::{Path, PathBuf}; + + use bytes::Bytes; + + use crate::{ + MicrosandboxError, MicrosandboxResult, + backend::LocalBackend, + sandbox::fs::{FsEntry, FsEntryKind, FsMetadata}, + }; + + use super::{VolumeFsReadStream, VolumeFsWriteSink}; + + /// Resolve a relative path against the volume root, preventing path traversal. + pub(crate) fn resolve_relative(root: &Path, path: &str) -> MicrosandboxResult { // Strip leading slash for joining. let clean = path.strip_prefix('/').unwrap_or(path); @@ -295,114 +336,240 @@ impl VolumeFs<'_> { Ok(canonical) } -} -//-------------------------------------------------------------------------------------------------- -// Methods: VolumeFsReadStream -//-------------------------------------------------------------------------------------------------- + /// Volume root directory on the host for the named volume. + fn volume_root(local: &LocalBackend, name: &str) -> PathBuf { + local.volume_path(name) + } -impl VolumeFsReadStream { - /// Receive the next chunk of file data. - /// - /// Returns `None` at EOF. - pub async fn recv(&mut self) -> MicrosandboxResult> { - let n = self.file.read(&mut self.buf).await?; - if n == 0 { - Ok(None) - } else { - Ok(Some(Bytes::copy_from_slice(&self.buf[..n]))) + /// Resolve `(volume_name, path)` to a canonical host path. + fn resolve(local: &LocalBackend, name: &str, path: &str) -> MicrosandboxResult { + resolve_relative(&volume_root(local, name), path) + } + + pub(crate) async fn read( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let full = resolve(local, name, path)?; + let data = tokio::fs::read(&full).await?; + Ok(Bytes::from(data)) + } + + pub(crate) async fn read_to_string( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let full = resolve(local, name, path)?; + let data = tokio::fs::read_to_string(&full).await?; + Ok(data) + } + + pub(crate) async fn write( + local: &LocalBackend, + name: &str, + path: &str, + data: &[u8], + ) -> MicrosandboxResult<()> { + let full = resolve(local, name, path)?; + if let Some(parent) = full.parent() { + tokio::fs::create_dir_all(parent).await?; } + tokio::fs::write(&full, data).await?; + Ok(()) } - /// Read the remaining file data into a single `Bytes` buffer. - pub async fn collect(mut self) -> MicrosandboxResult { - let mut data = Vec::new(); - let mut buf = vec![0u8; STREAM_CHUNK_SIZE]; - loop { - let n = self.file.read(&mut buf).await?; - if n == 0 { - break; + pub(crate) async fn list( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult> { + let root = volume_root(local, name); + let full = resolve_relative(&root, path)?; + let mut dir = tokio::fs::read_dir(&full).await?; + let mut entries = Vec::new(); + + while let Some(entry) = dir.next_entry().await? { + let entry_path = entry.path(); + let rel_path = entry_path.strip_prefix(&root).unwrap_or(&entry_path); + + match entry.metadata().await { + Ok(meta) => { + entries.push(metadata_to_entry( + &format!("/{}", rel_path.display()), + &meta, + )); + } + Err(_) => { + entries.push(FsEntry { + path: format!("/{}", rel_path.display()), + kind: FsEntryKind::Other, + size: 0, + mode: 0, + modified: None, + }); + } } - data.extend_from_slice(&buf[..n]); } - Ok(Bytes::from(data)) + + Ok(entries) } -} -//-------------------------------------------------------------------------------------------------- -// Methods: VolumeFsWriteSink -//-------------------------------------------------------------------------------------------------- + pub(crate) async fn mkdir( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult<()> { + let full = resolve(local, name, path)?; + tokio::fs::create_dir_all(&full).await?; + Ok(()) + } -impl VolumeFsWriteSink { - /// Write a chunk of data to the file. - pub async fn write(&mut self, data: impl AsRef<[u8]>) -> MicrosandboxResult<()> { - self.file.write_all(data.as_ref()).await?; + pub(crate) async fn remove( + local: &LocalBackend, + name: &str, + path: &str, + recursive: bool, + ) -> MicrosandboxResult<()> { + let full = resolve(local, name, path)?; + if recursive { + tokio::fs::remove_dir_all(&full).await?; + } else { + tokio::fs::remove_file(&full).await?; + } Ok(()) } - /// Flush and close the file. - pub async fn close(mut self) -> MicrosandboxResult<()> { - self.file.flush().await?; + pub(crate) async fn copy( + local: &LocalBackend, + name: &str, + from: &str, + to: &str, + ) -> MicrosandboxResult<()> { + let src = resolve(local, name, from)?; + let dst = resolve(local, name, to)?; + + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + tokio::fs::copy(&src, &dst).await?; Ok(()) } -} -//-------------------------------------------------------------------------------------------------- -// Functions -//-------------------------------------------------------------------------------------------------- + pub(crate) async fn rename( + local: &LocalBackend, + name: &str, + from: &str, + to: &str, + ) -> MicrosandboxResult<()> { + let src = resolve(local, name, from)?; + let dst = resolve(local, name, to)?; -/// Determine the `FsEntryKind` from `std::fs::Metadata`. -fn std_kind(meta: &std::fs::Metadata) -> FsEntryKind { - if meta.is_file() { - FsEntryKind::File - } else if meta.is_dir() { - FsEntryKind::Directory - } else if meta.is_symlink() { - FsEntryKind::Symlink - } else { - FsEntryKind::Other + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + tokio::fs::rename(&src, &dst).await?; + Ok(()) } -} -/// Extract the modification time from `std::fs::Metadata`. -fn std_modified(meta: &std::fs::Metadata) -> Option> { - meta.modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_default()) -} + pub(crate) async fn stat( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let full = resolve(local, name, path)?; + let meta = tokio::fs::symlink_metadata(&full).await?; + Ok(std_metadata_to_fs(&meta)) + } -/// Convert `std::fs::Metadata` to an `FsEntry`. -fn metadata_to_entry(path: &str, meta: &std::fs::Metadata) -> FsEntry { - use std::os::unix::fs::MetadataExt; + pub(crate) async fn exists( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let full = resolve(local, name, path)?; + Ok(tokio::fs::try_exists(&full).await.unwrap_or(false)) + } - FsEntry { - path: path.to_string(), - kind: std_kind(meta), - size: meta.len(), - mode: meta.mode(), - modified: std_modified(meta), + pub(crate) async fn read_stream( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let full = resolve(local, name, path)?; + let file = tokio::fs::File::open(&full).await?; + Ok(VolumeFsReadStream::from_file(file)) } -} -/// Extract the creation time from `std::fs::Metadata`. -fn std_created(meta: &std::fs::Metadata) -> Option> { - meta.created() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_default()) -} + pub(crate) async fn write_stream( + local: &LocalBackend, + name: &str, + path: &str, + ) -> MicrosandboxResult { + let full = resolve(local, name, path)?; + if let Some(parent) = full.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let file = tokio::fs::File::create(&full).await?; + Ok(VolumeFsWriteSink::from_file(file)) + } -/// Convert `std::fs::Metadata` to `FsMetadata`. -fn std_metadata_to_fs(meta: &std::fs::Metadata) -> FsMetadata { - use std::os::unix::fs::MetadataExt; - - FsMetadata { - kind: std_kind(meta), - size: meta.len(), - mode: meta.mode(), - readonly: meta.permissions().readonly(), - modified: std_modified(meta), - created: std_created(meta), + //---------------------------------------------------------------------------------------------- + // Functions: helpers + //---------------------------------------------------------------------------------------------- + + fn std_kind(meta: &std::fs::Metadata) -> FsEntryKind { + if meta.is_file() { + FsEntryKind::File + } else if meta.is_dir() { + FsEntryKind::Directory + } else if meta.is_symlink() { + FsEntryKind::Symlink + } else { + FsEntryKind::Other + } + } + + fn std_modified(meta: &std::fs::Metadata) -> Option> { + meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_default()) + } + + fn metadata_to_entry(path: &str, meta: &std::fs::Metadata) -> FsEntry { + use std::os::unix::fs::MetadataExt; + + FsEntry { + path: path.to_string(), + kind: std_kind(meta), + size: meta.len(), + mode: meta.mode(), + modified: std_modified(meta), + } + } + + fn std_created(meta: &std::fs::Metadata) -> Option> { + meta.created() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_default()) + } + + fn std_metadata_to_fs(meta: &std::fs::Metadata) -> FsMetadata { + use std::os::unix::fs::MetadataExt; + + FsMetadata { + kind: std_kind(meta), + size: meta.len(), + mode: meta.mode(), + readonly: meta.permissions().readonly(), + modified: std_modified(meta), + created: std_created(meta), + } } } diff --git a/crates/microsandbox/lib/volume/mod.rs b/crates/microsandbox/lib/volume/mod.rs index 740ac546a..f0935da78 100644 --- a/crates/microsandbox/lib/volume/mod.rs +++ b/crates/microsandbox/lib/volume/mod.rs @@ -1,15 +1,27 @@ //! Named volume management. //! -//! Volumes are persistent host-side directories stored under -//! `~/.microsandbox/volumes//` with metadata tracked in the database. +//! Volumes are persistent named storage. Locally they are host-side +//! directories under `~/.microsandbox/volumes//` with metadata tracked +//! in SQLite. Cloud-side they ultimately live in the org's S3 namespace via +//! msb-cloud (Phase 6; today every cloud op returns `Unsupported`). +//! +//! Per the SDK local-cloud parity plan (D6.4) [`Volume`] and [`VolumeHandle`] +//! stay single types regardless of backend. Each holds an +//! [`Arc`](crate::backend::Backend) to route lifecycle ops +//! through, and a backend-private [`VolumeInner`] / [`VolumeHandleInner`] +//! enum carrying variant-specific state. pub mod fs; pub use fs::{VolumeFs, VolumeFsReadStream, VolumeFsWriteSink}; -use std::path::PathBuf; +use std::path::Path; +use std::sync::Arc; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; +use crate::backend::{ + Backend, BackendKind, VolumeHandleInner, VolumeHandleLocalState, VolumeInner, VolumeLocalState, +}; use crate::{ MicrosandboxError, MicrosandboxResult, db::entity::volume as volume_entity, size::Mebibytes, }; @@ -19,9 +31,16 @@ use crate::{ //-------------------------------------------------------------------------------------------------- /// A named volume. +/// +/// Holds the backend it was created on plus a backend-private +/// [`VolumeInner`] enum carrying variant-specific state. Reach variant data +/// via [`Volume::local`] / [`Volume::cloud`]; the public surface stays +/// backend-agnostic. +#[derive(Clone)] pub struct Volume { + backend: Arc, + inner: Arc, name: String, - path: PathBuf, } /// Configuration for creating a volume. @@ -37,18 +56,17 @@ pub struct VolumeConfig { pub labels: Vec<(String, String)>, } -/// A lightweight handle to a volume from the database. +/// A lightweight handle to a volume. /// -/// Provides metadata access and management operations without requiring -/// a live [`Volume`] instance. Obtained via [`Volume::get`] or [`Volume::list`]. -#[derive(Debug)] +/// Provides metadata access and management operations without requiring a +/// live [`Volume`] instance. Obtained via [`Volume::get`] or [`Volume::list`]. +/// +/// Like [`Volume`], holds an [`Arc`] plus a backend-private +/// [`VolumeHandleInner`] enum; users see a single uniform type. pub struct VolumeHandle { - db_id: i32, + backend: Arc, + inner: VolumeHandleInner, name: String, - quota_mib: Option, - used_bytes: u64, - labels: Vec<(String, String)>, - created_at: Option>, } /// Builder for creating a volume. @@ -57,214 +75,243 @@ pub struct VolumeBuilder { } //-------------------------------------------------------------------------------------------------- -// Methods: VolumeHandle +// Methods: Volume (static) //-------------------------------------------------------------------------------------------------- -impl VolumeHandle { - /// Create a handle from a database entity model. - pub(crate) fn from_model(model: volume_entity::Model) -> Self { - let labels = model - .labels - .as_deref() - .map(|s| { - serde_json::from_str::>(s).unwrap_or_else(|e| { - tracing::warn!(volume = %model.name, error = %e, "failed to parse volume labels JSON"); - Vec::new() - }) - }) - .unwrap_or_default(); - - Self { - db_id: model.id, - name: model.name, - quota_mib: model.quota_mib.map(|v| v.max(0) as u32), - used_bytes: model.size_bytes.unwrap_or(0).max(0) as u64, - labels, - created_at: model.created_at.map(|dt| dt.and_utc()), - } - } - - /// Unique name identifying this volume. Used to reference the volume - /// in sandbox mount configurations via `v.named(handle.name())`. - pub fn name(&self) -> &str { - &self.name - } - - /// Maximum storage in MiB, or `None` if unlimited. - pub fn quota_mib(&self) -> Option { - self.quota_mib +impl Volume { + /// Start building a new named volume. Call `.create()` on the returned + /// builder to persist it. + pub fn builder(name: impl Into) -> VolumeBuilder { + VolumeBuilder::new(name) } - /// Disk usage snapshot from when this handle was created. Not live — - /// call [`Volume::get`] again for a fresh reading. - pub fn used_bytes(&self) -> u64 { - self.used_bytes + /// Provision a volume. + /// + /// Routes through the ambient + /// [`default_backend`](crate::backend::default_backend) so a cloud profile + /// dispatches to [`CloudBackend`](crate::backend::CloudBackend) instead of + /// the local disk path. The returned `Volume` carries the backend it was + /// created on; subsequent method calls keep using that backend. + /// + /// Locally fails with [`MicrosandboxError::VolumeAlreadyExists`] if a + /// volume with the same name already exists. + pub async fn create(config: VolumeConfig) -> MicrosandboxResult { + let backend = crate::backend::default_backend(); + backend.volumes().create(backend.clone(), config).await } - /// Key-value labels for organizing and filtering volumes. - pub fn labels(&self) -> &[(String, String)] { - &self.labels + /// Get a volume handle by name from the active backend. + pub async fn get(name: &str) -> MicrosandboxResult { + let backend = crate::backend::default_backend(); + backend.volumes().get(backend.clone(), name).await } - /// When this volume was first created, if recorded. - pub fn created_at(&self) -> Option> { - self.created_at + /// List all volumes from the active backend. + pub async fn list() -> MicrosandboxResult> { + let backend = crate::backend::default_backend(); + backend.volumes().list(backend.clone()).await } - /// Operate on the volume's host-side directory (read, write, list files) - /// without needing a running sandbox. - pub fn fs(&self) -> fs::VolumeFs<'_> { - let path = crate::config::config().volumes_dir().join(&self.name); - fs::VolumeFs::from_path(path) + /// Remove a volume by name via the active backend. + pub async fn remove(name: &str) -> MicrosandboxResult<()> { + let backend = crate::backend::default_backend(); + backend.volumes().remove(backend.clone(), name).await } +} - /// Remove this volume from the database and filesystem. - /// - /// Deletes the DB record first, then the directory. An orphaned directory - /// is easier to detect and clean up than an orphaned DB record. - pub async fn remove(&self) -> MicrosandboxResult<()> { - let pools = crate::db::init_global().await?; - - // Delete the DB record first. - volume_entity::Entity::delete_by_id(self.db_id) - .exec(pools.write()) - .await?; +//-------------------------------------------------------------------------------------------------- +// Methods: Volume (construction helpers) +//-------------------------------------------------------------------------------------------------- - // Then delete the directory. - let path = crate::config::config().volumes_dir().join(&self.name); - if path.exists() { - tokio::fs::remove_dir_all(&path).await?; +impl Volume { + /// Build an outer `Volume` from local-variant inner state. + pub(crate) fn from_local( + backend: Arc, + local: VolumeLocalState, + name: String, + ) -> Self { + Self { + backend, + inner: Arc::new(VolumeInner::Local(local)), + name, } - - Ok(()) } } //-------------------------------------------------------------------------------------------------- -// Methods: Static +// Methods: Volume (instance) //-------------------------------------------------------------------------------------------------- impl Volume { - /// Start building a new named volume. Call `.create()` on the returned - /// builder to persist it. - pub fn builder(name: impl Into) -> VolumeBuilder { - VolumeBuilder::new(name) + /// Unique name identifying this volume. + pub fn name(&self) -> &str { + &self.name } - /// Provision a volume: creates the host directory and database record. - /// Fails with [`MicrosandboxError::VolumeAlreadyExists`] if a volume - /// with the same name already exists. - pub async fn create(config: VolumeConfig) -> MicrosandboxResult { - tracing::debug!(name = %config.name, quota_mib = ?config.quota_mib, "Volume::create"); - validate_volume_name(&config.name)?; + /// Which backend variant this volume is bound to. + pub fn backend_kind(&self) -> BackendKind { + self.backend.kind() + } - let pools = crate::db::init_global().await?; + /// Local-only volume state. Returns `Some` for local-backed volumes. + pub fn local(&self) -> Option<&VolumeLocalState> { + match &*self.inner { + VolumeInner::Local(s) => Some(s), + VolumeInner::Cloud(_) => None, + } + } - // Check for existing volume. - let existing = volume_entity::Entity::find() - .filter(volume_entity::Column::Name.eq(&config.name)) - .one(pools.read()) - .await?; - if existing.is_some() { - return Err(MicrosandboxError::VolumeAlreadyExists(config.name)); + /// Cloud-only volume state. Returns `Some` for cloud-backed volumes. + pub fn cloud(&self) -> Option<&crate::backend::VolumeCloudState> { + match &*self.inner { + VolumeInner::Cloud(s) => Some(s), + VolumeInner::Local(_) => None, } + } - // Serialize labels. - let labels_json = if config.labels.is_empty() { - None - } else { - Some(serde_json::to_string(&config.labels)?) - }; - - // Insert DB record first — orphaned directories are easier to clean - // up than orphaned DB records. - let now = chrono::Utc::now().naive_utc(); - let model = volume_entity::ActiveModel { - name: Set(config.name.clone()), - quota_mib: Set(config.quota_mib.map(|v| v as i32)), - size_bytes: Set(None), - labels: Set(labels_json), - created_at: Set(Some(now)), - updated_at: Set(Some(now)), - ..Default::default() - }; - - volume_entity::Entity::insert(model) - .exec(pools.write()) - .await?; - - // Create the volume directory. If this fails, clean up the DB record. - let volumes_dir = crate::config::config().volumes_dir(); - let path = volumes_dir.join(&config.name); - - if let Err(e) = tokio::fs::create_dir_all(&path).await { - let _ = volume_entity::Entity::delete_many() - .filter(volume_entity::Column::Name.eq(&config.name)) - .exec(pools.write()) - .await; - return Err(e.into()); + /// Host-side directory where this volume's data is stored (local backend + /// only). + /// + /// Errors with [`MicrosandboxError::Unsupported`] for cloud volumes — + /// cloud bytes live in the org's S3 namespace, not on the caller's host. + pub fn path(&self) -> MicrosandboxResult<&Path> { + match &*self.inner { + VolumeInner::Local(s) => Ok(&s.path), + VolumeInner::Cloud(_) => Err(MicrosandboxError::Unsupported { + feature: "Volume::path on cloud".into(), + available_when: "never — cloud volumes don't live on the host".into(), + }), } + } - Ok(Self { - name: config.name, - path, - }) + /// Operate on the volume's filesystem (read, write, list files) without + /// needing a running sandbox. + /// + /// Routes through the backend trait — local ops hit `tokio::fs`, cloud + /// ops will route through msb-cloud HTTP once Phase 6 lands. + pub fn fs(&self) -> VolumeFs<'_> { + VolumeFs::new(self.backend.clone(), &self.name) } +} + +//-------------------------------------------------------------------------------------------------- +// Methods: VolumeHandle +//-------------------------------------------------------------------------------------------------- - /// Get a volume handle by name from the database. +impl VolumeHandle { + /// Build a handle from a local volume DB row. /// - /// Returns a lightweight handle for metadata and management operations. - pub async fn get(name: &str) -> MicrosandboxResult { - let db = crate::db::init_global().await?.read(); + /// Derives the host-side path from the `backend`'s [`LocalBackend`] + /// view — callers don't have to thread the same backend in twice. + /// Panics if `backend` is not a [`LocalBackend`]; this is the local + /// construction path and is only called from `get_local` / `list_local`, + /// which have already routed through the local trait impl. + pub(crate) fn from_local_model(backend: Arc, model: volume_entity::Model) -> Self { + let labels = model + .labels + .as_deref() + .map(|s| { + serde_json::from_str::>(s).unwrap_or_else(|e| { + tracing::warn!(volume = %model.name, error = %e, "failed to parse volume labels JSON"); + Vec::new() + }) + }) + .unwrap_or_default(); - let model = volume_entity::Entity::find() - .filter(volume_entity::Column::Name.eq(name)) - .one(db) - .await? - .ok_or_else(|| MicrosandboxError::VolumeNotFound(name.into()))?; + let local_backend = backend + .as_local() + .expect("from_local_model called outside a LocalBackend context"); + let path = local_backend.volume_path(&model.name); + let name = model.name; + Self { + backend, + inner: VolumeHandleInner::Local(VolumeHandleLocalState { + db_id: model.id, + path, + quota_mib: model.quota_mib.map(|v| v.max(0) as u32), + used_bytes: model.size_bytes.unwrap_or(0).max(0) as u64, + labels, + created_at: model.created_at.map(|dt| dt.and_utc()), + }), + name, + } + } - Ok(VolumeHandle::from_model(model)) + /// Unique name identifying this volume. + pub fn name(&self) -> &str { + &self.name } - /// List all volumes, ordered by creation time (newest first). - pub async fn list() -> MicrosandboxResult> { - let db = crate::db::init_global().await?.read(); + /// Which backend variant this handle is bound to. + pub fn backend_kind(&self) -> BackendKind { + self.backend.kind() + } - let models = volume_entity::Entity::find() - .order_by_desc(volume_entity::Column::CreatedAt) - .all(db) - .await?; + /// Local-only handle state. Returns `Some` for local-backed handles. + pub fn local(&self) -> Option<&VolumeHandleLocalState> { + match &self.inner { + VolumeHandleInner::Local(s) => Some(s), + VolumeHandleInner::Cloud(_) => None, + } + } - Ok(models.into_iter().map(VolumeHandle::from_model).collect()) + /// Cloud-only handle state. Returns `Some` for cloud-backed handles. + pub fn cloud(&self) -> Option<&crate::backend::VolumeHandleCloudState> { + match &self.inner { + VolumeHandleInner::Cloud(s) => Some(s), + VolumeHandleInner::Local(_) => None, + } } - /// Delete a volume's database record and host directory. - /// Fails with [`MicrosandboxError::VolumeNotFound`] if no such volume exists. - pub async fn remove(name: &str) -> MicrosandboxResult<()> { - Self::get(name).await?.remove().await + /// Maximum storage in MiB, or `None` if unlimited. + pub fn quota_mib(&self) -> Option { + match &self.inner { + VolumeHandleInner::Local(s) => s.quota_mib, + VolumeHandleInner::Cloud(s) => s.quota_mib, + } } -} -//-------------------------------------------------------------------------------------------------- -// Methods: Instance -//-------------------------------------------------------------------------------------------------- + /// Disk usage snapshot from when this handle was created. Not live — + /// call [`Volume::get`] again for a fresh reading. + pub fn used_bytes(&self) -> u64 { + match &self.inner { + VolumeHandleInner::Local(s) => s.used_bytes, + VolumeHandleInner::Cloud(s) => s.used_bytes, + } + } -impl Volume { - /// Unique name identifying this volume. - pub fn name(&self) -> &str { - &self.name + /// Key-value labels for organizing and filtering volumes. + pub fn labels(&self) -> &[(String, String)] { + match &self.inner { + VolumeHandleInner::Local(s) => &s.labels, + VolumeHandleInner::Cloud(s) => &s.labels, + } } - /// Host-side directory where this volume's data is stored - /// (under `~/.microsandbox/volumes//`). - pub fn path(&self) -> &std::path::Path { - &self.path + /// When this volume was first created, if recorded. + pub fn created_at(&self) -> Option> { + match &self.inner { + VolumeHandleInner::Local(s) => s.created_at, + VolumeHandleInner::Cloud(s) => s.created_at, + } } - /// Operate on the volume's host-side directory (read, write, list files) - /// without needing a running sandbox. - pub fn fs(&self) -> fs::VolumeFs<'_> { - fs::VolumeFs::from_path_ref(&self.path) + /// Operate on the volume's filesystem (read, write, list files) without + /// needing a running sandbox. Routes through the bound backend. + pub fn fs(&self) -> VolumeFs<'_> { + VolumeFs::new(self.backend.clone(), &self.name) + } + + /// Remove this volume. + /// + /// Locally deletes the DB record first, then the directory. An orphaned + /// directory is easier to detect and clean up than an orphaned DB record. + /// Cloud handles route through the backend's remove endpoint. + pub async fn remove(&self) -> MicrosandboxResult<()> { + self.backend + .volumes() + .remove(self.backend.clone(), &self.name) + .await } } @@ -311,7 +358,8 @@ impl VolumeBuilder { self.config } - /// Create the volume. + /// Create the volume. Routes through the ambient + /// [`default_backend`](crate::backend::default_backend). pub async fn create(self) -> MicrosandboxResult { Volume::create(self.config).await } @@ -327,6 +375,161 @@ impl From for VolumeBuilder { } } +impl std::fmt::Debug for VolumeHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VolumeHandle") + .field("name", &self.name) + .field("backend_kind", &self.backend.kind()) + .finish() + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions: Local lifecycle (called from the LocalBackend VolumeBackend impl) +//-------------------------------------------------------------------------------------------------- + +/// Local create path. Inserts a DB record, creates the host directory, and +/// returns a wrapped [`Volume`]. On directory-create failure rolls back the +/// DB insert so we don't leak phantom rows. +pub(crate) async fn create_local( + backend: Arc, + config: VolumeConfig, +) -> MicrosandboxResult { + tracing::debug!(name = %config.name, quota_mib = ?config.quota_mib, "Volume::create"); + validate_volume_name(&config.name)?; + + let local_backend = backend + .as_local() + .ok_or_else(|| MicrosandboxError::Unsupported { + feature: "Volume::create_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let pools = local_backend.db().await?; + + // Check for existing volume. + let existing = volume_entity::Entity::find() + .filter(volume_entity::Column::Name.eq(&config.name)) + .one(pools.read()) + .await?; + if existing.is_some() { + return Err(MicrosandboxError::VolumeAlreadyExists(config.name)); + } + + // Serialize labels. + let labels_json = if config.labels.is_empty() { + None + } else { + Some(serde_json::to_string(&config.labels)?) + }; + + // Insert DB record first — orphaned directories are easier to clean + // up than orphaned DB records. + let now = chrono::Utc::now().naive_utc(); + let model = volume_entity::ActiveModel { + name: Set(config.name.clone()), + quota_mib: Set(config.quota_mib.map(|v| v as i32)), + size_bytes: Set(None), + labels: Set(labels_json), + created_at: Set(Some(now)), + updated_at: Set(Some(now)), + ..Default::default() + }; + + volume_entity::Entity::insert(model) + .exec(pools.write()) + .await?; + + // Create the volume directory. If this fails, clean up the DB record. + let path = local_backend.volume_path(&config.name); + + if let Err(e) = tokio::fs::create_dir_all(&path).await { + let _ = volume_entity::Entity::delete_many() + .filter(volume_entity::Column::Name.eq(&config.name)) + .exec(pools.write()) + .await; + return Err(e.into()); + } + + Ok(Volume::from_local( + backend, + VolumeLocalState { path }, + config.name, + )) +} + +/// Local get path. Loads a volume row by name and wraps it in a +/// [`VolumeHandle`] bound to the supplied backend. +pub(crate) async fn get_local( + backend: Arc, + name: &str, +) -> MicrosandboxResult { + let local_backend = backend + .as_local() + .ok_or_else(|| MicrosandboxError::Unsupported { + feature: "Volume::get_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let db = local_backend.db().await?.read(); + + let model = volume_entity::Entity::find() + .filter(volume_entity::Column::Name.eq(name)) + .one(db) + .await? + .ok_or_else(|| MicrosandboxError::VolumeNotFound(name.into()))?; + + let handle = VolumeHandle::from_local_model(backend, model); + Ok(handle) +} + +/// Local list path. Returns all volumes ordered newest-first. +pub(crate) async fn list_local(backend: Arc) -> MicrosandboxResult> { + let local_backend = backend + .as_local() + .ok_or_else(|| MicrosandboxError::Unsupported { + feature: "Volume::list_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let db = local_backend.db().await?.read(); + + let models = volume_entity::Entity::find() + .order_by_desc(volume_entity::Column::CreatedAt) + .all(db) + .await?; + + Ok(models + .into_iter() + .map(|m| VolumeHandle::from_local_model(backend.clone(), m)) + .collect()) +} + +/// Local remove path. Deletes the DB record first, then the directory. +pub(crate) async fn remove_local(backend: Arc, name: &str) -> MicrosandboxResult<()> { + let local_backend = backend + .as_local() + .ok_or_else(|| MicrosandboxError::Unsupported { + feature: "Volume::remove_local".into(), + available_when: "with a LocalBackend".into(), + })?; + let pools = local_backend.db().await?; + + let model = volume_entity::Entity::find() + .filter(volume_entity::Column::Name.eq(name)) + .one(pools.read()) + .await? + .ok_or_else(|| MicrosandboxError::VolumeNotFound(name.into()))?; + + volume_entity::Entity::delete_by_id(model.id) + .exec(pools.write()) + .await?; + + let path = local_backend.volume_path(name); + if path.exists() { + tokio::fs::remove_dir_all(&path).await?; + } + + Ok(()) +} + //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- diff --git a/docs/sandboxes/lifecycle.mdx b/docs/sandboxes/lifecycle.mdx index 860358bd0..13747d6ed 100644 --- a/docs/sandboxes/lifecycle.mdx +++ b/docs/sandboxes/lifecycle.mdx @@ -263,7 +263,7 @@ msb rm worker ```rust Rust for handle in Sandbox::list().await? { - println!("{}: {:?}", handle.name(), handle.status()); + println!("{}: {:?}", handle.name(), handle.status_snapshot()); } ``` diff --git a/docs/sdk/rust/sandbox.mdx b/docs/sdk/rust/sandbox.mdx index d80fd8764..49aa7cd24 100644 --- a/docs/sdk/rust/sandbox.mdx +++ b/docs/sdk/rust/sandbox.mdx @@ -333,7 +333,7 @@ Whether this handle owns the sandbox lifecycle. `true` in attached mode (sandbox #### remove_persisted() ```rust -async fn remove_persisted(self) -> MicrosandboxResult<()> +async fn remove_persisted(&self) -> MicrosandboxResult<()> ``` Remove the sandbox and all its persisted state from disk. diff --git a/sdk/go/native/src/lib.rs b/sdk/go/native/src/lib.rs index ee6000ae6..bfedb23d6 100644 --- a/sdk/go/native/src/lib.rs +++ b/sdk/go/native/src/lib.rs @@ -1572,6 +1572,8 @@ pub unsafe extern "C" fn msb_sandbox_create( fn sandbox_status_str(s: microsandbox::sandbox::SandboxStatus) -> &'static str { use microsandbox::sandbox::SandboxStatus::*; match s { + Created => "created", + Starting => "starting", Running => "running", Draining => "draining", Paused => "paused", @@ -1593,7 +1595,7 @@ pub unsafe extern "C" fn msb_sandbox_lookup( let h = Sandbox::get(&name).await.map_err(FfiError::from)?; Ok(serde_json::json!({ "name": h.name(), - "status": sandbox_status_str(h.status()), + "status": sandbox_status_str(h.status_snapshot()), "config_json": h.config_json(), "created_at_unix": h.created_at().map(|t| t.timestamp()), "updated_at_unix": h.updated_at().map(|t| t.timestamp()), @@ -1931,7 +1933,7 @@ fn sandbox_handle_json(h: µsandbox::sandbox::SandboxHandle) -> String { format!( r#"{{"name":{name},"status":"{status}","config_json":{config},"created_at_unix":{created},"updated_at_unix":{updated}}}"#, name = name_json, - status = sandbox_status_str(h.status()), + status = sandbox_status_str(h.status_snapshot()), config = cfg_json, ) } @@ -2019,7 +2021,7 @@ pub unsafe extern "C" fn msb_sandbox_logs( let opts = parse_log_options(opts_json)?; Ok(Box::pin(async move { let sb = get(handle)?; - let entries = sb.logs(&opts).map_err(FfiError::from)?; + let entries = sb.logs(&opts).await.map_err(FfiError::from)?; log_entries_json(entries) })) }) @@ -2037,7 +2039,11 @@ pub unsafe extern "C" fn msb_sandbox_handle_logs( let name = unsafe { cstr(name) }?.to_owned(); let opts = parse_log_options(opts_json)?; Ok(Box::pin(async move { - let entries = logs::read_logs(&name, &opts).map_err(FfiError::from)?; + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| FfiError::invalid_argument("logs require a local backend"))?; + let entries = logs::read_logs(local, &name, &opts).map_err(FfiError::from)?; log_entries_json(entries) })) }) @@ -2489,11 +2495,13 @@ fn volume_handle_json(vh: &VolumeHandle) -> String { .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); let labels_json = serde_json::to_string(&labels_map).unwrap_or_else(|_| "{}".into()); - let path = microsandbox::config::config() - .volumes_dir() - .join(vh.name()) - .to_string_lossy() - .into_owned(); + // Best-effort: when the default backend is local we surface the host + // path; otherwise fall back to an empty path since cloud volumes don't + // expose a host-side directory. + let path = microsandbox::backend::default_backend() + .as_local() + .map(|local| local.volume_path(vh.name()).to_string_lossy().into_owned()) + .unwrap_or_default(); let name_json = serde_json::to_string(vh.name()).unwrap_or_else(|_| "\"\"".into()); let path_json = serde_json::to_string(&path).unwrap_or_else(|_| "\"\"".into()); format!( @@ -3071,7 +3079,11 @@ pub unsafe extern "C" fn msb_all_sandbox_metrics( ) -> *mut c_char { run_c(cancel_id, buf, buf_len, || { Ok(Box::pin(async move { - let map = all_sandbox_metrics().await.map_err(FfiError::from)?; + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| FfiError::invalid_argument("metrics require a local backend"))?; + let map = all_sandbox_metrics(local).await.map_err(FfiError::from)?; let mut entries = String::new(); for (name, m) in &map { if !entries.is_empty() { @@ -3214,7 +3226,11 @@ pub unsafe extern "C" fn msb_image_get( run_c(cancel_id, buf, buf_len, || { let reference = unsafe { cstr(reference) }?; Ok(Box::pin(async move { - let h = microsandbox::image::Image::get(&reference) + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| FfiError::invalid_argument("image ops require a local backend"))?; + let h = microsandbox::image::Image::get(local, &reference) .await .map_err(FfiError::from)?; Ok(image_handle_json(&h).to_string()) @@ -3230,7 +3246,11 @@ pub unsafe extern "C" fn msb_image_list( ) -> *mut c_char { run_c(cancel_id, buf, buf_len, || { Ok(Box::pin(async move { - let handles = microsandbox::image::Image::list() + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| FfiError::invalid_argument("image ops require a local backend"))?; + let handles = microsandbox::image::Image::list(local) .await .map_err(FfiError::from)?; let arr: Vec = handles.iter().map(image_handle_json).collect(); @@ -3249,7 +3269,11 @@ pub unsafe extern "C" fn msb_image_inspect( run_c(cancel_id, buf, buf_len, || { let reference = unsafe { cstr(reference) }?; Ok(Box::pin(async move { - let detail = microsandbox::image::Image::inspect(&reference) + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| FfiError::invalid_argument("image ops require a local backend"))?; + let detail = microsandbox::image::Image::inspect(local, &reference) .await .map_err(FfiError::from)?; let config = detail.config.as_ref().map(|c| { @@ -3302,7 +3326,11 @@ pub unsafe extern "C" fn msb_image_remove( run_c(cancel_id, buf, buf_len, || { let reference = unsafe { cstr(reference) }?; Ok(Box::pin(async move { - microsandbox::image::Image::remove(&reference, force) + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| FfiError::invalid_argument("image ops require a local backend"))?; + microsandbox::image::Image::remove(local, &reference, force) .await .map_err(FfiError::from)?; Ok(r#"{"ok":true}"#.into()) @@ -3318,7 +3346,11 @@ pub unsafe extern "C" fn msb_image_gc_layers( ) -> *mut c_char { run_c(cancel_id, buf, buf_len, || { Ok(Box::pin(async move { - let removed = microsandbox::image::Image::gc_layers() + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| FfiError::invalid_argument("image ops require a local backend"))?; + let removed = microsandbox::image::Image::gc_layers(local) .await .map_err(FfiError::from)?; Ok(format!(r#"{{"removed":{removed}}}"#)) @@ -3334,7 +3366,11 @@ pub unsafe extern "C" fn msb_image_gc( ) -> *mut c_char { run_c(cancel_id, buf, buf_len, || { Ok(Box::pin(async move { - let removed = microsandbox::image::Image::gc() + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| FfiError::invalid_argument("image ops require a local backend"))?; + let removed = microsandbox::image::Image::gc(local) .await .map_err(FfiError::from)?; Ok(format!(r#"{{"removed":{removed}}}"#)) diff --git a/sdk/node-ts/native/error.rs b/sdk/node-ts/native/error.rs index 3590ead5a..ed0ce57a4 100644 --- a/sdk/node-ts/native/error.rs +++ b/sdk/node-ts/native/error.rs @@ -16,6 +16,7 @@ fn error_type_str(err: &MicrosandboxError) -> &'static str { match err { MicrosandboxError::Io(_) => "Io", MicrosandboxError::Http(_) => "Http", + MicrosandboxError::CloudHttp { .. } => "CloudHttp", MicrosandboxError::LibkrunfwNotFound(_) => "LibkrunfwNotFound", MicrosandboxError::Database(_) => "Database", MicrosandboxError::InvalidConfig(_) => "InvalidConfig", @@ -44,6 +45,7 @@ fn error_type_str(err: &MicrosandboxError) -> &'static str { MicrosandboxError::SnapshotImageMissing(_) => "SnapshotImageMissing", MicrosandboxError::SnapshotIntegrity(_) => "SnapshotIntegrity", MicrosandboxError::MetricsDisabled(_) => "MetricsDisabled", + MicrosandboxError::Unsupported { .. } => "Unsupported", MicrosandboxError::Custom(_) => "Custom", } } diff --git a/sdk/node-ts/native/image.rs b/sdk/node-ts/native/image.rs index 58a66c9be..7d0083792 100644 --- a/sdk/node-ts/native/image.rs +++ b/sdk/node-ts/native/image.rs @@ -119,10 +119,22 @@ impl JsImageHandle { // Functions //-------------------------------------------------------------------------------------------------- +fn resolve_local() -> Result> { + let backend = microsandbox::backend::default_backend(); + if backend.as_local().is_none() { + return Err(napi::Error::from_reason( + "image ops require a local backend".to_string(), + )); + } + Ok(backend) +} + /// Look up a cached image by reference. #[napi(js_name = "imageGet")] pub async fn image_get(reference: String) -> Result { - let inner = msb_image::Image::get(&reference) + let backend = resolve_local()?; + let local = backend.as_local().expect("checked above"); + let inner = msb_image::Image::get(local, &reference) .await .map_err(to_napi_error)?; Ok(JsImageHandle { inner }) @@ -131,14 +143,18 @@ pub async fn image_get(reference: String) -> Result { /// List all cached images. #[napi(js_name = "imageList")] pub async fn image_list() -> Result> { - let handles = msb_image::Image::list().await.map_err(to_napi_error)?; + let backend = resolve_local()?; + let local = backend.as_local().expect("checked above"); + let handles = msb_image::Image::list(local).await.map_err(to_napi_error)?; Ok(handles.iter().map(image_handle_to_info).collect()) } /// Full inspect (config + layers). #[napi(js_name = "imageInspect")] pub async fn image_inspect(reference: String) -> Result { - let detail = msb_image::Image::inspect(&reference) + let backend = resolve_local()?; + let local = backend.as_local().expect("checked above"); + let detail = msb_image::Image::inspect(local, &reference) .await .map_err(to_napi_error)?; Ok(image_detail_to_js(detail)) @@ -148,7 +164,9 @@ pub async fn image_inspect(reference: String) -> Result { /// sandbox references it. #[napi(js_name = "imageRemove")] pub async fn image_remove(reference: String, force: Option) -> Result<()> { - msb_image::Image::remove(&reference, force.unwrap_or(false)) + let backend = resolve_local()?; + let local = backend.as_local().expect("checked above"); + msb_image::Image::remove(local, &reference, force.unwrap_or(false)) .await .map_err(to_napi_error) } @@ -156,13 +174,19 @@ pub async fn image_remove(reference: String, force: Option) -> Result<()> /// Garbage-collect orphaned layers. Returns the number reclaimed. #[napi(js_name = "imageGcLayers")] pub async fn image_gc_layers() -> Result { - msb_image::Image::gc_layers().await.map_err(to_napi_error) + let backend = resolve_local()?; + let local = backend.as_local().expect("checked above"); + msb_image::Image::gc_layers(local) + .await + .map_err(to_napi_error) } /// Garbage-collect everything reclaimable. Returns the number reclaimed. #[napi(js_name = "imageGc")] pub async fn image_gc() -> Result { - msb_image::Image::gc().await.map_err(to_napi_error) + let backend = resolve_local()?; + let local = backend.as_local().expect("checked above"); + msb_image::Image::gc(local).await.map_err(to_napi_error) } fn image_handle_to_info(h: &ImageHandle) -> ImageInfo { diff --git a/sdk/node-ts/native/metrics.rs b/sdk/node-ts/native/metrics.rs index 3902e8fc7..b30da9969 100644 --- a/sdk/node-ts/native/metrics.rs +++ b/sdk/node-ts/native/metrics.rs @@ -14,7 +14,11 @@ use crate::types::*; /// Get metrics for all running sandboxes. #[napi] pub async fn all_sandbox_metrics() -> Result> { - let metrics = microsandbox::sandbox::all_sandbox_metrics() + let backend = microsandbox::backend::default_backend(); + let local = backend.as_local().ok_or_else(|| { + napi::Error::from_reason("all_sandbox_metrics requires a local backend".to_string()) + })?; + let metrics = microsandbox::sandbox::all_sandbox_metrics(local) .await .map_err(to_napi_error)?; Ok(metrics diff --git a/sdk/node-ts/native/runtime_config.rs b/sdk/node-ts/native/runtime_config.rs index e70030133..9f2d43ced 100644 --- a/sdk/node-ts/native/runtime_config.rs +++ b/sdk/node-ts/native/runtime_config.rs @@ -7,3 +7,59 @@ use napi_derive::napi; pub fn set_runtime_msb_path(path: String) { microsandbox::config::set_sdk_msb_path(path); } + +/// Set the `libkrunfw` shared library path resolved by the JS SDK. +/// +/// Process-level setter — one dylib per process address space, so this is the +/// natural granularity. User env (`MSB_LIBKRUNFW_PATH`) still wins as tier 1. +/// Mirrors `setRuntimeMsbPath` for libkrunfw. +#[napi(js_name = "setRuntimeLibkrunfwPath")] +pub fn set_runtime_libkrunfw_path(path: String) { + microsandbox::config::set_sdk_libkrunfw_path(path); +} + +/// Set the process-wide default backend. +/// +/// `kind="local"` selects the local backend. `kind="cloud"` requires either +/// `url` + `api_key`, or `profile`. +#[napi(js_name = "setDefaultBackend")] +pub fn set_default_backend( + kind: String, + url: Option, + api_key: Option, + profile: Option, +) -> napi::Result<()> { + match kind.trim().to_ascii_lowercase().as_str() { + "local" => microsandbox::set_default_backend(microsandbox::LocalBackend::lazy()), + "cloud" => { + let cloud = if let Some(profile) = profile { + microsandbox::CloudBackend::from_profile(&profile) + } else { + let url = url.ok_or_else(|| { + napi::Error::from_reason("cloud backend requires url + apiKey or profile") + })?; + let api_key = api_key.ok_or_else(|| { + napi::Error::from_reason("cloud backend requires url + apiKey or profile") + })?; + microsandbox::CloudBackend::new(url, api_key) + } + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + microsandbox::set_default_backend(cloud); + } + other => { + return Err(napi::Error::from_reason(format!( + "backend kind must be 'local' or 'cloud', got {other:?}" + ))); + } + } + Ok(()) +} + +/// Return the active default backend kind (`"local"` or `"cloud"`). +#[napi(js_name = "defaultBackendKind")] +pub fn default_backend_kind() -> &'static str { + match microsandbox::default_backend().kind() { + microsandbox::BackendKind::Local => "local", + microsandbox::BackendKind::Cloud => "cloud", + } +} diff --git a/sdk/node-ts/native/sandbox.rs b/sdk/node-ts/native/sandbox.rs index 9ab0c996c..8490dda03 100644 --- a/sdk/node-ts/native/sandbox.rs +++ b/sdk/node-ts/native/sandbox.rs @@ -389,8 +389,12 @@ impl Sandbox { let sb = guard.as_ref().ok_or_else(consumed_error)?; let name = sb.name().to_string(); let rust_opts = log_options_from_js(opts).map_err(napi::Error::from_reason)?; - let entries = - microsandbox::sandbox::logs::read_logs(&name, &rust_opts).map_err(to_napi_error)?; + let backend = microsandbox::backend::default_backend(); + let local = backend + .as_local() + .ok_or_else(|| napi::Error::from_reason("logs require a local backend".to_string()))?; + let entries = microsandbox::sandbox::logs::read_logs(local, &name, &rust_opts) + .map_err(to_napi_error)?; Ok(entries.into_iter().map(log_entry_to_js).collect()) } } @@ -505,7 +509,7 @@ pub fn metrics_to_js(m: µsandbox::sandbox::SandboxMetrics) -> SandboxMetric fn sandbox_handle_to_info(handle: µsandbox::sandbox::SandboxHandle) -> SandboxInfo { SandboxInfo { name: handle.name().to_string(), - status: format!("{:?}", handle.status()).to_lowercase(), + status: format!("{:?}", handle.status_snapshot()).to_lowercase(), config_json: handle.config_json().to_string(), created_at: opt_datetime_to_ms(&handle.created_at()), updated_at: opt_datetime_to_ms(&handle.updated_at()), diff --git a/sdk/node-ts/native/sandbox_builder.rs b/sdk/node-ts/native/sandbox_builder.rs index cc1b8d834..e135dd8ad 100644 --- a/sdk/node-ts/native/sandbox_builder.rs +++ b/sdk/node-ts/native/sandbox_builder.rs @@ -275,13 +275,9 @@ impl JsSandboxBuilder { self } - /// Override the libkrunfw shared library path for this sandbox. - #[napi(js_name = "libkrunfwPath")] - pub fn libkrunfw_path(&mut self, path: String) -> &Self { - let prev = self.take_inner(); - self.inner = Some(prev.libkrunfw_path(PathBuf::from(path))); - self - } + // `libkrunfwPath` is a process-level concern (one dylib per process + // address space), not a per-sandbox builder method. Users set it once via + // `microsandbox.setLibkrunfwPath(...)` or the `MSB_LIBKRUNFW_PATH` env var. /// Default running user. #[napi] diff --git a/sdk/node-ts/native/sandbox_handle.rs b/sdk/node-ts/native/sandbox_handle.rs index e9400d4ab..d29fdf29c 100644 --- a/sdk/node-ts/native/sandbox_handle.rs +++ b/sdk/node-ts/native/sandbox_handle.rs @@ -39,7 +39,7 @@ impl JsSandboxHandle { /// Status at time of query: "running", "stopped", "crashed", or "draining". #[napi(getter)] pub fn status(&self) -> String { - format!("{:?}", self.inner.status()).to_lowercase() + format!("{:?}", self.inner.status_snapshot()).to_lowercase() } /// Raw config JSON string from the database. diff --git a/sdk/node-ts/native/snapshot.rs b/sdk/node-ts/native/snapshot.rs index df7c47085..0f48b27e9 100644 --- a/sdk/node-ts/native/snapshot.rs +++ b/sdk/node-ts/native/snapshot.rs @@ -134,7 +134,10 @@ impl JsSnapshot { pub async fn reindex(dir: Option) -> Result { let dir = match dir { Some(p) => PathBuf::from(p), - None => microsandbox::config::config().snapshots_dir(), + None => microsandbox::backend::default_backend() + .as_local() + .map(|l| l.snapshots_dir()) + .unwrap_or_else(|| PathBuf::from(".")), }; let n = RustSnapshot::reindex(&dir).await.map_err(to_napi_error)?; Ok(n as u32) diff --git a/sdk/node-ts/native/volume.rs b/sdk/node-ts/native/volume.rs index d412f051e..86c984adc 100644 --- a/sdk/node-ts/native/volume.rs +++ b/sdk/node-ts/native/volume.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; -use std::path::PathBuf; use std::sync::Arc; +use microsandbox::Backend; + use microsandbox::sandbox::FsEntry as RustFsEntry; use microsandbox::sandbox::FsEntryKind as RustFsEntryKind; use microsandbox::sandbox::FsMetadata as RustFsMetadata; -use microsandbox::volume::fs::{VolumeFs, VolumeFsReadStream, VolumeFsWriteSink}; +use microsandbox::volume::fs::{VolumeFsReadStream, VolumeFsWriteSink}; use microsandbox::volume::{Volume, VolumeHandle}; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -26,12 +27,12 @@ pub struct JsVolume { #[napi(js_name = "VolumeHandle")] pub struct JsVolumeHandle { inner: VolumeHandle, - path: PathBuf, } #[napi(js_name = "VolumeFs")] pub struct JsVolumeFs { - path: PathBuf, + backend: Arc, + name: String, } #[napi(async_iterator, js_name = "VolumeFsReadStream")] @@ -53,13 +54,7 @@ impl JsVolume { #[napi] pub async fn get(name: String) -> Result { let handle = Volume::get(&name).await.map_err(to_napi_error)?; - let path = microsandbox::config::config() - .volumes_dir() - .join(handle.name()); - Ok(JsVolumeHandle { - inner: handle, - path, - }) + Ok(JsVolumeHandle { inner: handle }) } #[napi] @@ -79,15 +74,21 @@ impl JsVolume { } #[napi(getter)] - pub fn path(&self) -> String { - self.inner.path().to_string_lossy().to_string() + pub fn path(&self) -> Result { + Ok(self + .inner + .path() + .map_err(to_napi_error)? + .to_string_lossy() + .to_string()) } /// Host-side filesystem operations on this volume's directory. #[napi] pub fn fs(&self) -> JsVolumeFs { JsVolumeFs { - path: self.inner.path().to_path_buf(), + backend: microsandbox::default_backend(), + name: self.inner.name().to_string(), } } } @@ -139,15 +140,20 @@ impl JsVolumeHandle { #[napi] pub fn fs(&self) -> JsVolumeFs { JsVolumeFs { - path: self.path.clone(), + backend: microsandbox::default_backend(), + name: self.inner.name().to_string(), } } } #[napi] impl JsVolumeFs { - fn make(&self) -> VolumeFs<'_> { - VolumeFs::from_path(self.path.clone()) + fn make(&self) -> microsandbox::volume::VolumeFs<'_> { + // VolumeFs borrows the parent's backend + name; we keep an owned copy + // of each on `JsVolumeFs` and re-construct per call. + // Safety / lifetime note: the temporary VolumeFs only lives for the + // duration of each FFI call. + microsandbox::volume::VolumeFs::with_backend(self.backend.clone(), &self.name) } #[napi] diff --git a/sdk/node-ts/src/errors.ts b/sdk/node-ts/src/errors.ts index 7869b4deb..ba00601b6 100644 --- a/sdk/node-ts/src/errors.ts +++ b/sdk/node-ts/src/errors.ts @@ -1,10 +1,12 @@ export type MicrosandboxErrorCode = | "io" | "http" + | "cloudHttp" | "libkrunfwNotFound" | "database" | "invalidConfig" | "sandboxNotFound" + | "sandboxAlreadyExists" | "sandboxStillRunning" | "runtime" | "json" @@ -20,6 +22,7 @@ export type MicrosandboxErrorCode = | "image" | "patchFailed" | "metricsDisabled" + | "unsupported" | "custom"; export class MicrosandboxError extends Error { @@ -44,6 +47,12 @@ export class HttpError extends MicrosandboxError { } } +export class CloudHttpError extends MicrosandboxError { + constructor(message: string, options?: ErrorOptions) { + super("cloudHttp", message, options); + } +} + export class LibkrunfwNotFoundError extends MicrosandboxError { constructor(message: string, options?: ErrorOptions) { super("libkrunfwNotFound", message, options); @@ -68,6 +77,12 @@ export class SandboxNotFoundError extends MicrosandboxError { } } +export class SandboxAlreadyExistsError extends MicrosandboxError { + constructor(message: string, options?: ErrorOptions) { + super("sandboxAlreadyExists", message, options); + } +} + export class SandboxStillRunningError extends MicrosandboxError { constructor(message: string, options?: ErrorOptions) { super("sandboxStillRunning", message, options); @@ -161,6 +176,12 @@ export class MetricsDisabledError extends MicrosandboxError { } } +export class UnsupportedError extends MicrosandboxError { + constructor(message: string, options?: ErrorOptions) { + super("unsupported", message, options); + } +} + export class CustomError extends MicrosandboxError { constructor(message: string, options?: ErrorOptions) { super("custom", message, options); diff --git a/sdk/node-ts/src/index.ts b/sdk/node-ts/src/index.ts index 3c697a02d..eff715352 100644 --- a/sdk/node-ts/src/index.ts +++ b/sdk/node-ts/src/index.ts @@ -1,6 +1,9 @@ import { mapNapiError } from "./internal/error-mapping.js"; import { napi } from "./internal/napi.js"; +export { defaultBackendKind, setDefaultBackend } from "./runtime.js"; +export type { DefaultBackend } from "./runtime.js"; + // Sandbox lifecycle and execution export { PullProgressCreate, Sandbox } from "./sandbox.js"; import { Sandbox as _Sandbox, type SandboxBuilder as _SBT } from "./sandbox.js"; @@ -325,6 +328,7 @@ export { allSandboxMetrics } from "./all-metrics.js"; // Errors export { CustomError, + CloudHttpError, DatabaseError, ExecTimeoutError, HttpError, @@ -341,10 +345,12 @@ export { PatchFailedError, ProtocolError, RuntimeError, + SandboxAlreadyExistsError, SandboxFsError, SandboxNotFoundError, SandboxStillRunningError, TerminalError, + UnsupportedError, VolumeAlreadyExistsError, VolumeNotFoundError, } from "./errors.js"; diff --git a/sdk/node-ts/src/internal/error-mapping.ts b/sdk/node-ts/src/internal/error-mapping.ts index 9ab4e7658..9e981dc22 100644 --- a/sdk/node-ts/src/internal/error-mapping.ts +++ b/sdk/node-ts/src/internal/error-mapping.ts @@ -1,5 +1,6 @@ import { CustomError, + CloudHttpError, DatabaseError, ExecTimeoutError, HttpError, @@ -17,9 +18,11 @@ import { ProtocolError, RuntimeError, SandboxFsError, + SandboxAlreadyExistsError, SandboxNotFoundError, SandboxStillRunningError, TerminalError, + UnsupportedError, VolumeAlreadyExistsError, VolumeNotFoundError, } from "../errors.js"; @@ -30,10 +33,12 @@ const PATTERN = /^\[(\w+)\] ([\s\S]*)$/; const CTORS = new Map MicrosandboxError>([ ["Io", (m, c) => new IoError(m, { cause: c })], ["Http", (m, c) => new HttpError(m, { cause: c })], + ["CloudHttp", (m, c) => new CloudHttpError(m, { cause: c })], ["LibkrunfwNotFound", (m, c) => new LibkrunfwNotFoundError(m, { cause: c })], ["Database", (m, c) => new DatabaseError(m, { cause: c })], ["InvalidConfig", (m, c) => new InvalidConfigError(m, { cause: c })], ["SandboxNotFound", (m, c) => new SandboxNotFoundError(m, { cause: c })], + ["SandboxAlreadyExists", (m, c) => new SandboxAlreadyExistsError(m, { cause: c })], ["SandboxStillRunning", (m, c) => new SandboxStillRunningError(m, { cause: c })], ["Runtime", (m, c) => new RuntimeError(m, { cause: c })], ["Json", (m, c) => new JsonError(m, { cause: c })], @@ -49,6 +54,7 @@ const CTORS = new Map MicrosandboxError>([ ["Image", (m, c) => new ImageError(m, { cause: c })], ["PatchFailed", (m, c) => new PatchFailedError(m, { cause: c })], ["MetricsDisabled", (m, c) => new MetricsDisabledError(m, { cause: c })], + ["Unsupported", (m, c) => new UnsupportedError(m, { cause: c })], ["Custom", (m, c) => new CustomError(m, { cause: c })], ]); diff --git a/sdk/node-ts/src/internal/napi.ts b/sdk/node-ts/src/internal/napi.ts index 8831d03d8..88d3b07cb 100644 --- a/sdk/node-ts/src/internal/napi.ts +++ b/sdk/node-ts/src/internal/napi.ts @@ -22,6 +22,13 @@ export const napi = native; export interface NativeBindings { readonly setRuntimeMsbPath?: (path: string) => void; + readonly setDefaultBackend?: ( + kind: string, + url?: string, + apiKey?: string, + profile?: string, + ) => void; + readonly defaultBackendKind?: () => "local" | "cloud"; readonly Sandbox: NapiSandboxStatic; readonly SandboxBuilder: NapiSandboxBuilderCtor; readonly Volume: NapiVolumeStatic; diff --git a/sdk/node-ts/src/runtime.ts b/sdk/node-ts/src/runtime.ts new file mode 100644 index 000000000..26bcbfcce --- /dev/null +++ b/sdk/node-ts/src/runtime.ts @@ -0,0 +1,42 @@ +import { napi } from "./internal/napi.js"; + +export type DefaultBackend = + | "local" + | { + kind: "cloud"; + url: string; + apiKey: string; + } + | { + kind: "cloud"; + profile: string; + }; + +/** Set the process-wide default backend used by SDK entry points. */ +export function setDefaultBackend(backend: DefaultBackend): void { + const setNative = napi.setDefaultBackend; + if (!setNative) { + throw new Error("native setDefaultBackend binding is unavailable"); + } + + if (backend === "local") { + setNative("local"); + return; + } + + if ("profile" in backend) { + setNative("cloud", undefined, undefined, backend.profile); + return; + } + + setNative("cloud", backend.url, backend.apiKey); +} + +/** Return the active default backend kind. */ +export function defaultBackendKind(): "local" | "cloud" { + const getNative = napi.defaultBackendKind; + if (!getNative) { + throw new Error("native defaultBackendKind binding is unavailable"); + } + return getNative(); +} diff --git a/sdk/python/microsandbox/__init__.py b/sdk/python/microsandbox/__init__.py index f5d6470af..b1c7dc92f 100644 --- a/sdk/python/microsandbox/__init__.py +++ b/sdk/python/microsandbox/__init__.py @@ -21,15 +21,21 @@ Volume, VolumeHandle, all_sandbox_metrics, + default_backend_kind, install, is_installed, + set_default_backend, version, ) +from microsandbox._microsandbox import ( + set_runtime_libkrunfw_path as set_libkrunfw_path, +) from microsandbox._microsandbox import ( set_runtime_msb_path as _set_runtime_msb_path, ) from microsandbox._runtime import msb_path as _msb_path from microsandbox.errors import ( + CloudHttpError, ExecFailedError, ExecTimeoutError, FilesystemError, @@ -47,6 +53,7 @@ SandboxStillRunningError, SecretViolationError, TlsError, + UnsupportedError, VolumeNotFoundError, ) from microsandbox.events import ( @@ -206,6 +213,7 @@ # Errors "MicrosandboxError", "InvalidConfigError", + "CloudHttpError", "SandboxNotFoundError", "SandboxNotRunningError", "SandboxAlreadyExistsError", @@ -222,8 +230,12 @@ "TlsError", "IoError", "MetricsDisabledError", + "UnsupportedError", # Setup "install", "is_installed", + "set_libkrunfw_path", + "set_default_backend", + "default_backend_kind", "version", ] diff --git a/sdk/python/microsandbox/_runtime.py b/sdk/python/microsandbox/_runtime.py index 344a23201..5eb538299 100644 --- a/sdk/python/microsandbox/_runtime.py +++ b/sdk/python/microsandbox/_runtime.py @@ -10,8 +10,11 @@ wrapping needed. libkrunfw is located by the Rust resolver relative to ``msb`` (``../lib/``), -which matches the wheel bundle layout. Pass ``libkrunfw_path`` to -``Sandbox.create(...)`` for per-sandbox overrides. +which matches the wheel bundle layout. To override it, set the +``MSB_LIBKRUNFW_PATH`` env var (highest precedence) or call +``microsandbox.set_libkrunfw_path(...)`` once at startup. libkrunfw is a +process-level concern (one dylib per process address space), so per-sandbox +overrides aren't supported. """ from __future__ import annotations diff --git a/sdk/python/microsandbox/errors.py b/sdk/python/microsandbox/errors.py index 142856f98..147cc5b89 100644 --- a/sdk/python/microsandbox/errors.py +++ b/sdk/python/microsandbox/errors.py @@ -17,6 +17,11 @@ class InvalidConfigError(MicrosandboxError): code = "invalid-config" +class CloudHttpError(MicrosandboxError): + """Cloud control-plane request failed.""" + code = "cloud-http" + + class SandboxNotFoundError(MicrosandboxError): """Sandbox does not exist.""" code = "sandbox-not-found" @@ -95,3 +100,8 @@ class IoError(MicrosandboxError): class MetricsDisabledError(MicrosandboxError): """Metrics sampling is disabled for this sandbox.""" code = "metrics-disabled" + + +class UnsupportedError(MicrosandboxError): + """The selected backend does not support a requested feature yet.""" + code = "unsupported" diff --git a/sdk/python/src/error.rs b/sdk/python/src/error.rs index 3c9f7ec37..4cabbb402 100644 --- a/sdk/python/src/error.rs +++ b/sdk/python/src/error.rs @@ -22,6 +22,7 @@ pub fn to_py_err(err: microsandbox::MicrosandboxError) -> PyErr { let (cls_name, msg) = match &err { InvalidConfig(_) => ("InvalidConfigError", err.to_string()), + CloudHttp { .. } => ("CloudHttpError", err.to_string()), SandboxNotFound(_) => ("SandboxNotFoundError", err.to_string()), SandboxAlreadyExists(_) => ("SandboxAlreadyExistsError", err.to_string()), SandboxStillRunning(_) => ("SandboxStillRunningError", err.to_string()), @@ -31,6 +32,7 @@ pub fn to_py_err(err: microsandbox::MicrosandboxError) -> PyErr { VolumeNotFound(_) => ("VolumeNotFoundError", err.to_string()), Io(_) => ("IoError", err.to_string()), MetricsDisabled(_) => ("MetricsDisabledError", err.to_string()), + Unsupported { .. } => ("UnsupportedError", err.to_string()), Terminal(_) => ("MicrosandboxError", err.to_string()), _ => ("MicrosandboxError", err.to_string()), }; diff --git a/sdk/python/src/fs.rs b/sdk/python/src/fs.rs index 5ee29a53d..6555ba5e6 100644 --- a/sdk/python/src/fs.rs +++ b/sdk/python/src/fs.rs @@ -10,10 +10,12 @@ use crate::error::to_py_err; //-------------------------------------------------------------------------------------------------- /// Filesystem operations on a running sandbox. -/// Holds a direct Arc — no Sandbox mutex lock per operation. +/// Holds the backend Arc + sandbox name; each op dispatches through the +/// `SandboxBackend` trait. No Sandbox mutex lock per operation. #[pyclass(name = "SandboxFs")] pub struct PySandboxFs { - client: Arc, + backend: Arc, + name: String, } /// Streaming reader for file data. @@ -33,8 +35,8 @@ pub struct PyFsWriteSink { //-------------------------------------------------------------------------------------------------- impl PySandboxFs { - pub fn from_client(client: Arc) -> Self { - Self { client } + pub fn from_backend(backend: Arc, name: String) -> Self { + Self { backend, name } } } @@ -42,9 +44,10 @@ impl PySandboxFs { impl PySandboxFs { /// Read an entire file as bytes. fn read<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); let data = fs.read(&path).await.map_err(to_py_err)?; Ok(data.to_vec()) }) @@ -52,9 +55,10 @@ impl PySandboxFs { /// Read a file as a UTF-8 string. fn read_text<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); let text = fs.read_to_string(&path).await.map_err(to_py_err)?; Ok(text) }) @@ -62,9 +66,10 @@ impl PySandboxFs { /// Read a file with streaming. Returns an async iterator of bytes chunks. fn read_stream<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); let stream = fs.read_stream(&path).await.map_err(to_py_err)?; Ok(PyFsReadStream { inner: Arc::new(Mutex::new(stream)), @@ -79,9 +84,10 @@ impl PySandboxFs { path: String, data: Vec, ) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); fs.write(&path, &data).await.map_err(to_py_err)?; Ok(()) }) @@ -89,9 +95,10 @@ impl PySandboxFs { /// Write with streaming. Returns an async context manager. fn write_stream<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); let sink = fs.write_stream(&path).await.map_err(to_py_err)?; Ok(PyFsWriteSink { inner: Arc::new(Mutex::new(Some(sink))), @@ -101,9 +108,10 @@ impl PySandboxFs { /// List directory contents. fn list<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); let entries = fs.list(&path).await.map_err(to_py_err)?; let py_entries: Vec = entries.into_iter().map(convert_fs_entry).collect(); Ok(py_entries) @@ -112,9 +120,10 @@ impl PySandboxFs { /// Create a directory (and parents). fn mkdir<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); fs.mkdir(&path).await.map_err(to_py_err)?; Ok(()) }) @@ -122,9 +131,10 @@ impl PySandboxFs { /// Remove a file. fn remove<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); fs.remove(&path).await.map_err(to_py_err)?; Ok(()) }) @@ -132,9 +142,10 @@ impl PySandboxFs { /// Remove a directory recursively. fn remove_dir<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); fs.remove_dir(&path).await.map_err(to_py_err)?; Ok(()) }) @@ -142,9 +153,10 @@ impl PySandboxFs { /// Copy a file within the sandbox. fn copy<'py>(&self, py: Python<'py>, src: String, dst: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); fs.copy(&src, &dst).await.map_err(to_py_err)?; Ok(()) }) @@ -157,9 +169,10 @@ impl PySandboxFs { src: String, dst: String, ) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); fs.rename(&src, &dst).await.map_err(to_py_err)?; Ok(()) }) @@ -167,9 +180,10 @@ impl PySandboxFs { /// Get file/directory metadata. fn stat<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); let meta = fs.stat(&path).await.map_err(to_py_err)?; Ok(convert_fs_metadata(&meta)) }) @@ -177,9 +191,10 @@ impl PySandboxFs { /// Check if a path exists. fn exists<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); let exists = fs.exists(&path).await.map_err(to_py_err)?; Ok(exists) }) @@ -192,9 +207,10 @@ impl PySandboxFs { host_path: String, guest_path: String, ) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); fs.copy_from_host(&host_path, &guest_path) .await .map_err(to_py_err)?; @@ -209,9 +225,10 @@ impl PySandboxFs { guest_path: String, host_path: String, ) -> PyResult> { - let client = Arc::clone(&self.client); + let backend = self.backend.clone(); + let name = self.name.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::sandbox::SandboxFs::new(&client); + let fs = microsandbox::sandbox::SandboxFs::with_backend(backend, &name); fs.copy_to_host(&guest_path, &host_path) .await .map_err(to_py_err)?; diff --git a/sdk/python/src/helpers.rs b/sdk/python/src/helpers.rs index c5a7ef035..cfcc7e3ea 100644 --- a/sdk/python/src/helpers.rs +++ b/sdk/python/src/helpers.rs @@ -129,9 +129,9 @@ pub fn sandbox_builder_from_args( if let Some(hostname) = extract_opt::(kwargs, "hostname")? { builder = builder.hostname(hostname); } - if let Some(libkrunfw_path) = extract_opt::(kwargs, "libkrunfw_path")? { - builder = builder.libkrunfw_path(libkrunfw_path); - } + // `libkrunfw_path` is a process-level concern (one dylib per process + // address space), not a per-sandbox builder kwarg. Users set it once via + // `microsandbox.set_libkrunfw_path(...)` or the `MSB_LIBKRUNFW_PATH` env var. if let Some(user) = extract_opt::(kwargs, "user")? { builder = builder.user(user); } @@ -1002,6 +1002,9 @@ fn resolve_snapshot_dir(s: &str) -> std::path::PathBuf { if s.contains('/') || s.starts_with('.') || s.starts_with('~') { std::path::PathBuf::from(s) } else { - microsandbox::config::config().snapshots_dir().join(s) + microsandbox::backend::default_backend() + .as_local() + .map(|local| local.snapshots_dir().join(s)) + .unwrap_or_else(|| std::path::PathBuf::from(s)) } } diff --git a/sdk/python/src/lib.rs b/sdk/python/src/lib.rs index bd938bbd1..49597e54e 100644 --- a/sdk/python/src/lib.rs +++ b/sdk/python/src/lib.rs @@ -23,6 +23,9 @@ fn _microsandbox(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(setup::install, m)?)?; m.add_function(wrap_pyfunction!(setup::is_installed, m)?)?; m.add_function(wrap_pyfunction!(set_runtime_msb_path, m)?)?; + m.add_function(wrap_pyfunction!(set_runtime_libkrunfw_path, m)?)?; + m.add_function(wrap_pyfunction!(set_default_backend, m)?)?; + m.add_function(wrap_pyfunction!(default_backend_kind, m)?)?; m.add_function(wrap_pyfunction!(resolved_msb_path, m)?)?; m.add_function(wrap_pyfunction!(metrics::all_sandbox_metrics, m)?)?; m.add_class::()?; @@ -60,12 +63,80 @@ fn set_runtime_msb_path(path: String) { microsandbox::config::set_sdk_msb_path(path); } +/// Set the `libkrunfw` shared library path resolved by the Python SDK. +/// +/// Process-level setter — one dylib per process address space, so this is the +/// natural granularity. User env (`MSB_LIBKRUNFW_PATH`) still wins. Mirrors +/// `set_runtime_msb_path` for libkrunfw. +#[pyfunction] +fn set_runtime_libkrunfw_path(path: String) { + microsandbox::config::set_sdk_libkrunfw_path(path); +} + +/// Set the process-wide default backend. +/// +/// `kind="local"` selects the local libkrun backend. `kind="cloud"` requires +/// either `url` + `api_key`, or `profile`. +#[pyfunction] +#[pyo3(signature = (kind, *, url=None, api_key=None, profile=None))] +fn set_default_backend( + kind: String, + url: Option, + api_key: Option, + profile: Option, +) -> PyResult<()> { + match kind.trim().to_ascii_lowercase().as_str() { + "local" => microsandbox::set_default_backend(microsandbox::LocalBackend::lazy()), + "cloud" => { + let cloud = if let Some(profile) = profile { + microsandbox::CloudBackend::from_profile(&profile) + } else { + let url = url.ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "cloud backend requires url + api_key or profile", + ) + })?; + let api_key = api_key.ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "cloud backend requires url + api_key or profile", + ) + })?; + microsandbox::CloudBackend::new(url, api_key) + } + .map_err(error::to_py_err)?; + microsandbox::set_default_backend(cloud); + } + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "backend kind must be 'local' or 'cloud', got {other:?}" + ))); + } + } + Ok(()) +} + +/// Return the active default backend kind (`"local"` or `"cloud"`). +#[pyfunction] +fn default_backend_kind() -> &'static str { + match microsandbox::default_backend().kind() { + microsandbox::BackendKind::Local => "local", + microsandbox::BackendKind::Cloud => "cloud", + } +} + /// Return the `msb` binary path the native resolver would currently use. /// /// Intended as a test/diagnostic hook for verifying the Python-to-native bridge. #[pyfunction] fn resolved_msb_path() -> PyResult { - microsandbox::config::resolve_msb_path() + let backend = microsandbox::backend::default_backend(); + let local = backend.as_local().ok_or_else(|| { + error::to_py_err(microsandbox::MicrosandboxError::Unsupported { + feature: "resolved_msb_path requires a local backend".into(), + available_when: "with a local backend".into(), + }) + })?; + microsandbox::config::resolve_msb_path(local.config()) .map(|path| path.to_string_lossy().into_owned()) .map_err(error::to_py_err) } diff --git a/sdk/python/src/logs.rs b/sdk/python/src/logs.rs index 59644b4c8..e3c430fb4 100644 --- a/sdk/python/src/logs.rs +++ b/sdk/python/src/logs.rs @@ -111,7 +111,14 @@ pub fn read_logs_blocking( } } - let entries = microsandbox::sandbox::logs::read_logs(name, &opts).map_err(to_py_err)?; + let backend = microsandbox::backend::default_backend(); + let local = backend.as_local().ok_or_else(|| { + to_py_err(microsandbox::MicrosandboxError::Unsupported { + feature: "logs require a local backend".into(), + available_when: "when cloud logs land".into(), + }) + })?; + let entries = microsandbox::sandbox::logs::read_logs(local, name, &opts).map_err(to_py_err)?; Ok(entries.into_iter().map(convert_entry).collect()) } diff --git a/sdk/python/src/metrics.rs b/sdk/python/src/metrics.rs index 3f198c8b8..a2ad25cf0 100644 --- a/sdk/python/src/metrics.rs +++ b/sdk/python/src/metrics.rs @@ -107,7 +107,14 @@ pub fn convert_metrics(m: µsandbox::sandbox::SandboxMetrics) -> PySandboxMe #[pyfunction] pub fn all_sandbox_metrics<'py>(py: Python<'py>) -> PyResult> { pyo3_async_runtimes::tokio::future_into_py(py, async move { - let metrics = microsandbox::sandbox::all_sandbox_metrics() + let backend = microsandbox::backend::default_backend(); + let local = backend.as_local().ok_or_else(|| { + to_py_err(microsandbox::MicrosandboxError::Unsupported { + feature: "all_sandbox_metrics requires a local backend".into(), + available_when: "when cloud metrics land".into(), + }) + })?; + let metrics = microsandbox::sandbox::all_sandbox_metrics(local) .await .map_err(to_py_err)?; let result: std::collections::HashMap = metrics diff --git a/sdk/python/src/sandbox.rs b/sdk/python/src/sandbox.rs index 675692a2c..8f6e9aed1 100644 --- a/sdk/python/src/sandbox.rs +++ b/sdk/python/src/sandbox.rs @@ -215,7 +215,8 @@ impl PySandbox { }) } - /// Get a filesystem handle. Extracts the AgentClient Arc — no lock per FS op. + /// Get a filesystem handle. Captures the backend Arc + name once — no + /// Sandbox mutex lock per FS op. #[getter] fn fs(&self) -> PyResult { let guard = self @@ -223,7 +224,10 @@ impl PySandbox { .try_lock() .map_err(|_| pyo3::exceptions::PyRuntimeError::new_err("sandbox is busy"))?; let sb = guard.as_ref().ok_or_else(crate::error::consumed)?; - Ok(PySandboxFs::from_client(sb.client_arc())) + Ok(PySandboxFs::from_backend( + sb.backend().clone(), + sb.name().to_string(), + )) } //---------------------------------------------------------------------------------------------- diff --git a/sdk/python/src/sandbox_handle.rs b/sdk/python/src/sandbox_handle.rs index 154a48602..72c91d823 100644 --- a/sdk/python/src/sandbox_handle.rs +++ b/sdk/python/src/sandbox_handle.rs @@ -48,7 +48,7 @@ impl PySandboxHandle { .inner .try_lock() .map_err(|_| pyo3::exceptions::PyRuntimeError::new_err("handle is busy"))?; - Ok(format!("{:?}", guard.status()).to_lowercase()) + Ok(format!("{:?}", guard.status_snapshot()).to_lowercase()) } /// Raw config JSON string. diff --git a/sdk/python/src/snapshot.rs b/sdk/python/src/snapshot.rs index eae5ae239..878e850c2 100644 --- a/sdk/python/src/snapshot.rs +++ b/sdk/python/src/snapshot.rs @@ -163,7 +163,12 @@ impl PySnapshot { #[staticmethod] #[pyo3(signature = (dir = None))] fn reindex<'py>(py: Python<'py>, dir: Option) -> PyResult> { - let dir = dir.unwrap_or_else(|| microsandbox::config::config().snapshots_dir()); + let dir = dir.unwrap_or_else(|| { + microsandbox::backend::default_backend() + .as_local() + .map(|l| l.snapshots_dir()) + .unwrap_or_else(|| std::path::PathBuf::from(".")) + }); pyo3_async_runtimes::tokio::future_into_py(py, async move { let n = RustSnapshot::reindex(&dir).await.map_err(to_py_err)?; Ok(n) diff --git a/sdk/python/src/volume.rs b/sdk/python/src/volume.rs index 782507c29..0f57157a3 100644 --- a/sdk/python/src/volume.rs +++ b/sdk/python/src/volume.rs @@ -48,9 +48,13 @@ impl PyVolume { } } let vol = builder.create().await.map_err(to_py_err)?; + let path = vol + .path() + .map(|p| p.display().to_string()) + .map_err(to_py_err)?; Ok(PyVolume { name: vol.name().to_string(), - path: vol.path().display().to_string(), + path, }) }) } @@ -217,11 +221,9 @@ impl PyVolumeHandle { /// Host-side filesystem operations on this volume. #[getter] fn fs(&self) -> PyVolumeFs { - let vol_dir = microsandbox::config::config() - .volumes_dir() - .join(self.inner.name()); PyVolumeFs { - vol_dir: vol_dir.to_string_lossy().into(), + name: self.inner.name().to_string().into(), + backend: microsandbox::default_backend(), } } } @@ -231,27 +233,30 @@ impl PyVolumeHandle { //-------------------------------------------------------------------------------------------------- /// Host-side filesystem operations on a volume (no running sandbox needed). -/// Path resolved once at construction — zero DB lookups per operation. +/// Holds the volume name + a backend Arc; constructs a [`VolumeFs`] per op. #[pyclass(name = "VolumeFs")] pub struct PyVolumeFs { - vol_dir: Arc, + name: Arc, + backend: Arc, } #[pymethods] impl PyVolumeFs { fn read<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let dir = Arc::clone(&self.vol_dir); + let name = Arc::clone(&self.name); + let backend = Arc::clone(&self.backend); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::volume::VolumeFs::from_path((*dir).into()); + let fs = microsandbox::volume::VolumeFs::with_backend(backend, &name); let data = fs.read(&path).await.map_err(to_py_err)?; Ok(data.to_vec()) }) } fn read_text<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let dir = Arc::clone(&self.vol_dir); + let name = Arc::clone(&self.name); + let backend = Arc::clone(&self.backend); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::volume::VolumeFs::from_path((*dir).into()); + let fs = microsandbox::volume::VolumeFs::with_backend(backend, &name); let text = fs.read_to_string(&path).await.map_err(to_py_err)?; Ok(text) }) @@ -263,18 +268,20 @@ impl PyVolumeFs { path: String, data: Vec, ) -> PyResult> { - let dir = Arc::clone(&self.vol_dir); + let name = Arc::clone(&self.name); + let backend = Arc::clone(&self.backend); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::volume::VolumeFs::from_path((*dir).into()); + let fs = microsandbox::volume::VolumeFs::with_backend(backend, &name); fs.write(&path, &data).await.map_err(to_py_err)?; Ok(()) }) } fn list<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let dir = Arc::clone(&self.vol_dir); + let name = Arc::clone(&self.name); + let backend = Arc::clone(&self.backend); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::volume::VolumeFs::from_path((*dir).into()); + let fs = microsandbox::volume::VolumeFs::with_backend(backend, &name); let entries = fs.list(&path).await.map_err(to_py_err)?; let py_entries: Vec = entries .into_iter() @@ -285,27 +292,30 @@ impl PyVolumeFs { } fn mkdir<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let dir = Arc::clone(&self.vol_dir); + let name = Arc::clone(&self.name); + let backend = Arc::clone(&self.backend); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::volume::VolumeFs::from_path((*dir).into()); + let fs = microsandbox::volume::VolumeFs::with_backend(backend, &name); fs.mkdir(&path).await.map_err(to_py_err)?; Ok(()) }) } fn remove_file<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let dir = Arc::clone(&self.vol_dir); + let name = Arc::clone(&self.name); + let backend = Arc::clone(&self.backend); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::volume::VolumeFs::from_path((*dir).into()); + let fs = microsandbox::volume::VolumeFs::with_backend(backend, &name); fs.remove(&path).await.map_err(to_py_err)?; Ok(()) }) } fn exists<'py>(&self, py: Python<'py>, path: String) -> PyResult> { - let dir = Arc::clone(&self.vol_dir); + let name = Arc::clone(&self.name); + let backend = Arc::clone(&self.backend); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let fs = microsandbox::volume::VolumeFs::from_path((*dir).into()); + let fs = microsandbox::volume::VolumeFs::with_backend(backend, &name); let exists = fs.exists(&path).await.map_err(to_py_err)?; Ok(exists) }) diff --git a/skills b/skills index c0a25d935..8d6e2c5d9 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit c0a25d935c9e717cf40126f1cdca80043cc9ee8a +Subproject commit 8d6e2c5d9760a6dcd1bc09a069f8d764da910110