Initial commit.

This commit is contained in:
Edu4rdSHL 2021-04-15 21:12:08 -05:00
parent 3699578552
commit 882bff2d2e
No known key found for this signature in database
GPG key ID: 3A574A4009F553E5
14 changed files with 2113 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

19
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,19 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug",
"program": "${workspaceRoot}/target/debug/${workspaceRootFolderName}",
"args": "--cr -p root -d snapshots -t /mnt/defvol/_snapshots/ -s /mnt/defvol/_active/root/",
"cwd": "${workspaceRoot}",
"sourceLanguages": [
"rust"
]
}
]
}

1520
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

18
Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "rusnapshot"
version = "0.1.0"
authors = ["edu4rdshl"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "2.33.3", features = ["yaml"] }
reqwest = { version = "0.11.3", features = ["default-tls"] }
openssl = { version = "0.10.30", features = ["vendored"] }
config = { version = "0.11.0", features = ["yaml", "json", "toml", "hjson", "ini"] }
failure = "0.1.8"
sqlite = "0.26.0"
md5 = "0.7.0"
chrono = "0.4.19"
prettytable-rs = "0.8.0"

13
examples/config.toml Normal file
View file

@ -0,0 +1,13 @@
# Example configuration file for rustnapshot
# Snapshots directory
dest_dir = "/mnt/defvol/_snapshots/"
# Directory to be snapshoted
source_dir = "/mnt/defvol/_active/root/"
# SQLite database file PATH, created if no exists, recommended to be in the snapshots dir
database_file = "/mnt/defvol/_snapshots/rustnapshot.sqlite"
# Prefix for the snapshots, they will be saved in the format prefix-$current_date
snapshot_prefix = "root"
# Last snapshots to keep. If prefix is specified then only the snapshots with that prefix
# are deleted
keep_only = "2"

123
src/args.rs Normal file
View file

@ -0,0 +1,123 @@
use {
crate::structs::Args,
clap::{load_yaml, value_t, App},
std::{collections::HashMap, path::Path},
};
pub fn get_args() -> Args {
let yaml = load_yaml!("cli.yml");
let matches = App::from_yaml(yaml)
.version(clap::crate_version!())
.get_matches();
let settings: HashMap<String, String> =
return_settings(&matches, &mut config::Config::default());
Args {
create_snapshot: matches.is_present("create-snapshot"),
delete_snapshot: matches.is_present("delete-snapshot"),
list_snapshots: matches.is_present("list-snapshots"),
clean_snapshots: matches.is_present("clean-snapshots") || matches.is_present("keep-only"),
restore_snapshot: matches.is_present("restore-snapshot"),
dest_dir: if matches.is_present("dest-dir") {
value_t!(matches, "dest-dir", String).unwrap_or_else(|_| String::new())
} else {
return_value_or_default(&settings, "dest_dir", String::new())
},
source_dir: if matches.is_present("source-dir") {
value_t!(matches, "source-dir", String).unwrap_or_else(|_| String::new())
} else {
return_value_or_default(&settings, "source_dir", String::new())
},
database_file: if matches.is_present("database-file") {
value_t!(matches, "database-file", String)
.unwrap_or_else(|_| String::from("rusnapshot.sqlite"))
} else {
return_value_or_default(
&settings,
"database_file",
String::from("rusnapshot.sqlite"),
)
},
snapshot_name: String::new(),
snapshot_id: value_t!(matches, "snapshot-id", String).unwrap_or_else(|_| String::new()),
snapshot_prefix: if matches.is_present("snapshot-prefix") {
value_t!(matches, "snapshot-prefix", String)
.unwrap_or_else(|_| String::from("snapshot"))
} else {
return_value_or_default(&settings, "snapshot_prefix", String::from("snapshot"))
},
keep_only: if matches.is_present("keep-only") {
value_t!(matches, "keep-only", usize).unwrap_or_else(|_| 0)
} else {
return_value_or_default(&settings, "keep_only", 0.to_string())
.parse()
.unwrap_or_default()
},
}
}
fn return_settings(
matches: &clap::ArgMatches,
settings: &mut config::Config,
) -> HashMap<String, String> {
if matches.is_present("config-file") {
match settings.merge(config::File::with_name(
&value_t!(matches, "config-file", String).unwrap(),
)) {
Ok(settings) => match settings.merge(config::Environment::with_prefix("RUSNAPSHOT")) {
Ok(settings) => settings
.clone()
.try_into::<HashMap<String, String>>()
.unwrap(),
Err(e) => {
eprintln!("Error merging environment variables into settings: {}", e);
std::process::exit(1)
}
},
Err(e) => {
eprintln!("Error reading config file: {}", e);
std::process::exit(1)
}
}
} else if Path::new("rusnapshot.toml").exists()
|| Path::new("rusnapshot.json").exists()
|| Path::new("rusnapshot.hjson").exists()
|| Path::new("rusnapshot.ini").exists()
|| Path::new("rusnapshot.yml").exists()
{
match settings.merge(config::File::with_name("rusnapshot")) {
Ok(settings) => match settings.merge(config::Environment::with_prefix("RUSNAPSHOT")) {
Ok(settings) => settings
.clone()
.try_into::<HashMap<String, String>>()
.unwrap(),
Err(e) => {
eprintln!("Error merging environment variables into settings: {}", e);
std::process::exit(1)
}
},
Err(e) => {
eprintln!("Error reading config file: {}", e);
std::process::exit(1)
}
}
} else {
match settings.merge(config::Environment::with_prefix("RUSNAPSHOT")) {
Ok(settings) => settings
.clone()
.try_into::<HashMap<String, String>>()
.unwrap(),
Err(e) => {
eprintln!("Error merging environment variables into settings: {}", e);
std::process::exit(1)
}
}
}
}
fn return_value_or_default(
settings: &HashMap<String, String>,
value: &str,
default_value: String,
) -> String {
settings.get(value).unwrap_or(&default_value).to_string()
}

