diff --git a/Cargo.lock b/Cargo.lock index 5a26050..aee12a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,7 +238,7 @@ dependencies = [ "num-integer", "num-traits", "serde", - "time", + "time 0.1.44", "wasm-bindgen", "winapi", ] @@ -374,6 +374,41 @@ dependencies = [ "cipher 0.3.0", ] +[[package]] +name = "darling" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derivative" version = "2.2.0" @@ -420,6 +455,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "4.0.0" @@ -852,6 +896,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.2.3" @@ -889,6 +939,7 @@ checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ "autocfg", "hashbrown", + "serde", ] [[package]] @@ -1032,6 +1083,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merge" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10bbef93abb1da61525bbc45eeaff6473a41907d19f8f9aa5168d214e10693e9" +dependencies = [ + "merge_derive", + "num-traits", +] + +[[package]] +name = "merge_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209d075476da2e63b4b29e72a2ef627b840589588e71400a25e3565c4f849d07" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.16" @@ -1102,6 +1175,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -1498,6 +1580,7 @@ dependencies = [ "derivative", "derive-getters", "derive_more", + "directories", "dirs", "enum-map", "enum-map-derive", @@ -1511,6 +1594,7 @@ dependencies = [ "integer-sqrt", "itertools", "lazy_static", + "merge", "nix", "path-absolutize", "prettytable-rs", @@ -1525,11 +1609,13 @@ dependencies = [ "serde", "serde-aux", "serde_json", + "serde_with", "sha1", "sha2", "tempfile", "thiserror", "tokio", + "toml", "users", "vlog", "walkdir", @@ -1695,6 +1781,34 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89df7a26519371a3cce44fbb914c2819c84d9b897890987fa3ab096491cc0ea8" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap", + "serde", + "serde_json", + "serde_with_macros", + "time 0.3.14", +] + +[[package]] +name = "serde_with_macros" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de337f322382fcdfbb21a014f7c224ee041a23785651db67b9827403178f698f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.2" @@ -1881,6 +1995,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "time" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b" +dependencies = [ + "itoa", + "libc", + "num_threads", + "serde", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1953,6 +2079,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +dependencies = [ + "serde", +] + [[package]] name = "tower-service" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 8fcdd4c..bc322da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,10 @@ dirs = "4" cachedir = "0.3" # commands clap = { version = "3", features = ["derive", "env"] } +directories = "4" +toml = "0.5" +merge = "0.1" +serde_with = "2" rpassword = "7" prettytable-rs = {version = "0.9", default-features = false } bytesize = "1" diff --git a/README.md b/README.md index 7080055..7cee7de 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Look at the [FAQ][3] or open an issue! Improvements: * Allows using cold storage (e.g. AWS Glacier) repos which are only read in the `restore` command + supports warm-up * Completely lock-free pruning; option `instant-delete` is available + * Supports configuration in a config file ([example config files](https://github.com/rustic-rs/rustic/tree/main/examples)) * Huge decrease in memory requirement * Pack size can be customized * Already faster than restic for most operations (but not yet fully speed optimized) diff --git a/examples/local.toml b/examples/local.toml new file mode 100644 index 0000000..c8efcd7 --- /dev/null +++ b/examples/local.toml @@ -0,0 +1,31 @@ +# rustic config file to backup /home, /etc and /root to a local repository +# +# backup usage: "rustic-rs -P local backup +# cleanup: "rustic-rs -P local forget --prune +# +[global] +repository = "/backup/rustic" +password-file = "/root/key-rustic" +no-cache = true # no cache needed for local repository + +[forget] +keep-hourly = 20 +keep-daily = 14 +keep-weekly = 8 +keep-monthly = 24 +keep-yearly = 10 + +[backup] +exclude-if-present = [".nobackup", "CACHEDIR.TAG"] +glob-file = ["/root/rustic-local.glob"] +one-filesystem = true + +[[backup.sources]] +source = "/home" +git-ignore = true + +[[backup.sources]] +source = "/etc" + +[[backup.sources]] +source = "/root" diff --git a/examples/ovh-hot-cold.toml b/examples/ovh-hot-cold.toml new file mode 100644 index 0000000..19daebe --- /dev/null +++ b/examples/ovh-hot-cold.toml @@ -0,0 +1,31 @@ +# rustic config file to backup /home, /etc and /root to a hot/cold repository hosted by OVH +# +# backup usage: "rustic-rs -P ovh-hot-cold backup +# cleanup: "rustic-rs -P ovh-hot-cold forget --prune +# +[global] +repository = "rclone:ovh:backup-home" +repo-hot = "rclone:ovh:backup-home-hot" +password-file = "/root/key-rustic-ovh" +cache-dir = "/var/lib/cache/rustic" # explicitely specify cache dir for remote repository + +[forget] +keep-daily = 8 +keep-weekly = 5 +keep-monthly = 13 +keep-yearly = 10 + +[backup] +exclude-if-present = [".nobackup", "CACHEDIR.TAG"] +glob-file = ["/root/rustic-ovh.glob"] +one-filesystem = true + +[[backup.sources]] +source = "/home" +git-ignore = true + +[[backup.sources]] +source = "/etc" + +[[backup.sources]] +source = "/root" diff --git a/examples/rustic.toml b/examples/rustic.toml new file mode 100644 index 0000000..55c87cc --- /dev/null +++ b/examples/rustic.toml @@ -0,0 +1,40 @@ +# Example rustic config file. +# +# This file should be placed in the user's local config dir (~/.config/rustic/) +# If you save it under NAME.toml, use "rustic-rs -P NAME" to access this profile. +# +# Note that most options can be overwritten by the corresponding command line option. + +# global options: These options are used for all commands. +[global] +repository = "/tmp/rustic" +password = "mySecretPassword" + +# snapshot-filter options: These options appy to the snapshots, tag and forget command. +[snapshot-filter] +filter-host = ["myhost"] + +# backup options: These options are used for all sources when calling the backup command. +# They can be overwritten by source-specific options (see below) or command line options. +[backup] +git-ignore = true + +# backup options can be given for specific sources. These options only apply +# when calling "rustic-rs backup SOURCE". +# +# Note that if you call "rustic-rs backup" without any source, all sources from this config +# file will be processed. +[[backup.sources]] +source = "/data/dir" + +[[backup.sources]] +source = "/home" +glob = ["!/home/*/Downloads/*"] + +# forget options +[forget] +filter-host = ["forgethost"] # <- this overwrites the snapshot-filter option defined above +keep-tags = ["mytag"] +keep-within-daily = "7 days" +keep-monthly = 5 +keep-yearly = 2 diff --git a/src/backend/ignore.rs b/src/backend/ignore.rs index 5d7b0df..792964a 100644 --- a/src/backend/ignore.rs +++ b/src/backend/ignore.rs @@ -7,6 +7,9 @@ use bytesize::ByteSize; use chrono::{TimeZone, Utc}; use clap::Parser; use ignore::{overrides::OverrideBuilder, DirEntry, Walk, WalkBuilder}; +use merge::Merge; +use serde::Deserialize; +use serde_with::{serde_as, DisplayFromStr}; use users::{Groups, Users, UsersCache}; use super::{node::Metadata, node::NodeType, Node, ReadSource}; @@ -18,42 +21,53 @@ pub struct LocalSource { cache: UsersCache, } -#[derive(Clone, Parser)] +#[serde_as] +#[derive(Default, Clone, Parser, Deserialize, Merge)] +#[serde(default, rename_all = "kebab-case")] pub struct LocalSourceOptions { /// Save access time for files and directories #[clap(long)] + #[merge(strategy = merge::bool::overwrite_false)] with_atime: bool, /// Glob pattern to exclude/include (can be specified multiple times) #[clap(long, short = 'g', help_heading = "EXCLUDE OPTIONS")] + #[merge(strategy = merge::vec::overwrite_empty)] glob: Vec, /// Same as --glob pattern but ignores the casing of filenames #[clap(long, value_name = "GLOB", help_heading = "EXCLUDE OPTIONS")] + #[merge(strategy = merge::vec::overwrite_empty)] iglob: Vec, /// Read glob patterns to exclude/include from this file (can be specified multiple times) #[clap(long, value_name = "FILE", help_heading = "EXCLUDE OPTIONS")] + #[merge(strategy = merge::vec::overwrite_empty)] glob_file: Vec, /// Same as --glob-file ignores the casing of filenames in patterns #[clap(long, value_name = "FILE", help_heading = "EXCLUDE OPTIONS")] + #[merge(strategy = merge::vec::overwrite_empty)] iglob_file: Vec, /// Ignore files based on .gitignore files #[clap(long, help_heading = "EXCLUDE OPTIONS")] + #[merge(strategy = merge::bool::overwrite_false)] git_ignore: bool, /// Exclude contents of directories containing this filename (can be specified multiple times) #[clap(long, value_name = "FILE", help_heading = "EXCLUDE OPTIONS")] + #[merge(strategy = merge::vec::overwrite_empty)] exclude_if_present: Vec, /// Exclude other file systems, don't cross filesystem boundaries and subvolumes #[clap(long, short = 'x', help_heading = "EXCLUDE OPTIONS")] + #[merge(strategy = merge::bool::overwrite_false)] one_file_system: bool, /// Maximum size of files to be backuped. Larger files will be excluded. #[clap(long, value_name = "SIZE", help_heading = "EXCLUDE OPTIONS")] + #[serde_as(as = "Option")] exclude_larger_than: Option, } diff --git a/src/commands/backup.rs b/src/commands/backup.rs index 83dbcb5..3e1c4a8 100644 --- a/src/commands/backup.rs +++ b/src/commands/backup.rs @@ -4,10 +4,13 @@ use anyhow::{anyhow, Result}; use chrono::{Duration, Local}; use clap::{AppSettings, Parser}; use gethostname::gethostname; +use merge::Merge; use path_absolutize::*; +use serde::Deserialize; +use serde_with::{serde_as, DisplayFromStr}; use vlog::*; -use super::{bytes, progress_bytes, progress_counter}; +use super::{bytes, progress_bytes, progress_counter, RusticConfig}; use crate::archiver::{Archiver, Parent}; use crate::backend::{ DecryptFullBackend, DecryptWriteBackend, DryRunBackend, LocalSource, LocalSourceOptions, @@ -17,11 +20,14 @@ use crate::blob::{Metadata, Node, NodeType}; use crate::index::IndexBackend; use crate::repo::{ConfigFile, DeleteOption, SnapshotFile, SnapshotSummary, StringList}; -#[derive(Parser)] +#[serde_as] +#[derive(Clone, Default, Parser, Deserialize, Merge)] #[clap(global_setting(AppSettings::DeriveDisplayOrder))] +#[serde(default, rename_all = "kebab-case")] pub(super) struct Opts { /// Do not upload or write any data, just show what would be done #[clap(long, short = 'n')] + #[merge(strategy = merge::bool::overwrite_false)] dry_run: bool, /// Snapshot to use as parent @@ -30,61 +36,97 @@ pub(super) struct Opts { /// Use no parent, read all files #[clap(long, short, conflicts_with = "parent")] + #[merge(strategy = merge::bool::overwrite_false)] force: bool, /// Ignore ctime changes when checking for modified files #[clap(long, conflicts_with = "force")] + #[merge(strategy = merge::bool::overwrite_false)] ignore_ctime: bool, /// Ignore inode number changes when checking for modified files #[clap(long, conflicts_with = "force")] + #[merge(strategy = merge::bool::overwrite_false)] ignore_inode: bool, /// Tags to add to backup (can be specified multiple times) #[clap(long, value_name = "TAG[,TAG,..]")] + #[serde_as(as = "Vec")] + #[merge(strategy = merge::vec::overwrite_empty)] tag: Vec, /// Mark snapshot as uneraseable #[clap(long, conflicts_with = "delete-after")] + #[merge(strategy = merge::bool::overwrite_false)] delete_never: bool, /// Mark snapshot to be deleted after given duration (e.g. 10d) #[clap(long, value_name = "DURATION")] + #[serde_as(as = "Option")] delete_after: Option, /// Set filename to be used when backing up from stdin #[clap(long, value_name = "FILENAME", default_value = "stdin")] + #[merge(skip)] stdin_filename: String, #[clap(flatten)] + #[serde(flatten)] ignore_opts: LocalSourceOptions, - /// Backup source, use - for stdin + /// Backup source (can be specified multiple times), use - for stdin. If no source is given, uses all + /// sources defined in the config file #[clap(value_name = "SOURCE")] + #[merge(skip)] + #[serde(skip)] sources: Vec, + + /// Backup source, used within config file + #[clap(skip)] + #[merge(skip)] + source: String, } pub(super) async fn execute( be: &impl DecryptFullBackend, opts: Opts, config: ConfigFile, + config_file: RusticConfig, command: String, ) -> Result<()> { let time = Local::now(); - let zstd = config.zstd()?; - let mut be = DryRunBackend::new(be.clone(), opts.dry_run); - be.set_zstd(zstd); - if opts.sources.is_empty() { - v1!("no backup source given."); - return Ok(()); - } + let zstd = config.zstd()?; + + let mut config_opts: Vec = config_file.get("backup.sources")?; + + let sources = match (opts.sources.is_empty(), config_opts.is_empty()) { + (false, _) => opts.sources.clone(), + (true, false) => { + v1!("using all backup sources from config file."); + config_opts.iter().map(|opt| opt.source.clone()).collect() + } + (true, true) => { + v1!("no backup source given."); + return Ok(()); + } + }; let index = IndexBackend::only_full_trees(&be.clone(), progress_counter()).await?; - for source in opts.sources { + for source in sources { + let mut opts = opts.clone(); + + // merge Options from config file, if given + if let Some(idx) = config_opts.iter().position(|opt| opt.source == *source) { + opts.merge(config_opts.remove(idx)); + } + // merge "backup" section from config file, if given + config_file.merge_into("backup", &mut opts)?; + + let mut be = DryRunBackend::new(be.clone(), opts.dry_run); + be.set_zstd(zstd); v1!("\nbacking up {source}..."); - let be = be.clone(); let index = index.clone(); let backup_stdin = source == "-"; let backup_path = if backup_stdin { diff --git a/src/commands/forget.rs b/src/commands/forget.rs index f7cd495..bc2f7b4 100644 --- a/src/commands/forget.rs +++ b/src/commands/forget.rs @@ -1,10 +1,15 @@ +use std::str::FromStr; + use anyhow::Result; use chrono::{DateTime, Datelike, Duration, Local, Timelike}; use clap::{AppSettings, Parser}; use derivative::Derivative; +use merge::Merge; use prettytable::{format, row, Table}; +use serde::Deserialize; +use serde_with::{serde_as, DisplayFromStr}; -use super::{progress_counter, prune}; +use super::{progress_counter, prune, RusticConfig}; use crate::backend::{Cache, DecryptFullBackend, FileType}; use crate::repo::{ ConfigFile, SnapshotFile, SnapshotFilter, SnapshotGroup, SnapshotGroupCriterion, StringList, @@ -13,20 +18,8 @@ use crate::repo::{ #[derive(Parser)] #[clap(global_setting(AppSettings::DeriveDisplayOrder))] pub(super) struct Opts { - /// Group snapshots by any combination of host,paths,tags - #[clap( - long, - short = 'g', - value_name = "CRITERION", - default_value = "host,paths" - )] - group_by: SnapshotGroupCriterion, - - #[clap(flatten, help_heading = "SNAPSHOT FILTER OPTIONS")] - filter: SnapshotFilter, - - #[clap(flatten, help_heading = "RETENTION OPTIONS")] - keep: KeepOptions, + #[clap(flatten)] + config: ConfigOpts, /// Also prune the repository #[clap(long)] @@ -43,15 +36,45 @@ pub(super) struct Opts { ids: Vec, } +#[serde_as] +#[derive(Default, Parser, Deserialize, Merge)] +#[clap(global_setting(AppSettings::DeriveDisplayOrder))] +#[serde(default, rename_all = "kebab-case")] +struct ConfigOpts { + /// Group snapshots by any combination of host,paths,tags (default: "host,paths") + #[clap(long, short = 'g', value_name = "CRITERION")] + #[serde_as(as = "Option")] + group_by: Option, + + #[clap(flatten, help_heading = "SNAPSHOT FILTER OPTIONS")] + #[serde(flatten)] + filter: SnapshotFilter, + + #[clap(flatten, help_heading = "RETENTION OPTIONS")] + #[serde(flatten)] + keep: KeepOptions, +} + pub(super) async fn execute( be: &(impl DecryptFullBackend + Unpin), cache: Option, mut opts: Opts, config: ConfigFile, + config_file: RusticConfig, ) -> Result<()> { + // merge "forget" section from config file, if given + config_file.merge_into("forget", &mut opts.config)?; + // merge "snapshot-filter" section from config file, if given + config_file.merge_into("snapshot-filter", &mut opts.config.filter)?; + opts.dry_run = opts.prune_opts.dry_run; + let group_by = opts + .config + .group_by + .unwrap_or_else(|| SnapshotGroupCriterion::from_str("host,paths").unwrap()); + let groups = match opts.ids.is_empty() { - true => SnapshotFile::group_from_backend(be, &opts.filter, &opts.group_by).await?, + true => SnapshotFile::group_from_backend(be, &opts.config.filter, &group_by).await?, false => vec![( SnapshotGroup::default(), SnapshotFile::from_ids(be, &opts.ids).await?, @@ -65,7 +88,7 @@ pub(super) async fn execute( } snapshots.sort_unstable_by(|sn1, sn2| sn1.cmp(sn2).reverse()); let latest_time = snapshots[0].time; - let mut group_keep = opts.keep.clone(); + let mut group_keep = opts.config.keep.clone(); let mut table = Table::new(); let mut iter = snapshots.iter().peekable(); @@ -74,7 +97,7 @@ pub(super) async fn execute( // snapshots that have no reason to be kept are removed. The only exception // is if no IDs are explicitely given and no keep option is set. In this // case, the default is to keep the snapshots. - let default_keep = opts.ids.is_empty() && opts.keep == KeepOptions::default(); + let default_keep = opts.ids.is_empty() && group_keep == KeepOptions::default(); while let Some(sn) = iter.next() { let (action, reason) = { @@ -135,72 +158,101 @@ pub(super) async fn execute( Ok(()) } -#[derive(Clone, PartialEq, Derivative, Parser)] +#[serde_as] +#[derive(Clone, PartialEq, Derivative, Parser, Deserialize, Merge)] #[derivative(Default)] -struct KeepOptions { +#[serde(default, rename_all = "kebab-case")] +pub(super) struct KeepOptions { /// Keep snapshots with this taglist (can be specified multiple times) #[clap(long, value_name = "TAG[,TAG,..]")] + #[serde_as(as = "Vec")] + #[merge(strategy=merge::vec::overwrite_empty)] keep_tags: Vec, /// Keep snapshots ids that start with ID (can be specified multiple times) #[clap(long = "keep-id", value_name = "ID")] + #[merge(strategy=merge::vec::overwrite_empty)] keep_ids: Vec, /// Keep the last N snapshots #[clap(long, short = 'l', value_name = "N", default_value = "0")] + #[merge(strategy=merge::num::overwrite_zero)] keep_last: u32, /// Keep the last N hourly snapshots #[clap(long, short = 'H', value_name = "N", default_value = "0")] + #[merge(strategy=merge::num::overwrite_zero)] keep_hourly: u32, /// Keep the last N daily snapshots #[clap(long, short = 'd', value_name = "N", default_value = "0")] + #[merge(strategy=merge::num::overwrite_zero)] keep_daily: u32, /// Keep the last N weekly snapshots #[clap(long, short = 'w', value_name = "N", default_value = "0")] + #[merge(strategy=merge::num::overwrite_zero)] keep_weekly: u32, /// Keep the last N monthly snapshots #[clap(long, short = 'm', value_name = "N", default_value = "0")] + #[merge(strategy=merge::num::overwrite_zero)] keep_monthly: u32, /// Keep the last N yearly snapshots #[clap(long, short = 'y', value_name = "N", default_value = "0")] + #[merge(strategy=merge::num::overwrite_zero)] keep_yearly: u32, /// Keep snapshots newer than DURATION relative to latest snapshot #[clap(long, value_name = "DURATION", default_value = "0h")] #[derivative(Default(value = "std::time::Duration::ZERO.into()"))] + #[serde_as(as = "DisplayFromStr")] + #[merge(strategy=overwrite_zero_duration)] keep_within: humantime::Duration, /// Keep hourly snapshots newer than DURATION relative to latest snapshot #[clap(long, value_name = "DURATION", default_value = "0h")] #[derivative(Default(value = "std::time::Duration::ZERO.into()"))] + #[serde_as(as = "DisplayFromStr")] + #[merge(strategy=overwrite_zero_duration)] keep_within_hourly: humantime::Duration, /// Keep daily snapshots newer than DURATION relative to latest snapshot #[clap(long, value_name = "DURATION", default_value = "0d")] #[derivative(Default(value = "std::time::Duration::ZERO.into()"))] + #[serde_as(as = "DisplayFromStr")] + #[merge(strategy=overwrite_zero_duration)] keep_within_daily: humantime::Duration, /// Keep weekly snapshots newer than DURATION relative to latest snapshot #[clap(long, value_name = "DURATION", default_value = "0w")] #[derivative(Default(value = "std::time::Duration::ZERO.into()"))] + #[serde_as(as = "DisplayFromStr")] + #[merge(strategy=overwrite_zero_duration)] keep_within_weekly: humantime::Duration, /// Keep monthly snapshots newer than DURATION relative to latest snapshot #[clap(long, value_name = "DURATION", default_value = "0m")] #[derivative(Default(value = "std::time::Duration::ZERO.into()"))] + #[serde_as(as = "DisplayFromStr")] + #[merge(strategy=overwrite_zero_duration)] keep_within_monthly: humantime::Duration, /// Keep yearly snapshots newer than DURATION relative to latest snapshot #[clap(long, value_name = "DURATION", default_value = "0y")] #[derivative(Default(value = "std::time::Duration::ZERO.into()"))] + #[serde_as(as = "DisplayFromStr")] + #[merge(strategy=overwrite_zero_duration)] keep_within_yearly: humantime::Duration, } +fn overwrite_zero_duration(left: &mut humantime::Duration, right: humantime::Duration) { + if *left == std::time::Duration::ZERO.into() { + *left = right; + } +} + fn always_false(_sn1: &SnapshotFile, _sn2: &SnapshotFile) -> bool { false } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7c2f6b4..a1b3efa 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; +use merge::Merge; +use serde::Deserialize; use crate::backend::{ Cache, CachedBackend, ChooseBackend, DecryptBackend, DecryptReadBackend, FileType, @@ -23,42 +25,48 @@ mod ls; mod prune; mod repoinfo; mod restore; +mod rustic_config; mod self_update; mod snapshots; mod tag; use helpers::*; +use rustic_config::RusticConfig; use vlog::*; #[derive(Parser)] #[clap(about, version)] struct Opts { - /// Repository to use + #[clap(flatten, help_heading = "GLOBAL OPTIONS")] + global: GlobalOpts, + + /// Config profile to use. This parses the file .toml in the config directory. #[clap( - short, + short = 'P', long, + value_name = "PROFILE", global = true, - env = "RUSTIC_REPOSITORY", - help_heading = "GLOBAL OPTIONS" + default_value = "rustic" )] + config_profile: String, + + #[clap(subcommand)] + command: Command, +} + +#[derive(Default, Parser, Deserialize, Merge)] +#[serde(default, rename_all = "kebab-case")] +struct GlobalOpts { + /// Repository to use + #[clap(short, long, global = true, env = "RUSTIC_REPOSITORY")] repository: Option, /// Repository to use as hot storage - #[clap( - long, - global = true, - env = "RUSTIC_REPO_HOT", - help_heading = "GLOBAL OPTIONS" - )] + #[clap(long, global = true, env = "RUSTIC_REPO_HOT")] repo_hot: Option, /// Password of the repository - WARNING: Using --password can reveal the password in the process list! - #[clap( - long, - global = true, - env = "RUSTIC_PASSWORD", - help_heading = "GLOBAL OPTIONS" - )] + #[clap(long, global = true, env = "RUSTIC_PASSWORD")] password: Option, /// File to read the password from @@ -68,7 +76,6 @@ struct Opts { global = true, parse(from_os_str), env = "RUSTIC_PASSWORD_FILE", - help_heading = "GLOBAL OPTIONS", conflicts_with = "password" )] password_file: Option, @@ -78,19 +85,13 @@ struct Opts { long, global = true, env = "RUSTIC_PASSWORD_COMMAND", - help_heading = "GLOBAL OPTIONS", conflicts_with_all = &["password", "password-file"], )] password_command: Option, /// Increase verbosity (can be used multiple times) - #[clap( - long, - short = 'v', - global = true, - parse(from_occurrences), - help_heading = "GLOBAL OPTIONS" - )] + #[clap(long, short = 'v', global = true, parse(from_occurrences))] + #[merge(strategy = merge::num::overwrite_zero)] verbose: i8, /// Don't be verbose at all @@ -99,18 +100,14 @@ struct Opts { short = 'q', global = true, parse(from_occurrences), - conflicts_with = "verbose", - help_heading = "GLOBAL OPTIONS" + conflicts_with = "verbose" )] + #[merge(strategy = merge::num::overwrite_zero)] quiet: i8, /// Don't use a cache. - #[clap( - long, - global = true, - env = "RUSTIC_NO_CACHE", - help_heading = "GLOBAL OPTIONS" - )] + #[clap(long, global = true, env = "RUSTIC_NO_CACHE")] + #[merge(strategy = merge::bool::overwrite_false)] no_cache: bool, /// Use this dir as cache dir instead of the standard cache dir @@ -119,13 +116,9 @@ struct Opts { global = true, parse(from_os_str), conflicts_with = "no-cache", - env = "RUSTIC_CACHE_DIR", - help_heading = "GLOBAL OPTIONS" + env = "RUSTIC_CACHE_DIR" )] cache_dir: Option, - - #[clap(subcommand)] - command: Command, } #[derive(Subcommand)] @@ -183,6 +176,11 @@ pub async fn execute() -> Result<()> { let command: Vec<_> = std::env::args_os().into_iter().collect(); let args = Opts::parse_from(&command); + let config_file = RusticConfig::new(&args.config_profile)?; + + let mut opts = args.global; + config_file.merge_into("global", &mut opts)?; + if let Command::SelfUpdate(opts) = args.command { self_update::execute(opts).await?; return Ok(()); @@ -194,15 +192,15 @@ pub async fn execute() -> Result<()> { .collect::>() .join(" "); - let verbosity = (1 + args.verbose - args.quiet).clamp(0, 3); + let verbosity = (1 + opts.verbose - opts.quiet).clamp(0, 3); set_verbosity_level(verbosity as usize); - let be = match &args.repository { + let be = match &opts.repository { Some(repo) => ChooseBackend::from_url(repo)?, None => bail!("No repository given. Please use the --repository option."), }; - let be_hot = args + let be_hot = opts .repo_hot .map(|repo| ChooseBackend::from_url(&repo)) .transpose()?; @@ -225,9 +223,9 @@ pub async fn execute() -> Result<()> { let key = get_key( &be, - args.password.as_deref(), - args.password_file.as_deref(), - args.password_command.as_deref(), + opts.password.as_deref(), + opts.password_file.as_deref(), + opts.password_command.as_deref(), ) .await?; ve1!("password is correct."); @@ -239,8 +237,8 @@ pub async fn execute() -> Result<()> { (false, true) => bail!("repo-hot is not a hot repository! Aborting."), _ => {} } - let cache = (!args.no_cache) - .then(|| Cache::new(config.id, args.cache_dir).ok()) + let cache = (!opts.no_cache) + .then(|| Cache::new(config.id, opts.cache_dir).ok()) .flatten(); match &cache { None => v1!("using no cache"), @@ -255,22 +253,22 @@ pub async fn execute() -> Result<()> { }; match cmd { - Command::Backup(opts) => backup::execute(&dbe, opts, config, command).await?, + Command::Backup(opts) => backup::execute(&dbe, opts, config, config_file, command).await?, Command::Config(opts) => config::execute(&dbe, &be_hot, opts, config).await?, Command::Cat(opts) => cat::execute(&dbe, opts).await?, Command::Check(opts) => check::execute(&dbe, &cache, &be_hot, &be, opts).await?, Command::Diff(opts) => diff::execute(&dbe, opts).await?, - Command::Forget(opts) => forget::execute(&dbe, cache, opts, config).await?, + Command::Forget(opts) => forget::execute(&dbe, cache, opts, config, config_file).await?, Command::Init(_) => {} // already handled above Command::Key(opts) => key::execute(&dbe, key, opts).await?, Command::List(opts) => list::execute(&dbe, opts).await?, Command::Ls(opts) => ls::execute(&dbe, opts).await?, Command::SelfUpdate(_) => {} // already handled above - Command::Snapshots(opts) => snapshots::execute(&dbe, opts).await?, + Command::Snapshots(opts) => snapshots::execute(&dbe, opts, config_file).await?, Command::Prune(opts) => prune::execute(&dbe, cache, opts, config, vec![]).await?, Command::Restore(opts) => restore::execute(&dbe, opts).await?, Command::Repoinfo(opts) => repoinfo::execute(&dbe, &be_hot, opts).await?, - Command::Tag(opts) => tag::execute(&dbe, opts).await?, + Command::Tag(opts) => tag::execute(&dbe, opts, config_file).await?, }; Ok(()) diff --git a/src/commands/rustic_config.rs b/src/commands/rustic_config.rs new file mode 100644 index 0000000..399fd4f --- /dev/null +++ b/src/commands/rustic_config.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +use anyhow::Result; +use directories::ProjectDirs; +use merge::Merge; +use serde::Deserialize; +use toml::Value; + +pub struct RusticConfig(Value); + +impl RusticConfig { + pub fn new(profile: &str) -> Result { + let mut path = match ProjectDirs::from("", "", "rustic") { + Some(path) => path.config_dir().to_path_buf(), + None => Path::new(".").to_path_buf(), + }; + if !path.exists() { + path = Path::new(".").to_path_buf(); + }; + let path = path.join(profile.to_string() + ".toml"); + + let config = if path.exists() { + eprintln!("using config {}", path.display()); + let data = std::fs::read_to_string(path)?; + toml::from_str(&data)? + } else { + toml::from_str("")? + }; + + Ok(RusticConfig(config)) + } + + fn get_value(&self, section: &str) -> Option<&Value> { + // loop over subsections separated by '.' + section + .split('.') + .fold(Some(&self.0), |acc, x| acc.and_then(|value| value.get(x))) + } + + pub fn merge_into<'de, Opts>(&self, section: &str, opts: &mut Opts) -> Result<()> + where + Opts: Merge + Deserialize<'de>, + { + if let Some(value) = self.get_value(section) { + let config: Opts = value.clone().try_into()?; + opts.merge(config); + } + Ok(()) + } + + pub fn get<'de, Opts>(&self, section: &str) -> Result + where + Opts: Default + Deserialize<'de>, + { + match self.get_value(section) { + Some(value) => Ok(value.clone().try_into()?), + None => Ok(Opts::default()), + } + } +} diff --git a/src/commands/snapshots.rs b/src/commands/snapshots.rs index 7839735..46987cf 100644 --- a/src/commands/snapshots.rs +++ b/src/commands/snapshots.rs @@ -6,7 +6,7 @@ use humantime::format_duration; use itertools::Itertools; use prettytable::{format, row, Table}; -use super::bytes; +use super::{bytes, RusticConfig}; use crate::backend::DecryptReadBackend; use crate::repo::{ DeleteOption, SnapshotFile, SnapshotFilter, SnapshotGroup, SnapshotGroupCriterion, @@ -39,7 +39,13 @@ pub(super) struct Opts { ids: Vec, } -pub(super) async fn execute(be: &impl DecryptReadBackend, opts: Opts) -> Result<()> { +pub(super) async fn execute( + be: &impl DecryptReadBackend, + mut opts: Opts, + config_file: RusticConfig, +) -> Result<()> { + config_file.merge_into("snapshot-filter", &mut opts.filter)?; + let groups = match &opts.ids[..] { [] => SnapshotFile::group_from_backend(be, &opts.filter, &opts.group_by).await?, [id] if id == "latest" => { diff --git a/src/commands/tag.rs b/src/commands/tag.rs index 8cf8305..c2a1996 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -2,7 +2,7 @@ use anyhow::Result; use chrono::{Duration, Local}; use clap::{AppSettings, Parser}; -use super::progress_counter; +use super::{progress_counter, RusticConfig}; use crate::backend::{DecryptFullBackend, FileType}; use crate::repo::{DeleteOption, SnapshotFile, SnapshotFilter, StringList}; @@ -67,7 +67,13 @@ pub(super) struct Opts { ids: Vec, } -pub(super) async fn execute(be: &impl DecryptFullBackend, opts: Opts) -> Result<()> { +pub(super) async fn execute( + be: &impl DecryptFullBackend, + mut opts: Opts, + config_file: RusticConfig, +) -> Result<()> { + config_file.merge_into("snapshot-filter", &mut opts.filter)?; + let snapshots = match opts.ids.is_empty() { true => SnapshotFile::all_from_backend(be, &opts.filter).await?, false => SnapshotFile::from_ids(be, &opts.ids).await?, diff --git a/src/repo/snapshotfile.rs b/src/repo/snapshotfile.rs index 8b0ad88..c30a03d 100644 --- a/src/repo/snapshotfile.rs +++ b/src/repo/snapshotfile.rs @@ -7,7 +7,9 @@ use clap::Parser; use derivative::Derivative; use futures::{future, TryStreamExt}; use indicatif::ProgressBar; +use merge::Merge; use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; use vlog::*; use super::Id; @@ -244,9 +246,9 @@ impl SnapshotFile { } pub fn matches(&self, filter: &SnapshotFilter) -> bool { - self.paths.matches(&filter.paths) - && self.tags.matches(&filter.tags) - && (filter.hostnames.is_empty() || filter.hostnames.contains(&self.hostname)) + self.paths.matches(&filter.filter_paths) + && self.tags.matches(&filter.filter_tags) + && (filter.filter_host.is_empty() || filter.filter_host.contains(&self.hostname)) } /// Add tag lists to snapshot. return wheter snapshot was changed @@ -308,19 +310,26 @@ impl Ord for SnapshotFile { } } -#[derive(Parser)] +#[serde_as] +#[derive(Default, Parser, Deserialize, Merge)] +#[serde(default, rename_all = "kebab-case")] pub struct SnapshotFilter { /// Path list to filter (can be specified multiple times) - #[clap(long = "filter-paths", value_name = "PATH[,PATH,..]")] - paths: Vec, + #[clap(long, value_name = "PATH[,PATH,..]")] + #[serde_as(as = "Vec")] + #[merge(strategy=merge::vec::overwrite_empty)] + filter_paths: Vec, /// Tag list to filter (can be specified multiple times) - #[clap(long = "filter-tags", value_name = "TAG[,TAG,..]")] - tags: Vec, + #[clap(long, value_name = "TAG[,TAG,..]")] + #[serde_as(as = "Vec")] + #[merge(strategy=merge::vec::overwrite_empty)] + filter_tags: Vec, /// Hostname to filter (can be specified multiple times) - #[clap(long = "filter-host", value_name = "HOSTNAME")] - hostnames: Vec, + #[clap(long, value_name = "HOSTNAME")] + #[merge(strategy=merge::vec::overwrite_empty)] + filter_host: Vec, } #[derive(Default)]