pub mod s3;
pub mod scheduler;
pub mod tarball;
pub use s3::{BackupEntry, S3Client};
use std::path::Path;
use sqlx::SqlitePool;
use crate::config::BackupConfig;
use crate::timestamp;
#[derive(Debug, thiserror::Error)]
pub enum BackupError {
#[error("S3 error: {0}")]
S3(#[from] s3::S3Error),
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Other(String),
}
pub async fn create_backup(
pool: &SqlitePool,
config: &BackupConfig,
config_path: &Path,
) -> Result<String, BackupError> {
let client = S3Client::new(config)?;
let tmp = tempfile::TempDir::new()?;
let snapshot_path = tmp.path().join("sendword.db");
let snapshot_str = snapshot_path.to_string_lossy().into_owned();
let escaped = snapshot_str.replace('\'', "''");
{
let vacuum_sql = format!("VACUUM INTO '{escaped}'");
sqlx::query(sqlx::AssertSqlSafe(vacuum_sql))
.execute(pool)
.await?;
}
let tarball_name = format!("backup-{}.tar.gz", timestamp::now_utc_filename());
let tarball_path = tmp.path().join(&tarball_name);
tarball::create_tarball(config_path, &snapshot_path, &tarball_path)?;
let tarball_bytes = std::fs::read(&tarball_path)?;
client.put(&tarball_name, &tarball_bytes).await?;
tracing::info!(key = %tarball_name, "backup created");
Ok(tarball_name)
}
pub async fn restore_backup(
config: &BackupConfig,
key: &str,
output_dir: &Path,
) -> Result<(), BackupError> {
let client = S3Client::new(config)?;
let data = client.get(key).await?;
let tmp = tempfile::TempDir::new()?;
let tarball_path = tmp.path().join("download.tar.gz");
std::fs::write(&tarball_path, &data)?;
tarball::extract_tarball(&tarball_path, output_dir)?;
tracing::info!(key = %key, dir = %output_dir.display(), "backup extracted");
Ok(())
}
pub async fn apply_retention(config: &BackupConfig) -> Result<(), BackupError> {
let retention = &config.retention;
if retention.max_count.is_none() && retention.max_age.is_none() {
return Ok(());
}
let client = S3Client::new(config)?;
let mut entries = client.list().await?;
entries.sort_by(|a, b| a.last_modified.cmp(&b.last_modified));
let mut to_delete: Vec<String> = Vec::new();
if let Some(max_count) = retention.max_count {
let max_count = max_count as usize;
if entries.len() > max_count {
let excess = entries.len() - max_count;
for entry in entries.iter().take(excess) {
to_delete.push(entry.key.clone());
}
}
}
if let Some(max_age) = retention.max_age {
let cutoff = chrono::Utc::now() - chrono::Duration::from_std(max_age).unwrap_or_default();
let cutoff_str = cutoff.to_rfc3339();
for entry in &entries {
if entry.last_modified < cutoff_str && !to_delete.contains(&entry.key) {
to_delete.push(entry.key.clone());
}
}
}
for key in &to_delete {
client.delete(key).await?;
tracing::info!(key = %key, "deleted old backup (retention policy)");
}
Ok(())
}
pub async fn list_backups(config: &BackupConfig) -> Result<Vec<BackupEntry>, BackupError> {
let client = S3Client::new(config)?;
let mut entries = client.list().await?;
entries.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
Ok(entries)
}