pub(crate) mod attach;
mod builder;
pub(crate) mod config;
pub mod exec;
pub mod fs;
mod handle;
pub mod init;
pub(crate) mod metrics;
mod modify;
mod patch;
#[cfg(windows)]
mod reap;
#[cfg(feature = "ssh")]
pub mod ssh;
mod types;
pub(crate) mod upper;
use std::{collections::BTreeMap, path::Path, process::ExitStatus, sync::Arc};
use microsandbox_db::DbReadConnection;
use microsandbox_protocol::{
core::{CoreError, Ping, Pong, Touch, Touched},
exec::{ExecRequest, ExecRlimit},
message::MessageType,
};
use microsandbox_types::hostname_from_sandbox_name as derive_hostname;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use microsandbox_image::progress_channel;
use crate::{
MicrosandboxResult,
agent::AgentClient,
db::entity::sandbox as sandbox_entity,
error::{Operation, UnsupportedReason},
runtime::SpawnMode,
};
use self::exec::{ExecHandle, ExecOptions};
pub(crate) const RESERVED_LABEL_PREFIXES: [&str; 3] = ["sandbox.", "microsandbox.", "service."];
pub fn validate_sandbox_name(name: &str) -> MicrosandboxResult<()> {
microsandbox_types::validate_sandbox_name(name).map_err(Into::into)
}
pub(super) fn validate_hostname(hostname: Option<&str>) -> MicrosandboxResult<()> {
microsandbox_types::validate_hostname(hostname).map_err(Into::into)
}
pub(crate) fn sandbox_name_validation_message(name: &str) -> Option<String> {
validate_sandbox_name(name).err().map(|err| err.to_string())
}
pub(crate) fn reserved_label_prefix(key: &str) -> Option<&'static str> {
RESERVED_LABEL_PREFIXES
.iter()
.copied()
.find(|prefix| key.starts_with(prefix))
}
pub(crate) use patch::{apply_patches, build_upper_tree};
#[cfg(windows)]
pub(crate) use reap::reap_leaked_runtime_process;
pub(crate) use types::validate_named_disk_mount_options;
pub(crate) use types::validate_volume_mounts;
pub use crate::db::entity::sandbox::SandboxStatus;
pub use crate::logs::{LogEntry, LogOptions, LogSource, LogStreamOptions};
pub use attach::AttachOptionsBuilder;
pub use builder::{RegistryConfigBuilder, SandboxBuilder};
pub use config::SandboxConfig;
pub use exec::{ExecOptionsBuilder, ExecOutput, Rlimit, RlimitResource};
pub use fs::{
FsEntry, FsEntryKind, FsHandle, FsMetadata, FsOpenOptions, FsReadStream, FsSetAttrs,
FsWriteSink, SandboxFsOps,
};
pub use handle::{DEFAULT_KILL_TIMEOUT, DEFAULT_STOP_TIMEOUT, SandboxHandle};
pub use init::{HandoffInit, InitOptionsBuilder};
pub use metrics::{
SandboxMetrics, SandboxMetricsReport, SandboxMetricsState, all_sandbox_metrics,
all_sandbox_metrics_local, all_sandbox_metrics_reports_local, sandbox_metrics_report_local,
};
pub use microsandbox_image::{PullProgress, PullProgressHandle};
#[cfg(feature = "net")]
pub use microsandbox_network::builder::SecretBuilder;
#[cfg(feature = "net")]
pub use microsandbox_network::config::NetworkConfig;
#[cfg(feature = "net")]
pub use microsandbox_network::policy::{NetworkPolicy, NetworkProfile};
pub use microsandbox_runtime::logging::LogLevel;
pub use microsandbox_types::PullPolicy;
pub use microsandbox_types::{
EnvVar, MAX_HOSTNAME_BYTES, MAX_SANDBOX_NAME_BYTES, NetworkSpec, PortProtocol,
PublishedPortSpec, SandboxLogLevel, SandboxResources, SandboxRuntimeOptions, SandboxSpec,
};
pub use modify::{
ChangeKind, ConfigPlannedChange, ModificationConflict, ModificationDisposition,
ModificationPolicy, ModificationWarning, PlannedChange, ResourceConvergenceState, ResourceKind,
ResourceResizeStatus, SandboxModificationBuilder, SandboxModificationPatch,
SandboxModificationPlan, SecretChangeKind, SecretModificationPatch, SecretPatchBuilder,
SecretPlannedChange, SecretSource,
};
#[cfg(feature = "ssh")]
pub use ssh::{
DEFAULT_SSH_HOST, DEFAULT_SSH_PORT, SandboxSshOps, SftpClient, SshAttachOptionsBuilder,
SshClient, SshClientOptionsBuilder, SshExecOptionsBuilder, SshOutput, SshServer,
SshServerOptionsBuilder, SshStdioStream,
};
pub use types::{
DiskImageFormat, HostPermissions, ImageBuilder, ImageSource, IntoImage, MountBuilder,
MountOptions, NamedVolumeMode, OciRootfsSource, Patch, PatchBuilder, RootDisk, RootDiskBuilder,
RootfsSource, SecurityProfile, StatVirtualization, VolumeMount,
};
pub const DEFAULT_SANDBOX_LIST_LIMIT: u32 = 20;
pub const MAX_SANDBOX_LIST_LIMIT: u32 = 100;
#[derive(Debug, Clone)]
pub struct SandboxListBuilder {
pub(crate) cursor: Option<String>,
pub(crate) limit: u32,
pub(crate) labels: BTreeMap<String, String>,
}
pub struct SandboxPage {
pub sandboxes: Vec<SandboxHandle>,
pub next_cursor: Option<String>,
}
#[derive(Clone)]
pub struct Sandbox {
backend: Arc<dyn crate::backend::Backend>,
inner: Arc<crate::backend::SandboxInner>,
name: String,
config: SandboxConfig,
}
#[derive(Debug, Clone)]
pub struct SandboxStopResult {
pub name: String,
pub status: SandboxStatus,
pub exit_code: Option<i32>,
pub signal: Option<i32>,
pub observed_at: chrono::DateTime<chrono::Utc>,
pub source: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SandboxPingResult {
pub name: String,
pub latency: std::time::Duration,
}
#[derive(Debug, Clone)]
pub struct SandboxTouchResult {
pub name: String,
pub activity_seq: u64,
}
impl Sandbox {
pub fn builder(name: impl Into<String>) -> SandboxBuilder {
SandboxBuilder::new(name)
}
pub async fn create(config: SandboxConfig) -> MicrosandboxResult<Self> {
let backend = crate::backend::default_backend();
backend
.sandboxes()
.create(backend.clone(), config, true)
.await
}
pub async fn create_detached(config: SandboxConfig) -> MicrosandboxResult<Self> {
let backend = crate::backend::default_backend();
backend
.sandboxes()
.create_detached(backend.clone(), config)
.await
}
pub fn create_with_pull_progress(
config: SandboxConfig,
) -> (
PullProgressHandle,
tokio::task::JoinHandle<MicrosandboxResult<Self>>,
) {
Self::create_with_pull_progress_and_mode(config, SpawnMode::Attached)
}
pub fn create_detached_with_pull_progress(
config: SandboxConfig,
) -> (
PullProgressHandle,
tokio::task::JoinHandle<MicrosandboxResult<Self>>,
) {
Self::create_with_pull_progress_and_mode(config, SpawnMode::Detached)
}
fn create_with_pull_progress_and_mode(
config: SandboxConfig,
mode: SpawnMode,
) -> (
PullProgressHandle,
tokio::task::JoinHandle<MicrosandboxResult<Self>>,
) {
let (handle, sender) = progress_channel();
let task = tokio::spawn(async move {
let backend = crate::backend::default_backend();
match backend.kind() {
crate::backend::BackendKind::Local => {
let local = backend.as_local().ok_or_else(|| {
crate::MicrosandboxError::local_only(Operation::SandboxCreate)
})?;
local
.create_sandbox(backend.clone(), config, mode, Some(sender))
.await
}
crate::backend::BackendKind::Cloud => {
drop(sender); backend
.sandboxes()
.create(backend.clone(), config, true)
.await
}
}
});
(handle, task)
}
pub async fn start(name: &str) -> MicrosandboxResult<Self> {
let backend = crate::backend::default_backend();
backend.sandboxes().start(backend.clone(), name).await
}
pub async fn start_detached(name: &str) -> MicrosandboxResult<Self> {
let backend = crate::backend::default_backend();
backend
.sandboxes()
.start_detached(backend.clone(), name)
.await
}
pub async fn get(name: &str) -> MicrosandboxResult<SandboxHandle> {
let backend = crate::backend::default_backend();
backend.sandboxes().get(backend.clone(), name).await
}
pub async fn list() -> MicrosandboxResult<SandboxPage> {
Self::list_with(|list| list).await
}
pub async fn list_with(
configure: impl FnOnce(SandboxListBuilder) -> SandboxListBuilder,
) -> MicrosandboxResult<SandboxPage> {
let query = configure(SandboxListBuilder::default());
if !(1..=MAX_SANDBOX_LIST_LIMIT).contains(&query.limit) {
return Err(crate::MicrosandboxError::InvalidConfig(format!(
"sandbox list limit must be between 1 and {MAX_SANDBOX_LIST_LIMIT}"
)));
}
let backend = crate::backend::default_backend();
backend.sandboxes().list(backend.clone(), query).await
}
pub async fn remove(name: &str) -> MicrosandboxResult<()> {
let backend = crate::backend::default_backend();
backend.sandboxes().remove(backend.clone(), name).await
}
}
impl SandboxListBuilder {
pub fn limit(mut self, limit: u32) -> Self {
self.limit = limit;
self
}
pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
self.cursor = Some(cursor.into());
self
}
pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.labels.insert(key.into(), value.into());
self
}
pub fn labels(
mut self,
labels: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
) -> Self {
self.labels.extend(
labels
.into_iter()
.map(|(key, value)| (key.into(), value.into())),
);
self
}
}
impl Default for SandboxListBuilder {
fn default() -> Self {
Self {
cursor: None,
limit: DEFAULT_SANDBOX_LIST_LIMIT,
labels: BTreeMap::new(),
}
}
}
impl Sandbox {
pub(crate) fn from_local(
backend: Arc<dyn crate::backend::Backend>,
local: crate::backend::SandboxLocalState,
config: SandboxConfig,
) -> Self {
Self {
backend,
inner: Arc::new(crate::backend::SandboxInner::Local(local)),
name: config.spec.name.clone(),
config,
}
}
pub(crate) fn from_cloud(
backend: Arc<dyn crate::backend::Backend>,
cloud: crate::backend::CloudCreateSandboxResponse,
config: SandboxConfig,
) -> Self {
let state = crate::backend::SandboxCloudState {
id: cloud.id,
org_id: cloud.org_id,
created_at: cloud.created_at,
};
Self::from_cloud_state(backend, state, cloud.name, config)
}
pub(crate) fn from_cloud_state(
backend: Arc<dyn crate::backend::Backend>,
state: crate::backend::SandboxCloudState,
name: String,
config: SandboxConfig,
) -> Self {
Self {
backend,
inner: Arc::new(crate::backend::SandboxInner::Cloud(state)),
name,
config,
}
}
}
async fn ping_agent(name: &str, client: &AgentClient) -> MicrosandboxResult<SandboxPingResult> {
let started_at = std::time::Instant::now();
let msg = client.request(MessageType::Ping, &Ping {}).await?;
let latency = started_at.elapsed();
if msg.t != MessageType::Pong {
return Err(unexpected_agent_response("ping", &msg));
}
let _: Pong = msg.payload()?;
Ok(SandboxPingResult {
name: name.to_string(),
latency,
})
}
async fn touch_agent(name: &str, client: &AgentClient) -> MicrosandboxResult<SandboxTouchResult> {
let msg = client.request(MessageType::Touch, &Touch {}).await?;
if msg.t != MessageType::Touched {
return Err(unexpected_agent_response("touch", &msg));
}
let touched: Touched = msg.payload()?;
Ok(SandboxTouchResult {
name: name.to_string(),
activity_seq: touched.activity_seq,
})
}
fn unexpected_agent_response(
operation: &'static str,
msg: µsandbox_protocol::message::Message,
) -> crate::MicrosandboxError {
if msg.t == MessageType::CoreError
&& let Ok(error) = msg.payload::<CoreError>()
{
return crate::MicrosandboxError::Runtime(format!(
"agent rejected {operation}: {:?}: {}",
error.kind, error.message
));
}
crate::MicrosandboxError::Runtime(format!("agent returned {} to {operation}", msg.t.as_str()))
}
impl Sandbox {
pub async fn remove_persisted(&self) -> MicrosandboxResult<()> {
let local = self.require_local(Operation::SandboxRemovePersisted)?;
let local_backend = self.backend.as_local().ok_or_else(|| {
crate::MicrosandboxError::unsupported(
Operation::SandboxRemovePersisted,
UnsupportedReason::UseInstead(Operation::SandboxRemove),
)
})?;
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?;
Ok(())
}
pub fn name(&self) -> &str {
&self.name
}
pub fn config(&self) -> &SandboxConfig {
&self.config
}
pub fn modify(&self) -> SandboxModificationBuilder {
SandboxModificationBuilder::new(self.backend.clone(), self.name.clone())
}
pub fn backend_kind(&self) -> crate::backend::BackendKind {
self.backend.kind()
}
pub fn backend(&self) -> &Arc<dyn crate::backend::Backend> {
&self.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,
}
}
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,
}
}
fn require_local(
&self,
op: Operation,
) -> MicrosandboxResult<&crate::backend::SandboxLocalState> {
self.local()
.ok_or_else(|| crate::MicrosandboxError::local_only(op))
}
pub async fn status(&self) -> MicrosandboxResult<SandboxStatus> {
let handle = self
.backend
.sandboxes()
.get(self.backend.clone(), &self.name)
.await?;
Ok(handle.status_snapshot())
}
pub async fn last_failure_message(&self) -> MicrosandboxResult<Option<String>> {
let handle = self
.backend
.sandboxes()
.get(self.backend.clone(), &self.name)
.await?;
Ok(handle.last_failure_message_snapshot())
}
pub async fn logs(&self, opts: &LogOptions) -> MicrosandboxResult<Vec<LogEntry>> {
self.backend
.sandboxes()
.logs(self.backend.clone(), &self.name, opts)
.await
}
pub async fn log_stream(
&self,
opts: &LogStreamOptions,
) -> MicrosandboxResult<crate::backend::sandbox::LogStream> {
self.backend
.sandboxes()
.log_stream(self.backend.clone(), &self.name, opts)
.await
}
pub fn logger(&self) -> MicrosandboxResult<crate::logs::SandboxLogger> {
self.require_local(Operation::SandboxLogger)?;
let local = self
.backend
.as_local()
.ok_or_else(|| crate::MicrosandboxError::local_only(Operation::SandboxLogger))?;
let log_dir = crate::logs::log_dir_for_local(local, &self.name);
Ok(crate::logs::SandboxLogger::new(self.name.clone(), log_dir))
}
pub async fn ping(&self) -> MicrosandboxResult<SandboxPingResult> {
self.require_local(Operation::SandboxPing)?;
ping_agent(&self.name, self.client()).await
}
pub async fn touch(&self) -> MicrosandboxResult<SandboxTouchResult> {
self.require_local(Operation::SandboxTouch)?;
touch_agent(&self.name, self.client()).await
}
pub fn client(&self) -> &AgentClient {
match self.local() {
Some(local) => &local.client,
None => {
panic!("Sandbox::client called on cloud sandbox — use sb.local() to check first")
}
}
}
pub fn client_arc(&self) -> Arc<AgentClient> {
match self.local() {
Some(local) => Arc::clone(&local.client),
None => panic!(
"Sandbox::client_arc called on cloud sandbox — use sb.local() to check first"
),
}
}
pub fn owns_lifecycle(&self) -> bool {
self.local().map(|s| s.handle.is_some()).unwrap_or(false)
}
pub fn fs(&self) -> fs::SandboxFsOps<'_> {
let client = self.local().map(|local| Arc::clone(&local.client));
fs::SandboxFsOps::new(self.backend.clone(), &self.name, client)
}
pub async fn stop(&self) -> MicrosandboxResult<()> {
self.stop_with_timeout(DEFAULT_STOP_TIMEOUT).await
}
pub async fn request_stop(&self) -> MicrosandboxResult<()> {
tracing::debug!(sandbox = %self.name, "stop: dispatching");
self.backend
.sandboxes()
.stop(self.backend.clone(), &self.name)
.await
}
pub async fn stop_with_timeout(&self, timeout: std::time::Duration) -> MicrosandboxResult<()> {
if timeout.is_zero() {
self.kill_with_timeout(DEFAULT_KILL_TIMEOUT).await?;
return Ok(());
}
self.request_stop().await?;
if let Ok(result) = tokio::time::timeout(timeout, self.wait_until_stopped()).await {
result?;
return Ok(());
}
tracing::warn!(
sandbox = %self.name,
timeout_secs = timeout.as_secs(),
"graceful stop exceeded timeout, escalating to kill"
);
self.request_kill().await?;
match tokio::time::timeout(DEFAULT_KILL_TIMEOUT, self.wait_until_stopped()).await {
Ok(result) => {
result?;
Ok(())
}
Err(_) => Err(crate::MicrosandboxError::Runtime(format!(
"timed out observing stopped state for sandbox '{}'",
self.name
))),
}
}
pub async fn stop_and_wait(&self) -> MicrosandboxResult<ExitStatus> {
let local = self.require_local(Operation::SandboxStopAndWait)?;
let stop_result = self.request_stop().await;
if local.handle.is_none() {
stop_result?;
return Ok(std::process::ExitStatus::default());
}
let wait_result = self.wait().await;
stop_result?;
wait_result
}
pub async fn kill(&self) -> MicrosandboxResult<()> {
self.kill_with_timeout(DEFAULT_KILL_TIMEOUT).await
}
pub async fn request_kill(&self) -> MicrosandboxResult<()> {
self.backend
.sandboxes()
.kill(self.backend.clone(), &self.name)
.await
}
pub async fn kill_with_timeout(&self, timeout: std::time::Duration) -> MicrosandboxResult<()> {
self.request_kill().await?;
match tokio::time::timeout(timeout, self.wait_until_stopped()).await {
Ok(result) => {
result?;
Ok(())
}
Err(_) => Err(crate::MicrosandboxError::Runtime(format!(
"timed out observing stopped state for sandbox '{}'",
self.name
))),
}
}
pub async fn drain(&self) -> MicrosandboxResult<()> {
self.request_drain().await
}
pub async fn request_drain(&self) -> MicrosandboxResult<()> {
self.backend
.sandboxes()
.drain(self.backend.clone(), &self.name)
.await
}
pub async fn wait(&self) -> MicrosandboxResult<ExitStatus> {
let local = self.require_local(Operation::SandboxWait)?;
match &local.handle {
Some(h) => h.lock().await.wait().await,
None => Err(crate::MicrosandboxError::Runtime(
"cannot wait: not the lifecycle owner".into(),
)),
}
}
pub async fn wait_until_stopped(&self) -> MicrosandboxResult<SandboxStopResult> {
if self.owns_lifecycle() {
let status = self.wait().await?;
return Ok(stop_result_from_exit_status(&self.name, status));
}
match self
.backend
.sandboxes()
.get(self.backend.clone(), &self.name)
.await
{
Ok(handle) => handle.wait_until_stopped().await,
Err(error)
if self.is_local_ephemeral() && sandbox_not_found_for_name(&error, &self.name) =>
{
Ok(ephemeral_cleanup_stop_result(&self.name))
}
Err(error) => Err(error),
}
}
pub async fn detach(self) {
if let crate::backend::SandboxInner::Local(local) = self.inner.as_ref()
&& let Some(h) = &local.handle
{
h.lock().await.disarm();
}
}
fn is_local_ephemeral(&self) -> bool {
self.local().is_some() && self.config.spec.lifecycle.ephemeral
}
}
impl Sandbox {
pub async fn exec_stream(
&self,
cmd: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> MicrosandboxResult<ExecHandle> {
let opts = ExecOptions {
args: args.into_iter().map(Into::into).collect(),
..Default::default()
};
self.backend
.sandboxes()
.exec_stream(
self.backend.clone(),
&self.name,
&self.config,
cmd.into(),
opts,
)
.await
}
pub async fn exec_stream_with(
&self,
cmd: impl Into<String>,
f: impl FnOnce(ExecOptionsBuilder) -> ExecOptionsBuilder,
) -> MicrosandboxResult<ExecHandle> {
let opts = f(ExecOptionsBuilder::default()).build()?;
self.backend
.sandboxes()
.exec_stream(
self.backend.clone(),
&self.name,
&self.config,
cmd.into(),
opts,
)
.await
}
pub async fn exec(
&self,
cmd: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> MicrosandboxResult<ExecOutput> {
let opts = ExecOptions {
args: args.into_iter().map(Into::into).collect(),
..Default::default()
};
self.backend
.sandboxes()
.exec(
self.backend.clone(),
&self.name,
&self.config,
cmd.into(),
opts,
)
.await
}
pub async fn exec_with(
&self,
cmd: impl Into<String>,
f: impl FnOnce(ExecOptionsBuilder) -> ExecOptionsBuilder,
) -> MicrosandboxResult<ExecOutput> {
let opts = f(ExecOptionsBuilder::default()).build()?;
self.backend
.sandboxes()
.exec(
self.backend.clone(),
&self.name,
&self.config,
cmd.into(),
opts,
)
.await
}
pub async fn shell(&self, script: impl Into<String>) -> MicrosandboxResult<ExecOutput> {
let shell = self
.config
.spec
.runtime
.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
}
pub async fn shell_with(
&self,
script: impl Into<String>,
f: impl FnOnce(ExecOptionsBuilder) -> ExecOptionsBuilder,
) -> MicrosandboxResult<ExecOutput> {
let shell = self
.config
.spec
.runtime
.shell
.as_deref()
.unwrap_or("/bin/sh")
.to_string();
let mut opts = f(ExecOptionsBuilder::default()).build()?;
opts.args.splice(0..0, ["-c".to_string(), script.into()]);
self.backend
.sandboxes()
.exec(self.backend.clone(), &self.name, &self.config, shell, opts)
.await
}
pub async fn shell_stream(&self, script: impl Into<String>) -> MicrosandboxResult<ExecHandle> {
let shell = self
.config
.spec
.runtime
.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_stream(self.backend.clone(), &self.name, &self.config, shell, opts)
.await
}
pub async fn shell_stream_with(
&self,
script: impl Into<String>,
f: impl FnOnce(ExecOptionsBuilder) -> ExecOptionsBuilder,
) -> MicrosandboxResult<ExecHandle> {
let shell = self
.config
.spec
.runtime
.shell
.as_deref()
.unwrap_or("/bin/sh")
.to_string();
let mut opts = f(ExecOptionsBuilder::default()).build()?;
opts.args.splice(0..0, ["-c".to_string(), script.into()]);
self.backend
.sandboxes()
.exec_stream(self.backend.clone(), &self.name, &self.config, shell, opts)
.await
}
}
impl Sandbox {
pub async fn attach(
&self,
cmd: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> MicrosandboxResult<i32> {
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
}
pub async fn attach_with(
&self,
cmd: impl Into<String>,
f: impl FnOnce(AttachOptionsBuilder) -> AttachOptionsBuilder,
) -> MicrosandboxResult<i32> {
let builder = f(AttachOptionsBuilder::default());
self.backend
.sandboxes()
.attach(
self.backend.clone(),
&self.name,
&self.config,
cmd.into(),
builder,
)
.await
}
pub async fn attach_shell(&self) -> MicrosandboxResult<i32> {
let shell = self
.config
.spec
.runtime
.shell
.as_deref()
.unwrap_or("/bin/sh")
.to_string();
self.backend
.sandboxes()
.attach(
self.backend.clone(),
&self.name,
&self.config,
shell,
AttachOptionsBuilder::default(),
)
.await
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_exec_request(
config: &SandboxConfig,
cmd: String,
args: Vec<String>,
cwd: Option<String>,
user: Option<String>,
env: &[EnvVar],
rlimits: &[Rlimit],
tty: bool,
rows: u16,
cols: u16,
) -> ExecRequest {
let merged = config::merge_env_pairs(&config.spec.env, env);
let mut env: Vec<String> = merged
.iter()
.map(|var| format!("{}={}", var.key, var.value))
.collect();
if tty && !env.iter().any(|e| e.starts_with("TERM=")) {
env.push(format!("TERM={}", default_tty_term()));
}
let rlimits: Vec<ExecRlimit> = rlimits
.iter()
.map(|rl| ExecRlimit {
resource: rl.resource.as_str().to_string(),
soft: rl.soft,
hard: rl.hard,
})
.collect();
ExecRequest {
cmd,
args,
env,
cwd: cwd
.or_else(|| config.spec.runtime.workdir.clone())
.or_else(|| Some("/".to_string())),
user: user.or_else(|| config.spec.runtime.user.clone()),
tty,
rows,
cols,
rlimits,
}
}
fn default_tty_term() -> String {
select_tty_term(std::env::var("TERM").ok().as_deref())
}
fn select_tty_term(term: Option<&str>) -> String {
match term {
Some(term) if !term.trim().is_empty() && term != "dumb" => term.to_string(),
_ => "xterm".to_string(),
}
}
#[cfg(unix)]
pub(crate) fn terminal_path_for_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::path::PathBuf> {
let mut buf = [0u8; 1024];
let rc = unsafe { libc::ttyname_r(fd, buf.as_mut_ptr().cast(), buf.len()) };
if rc != 0 {
return Err(std::io::Error::from_raw_os_error(rc));
}
let end = buf
.iter()
.position(|&byte| byte == 0)
.ok_or_else(|| std::io::Error::other("ttyname_r did not NUL-terminate"))?;
let path = std::str::from_utf8(&buf[..end]).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"tty path is not valid UTF-8",
)
})?;
Ok(std::path::PathBuf::from(path))
}
#[cfg(unix)]
pub(crate) fn open_nonblocking_terminal_input(
path: &std::path::Path,
) -> std::io::Result<std::fs::File> {
use std::os::fd::AsRawFd;
let file = std::fs::File::open(path)?;
let fd = file.as_raw_fd();
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 {
return Err(std::io::Error::last_os_error());
}
if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(file)
}
#[cfg(unix)]
pub(crate) fn read_from_fd(fd: std::os::fd::RawFd, buf: &mut [u8]) -> std::io::Result<usize> {
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())
} else {
Ok(n as usize)
}
}
fn stop_result_from_exit_status(name: &str, status: ExitStatus) -> SandboxStopResult {
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
SandboxStopResult {
name: name.to_string(),
status: SandboxStatus::Stopped,
exit_code: status.code(),
signal: {
#[cfg(unix)]
{
status.signal()
}
#[cfg(not(unix))]
{
None
}
},
observed_at: chrono::Utc::now(),
source: Some("owned process wait".to_string()),
}
}
pub(super) fn ephemeral_cleanup_stop_result(name: &str) -> SandboxStopResult {
SandboxStopResult {
name: name.to_string(),
status: SandboxStatus::Stopped,
exit_code: None,
signal: None,
observed_at: chrono::Utc::now(),
source: Some("ephemeral cleanup removed persisted state".to_string()),
}
}
pub(super) fn sandbox_not_found_for_name(error: &crate::MicrosandboxError, name: &str) -> bool {
matches!(error, crate::MicrosandboxError::SandboxNotFound(missing) if missing == name)
}
pub(crate) fn hostname_from_sandbox_name(name: &str) -> String {
derive_hostname(name)
}
pub(crate) fn validate_labels(labels: &BTreeMap<String, String>) -> MicrosandboxResult<()> {
for key in labels.keys() {
if key.is_empty() {
return Err(crate::MicrosandboxError::InvalidConfig(
"label key must not be empty".into(),
));
}
if let Some(prefix) = reserved_label_prefix(key) {
return Err(crate::MicrosandboxError::InvalidConfig(format!(
"label key '{key}' uses reserved prefix '{prefix}'"
)));
}
}
Ok(())
}
pub(crate) fn validate_env(env: &[EnvVar]) -> MicrosandboxResult<()> {
for var in env {
if var.key.starts_with("MSB_") {
return Err(crate::MicrosandboxError::InvalidConfig(format!(
"environment variable {:?} uses the reserved MSB_ prefix",
var.key
)));
}
}
Ok(())
}
pub(super) fn remove_dir_if_exists(path: &Path) -> MicrosandboxResult<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
pub(super) async fn load_sandbox_record(
db: &DbReadConnection,
name: &str,
) -> MicrosandboxResult<sandbox_entity::Model> {
sandbox_entity::Entity::find()
.filter(sandbox_entity::Column::Name.eq(name))
.one(db)
.await?
.ok_or_else(|| crate::MicrosandboxError::SandboxNotFound(name.into()))
}
#[cfg(test)]
mod tests {
use std::fs;
#[cfg(unix)]
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use tempfile::tempdir;
use super::{
MAX_HOSTNAME_BYTES, MAX_SANDBOX_NAME_BYTES, SandboxStatus, ephemeral_cleanup_stop_result,
hostname_from_sandbox_name, remove_dir_if_exists, sandbox_not_found_for_name,
validate_hostname,
};
#[test]
fn test_sandbox_not_found_for_name_requires_exact_match() {
assert!(sandbox_not_found_for_name(
&crate::MicrosandboxError::SandboxNotFound("gone".into()),
"gone"
));
assert!(!sandbox_not_found_for_name(
&crate::MicrosandboxError::SandboxNotFound("other".into()),
"gone"
));
assert!(!sandbox_not_found_for_name(
&crate::MicrosandboxError::Runtime("gone".into()),
"gone"
));
}
#[test]
fn test_ephemeral_cleanup_stop_result_marks_stopped() {
let result = ephemeral_cleanup_stop_result("msb-gone");
assert_eq!(result.name, "msb-gone");
assert_eq!(result.status, SandboxStatus::Stopped);
assert_eq!(result.exit_code, None);
assert_eq!(result.signal, None);
assert_eq!(
result.source.as_deref(),
Some("ephemeral cleanup removed persisted state")
);
}
#[test]
fn test_live_sandbox_lifecycle_api_methods_stay_available() {
let _ = super::Sandbox::stop;
let _ = super::Sandbox::request_stop;
let _ = super::Sandbox::stop_with_timeout;
let _ = super::Sandbox::kill;
let _ = super::Sandbox::request_kill;
let _ = super::Sandbox::kill_with_timeout;
let _ = super::Sandbox::request_drain;
let _ = super::Sandbox::wait_until_stopped;
let _ = super::all_sandbox_metrics;
}
#[test]
fn test_default_tty_term_prefers_host_term() {
assert_eq!(super::select_tty_term(Some("wezterm")), "wezterm");
}
#[test]
fn test_default_tty_term_falls_back_from_dumb() {
assert_eq!(super::select_tty_term(Some("dumb")), "xterm");
}
#[test]
#[cfg(unix)]
fn test_shared_tty_fd_flags_are_shared_across_dups() {
let pty = nix::pty::openpty(None, None).unwrap();
let shared_a = unsafe { OwnedFd::from_raw_fd(libc::dup(pty.slave.as_raw_fd())) };
let shared_b = unsafe { OwnedFd::from_raw_fd(libc::dup(shared_a.as_raw_fd())) };
let flags = unsafe { libc::fcntl(shared_a.as_raw_fd(), libc::F_GETFL) };
assert_ne!(flags, -1);
let ret = unsafe {
libc::fcntl(
shared_a.as_raw_fd(),
libc::F_SETFL,
flags | libc::O_NONBLOCK,
)
};
assert_ne!(ret, -1);
let other_flags = unsafe { libc::fcntl(shared_b.as_raw_fd(), libc::F_GETFL) };
assert_ne!(other_flags, -1);
assert_ne!(
other_flags & libc::O_NONBLOCK,
0,
"dup'd tty fds should share O_NONBLOCK state"
);
}
#[test]
#[cfg(unix)]
fn test_open_nonblocking_terminal_input_keeps_existing_tty_fds_blocking() {
let pty = nix::pty::openpty(None, None).unwrap();
let shared_a = unsafe { OwnedFd::from_raw_fd(libc::dup(pty.slave.as_raw_fd())) };
let shared_b = unsafe { OwnedFd::from_raw_fd(libc::dup(shared_a.as_raw_fd())) };
let tty_path = super::terminal_path_for_fd(pty.slave.as_raw_fd()).unwrap();
let input = super::open_nonblocking_terminal_input(&tty_path).unwrap();
let input_flags = unsafe { libc::fcntl(input.as_raw_fd(), libc::F_GETFL) };
assert_ne!(input_flags, -1);
assert_ne!(
input_flags & libc::O_NONBLOCK,
0,
"re-opened tty input fd should be non-blocking"
);
let flags_a = unsafe { libc::fcntl(shared_a.as_raw_fd(), libc::F_GETFL) };
let flags_b = unsafe { libc::fcntl(shared_b.as_raw_fd(), libc::F_GETFL) };
assert_ne!(flags_a, -1);
assert_ne!(flags_b, -1);
assert_eq!(
flags_a & libc::O_NONBLOCK,
0,
"existing tty fd should remain blocking"
);
assert_eq!(
flags_b & libc::O_NONBLOCK,
0,
"dup'd tty fd should remain blocking"
);
}
#[test]
fn test_hostname_from_sandbox_name_passes_short_names_through() {
let name = "short-name";
assert_eq!(hostname_from_sandbox_name(name), name);
let name = "a".repeat(MAX_HOSTNAME_BYTES);
assert_eq!(hostname_from_sandbox_name(&name), name);
}
#[test]
fn test_hostname_from_sandbox_name_collapses_long_names_to_64_bytes() {
let derived = hostname_from_sandbox_name(&"a".repeat(MAX_HOSTNAME_BYTES + 1));
assert_eq!(derived.len(), MAX_HOSTNAME_BYTES);
let derived = hostname_from_sandbox_name(&"a".repeat(MAX_SANDBOX_NAME_BYTES));
assert_eq!(derived.len(), MAX_HOSTNAME_BYTES);
let bytes = derived.as_bytes();
assert_eq!(bytes[MAX_HOSTNAME_BYTES - 9], b'-');
assert!(
bytes[MAX_HOSTNAME_BYTES - 8..]
.iter()
.all(u8::is_ascii_hexdigit)
);
}
#[test]
fn test_hostname_from_sandbox_name_is_deterministic_and_unique() {
let a = "a".repeat(MAX_SANDBOX_NAME_BYTES);
let mut b = a.clone();
b.pop();
b.push('b');
assert_eq!(
hostname_from_sandbox_name(&a),
hostname_from_sandbox_name(&a)
);
assert_ne!(
hostname_from_sandbox_name(&a),
hostname_from_sandbox_name(&b)
);
}
#[test]
fn test_hostname_from_sandbox_name_respects_utf8_boundaries() {
let name = "é".repeat(64);
assert_eq!(name.len(), 128);
let derived = hostname_from_sandbox_name(&name);
assert!(derived.len() <= MAX_HOSTNAME_BYTES);
assert!(derived.is_char_boundary(derived.len()));
}
#[test]
fn test_validate_hostname_accepts_absent_and_64_byte_hostname() {
validate_hostname(None).unwrap();
validate_hostname(Some(&"y".repeat(MAX_HOSTNAME_BYTES))).unwrap();
}
#[test]
fn test_validate_hostname_rejects_empty_hostname() {
let err = validate_hostname(Some("")).unwrap_err();
assert_eq!(
err.to_string(),
"invalid config: hostname must not be empty"
);
}
#[test]
fn test_validate_hostname_rejects_over_64_byte_hostname() {
let err = validate_hostname(Some(&"y".repeat(MAX_HOSTNAME_BYTES + 1))).unwrap_err();
assert_eq!(
err.to_string(),
"invalid config: hostname is too long: 65 bytes (max 64)"
);
}
#[test]
fn test_remove_dir_if_exists_removes_existing_sandbox_tree() {
let temp = tempdir().unwrap();
let sandbox_dir = temp.path().join("sandbox");
fs::create_dir_all(sandbox_dir.join("runtime/scripts")).unwrap();
fs::write(sandbox_dir.join("runtime/scripts/start.sh"), b"echo hi").unwrap();
fs::create_dir_all(sandbox_dir.join("rw")).unwrap();
remove_dir_if_exists(&sandbox_dir).unwrap();
assert!(!sandbox_dir.exists());
}
#[test]
fn test_remove_dir_if_exists_ignores_missing_directory() {
let temp = tempdir().unwrap();
let sandbox_dir = temp.path().join("missing");
remove_dir_if_exists(&sandbox_dir).unwrap();
assert!(!sandbox_dir.exists());
}
}