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 atlas && adduser -S 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 @@ -45,3 +45,4 @@ tokio = { workspace = true }
tower = { workspace = true, features = ["util"] }
serde_json = { workspace = true }
sqlx = { workspace = true }
tempfile = "3"
172 changes: 172 additions & 0 deletions 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};

const DEFAULT_DA_WORKER_CONCURRENCY: u32 = 50;
Expand Down Expand Up @@ -251,6 +252,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 @@ -553,6 +620,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
14 changes: 14 additions & 0 deletions backend/crates/atlas-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod config;
mod faucet;
mod head;
mod indexer;
mod snapshot;

/// Retry delays for exponential backoff (in seconds)
const RETRY_DELAYS: &[u64] = &[5, 10, 20, 30, 60];
Expand Down Expand Up @@ -60,6 +61,7 @@ async fn main() -> Result<()> {
dotenvy::dotenv().ok();
let config = config::Config::from_env()?;
let faucet_config = config::FaucetConfig::from_env()?;
let snapshot_config = config::SnapshotConfig::from_env(&config.database_url)?;

let faucet = if faucet_config.enabled {
tracing::info!("Faucet enabled");
Expand Down Expand Up @@ -181,6 +183,18 @@ async fn main() -> Result<()> {
}
});

// Spawn snapshot scheduler if enabled
if snapshot_config.enabled {
tracing::info!("Snapshot scheduler enabled");
tokio::spawn(async move {
if let Err(e) =
run_with_retry(|| snapshot::run_snapshot_loop(snapshot_config.clone())).await
{
tracing::error!("Snapshot scheduler terminated with error: {}", e);
}
});
}

// Build and serve API
let app = api::build_router(state, config.cors_origin.clone());
let addr = format!("{}:{}", config.api_host, config.api_port);
Expand Down
Loading
Loading