btrfs_backup/
lib.rs

1// Copyright (C) 2022-2024 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4#![allow(clippy::let_and_return, clippy::let_unit_value)]
5#![warn(clippy::dbg_macro, clippy::unwrap_used)]
6
7#[macro_use]
8mod redefine;
9
10mod args;
11#[doc(hidden)]
12pub mod btrfs;
13mod ops;
14mod repo;
15#[doc(hidden)]
16pub mod snapshot;
17#[allow(clippy::unwrap_used)]
18#[cfg(any(test, feature = "test"))]
19pub mod test;
20#[doc(hidden)]
21pub mod util;
22
23use std::borrow::Cow;
24use std::ffi::OsString;
25use std::fs::canonicalize;
26use std::path::Path;
27use std::path::PathBuf;
28
29use anyhow::Context as _;
30use anyhow::Error;
31use anyhow::Result;
32
33use clap::error::ErrorKind;
34use clap::Parser as _;
35
36use crate::args::Args;
37use crate::args::Backup;
38use crate::args::Command;
39use crate::args::Purge;
40use crate::args::RemoteCommand;
41use crate::args::Restore;
42use crate::args::Snapshot;
43use crate::args::Tag;
44use crate::btrfs::trace_commands;
45use crate::btrfs::Btrfs;
46use crate::ops::RemoteOps;
47use crate::repo::backup as backup_subvol;
48use crate::repo::purge as purge_subvol;
49use crate::repo::restore as restore_subvol;
50use crate::repo::Repo;
51use crate::util::canonicalize_non_strict;
52
53
54/// A helper function for creating a btrfs repository in the provided
55/// directory, taking care of all error annotations.
56fn create_repo(directory: &Path, remote_command: Option<(String, Vec<String>)>) -> Result<Repo> {
57  let mut builder = Repo::builder();
58
59  if let Some((command, args)) = remote_command {
60    let ops = RemoteOps::new(command.clone(), args.clone());
61    let () = builder.set_file_ops(ops);
62
63    let btrfs = Btrfs::with_command_prefix(command, args);
64    let () = builder.set_btrfs_ops(btrfs);
65  }
66
67  let repo = builder.build(directory).with_context(|| {
68    format!(
69      "failed to create btrfs snapshot repository at {}",
70      directory.display()
71    )
72  })?;
73
74  Ok(repo)
75}
76
77
78/// Perform an operation on a bunch of subvolumes, providing either a
79/// single repository at the provided path or create a new one
80/// co-located to each subvolume.
81fn with_repo_and_subvols<F>(repo_path: Option<&Path>, subvols: &[PathBuf], f: F) -> Result<()>
82where
83  F: FnMut(&Repo, &Path) -> Result<()>,
84{
85  let mut f = f;
86
87  let repo = if let Some(repo_path) = repo_path {
88    Some(create_repo(repo_path, None)?)
89  } else {
90    None
91  };
92
93  let () = subvols.iter().try_for_each::<_, Result<()>>(|subvol| {
94    let repo = if let Some(repo) = &repo {
95      Cow::Borrowed(repo)
96    } else {
97      let directory = subvol.parent().with_context(|| {
98        format!(
99          "subvolume {} does not have a parent; need repository path",
100          subvol.display()
101        )
102      })?;
103      Cow::Owned(create_repo(directory, None)?)
104    };
105
106    f(&repo, subvol)
107  })?;
108
109  Ok(())
110}
111
112
113/// Handler for the `backup` sub-command.
114fn backup(backup: Backup) -> Result<()> {
115  let Backup {
116    mut subvolumes,
117    destination,
118    source,
119    tag: Tag { tag },
120    remote_command: RemoteCommand { remote_command },
121  } = backup;
122
123  let () = subvolumes.iter_mut().try_for_each(|subvol| {
124    *subvol = canonicalize(&*subvol)?;
125    Result::<(), Error>::Ok(())
126  })?;
127
128  let dst = create_repo(&destination, remote_command)?;
129
130  with_repo_and_subvols(source.as_deref(), subvolumes.as_slice(), |src, subvol| {
131    let _snapshot = backup_subvol(src, &dst, subvol, &tag)
132      .with_context(|| format!("failed to backup subvolume {}", subvol.display()))?;
133    Ok(())
134  })
135}
136
137
138/// Handler for the `restore` sub-command.
139fn restore(restore: Restore) -> Result<()> {
140  let Restore {
141    mut subvolumes,
142    destination,
143    source,
144    remote_command: RemoteCommand { remote_command },
145    snapshots_only,
146  } = restore;
147
148  let () = subvolumes.iter_mut().try_for_each(|subvol| {
149    *subvol = canonicalize_non_strict(subvol)?;
150    Result::<(), Error>::Ok(())
151  })?;
152
153  let src = create_repo(&source, remote_command)?;
154
155  with_repo_and_subvols(
156    destination.as_deref(),
157    subvolumes.as_slice(),
158    |dst, subvol| {
159      let _snapshot = restore_subvol(&src, dst, subvol, snapshots_only)
160        .with_context(|| format!("failed to restore subvolume {}", subvol.display()))?;
161      Ok(())
162    },
163  )
164}
165
166
167/// Handler for the `purge` sub-command.
168fn purge(purge: Purge) -> Result<()> {
169  let Purge {
170    mut subvolumes,
171    source,
172    destination,
173    tag: Tag { tag },
174    remote_command: RemoteCommand { remote_command },
175    keep_for,
176  } = purge;
177
178  let () = subvolumes.iter_mut().try_for_each(|subvol| {
179    *subvol = canonicalize_non_strict(subvol)?;
180    Result::<(), Error>::Ok(())
181  })?;
182
183  if let Some(destination) = destination {
184    let repo = create_repo(&destination, remote_command)?;
185
186    let () = subvolumes
187      .iter()
188      .try_for_each(|subvol| purge_subvol(&repo, subvol, &tag, keep_for))?;
189  }
190
191  // TODO: This logic is arguably a bit sub-optimal for the single-repo
192  //       case, because we list snapshots for each subvolume.
193  with_repo_and_subvols(source.as_deref(), subvolumes.as_slice(), |repo, subvol| {
194    purge_subvol(repo, subvol, &tag, keep_for)
195  })
196}
197
198
199/// Handler for the `snapshot` sub-command.
200fn snapshot(snapshot: Snapshot) -> Result<()> {
201  let Snapshot {
202    repository,
203    mut subvolumes,
204    tag: Tag { tag },
205  } = snapshot;
206
207  let () = subvolumes.iter_mut().try_for_each(|subvol| {
208    *subvol = canonicalize(&*subvol)?;
209    Result::<(), Error>::Ok(())
210  })?;
211
212  with_repo_and_subvols(
213    repository.as_deref(),
214    subvolumes.as_slice(),
215    |repo, subvol| {
216      let _snapshot = repo
217        .snapshot(subvol, &tag)
218        .with_context(|| format!("failed to snapshot subvolume {}", subvol.display()))?;
219      Ok(())
220    },
221  )
222}
223
224
225/// Run the program and report errors, if any.
226pub fn run<A, T>(args: A) -> Result<()>
227where
228  A: IntoIterator<Item = T>,
229  T: Into<OsString> + Clone,
230{
231  let args = match Args::try_parse_from(args) {
232    Ok(args) => args,
233    Err(err) => match err.kind() {
234      ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
235        print!("{}", err);
236        return Ok(())
237      },
238      _ => return Err(err.into()),
239    },
240  };
241
242  if args.trace {
243    let () = trace_commands();
244  }
245
246  match args.command {
247    Command::Backup(backup) => self::backup(backup),
248    Command::Purge(purge) => self::purge(purge),
249    Command::Restore(restore) => self::restore(restore),
250    Command::Snapshot(snapshot) => self::snapshot(snapshot),
251  }
252}
253
254
255#[cfg(test)]
256mod tests {
257  use super::*;
258
259  use std::ffi::OsStr;
260
261
262  /// Check that we do not error out on the --version option.
263  #[test]
264  fn version() {
265    let args = [OsStr::new("btrfs-backup"), OsStr::new("--version")];
266    let () = run(args).unwrap();
267  }
268
269  /// Check that we do not error out on the --help option.
270  #[test]
271  fn help() {
272    let args = [OsStr::new("btrfs-backup"), OsStr::new("--help")];
273    let () = run(args).unwrap();
274  }
275}