Add config file support

This commit is contained in:
Alexander Weiss 2022-09-01 09:42:56 +02:00
parent f602535e4d
commit aaa5b4f4d5
14 changed files with 527 additions and 98 deletions

137
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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)

31
examples/local.toml Normal file
View File

@ -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"

View File

@ -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"

40
examples/rustic.toml Normal file
View File

@ -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

View File

@ -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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<DisplayFromStr>")]
exclude_larger_than: Option<ByteSize>,
}

View File

@ -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<DisplayFromStr>")]
#[merge(strategy = merge::vec::overwrite_empty)]
tag: Vec<StringList>,
/// 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<DisplayFromStr>")]
delete_after: Option<humantime::Duration>,
/// 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<String>,
/// 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<Opts> = 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 {

View File

@ -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<String>,
}
#[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<DisplayFromStr>")]
group_by: Option<SnapshotGroupCriterion>,
#[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<Cache>,
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<DisplayFromStr>")]
#[merge(strategy=merge::vec::overwrite_empty)]
keep_tags: Vec<StringList>,
/// 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<String>,
/// 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
}

View File

@ -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 <PROFILE>.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<String>,
/// 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<String>,
/// 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<String>,
/// 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<PathBuf>,
@ -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<String>,
/// 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<PathBuf>,
#[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::<Vec<_>>()
.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(())

View File

@ -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<Self> {
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<Opts>
where
Opts: Default + Deserialize<'de>,
{
match self.get_value(section) {
Some(value) => Ok(value.clone().try_into()?),
None => Ok(Opts::default()),
}
}
}

View File

@ -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<String>,
}
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" => {

View File

@ -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<String>,
}
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?,

View File

@ -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<StringList>,
#[clap(long, value_name = "PATH[,PATH,..]")]
#[serde_as(as = "Vec<DisplayFromStr>")]
#[merge(strategy=merge::vec::overwrite_empty)]
filter_paths: Vec<StringList>,
/// Tag list to filter (can be specified multiple times)
#[clap(long = "filter-tags", value_name = "TAG[,TAG,..]")]
tags: Vec<StringList>,
#[clap(long, value_name = "TAG[,TAG,..]")]
#[serde_as(as = "Vec<DisplayFromStr>")]
#[merge(strategy=merge::vec::overwrite_empty)]
filter_tags: Vec<StringList>,
/// Hostname to filter (can be specified multiple times)
#[clap(long = "filter-host", value_name = "HOSTNAME")]
hostnames: Vec<String>,
#[clap(long, value_name = "HOSTNAME")]
#[merge(strategy=merge::vec::overwrite_empty)]
filter_host: Vec<String>,
}
#[derive(Default)]