Skip to main content

a3s_box_core/
volume.rs

1//! Volume types for named volume management.
2//!
3//! Provides volume configuration and metadata for persistent
4//! named volumes that can be shared across box instances.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Configuration for a named volume.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct VolumeConfig {
12    /// Volume name (unique identifier).
13    pub name: String,
14
15    /// Volume driver (currently only "local" is supported).
16    #[serde(default = "default_driver")]
17    pub driver: String,
18
19    /// Host path where volume data is stored.
20    pub mount_point: String,
21
22    /// User-defined labels.
23    #[serde(default)]
24    pub labels: HashMap<String, String>,
25
26    /// Box IDs currently using this volume.
27    #[serde(default)]
28    pub in_use_by: Vec<String>,
29
30    /// Maximum size in bytes (0 = unlimited).
31    #[serde(default)]
32    pub size_limit: u64,
33
34    /// Creation timestamp (RFC 3339).
35    pub created_at: String,
36}
37
38fn default_driver() -> String {
39    "local".to_string()
40}
41
42impl VolumeConfig {
43    /// Create a new named volume.
44    pub fn new(name: &str, mount_point: &str) -> Self {
45        Self {
46            name: name.to_string(),
47            driver: "local".to_string(),
48            mount_point: mount_point.to_string(),
49            labels: HashMap::new(),
50            in_use_by: Vec::new(),
51            size_limit: 0,
52            created_at: chrono::Utc::now().to_rfc3339(),
53        }
54    }
55
56    /// Create a new named volume with a size limit.
57    pub fn with_size_limit(name: &str, mount_point: &str, size_limit: u64) -> Self {
58        let mut vol = Self::new(name, mount_point);
59        vol.size_limit = size_limit;
60        vol
61    }
62
63    /// Check if the volume exceeds its size limit.
64    ///
65    /// Returns `Ok(current_size)` if within quota, or `Err` with a message
66    /// if the volume exceeds its limit. Returns `Ok(0)` if no limit is set.
67    pub fn check_quota(&self) -> Result<u64, String> {
68        if self.size_limit == 0 {
69            return Ok(0);
70        }
71        let path = std::path::Path::new(&self.mount_point);
72        if !path.exists() {
73            return Ok(0);
74        }
75        let size = dir_size(path);
76        if size > self.size_limit {
77            Err(format!(
78                "volume '{}' exceeds size limit: {} > {} bytes",
79                self.name, size, self.size_limit
80            ))
81        } else {
82            Ok(size)
83        }
84    }
85
86    /// Mark a box as using this volume.
87    pub fn attach(&mut self, box_id: &str) {
88        if !self.in_use_by.contains(&box_id.to_string()) {
89            self.in_use_by.push(box_id.to_string());
90        }
91    }
92
93    /// Remove a box from this volume's users.
94    pub fn detach(&mut self, box_id: &str) {
95        self.in_use_by.retain(|id| id != box_id);
96    }
97
98    /// Check if any boxes are using this volume.
99    pub fn is_in_use(&self) -> bool {
100        !self.in_use_by.is_empty()
101    }
102}
103
104/// Recursively calculate directory size in bytes.
105fn dir_size(path: &std::path::Path) -> u64 {
106    let mut total = 0u64;
107    if let Ok(entries) = std::fs::read_dir(path) {
108        for entry in entries.flatten() {
109            let p = entry.path();
110            if p.is_dir() {
111                total += dir_size(&p);
112            } else if let Ok(meta) = p.metadata() {
113                total += meta.len();
114            }
115        }
116    }
117    total
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_volume_config_new() {
126        let vol = VolumeConfig::new("mydata", "/home/user/.a3s/volumes/mydata");
127        assert_eq!(vol.name, "mydata");
128        assert_eq!(vol.driver, "local");
129        assert!(vol.in_use_by.is_empty());
130        assert!(vol.labels.is_empty());
131    }
132
133    #[test]
134    fn test_volume_attach_detach() {
135        let mut vol = VolumeConfig::new("mydata", "/tmp/vol");
136        vol.attach("box-1");
137        vol.attach("box-2");
138        assert_eq!(vol.in_use_by.len(), 2);
139        assert!(vol.is_in_use());
140
141        vol.detach("box-1");
142        assert_eq!(vol.in_use_by.len(), 1);
143        assert!(vol.is_in_use());
144
145        vol.detach("box-2");
146        assert!(!vol.is_in_use());
147    }
148
149    #[test]
150    fn test_volume_attach_idempotent() {
151        let mut vol = VolumeConfig::new("mydata", "/tmp/vol");
152        vol.attach("box-1");
153        vol.attach("box-1");
154        assert_eq!(vol.in_use_by.len(), 1);
155    }
156
157    #[test]
158    fn test_volume_detach_nonexistent() {
159        let mut vol = VolumeConfig::new("mydata", "/tmp/vol");
160        vol.detach("nonexistent"); // should not panic
161        assert!(vol.in_use_by.is_empty());
162    }
163
164    #[test]
165    fn test_volume_with_labels() {
166        let mut vol = VolumeConfig::new("mydata", "/tmp/vol");
167        vol.labels.insert("env".to_string(), "prod".to_string());
168        assert_eq!(vol.labels.get("env").unwrap(), "prod");
169    }
170
171    #[test]
172    fn test_volume_serialization() {
173        let mut vol = VolumeConfig::new("mydata", "/tmp/vol");
174        vol.attach("box-1");
175        vol.labels.insert("env".to_string(), "test".to_string());
176
177        let json = serde_json::to_string(&vol).unwrap();
178        let parsed: VolumeConfig = serde_json::from_str(&json).unwrap();
179
180        assert_eq!(parsed.name, "mydata");
181        assert_eq!(parsed.in_use_by, vec!["box-1"]);
182        assert_eq!(parsed.labels.get("env").unwrap(), "test");
183    }
184
185    #[test]
186    fn test_volume_default_driver() {
187        let json = r#"{"name":"test","mount_point":"/tmp","created_at":"2024-01-01T00:00:00Z"}"#;
188        let vol: VolumeConfig = serde_json::from_str(json).unwrap();
189        assert_eq!(vol.driver, "local");
190    }
191
192    #[test]
193    fn test_volume_size_limit_default_zero() {
194        let vol = VolumeConfig::new("test", "/tmp/vol");
195        assert_eq!(vol.size_limit, 0);
196    }
197
198    #[test]
199    fn test_volume_with_size_limit() {
200        let vol = VolumeConfig::with_size_limit("test", "/tmp/vol", 1024 * 1024);
201        assert_eq!(vol.size_limit, 1024 * 1024);
202        assert_eq!(vol.name, "test");
203    }
204
205    #[test]
206    fn test_volume_check_quota_no_limit() {
207        let vol = VolumeConfig::new("test", "/tmp/nonexistent");
208        assert!(vol.check_quota().is_ok());
209        assert_eq!(vol.check_quota().unwrap(), 0);
210    }
211
212    #[test]
213    fn test_volume_check_quota_within_limit() {
214        let dir = std::env::temp_dir().join("a3s_test_vol_quota_ok");
215        let _ = std::fs::remove_dir_all(&dir);
216        std::fs::create_dir_all(&dir).unwrap();
217        std::fs::write(dir.join("data.txt"), "hello").unwrap();
218
219        let vol = VolumeConfig::with_size_limit(
220            "test",
221            dir.to_str().unwrap(),
222            1024 * 1024, // 1MB limit
223        );
224        let result = vol.check_quota();
225        assert!(result.is_ok());
226        assert_eq!(result.unwrap(), 5); // "hello" = 5 bytes
227
228        let _ = std::fs::remove_dir_all(&dir);
229    }
230
231    #[test]
232    fn test_volume_check_quota_exceeded() {
233        let dir = std::env::temp_dir().join("a3s_test_vol_quota_exceed");
234        let _ = std::fs::remove_dir_all(&dir);
235        std::fs::create_dir_all(&dir).unwrap();
236        std::fs::write(dir.join("data.txt"), "hello world!").unwrap();
237
238        let vol = VolumeConfig::with_size_limit(
239            "test",
240            dir.to_str().unwrap(),
241            5, // 5 byte limit
242        );
243        let result = vol.check_quota();
244        assert!(result.is_err());
245        assert!(result.unwrap_err().contains("exceeds size limit"));
246
247        let _ = std::fs::remove_dir_all(&dir);
248    }
249
250    #[test]
251    fn test_volume_size_limit_serde() {
252        let vol = VolumeConfig::with_size_limit("test", "/tmp/vol", 1024);
253        let json = serde_json::to_string(&vol).unwrap();
254        let parsed: VolumeConfig = serde_json::from_str(&json).unwrap();
255        assert_eq!(parsed.size_limit, 1024);
256    }
257
258    #[test]
259    fn test_dir_size_empty() {
260        let dir = std::env::temp_dir().join("a3s_test_dir_size_empty");
261        let _ = std::fs::remove_dir_all(&dir);
262        std::fs::create_dir_all(&dir).unwrap();
263        assert_eq!(dir_size(&dir), 0);
264        let _ = std::fs::remove_dir_all(&dir);
265    }
266
267    #[test]
268    fn test_dir_size_with_files() {
269        let dir = std::env::temp_dir().join("a3s_test_dir_size_files");
270        let _ = std::fs::remove_dir_all(&dir);
271        std::fs::create_dir_all(&dir).unwrap();
272        std::fs::write(dir.join("a.txt"), "aaa").unwrap();
273        std::fs::write(dir.join("b.txt"), "bb").unwrap();
274        assert_eq!(dir_size(&dir), 5);
275        let _ = std::fs::remove_dir_all(&dir);
276    }
277
278    #[test]
279    fn test_dir_size_recursive() {
280        let dir = std::env::temp_dir().join("a3s_test_dir_size_recursive");
281        let _ = std::fs::remove_dir_all(&dir);
282        std::fs::create_dir_all(dir.join("sub")).unwrap();
283        std::fs::write(dir.join("a.txt"), "aaa").unwrap();
284        std::fs::write(dir.join("sub").join("b.txt"), "bb").unwrap();
285        assert_eq!(dir_size(&dir), 5);
286        let _ = std::fs::remove_dir_all(&dir);
287    }
288}