Skip to main content

rustic_rs/commands/
backup.rs

1//! `backup` subcommand
2
3use std::collections::HashMap;
4use std::fmt::Display;
5use std::path::PathBuf;
6use std::{collections::BTreeMap, env};
7
8use crate::repository::IndexedIdsRepo;
9use crate::{
10    Application, RUSTIC_APP,
11    commands::{init::init, snapshots::fill_table},
12    config::{hooks::Hooks, parse_labels},
13    helpers::{bold_cell, bytes_size_to_string, table},
14    repository::Repo,
15    status_err,
16};
17
18use abscissa_core::{Command, Runnable, Shutdown};
19use anyhow::{Context, Result, anyhow, bail};
20use clap::ValueHint;
21use comfy_table::Cell;
22use conflate::{Merge, MergeFrom};
23use log::{debug, error, info, warn};
24use rustic_core::{Excludes, StringList};
25use serde::{Deserialize, Serialize};
26use serde_with::serde_as;
27
28use rustic_core::{
29    BackupOptions, CommandInput, ConfigOptions, KeyOptions, LocalSourceFilterOptions,
30    LocalSourceSaveOptions, ParentOptions, PathList, SnapshotOptions, repofile::SnapshotFile,
31};
32
33/// `backup` subcommand
34#[serde_as]
35#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
36#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
37// Note: using cli_sources, sources and snapshots within this struct is a hack to support serde(deny_unknown_fields)
38// for deserializing the backup options from TOML
39// Unfortunately we cannot work with nested flattened structures, see
40// https://github.com/serde-rs/serde/issues/1547
41// A drawback is that a wrongly set "snapshots = ..." won't get correct error handling and need to be manually checked, see below.
42#[allow(clippy::struct_excessive_bools)]
43pub struct BackupCmd {
44    /// Backup source (can be specified multiple times), use - for stdin. If no source is given, uses all
45    /// sources defined in the config file
46    #[clap(value_name = "SOURCE", value_hint = ValueHint::AnyPath)]
47    #[merge(skip)]
48    #[serde(skip)]
49    cli_sources: Vec<String>,
50
51    /// Backup sources defined in the config profile by the given name (can be specified multiple times)
52    #[clap(long = "name", value_name = "NAME", conflicts_with = "cli_sources")]
53    #[merge(skip)]
54    #[serde(skip)]
55    cli_name: Vec<String>,
56
57    #[clap(skip)]
58    #[merge(skip)]
59    name: Option<String>,
60
61    /// Set filename to be used when backing up from stdin
62    #[clap(long, value_name = "FILENAME", default_value = "stdin", value_hint = ValueHint::FilePath)]
63    #[merge(skip)]
64    stdin_filename: String,
65
66    /// Start the given command and use its output as stdin
67    #[clap(long, value_name = "COMMAND")]
68    #[merge(strategy=conflate::option::overwrite_none)]
69    stdin_command: Option<CommandInput>,
70
71    /// Manually set backup path in snapshot
72    #[clap(long, value_name = "PATH", value_hint = ValueHint::DirPath)]
73    #[merge(strategy=conflate::option::overwrite_none)]
74    as_path: Option<PathBuf>,
75
76    /// Don't scan the backup source for its size - this disables ETA estimation for backup.
77    #[clap(long)]
78    #[merge(strategy=conflate::bool::overwrite_false)]
79    pub no_scan: bool,
80
81    /// Output generated snapshot in json format
82    #[clap(long)]
83    #[merge(strategy=conflate::bool::overwrite_false)]
84    json: bool,
85
86    /// Show detailed information about generated snapshot
87    #[clap(long, conflicts_with = "json")]
88    #[merge(strategy=conflate::bool::overwrite_false)]
89    long: bool,
90
91    /// Initialize repository, if it doesn't exist yet
92    #[clap(long)]
93    #[merge(strategy=conflate::bool::overwrite_false)]
94    init: bool,
95
96    /// Node save options
97    #[clap(flatten, next_help_heading = "Node modification options")]
98    #[serde(flatten)]
99    ignore_save_opts: LocalSourceSaveOptions,
100
101    /// Parent processing options
102    #[clap(flatten, next_help_heading = "Options for parent processing")]
103    #[serde(flatten)]
104    parent_opts: ParentOptions,
105
106    /// Exclude options
107    #[clap(flatten, next_help_heading = "Exclude options")]
108    #[serde(flatten)]
109    excludes: Excludes,
110
111    /// Exclude options for local source
112    #[clap(flatten, next_help_heading = "Exclude options for local source")]
113    #[serde(flatten)]
114    ignore_filter_opts: LocalSourceFilterOptions,
115
116    /// Snapshot options
117    #[clap(flatten, next_help_heading = "Snapshot options")]
118    #[serde(flatten)]
119    snap_opts: SnapshotOptions,
120
121    /// Key options (when using --init)
122    #[clap(flatten, next_help_heading = "Key options (when using --init)")]
123    #[serde(skip)]
124    #[merge(skip)]
125    key_opts: KeyOptions,
126
127    /// Config options (when using --init)
128    #[clap(flatten, next_help_heading = "Config options (when using --init)")]
129    #[serde(skip)]
130    #[merge(skip)]
131    config_opts: ConfigOptions,
132
133    /// Hooks to use
134    #[clap(skip)]
135    hooks: Hooks,
136
137    /// Backup snapshots to generate
138    #[clap(skip)]
139    #[merge(strategy = merge_snapshots)]
140    snapshots: Vec<Self>,
141
142    /// Backup source, used within config file
143    #[clap(skip)]
144    #[merge(skip)]
145    sources: Vec<String>,
146
147    /// Job name for the metrics. Default: rustic-backup
148    #[clap(long, value_name = "JOB_NAME", env = "RUSTIC_METRICS_JOB")]
149    #[merge(strategy=conflate::option::overwrite_none)]
150    pub metrics_job: Option<String>,
151
152    /// Additional labels to set to generated metrics
153    #[clap(long, value_name = "NAME=VALUE", value_parser = parse_labels, default_value = "")]
154    #[merge(strategy=conflate::btreemap::append_or_ignore)]
155    metrics_labels: BTreeMap<String, String>,
156}
157
158impl BackupCmd {
159    fn validate(&self) -> Result<(), &str> {
160        // manually check for a "source" field, check is not done by serde, see above.
161        if !self.sources.is_empty() {
162            return Err("key \"sources\" is not valid in the [backup] section!");
163        }
164
165        // manually check for a "name" field, check is not done by serde, see above.
166        if self.name.is_some() {
167            return Err("key \"name\" is not valid in the [backup] section!");
168        }
169
170        let snapshot_opts = &self.snapshots;
171        // manually check for a "sources" field, check is not done by serde, see above.
172        if snapshot_opts.iter().any(|opt| !opt.snapshots.is_empty()) {
173            return Err("key \"snapshots\" is not valid in a [[backup.snapshots]] section!");
174        }
175        Ok(())
176    }
177}
178
179/// Merge backup snapshots to generate
180///
181/// If a snapshot is already defined on left, use that. Else add it.
182///
183/// # Arguments
184///
185/// * `left` - Vector of backup sources
186pub(crate) fn merge_snapshots(left: &mut Vec<BackupCmd>, mut right: Vec<BackupCmd>) {
187    let order = |opt1: &BackupCmd, opt2: &BackupCmd| {
188        opt1.name
189            .cmp(&opt2.name)
190            .then(opt1.sources.cmp(&opt2.sources))
191    };
192
193    left.append(&mut right);
194    left.sort_by(order);
195    left.dedup_by(|opt1, opt2| order(opt1, opt2).is_eq());
196}
197
198impl Runnable for BackupCmd {
199    fn run(&self) {
200        let config = RUSTIC_APP.config();
201        if let Err(err) = config.backup.validate() {
202            status_err!("{}", err);
203            RUSTIC_APP.shutdown(Shutdown::Crash);
204        }
205
206        if let Err(err) = config.repository.run(|repo| self.inner_run(repo)) {
207            status_err!("{}", err);
208            RUSTIC_APP.shutdown(Shutdown::Crash);
209        };
210    }
211}
212
213impl BackupCmd {
214    fn inner_run(&self, repo: Repo) -> Result<()> {
215        let config = RUSTIC_APP.config();
216
217        // Initialize repository if --init is set and it is not yet initialized
218        let repo = if self.init && repo.config_id()?.is_none() {
219            if config.global.dry_run {
220                bail!(
221                    "cannot initialize repository {} in dry-run mode!",
222                    repo.name
223                );
224            }
225            init(
226                repo,
227                &config.repository.credential_opts,
228                &self.key_opts,
229                &self.config_opts,
230            )?
231        } else {
232            repo.open(&config.repository.credential_opts)?
233        }
234        .to_indexed_ids()?;
235
236        let hooks = self.hooks(
237            &config.backup.hooks,
238            "backup",
239            itertools::join(&config.backup.sources, ","),
240        );
241
242        hooks.use_with(|| -> Result<_> {
243            let mut is_err = false;
244            for (opts, sources) in self.get_snapshots_to_backup()? {
245                if let Err(err) = opts.backup_snapshot(sources.clone(), &repo) {
246                    error!("error backing up {sources}: {err}");
247                    is_err = true;
248                }
249            }
250            if is_err {
251                Err(anyhow!("Not all snapshots were generated successfully!"))
252            } else {
253                Ok(())
254            }
255        })
256    }
257
258    fn get_snapshots_to_backup(&self) -> Result<Vec<(Self, PathList)>> {
259        let config = RUSTIC_APP.config();
260        let mut config_snapshots = config
261            .backup
262            .snapshots
263            .iter()
264            .map(|opt| (opt.clone(), PathList::from_iter(&opt.sources)));
265
266        if !self.cli_sources.is_empty() {
267            let sources = PathList::from_iter(&self.cli_sources);
268            let mut opts = self.clone();
269            // merge Options from config file, if given
270            if let Some((config_opts, _)) = config_snapshots.find(|(_, s)| s == &sources) {
271                info!("merging sources={sources} section from config file");
272                opts.merge(config_opts);
273            }
274            return Ok(vec![(opts, sources)]);
275        }
276
277        let config_snapshots: Vec<_> = config_snapshots
278            // filter out using cli_name, if given
279            .filter(|(opt, _)| {
280                self.cli_name.is_empty()
281                    || opt
282                        .name
283                        .as_ref()
284                        .is_some_and(|name| self.cli_name.contains(name))
285            })
286            .map(|(opt, sources)| (self.clone().merge_from(opt), sources))
287            .collect();
288
289        if config_snapshots.is_empty() {
290            bail!("no backup source given.");
291        }
292
293        info!("using backup sources from config file.");
294        Ok(config_snapshots)
295    }
296
297    fn hooks(&self, hooks: &Hooks, action: &str, source: impl Display) -> Hooks {
298        let mut hooks_variables =
299            HashMap::from([("RUSTIC_ACTION".to_string(), action.to_string())]);
300
301        if let Some(label) = &self.snap_opts.label {
302            let _ = hooks_variables.insert("RUSTIC_BACKUP_LABEL".to_string(), label.to_string());
303        }
304
305        let source = source.to_string();
306        if !source.is_empty() {
307            let _ = hooks_variables.insert("RUSTIC_BACKUP_SOURCES".to_string(), source.clone());
308        }
309
310        let mut tags = StringList::default();
311        tags.add_all(self.snap_opts.tags.clone());
312        let tags = tags.to_string();
313        if !tags.is_empty() {
314            let _ = hooks_variables.insert("RUSTIC_BACKUP_TAGS".to_string(), tags);
315        }
316
317        let hooks = if action == "backup" {
318            hooks.with_context("backup")
319        } else {
320            hooks.with_context(&format!("backup {source}"))
321        };
322
323        hooks.with_env(&hooks_variables)
324    }
325
326    fn backup_snapshot(mut self, source: PathList, repo: &IndexedIdsRepo) -> Result<()> {
327        let config = RUSTIC_APP.config();
328        let snapshot_opts = &config.backup.snapshots;
329        if let Some(path) = &self.as_path {
330            // as_path only works in combination with a single target
331            if source.len() > 1 {
332                bail!("as-path only works with a single source!");
333            }
334            // merge Options from config file using as_path, if given
335            if let Some(path) = path.as_os_str().to_str()
336                && let Some(idx) = snapshot_opts
337                    .iter()
338                    .position(|opt| opt.sources == vec![path])
339            {
340                info!("merging snapshot=\"{path}\" section from config file");
341                self.merge(snapshot_opts[idx].clone());
342            }
343        }
344
345        // use hooks definition before merging "backup" section
346        let hooks = self.hooks.clone();
347
348        // merge "backup" section from config file, if given
349        self.merge(config.backup.clone());
350
351        let hooks = self.hooks(&hooks, "source-specific-backup", &source);
352
353        // use global group-by if not set
354        let mut parent_opts = self.parent_opts;
355        parent_opts.group_by = parent_opts.group_by.or(config.global.group_by);
356
357        let backup_opts = BackupOptions::default()
358            .stdin_filename(self.stdin_filename)
359            .stdin_command(self.stdin_command)
360            .as_path(self.as_path)
361            .parent_opts(parent_opts)
362            .ignore_save_opts(self.ignore_save_opts)
363            .excludes(self.excludes)
364            .ignore_filter_opts(self.ignore_filter_opts)
365            .no_scan(self.no_scan)
366            .dry_run(config.global.dry_run);
367
368        let snap = hooks.use_with(|| -> Result<_> {
369            let source = source
370                .clone()
371                .sanitize()
372                .with_context(|| format!("error sanitizing source=s\"{:?}\"", source))?
373                .merge();
374            Ok(repo.backup(&backup_opts, &source, self.snap_opts.to_snapshot()?)?)
375        })?;
376
377        if self.json {
378            let mut stdout = std::io::stdout();
379            serde_json::to_writer_pretty(&mut stdout, &snap)?;
380        } else if self.long {
381            let mut table = table();
382
383            let add_entry = |title: &str, value: String| {
384                _ = table.add_row([bold_cell(title), Cell::new(value)]);
385            };
386            fill_table(&snap, add_entry);
387
388            println!("{table}");
389        } else {
390            let summary = snap.summary.as_ref().unwrap();
391            info!(
392                "Files:       {} new, {} changed, {} unchanged",
393                summary.files_new, summary.files_changed, summary.files_unmodified
394            );
395            info!(
396                "Dirs:        {} new, {} changed, {} unchanged",
397                summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified
398            );
399            debug!("Data Blobs:  {} new", summary.data_blobs);
400            debug!("Tree Blobs:  {} new", summary.tree_blobs);
401            info!(
402                "Added to the repo: {} (raw: {})",
403                bytes_size_to_string(summary.data_added_packed),
404                bytes_size_to_string(summary.data_added)
405            );
406
407            info!(
408                "processed {} files, {}",
409                summary.total_files_processed,
410                bytes_size_to_string(summary.total_bytes_processed)
411            );
412            info!("snapshot {} successfully saved.", snap.id);
413        }
414
415        if config.global.is_metrics_configured() {
416            // Merge global metrics labels
417            conflate::btreemap::append_or_ignore(
418                &mut self.metrics_labels,
419                config.global.metrics_labels.clone(),
420            );
421            if let Err(err) = publish_metrics(&snap, self.metrics_job, self.metrics_labels) {
422                warn!("error pushing metrics: {err}");
423            }
424        }
425
426        info!("backup of {source} done.");
427        Ok(())
428    }
429}
430
431#[cfg(not(any(feature = "prometheus", feature = "opentelemetry")))]
432fn publish_metrics(
433    snap: &SnapshotFile,
434    job_name: Option<String>,
435    mut labels: BTreeMap<String, String>,
436) -> Result<()> {
437    Err(anyhow!("metrics support is not compiled-in!"))
438}
439
440#[cfg(any(feature = "prometheus", feature = "opentelemetry"))]
441fn publish_metrics(
442    snap: &SnapshotFile,
443    job_name: Option<String>,
444    mut labels: BTreeMap<String, String>,
445) -> Result<()> {
446    use crate::metrics::MetricValue::*;
447    use crate::metrics::{Metric, MetricsExporter};
448
449    let summary = snap.summary.as_ref().expect("Reaching the 'push to prometheus' point should only happen for successful backups, which must have a summary set.");
450    let metrics = [
451        Metric {
452            name: "rustic_backup_time",
453            description: "Timestamp of this snapshot",
454            value: Float(snap.time.timestamp().as_millisecond() as f64 / 1000.),
455        },
456        Metric {
457            name: "rustic_backup_files_new",
458            description: "New files compared to the last (i.e. parent) snapshot",
459            value: Int(summary.files_new),
460        },
461        Metric {
462            name: "rustic_backup_files_changed",
463            description: "Changed files compared to the last (i.e. parent) snapshot",
464            value: Int(summary.files_changed),
465        },
466        Metric {
467            name: "rustic_backup_files_unmodified",
468            description: "Unchanged files compared to the last (i.e. parent) snapshot",
469            value: Int(summary.files_unmodified),
470        },
471        Metric {
472            name: "rustic_backup_total_files_processed",
473            description: "Total processed files",
474            value: Int(summary.total_files_processed),
475        },
476        Metric {
477            name: "rustic_backup_total_bytes_processed",
478            description: "Total size of all processed files",
479            value: Int(summary.total_bytes_processed),
480        },
481        Metric {
482            name: "rustic_backup_dirs_new",
483            description: "New directories compared to the last (i.e. parent) snapshot",
484            value: Int(summary.dirs_new),
485        },
486        Metric {
487            name: "rustic_backup_dirs_changed",
488            description: "Changed directories compared to the last (i.e. parent) snapshot",
489            value: Int(summary.dirs_changed),
490        },
491        Metric {
492            name: "rustic_backup_dirs_unmodified",
493            description: "Unchanged directories compared to the last (i.e. parent) snapshot",
494            value: Int(summary.dirs_unmodified),
495        },
496        Metric {
497            name: "rustic_backup_total_dirs_processed",
498            description: "Total processed directories",
499            value: Int(summary.total_dirs_processed),
500        },
501        Metric {
502            name: "rustic_backup_total_dirsize_processed",
503            description: "Total size of all processed dirs",
504            value: Int(summary.total_dirsize_processed),
505        },
506        Metric {
507            name: "rustic_backup_data_blobs",
508            description: "Total number of data blobs added by this snapshot",
509            value: Int(summary.data_blobs),
510        },
511        Metric {
512            name: "rustic_backup_tree_blobs",
513            description: "Total number of tree blobs added by this snapshot",
514            value: Int(summary.tree_blobs),
515        },
516        Metric {
517            name: "rustic_backup_data_added",
518            description: "Total uncompressed bytes added by this snapshot",
519            value: Int(summary.data_added),
520        },
521        Metric {
522            name: "rustic_backup_data_added_packed",
523            description: "Total bytes added to the repository by this snapshot",
524            value: Int(summary.data_added_packed),
525        },
526        Metric {
527            name: "rustic_backup_data_added_files",
528            description: "Total uncompressed bytes (new/changed files) added by this snapshot",
529            value: Int(summary.data_added_files),
530        },
531        Metric {
532            name: "rustic_backup_data_added_files_packed",
533            description: "Total bytes for new/changed files added to the repository by this snapshot",
534            value: Int(summary.data_added_files_packed),
535        },
536        Metric {
537            name: "rustic_backup_data_added_trees",
538            description: "Total uncompressed bytes (new/changed directories) added by this snapshot",
539            value: Int(summary.data_added_trees),
540        },
541        Metric {
542            name: "rustic_backup_data_added_trees_packed",
543            description: "Total bytes (new/changed directories) added to the repository by this snapshot",
544            value: Int(summary.data_added_trees_packed),
545        },
546        Metric {
547            name: "rustic_backup_backup_start",
548            description: "Start time of the backup. This may differ from the snapshot `time`.",
549            value: Float(summary.backup_start.timestamp().as_millisecond() as f64 / 1000.),
550        },
551        Metric {
552            name: "rustic_backup_backup_end",
553            description: "The time that the backup has been finished.",
554            value: Float(summary.backup_end.timestamp().as_millisecond() as f64 / 1000.),
555        },
556        Metric {
557            name: "rustic_backup_backup_duration",
558            description: "Total duration of the backup in seconds, i.e. the time between `backup_start` and `backup_end`",
559            value: Float(summary.backup_duration),
560        },
561        Metric {
562            name: "rustic_backup_total_duration",
563            description: "Total duration that the rustic command ran in seconds",
564            value: Float(summary.total_duration),
565        },
566    ];
567
568    _ = labels
569        .entry("paths".to_string())
570        .or_insert_with(|| format!("{}", snap.paths));
571    _ = labels
572        .entry("hostname".to_owned())
573        .or_insert_with(|| snap.hostname.clone());
574    _ = labels
575        .entry("snapshot_label".to_string())
576        .or_insert_with(|| snap.label.clone());
577    _ = labels
578        .entry("tags".to_string())
579        .or_insert_with(|| format!("{}", snap.tags));
580
581    let job_name = job_name.as_deref().unwrap_or("rustic_backup");
582    let global_config = &RUSTIC_APP.config().global;
583
584    #[cfg(feature = "prometheus")]
585    if let Some(prometheus_endpoint) = &global_config.prometheus {
586        use crate::metrics::prometheus::PrometheusExporter;
587
588        let metrics_exporter = PrometheusExporter {
589            endpoint: prometheus_endpoint.clone(),
590            job_name: job_name.to_string(),
591            grouping: labels.clone(),
592            prometheus_user: global_config.prometheus_user.clone(),
593            prometheus_pass: global_config.prometheus_pass.clone(),
594        };
595
596        metrics_exporter
597            .push_metrics(metrics.as_slice())
598            .context("pushing prometheus metrics")?;
599    }
600
601    #[cfg(not(feature = "prometheus"))]
602    if global_config.prometheus.is_some() {
603        bail!("prometheus metrics support is not compiled-in!");
604    }
605
606    #[cfg(feature = "opentelemetry")]
607    if let Some(otlp_endpoint) = &global_config.opentelemetry {
608        use crate::metrics::opentelemetry::OpentelemetryExporter;
609
610        let metrics_exporter = OpentelemetryExporter {
611            endpoint: otlp_endpoint.clone(),
612            service_name: job_name.to_string(),
613            labels: global_config.metrics_labels.clone(),
614        };
615
616        metrics_exporter
617            .push_metrics(metrics.as_slice())
618            .context("pushing opentelemetry metrics")?;
619    }
620
621    #[cfg(not(feature = "opentelemetry"))]
622    if global_config.opentelemetry.is_some() {
623        bail!("opentelemetry metrics support is not compiled-in!");
624    }
625
626    Ok(())
627}