Skip to main content

nklave_storage/
rotation.rs

1//! Log rotation for decision logs
2//!
3//! This module provides size-based log rotation with optional compression.
4//! Old log files are renamed with timestamps and optionally compressed.
5
6use std::fs::{self, File};
7use std::io::{BufRead, BufReader};
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10use tracing::{debug, info, warn};
11
12/// Configuration for log rotation
13#[derive(Debug, Clone)]
14pub struct RotationConfig {
15    /// Maximum size in bytes before rotation
16    pub max_size_bytes: u64,
17
18    /// Maximum number of rotated files to keep
19    pub max_files: u32,
20
21    /// Whether to compress rotated files
22    pub compress: bool,
23}
24
25impl Default for RotationConfig {
26    fn default() -> Self {
27        Self {
28            max_size_bytes: 100 * 1024 * 1024, // 100 MB
29            max_files: 10,
30            compress: false, // Compression requires flate2 feature
31        }
32    }
33}
34
35impl RotationConfig {
36    /// Create a configuration for small logs (good for testing)
37    pub fn small() -> Self {
38        Self {
39            max_size_bytes: 1024 * 1024, // 1 MB
40            max_files: 5,
41            compress: false,
42        }
43    }
44
45    /// Create a configuration for production use
46    pub fn production() -> Self {
47        Self {
48            max_size_bytes: 100 * 1024 * 1024, // 100 MB
49            max_files: 10,
50            compress: false,
51        }
52    }
53}
54
55/// Log rotator that manages log file rotation
56pub struct LogRotator {
57    /// Base path for the log file (e.g., "/var/log/nklave/decisions.log")
58    base_path: PathBuf,
59
60    /// Rotation configuration
61    config: RotationConfig,
62}
63
64impl LogRotator {
65    /// Create a new log rotator
66    pub fn new(base_path: impl AsRef<Path>, config: RotationConfig) -> Self {
67        Self {
68            base_path: base_path.as_ref().to_path_buf(),
69            config,
70        }
71    }
72
73    /// Check if rotation is needed based on file size
74    pub fn needs_rotation(&self) -> bool {
75        if let Ok(metadata) = fs::metadata(&self.base_path) {
76            metadata.len() >= self.config.max_size_bytes
77        } else {
78            false
79        }
80    }
81
82    /// Get the current file size
83    pub fn current_size(&self) -> u64 {
84        fs::metadata(&self.base_path).map(|m| m.len()).unwrap_or(0)
85    }
86
87    /// Perform log rotation
88    ///
89    /// This will:
90    /// 1. Rename the current log file with a timestamp
91    /// 2. Optionally compress the rotated file
92    /// 3. Clean up old rotated files beyond max_files
93    ///
94    /// Returns the path to the rotated file.
95    pub fn rotate(&self) -> Result<PathBuf, RotationError> {
96        if !self.base_path.exists() {
97            return Err(RotationError::FileNotFound(
98                self.base_path.display().to_string(),
99            ));
100        }
101
102        // Generate timestamp suffix
103        let timestamp = std::time::SystemTime::now()
104            .duration_since(std::time::UNIX_EPOCH)
105            .map(|d| d.as_secs())
106            .unwrap_or(0);
107
108        // Create rotated filename
109        let rotated_name = format!(
110            "{}.{}",
111            self.base_path.file_name().unwrap().to_string_lossy(),
112            timestamp
113        );
114
115        let rotated_path = self
116            .base_path
117            .parent()
118            .unwrap_or(Path::new("."))
119            .join(&rotated_name);
120
121        // Rename current log to rotated path
122        fs::rename(&self.base_path, &rotated_path)
123            .map_err(|e| RotationError::Io(format!("Failed to rename log: {}", e)))?;
124
125        info!(
126            from = %self.base_path.display(),
127            to = %rotated_path.display(),
128            "Rotated log file"
129        );
130
131        // Cleanup old files
132        self.cleanup_old_files()?;
133
134        Ok(rotated_path)
135    }
136
137    /// List all rotated log files, sorted by age (newest first)
138    pub fn list_rotated_files(&self) -> Result<Vec<PathBuf>, RotationError> {
139        let dir = self
140            .base_path
141            .parent()
142            .unwrap_or(Path::new("."));
143
144        let base_name = self
145            .base_path
146            .file_name()
147            .unwrap()
148            .to_string_lossy()
149            .to_string();
150
151        let mut files: Vec<PathBuf> = fs::read_dir(dir)
152            .map_err(|e| RotationError::Io(format!("Failed to read directory: {}", e)))?
153            .filter_map(|entry| entry.ok())
154            .map(|entry| entry.path())
155            .filter(|path| {
156                if let Some(name) = path.file_name() {
157                    let name_str = name.to_string_lossy();
158                    // Match patterns like "decisions.log.1234567890"
159                    name_str.starts_with(&format!("{}.", base_name))
160                        && name_str != base_name
161                } else {
162                    false
163                }
164            })
165            .collect();
166
167        // Sort by modification time (newest first)
168        files.sort_by(|a, b| {
169            let a_time = fs::metadata(a)
170                .and_then(|m| m.modified())
171                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
172            let b_time = fs::metadata(b)
173                .and_then(|m| m.modified())
174                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
175            b_time.cmp(&a_time)
176        });
177
178        Ok(files)
179    }
180
181    /// Cleanup old rotated files beyond max_files limit
182    fn cleanup_old_files(&self) -> Result<(), RotationError> {
183        let files = self.list_rotated_files()?;
184
185        if files.len() as u32 > self.config.max_files {
186            let to_delete = &files[self.config.max_files as usize..];
187
188            for file in to_delete {
189                debug!(path = %file.display(), "Deleting old rotated log");
190                fs::remove_file(file).map_err(|e| {
191                    RotationError::Io(format!("Failed to delete {}: {}", file.display(), e))
192                })?;
193            }
194
195            info!(
196                deleted = to_delete.len(),
197                "Cleaned up old rotated log files"
198            );
199        }
200
201        Ok(())
202    }
203
204    /// Get total size of all log files (current + rotated)
205    pub fn total_log_size(&self) -> Result<u64, RotationError> {
206        let mut total = self.current_size();
207
208        for file in self.list_rotated_files()? {
209            if let Ok(metadata) = fs::metadata(&file) {
210                total += metadata.len();
211            }
212        }
213
214        Ok(total)
215    }
216
217    /// Read records from a rotated log file
218    ///
219    /// This is useful for forensic analysis or state recovery.
220    pub fn read_rotated_file<T, F>(&self, path: &Path, parse_fn: F) -> Result<Vec<T>, RotationError>
221    where
222        F: Fn(&str) -> Result<T, String>,
223    {
224        let file =
225            File::open(path).map_err(|e| RotationError::Io(format!("Failed to open: {}", e)))?;
226
227        let reader = BufReader::new(file);
228        let mut records = Vec::new();
229
230        for (line_num, line) in reader.lines().enumerate() {
231            let line = line.map_err(|e| RotationError::Io(e.to_string()))?;
232            if line.is_empty() {
233                continue;
234            }
235
236            match parse_fn(&line) {
237                Ok(record) => records.push(record),
238                Err(e) => {
239                    warn!(
240                        path = %path.display(),
241                        line = line_num + 1,
242                        error = %e,
243                        "Failed to parse record in rotated log"
244                    );
245                }
246            }
247        }
248
249        Ok(records)
250    }
251}
252
253/// Errors from log rotation
254#[derive(Debug, Error)]
255pub enum RotationError {
256    #[error("I/O error: {0}")]
257    Io(String),
258
259    #[error("File not found: {0}")]
260    FileNotFound(String),
261
262    #[error("Parse error: {0}")]
263    Parse(String),
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use std::fs::OpenOptions;
270    use std::io::Write;
271    use tempfile::TempDir;
272
273    #[test]
274    fn test_rotation_config_default() {
275        let config = RotationConfig::default();
276        assert_eq!(config.max_size_bytes, 100 * 1024 * 1024);
277        assert_eq!(config.max_files, 10);
278        assert!(!config.compress);
279    }
280
281    #[test]
282    fn test_needs_rotation() {
283        let dir = TempDir::new().unwrap();
284        let log_path = dir.path().join("test.log");
285
286        // Create a small log
287        {
288            let mut file = File::create(&log_path).unwrap();
289            file.write_all(b"test data").unwrap();
290        }
291
292        let config = RotationConfig {
293            max_size_bytes: 100, // Very small for testing
294            max_files: 5,
295            compress: false,
296        };
297
298        let rotator = LogRotator::new(&log_path, config);
299        assert!(!rotator.needs_rotation()); // 9 bytes < 100 bytes
300
301        // Write more data
302        {
303            let mut file = OpenOptions::new().append(true).open(&log_path).unwrap();
304            file.write_all(&[0u8; 200]).unwrap();
305        }
306
307        assert!(rotator.needs_rotation()); // Now > 100 bytes
308    }
309
310    #[test]
311    fn test_rotate_file() {
312        let dir = TempDir::new().unwrap();
313        let log_path = dir.path().join("decisions.log");
314
315        // Create initial log
316        {
317            let mut file = File::create(&log_path).unwrap();
318            file.write_all(b"original content").unwrap();
319        }
320
321        let config = RotationConfig::small();
322        let rotator = LogRotator::new(&log_path, config);
323
324        let rotated_path = rotator.rotate().unwrap();
325
326        // Original should not exist
327        assert!(!log_path.exists());
328
329        // Rotated should exist
330        assert!(rotated_path.exists());
331
332        // Rotated content should match original
333        let content = fs::read_to_string(&rotated_path).unwrap();
334        assert_eq!(content, "original content");
335    }
336
337    #[test]
338    fn test_list_rotated_files() {
339        let dir = TempDir::new().unwrap();
340        let log_path = dir.path().join("test.log");
341
342        // Create some rotated files
343        for i in 1..=3 {
344            let rotated = dir.path().join(format!("test.log.{}", 1000 + i));
345            File::create(&rotated).unwrap();
346            // Add small delay to ensure different timestamps
347            std::thread::sleep(std::time::Duration::from_millis(10));
348        }
349
350        // Create current log
351        File::create(&log_path).unwrap();
352
353        let config = RotationConfig::small();
354        let rotator = LogRotator::new(&log_path, config);
355
356        let files = rotator.list_rotated_files().unwrap();
357        assert_eq!(files.len(), 3);
358    }
359
360    #[test]
361    fn test_cleanup_old_files() {
362        let dir = TempDir::new().unwrap();
363        let log_path = dir.path().join("test.log");
364
365        // Create more rotated files than max_files
366        for i in 1..=10 {
367            let rotated = dir.path().join(format!("test.log.{}", 1000 + i));
368            File::create(&rotated).unwrap();
369            std::thread::sleep(std::time::Duration::from_millis(10));
370        }
371
372        // Create current log
373        File::create(&log_path).unwrap();
374
375        let config = RotationConfig {
376            max_size_bytes: 1024,
377            max_files: 3, // Keep only 3
378            compress: false,
379        };
380
381        let rotator = LogRotator::new(&log_path, config);
382
383        // Trigger cleanup
384        rotator.cleanup_old_files().unwrap();
385
386        // Should only have 3 files left
387        let files = rotator.list_rotated_files().unwrap();
388        assert_eq!(files.len(), 3);
389    }
390
391    #[test]
392    fn test_total_log_size() {
393        let dir = TempDir::new().unwrap();
394        let log_path = dir.path().join("test.log");
395
396        // Create current log with known size
397        {
398            let mut file = File::create(&log_path).unwrap();
399            file.write_all(&[0u8; 100]).unwrap();
400        }
401
402        // Create rotated files
403        for i in 1..=2 {
404            let rotated = dir.path().join(format!("test.log.{}", 1000 + i));
405            let mut file = File::create(&rotated).unwrap();
406            file.write_all(&[0u8; 50]).unwrap();
407        }
408
409        let config = RotationConfig::small();
410        let rotator = LogRotator::new(&log_path, config);
411
412        let total = rotator.total_log_size().unwrap();
413        assert_eq!(total, 100 + 50 + 50); // 200 bytes total
414    }
415
416    #[test]
417    fn test_read_rotated_file() {
418        let dir = TempDir::new().unwrap();
419        let log_path = dir.path().join("test.log");
420        let rotated_path = dir.path().join("test.log.12345");
421
422        // Create rotated file with parseable content
423        {
424            let mut file = File::create(&rotated_path).unwrap();
425            writeln!(file, "line1").unwrap();
426            writeln!(file, "line2").unwrap();
427            writeln!(file, "line3").unwrap();
428        }
429
430        // Create current log
431        File::create(&log_path).unwrap();
432
433        let config = RotationConfig::small();
434        let rotator = LogRotator::new(&log_path, config);
435
436        let records: Vec<String> = rotator
437            .read_rotated_file(&rotated_path, |line| Ok(line.to_string()))
438            .unwrap();
439
440        assert_eq!(records.len(), 3);
441        assert_eq!(records[0], "line1");
442        assert_eq!(records[2], "line3");
443    }
444}