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};
pub struct LocalBackend {
config: Arc<LocalConfig>,
db: OnceCell<DbPools>,
}
#[derive(Default)]
pub struct LocalBackendBuilder {
home: Option<PathBuf>,
sandboxes_dir: Option<PathBuf>,
volumes_dir: Option<PathBuf>,
snapshots_dir: Option<PathBuf>,
cache_dir: Option<PathBuf>,
logs_dir: Option<PathBuf>,
secrets_dir: Option<PathBuf>,
max_connections: Option<u32>,
connect_timeout_secs: Option<u64>,
busy_timeout_secs: Option<u64>,
default_cpus: Option<u8>,
default_memory_mib: Option<u32>,
shell: Option<String>,
workdir: Option<String>,
metrics_sample_interval_ms: Option<Option<NonZero<u64>>>,
disable_metrics_sample: Option<bool>,
ca_certs: Option<Option<PathBuf>>,
registry_hosts: Option<HashMap<String, RegistryEntry>>,
log_level: Option<microsandbox_runtime::logging::LogLevel>,
}
impl LocalBackend {
pub fn lazy() -> Self {
let config = Arc::new(load_persisted_config_or_default().unwrap_or_default());
Self {
config,
db: OnceCell::new(),
}
}
pub async fn new() -> MicrosandboxResult<Self> {
let backend = Self::lazy();
let _ = backend.db().await?;
Ok(backend)
}
pub fn builder() -> LocalBackendBuilder {
LocalBackendBuilder::default()
}
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
}
pub fn config(&self) -> &LocalConfig {
&self.config
}
pub fn volume_path(&self, name: &str) -> PathBuf {
self.config.volumes_dir().join(name)
}
pub fn sandboxes_dir(&self) -> PathBuf {
self.config.sandboxes_dir()
}
pub fn volumes_dir(&self) -> PathBuf {
self.config.volumes_dir()
}
pub fn snapshots_dir(&self) -> PathBuf {
self.config.snapshots_dir()
}
pub fn cache_dir(&self) -> PathBuf {
self.config.cache_dir()
}
pub fn logs_dir(&self) -> PathBuf {
self.config.logs_dir()
}
pub fn secrets_dir(&self) -> PathBuf {
self.config.secrets_dir()
}
}
impl LocalBackendBuilder {
pub fn home(mut self, path: impl Into<PathBuf>) -> Self {
self.home = Some(path.into());
self
}
pub fn sandboxes_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.sandboxes_dir = Some(path.into());
self
}
pub fn volumes_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.volumes_dir = Some(path.into());
self
}
pub fn snapshots_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.snapshots_dir = Some(path.into());
self
}
pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(path.into());
self
}
pub fn logs_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.logs_dir = Some(path.into());
self
}
pub fn secrets_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.secrets_dir = Some(path.into());
self
}
pub fn max_connections(mut self, n: u32) -> Self {
self.max_connections = Some(n);
self
}
pub fn connect_timeout_secs(mut self, secs: u64) -> Self {
self.connect_timeout_secs = Some(secs);
self
}
pub fn busy_timeout_secs(mut self, secs: u64) -> Self {
self.busy_timeout_secs = Some(secs);
self
}
pub fn default_cpus(mut self, cpus: u8) -> Self {
self.default_cpus = Some(cpus);
self
}
pub fn default_memory_mib(mut self, mib: u32) -> Self {
self.default_memory_mib = Some(mib);
self
}
pub fn shell(mut self, shell: impl Into<String>) -> Self {
self.shell = Some(shell.into());
self
}
pub fn workdir(mut self, workdir: impl Into<String>) -> Self {
self.workdir = Some(workdir.into());
self
}
pub fn metrics_sample_interval_ms(mut self, ms: u64) -> Self {
self.metrics_sample_interval_ms = Some(NonZero::new(ms));
self
}
pub fn disable_metrics_sample(mut self, disable: bool) -> Self {
self.disable_metrics_sample = Some(disable);
self
}
pub fn ca_certs(mut self, path: Option<PathBuf>) -> Self {
self.ca_certs = Some(path);
self
}
pub fn registry_hosts(mut self, hosts: HashMap<String, RegistryEntry>) -> Self {
self.registry_hosts = Some(hosts);
self
}
pub fn log_level(mut self, level: microsandbox_runtime::logging::LogLevel) -> Self {
self.log_level = Some(level);
self
}
pub async fn build(self) -> MicrosandboxResult<LocalBackend> {
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)
}
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
}
}
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<LocalBackend> for Arc<dyn Backend> {
fn from(backend: LocalBackend) -> Self {
Arc::new(backend)
}
}
async fn connect_and_migrate(
db_dir: &Path,
database: &DatabaseConfig,
) -> MicrosandboxResult<DbPools> {
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)
}
#[cfg(test)]
mod tests {
use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement};
use super::*;
use crate::backend::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();
assert!(db_dir.join(microsandbox_utils::DB_FILENAME).exists());
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<String> = rows
.iter()
.map(|r| r.try_get_by_index::<String>(0).unwrap())
.collect();
let expected = vec![
"config",
"image_ref",
"layer",
"maintenance_lease",
"manifest",
"manifest_layer",
"run",
"sandbox",
"sandbox_labels",
"sandbox_rootfs",
"snapshot_index",
"volume",
"volume_attach",
];
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();
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();
Migrator::up(&conn, Some(2)).await.unwrap();
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::<i64>(0)
.unwrap();
assert_eq!(migration_row_count, 1);
}
#[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<dyn Backend> = Arc::new(
LocalBackend::builder()
.home(home_a.path())
.build()
.await
.unwrap(),
);
let backend_b: Arc<dyn Backend> = Arc::new(
LocalBackend::builder()
.home(home_b.path())
.build()
.await
.unwrap(),
);
backend_b
.volumes()
.create(
backend_b.clone(),
VolumeConfig {
name: "vol".into(),
kind: crate::volume::VolumeKind::Directory,
quota_mib: None,
capacity_mib: None,
labels: Vec::new(),
},
)
.await
.unwrap();
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;
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"
);
}
#[test]
fn builder_merge_preserves_persisted_fields_when_not_overridden() {
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,
oci: crate::config::OciSandboxDefaults::default(),
shell: "/bin/zsh".into(),
workdir: Some("/work".into()),
metrics_sample_interval_ms: NonZero::new(750),
disable_metrics_sample: true,
},
..Default::default()
};
let merged = LocalBackend::builder().default_cpus(2).merge_into(base);
assert_eq!(merged.sandbox_defaults.cpus, 2);
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)
);
}
}