-
Notifications
You must be signed in to change notification settings - Fork 0
feat: scheduled database snapshots #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f171e2a
fa1565f
8417d85
487d20e
1aec619
93dc63c
302692d
a076445
170732a
7868a81
e027a4d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| use alloy::primitives::U256; | ||
| use alloy::signers::local::PrivateKeySigner; | ||
| use anyhow::{bail, Context, Result}; | ||
| use chrono::NaiveTime; | ||
| use std::{env, str::FromStr}; | ||
|
|
||
| #[cfg(test)] | ||
|
|
@@ -364,11 +365,14 @@ impl FaucetConfig { | |
| bail!("faucet amount must be greater than 0"); | ||
| } | ||
|
|
||
| let cooldown_minutes = args.cooldown_minutes.ok_or_else(|| { | ||
| let cooldown_minutes = parse_optional_env(args.cooldown_minutes.clone()).ok_or_else(|| { | ||
| anyhow::anyhow!( | ||
| "--atlas.faucet.cooldown-minutes (or FAUCET_COOLDOWN_MINUTES) must be set when faucet is enabled" | ||
| ) | ||
| })?; | ||
| let cooldown_minutes = cooldown_minutes | ||
| .parse::<u64>() | ||
| .context("Invalid --atlas.faucet.cooldown-minutes / FAUCET_COOLDOWN_MINUTES")?; | ||
| if cooldown_minutes == 0 { | ||
| bail!("faucet cooldown must be greater than 0"); | ||
| } | ||
|
|
@@ -385,6 +389,72 @@ impl FaucetConfig { | |
| } | ||
| } | ||
|
|
||
| #[derive(Clone)] | ||
| pub struct SnapshotConfig { | ||
| pub enabled: bool, | ||
| pub time: NaiveTime, | ||
| pub retention: u32, | ||
| pub dir: String, | ||
| pub database_url: String, | ||
| } | ||
|
|
||
| impl std::fmt::Debug for SnapshotConfig { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| f.debug_struct("SnapshotConfig") | ||
| .field("enabled", &self.enabled) | ||
| .field("time", &self.time) | ||
| .field("retention", &self.retention) | ||
| .field("dir", &self.dir) | ||
| .field("database_url", &"[redacted]") | ||
| .finish() | ||
| } | ||
| } | ||
|
|
||
| impl SnapshotConfig { | ||
| pub fn from_env(database_url: &str) -> Result<Self> { | ||
| let enabled = env::var("SNAPSHOT_ENABLED") | ||
| .unwrap_or_else(|_| "false".to_string()) | ||
| .parse::<bool>() | ||
| .context("Invalid SNAPSHOT_ENABLED")?; | ||
|
|
||
| if !enabled { | ||
| return Ok(Self { | ||
| enabled, | ||
| time: NaiveTime::from_hms_opt(3, 0, 0).unwrap(), | ||
| retention: 7, | ||
| dir: "/snapshots".to_string(), | ||
| database_url: database_url.to_string(), | ||
| }); | ||
| } | ||
|
|
||
| let time_str = env::var("SNAPSHOT_TIME").unwrap_or_else(|_| "03:00".to_string()); | ||
| let time = NaiveTime::parse_from_str(&time_str, "%H:%M") | ||
| .context("Invalid SNAPSHOT_TIME (expected HH:MM)")?; | ||
|
|
||
| let retention = env::var("SNAPSHOT_RETENTION") | ||
| .unwrap_or_else(|_| "7".to_string()) | ||
| .parse::<u32>() | ||
| .context("Invalid SNAPSHOT_RETENTION")?; | ||
| if retention == 0 { | ||
| bail!("SNAPSHOT_RETENTION must be greater than 0"); | ||
| } | ||
|
|
||
| let dir = env::var("SNAPSHOT_DIR").unwrap_or_else(|_| "/snapshots".to_string()); | ||
| let dir = dir.trim().to_string(); | ||
| if dir.is_empty() { | ||
| bail!("SNAPSHOT_DIR must not be empty"); | ||
| } | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Ok(Self { | ||
| enabled, | ||
| time, | ||
| retention, | ||
| dir, | ||
| database_url: database_url.to_string(), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| fn parse_optional_env(val: Option<String>) -> Option<String> { | ||
| val.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) | ||
| } | ||
|
|
@@ -605,6 +675,26 @@ mod tests_from_run_args { | |
| Some("/branding/dark.svg") | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn faucet_blank_cooldown_is_treated_as_missing() { | ||
| let mut args = minimal_run_args(); | ||
| args.faucet.enabled = true; | ||
| args.faucet.amount = Some("0.1".to_string()); | ||
| args.faucet.cooldown_minutes = Some(" ".to_string()); | ||
|
|
||
| unsafe { | ||
| env::set_var( | ||
| "FAUCET_PRIVATE_KEY", | ||
| "0x59c6995e998f97a5a0044966f0945382dbd8c5df5440d8d6d0d0f66f6d7d6a0d", | ||
| ); | ||
| } | ||
| let err = FaucetConfig::from_faucet_args(&args.faucet).unwrap_err(); | ||
| assert!(err.to_string().contains("cooldown-minutes")); | ||
| unsafe { | ||
| env::remove_var("FAUCET_PRIVATE_KEY"); | ||
| } | ||
| } | ||
|
Comment on lines
+679
to
+697
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, locate the file and check its size
wc -l backend/crates/atlas-server/src/config.rsRepository: evstack/atlas Length of output: 103 🏁 Script executed: # Search for ENV_LOCK in the file
rg "ENV_LOCK" backend/crates/atlas-server/src/config.rs -B 2 -A 2Repository: evstack/atlas Length of output: 3562 🏁 Script executed: # Look at the tests module structure and how env is managed
rg "mod tests" backend/crates/atlas-server/src/config.rs -A 50 | head -80Repository: evstack/atlas Length of output: 2620 🏁 Script executed: # Check the specific lines 679-697 and surrounding context to see if ENV_LOCK is used
sed -n '670,710p' backend/crates/atlas-server/src/config.rsRepository: evstack/atlas Length of output: 1255 Protect this env-driven test with the shared env lock. This test writes 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| #[cfg(test)] | ||
|
|
@@ -895,6 +985,111 @@ mod tests { | |
| ); | ||
| } | ||
|
|
||
| fn clear_snapshot_env() { | ||
| env::remove_var("SNAPSHOT_ENABLED"); | ||
| env::remove_var("SNAPSHOT_TIME"); | ||
| env::remove_var("SNAPSHOT_RETENTION"); | ||
| env::remove_var("SNAPSHOT_DIR"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn snapshot_config_defaults_disabled() { | ||
| let _lock = ENV_LOCK.lock().unwrap(); | ||
| clear_snapshot_env(); | ||
|
|
||
| let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap(); | ||
| assert!(!config.enabled); | ||
| assert_eq!(config.time, NaiveTime::from_hms_opt(3, 0, 0).unwrap()); | ||
| assert_eq!(config.retention, 7); | ||
| assert_eq!(config.dir, "/snapshots"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn snapshot_config_parses_valid_time() { | ||
| let _lock = ENV_LOCK.lock().unwrap(); | ||
| clear_snapshot_env(); | ||
| env::set_var("SNAPSHOT_ENABLED", "true"); | ||
|
|
||
| for (input, hour, minute) in [("00:00", 0, 0), ("03:00", 3, 0), ("23:59", 23, 59)] { | ||
| env::set_var("SNAPSHOT_TIME", input); | ||
| let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap(); | ||
| assert_eq!( | ||
| config.time, | ||
| NaiveTime::from_hms_opt(hour, minute, 0).unwrap(), | ||
| "failed for input {input}" | ||
| ); | ||
| } | ||
| clear_snapshot_env(); | ||
| } | ||
|
|
||
| #[test] | ||
| fn snapshot_config_rejects_invalid_time() { | ||
| let _lock = ENV_LOCK.lock().unwrap(); | ||
| clear_snapshot_env(); | ||
| env::set_var("SNAPSHOT_ENABLED", "true"); | ||
|
|
||
| for val in ["25:00", "abc", "12:60"] { | ||
| env::set_var("SNAPSHOT_TIME", val); | ||
| let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err(); | ||
| assert!( | ||
| err.to_string().contains("Invalid SNAPSHOT_TIME"), | ||
| "expected error for {val}, got: {err}" | ||
| ); | ||
| } | ||
| clear_snapshot_env(); | ||
| } | ||
|
|
||
| #[test] | ||
| fn snapshot_config_rejects_zero_retention() { | ||
| let _lock = ENV_LOCK.lock().unwrap(); | ||
| clear_snapshot_env(); | ||
| env::set_var("SNAPSHOT_ENABLED", "true"); | ||
| env::set_var("SNAPSHOT_RETENTION", "0"); | ||
|
|
||
| let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err(); | ||
| assert!(err.to_string().contains("must be greater than 0")); | ||
| clear_snapshot_env(); | ||
| } | ||
|
|
||
| #[test] | ||
| fn snapshot_config_custom_dir() { | ||
| let _lock = ENV_LOCK.lock().unwrap(); | ||
| clear_snapshot_env(); | ||
| env::set_var("SNAPSHOT_ENABLED", "true"); | ||
| env::set_var("SNAPSHOT_DIR", "/data/backups"); | ||
|
|
||
| let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap(); | ||
| assert_eq!(config.dir, "/data/backups"); | ||
| clear_snapshot_env(); | ||
| } | ||
|
|
||
| #[test] | ||
| fn snapshot_config_rejects_empty_dir() { | ||
| let _lock = ENV_LOCK.lock().unwrap(); | ||
| clear_snapshot_env(); | ||
| env::set_var("SNAPSHOT_ENABLED", "true"); | ||
| env::set_var("SNAPSHOT_DIR", " "); | ||
|
|
||
| let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err(); | ||
| assert!(err.to_string().contains("SNAPSHOT_DIR must not be empty")); | ||
| clear_snapshot_env(); | ||
| } | ||
|
|
||
| #[test] | ||
| fn snapshot_config_debug_redacts_database_url() { | ||
| let config = SnapshotConfig { | ||
| enabled: true, | ||
| time: NaiveTime::from_hms_opt(3, 0, 0).unwrap(), | ||
| retention: 7, | ||
| dir: "/snapshots".to_string(), | ||
| database_url: "postgres://atlas:secret@db/atlas".to_string(), | ||
| }; | ||
|
|
||
| let debug = format!("{config:?}"); | ||
| assert!(debug.contains("[redacted]")); | ||
| assert!(!debug.contains("secret")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn faucet_config_rejects_bad_inputs() { | ||
| let _lock = ENV_LOCK.lock().unwrap(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: evstack/atlas
Length of output: 1114
🏁 Script executed:
Repository: evstack/atlas
Length of output: 689
🏁 Script executed:
Repository: evstack/atlas
Length of output: 83
🏁 Script executed:
Repository: evstack/atlas
Length of output: 226
🏁 Script executed:
Repository: evstack/atlas
Length of output: 83
🏁 Script executed:
Repository: evstack/atlas
Length of output: 1268
🏁 Script executed:
Repository: evstack/atlas
Length of output: 945
🏁 Script executed:
Repository: evstack/atlas
Length of output: 4355
🏁 Script executed:
Repository: evstack/atlas
Length of output: 3082
🏁 Script executed:
Repository: evstack/atlas
Length of output: 6343
🏁 Script executed:
Repository: evstack/atlas
Length of output: 2747
Guard env mutations with the established ENV_LOCK pattern.
This test mutates process-global env vars without synchronization. The crate already defines
static ENV_LOCK: Mutex<()>in config.rs and uses it consistently across all env-mutating tests (e.g.,let _lock = ENV_LOCK.lock().unwrap();). Apply the same pattern here to prevent races with parallel test execution.🤖 Prompt for AI Agents