Skip to content

Commit 8653e81

Browse files
authored
feat: scheduled database snapshots (#37)
* feat: scheduled database snapshots via pg_dump (#23) Add opt-in daily pg_dump snapshots to protect against data loss. Runs as a background tokio task with configurable time, retention, and output directory. Includes integration test for dump/restore round-trip. * fix snapshot retries and CI formatting * harden snapshot config handling * ignore local snapshot artifacts * fix: pin atlas user to uid/gid 1000 to match compose default * refactor: share postgres_connection_config between snapshot and db CLI commands * Fix snapshot dump portability * Fix CLI env parsing and restore flow
1 parent f7c8262 commit 8653e81

12 files changed

Lines changed: 710 additions & 28 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,12 @@ ENABLE_DA_TRACKING=false
5757
# FAUCET_PRIVATE_KEY=0x...
5858
# FAUCET_AMOUNT=0.01
5959
# FAUCET_COOLDOWN_MINUTES=30
60+
61+
# Optional snapshot feature (daily pg_dump backups)
62+
# SNAPSHOT_ENABLED=false
63+
# SNAPSHOT_TIME=03:00 # UTC time (HH:MM) to run daily pg_dump
64+
# SNAPSHOT_RETENTION=7 # Number of snapshot files to keep
65+
# SNAPSHOT_DIR=/snapshots # Container path for snapshots
66+
# SNAPSHOT_HOST_DIR=./snapshots # Host path mounted to SNAPSHOT_DIR
67+
# UID=1000 # Optional: host UID for writable snapshot bind mounts
68+
# GID=1000 # Optional: host GID for writable snapshot bind mounts

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ Thumbs.db
2424
*.log
2525
logs/
2626

27+
# Local snapshot test artifacts
28+
snapshots/
29+
2730
# Node (frontend)
2831
frontend/node_modules/
2932
frontend/dist/

backend/Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ RUN cargo build --release
1111
# Server image
1212
FROM alpine:3.21 AS server
1313

14-
RUN apk add --no-cache ca-certificates
14+
RUN apk add --no-cache ca-certificates postgresql16-client
1515

1616
COPY --from=builder /app/target/release/atlas-server /usr/local/bin/
1717

18-
RUN addgroup -S atlas && adduser -S atlas -G atlas
18+
RUN addgroup -S -g 1000 atlas && adduser -S -u 1000 atlas -G atlas \
19+
&& mkdir -p /snapshots \
20+
&& chown atlas:atlas /snapshots
1921
USER atlas
2022

2123
EXPOSE 3000

backend/crates/atlas-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ tokio = { workspace = true }
4848
tower = { workspace = true, features = ["util"] }
4949
serde_json = { workspace = true }
5050
sqlx = { workspace = true }
51+
tempfile = "3"

backend/crates/atlas-server/src/cli.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ pub struct FaucetArgs {
326326
value_name = "MINS",
327327
help = "Cooldown period in minutes between faucet requests per address"
328328
)]
329-
pub cooldown_minutes: Option<u64>,
329+
pub cooldown_minutes: Option<String>,
330330
// FAUCET_PRIVATE_KEY is intentionally env-only (security: never pass secrets as CLI flags)
331331
}
332332

@@ -396,6 +396,34 @@ pub struct LogArgs {
396396
pub format: String,
397397
}
398398

399+
#[cfg(test)]
400+
mod tests {
401+
use super::*;
402+
403+
#[test]
404+
fn empty_faucet_cooldown_env_is_ignored_when_faucet_disabled() {
405+
unsafe {
406+
std::env::set_var("DATABASE_URL", "postgres://atlas:atlas@localhost/atlas");
407+
std::env::set_var("RPC_URL", "http://localhost:8545");
408+
std::env::set_var("FAUCET_ENABLED", "false");
409+
std::env::set_var("FAUCET_COOLDOWN_MINUTES", "");
410+
}
411+
412+
let cli = Cli::try_parse_from(["atlas-server", "run"]).expect("parse cli");
413+
match cli.command {
414+
Command::Run(args) => assert_eq!(args.faucet.cooldown_minutes, Some(String::new())),
415+
_ => panic!("expected run command"),
416+
}
417+
418+
unsafe {
419+
std::env::remove_var("DATABASE_URL");
420+
std::env::remove_var("RPC_URL");
421+
std::env::remove_var("FAUCET_ENABLED");
422+
std::env::remove_var("FAUCET_COOLDOWN_MINUTES");
423+
}
424+
}
425+
}
426+
399427
// ── db subcommand ─────────────────────────────────────────────────────────────
400428

401429
#[derive(Args)]

