1use 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#[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(long)]
78 #[merge(strategy=conflate::bool::overwrite_false)]
79 pub no_scan: bool,
80
81 #[clap(long)]
83 #[merge(strategy=conflate::bool::overwrite_false)]
84 json: bool,
85
86 #[clap(long, conflicts_with = "json")]
88 #[merge(strategy=conflate::bool::overwrite_false)]
89 long: bool,
90
91 #[clap(long)]
93 #[merge(strategy=conflate::bool::overwrite_false)]
94 init: bool,
95
96 #[clap(flatten, next_help_heading = "Node modification options")]
98 #[serde(flatten)]
99 ignore_save_opts: LocalSourceSaveOptions,
100
101 #[clap(flatten, next_help_heading = "Options for parent processing")]
103 #[serde(flatten)]
104 parent_opts: ParentOptions,
105
106 #[clap(flatten, next_help_heading = "Exclude options")]
108 #[serde(flatten)]
109 excludes: Excludes,
110
111 #[clap(flatten, next_help_heading = "Exclude options for local source")]
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<Self>,
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: Repo) -> 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(
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 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(|(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 if source.len() > 1 {
332 bail!("as-path only works with a single source!");
333 }
334 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 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 .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 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}