88
src/cli.yml Normal file
View file

@ -0,0 +1,88 @@
name: Rusnapshot
author: Eduard Tolosa <edu4rdshl@protonmail.com>
settings:
- ArgRequiredElseHelp
- StrictUtf8
about: Simple and handy btrfs snapshoting tool.
args:
- config-file:
help: Path to configuration file.
short: c
long: config
takes_value: true
multiple: false
- dest-dir:
help: Directory where snapshots should be saved.
long: to
takes_value: true
multiple: false
- source-dir:
help: Directory from where snapshots should be created.
long: from
takes_value: true
multiple: false
- snapshot-id:
help: Snapshot id or name to work with.
long: id
takes_value: true
multiple: false
- database-file:
help: Path to the SQLite database file.
short: d
long: dfile
takes_value: true
multiple: false
- snapshot-prefix:
help: "Prefix for the snapshot name. Default: snapshot"
short: p
long: prefix
takes_value: true
multiple: false
- create-snapshot:
help: Create an snapshot.
long: cr
takes_value: false
multiple: false
- clean-snapshots:
help: "Enable snapshots cleaning, will keep only the last X snapshots specified with -k/--keep. WARNING: if -k/--keep is not specified, IT WILL DELETE ALL THE SNAPSHOTS."
long: clean
takes_value: false
multiple: false
- delete-snapshot:
help: Delete an snapshot.
long: del
takes_value: false
multiple: false
requires:
- snapshot-id
- restore-snapshot:
help: Restore an specific snapshot.
short: r
long: restore
takes_value: false
multiple: false
requires:
- snapshot-id
- list-snapshots:
help: List the snapshots tracked in the database.
short: l
long: list
takes_value: false
multiple: false
- keep-only:
help: Keep only the last X items.
short: k
long: keep
takes_value: true
multiple: false

108
src/controller.rs Normal file
View file

