Add a simple daily backup module

This commit is contained in:
Greg Burri 2025-03-29 23:59:48 +01:00
parent b8a8af3979
commit d22617538e
6 changed files with 133 additions and 24 deletions

View file

@ -3,8 +3,9 @@ use std::{
fs::{self, File}, fs::{self, File},
}; };
use chrono::NaiveTime;
use ron::{ use ron::{
de::from_reader, de::{from_reader, from_str},
ser::{PrettyConfig, to_string_pretty}, ser::{PrettyConfig, to_string_pretty},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -13,20 +14,48 @@ use crate::consts;
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
pub struct Config { pub struct Config {
#[serde(default = "port_default")]
pub port: u16, pub port: u16,
#[serde(default = "smtp_relay_address_default")]
pub smtp_relay_address: String, pub smtp_relay_address: String,
#[serde(default = "smtp_login_default")]
pub smtp_login: String, pub smtp_login: String,
#[serde(default = "smtp_password_default")]
pub smtp_password: String, pub smtp_password: String,
#[serde(default)]
pub backup_time: Option<NaiveTime>, // If not set, no backup will be done.
#[serde(default = "backup_directory_default")]
pub backup_directory: String,
}
fn port_default() -> u16 {
8082
}
fn smtp_relay_address_default() -> String {
"mail.something.com".to_string()
}
fn smtp_login_default() -> String {
"login".to_string()
}
fn smtp_password_default() -> String {
"password".to_string()
}
fn backup_directory_default() -> String {
"data".to_string()
} }
impl Config { impl Config {
pub fn default() -> Self { pub fn default() -> Self {
Config { from_str("()").unwrap()
port: 8082,
smtp_relay_address: "mail.something.com".to_string(),
smtp_login: "login".to_string(),
smtp_password: "password".to_string(),
}
} }
} }
@ -38,12 +67,14 @@ impl fmt::Debug for Config {
.field("smtp_relay_address", &self.smtp_relay_address) .field("smtp_relay_address", &self.smtp_relay_address)
.field("smtp_login", &self.smtp_login) .field("smtp_login", &self.smtp_login)
.field("smtp_password", &"*****") .field("smtp_password", &"*****")
.field("backup_time", &self.backup_time)
.field("backup_directory", &self.backup_directory)
.finish() .finish()
} }
} }
pub fn load() -> Config { pub fn load() -> Config {
match File::open(consts::FILE_CONF) { let config = match File::open(consts::FILE_CONF) {
Ok(file) => from_reader(file).unwrap_or_else(|error| { Ok(file) => from_reader(file).unwrap_or_else(|error| {
panic!( panic!(
"Failed to open configuration file {}: {}", "Failed to open configuration file {}: {}",
@ -51,17 +82,17 @@ pub fn load() -> Config {
error error
) )
}), }),
Err(_) => { Err(_) => Config::default(),
let default_config = Config::default(); };
let ron_string = to_string_pretty(&default_config, PrettyConfig::new()) // Rewrite the whole config, useful in the case
// when some fields are missing in the original or no config file at all.
// FIXME: It will remove any manually added comments.
let ron_string = to_string_pretty(&config, PrettyConfig::new())
.unwrap_or_else(|error| panic!("Failed to serialize ron configuration: {}", error)); .unwrap_or_else(|error| panic!("Failed to serialize ron configuration: {}", error));
fs::write(consts::FILE_CONF, ron_string).unwrap_or_else(|error| { fs::write(consts::FILE_CONF, ron_string)
panic!("Failed to write default configuration file: {}", error) .unwrap_or_else(|error| panic!("Failed to write default configuration file: {}", error));
});
default_config config
}
}
} }

View file

@ -0,0 +1,55 @@
use chrono::{NaiveTime, TimeDelta};
use tracing::{Level, event};
use super::db;
/// This function starts a backup process that runs at a specified time of day forever.
/// It creates a backup of the database at the specified directory.
/// The backup file is named with the date and time at the time of the backup.
pub fn start<P>(
directory: P,
db_connection: db::Connection,
time_of_day: NaiveTime,
) -> tokio::task::JoinHandle<()>
where
P: AsRef<std::path::Path> + Send + Sync + 'static,
{
if !directory.as_ref().is_dir() {
panic!(
"Path must be a directory: {}",
directory
.as_ref()
.to_str()
.unwrap_or("<Unable to convert directory to string>")
);
}
// Can also user tokio::spawn_blocking if needed.
tokio::task::spawn(async move {
loop {
let mut time_to_wait = time_of_day - chrono::Local::now().time();
if time_to_wait < TimeDelta::zero() {
time_to_wait += TimeDelta::days(1);
}
event!(Level::DEBUG, "Backup in {}s", time_to_wait.num_seconds());
tokio::time::sleep(time_to_wait.to_std().unwrap()).await;
let path = directory.as_ref().join(format!(
"recipes_backup_{}.sqlite",
chrono::Local::now().format("%Y-%m-%d_%H%M%S")
));
event!(
Level::INFO,
"Starting backup process to {}...",
path.display()
);
if let Err(error) = db_connection.backup(path).await {
event!(Level::ERROR, "Error when backing up database: {}", error);
}
event!(Level::INFO, "Backup done");
}
})
}

View file

@ -8,10 +8,10 @@ use std::{
}; };
use sqlx::{ use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
Pool, Sqlite, Transaction, Pool, Sqlite, Transaction,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
}; };
use tracing::{event, Level}; use tracing::{Level, event};
use crate::consts; use crate::consts;
@ -93,6 +93,18 @@ impl Connection {
Ok(connection) Ok(connection)
} }
pub async fn backup<P>(&self, file: P) -> Result<()>
where
P: AsRef<Path>,
{
sqlx::query("VACUUM INTO $1")
.bind(file.as_ref().to_str().unwrap())
.execute(&self.pool)
.await
.map(|_| ()) // Ignore the result.
.map_err(DBError::from)
}
async fn tx(&self) -> Result<Transaction<Sqlite>> { async fn tx(&self) -> Result<Transaction<Sqlite>> {
self.pool.begin().await.map_err(DBError::from) self.pool.begin().await.map_err(DBError::from)
} }

View file

@ -1,2 +1,3 @@
pub mod backup;
pub mod db; pub mod db;
pub mod model; pub mod model;

View file

@ -23,7 +23,7 @@ use tower_http::{
}; };
use tracing::{Level, event}; use tracing::{Level, event};
use data::{db, model}; use data::{backup, db, model};
use translation::Tr; use translation::Tr;
mod config; mod config;
@ -103,7 +103,17 @@ async fn main() {
event!(Level::INFO, "Configuration: {:?}", config); event!(Level::INFO, "Configuration: {:?}", config);
let db_connection = db::Connection::new().await.unwrap(); let Ok(db_connection) = db::Connection::new().await else {
event!(Level::ERROR, "Unable to connect to the database");
return;
};
backup::start(
"data",
db_connection.clone(),
// TODO: take from config.
NaiveTime::from_hms_opt(4, 0, 0).expect("Invalid time of day"),
);
let state = AppState { let state = AppState {
config, config,

View file

@ -1,7 +1,7 @@
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::{FromRequest, Request}, extract::{FromRequest, Request},
http::{header, StatusCode}, http::{StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@ -35,7 +35,7 @@ where
return Err( return Err(
ron_utils::ron_error(StatusCode::BAD_REQUEST, "No content type given") ron_utils::ron_error(StatusCode::BAD_REQUEST, "No content type given")
.into_response(), .into_response(),
) );
} }
} }