rustic_rs/commands/
backup.rs

1//! `backup` subcommand
2
3use std::path::PathBuf;
4
5use crate::{
6    commands::{init::init, snapshots::fill_table},
7    config::hooks::Hooks,
8    helpers::{bold_cell, bytes_size_to_string, table},
9    repository::CliRepo,
10    status_err, Application, RUSTIC_APP,
11};
12
13use abscissa_core::{Command, Runnable, Shutdown};
14use anyhow::{anyhow, bail, Context, Result};
15use clap::ValueHint;
16use comfy_table::Cell;
17use conflate::Merge;
18use log::{debug, error, info, warn};
19use serde::{Deserialize, Serialize};
20use serde_with::serde_as;
21
22use rustic_core::{
23    BackupOptions, CommandInput, ConfigOptions, IndexedIds, KeyOptions, LocalSourceFilterOptions,
24    LocalSourceSaveOptions, ParentOptions, PathList, ProgressBars, Repository, SnapshotOptions,
25};
26
27/// `backup` subcommand
28#[serde_as]
29#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
30#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
31// Note: using cli_sources, sources and snapshots within this struct is a hack to support serde(deny_unknown_fields)
32// for deserializing the backup options from TOML
33// Unfortunately we cannot work with nested flattened structures, see
34// https://github.com/serde-rs/serde/issues/1547
35// A drawback is that a wrongly set "snapshots = ..." won't get correct error handling and need to be manually checked, see below.
36#[allow(clippy::struct_excessive_bools)]
37pub struct BackupCmd {
38    /// Backup source (can be specified multiple times), use - for stdin. If no source is given, uses all
39    /// sources defined in the config file
40    #[clap(value_name = "SOURCE", value_hint = ValueHint::AnyPath)]
41    #[merge(skip)]
42    #[serde(skip)]
43    cli_sources: Vec<String>,
44
45    /// Set filename to be used when backing up from stdin
46    #[clap(long, value_name = "FILENAME", default_value = "stdin", value_hint = ValueHint::FilePath)]
47    #[merge(skip)]
48    stdin_filename: String,
49
50    /// Start the given command and use its output as stdin
51    #[clap(long, value_name = "COMMAND")]
52    #[merge(strategy=conflate::option::overwrite_none)]
53    stdin_command: Option<CommandInput>,
54
55    /// Manually set backup path in snapshot
56    #[clap(long, value_name = "PATH", value_hint = ValueHint::DirPath)]
57    #[merge(strategy=conflate::option::overwrite_none)]
58    as_path: Option<PathBuf>,
59
60    /// Ignore save options
61    #[clap(flatten)]
62    #[serde(flatten)]
63    ignore_save_opts: LocalSourceSaveOptions,
64
65    /// Don't scan the backup source for its size - this disables ETA estimation for backup.
66    #[clap(long)]
67    #[merge(strategy=conflate::bool::overwrite_false)]
68    pub no_scan: bool,
69
70    /// Output generated snapshot in json format
71    #[clap(long)]
72    #[merge(strategy=conflate::bool::overwrite_false)]
73    json: bool,
74
75    /// Show detailed information about generated snapshot
76    #[clap(long, conflicts_with = "json")]
77    #[merge(strategy=conflate::bool::overwrite_false)]
78    long: bool,
79
80    /// Don't show any output
81    #[clap(long, conflicts_with_all = ["json", "long"])]
82    #[merge(strategy=conflate::bool::overwrite_false)]
83    quiet: bool,
84
85    /// Initialize repository, if it doesn't exist yet
86    #[clap(long)]
87    #[merge(strategy=conflate::bool::overwrite_false)]
88    init: bool,
89
90    /// Parent processing options
91    #[clap(flatten, next_help_heading = "Options for parent processing")]
92    #[serde(flatten)]
93    parent_opts: ParentOptions,
94
95    /// Exclude options
96    #[clap(flatten, next_help_heading = "Exclude options")]
97    #[serde(flatten)]
98    ignore_filter_opts: LocalSourceFilterOptions,
99
100    /// Snapshot options
101    #[clap(flatten, next_help_heading = "Snapshot options")]
102    #[serde(flatten)]
103    snap_opts: SnapshotOptions,
104
105    /// Key options (when using --init)
106    #[clap(flatten, next_help_heading = "Key options (when using --init)")]
107    #[serde(skip)]
108    #[merge(skip)]
109    key_opts: KeyOptions,
110
111    /// Config options (when using --init)
112    #[clap(flatten, next_help_heading = "Config options (when using --init)")]
113    #[serde(skip)]
114    #[merge(skip)]
115    config_opts: ConfigOptions,
116
117    /// Hooks to use
118    #[clap(skip)]
119    hooks: Hooks,
120
121    /// Backup snapshots to generate
122    #[clap(skip)]
123    #[merge(strategy = merge_snapshots)]
124    snapshots: Vec<BackupCmd>,
125
126    /// Backup source, used within config file
127    #[clap(skip)]
128    #[merge(skip)]
129    sources: Vec<String>,
130}
131
132/// Merge backup snapshots to generate
133///
134/// If a snapshot is already defined on left, use that. Else add it.
135///
136/// # Arguments
137///
138/// * `left` - Vector of backup sources
139pub(crate) fn merge_snapshots(left: &mut Vec<BackupCmd>, mut right: Vec<BackupCmd>) {
140    left.append(&mut right);
141    left.sort_by(|opt1, opt2| opt1.sources.cmp(&opt2.sources));
142    left.dedup_by(|opt1, opt2| opt1.sources == opt2.sources);
143}
144
145impl Runnable for BackupCmd {
146    fn run(&self) {
147        let config = RUSTIC_APP.config();
148
149        // manually check for a "source" field, check is not done by serde, see above.
150        if !config.backup.sources.is_empty() {
151            status_err!("key \"sources\" is not valid in the [backup] section!");
152            RUSTIC_APP.shutdown(Shutdown::Crash);
153        }
154
155        let snapshot_opts = &config.backup.snapshots;
156        // manually check for a "sources" field, check is not done by serde, see above.
157        if snapshot_opts.iter().any(|opt| !opt.snapshots.is_empty()) {
158            status_err!("key \"snapshots\" is not valid in a [[backup.snapshots]] section!");
159            RUSTIC_APP.shutdown(Shutdown::Crash);
160        }
161        if let Err(err) = config.repository.run(|repo| self.inner_run(repo)) {
162            status_err!("{}", err);
163            RUSTIC_APP.shutdown(Shutdown::Crash);
164        };
165    }
166}
167
168impl BackupCmd {
169    fn inner_run(&self, repo: CliRepo) -> Result<()> {
170        let config = RUSTIC_APP.config();
171        let snapshot_opts = &config.backup.snapshots;
172
173        // Initialize repository if --init is set and it is not yet initialized
174        let repo = if self.init && repo.config_id()?.is_none() {
175            if config.global.dry_run {
176                bail!(
177                    "cannot initialize repository {} in dry-run mode!",
178                    repo.name
179                );
180            }
181            init(repo.0, &self.key_opts, &self.config_opts)?
182        } else {
183            repo.open()?
184        }
185        .to_indexed_ids()?;
186
187        let hooks = config.backup.hooks.with_context("backup");
188        hooks.use_with(|| -> Result<_> {
189            let config_snapshot_sources: Vec<_> = snapshot_opts
190                .iter()
191                .map(|opt| -> Result<_> {
192                    Ok(PathList::from_iter(&opt.sources)
193                        .sanitize()
194                        .with_context(|| {
195                            format!(
196                                "error sanitizing sources=\"{:?}\" in config file",
197                                opt.sources
198                            )
199                        })?
200                        .merge())
201                })
202                .filter_map(|p| match p {
203                    Ok(paths) => Some(paths),
204                    Err(err) => {
205                        warn!("{err}");
206                        None
207                    }
208                })
209                .collect();
210
211            let snapshot_sources = match (self.cli_sources.is_empty(), snapshot_opts.is_empty()) {
212                (false, _) => {
213                    let item = PathList::from_iter(&self.cli_sources).sanitize()?;
214                    vec![item]
215                }
216                (true, false) => {
217                    info!("using all backup sources from config file.");
218                    config_snapshot_sources.clone()
219                }
220                (true, true) => {
221                    bail!("no backup source given.");
222                }
223            };
224            if snapshot_sources.is_empty() {
225                return Ok(());
226            }
227
228            let mut is_err = false;
229            for sources in snapshot_sources {
230                let mut opts = self.clone();
231
232                // merge Options from config file, if given
233                if let Some(idx) = config_snapshot_sources.iter().position(|s| s == &sources) {
234                    info!("merging sources={sources} section from config file");
235                    opts.merge(snapshot_opts[idx].clone());
236                }
237                if let Err(err) = opts.backup_snapshot(sources.clone(), &repo) {
238                    error!("error backing up {sources}: {err}");
239                    is_err = true;
240                }
241            }
242            if is_err {
243                Err(anyhow!("Not all snapshots were generated successfully!"))
244            } else {
245                Ok(())
246            }
247        })
248    }
249
250    fn backup_snapshot<P: ProgressBars, S: IndexedIds>(
251        mut self,
252        source: PathList,
253        repo: &Repository<P, S>,
254    ) -> Result<()> {
255        let config = RUSTIC_APP.config();
256        let snapshot_opts = &config.backup.snapshots;
257        if let Some(path) = &self.as_path {
258            // as_path only works in combination with a single target
259            if source.len() > 1 {
260                bail!("as-path only works with a single source!");
261            }
262            // merge Options from config file using as_path, if given
263            if let Some(path) = path.as_os_str().to_str() {
264                if let Some(idx) = snapshot_opts
265                    .iter()
266                    .position(|opt| opt.sources == vec![path])
267                {
268                    info!("merging snapshot=\"{path}\" section from config file");
269                    self.merge(snapshot_opts[idx].clone());
270                }
271            }
272        }
273
274        // use the correct source-specific hooks
275        let hooks = self.hooks.with_context(&format!("backup {source}"));
276
277        // merge "backup" section from config file, if given
278        self.merge(config.backup.clone());
279
280        let backup_opts = BackupOptions::default()
281            .stdin_filename(self.stdin_filename)
282            .stdin_command(self.stdin_command)
283            .as_path(self.as_path)
284            .parent_opts(self.parent_opts)
285            .ignore_save_opts(self.ignore_save_opts)
286            .ignore_filter_opts(self.ignore_filter_opts)
287            .no_scan(self.no_scan)
288            .dry_run(config.global.dry_run);
289
290        let snap = hooks.use_with(|| -> Result<_> {
291            Ok(repo.backup(&backup_opts, &source, self.snap_opts.to_snapshot()?)?)
292        })?;
293
294        if self.json {
295            let mut stdout = std::io::stdout();
296            serde_json::to_writer_pretty(&mut stdout, &snap)?;
297        } else if self.long {
298            let mut table = table();
299
300            let add_entry = |title: &str, value: String| {
301                _ = table.add_row([bold_cell(title), Cell::new(value)]);
302            };
303            fill_table(&snap, add_entry);
304
305            println!("{table}");
306        } else if !self.quiet {
307            let summary = snap.summary.unwrap();
308            println!(
309                "Files:       {} new, {} changed, {} unchanged",
310                summary.files_new, summary.files_changed, summary.files_unmodified
311            );
312            println!(
313                "Dirs:        {} new, {} changed, {} unchanged",
314                summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified
315            );
316            debug!("Data Blobs:  {} new", summary.data_blobs);
317            debug!("Tree Blobs:  {} new", summary.tree_blobs);
318            println!(
319                "Added to the repo: {} (raw: {})",
320                bytes_size_to_string(summary.data_added_packed),
321                bytes_size_to_string(summary.data_added)
322            );
323
324            println!(
325                "processed {} files, {}",
326                summary.total_files_processed,
327                bytes_size_to_string(summary.total_bytes_processed)
328            );
329            println!("snapshot {} successfully saved.", snap.id);
330        }
331
332        info!("backup of {source} done.");
333        Ok(())
334    }
335}