backend/crates/atlas-server/src/config.rs

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use alloy::primitives::U256;
22
use alloy::signers::local::PrivateKeySigner;
33
use anyhow::{bail, Context, Result};
4+
use chrono::NaiveTime;
45
use std::{env, str::FromStr};
56

67
#[cfg(test)]
@@ -364,11 +365,14 @@ impl FaucetConfig {
364365
bail!("faucet amount must be greater than 0");
365366
}
366367

367-
let cooldown_minutes = args.cooldown_minutes.ok_or_else(|| {
368+
let cooldown_minutes = parse_optional_env(args.cooldown_minutes.clone()).ok_or_else(|| {
368369
anyhow::anyhow!(
369370
"--atlas.faucet.cooldown-minutes (or FAUCET_COOLDOWN_MINUTES) must be set when faucet is enabled"
370371
)
371372
})?;
373+
let cooldown_minutes = cooldown_minutes
374+
.parse::<u64>()
375+
.context("Invalid --atlas.faucet.cooldown-minutes / FAUCET_COOLDOWN_MINUTES")?;
372376
if cooldown_minutes == 0 {
373377
bail!("faucet cooldown must be greater than 0");
374378
}
@@ -385,6 +389,72 @@ impl FaucetConfig {
385389
}
386390
}
387391

392+
#[derive(Clone)]
393+
pub struct SnapshotConfig {
394+
pub enabled: bool,
395+
pub time: NaiveTime,
396+
pub retention: u32,
397+
pub dir: String,
398+
pub database_url: String,
399+
}
400+
401+
impl std::fmt::Debug for SnapshotConfig {
402+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403+
f.debug_struct("SnapshotConfig")
404+
.field("enabled", &self.enabled)
405+
.field("time", &self.time)
406+
.field("retention", &self.retention)
407+
.field("dir", &self.dir)
408+
.field("database_url", &"[redacted]")
409+
.finish()
410+
}
411+
}
412+
413+
impl SnapshotConfig {
414+
pub fn from_env(database_url: &str) -> Result<Self> {
415+
let enabled = env::var("SNAPSHOT_ENABLED")
416+
.unwrap_or_else(|_| "false".to_string())
417+
.parse::<bool>()
418+
.context("Invalid SNAPSHOT_ENABLED")?;
419+
420+
if !enabled {
421+
return Ok(Self {
422+
enabled,
423+
time: NaiveTime::from_hms_opt(3, 0, 0).unwrap(),
424+
retention: 7,
425+
dir: "/snapshots".to_string(),
426+
database_url: database_url.to_string(),
427+
});
428+
}
429+
430+
let time_str = env::var("SNAPSHOT_TIME").unwrap_or_else(|_| "03:00".to_string());
431+
let time = NaiveTime::parse_from_str(&time_str, "%H:%M")
432+
.context("Invalid SNAPSHOT_TIME (expected HH:MM)")?;
433+
434+
let retention = env::var("SNAPSHOT_RETENTION")
435+
.unwrap_or_else(|_| "7".to_string())
436+
.parse::<u32>()
437+
.context("Invalid SNAPSHOT_RETENTION")?;
438+
if retention == 0 {
439+
bail!("SNAPSHOT_RETENTION must be greater than 0");
440+
}
441+
442+
let dir = env::var("SNAPSHOT_DIR").unwrap_or_else(|_| "/snapshots".to_string());
443+
let dir = dir.trim().to_string();
444+
if dir.is_empty() {
445+
bail!("SNAPSHOT_DIR must not be empty");
446+
}
447+
448+
Ok(Self {
449+
enabled,
450+
time,
451+
retention,
452+
dir,
453+
database_url: database_url.to_string(),
454+
})
455+
}
456+
}
457+
388458
fn parse_optional_env(val: Option<String>) -> Option<String> {
389459
val.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
390460
}
@@ -605,6 +675,26 @@ mod tests_from_run_args {
605675
Some("/branding/dark.svg")
606676
);
607677
}
678+
679+
#[test]
680+
fn faucet_blank_cooldown_is_treated_as_missing() {
681+
let mut args = minimal_run_args();
682+
args.faucet.enabled = true;
683+
args.faucet.amount = Some("0.1".to_string());
684+
args.faucet.cooldown_minutes = Some(" ".to_string());
685+
686+
unsafe {
687+
env::set_var(
688+
"FAUCET_PRIVATE_KEY",
689+
"0x59c6995e998f97a5a0044966f0945382dbd8c5df5440d8d6d0d0f66f6d7d6a0d",
690+
);
691+
}
692+
let err = FaucetConfig::from_faucet_args(&args.faucet).unwrap_err();
693+
assert!(err.to_string().contains("cooldown-minutes"));
694+
unsafe {
695+
env::remove_var("FAUCET_PRIVATE_KEY");
696+
}
697+
}
608698
}
609699