@ -0,0 +1,108 @@
use {
crate::{database, errors::*, operations, structs::Args},
chrono::Utc,
md5,
prettytable::{cell, row, Table},
};
pub fn manage_creation(args: &mut Args) -> Result<()> {
let connection = sqlite::open(&args.database_file)?;
args.snapshot_name = format!(
"{}-{}",
args.snapshot_prefix,
Utc::now().format("%Y-%m-%d-%H-%M-%S")
);
args.snapshot_id = format!("{:?}", md5::compute(&args.snapshot_name));
if operations::take_snapshot(args) {
database::commit_to_database(&connection, args)?
}
drop(connection);
Ok(())
}
pub fn manage_deletion(args: &mut Args) -> Result<()> {
let connection = sqlite::open(&args.database_file)?;
let snapshot_data = database::return_snapshot_data(&connection, args)?;
if !snapshot_data.snap_id.is_empty() {
args.snapshot_name = snapshot_data.destination + &snapshot_data.name;
} else {
eprintln!(
"Snapshot ID {} does not returned any data. Please double check the ID.",
args.snapshot_id
)
}
if !args.snapshot_name.is_empty() && operations::del_snapshot(args) {
database::delete_from_database(&connection, args)?
}
drop(connection);
Ok(())
}
pub fn manage_restoring(args: &mut Args) -> Result<()> {
let connection = sqlite::open(&args.database_file)?;
let snapshot_data = database::return_snapshot_data(&connection, args)?;
if !snapshot_data.snap_id.is_empty() {
args.snapshot_name = snapshot_data.destination + &snapshot_data.name;
args.source_dir = snapshot_data.source;
} else {
eprintln!(
"Snapshot ID {} does not returned any data. Please double check the ID.",
args.snapshot_id
)
}
println!(
"ID: {}, NAME: {}, Rest Point: {}",
args.snapshot_id, args.snapshot_name, args.source_dir
);
if !args.snapshot_name.is_empty() && operations::restore_snapshot(args) {
println!(
"The snapshot with ID {} was successfully restored to {}",
args.snapshot_id, args.source_dir
)
}
drop(connection);
Ok(())
}
pub fn manage_listing(args: &mut Args) -> Result<()> {
let connection = sqlite::open(&args.database_file)?;
let snaps_data = database::return_all_data(&connection)?;
let mut table = Table::new();
table.set_titles(row![
bcFg => "NAME",
"ID",
"SOURCE DIR",
"DESTINATION DIR",
"DATE"
]);
for data in &snaps_data {
table.add_row(row![ d =>
data.name,
data.snap_id,
data.source,
data.destination,
data.date,
]);
}
table.printstd();
drop(connection);
Ok(())
}
pub fn keep_only_x(args: &mut Args) -> Result<()> {
let connection = sqlite::open(&args.database_file)?;
let snaps_data = database::return_only_x_items(&connection, args)?;
for data in &snaps_data {
args.snapshot_name = data.destination.clone() + &data.name;
args.snapshot_id = data.snap_id.clone();
manage_deletion(args)?
}
Ok(())
}

98
src/database.rs Normal file
View file

@ -0,0 +1,98 @@
use {
crate::{
errors::*,
structs::{Args, Database},
},
sqlite::{Connection, State},
};
pub fn test_database(connection_str: &str) -> Result<()> {
let connection = sqlite::open(&connection_str)?;
drop(connection);
Ok(())
}
pub fn setup_initial_database(connection: &Connection) -> Result<()> {
connection.execute("CREATE TABLE IF NOT EXISTS snapshots (name TEXT NOT NULL, snap_id TEXT NOT NULL, source TEXT NOT NULL, destination TEXT NOT NULL, date TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(name, snap_id))")?;
Ok(())
}
pub fn commit_to_database(connection: &Connection, args: &Args) -> Result<()> {
let statement = format!(
"INSERT INTO snapshots (name, snap_id, source, destination) VALUES ('{}', '{}', '{}', '{}')",
args.snapshot_name, args.snapshot_id, args.source_dir, args.dest_dir
);
connection.execute(&statement)?;
Ok(())
}
pub fn delete_from_database(connection: &Connection, args: &Args) -> Result<()> {
let statement = format!(
"DELETE FROM snapshots WHERE name = '{}' OR snap_id = '{}'",
args.snapshot_id, args.snapshot_id
);
connection.execute(&statement)?;
Ok(())
}
pub fn return_snapshot_data(connection: &Connection, args: &Args) -> Result<Database> {
let mut statement = connection.prepare(format!(
"SELECT * FROM snapshots WHERE name = '{}' OR snap_id = '{}'",
args.snapshot_id, args.snapshot_id
))?;
let mut snap_data = Database::default();
while let State::Row = statement.next()? {
snap_data.name = statement.read::<String>(0).unwrap();
snap_data.snap_id = statement.read::<String>(1).unwrap();
snap_data.source = statement.read::<String>(2).unwrap();
snap_data.destination = statement.read::<String>(3).unwrap();
snap_data.date = statement.read::<String>(4).unwrap();
}
Ok(snap_data)
}
pub fn return_all_data(connection: &Connection) -> Result<Vec<Database>> {
let mut snapshots_data: Vec<Database> = Vec::new();
let mut statement = connection.prepare("SELECT * FROM snapshots ORDER BY date DESC")?;
while let State::Row = statement.next()? {
let mut db_struct = Database::default();
db_struct.name = statement.read::<String>(0).unwrap();
db_struct.snap_id = statement.read::<String>(1).unwrap();
db_struct.source = statement.read::<String>(2).unwrap();
db_struct.destination = statement.read::<String>(3).unwrap();
db_struct.date = statement.read::<String>(4).unwrap();
snapshots_data.push(db_struct);
}
Ok(snapshots_data)
}
pub fn return_only_x_items(connection: &Connection, args: &Args) -> Result<Vec<Database>> {
let mut snapshots_data: Vec<Database> = Vec::new();
let mut statement = connection.prepare(&format!("SELECT name,snap_id,source,destination,date FROM (SELECT row_number() over(ORDER BY date DESC) n,* from snapshots) WHERE n > {} AND name LIKE '{}%'", args.keep_only, args.snapshot_prefix))?;
while let State::Row = statement.next()? {
let mut db_struct = Database::default();
db_struct.name = statement.read::<String>(0).unwrap();
db_struct.snap_id = statement.read::<String>(1).unwrap();
db_struct.source = statement.read::<String>(2).unwrap();
db_struct.destination = statement.read::<String>(3).unwrap();
db_struct.date = statement.read::<String>(4).unwrap();
snapshots_data.push(db_struct);
}
Ok(snapshots_data)
}

