Add a simple daily backup module
authorGreg Burri <greg.burri@gmail.com>
Sat, 29 Mar 2025 22:59:48 +0000 (23:59 +0100)
committerGreg Burri <greg.burri@gmail.com>
Sat, 29 Mar 2025 22:59:48 +0000 (23:59 +0100)
backend/src/config.rs
backend/src/data/backup.rs [new file with mode: 0644]
backend/src/data/db/mod.rs
backend/src/data/mod.rs
backend/src/main.rs
backend/src/ron_extractor.rs

index 665fb16..df5977d 100644 (file)
@@ -3,8 +3,9 @@ use std::{
     fs::{self, File},
 };
 
+use chrono::NaiveTime;
 use ron::{
-    de::from_reader,
+    de::{from_reader, from_str},
     ser::{PrettyConfig, to_string_pretty},
 };
 use serde::{Deserialize, Serialize};
@@ -13,20 +14,48 @@ use crate::consts;
 
 #[derive(Deserialize, Serialize, Clone)]
 pub struct Config {
+    #[serde(default = "port_default")]
     pub port: u16,
+
+    #[serde(default = "smtp_relay_address_default")]
     pub smtp_relay_address: String,
+
+    #[serde(default = "smtp_login_default")]
     pub smtp_login: String,
+
+    #[serde(default = "smtp_password_default")]
     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 {
     pub fn default() -> Self {
-        Config {
-            port: 8082,
-            smtp_relay_address: "mail.something.com".to_string(),
-            smtp_login: "login".to_string(),
-            smtp_password: "password".to_string(),
-        }
+        from_str("()").unwrap()
     }
 }
 
@@ -38,12 +67,14 @@ impl fmt::Debug for Config {
             .field("smtp_relay_address", &self.smtp_relay_address)
             .field("smtp_login", &self.smtp_login)
             .field("smtp_password", &"*****")
+            .field("backup_time", &self.backup_time)
+            .field("backup_directory", &self.backup_directory)
             .finish()
     }
 }
 
 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| {
             panic!(
                 "Failed to open configuration file {}: {}",
@@ -51,17 +82,17 @@ pub fn load() -> Config {
                 error
             )
         }),
-        Err(_) => {
-            let default_config = Config::default();
+        Err(_) => Config::default(),
+    };
 
-            let ron_string = to_string_pretty(&default_config, PrettyConfig::new())
-                .unwrap_or_else(|error| panic!("Failed to serialize ron configuration: {}", error));
+    // 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));
 
-            fs::write(consts::FILE_CONF, ron_string).unwrap_or_else(|error| {
-                panic!("Failed to write default configuration file: {}", error)
-            });
+    fs::write(consts::FILE_CONF, ron_string)
+        .unwrap_or_else(|error| panic!("Failed to write default configuration file: {}", error));
 
-            default_config
-        }
-    }
+    config
 }
diff --git a/backend/src/data/backup.rs b/backend/src/data/backup.rs
new file mode 100644 (file)
index 0000000..6826da0
--- /dev/null
@@ -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");
+        }
+    })
+}
index f0a6382..c321273 100644 (file)
@@ -8,10 +8,10 @@ use std::{
 };
 
 use sqlx::{
-    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
     Pool, Sqlite, Transaction,
+    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
 };
-use tracing::{event, Level};
+use tracing::{Level, event};
 
 use crate::consts;
 
@@ -93,6 +93,18 @@ impl 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>> {
         self.pool.begin().await.map_err(DBError::from)
     }
index bdce02a..0eb71c4 100644 (file)
@@ -1,2 +1,3 @@
+pub mod backup;
 pub mod db;
 pub mod model;
index 4df3338..4588630 100644 (file)
@@ -23,7 +23,7 @@ use tower_http::{
 };
 use tracing::{Level, event};
 
-use data::{db, model};
+use data::{backup, db, model};
 use translation::Tr;
 
 mod config;
@@ -103,7 +103,17 @@ async fn main() {
 
     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 {
         config,
index c3e1ddd..1c14869 100644 (file)
@@ -1,7 +1,7 @@
 use axum::{
     body::Bytes,
     extract::{FromRequest, Request},
-    http::{header, StatusCode},
+    http::{StatusCode, header},
     response::{IntoResponse, Response},
 };
 use serde::de::DeserializeOwned;
@@ -35,7 +35,7 @@ where
                 return Err(
                     ron_utils::ron_error(StatusCode::BAD_REQUEST, "No content type given")
                         .into_response(),
-                )
+                );
             }
         }