pub mod fuse;
use std::{
collections::HashMap,
fs::{self, File},
io::Write,
path::{Path, PathBuf},
sync::Arc,
};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::{
dicfuse::{Dicfuse, DicfuseManager},
util::config,
};
fn unmount_grace_duration() -> std::time::Duration {
const DEFAULT_MS: u64 = 150;
match std::env::var("ANTARES_UNMOUNT_GRACE_MS") {
Ok(raw) => match raw.trim().parse::<u64>() {
Ok(ms) => std::time::Duration::from_millis(ms.clamp(0, 3_000)),
Err(_) => {
tracing::warn!(
value = %raw,
default_ms = DEFAULT_MS,
"invalid ANTARES_UNMOUNT_GRACE_MS, using default"
);
std::time::Duration::from_millis(DEFAULT_MS)
}
},
Err(_) => std::time::Duration::from_millis(DEFAULT_MS),
}
}
#[derive(Debug, Clone)]
pub struct AntaresPaths {
pub upper_root: PathBuf,
pub cl_root: PathBuf,
pub mount_root: PathBuf,
pub state_file: PathBuf,
}
impl AntaresPaths {
pub fn new(
upper_root: PathBuf,
cl_root: PathBuf,
mount_root: PathBuf,
state_file: PathBuf,
) -> Self {
Self {
upper_root,
cl_root,
mount_root,
state_file,
}
}
pub fn from_global_config() -> Self {
Self {
upper_root: PathBuf::from(config::antares_upper_root()),
cl_root: PathBuf::from(config::antares_cl_root()),
mount_root: PathBuf::from(config::antares_mount_root()),
state_file: PathBuf::from(config::antares_state_file()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AntaresConfig {
pub job_id: String,
pub mountpoint: PathBuf,
pub upper_id: String,
pub upper_dir: PathBuf,
pub cl_dir: Option<PathBuf>,
pub cl_id: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct AntaresState {
mounts: Vec<AntaresConfig>,
}
pub struct AntaresManager {
dic: Arc<Dicfuse>,
paths: AntaresPaths,
instances: Arc<Mutex<HashMap<String, AntaresConfig>>>,
fuse_handles: Arc<Mutex<HashMap<String, fuse::AntaresFuse>>>,
}
impl AntaresManager {
pub async fn new(paths: AntaresPaths) -> Self {
let dic = DicfuseManager::global().await;
let instances = Self::load_state(&paths.state_file).unwrap_or_default();
Self {
dic,
paths,
instances: Arc::new(Mutex::new(instances)),
fuse_handles: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn mount_job(
&self,
job_id: &str,
cl_name: Option<&str>,
) -> std::io::Result<AntaresConfig> {
let mountpoint = self.paths.mount_root.join(job_id);
self.mount_job_at(job_id, mountpoint, cl_name).await
}
pub async fn mount_job_at(
&self,
job_id: &str,
mountpoint: impl Into<PathBuf>,
cl_name: Option<&str>,
) -> std::io::Result<AntaresConfig> {
let mountpoint = mountpoint.into();
let start = std::time::Instant::now();
tracing::info!(
"antares: mount_job_at start job_id={} mountpoint={} cl={:?}",
job_id,
mountpoint.display(),
cl_name
);
let upper_id = Uuid::new_v4().to_string();
let upper_dir = self.paths.upper_root.join(&upper_id);
let (cl_id, cl_dir) = match cl_name {
Some(_) => {
let id = Uuid::new_v4().to_string();
(Some(id.clone()), Some(self.paths.cl_root.join(id)))
}
None => (None, None),
};
std::fs::create_dir_all(&upper_dir)?;
if let Some(cl) = &cl_dir {
std::fs::create_dir_all(cl)?;
}
std::fs::create_dir_all(&mountpoint)?;
const DICFUSE_INIT_TIMEOUT_SECS: u64 = 120;
tracing::info!(
"antares: mount_job_at waiting for Dicfuse ready (timeout: {}s)",
DICFUSE_INIT_TIMEOUT_SECS
);
match tokio::time::timeout(
std::time::Duration::from_secs(DICFUSE_INIT_TIMEOUT_SECS),
self.dic.store.wait_for_ready(),
)
.await
{
Ok(_) => {
tracing::info!("antares: mount_job_at Dicfuse ready");
}
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"Dicfuse initialization timed out after {}s for job {}",
DICFUSE_INIT_TIMEOUT_SECS, job_id
),
));
}
}
let mut antares_fuse = fuse::AntaresFuse::new(
mountpoint.clone(),
self.dic.clone(),
upper_dir.clone(),
cl_dir.clone(),
)
.await?;
antares_fuse.mount().await?;
tracing::info!(
"antares: mount_job_at FUSE mounted job_id={} mountpoint={}",
job_id,
mountpoint.display()
);
let instance = AntaresConfig {
job_id: job_id.to_string(),
mountpoint,
upper_id,
upper_dir,
cl_dir,
cl_id,
};
self.instances
.lock()
.await
.insert(job_id.to_string(), instance.clone());
self.fuse_handles
.lock()
.await
.insert(job_id.to_string(), antares_fuse);
self.persist_state().await?;
tracing::info!(
"antares: mount_job done job_id={} mountpoint={} elapsed={:.2}s",
job_id,
instance.mountpoint.display(),
start.elapsed().as_secs_f64()
);
Ok(instance)
}
pub async fn umount_job(&self, job_id: &str) -> std::io::Result<Option<AntaresConfig>> {
use tracing::{info, warn};
let config = match self.instances.lock().await.get(job_id) {
Some(cfg) => cfg.clone(),
None => return Ok(None),
};
let mount_path = &config.mountpoint;
info!("Attempting to unmount FUSE mount at {:?}", mount_path);
let grace = unmount_grace_duration();
if !grace.is_zero() {
info!("Quiescing {:?} for {:?} before unmount", mount_path, grace);
tokio::time::sleep(grace).await;
}
let mut fuse_handles = self.fuse_handles.lock().await;
if let Some(mut fuse) = fuse_handles.remove(job_id) {
match fuse.unmount().await {
Ok(()) => {
info!("Successfully unmounted {:?} via FUSE handle", mount_path);
}
Err(e) => {
warn!(
"FUSE handle unmount failed for {:?}: {}, falling back to fusermount",
mount_path, e
);
let _ = tokio::process::Command::new("fusermount")
.arg("-u")
.arg(mount_path)
.output()
.await;
}
}
} else {
let output = tokio::process::Command::new("fusermount")
.arg("-u")
.arg(mount_path)
.output()
.await?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
if error_msg.contains("not mounted") || error_msg.contains("Invalid argument") {
warn!(
"Filesystem at {:?} is not mounted, removing bookkeeping only: {}",
mount_path, error_msg
);
} else {
warn!(
"fusermount -u failed with status {} for {:?}: {}",
output.status, mount_path, error_msg
);
}
} else {
info!("Successfully unmounted {:?} via fusermount", mount_path);
}
}
drop(fuse_handles);
let mut instances = self.instances.lock().await;
let removed = instances.remove(job_id);
drop(instances);
self.persist_state().await?;
Ok(removed)
}
pub async fn list(&self) -> Vec<AntaresConfig> {
self.instances.lock().await.values().cloned().collect()
}
pub fn dicfuse(&self) -> Arc<Dicfuse> {
self.dic.clone()
}
fn load_state(path: &Path) -> std::io::Result<HashMap<String, AntaresConfig>> {
if !path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(path)?;
let state: AntaresState = toml::from_str(&content).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("parse state: {e}"))
})?;
let mut map = HashMap::new();
for m in state.mounts {
map.insert(m.job_id.clone(), m);
}
Ok(map)
}
async fn persist_state(&self) -> std::io::Result<()> {
let mounts: Vec<AntaresConfig> = self.instances.lock().await.values().cloned().collect();
let state = AntaresState { mounts };
let data = toml::to_string_pretty(&state)
.map_err(|e| std::io::Error::other(format!("encode state: {e}")))?;
if let Some(parent) = self.paths.state_file.parent() {
fs::create_dir_all(parent)?;
}
let mut f = File::create(&self.paths.state_file)?;
f.write_all(data.as_bytes())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::unmount_grace_duration;
use serial_test::serial;
fn set_unmount_grace_env(value: Option<&str>) {
unsafe {
match value {
Some(value) => std::env::set_var("ANTARES_UNMOUNT_GRACE_MS", value),
None => std::env::remove_var("ANTARES_UNMOUNT_GRACE_MS"),
}
}
}
#[test]
#[serial]
fn test_unmount_grace_duration_defaults_to_150ms() {
set_unmount_grace_env(None);
assert_eq!(
unmount_grace_duration(),
std::time::Duration::from_millis(150)
);
}
#[test]
#[serial]
fn test_unmount_grace_duration_accepts_explicit_value() {
set_unmount_grace_env(Some("275"));
assert_eq!(
unmount_grace_duration(),
std::time::Duration::from_millis(275)
);
set_unmount_grace_env(None);
}
#[test]
#[serial]
fn test_unmount_grace_duration_clamps_and_falls_back() {
set_unmount_grace_env(Some("50000"));
assert_eq!(
unmount_grace_duration(),
std::time::Duration::from_millis(3_000)
);
set_unmount_grace_env(Some("not-a-number"));
assert_eq!(
unmount_grace_duration(),
std::time::Duration::from_millis(150)
);
set_unmount_grace_env(None);
}
}