Merge branch 'master'

This commit is contained in:
timeshifter
2025-07-28 14:55:48 +02:00
15 changed files with 614 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
backup.dat
Generated
+16
View File
@@ -0,0 +1,16 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "zfs_backup"
version = "0.1.0"
dependencies = [
"anyhow",
]
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "zfs_backup"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.98"
+1
View File
@@ -0,0 +1 @@
backup
+1
View File
@@ -0,0 +1 @@
backup.dat
+2
View File
@@ -0,0 +1,2 @@
test
test2
+1
View File
@@ -0,0 +1 @@
zroot
+1
View File
@@ -0,0 +1 @@
test
+15
View File
@@ -0,0 +1,15 @@
use crate::cmd::run_command;
use std::path::Path;
use anyhow::Result;
pub fn open(path: &Path, name: &str) -> Result<()> {
run_command(
"/usr/bin/cryptsetup",
vec!["open", path.to_str().unwrap(), name],
)
}
pub fn close(name: &str) -> Result<()> {
run_command("/usr/bin/cryptsetup", vec!["close", name])
}
+43
View File
@@ -0,0 +1,43 @@
pub mod cryptsetup;
pub mod zfs;
use anyhow::{Result, anyhow, bail};
use std::process::{Child, Command, Output, Stdio};
fn run_command(cmd: &str, args: Vec<&str>) -> Result<()> {
let mut command = Command::new(cmd);
command.args(args);
command.stdout(std::process::Stdio::null());
let status = command.status()?;
if !status.success() {
bail!("error on running command {} {:?}", cmd, command.get_args());
}
Ok(())
}
fn get_output(cmd: &str, args: Vec<&str>) -> Result<Output> {
let mut command = Command::new(cmd);
command.args(args);
let output = command.output()?;
Ok(output)
}
fn run_command_with_output_pipe(cmd: &str, args: Vec<&str>) -> Result<Child> {
let mut command = Command::new(cmd);
command.args(args);
command.stdout(Stdio::piped());
let child = command.spawn()?;
Ok(child)
}
fn run_command_with_input_pipe(cmd: &str, args: Vec<&str>, handle: Child) -> Result<()> {
let mut command = Command::new(cmd);
command.args(args);
command.stdin(Stdio::from(
handle.stdout.ok_or(anyhow!("cannot get stdout"))?,
));
command.output()?;
Ok(())
}
+84
View File
@@ -0,0 +1,84 @@
use std::process::Child;
use anyhow::{Context, Result};
use crate::cmd::{
get_output, run_command, run_command_with_input_pipe, run_command_with_output_pipe,
};
pub fn zpool_import(pool_name: &str) -> Result<()> {
run_command(
"zpool",
vec!["import", "-d", "/dev/mapper", "-N", pool_name],
)
}
pub fn zpool_export(pool_name: &str) -> Result<()> {
run_command("zpool", vec!["export", pool_name])
}
#[rustfmt::skip]
pub fn zfs_list_snapshots(dataset_name: &str) -> Result<Vec<String>> {
let output = get_output(
"zfs",
vec![
"list",
"-t",
"snapshot",
"-H",
"-o",
"name",
dataset_name
],
)?;
let items = String::from_utf8(output.stdout)?;
let result = items
.lines()
.map(|snapshot_raw| {
let after_at = snapshot_raw.split_once('@').unwrap().1;
String::from(after_at)
})
.collect();
Ok(result)
}
pub fn pool_exists(pool: &str) -> bool {
run_command("zfs", vec!["list", pool]).is_ok()
}
pub fn zfs_send_absolute(qualified_name: &str) -> Result<Child> {
run_command_with_output_pipe("zfs", vec!["send", "-R", qualified_name])
}
pub fn zfs_send_incremental(from: &str, to: &str) -> Result<Child> {
run_command_with_output_pipe("zfs", vec!["send", "-R", "-I", from, to])
}
pub fn zfs_receive(qualified_name: &str, child: Child) -> Result<()> {
run_command_with_input_pipe(
"zfs",
vec![
"receive",
"-d", // discard first element of sent snapshot's filename
"-F", // rollback to most recent snapshot
"-u", // do not mount received filesystem
qualified_name,
],
child,
)?;
Ok(())
}
pub fn zfs_list_main_datasets(name: &str) -> Result<Vec<String>> {
let output = get_output(
"zfs",
vec!["list", "-H", "-o", "name", "-r", "-d", "1", name],
)
.context("running zfs list ...")?;
let stdout = String::from_utf8(output.stdout).context("converting to String")?;
let result = stdout.lines().map(String::from).collect();
Ok(result)
}
+15
View File
@@ -0,0 +1,15 @@
use std::path::PathBuf;
use anyhow::Result;
pub fn read_var_from_file(filename: &str) -> Result<String> {
let content = std::fs::read_to_string(PathBuf::from(filename))?;
let s = content.trim();
Ok(s.trim().into())
}
pub fn read_vars_from_file(filename: &str) -> Result<Vec<String>> {
let content = std::fs::read_to_string(PathBuf::from(filename))?;
let s = content.trim();
Ok(s.lines().map(|s| s.to_owned()).collect())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod cmd;
pub mod io;
pub mod zfs;
+196
View File
@@ -0,0 +1,196 @@
use std::env;
use std::io::stdin;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use zfs_backup::cmd::cryptsetup;
use zfs_backup::cmd::zfs::{zpool_export, zpool_import};
use zfs_backup::io::read_var_from_file;
use zfs_backup::zfs::{Dataset, Pool, Snapshot};
fn main() -> Result<()> {
verify_running_as_root()?;
decrypt_backup()?;
import_backup_pool()?;
do_backup()?;
export_backup_pool()?;
encrypt_backup()?;
println!("all done");
Ok(())
}
/// Export the ZFS pool after the backup so it can be re-encrypted.
///
/// # Errors
///
/// This function will return an error if the name of the backup pool is not provided or the pool
/// cannot be exported.
fn export_backup_pool() -> Result<()> {
let pool_name = &read_var_from_file("backup_pool.txt")?;
zpool_export(pool_name)?;
Ok(())
}
/// Import the decrypted ZFS pool so that a backup can be sent.
///
/// # Errors
///
/// This function will return an error if the name of the backup pool is not provided or the pool
/// cannot be imported.
fn import_backup_pool() -> Result<()> {
let pool_name = &read_var_from_file("backup_pool.txt")?;
zpool_import(pool_name)?;
Ok(())
}
fn do_backup() -> Result<()> {
let (local, remote) =
load_zfs_pools("local_pool.txt", "backup_pool.txt").context("loading zfs pools")?;
let snapshot_tag = read_var_from_file("snapshot.txt").context("fetching snapshot tag")?;
let todos = Todo::plan(&local, &remote, snapshot_tag).context("planning todos")?;
if ask_user_confirmation(&todos).context("asking user confirmation")? {
println!("executing:");
execute_todos(local, remote, todos)?;
}
Ok(())
}
fn execute_todos(local: Pool, remote: Pool, todos: Vec<Todo>) -> Result<()> {
for todo in todos {
todo.print();
match todo {
Todo::Absolute(dataset, snapshot) => {
let handle = local.send_absolute(&dataset, &snapshot)?;
remote.receive(handle)?;
}
Todo::Incremental(dataset, common_snapshot, recent_snapshot) => {
let handle =
local.send_incremental(&dataset, &common_snapshot, &recent_snapshot)?;
remote.receive(handle)?;
}
Todo::UpToDate(_, _) => (),
}
}
Ok(())
}
fn ask_user_confirmation(todos: &Vec<Todo>) -> Result<bool> {
println!("Would to the following:");
for todo in todos {
todo.print();
}
println!("Continue? [y/N] ");
let mut input = String::new();
let stdin = stdin();
stdin.read_line(&mut input).context("reading input line")?;
input = input.to_lowercase();
if input.starts_with('y') {
return Ok(true);
}
Ok(false)
}
fn load_zfs_pools(local_pool: &str, backup_pool: &str) -> Result<(Pool, Pool)> {
let mut local =
Pool::new(&read_var_from_file(local_pool).context("getting local pool from config")?)
.context("finding local pool")?;
local
.load_datasets_from_file("datasets.txt")
.context("loading datasets to send")?;
let mut remote =
Pool::new(&read_var_from_file(backup_pool).context("getting backup pool from config")?)
.context("finding backup pool")?;
remote
.get_datasets_from_system()
.context("loading remote datasets")?;
Ok((local, remote))
}
fn decrypt_backup() -> Result<()> {
// let mut uuid = "/dev/disk/by-uuid/".to_string(); // TODO change back
let mut pathbuf = PathBuf::from("/home/timeshifter/rust/zfs_backup/");
pathbuf.push(&read_var_from_file("backup_uuid.txt")?);
cryptsetup::open(&pathbuf, "crypt-backup")
}
fn encrypt_backup() -> Result<()> {
cryptsetup::close("crypt-backup")
}
fn verify_running_as_root() -> Result<()> {
let uid = env::var("USER")?;
if uid != "root" {
bail!("must be run as root")
}
Ok(())
}
enum Todo {
Absolute(Dataset, Snapshot),
Incremental(Dataset, Snapshot, Snapshot),
UpToDate(Dataset, Snapshot),
}
impl Todo {
fn print(&self) {
match self {
Todo::Absolute(dataset, snapshot) => {
println!(" {dataset}@{snapshot} -> [new]")
}
Todo::Incremental(dataset, last_common_snapshot, recent_snapshot) => {
println!(" {dataset}@{last_common_snapshot} -> ...@{recent_snapshot}")
}
Todo::UpToDate(dataset, snapshot) => {
println!(" {dataset}@{snapshot} is already backed up")
}
}
}
fn plan(local: &Pool, remote: &Pool, snapshot_tag: String) -> Result<Vec<Self>> {
let mut result = vec![];
for local_dataset in &local.datasets {
let last_snapshot_with_tag =
local_dataset.find_last_snapshot_with_tag(&snapshot_tag)?;
let remote_dataset = local_dataset.change_pool_name(remote);
if !remote.contains(&remote_dataset) {
result.push(Todo::Absolute(
local_dataset.clone(),
last_snapshot_with_tag,
));
continue;
}
let remote_dataset = remote.get_matching_dataset(local_dataset).unwrap();
let last_common_snapshot = Snapshot::find_last_common_snapshot_with_tag(
local_dataset,
&remote_dataset,
&snapshot_tag,
)
.unwrap();
let todo = if last_common_snapshot == last_snapshot_with_tag {
Todo::UpToDate(local_dataset.clone(), last_common_snapshot)
} else {
Todo::Incremental(
local_dataset.clone(),
last_common_snapshot,
last_snapshot_with_tag,
)
};
result.push(todo);
}
Ok(result)
}
}
+227
View File
@@ -0,0 +1,227 @@
use std::fmt::Write;
use std::{
fmt::Display,
path::{Path, PathBuf},
process::Child,
};
use anyhow::{Result, anyhow, bail};
use crate::cmd::zfs::*;
use crate::io::read_vars_from_file;
/// A ZFS pool. It holds the name of the pool and the list of datasets belonging to it.
pub struct Pool {
name: String,
pub datasets: Vec<Dataset>,
}
impl Pool {
pub fn new(arg: &str) -> Result<Self> {
let result = Self {
name: arg.to_string(),
datasets: Vec::new(),
};
if !result.exists() {
bail!("pool does not exist")
}
Ok(result)
}
fn exists(&self) -> bool {
pool_exists(&self.name)
}
pub fn send_absolute(&self, dataset_to_send: &Dataset, snapshot: &Snapshot) -> Result<Child> {
assert!(self.datasets.contains(dataset_to_send));
let mut qualified_name = dataset_to_send.0.to_str().unwrap().to_string();
qualified_name.push('@');
qualified_name.push_str(&snapshot.0);
let handle = zfs_send_absolute(&qualified_name)?;
Ok(handle)
}
pub fn send_incremental(
&self,
dataset_to_send: &Dataset,
common_snapshot: &Snapshot,
recent_snapshot: &Snapshot,
) -> Result<Child> {
let mut from = String::new();
let mut to = String::new();
write!(from, "@{common_snapshot}")?;
write!(to, "{dataset_to_send}@{recent_snapshot}")?;
zfs_send_incremental(&from, &to)
}
pub fn receive(&self, handle: Child) -> Result<()> {
zfs_receive(&self.name, handle)?;
Ok(())
}
pub fn load_datasets_from_file(&mut self, filename: &str) -> Result<()> {
self.datasets = Dataset::from_file(&PathBuf::from(filename), &self.name)?;
Ok(())
}
pub fn get_datasets_from_system(&mut self) -> Result<()> {
let datasets = zfs_list_main_datasets(&self.name)?;
self.datasets = datasets
.iter()
.map(PathBuf::from)
.map(Dataset::new)
.skip(1)
.collect(); // first one is the Pool
Ok(())
}
pub fn contains(&self, dataset: &Dataset) -> bool {
self.datasets.contains(dataset)
}
pub fn get_matching_dataset(&self, dataset_from_other_pool: &Dataset) -> Option<Dataset> {
let mut required_name = PathBuf::new();
required_name.push(&self.name);
dataset_from_other_pool
.0
.iter()
.skip(1)
.for_each(|element| required_name.push(element));
let matching_dataset = Dataset::new(required_name);
if self.contains(&matching_dataset) {
Some(matching_dataset)
} else {
None
}
}
}
pub fn import(name: &str) -> Result<Pool> {
zpool_import(name)?;
let result = Pool::new(name)?;
Ok(result)
}
/// Full path of a ZFS dataset.
///
/// E.g. if the name of the pool is `tank`, with the elements `ROOT` and `default` below it, this
/// struct would contain `tank/ROOT/default`.
#[derive(Debug, PartialEq, Clone)]
pub struct Dataset(PathBuf);
impl Dataset {
fn new(path: PathBuf) -> Self {
Self(path)
}
fn from_file(filename: &Path, pool: &str) -> Result<Vec<Self>> {
let datasets = read_vars_from_file(filename.to_str().unwrap())?;
Ok(datasets
.into_iter()
.map(|ds| {
let mut pb = PathBuf::new();
pb.push(pool);
pb.push(ds);
Self::new(pb)
})
.collect())
}
pub fn find_snapshots_like_tag(&self, snapshot_tag: &str) -> Result<Vec<Snapshot>> {
let snapshots = zfs_list_snapshots(self.0.to_str().unwrap())?;
let mut result = vec![];
for item in snapshots {
let snapshot_name = item.split('@').next_back().unwrap();
if snapshot_name.contains(snapshot_tag) {
result.push(Snapshot(snapshot_name.to_string()));
}
}
Ok(result)
}
pub fn find_last_snapshot_with_tag(&self, snapshot_tag: &str) -> Result<Snapshot> {
let mut snapshots = self.find_snapshots_like_tag(snapshot_tag)?;
snapshots.sort_unstable();
let result = snapshots
.last()
.cloned()
.ok_or(anyhow!("could not find any snapshots"))?;
Ok(result)
}
fn get_snapshots(&self) -> Result<Vec<Snapshot>> {
let snapshots_string = zfs_list_snapshots(self.0.to_str().unwrap())?;
let snapshots = snapshots_string
.into_iter()
// TODO should snapshot just take a string?
.map(|s| Snapshot::new(&s))
.collect();
Ok(snapshots)
}
pub fn change_pool_name(&self, new_pool: &Pool) -> Self {
let new_pool = new_pool.name.as_str();
let mut pathbuf = PathBuf::new();
pathbuf.push(new_pool);
self.0
.iter()
.skip(1)
.for_each(|element| pathbuf.push(element));
Self(pathbuf)
}
}
impl Display for Dataset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.to_str().unwrap())
}
}
#[derive(Debug, Clone, PartialEq, PartialOrd, Ord, Eq)]
/// A ZFS snapshot.
///
/// If the absolute ZFS path were "zpool/dataset@snapshot", this would contain "snapshot".
pub struct Snapshot(String);
impl Snapshot {
fn new(name: &str) -> Self {
Self(name.to_string())
}
pub fn find_last_common_snapshot_with_tag(
ds1: &Dataset,
ds2: &Dataset,
tag: &str,
) -> Option<Self> {
let sn1 = ds1.get_snapshots().ok()?;
let sn2 = ds2.get_snapshots().ok()?;
let mut common = vec![];
for snapshot in sn1 {
if !snapshot.0.contains(tag) {
continue;
}
if sn2.contains(&snapshot) {
common.push(snapshot);
}
}
common.sort();
common.last().cloned()
}
}
impl Display for Snapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}