1#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct BackupConfig {
38 pub endpoint: String,
39 pub bucket: String,
40 pub region: String,
41 pub access_key_id: String,
42 pub secret_access_key: String,
43 pub credentials_file: Option<String>,
44 pub prefix: String,
45 pub checkpoint_interval_secs: u64,
46 pub wal_flush_interval_secs: u64,
47 pub pause_on_lag_secs: u64,
53}
54
55const REQUIRED_VARS: &[&str] = &[
56 "REDDB_BACKUP_S3_ENDPOINT",
57 "REDDB_BACKUP_S3_BUCKET",
58 "REDDB_BACKUP_S3_PREFIX",
59 "REDDB_BACKUP_S3_ACCESS_KEY_ID",
60 "REDDB_BACKUP_S3_SECRET_ACCESS_KEY",
61];
62
63const REGION_VAR: &str = "REDDB_BACKUP_S3_REGION";
64const CREDENTIALS_FILE_VAR: &str = "REDDB_BACKUP_S3_CREDENTIALS_FILE";
65const CHECKPOINT_VAR: &str = "REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS";
66const WAL_FLUSH_VAR: &str = "REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS";
67const PAUSE_ON_LAG_VAR: &str = "REDDB_BACKUP_PAUSE_ON_LAG_SECS";
68
69const DEFAULT_REGION: &str = "auto";
70const DEFAULT_CHECKPOINT_SECS: u64 = 3600;
71const DEFAULT_WAL_FLUSH_SECS: u64 = 30;
72const DEFAULT_PAUSE_ON_LAG_SECS: u64 = 0;
73
74pub fn from_env<F>(env: F) -> Result<Option<BackupConfig>, String>
77where
78 F: Fn(&str) -> Option<String>,
79{
80 let credentials_file = env(CREDENTIALS_FILE_VAR).filter(|v| !v.trim().is_empty());
81 if let Some(path) = &credentials_file {
82 if !std::path::Path::new(path).is_absolute() {
83 return Err(format!("{CREDENTIALS_FILE_VAR} must be an absolute path"));
84 }
85 if REQUIRED_VARS[3..]
86 .iter()
87 .any(|name| env(name).is_some_and(|v| !v.trim().is_empty()))
88 {
89 return Err("backup credentials file and static keys are mutually exclusive".into());
90 }
91 }
92 let required_vars = if credentials_file.is_some() {
93 &REQUIRED_VARS[..3]
94 } else {
95 REQUIRED_VARS
96 };
97 let presence: Vec<(&str, Option<String>)> = required_vars
98 .iter()
99 .map(|name| (*name, env(name).filter(|v| !v.trim().is_empty())))
100 .collect();
101
102 let present_count = presence.iter().filter(|(_, v)| v.is_some()).count();
103
104 if present_count == 0 && credentials_file.is_none() {
105 return Ok(None);
106 }
107
108 if present_count < required_vars.len() {
109 let missing: Vec<&str> = presence
110 .iter()
111 .filter_map(|(n, v)| v.is_none().then_some(*n))
112 .collect();
113 return Err(format!(
114 "partial REDDB_BACKUP_S3_* config; missing: {}",
115 missing.join(", ")
116 ));
117 }
118
119 let mut required = presence.into_iter().map(|(_, v)| v.unwrap());
120 let endpoint = required.next().unwrap();
121 let bucket = required.next().unwrap();
122 let prefix = required.next().unwrap();
123 let access_key_id = required.next().unwrap_or_default();
124 let secret_access_key = required.next().unwrap_or_default();
125
126 let region = env(REGION_VAR)
127 .filter(|v| !v.trim().is_empty())
128 .unwrap_or_else(|| DEFAULT_REGION.to_string());
129
130 let checkpoint_interval_secs = parse_interval(&env, CHECKPOINT_VAR, DEFAULT_CHECKPOINT_SECS)?;
131 let wal_flush_interval_secs = parse_interval(&env, WAL_FLUSH_VAR, DEFAULT_WAL_FLUSH_SECS)?;
132 let pause_on_lag_secs = parse_pause_on_lag(&env, DEFAULT_PAUSE_ON_LAG_SECS)?;
133
134 Ok(Some(BackupConfig {
135 endpoint,
136 bucket,
137 region,
138 access_key_id,
139 secret_access_key,
140 credentials_file,
141 prefix,
142 checkpoint_interval_secs,
143 wal_flush_interval_secs,
144 pause_on_lag_secs,
145 }))
146}
147
148fn parse_pause_on_lag<F>(env: &F, default: u64) -> Result<u64, String>
149where
150 F: Fn(&str) -> Option<String>,
151{
152 let Some(raw) = env(PAUSE_ON_LAG_VAR).filter(|v| !v.trim().is_empty()) else {
153 return Ok(default);
154 };
155 let trimmed = raw.trim();
156 let parsed: i128 = trimmed
157 .parse()
158 .map_err(|_| format!("{PAUSE_ON_LAG_VAR} must be a non-negative integer; got {raw:?}"))?;
159 if parsed < 0 {
160 return Err(format!(
161 "{PAUSE_ON_LAG_VAR} must be >= 0; got {parsed} (negative not allowed)"
162 ));
163 }
164 let as_u64 = u64::try_from(parsed)
165 .map_err(|_| format!("{PAUSE_ON_LAG_VAR} exceeds u64 range; got {parsed}"))?;
166 Ok(as_u64)
167}
168
169fn parse_interval<F>(env: &F, name: &str, default: u64) -> Result<u64, String>
170where
171 F: Fn(&str) -> Option<String>,
172{
173 let Some(raw) = env(name).filter(|v| !v.trim().is_empty()) else {
174 return Ok(default);
175 };
176 let trimmed = raw.trim();
177 let parsed: i128 = trimmed
178 .parse()
179 .map_err(|_| format!("{name} must be a positive integer; got {raw:?}"))?;
180 if parsed <= 0 {
181 return Err(format!(
182 "{name} must be > 0; got {parsed} (zero/negative not allowed)"
183 ));
184 }
185 let as_u64 =
186 u64::try_from(parsed).map_err(|_| format!("{name} exceeds u64 range; got {parsed}"))?;
187 Ok(as_u64)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use std::collections::HashMap;
194
195 #[cfg(windows)]
196 const TEST_CREDENTIALS_PATH: &str = r"C:\var\run\secrets\backup\credentials.json";
197 #[cfg(not(windows))]
198 const TEST_CREDENTIALS_PATH: &str = "/var/run/secrets/backup/credentials.json";
199
200 #[test]
201 fn mounted_credentials_replace_static_keys_without_fallback() {
202 let mut map: HashMap<&str, &str> = [
203 ("REDDB_BACKUP_S3_ENDPOINT", "https://s3.example.com"),
204 ("REDDB_BACKUP_S3_BUCKET", "synthetic-bucket"),
205 ("REDDB_BACKUP_S3_PREFIX", "org/db/"),
206 ("REDDB_BACKUP_S3_CREDENTIALS_FILE", TEST_CREDENTIALS_PATH),
207 ]
208 .into_iter()
209 .collect();
210 let lookup_map = |key: &str| map.get(key).map(|v| v.to_string());
211 let config = from_env(lookup_map)
212 .expect("valid file mode")
213 .expect("backup configured");
214 assert_eq!(
215 config.credentials_file.as_deref(),
216 Some(TEST_CREDENTIALS_PATH)
217 );
218 assert!(config.access_key_id.is_empty() && config.secret_access_key.is_empty());
219 map.insert("REDDB_BACKUP_S3_ACCESS_KEY_ID", "must-not-fall-back");
220 assert!(from_env(|key| map.get(key).map(|v| v.to_string())).is_err());
221 map.remove("REDDB_BACKUP_S3_ACCESS_KEY_ID");
222 map.insert(
223 "REDDB_BACKUP_S3_CREDENTIALS_FILE",
224 "relative/credentials.json",
225 );
226 assert!(from_env(|key| map.get(key).map(|v| v.to_string())).is_err());
227 map.insert("REDDB_BACKUP_S3_CREDENTIALS_FILE", TEST_CREDENTIALS_PATH);
228 map.remove("REDDB_BACKUP_S3_BUCKET");
229 assert!(from_env(|key| map.get(key).map(|v| v.to_string())).is_err());
230 }
231
232 #[test]
233 fn credentials_file_alone_is_partial_configuration() {
234 assert!(
235 from_env(|key| (key == CREDENTIALS_FILE_VAR).then(|| TEST_CREDENTIALS_PATH.into()))
236 .is_err()
237 );
238 }
239
240 fn lookup<'a>(
241 map: &'a HashMap<&'static str, &'static str>,
242 ) -> impl Fn(&str) -> Option<String> + 'a {
243 move |k| map.get(k).map(|s| s.to_string())
244 }
245
246 #[test]
247 fn none_present_yields_none() {
248 let map: HashMap<&'static str, &'static str> = HashMap::new();
249 let got = from_env(lookup(&map)).unwrap();
250 assert!(got.is_none());
251 }
252
253 #[test]
254 fn all_required_present_yields_config_with_defaults() {
255 let map: HashMap<&'static str, &'static str> = [
256 ("REDDB_BACKUP_S3_ENDPOINT", "https://s3.example.com"),
257 ("REDDB_BACKUP_S3_BUCKET", "buck"),
258 ("REDDB_BACKUP_S3_PREFIX", "clusters/dev/"),
259 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
260 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
261 ]
262 .into_iter()
263 .collect();
264 let cfg = from_env(lookup(&map)).unwrap().expect("Some");
265 assert_eq!(cfg.endpoint, "https://s3.example.com");
266 assert_eq!(cfg.bucket, "buck");
267 assert_eq!(cfg.prefix, "clusters/dev/");
268 assert_eq!(cfg.access_key_id, "AK");
269 assert_eq!(cfg.secret_access_key, "SK");
270 assert_eq!(cfg.region, DEFAULT_REGION);
271 assert_eq!(cfg.checkpoint_interval_secs, DEFAULT_CHECKPOINT_SECS);
272 assert_eq!(cfg.wal_flush_interval_secs, DEFAULT_WAL_FLUSH_SECS);
273 assert_eq!(cfg.pause_on_lag_secs, DEFAULT_PAUSE_ON_LAG_SECS);
274 }
275
276 #[test]
277 fn pause_on_lag_is_parsed_when_present() {
278 let map: HashMap<&'static str, &'static str> = [
279 ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
280 ("REDDB_BACKUP_S3_BUCKET", "b"),
281 ("REDDB_BACKUP_S3_PREFIX", "p/"),
282 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
283 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
284 ("REDDB_BACKUP_PAUSE_ON_LAG_SECS", "300"),
285 ]
286 .into_iter()
287 .collect();
288 let cfg = from_env(lookup(&map)).unwrap().expect("Some");
289 assert_eq!(cfg.pause_on_lag_secs, 300);
290 }
291
292 #[test]
293 fn pause_on_lag_zero_is_disabled() {
294 let map: HashMap<&'static str, &'static str> = [
295 ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
296 ("REDDB_BACKUP_S3_BUCKET", "b"),
297 ("REDDB_BACKUP_S3_PREFIX", "p/"),
298 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
299 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
300 ("REDDB_BACKUP_PAUSE_ON_LAG_SECS", "0"),
301 ]
302 .into_iter()
303 .collect();
304 let cfg = from_env(lookup(&map)).unwrap().expect("Some");
305 assert_eq!(cfg.pause_on_lag_secs, 0);
306 }
307
308 #[test]
309 fn pause_on_lag_negative_is_error() {
310 let map: HashMap<&'static str, &'static str> = [
311 ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
312 ("REDDB_BACKUP_S3_BUCKET", "b"),
313 ("REDDB_BACKUP_S3_PREFIX", "p/"),
314 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
315 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
316 ("REDDB_BACKUP_PAUSE_ON_LAG_SECS", "-1"),
317 ]
318 .into_iter()
319 .collect();
320 let err = from_env(lookup(&map)).unwrap_err();
321 assert!(err.contains("REDDB_BACKUP_PAUSE_ON_LAG_SECS"), "{err}");
322 }
323
324 #[test]
325 fn pause_on_lag_non_numeric_is_error() {
326 let map: HashMap<&'static str, &'static str> = [
327 ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
328 ("REDDB_BACKUP_S3_BUCKET", "b"),
329 ("REDDB_BACKUP_S3_PREFIX", "p/"),
330 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
331 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
332 ("REDDB_BACKUP_PAUSE_ON_LAG_SECS", "soon"),
333 ]
334 .into_iter()
335 .collect();
336 let err = from_env(lookup(&map)).unwrap_err();
337 assert!(err.contains("REDDB_BACKUP_PAUSE_ON_LAG_SECS"), "{err}");
338 assert!(err.contains("non-negative"), "{err}");
339 }
340
341 #[test]
342 fn all_required_present_with_explicit_overrides() {
343 let map: HashMap<&'static str, &'static str> = [
344 ("REDDB_BACKUP_S3_ENDPOINT", "https://s3.example.com"),
345 ("REDDB_BACKUP_S3_BUCKET", "b"),
346 ("REDDB_BACKUP_S3_PREFIX", "p/"),
347 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
348 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
349 ("REDDB_BACKUP_S3_REGION", "us-east-1"),
350 ("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS", "60"),
351 ("REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS", "5"),
352 ]
353 .into_iter()
354 .collect();
355 let cfg = from_env(lookup(&map)).unwrap().expect("Some");
356 assert_eq!(cfg.region, "us-east-1");
357 assert_eq!(cfg.checkpoint_interval_secs, 60);
358 assert_eq!(cfg.wal_flush_interval_secs, 5);
359 }
360
361 #[test]
362 fn partial_config_names_missing_var() {
363 let map: HashMap<&'static str, &'static str> = [
364 ("REDDB_BACKUP_S3_ENDPOINT", "https://s3.example.com"),
365 ("REDDB_BACKUP_S3_BUCKET", "b"),
366 ]
367 .into_iter()
368 .collect();
369 let err = from_env(lookup(&map)).unwrap_err();
370 assert!(err.contains("REDDB_BACKUP_S3_PREFIX"), "{err}");
371 assert!(err.contains("REDDB_BACKUP_S3_ACCESS_KEY_ID"), "{err}");
372 assert!(err.contains("REDDB_BACKUP_S3_SECRET_ACCESS_KEY"), "{err}");
373 }
374
375 #[test]
376 fn whitespace_only_required_treated_as_missing() {
377 let map: HashMap<&'static str, &'static str> = [
378 ("REDDB_BACKUP_S3_ENDPOINT", " "),
379 ("REDDB_BACKUP_S3_BUCKET", "b"),
380 ("REDDB_BACKUP_S3_PREFIX", "p/"),
381 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
382 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
383 ]
384 .into_iter()
385 .collect();
386 let err = from_env(lookup(&map)).unwrap_err();
387 assert!(err.contains("REDDB_BACKUP_S3_ENDPOINT"), "{err}");
388 }
389
390 #[test]
391 fn non_numeric_interval_is_error() {
392 let map: HashMap<&'static str, &'static str> = [
393 ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
394 ("REDDB_BACKUP_S3_BUCKET", "b"),
395 ("REDDB_BACKUP_S3_PREFIX", "p/"),
396 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
397 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
398 ("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS", "abc"),
399 ]
400 .into_iter()
401 .collect();
402 let err = from_env(lookup(&map)).unwrap_err();
403 assert!(
404 err.contains("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS"),
405 "{err}"
406 );
407 assert!(err.contains("positive integer"), "{err}");
408 }
409
410 #[test]
411 fn zero_interval_is_error() {
412 let map: HashMap<&'static str, &'static str> = [
413 ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
414 ("REDDB_BACKUP_S3_BUCKET", "b"),
415 ("REDDB_BACKUP_S3_PREFIX", "p/"),
416 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
417 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
418 ("REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS", "0"),
419 ]
420 .into_iter()
421 .collect();
422 let err = from_env(lookup(&map)).unwrap_err();
423 assert!(
424 err.contains("REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS"),
425 "{err}"
426 );
427 assert!(err.contains("> 0"), "{err}");
428 }
429
430 #[test]
431 fn negative_interval_is_error() {
432 let map: HashMap<&'static str, &'static str> = [
433 ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
434 ("REDDB_BACKUP_S3_BUCKET", "b"),
435 ("REDDB_BACKUP_S3_PREFIX", "p/"),
436 ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
437 ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
438 ("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS", "-10"),
439 ]
440 .into_iter()
441 .collect();
442 let err = from_env(lookup(&map)).unwrap_err();
443 assert!(
444 err.contains("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS"),
445 "{err}"
446 );
447 }
448}