Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,12 @@ ENABLE_DA_TRACKING=false
# FAUCET_PRIVATE_KEY=0x...
# FAUCET_AMOUNT=0.01
# FAUCET_COOLDOWN_MINUTES=30

# Optional snapshot feature (daily pg_dump backups)
# SNAPSHOT_ENABLED=false
# SNAPSHOT_TIME=03:00 # UTC time (HH:MM) to run daily pg_dump
# SNAPSHOT_RETENTION=7 # Number of snapshot files to keep
# SNAPSHOT_DIR=/snapshots # Container path for snapshots
# SNAPSHOT_HOST_DIR=./snapshots # Host path mounted to SNAPSHOT_DIR
# UID=1000 # Optional: host UID for writable snapshot bind mounts
# GID=1000 # Optional: host GID for writable snapshot bind mounts
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Thumbs.db
*.log
logs/

# Local snapshot test artifacts
snapshots/

# Node (frontend)
frontend/node_modules/
frontend/dist/
Expand Down
6 changes: 4 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ RUN cargo build --release
# Server image
FROM alpine:3.21 AS server

RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates postgresql16-client

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

RUN addgroup -S atlas && adduser -S atlas -G atlas
RUN addgroup -S -g 1000 atlas && adduser -S -u 1000 atlas -G atlas \
&& mkdir -p /snapshots \
&& chown atlas:atlas /snapshots
USER atlas

EXPOSE 3000
Expand Down
1 change: 1 addition & 0 deletions backend/crates/atlas-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ tokio = { workspace = true }
tower = { workspace = true, features = ["util"] }
serde_json = { workspace = true }
sqlx = { workspace = true }
tempfile = "3"
30 changes: 29 additions & 1 deletion backend/crates/atlas-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ pub struct FaucetArgs {
value_name = "MINS",
help = "Cooldown period in minutes between faucet requests per address"
)]
pub cooldown_minutes: Option<u64>,
pub cooldown_minutes: Option<String>,
// FAUCET_PRIVATE_KEY is intentionally env-only (security: never pass secrets as CLI flags)
}

Expand Down Expand Up @@ -371,6 +371,34 @@ pub struct LogArgs {
pub level: String,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn empty_faucet_cooldown_env_is_ignored_when_faucet_disabled() {
unsafe {
std::env::set_var("DATABASE_URL", "postgres://atlas:atlas@localhost/atlas");
std::env::set_var("RPC_URL", "http://localhost:8545");
std::env::set_var("FAUCET_ENABLED", "false");
std::env::set_var("FAUCET_COOLDOWN_MINUTES", "");
}

let cli = Cli::try_parse_from(["atlas-server", "run"]).expect("parse cli");
match cli.command {
Command::Run(args) => assert_eq!(args.faucet.cooldown_minutes, Some(String::new())),
_ => panic!("expected run command"),
}

unsafe {
std::env::remove_var("DATABASE_URL");
std::env::remove_var("RPC_URL");
std::env::remove_var("FAUCET_ENABLED");
std::env::remove_var("FAUCET_COOLDOWN_MINUTES");
}
}
}

// ── db subcommand ─────────────────────────────────────────────────────────────

#[derive(Args)]
Expand Down
197 changes: 196 additions & 1 deletion backend/crates/atlas-server/src/config.rs
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)]
Expand Down Expand Up @@ -358,11 +359,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");
}
Expand All @@ -379,6 +383,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");
}

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())
}
Expand Down Expand Up @@ -578,6 +648,26 @@ mod tests_from_run_args {
assert!(config.accent_color.is_none());
assert_eq!(config.success_color.as_deref(), Some("#00ff00"));
}

#[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");
}
}
}

#[cfg(test)]
Expand Down Expand Up @@ -835,6 +925,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();
Expand Down
Loading
Loading