2
src/errors.rs Normal file
View file

@ -0,0 +1,2 @@
pub use failure::{Error, ResultExt};
pub type Result<T> = ::std::result::Result<T, Error>;

6
src/lib.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod args;
pub mod controller;
pub mod database;
pub mod errors;
mod operations;
mod structs;

37
src/main.rs Normal file
View file

@ -0,0 +1,37 @@
use {
rusnapshot::{args, controller, database, errors::*},
sqlite,
};
fn run() -> Result<()> {
let mut arguments = args::get_args();
database::test_database(&arguments.database_file)?;
database::setup_initial_database(&sqlite::open(&arguments.database_file)?)?;
if arguments.create_snapshot {
controller::manage_creation(&mut arguments)?
}
if arguments.delete_snapshot {
controller::manage_deletion(&mut arguments)?
}
if arguments.list_snapshots {
controller::manage_listing(&mut arguments)?
}
if arguments.clean_snapshots {
controller::keep_only_x(&mut arguments)?
}
if arguments.restore_snapshot {
controller::manage_restoring(&mut arguments)?
}
Ok(())
}
fn main() {
if let Err(err) = run() {
eprintln!("\nError: {}", err);
for cause in err.iter_chain().skip(1) {
eprintln!("Error description: {}", cause);
}
std::process::exit(1);
}
}

45
src/operations.rs Normal file
View file

@ -0,0 +1,45 @@
use {
crate::structs::Args,
std::{path::Path, process::Command},
};
pub fn take_snapshot(args: &Args) -> bool {
Command::new("btrfs")
.args(&[
"subvolume",
"snapshot",
"-r",
&args.source_dir,
&format!("{}/{}", args.dest_dir, args.snapshot_name),
])
.status()
.expect("Error while taking the snapshot.")
.success()
}
pub fn del_snapshot(args: &Args) -> bool {
Command::new("btrfs")
.args(&["subvolume", "delete", &args.snapshot_name])
.status()
.expect("Error deleting the snapshot.")
.success()
}
pub fn restore_snapshot(args: &Args) -> bool {
(!Path::new(&args.source_dir).exists()
|| Command::new("btrfs")
.args(&["subvolume", "delete", &args.source_dir])
.status()
.expect("Error deleting the subvolume.")
.success())
&& Command::new("btrfs")
.args(&[
"subvolume",
"snapshot",
&args.snapshot_name,
&args.source_dir,
])
.status()
.expect("Error restoring the snapshot.")
.success()
}

35
src/structs.rs Normal file
View file

@ -0,0 +1,35 @@
#[derive(Clone, Debug)]
pub struct Args {
pub create_snapshot: bool,
pub delete_snapshot: bool,
pub list_snapshots: bool,
pub clean_snapshots: bool,
pub restore_snapshot: bool,
pub dest_dir: String,
pub source_dir: String,
pub database_file: String,
pub snapshot_name: String,
pub snapshot_id: String,
pub snapshot_prefix: String,
pub keep_only: usize,
}
pub struct Database {
pub name: String,
pub snap_id: String,
pub source: String,
pub destination: String,
pub date: String,
}
impl Database {
pub fn default() -> Database {
Database {
name: String::new(),
snap_id: String::new(),
source: String::new(),
destination: String::new(),
date: String::new(),
}
}
}