Skip to main content

reddb_server/
backup_bootstrap.rs

1//! Env-driven `BackupBootstrap` (issue #517).
2//!
3//! Parses the canonical `REDDB_BACKUP_*` env contract into a
4//! [`BackupConfig`]. Pure function — env access is injected as a
5//! closure so unit tests need no real process env. The `red` binary
6//! calls [`from_env`] at boot; the returned `Option<BackupConfig>`
7//! drives `Options::with_remote_backend` + `with_atomic_remote_backend`
8//! wiring and the archiver / checkpointer task intervals.
9//!
10//! Contract:
11//!   * `REDDB_BACKUP_S3_ENDPOINT`   (required)
12//!   * `REDDB_BACKUP_S3_BUCKET`     (required)
13//!   * `REDDB_BACKUP_S3_PREFIX`     (required)
14//!   * `REDDB_BACKUP_S3_ACCESS_KEY_ID`     (required)
15//!   * `REDDB_BACKUP_S3_SECRET_ACCESS_KEY` (required)
16//!   * `REDDB_BACKUP_S3_REGION`     (default `auto`)
17//!   * `REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS` (default 3600, must be > 0)
18//!   * `REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS`  (default 30,   must be > 0)
19//!
20//! Resolution:
21//!   * All required vars absent → `Ok(None)` (standalone; identical to
22//!     today's behaviour).
23//!   * All required vars present → `Ok(Some(BackupConfig))`.
24//!   * Partial config (at least one required present, at least one
25//!     missing) → `Err` naming the missing var.
26//!   * Non-numeric / zero interval → `Err`.
27
28/// Parsed configuration produced by [`from_env`]. Carries everything
29/// the `red` binary needs to construct an `S3Backend` and the two
30/// background tasks (archiver + checkpointer).
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BackupConfig {
33    pub endpoint: String,
34    pub bucket: String,
35    pub region: String,
36    pub access_key_id: String,
37    pub secret_access_key: String,
38    pub prefix: String,
39    pub checkpoint_interval_secs: u64,
40    pub wal_flush_interval_secs: u64,
41}
42
43const REQUIRED_VARS: &[&str] = &[
44    "REDDB_BACKUP_S3_ENDPOINT",
45    "REDDB_BACKUP_S3_BUCKET",
46    "REDDB_BACKUP_S3_PREFIX",
47    "REDDB_BACKUP_S3_ACCESS_KEY_ID",
48    "REDDB_BACKUP_S3_SECRET_ACCESS_KEY",
49];
50
51const REGION_VAR: &str = "REDDB_BACKUP_S3_REGION";
52const CHECKPOINT_VAR: &str = "REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS";
53const WAL_FLUSH_VAR: &str = "REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS";
54
55const DEFAULT_REGION: &str = "auto";
56const DEFAULT_CHECKPOINT_SECS: u64 = 3600;
57const DEFAULT_WAL_FLUSH_SECS: u64 = 30;
58
59/// Parse the `REDDB_BACKUP_*` env contract using the supplied
60/// env-var lookup. See module docs for the contract.
61pub fn from_env<F>(env: F) -> Result<Option<BackupConfig>, String>
62where
63    F: Fn(&str) -> Option<String>,
64{
65    let presence: Vec<(&str, Option<String>)> = REQUIRED_VARS
66        .iter()
67        .map(|name| (*name, env(name).filter(|v| !v.trim().is_empty())))
68        .collect();
69
70    let present_count = presence.iter().filter(|(_, v)| v.is_some()).count();
71
72    if present_count == 0 {
73        return Ok(None);
74    }
75
76    if present_count < REQUIRED_VARS.len() {
77        let missing: Vec<&str> = presence
78            .iter()
79            .filter_map(|(n, v)| v.is_none().then_some(*n))
80            .collect();
81        return Err(format!(
82            "partial REDDB_BACKUP_S3_* config; missing: {}",
83            missing.join(", ")
84        ));
85    }
86
87    let mut required = presence.into_iter().map(|(_, v)| v.unwrap());
88    let endpoint = required.next().unwrap();
89    let bucket = required.next().unwrap();
90    let prefix = required.next().unwrap();
91    let access_key_id = required.next().unwrap();
92    let secret_access_key = required.next().unwrap();
93
94    let region = env(REGION_VAR)
95        .filter(|v| !v.trim().is_empty())
96        .unwrap_or_else(|| DEFAULT_REGION.to_string());
97
98    let checkpoint_interval_secs =
99        parse_interval(&env, CHECKPOINT_VAR, DEFAULT_CHECKPOINT_SECS)?;
100    let wal_flush_interval_secs = parse_interval(&env, WAL_FLUSH_VAR, DEFAULT_WAL_FLUSH_SECS)?;
101
102    Ok(Some(BackupConfig {
103        endpoint,
104        bucket,
105        region,
106        access_key_id,
107        secret_access_key,
108        prefix,
109        checkpoint_interval_secs,
110        wal_flush_interval_secs,
111    }))
112}
113
114fn parse_interval<F>(env: &F, name: &str, default: u64) -> Result<u64, String>
115where
116    F: Fn(&str) -> Option<String>,
117{
118    let Some(raw) = env(name).filter(|v| !v.trim().is_empty()) else {
119        return Ok(default);
120    };
121    let trimmed = raw.trim();
122    let parsed: i128 = trimmed
123        .parse()
124        .map_err(|_| format!("{name} must be a positive integer; got {raw:?}"))?;
125    if parsed <= 0 {
126        return Err(format!(
127            "{name} must be > 0; got {parsed} (zero/negative not allowed)"
128        ));
129    }
130    let as_u64 = u64::try_from(parsed)
131        .map_err(|_| format!("{name} exceeds u64 range; got {parsed}"))?;
132    Ok(as_u64)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::collections::HashMap;
139
140    fn lookup<'a>(
141        map: &'a HashMap<&'static str, &'static str>,
142    ) -> impl Fn(&str) -> Option<String> + 'a {
143        move |k| map.get(k).map(|s| s.to_string())
144    }
145
146    #[test]
147    fn none_present_yields_none() {
148        let map: HashMap<&'static str, &'static str> = HashMap::new();
149        let got = from_env(lookup(&map)).unwrap();
150        assert!(got.is_none());
151    }
152
153    #[test]
154    fn all_required_present_yields_config_with_defaults() {
155        let map: HashMap<&'static str, &'static str> = [
156            ("REDDB_BACKUP_S3_ENDPOINT", "https://s3.example.com"),
157            ("REDDB_BACKUP_S3_BUCKET", "buck"),
158            ("REDDB_BACKUP_S3_PREFIX", "clusters/dev/"),
159            ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
160            ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
161        ]
162        .into_iter()
163        .collect();
164        let cfg = from_env(lookup(&map)).unwrap().expect("Some");
165        assert_eq!(cfg.endpoint, "https://s3.example.com");
166        assert_eq!(cfg.bucket, "buck");
167        assert_eq!(cfg.prefix, "clusters/dev/");
168        assert_eq!(cfg.access_key_id, "AK");
169        assert_eq!(cfg.secret_access_key, "SK");
170        assert_eq!(cfg.region, DEFAULT_REGION);
171        assert_eq!(cfg.checkpoint_interval_secs, DEFAULT_CHECKPOINT_SECS);
172        assert_eq!(cfg.wal_flush_interval_secs, DEFAULT_WAL_FLUSH_SECS);
173    }
174
175    #[test]
176    fn all_required_present_with_explicit_overrides() {
177        let map: HashMap<&'static str, &'static str> = [
178            ("REDDB_BACKUP_S3_ENDPOINT", "https://s3.example.com"),
179            ("REDDB_BACKUP_S3_BUCKET", "b"),
180            ("REDDB_BACKUP_S3_PREFIX", "p/"),
181            ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
182            ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
183            ("REDDB_BACKUP_S3_REGION", "us-east-1"),
184            ("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS", "60"),
185            ("REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS", "5"),
186        ]
187        .into_iter()
188        .collect();
189        let cfg = from_env(lookup(&map)).unwrap().expect("Some");
190        assert_eq!(cfg.region, "us-east-1");
191        assert_eq!(cfg.checkpoint_interval_secs, 60);
192        assert_eq!(cfg.wal_flush_interval_secs, 5);
193    }
194
195    #[test]
196    fn partial_config_names_missing_var() {
197        let map: HashMap<&'static str, &'static str> = [
198            ("REDDB_BACKUP_S3_ENDPOINT", "https://s3.example.com"),
199            ("REDDB_BACKUP_S3_BUCKET", "b"),
200        ]
201        .into_iter()
202        .collect();
203        let err = from_env(lookup(&map)).unwrap_err();
204        assert!(err.contains("REDDB_BACKUP_S3_PREFIX"), "{err}");
205        assert!(err.contains("REDDB_BACKUP_S3_ACCESS_KEY_ID"), "{err}");
206        assert!(err.contains("REDDB_BACKUP_S3_SECRET_ACCESS_KEY"), "{err}");
207    }
208
209    #[test]
210    fn whitespace_only_required_treated_as_missing() {
211        let map: HashMap<&'static str, &'static str> = [
212            ("REDDB_BACKUP_S3_ENDPOINT", "   "),
213            ("REDDB_BACKUP_S3_BUCKET", "b"),
214            ("REDDB_BACKUP_S3_PREFIX", "p/"),
215            ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
216            ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
217        ]
218        .into_iter()
219        .collect();
220        let err = from_env(lookup(&map)).unwrap_err();
221        assert!(err.contains("REDDB_BACKUP_S3_ENDPOINT"), "{err}");
222    }
223
224    #[test]
225    fn non_numeric_interval_is_error() {
226        let map: HashMap<&'static str, &'static str> = [
227            ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
228            ("REDDB_BACKUP_S3_BUCKET", "b"),
229            ("REDDB_BACKUP_S3_PREFIX", "p/"),
230            ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
231            ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
232            ("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS", "abc"),
233        ]
234        .into_iter()
235        .collect();
236        let err = from_env(lookup(&map)).unwrap_err();
237        assert!(err.contains("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS"), "{err}");
238        assert!(err.contains("positive integer"), "{err}");
239    }
240
241    #[test]
242    fn zero_interval_is_error() {
243        let map: HashMap<&'static str, &'static str> = [
244            ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
245            ("REDDB_BACKUP_S3_BUCKET", "b"),
246            ("REDDB_BACKUP_S3_PREFIX", "p/"),
247            ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
248            ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
249            ("REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS", "0"),
250        ]
251        .into_iter()
252        .collect();
253        let err = from_env(lookup(&map)).unwrap_err();
254        assert!(err.contains("REDDB_BACKUP_WAL_FLUSH_INTERVAL_SECS"), "{err}");
255        assert!(err.contains("> 0"), "{err}");
256    }
257
258    #[test]
259    fn negative_interval_is_error() {
260        let map: HashMap<&'static str, &'static str> = [
261            ("REDDB_BACKUP_S3_ENDPOINT", "https://x"),
262            ("REDDB_BACKUP_S3_BUCKET", "b"),
263            ("REDDB_BACKUP_S3_PREFIX", "p/"),
264            ("REDDB_BACKUP_S3_ACCESS_KEY_ID", "AK"),
265            ("REDDB_BACKUP_S3_SECRET_ACCESS_KEY", "SK"),
266            ("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS", "-10"),
267        ]
268        .into_iter()
269        .collect();
270        let err = from_env(lookup(&map)).unwrap_err();
271        assert!(err.contains("REDDB_BACKUP_CHECKPOINT_INTERVAL_SECS"), "{err}");
272    }
273}