1#[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
59pub 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}