pub mod fs;
pub use fs::{VolumeFs, VolumeFsReadStream, VolumeFsWriteSink};
use std::path::PathBuf;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use crate::{
MicrosandboxError, MicrosandboxResult, db::entity::volume as volume_entity, size::Mebibytes,
};
pub struct Volume {
name: String,
path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct VolumeConfig {
pub name: String,
pub quota_mib: Option<u32>,
pub labels: Vec<(String, String)>,
}
#[derive(Debug)]
pub struct VolumeHandle {
db_id: i32,
name: String,
quota_mib: Option<u32>,
used_bytes: u64,
labels: Vec<(String, String)>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
}
pub struct VolumeBuilder {
config: VolumeConfig,
}
impl VolumeHandle {
pub(crate) fn from_model(model: volume_entity::Model) -> Self {
let labels = model
.labels
.as_deref()
.map(|s| {
serde_json::from_str::<Vec<(String, String)>>(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()),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn quota_mib(&self) -> Option<u32> {
self.quota_mib
}
pub fn used_bytes(&self) -> u64 {
self.used_bytes
}
pub fn labels(&self) -> &[(String, String)] {
&self.labels
}
pub fn created_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.created_at
}
pub fn fs(&self) -> fs::VolumeFs<'_> {
let path = crate::config::config().volumes_dir().join(&self.name);
fs::VolumeFs::from_path(path)
}
pub async fn remove(&self) -> MicrosandboxResult<()> {
let db =
crate::db::init_global(Some(crate::config::config().database.max_connections)).await?;
volume_entity::Entity::delete_by_id(self.db_id)
.exec(db)
.await?;
let path = crate::config::config().volumes_dir().join(&self.name);
if path.exists() {
tokio::fs::remove_dir_all(&path).await?;
}
Ok(())
}
}
impl Volume {
pub fn builder(name: impl Into<String>) -> VolumeBuilder {
VolumeBuilder::new(name)
}
pub async fn create(config: VolumeConfig) -> MicrosandboxResult<Self> {
tracing::debug!(name = %config.name, quota_mib = ?config.quota_mib, "Volume::create");
validate_volume_name(&config.name)?;
let db =
crate::db::init_global(Some(crate::config::config().database.max_connections)).await?;
let existing = volume_entity::Entity::find()
.filter(volume_entity::Column::Name.eq(&config.name))
.one(db)
.await?;
if existing.is_some() {
return Err(MicrosandboxError::VolumeAlreadyExists(config.name));
}
let labels_json = if config.labels.is_empty() {
None
} else {
Some(serde_json::to_string(&config.labels)?)
};
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(db).await?;
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(db)
.await;
return Err(e.into());
}
Ok(Self {
name: config.name,
path,
})
}
pub async fn get(name: &str) -> MicrosandboxResult<VolumeHandle> {
let db =
crate::db::init_global(Some(crate::config::config().database.max_connections)).await?;
let model = volume_entity::Entity::find()
.filter(volume_entity::Column::Name.eq(name))
.one(db)
.await?
.ok_or_else(|| MicrosandboxError::VolumeNotFound(name.into()))?;
Ok(VolumeHandle::from_model(model))
}
pub async fn list() -> MicrosandboxResult<Vec<VolumeHandle>> {
let db =
crate::db::init_global(Some(crate::config::config().database.max_connections)).await?;
let models = volume_entity::Entity::find()
.order_by_desc(volume_entity::Column::CreatedAt)
.all(db)
.await?;
Ok(models.into_iter().map(VolumeHandle::from_model).collect())
}
pub async fn remove(name: &str) -> MicrosandboxResult<()> {
Self::get(name).await?.remove().await
}
}
impl Volume {
pub fn name(&self) -> &str {
&self.name
}
pub fn path(&self) -> &std::path::Path {
&self.path
}
pub fn fs(&self) -> fs::VolumeFs<'_> {
fs::VolumeFs::from_path_ref(&self.path)
}
}
impl VolumeBuilder {
pub fn new(name: impl Into<String>) -> Self {
Self {
config: VolumeConfig {
name: name.into(),
quota_mib: None,
labels: Vec::new(),
},
}
}
pub fn quota(mut self, size: impl Into<Mebibytes>) -> Self {
self.config.quota_mib = Some(size.into().as_u32());
self
}
pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.config.labels.push((key.into(), value.into()));
self
}
pub fn build(self) -> VolumeConfig {
self.config
}
pub async fn create(self) -> MicrosandboxResult<Volume> {
Volume::create(self.config).await
}
}
impl From<VolumeConfig> for VolumeBuilder {
fn from(config: VolumeConfig) -> Self {
Self { config }
}
}
fn validate_volume_name(name: &str) -> MicrosandboxResult<()> {
if name.is_empty() {
return Err(MicrosandboxError::InvalidConfig(
"volume name must not be empty".into(),
));
}
let valid = name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
if !valid {
return Err(MicrosandboxError::InvalidConfig(format!(
"volume name must start with an alphanumeric character and contain only \
alphanumeric characters, dots, hyphens, and underscores: {name}"
)));
}
Ok(())
}