Skip to main content

tatara_engine/domain/
volume_manager.rs

1//! Volume lifecycle management — create, attach, detach, delete.
2
3use anyhow::{Context, Result};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use tatara_core::domain::volume::{VolumeClaim, VolumeSource, VolumeSpec};
7use tokio::sync::RwLock;
8use tracing::{debug, info};
9use uuid::Uuid;
10
11/// Handle to an active volume.
12#[derive(Debug, Clone)]
13pub struct VolumeHandle {
14    pub name: String,
15    pub path: PathBuf,
16    pub read_only: bool,
17}
18
19/// Manages volume lifecycle for a node.
20pub struct VolumeManager {
21    /// Base directory for local volumes.
22    volume_dir: PathBuf,
23    /// Active volumes: volume_name -> VolumeHandle
24    active: RwLock<HashMap<String, VolumeHandle>>,
25}
26
27impl VolumeManager {
28    pub fn new(volume_dir: PathBuf) -> Self {
29        Self {
30            volume_dir,
31            active: RwLock::new(HashMap::new()),
32        }
33    }
34
35    /// Create a volume from its spec. Returns a handle for mounting.
36    pub async fn create(&self, spec: &VolumeSpec) -> Result<VolumeHandle> {
37        let path = match &spec.source {
38            VolumeSource::Local { size_mb: _ } => {
39                let vol_path = self.volume_dir.join(&spec.name);
40                tokio::fs::create_dir_all(&vol_path)
41                    .await
42                    .with_context(|| {
43                        format!("failed to create volume dir: {}", vol_path.display())
44                    })?;
45                info!(volume = %spec.name, path = %vol_path.display(), "created local volume");
46                vol_path
47            }
48            VolumeSource::HostPath { path } => {
49                let host_path = PathBuf::from(path);
50                if !host_path.exists() {
51                    anyhow::bail!("host path does not exist: {}", host_path.display());
52                }
53                debug!(volume = %spec.name, path = %host_path.display(), "using host path volume");
54                host_path
55            }
56            VolumeSource::Nfs { server, path } => {
57                // NFS mount requires platform-specific `mount` syscall.
58                // Return error until implemented to prevent silent data loss.
59                anyhow::bail!(
60                    "NFS mounts not yet implemented (volume '{}', server '{}', path '{}'). \
61                     Use Local or HostPath volumes, or mount NFS externally and use HostPath.",
62                    spec.name,
63                    server,
64                    path
65                );
66            }
67        };
68
69        let handle = VolumeHandle {
70            name: spec.name.clone(),
71            path,
72            read_only: spec.read_only,
73        };
74
75        self.active
76            .write()
77            .await
78            .insert(spec.name.clone(), handle.clone());
79
80        Ok(handle)
81    }
82
83    /// Resolve volume claims against created volumes.
84    /// Returns a map of mount_path -> host_path for the driver.
85    pub async fn resolve_mounts(&self, claims: &[VolumeClaim]) -> Result<HashMap<String, String>> {
86        let active = self.active.read().await;
87        let mut mounts = HashMap::new();
88
89        for claim in claims {
90            let handle = active
91                .get(&claim.volume_name)
92                .with_context(|| format!("volume '{}' not found", claim.volume_name))?;
93            mounts.insert(
94                handle.path.to_string_lossy().to_string(),
95                claim.mount_path.clone(),
96            );
97        }
98
99        Ok(mounts)
100    }
101
102    /// Release a volume by name.
103    pub async fn release(&self, name: &str) {
104        self.active.write().await.remove(name);
105        debug!(volume = name, "released volume");
106    }
107
108    /// Delete a local volume (removes the directory).
109    pub async fn delete(&self, name: &str) -> Result<()> {
110        let path = self.volume_dir.join(name);
111        if path.exists() {
112            tokio::fs::remove_dir_all(&path)
113                .await
114                .with_context(|| format!("failed to delete volume: {}", path.display()))?;
115            info!(volume = name, "deleted local volume");
116        }
117        self.active.write().await.remove(name);
118        Ok(())
119    }
120
121    /// Get a handle to an existing volume.
122    pub async fn get(&self, name: &str) -> Option<VolumeHandle> {
123        self.active.read().await.get(name).cloned()
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use tempfile::TempDir;
131
132    #[tokio::test]
133    async fn test_create_local_volume() {
134        let tmp = TempDir::new().unwrap();
135        let mgr = VolumeManager::new(tmp.path().to_path_buf());
136
137        let spec = VolumeSpec {
138            name: "data".to_string(),
139            source: VolumeSource::Local { size_mb: None },
140            read_only: false,
141        };
142
143        let handle = mgr.create(&spec).await.unwrap();
144        assert!(handle.path.exists());
145        assert_eq!(handle.name, "data");
146    }
147
148    #[tokio::test]
149    async fn test_resolve_mounts() {
150        let tmp = TempDir::new().unwrap();
151        let mgr = VolumeManager::new(tmp.path().to_path_buf());
152
153        let spec = VolumeSpec {
154            name: "data".to_string(),
155            source: VolumeSource::Local { size_mb: None },
156            read_only: false,
157        };
158        mgr.create(&spec).await.unwrap();
159
160        let claims = vec![VolumeClaim {
161            volume_name: "data".to_string(),
162            mount_path: "/app/data".to_string(),
163            read_only: false,
164        }];
165
166        let mounts = mgr.resolve_mounts(&claims).await.unwrap();
167        assert_eq!(mounts.len(), 1);
168        assert!(mounts.values().next().unwrap() == "/app/data");
169    }
170
171    #[tokio::test]
172    async fn test_delete_volume() {
173        let tmp = TempDir::new().unwrap();
174        let mgr = VolumeManager::new(tmp.path().to_path_buf());
175
176        let spec = VolumeSpec {
177            name: "ephemeral".to_string(),
178            source: VolumeSource::Local { size_mb: None },
179            read_only: false,
180        };
181        let handle = mgr.create(&spec).await.unwrap();
182        assert!(handle.path.exists());
183
184        mgr.delete("ephemeral").await.unwrap();
185        assert!(!handle.path.exists());
186    }
187}