use super::{
DockerSandbox, ExecutionResult, IsolationLevel, K8sSandbox, LocalSandbox, ResourceLimits,
SandboxCommand, SandboxExecutor, docker::DockerConfig, k8s::K8sConfig, local::LocalConfig,
policy::SandboxPolicy,
};
use echo_core::error::Result;
use echo_core::error::SandboxError;
use futures::future::BoxFuture;
use std::sync::Arc;
use tracing::{info, warn};
#[derive(Debug)]
pub struct SandboxManager {
local: Arc<LocalSandbox>,
docker: Option<Arc<DockerSandbox>>,
k8s: Option<Arc<K8sSandbox>>,
policy: SandboxPolicy,
allow_fallback: bool,
}
impl SandboxExecutor for SandboxManager {
fn name(&self) -> &str {
"manager"
}
fn isolation_level(&self) -> IsolationLevel {
self.available_levels()
.into_iter()
.max()
.unwrap_or(IsolationLevel::None)
}
fn is_available(&self) -> BoxFuture<'_, bool> {
Box::pin(async { true })
}
fn execute(&self, command: SandboxCommand) -> BoxFuture<'_, Result<ExecutionResult>> {
Box::pin(async move { SandboxManager::execute(self, command).await })
}
fn execute_with_limits(
&self,
command: SandboxCommand,
limits: ResourceLimits,
) -> BoxFuture<'_, Result<ExecutionResult>> {
Box::pin(async move { SandboxManager::execute_with_limits(self, command, limits).await })
}
fn cleanup(&self) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
self.local.cleanup().await?;
if let Some(docker) = &self.docker {
docker.cleanup().await?;
}
if let Some(k8s) = &self.k8s {
k8s.cleanup().await?;
}
Ok(())
})
}
}
impl SandboxManager {
pub async fn auto_detect() -> Self {
let local = Arc::new(LocalSandbox::new(LocalConfig::default()));
let docker = Arc::new(DockerSandbox::new(DockerConfig::default()));
let k8s = Arc::new(K8sSandbox::new(K8sConfig::default()));
let docker_available = docker.is_available().await;
let k8s_available = k8s.is_available().await;
info!(
docker = docker_available,
k8s = k8s_available,
"Sandbox auto-detection complete"
);
Self {
local,
docker: if docker_available { Some(docker) } else { None },
k8s: if k8s_available { Some(k8s) } else { None },
policy: SandboxPolicy::default(),
allow_fallback: true,
}
}
pub fn with_configs(
local_config: LocalConfig,
docker_config: Option<DockerConfig>,
k8s_config: Option<K8sConfig>,
policy: SandboxPolicy,
) -> Self {
Self {
local: Arc::new(LocalSandbox::new(local_config)),
docker: docker_config.map(|c| Arc::new(DockerSandbox::new(c))),
k8s: k8s_config.map(|c| Arc::new(K8sSandbox::new(c))),
policy,
allow_fallback: true,
}
}
pub fn local_only() -> Self {
Self {
local: Arc::new(LocalSandbox::new(LocalConfig {
enable_os_sandbox: false,
..Default::default()
})),
docker: None,
k8s: None,
policy: SandboxPolicy::trusted(),
allow_fallback: false,
}
}
pub fn set_allow_fallback(&mut self, allow: bool) {
self.allow_fallback = allow;
}
pub fn set_policy(&mut self, policy: SandboxPolicy) {
self.policy = policy;
}
pub async fn execute(&self, command: SandboxCommand) -> Result<ExecutionResult> {
let required_level = self.policy.evaluate(&command);
self.execute_at_level(command, required_level, None).await
}
pub async fn execute_with_limits(
&self,
command: SandboxCommand,
limits: ResourceLimits,
) -> Result<ExecutionResult> {
let required_level = self.policy.evaluate_with_limits(&command, Some(&limits));
self.execute_at_level(command, required_level, Some(limits))
.await
}
async fn execute_at_level(
&self,
command: SandboxCommand,
required: IsolationLevel,
limits: Option<ResourceLimits>,
) -> Result<ExecutionResult> {
let executor = self.select_executor(required)?;
let actual = executor.isolation_level();
if actual < required {
if !self.allow_fallback {
return Err(echo_core::error::ReactError::Sandbox(
SandboxError::PermissionDenied(format!(
"Cannot downgrade from {} to {}: no executor meets the required isolation level",
required, actual
)),
));
}
warn!(
required = %required,
actual = %actual,
executor = executor.name(),
"Falling back to lower isolation executor"
);
}
info!(
required = %required,
actual = %actual,
executor = executor.name(),
"Sandbox routing"
);
match limits {
Some(limits) => executor.execute_with_limits(command, limits).await,
None => executor.execute(command).await,
}
}
fn select_executor(
&self,
required: IsolationLevel,
) -> std::result::Result<Arc<dyn SandboxExecutor>, echo_core::error::ReactError> {
if self.local.isolation_level() >= required {
return Ok(self.local.clone());
}
if let Some(ref docker) = self.docker
&& docker.isolation_level() >= required
{
return Ok(docker.clone());
}
if let Some(ref k8s) = self.k8s
&& k8s.isolation_level() >= required
{
return Ok(k8s.clone());
}
if self.allow_fallback {
let best_available: Arc<dyn SandboxExecutor> = if let Some(ref k8s) = self.k8s {
k8s.clone()
} else if let Some(ref docker) = self.docker {
docker.clone()
} else {
self.local.clone()
};
warn!(
required = %required,
available = %best_available.isolation_level(),
executor = best_available.name(),
"No executor meets required isolation level, returning best available for fallback decision"
);
return Ok(best_available);
}
Err(echo_core::error::ReactError::Sandbox(
SandboxError::Unavailable(format!(
"Isolation level {} required but no executor meets the requirement",
required
)),
))
}
pub fn available_levels(&self) -> Vec<IsolationLevel> {
let mut levels = vec![IsolationLevel::None, IsolationLevel::Process];
if self.local.isolation_level() >= IsolationLevel::OsSandbox {
levels.push(IsolationLevel::OsSandbox);
}
if self.docker.is_some() {
levels.push(IsolationLevel::Container);
}
if self.k8s.is_some() {
levels.push(IsolationLevel::Orchestrated);
}
levels
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_local_only() {
let manager = SandboxManager::local_only();
let levels = manager.available_levels();
assert!(levels.contains(&IsolationLevel::None));
assert!(levels.contains(&IsolationLevel::Process));
assert!(!levels.contains(&IsolationLevel::Container));
assert!(!levels.contains(&IsolationLevel::Orchestrated));
}
#[tokio::test]
async fn test_execute_simple_command() {
let manager = SandboxManager::local_only();
let cmd = SandboxCommand::shell("echo sandbox_test");
let result = manager.execute(cmd).await.unwrap();
assert!(result.success());
assert_eq!(result.stdout.trim(), "sandbox_test");
}
#[test]
fn test_select_executor_none() {
let manager = SandboxManager::local_only();
let executor = manager.select_executor(IsolationLevel::None).unwrap();
assert_eq!(executor.name(), "local");
}
#[test]
fn test_select_executor_container_fallback() {
let mut manager = SandboxManager::local_only();
manager.allow_fallback = true;
let executor = manager.select_executor(IsolationLevel::Container).unwrap();
assert_eq!(executor.name(), "local");
}
#[test]
fn test_select_executor_container_no_fallback() {
let mut manager = SandboxManager::local_only();
manager.allow_fallback = false;
let result = manager.select_executor(IsolationLevel::Container);
assert!(result.is_err());
}
#[tokio::test]
async fn test_execute_with_policy() {
let manager = SandboxManager::local_only();
let cmd = SandboxCommand::shell("echo safe");
let result = manager.execute(cmd).await.unwrap();
assert!(result.success());
}
#[tokio::test]
async fn test_execute_with_limits_uses_limit_aware_policy() {
let mut manager = SandboxManager::local_only();
manager.set_policy(SandboxPolicy::default());
let err = manager
.execute_with_limits(
SandboxCommand::shell("echo safe"),
ResourceLimits {
network: true,
..Default::default()
},
)
.await
.unwrap_err();
assert!(format!("{err}").contains("Isolation level"));
}
#[tokio::test]
async fn test_execute_with_limits_falls_back_when_enabled() {
let mut manager = SandboxManager::local_only();
manager.set_policy(SandboxPolicy::default());
manager.set_allow_fallback(true);
let result = manager
.execute_with_limits(
SandboxCommand::shell("echo downgraded"),
ResourceLimits {
network: true,
..Default::default()
},
)
.await
.unwrap();
assert!(result.success());
assert_eq!(result.stdout.trim(), "downgraded");
assert_eq!(result.sandbox_type, "local");
}
}