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