1use std::collections::HashMap;
4use std::fmt::Display;
5use std::path::PathBuf;
6use std::{collections::BTreeMap, env};
7
8use crate::{
9 Application, RUSTIC_APP,
10 commands::{init::init, snapshots::fill_table},
11 config::{hooks::Hooks, parse_labels},
12 helpers::{bold_cell, bytes_size_to_string, table},
13 repository::CliRepo,
14 status_err,
15};
16
17use abscissa_core::{Command, Runnable, Shutdown};
18use anyhow::{Context, Result, anyhow, bail};
19use clap::ValueHint;
20use comfy_table::Cell;
21use conflate::{Merge, MergeFrom};
22use log::{debug, error, info, warn};
23use rustic_core::StringList;
24use serde::{Deserialize, Serialize};
25use serde_with::serde_as;
26
27use rustic_core::{
28 BackupOptions, CommandInput, ConfigOptions, IndexedIds, KeyOptions, LocalSourceFilterOptions,
29 LocalSourceSaveOptions, ParentOptions, PathList, ProgressBars, Repository, SnapshotOptions,
30 repofile::SnapshotFile,
31};
32
33#[serde_as]
35#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
36#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
37#[allow(clippy::struct_excessive_bools)]
43pub struct BackupCmd {
44 #[clap(value_name = "SOURCE", value_hint = ValueHint::AnyPath)]
47 #[merge(skip)]
48 #[serde(skip)]
49 cli_sources: Vec<String>,
50
51 #[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 #[clap(long, value_name = "FILENAME", default_value = "stdin", value_hint = ValueHint::FilePath)]
63 #[merge(skip)]
64 stdin_filename: String,
65
66 #[clap(long, value_name = "COMMAND")]
68 #[merge(strategy=conflate::option::overwrite_none)]
69 stdin_command: Option<CommandInput>,
70
71 #[clap(long, value_name = "PATH", value_hint = ValueHint::DirPath)]
73 #[merge(strategy=conflate::option::overwrite_none)]
74 as_path: Option<PathBuf>,
75
76 #[clap(flatten)]
78 #[serde(flatten)]
79 ignore_save_opts: LocalSourceSaveOptions,
80
81 #[clap(long)]
83 #[merge(strategy=conflate::bool::overwrite_false)]
84 pub no_scan: bool,
85
86 #[clap(long)]
88 #[merge(strategy=conflate::bool::overwrite_false)]
89 json: bool,
90
91 #[clap(long, conflicts_with = "json")]
93 #[merge(strategy=conflate::bool::overwrite_false)]
94 long: bool,
95
96 #[clap(long, conflicts_with_all = ["json", "long"])]
98 #[merge(strategy=conflate::bool::overwrite_false)]
99 quiet: bool,
100
101 #[clap(long)]
103 #[merge(strategy=conflate::bool::overwrite_false)]
104 init: bool,
105
106 #[clap(flatten, next_help_heading = "Options for parent processing")]
108 #[serde(flatten)]
109 parent_opts: ParentOptions,
110
111 #[clap(flatten, next_help_heading = "Exclude options")]
113 #[serde(flatten)]
114 ignore_filter_opts: LocalSourceFilterOptions,
115
116 #[clap(flatten, next_help_heading = "Snapshot options")]
118 #[serde(flatten)]
119 snap_opts: SnapshotOptions,
120
121 #[clap(flatten, next_help_heading = "Key options (when using --init)")]
123 #[serde(skip)]
124 #[merge(skip)]
125 key_opts: KeyOptions,
126
127 #[clap(flatten, next_help_heading = "Config options (when using --init)")]
129 #[serde(skip)]
130 #[merge(skip)]
131 config_opts: ConfigOptions,
132
133 #[clap(skip)]
135 hooks: Hooks,
136
137 #[clap(skip)]
139 #[merge(strategy = merge_snapshots)]
140 snapshots: Vec<BackupCmd>,
141
142 #[clap(skip)]
144 #[merge(skip)]
145 sources: Vec<String>,
146
147 #[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 #[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 if !self.sources.is_empty() {
162 return Err("key \"sources\" is not valid in the [backup] section!");
163 }
164
165 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 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
179pub(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: CliRepo) -> Result<()> {
215 let config = RUSTIC_APP.config();
216
217 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(repo.0, &self.key_opts, &self.config_opts)?
226 } else {
227 repo.open()?
228 }
229 .to_indexed_ids()?;
230
231 let hooks = self.hooks(
232 &config.backup.hooks,
233 "backup",
234 itertools::join(&config.backup.sources, ","),
235 );
236
237 hooks.use_with(|| -> Result<_> {
238 let mut is_err = false;
239 for (opts, sources) in self.get_snapshots_to_backup()? {
240 if let Err(err) = opts.backup_snapshot(sources.clone(), &repo) {
241 error!("error backing up {sources}: {err}");
242 is_err = true;
243 }
244 }
245 if is_err {
246 Err(anyhow!("Not all snapshots were generated successfully!"))
247 } else {
248 Ok(())
249 }
250 })
251 }
252
253 fn get_snapshots_to_backup(&self) -> Result<Vec<(Self, PathList)>> {
254 let config = RUSTIC_APP.config();
255 let mut config_snapshots = config
256 .backup
257 .snapshots
258 .iter()
259 .map(|opt| (opt.clone(), PathList::from_iter(&opt.sources)));
260
261 if !self.cli_sources.is_empty() {
262 let sources = PathList::from_iter(&self.cli_sources);
263 let mut opts = self.clone();
264 if let Some((config_opts, _)) = config_snapshots.find(|(_, s)| s == &sources) {
266 info!("merging sources={sources} section from config file");
267 opts.merge(config_opts);
268 }
269 return Ok(vec![(opts, sources)]);
270 }
271
272 let config_snapshots: Vec<_> = config_snapshots
273 .filter(|(opt, _)| {
275 self.cli_name.is_empty()
276 || opt
277 .name
278 .as_ref()
279 .is_some_and(|name| self.cli_name.contains(name))
280 })
281 .map(|(opt, sources)| (self.clone().merge_from(opt), sources))
282 .collect();
283
284 if config_snapshots.is_empty() {
285 bail!("no backup source given.");
286 }
287
288 info!("using backup sources from config file.");
289 Ok(config_snapshots)
290 }
291
292 fn hooks(&self, hooks: &Hooks, action: &str, source: impl Display) -> Hooks {
293 let mut hooks_variables =
294 HashMap::from([("RUSTIC_ACTION".to_string(), action.to_string())]);
295
296 if let Some(label) = &self.snap_opts.label {
297 let _ = hooks_variables.insert("RUSTIC_BACKUP_LABEL".to_string(), label.to_string());
298 }
299
300 let source = source.to_string();
301 if !source.is_empty() {
302 let _ = hooks_variables.insert("RUSTIC_BACKUP_SOURCES".to_string(), source.clone());
303 }
304
305 let mut tags = StringList::default();
306 tags.add_all(self.snap_opts.tags.clone());
307 let tags = tags.to_string();
308 if !tags.is_empty() {
309 let _ = hooks_variables.insert("RUSTIC_BACKUP_TAGS".to_string(), tags);
310 }
311
312 let hooks = if action == "backup" {
313 hooks.with_context("backup")
314 } else {
315 hooks.with_context(&format!("backup {source}"))
316 };
317
318 hooks.with_env(&hooks_variables)
319 }
320
321 fn backup_snapshot<P: ProgressBars, S: IndexedIds>(
322 mut self,
323 source: PathList,
324 repo: &Repository<P, S>,
325 ) -> Result<()> {
326 let config = RUSTIC_APP.config();
327 let snapshot_opts = &config.backup.snapshots;
328 if let Some(path) = &self.as_path {
329 if source.len() > 1 {
331 bail!("as-path only works with a single source!");
332 }
333 if let Some(path) = path.as_os_str().to_str() {
335 if let Some(idx) = snapshot_opts
336 .iter()
337 .position(|opt| opt.sources == vec![path])
338 {
339 info!("merging snapshot=\"{path}\" section from config file");
340 self.merge(snapshot_opts[idx].clone());
341 }
342 }
343 }
344
345 let hooks = self.hooks.clone();
347
348 self.merge(config.backup.clone());
350
351 let hooks = self.hooks(&hooks, "source-specific-backup", &source);
352
353 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 .ignore_filter_opts(self.ignore_filter_opts)
364 .no_scan(self.no_scan)
365 .dry_run(config.global.dry_run);
366
367 let snap = hooks.use_with(|| -> Result<_> {
368 let source = source
369 .clone()
370 .sanitize()
371 .with_context(|| format!("error sanitizing source=s\"{:?}\"", source))?
372 .merge();
373 Ok(repo.backup(&backup_opts, &source, self.snap_opts.to_snapshot()?)?)
374 })?;
375
376 if self.json {
377 let mut stdout = std::io::stdout();
378 serde_json::to_writer_pretty(&mut stdout, &snap)?;
379 } else if self.long {
380 let mut table = table();
381
382 let add_entry = |title: &str, value: String| {
383 _ = table.add_row([bold_cell(title), Cell::new(value)]);
384 };
385 fill_table(&snap, add_entry);
386
387 println!("{table}");
388 } else if !self.quiet {
389 let summary = snap.summary.as_ref().unwrap();
390 info!(
391 "Files: {} new, {} changed, {} unchanged",
392 summary.files_new, summary.files_changed, summary.files_unmodified
393 );
394 info!(
395 "Dirs: {} new, {} changed, {} unchanged",
396 summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified
397 );
398 debug!("Data Blobs: {} new", summary.data_blobs);
399 debug!("Tree Blobs: {} new", summary.tree_blobs);
400 info!(
401 "Added to the repo: {} (raw: {})",
402 bytes_size_to_string(summary.data_added_packed),
403 bytes_size_to_string(summary.data_added)
404 );
405
406 info!(
407 "processed {} files, {}",
408 summary.total_files_processed,
409 bytes_size_to_string(summary.total_bytes_processed)
410 );
411 info!("snapshot {} successfully saved.", snap.id);
412 }
413
414 if config.global.is_metrics_configured() {
415 conflate::btreemap::append_or_ignore(
417 &mut self.metrics_labels,
418 config.global.metrics_labels.clone(),
419 );
420 if let Err(err) = publish_metrics(&snap, self.metrics_job, self.metrics_labels) {
421 warn!("error pushing metrics: {err}");
422 }
423 }
424
425 info!("backup of {source} done.");
426 Ok(())
427 }
428}
429
430#[cfg(not(any(feature = "prometheus", feature = "opentelemetry")))]
431fn publish_metrics(
432 snap: &SnapshotFile,
433 job_name: Option<String>,
434 mut labels: BTreeMap<String, String>,
435) -> Result<()> {
436 Err(anyhow!("metrics support is not compiled-in!"))
437}
438
439#[cfg(any(feature = "prometheus", feature = "opentelemetry"))]
440fn publish_metrics(
441 snap: &SnapshotFile,
442 job_name: Option<String>,
443 mut labels: BTreeMap<String, String>,
444) -> Result<()> {
445 use crate::metrics::MetricValue::*;
446 use crate::metrics::{Metric, MetricsExporter};
447
448 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.");
449 let metrics = [
450 Metric {
451 name: "rustic_backup_time",
452 description: "Timestamp of this snapshot",
453 value: Float(snap.time.timestamp_millis() as f64 / 1000.),
454 },
455 Metric {
456 name: "rustic_backup_files_new",
457 description: "New files compared to the last (i.e. parent) snapshot",
458 value: Int(summary.files_new),
459 },
460 Metric {
461 name: "rustic_backup_files_changed",
462 description: "Changed files compared to the last (i.e. parent) snapshot",
463 value: Int(summary.files_changed),
464 },
465 Metric {
466 name: "rustic_backup_files_unmodified",
467 description: "Unchanged files compared to the last (i.e. parent) snapshot",
468 value: Int(summary.files_unmodified),
469 },
470 Metric {
471 name: "rustic_backup_total_files_processed",
472 description: "Total processed files",
473 value: Int(summary.total_files_processed),
474 },
475 Metric {
476 name: "rustic_backup_total_bytes_processed",
477 description: "Total size of all processed files",
478 value: Int(summary.total_bytes_processed),
479 },
480 Metric {
481 name: "rustic_backup_dirs_new",
482 description: "New directories compared to the last (i.e. parent) snapshot",
483 value: Int(summary.dirs_new),
484 },
485 Metric {
486 name: "rustic_backup_dirs_changed",
487 description: "Changed directories compared to the last (i.e. parent) snapshot",
488 value: Int(summary.dirs_changed),
489 },
490 Metric {
491 name: "rustic_backup_dirs_unmodified",
492 description: "Unchanged directories compared to the last (i.e. parent) snapshot",
493 value: Int(summary.dirs_unmodified),
494 },
495 Metric {
496 name: "rustic_backup_total_dirs_processed",
497 description: "Total processed directories",
498 value: Int(summary.total_dirs_processed),
499 },
500 Metric {
501 name: "rustic_backup_total_dirsize_processed",
502 description: "Total size of all processed dirs",
503 value: Int(summary.total_dirsize_processed),
504 },
505 Metric {
506 name: "rustic_backup_data_blobs",
507 description: "Total number of data blobs added by this snapshot",
508 value: Int(summary.data_blobs),
509 },
510 Metric {
511 name: "rustic_backup_tree_blobs",
512 description: "Total number of tree blobs added by this snapshot",
513 value: Int(summary.tree_blobs),
514 },
515 Metric {
516 name: "rustic_backup_data_added",
517 description: "Total uncompressed bytes added by this snapshot",
518 value: Int(summary.data_added),
519 },
520 Metric {
521 name: "rustic_backup_data_added_packed",
522 description: "Total bytes added to the repository by this snapshot",
523 value: Int(summary.data_added_packed),
524 },
525 Metric {
526 name: "rustic_backup_data_added_files",
527 description: "Total uncompressed bytes (new/changed files) added by this snapshot",
528 value: Int(summary.data_added_files),
529 },
530 Metric {
531 name: "rustic_backup_data_added_files_packed",
532 description: "Total bytes for new/changed files added to the repository by this snapshot",
533 value: Int(summary.data_added_files_packed),
534 },
535 Metric {
536 name: "rustic_backup_data_added_trees",
537 description: "Total uncompressed bytes (new/changed directories) added by this snapshot",
538 value: Int(summary.data_added_trees),
539 },
540 Metric {
541 name: "rustic_backup_data_added_trees_packed",
542 description: "Total bytes (new/changed directories) added to the repository by this snapshot",
543 value: Int(summary.data_added_trees_packed),
544 },
545 Metric {
546 name: "rustic_backup_backup_start",
547 description: "Start time of the backup. This may differ from the snapshot `time`.",
548 value: Float(summary.backup_start.timestamp_millis() as f64 / 1000.),
549 },
550 Metric {
551 name: "rustic_backup_backup_end",
552 description: "The time that the backup has been finished.",
553 value: Float(summary.backup_end.timestamp_millis() as f64 / 1000.),
554 },
555 Metric {
556 name: "rustic_backup_backup_duration",
557 description: "Total duration of the backup in seconds, i.e. the time between `backup_start` and `backup_end`",
558 value: Float(summary.backup_duration),
559 },
560 Metric {
561 name: "rustic_backup_total_duration",
562 description: "Total duration that the rustic command ran in seconds",
563 value: Float(summary.total_duration),
564 },
565 ];
566
567 _ = labels
568 .entry("paths".to_string())
569 .or_insert_with(|| format!("{}", snap.paths));
570 _ = labels
571 .entry("hostname".to_owned())
572 .or_insert_with(|| snap.hostname.clone());
573 _ = labels
574 .entry("snapshot_label".to_string())
575 .or_insert_with(|| snap.label.clone());
576 _ = labels
577 .entry("tags".to_string())
578 .or_insert_with(|| format!("{}", snap.tags));
579
580 let job_name = job_name.as_deref().unwrap_or("rustic_backup");
581 let global_config = &RUSTIC_APP.config().global;
582
583 #[cfg(feature = "prometheus")]
584 if let Some(prometheus_endpoint) = &global_config.prometheus {
585 use crate::metrics::prometheus::PrometheusExporter;
586
587 let metrics_exporter = PrometheusExporter {
588 endpoint: prometheus_endpoint.clone(),
589 job_name: job_name.to_string(),
590 grouping: labels.clone(),
591 prometheus_user: global_config.prometheus_user.clone(),
592 prometheus_pass: global_config.prometheus_pass.clone(),
593 };
594
595 metrics_exporter
596 .push_metrics(metrics.as_slice())
597 .context("pushing prometheus metrics")?;
598 }
599
600 #[cfg(not(feature = "prometheus"))]
601 if global_config.prometheus.is_some() {
602 bail!("prometheus metrics support is not compiled-in!");
603 }
604
605 #[cfg(feature = "opentelemetry")]
606 if let Some(otlp_endpoint) = &global_config.opentelemetry {
607 use crate::metrics::opentelemetry::OpentelemetryExporter;
608
609 let metrics_exporter = OpentelemetryExporter {
610 endpoint: otlp_endpoint.clone(),
611 service_name: job_name.to_string(),
612 labels: global_config.metrics_labels.clone(),
613 };
614
615 metrics_exporter
616 .push_metrics(metrics.as_slice())
617 .context("pushing opentelemetry metrics")?;
618 }
619
620 #[cfg(not(feature = "opentelemetry"))]
621 if global_config.opentelemetry.is_some() {
622 bail!("opentelemetry metrics support is not compiled-in!");
623 }
624
625 Ok(())
626}