Skip to main content

max / makenotwork

6.3 KB · 203 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 Ok(metadata) = entry.metadata() else {
70 continue;
71 };
72
73 if !metadata.is_file() {
74 continue;
75 }
76
77 let Ok(modified) = metadata.modified() else {
78 continue;
79 };
80
81 let size = metadata.len();
82
83 if newest
84 .as_ref()
85 .is_none_or(|(best_time, _, _)| modified > *best_time)
86 {
87 newest = Some((modified, size, name_str.into_owned()));
88 }
89 }
90
91 let Some((modified_time, size, _filename)) = newest else {
92 return BackupCheckResult {
93 target: target_name.to_string(),
94 database_name: database.to_string(),
95 status: "missing".to_string(),
96 last_backup_at: None,
97 size_bytes: None,
98 age_hours: None,
99 checked_at,
100 error: None,
101 };
102 };
103
104 let modified_chrono = chrono::DateTime::<chrono::Utc>::from(modified_time);
105 let age = chrono::Utc::now().signed_duration_since(modified_chrono);
106 let age_hours = age.num_hours();
107
108 let status = if age_hours < max_age_hours as i64 {
109 "ok"
110 } else {
111 "stale"
112 };
113
114 BackupCheckResult {
115 target: target_name.to_string(),
116 database_name: database.to_string(),
117 status: status.to_string(),
118 last_backup_at: Some(modified_chrono.to_rfc3339()),
119 size_bytes: Some(size as i64),
120 age_hours: Some(age_hours),
121 checked_at,
122 error: None,
123 }
124 }
125
126 #[cfg(test)]
127 mod tests {
128 use super::*;
129
130 #[test]
131 fn missing_directory_returns_error() {
132 let result = check_backup("test", "/nonexistent/path/123", "testdb", 25);
133 assert_eq!(result.status, "error");
134 assert!(result.error.as_ref().unwrap().contains("does not exist"));
135 }
136
137 #[test]
138 fn empty_directory_returns_missing() {
139 let dir = std::env::temp_dir().join("pom_backup_test_empty");
140 let _ = std::fs::create_dir_all(&dir);
141 let result = check_backup("test", dir.to_str().unwrap(), "testdb", 25);
142 assert_eq!(result.status, "missing");
143 assert!(result.error.is_none());
144 let _ = std::fs::remove_dir_all(&dir);
145 }
146
147 #[test]
148 fn recent_backup_returns_ok() {
149 let dir = std::env::temp_dir().join("pom_backup_test_ok");
150 let _ = std::fs::create_dir_all(&dir);
151 let file = dir.join("mydb_2026-04-15.sql.gz");
152 std::fs::write(&file, b"fake backup").unwrap();
153
154 let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25);
155 assert_eq!(result.status, "ok");
156 assert!(result.last_backup_at.is_some());
157 assert!(result.size_bytes.unwrap() > 0);
158 assert_eq!(result.age_hours.unwrap(), 0);
159
160 let _ = std::fs::remove_dir_all(&dir);
161 }
162
163 #[test]
164 fn hyphen_separator_returns_ok() {
165 let dir = std::env::temp_dir().join("pom_backup_test_hyphen");
166 let _ = std::fs::create_dir_all(&dir);
167 let file = dir.join("mydb-20260508-030000.sql.gz");
168 std::fs::write(&file, b"fake backup").unwrap();
169
170 let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25);
171 assert_eq!(result.status, "ok");
172 assert!(result.size_bytes.unwrap() > 0);
173
174 let _ = std::fs::remove_dir_all(&dir);
175 }
176
177 #[test]
178 fn wrong_prefix_not_matched() {
179 let dir = std::env::temp_dir().join("pom_backup_test_prefix");
180 let _ = std::fs::create_dir_all(&dir);
181 let file = dir.join("otherdb_2026-04-15.sql.gz");
182 std::fs::write(&file, b"wrong db").unwrap();
183
184 let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25);
185 assert_eq!(result.status, "missing");
186
187 let _ = std::fs::remove_dir_all(&dir);
188 }
189
190 #[test]
191 fn wrong_suffix_not_matched() {
192 let dir = std::env::temp_dir().join("pom_backup_test_suffix");
193 let _ = std::fs::create_dir_all(&dir);
194 let file = dir.join("mydb_2026-04-15.sql");
195 std::fs::write(&file, b"wrong suffix").unwrap();
196
197 let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25);
198 assert_eq!(result.status, "missing");
199
200 let _ = std::fs::remove_dir_all(&dir);
201 }
202 }
203