610700
#[cfg(test)]
@@ -895,6 +985,111 @@ mod tests {
895985
);
896986
}
897987

988+
fn clear_snapshot_env() {
989+
env::remove_var("SNAPSHOT_ENABLED");
990+
env::remove_var("SNAPSHOT_TIME");
991+
env::remove_var("SNAPSHOT_RETENTION");
992+
env::remove_var("SNAPSHOT_DIR");
993+
}
994+
995+
#[test]
996+
fn snapshot_config_defaults_disabled() {
997+
let _lock = ENV_LOCK.lock().unwrap();
998+
clear_snapshot_env();
999+
1000+
let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap();
1001+
assert!(!config.enabled);
1002+
assert_eq!(config.time, NaiveTime::from_hms_opt(3, 0, 0).unwrap());
1003+
assert_eq!(config.retention, 7);
1004+
assert_eq!(config.dir, "/snapshots");
1005+
}
1006+
1007+
#[test]
1008+
fn snapshot_config_parses_valid_time() {
1009+
let _lock = ENV_LOCK.lock().unwrap();
1010+
clear_snapshot_env();
1011+
env::set_var("SNAPSHOT_ENABLED", "true");
1012+
1013+
for (input, hour, minute) in [("00:00", 0, 0), ("03:00", 3, 0), ("23:59", 23, 59)] {
1014+
env::set_var("SNAPSHOT_TIME", input);
1015+
let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap();
1016+
assert_eq!(
1017+
config.time,
1018+
NaiveTime::from_hms_opt(hour, minute, 0).unwrap(),
1019+
"failed for input {input}"
1020+
);
1021+
}
1022+
clear_snapshot_env();
1023+
}
1024+
1025+
#[test]
1026+
fn snapshot_config_rejects_invalid_time() {
1027+
let _lock = ENV_LOCK.lock().unwrap();
1028+
clear_snapshot_env();
1029+
env::set_var("SNAPSHOT_ENABLED", "true");
1030+
1031+
for val in ["25:00", "abc", "12:60"] {
1032+
env::set_var("SNAPSHOT_TIME", val);
1033+
let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err();
1034+
assert!(
1035+
err.to_string().contains("Invalid SNAPSHOT_TIME"),
1036+
"expected error for {val}, got: {err}"
1037+
);
1038+
}
1039+
clear_snapshot_env();
1040+
}
1041+
1042+
#[test]
1043+
fn snapshot_config_rejects_zero_retention() {
1044+
let _lock = ENV_LOCK.lock().unwrap();
1045+
clear_snapshot_env();
1046+
env::set_var("SNAPSHOT_ENABLED", "true");
1047+
env::set_var("SNAPSHOT_RETENTION", "0");
1048+
1049+
let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err();
1050+
assert!(err.to_string().contains("must be greater than 0"));
1051+
clear_snapshot_env();
1052+
}
1053+
1054+
#[test]
1055+
fn snapshot_config_custom_dir() {
1056+
let _lock = ENV_LOCK.lock().unwrap();
1057+
clear_snapshot_env();
1058+
env::set_var("SNAPSHOT_ENABLED", "true");
1059+
env::set_var("SNAPSHOT_DIR", "/data/backups");
1060+
1061+
let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap();
1062+
assert_eq!(config.dir, "/data/backups");
1063+
clear_snapshot_env();
1064+
}
1065+
1066+
#[test]
1067+
fn snapshot_config_rejects_empty_dir() {
1068+
let _lock = ENV_LOCK.lock().unwrap();
1069+
clear_snapshot_env();
1070+
env::set_var("SNAPSHOT_ENABLED", "true");
1071+
env::set_var("SNAPSHOT_DIR", " ");
1072+
1073+
let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err();
1074+
assert!(err.to_string().contains("SNAPSHOT_DIR must not be empty"));
1075+
clear_snapshot_env();
1076+
}
1077+
1078+
#[test]
1079+
fn snapshot_config_debug_redacts_database_url() {
1080+
let config = SnapshotConfig {
1081+
enabled: true,
1082+
time: NaiveTime::from_hms_opt(3, 0, 0).unwrap(),
1083+
retention: 7,
1084+
dir: "/snapshots".to_string(),
1085+
database_url: "postgres://atlas:secret@db/atlas".to_string(),
1086+
};
1087+
1088+
let debug = format!("{config:?}");
1089+
assert!(debug.contains("[redacted]"));
1090+
assert!(!debug.contains("secret"));
1091+
}
1092+
8981093
#[test]
8991094
fn faucet_config_rejects_bad_inputs() {
9001095
let _lock = ENV_LOCK.lock().unwrap();

0 commit comments

Comments
 (0)