Skip to main content

max / makenotwork

6.3 KB · 202 lines History Blame Raw
1 //! Local filesystem backup verification — scan for PostgreSQL backup files
2 //! and check their recency.
3
4 use std::path::Path;
5
6 use tracing::instrument;
7
8 use crate::types::BackupCheckResult;
9
10 /// Scan `directory` for backup files matching `{database}_*.sql.gz`, find the
11 /// newest one by modification time, and return a status based on its age.
12 ///
13 /// Returns "ok" if the newest backup is younger than `max_age_hours`, "stale"
14 /// if older, "missing" if no matching files exist, or "error" on I/O failures.
15 #[instrument(skip_all)]
16 pub fn check_backup(
17 target_name: &str,
18 directory: &str,
19 database: &str,
20 max_age_hours: u64,
21 ) -> BackupCheckResult {
22 let checked_at = chrono::Utc::now().to_rfc3339();
23 let dir = Path::new(directory);
24
25 if !dir.exists() {
26 return BackupCheckResult {
27 target: target_name.to_string(),
28 database_name: database.to_string(),
29 status: "error".to_string(),
30 last_backup_at: None,
31 size_bytes: None,
32 age_hours: None,
33 checked_at,
34 error: Some(format!("backup directory does not exist: {directory}")),
35 };
36 }
37
38 let prefix_underscore = format!("{database}_");
39 let prefix_hyphen = format!("{database}-");
40 let suffix = ".sql.gz";
41
42 let entries = match std::fs::read_dir(dir) {
43 Ok(entries) => entries,
44 Err(e) => {
45 return BackupCheckResult {
46 target: target_name.to_string(),
47 database_name: database.to_string(),
48 status: "error".to_string(),
49 last_backup_at: None,
50 size_bytes: None,
51 age_hours: None,
52 checked_at,
53 error: Some(format!("failed to read backup directory: {e}")),
54 };
55 }
56 };
57
58 let mut newest: Option<(std::time::SystemTime, u64, String)> = None;
59
60 for entry in entries.flatten() {
61 let name = entry.file_name();
62 let name_str = name.to_string_lossy();
63 if !(name_str.starts_with(&prefix_underscore) || name_str.starts_with(&prefix_hyphen))
64 || !name_str.ends_with(suffix)
65 {
66 continue;
67 }
68
69 let metadata = match entry.metadata() {
70 Ok(m) => m,
71 Err(_) => continue,
72 };
73
74 if !metadata.is_file() {
75 continue;
76 }
77
78 let modified = match metadata.modified() {
79 Ok(t) => t,
80 Err(_) => continue,
81 };
82
83 let size = metadata.len();
84
85 if newest.as_ref().is_none_or(|(best_time, _, _)| modified > *best_time) {
86 newest = Some((modified, size, name_str.into_owned()));
87 }
88 }
89
90 let Some((modified_time, size, _filename)) = newest else {
91 return BackupCheckResult {
92 target: target_name.to_string(),
93 database_name: database.to_string(),
94 status: "missing".to_string(),
95 last_backup_at: None,
96 size_bytes: None,
97 age_hours: None,
98 checked_at,
99 error: None,
100 };
101 };
102
103 let modified_chrono = chrono::DateTime::<chrono::Utc>::from(modified_time);
104 let age = chrono::Utc::now().signed_duration_since(modified_chrono);
105 let age_hours = age.num_hours();
106
107 let status = if age_hours < max_age_hours as i64 {
108 "ok"
109 } else {
110 "stale"
111 };
112
113 BackupCheckResult {
114 target: target_name.to_string(),
115 database_name: database.to_string(),
116 status: status.to_string(),
117 last_backup_at: Some(modified_chrono.to_rfc3339()),
118 size_bytes: Some(size as i64),
119 age_hours: Some(age_hours),
120 checked_at,
121 error: None,
122 }
123 }
124
125 #[cfg(test)]
126 mod tests {
127 use super::*;
128
129 #[test]
130 fn missing_directory_returns_error() {
131 let result = check_backup("test", "/nonexistent/path/123", "testdb", 25);
132 assert_eq!(result.status, "error");
133 assert!(result.error.as_ref().unwrap().contains("does not exist"));
134 }
135
136 #[test]
137 fn empty_directory_returns_missing() {
138 let dir = std::env::temp_dir().join("pom_backup_test_empty");
139 let _ = std::fs::create_dir_all(&dir);
140 let result = check_backup("test", dir.to_str().unwrap(), "testdb", 25);
141 assert_eq!(result.status, "missing");
142 assert!(result.error.is_none());
143 let _ = std::fs::remove_dir_all(&dir);
144 }
145
146 #[test]
147 fn recent_backup_returns_ok() {
148 let dir = std::env::temp_dir().join("pom_backup_test_ok");
149 let _ = std::fs::create_dir_all(&dir);
150 let file = dir.join("mydb_2026-04-15.sql.gz");
151 std::fs::write(&file, b"fake backup").unwrap();
152
153 let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25);
154 assert_eq!(result.status, "ok");
155 assert!(result.last_backup_at.is_some());
156 assert!(result.size_bytes.unwrap() > 0);
157 assert_eq!(result.age_hours.unwrap(), 0);
158
159 let _ = std::fs::remove_dir_all(&dir);
160 }
161
162 #[test]
163 fn hyphen_separator_returns_ok() {
164 let dir = std::env::temp_dir().join("pom_backup_test_hyphen");
165 let _ = std::fs::create_dir_all(&dir);
166 let file = dir.join("mydb-20260508-030000.sql.gz");
167 std::fs::write(&file, b"fake backup").unwrap();
168
169 let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25);
170 assert_eq!(result.status, "ok");
171 assert!(result.size_bytes.unwrap() > 0);
172
173 let _ = std::fs::remove_dir_all(&dir);
174 }
175
176 #[test]
177 fn wrong_prefix_not_matched() {
178 let dir = std::env::temp_dir().join("pom_backup_test_prefix");
179 let _ = std::fs::create_dir_all(&dir);
180 let file = dir.join("otherdb_2026-04-15.sql.gz");
181 std::fs::write(&file, b"wrong db").unwrap();
182
183 let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25);
184 assert_eq!(result.status, "missing");
185
186 let _ = std::fs::remove_dir_all(&dir);
187 }
188
189 #[test]
190 fn wrong_suffix_not_matched() {
191 let dir = std::env::temp_dir().join("pom_backup_test_suffix");
192 let _ = std::fs::create_dir_all(&dir);
193 let file = dir.join("mydb_2026-04-15.sql");
194 std::fs::write(&file, b"wrong suffix").unwrap();
195
196 let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25);
197 assert_eq!(result.status, "missing");
198
199 let _ = std::fs::remove_dir_all(&dir);
200 }
201 }
202