From 6ac5290a2893929bd4adb6740e9961c16038073f Mon Sep 17 00:00:00 2001 From: timeshifter Date: Thu, 17 Jul 2025 17:53:32 +0200 Subject: [PATCH] add: version 1 --- .gitignore | 1 + Cargo.lock | 16 +++ Cargo.toml | 7 ++ backup_pool.txt | 1 + backup_uuid.txt | 1 + datasets.txt | 2 + local_pool.txt | 1 + snapshot.txt | 1 + src/cmd/cryptsetup.rs | 15 +++ src/cmd/mod.rs | 43 ++++++++ src/cmd/zfs.rs | 84 ++++++++++++++++ src/io.rs | 15 +++ src/lib.rs | 3 + src/main.rs | 184 +++++++++++++++++++++++++++++++++ src/zfs.rs | 229 ++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 603 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 backup_pool.txt create mode 100644 backup_uuid.txt create mode 100644 datasets.txt create mode 100644 local_pool.txt create mode 100644 snapshot.txt create mode 100644 src/cmd/cryptsetup.rs create mode 100644 src/cmd/mod.rs create mode 100644 src/cmd/zfs.rs create mode 100644 src/io.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/zfs.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..df18e97 --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..29a89d6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "zfs_backup" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.98" diff --git a/backup_pool.txt b/backup_pool.txt new file mode 100644 index 0000000..ec76ec2 --- /dev/null +++ b/backup_pool.txt @@ -0,0 +1 @@ +backup diff --git a/backup_uuid.txt b/backup_uuid.txt new file mode 100644 index 0000000..6af75cd --- /dev/null +++ b/backup_uuid.txt @@ -0,0 +1 @@ +backup.dat diff --git a/datasets.txt b/datasets.txt new file mode 100644 index 0000000..b02def2 --- /dev/null +++ b/datasets.txt @@ -0,0 +1,2 @@ +test +test2 diff --git a/local_pool.txt b/local_pool.txt new file mode 100644 index 0000000..ed841ad --- /dev/null +++ b/local_pool.txt @@ -0,0 +1 @@ +zroot diff --git a/snapshot.txt b/snapshot.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/snapshot.txt @@ -0,0 +1 @@ +test diff --git a/src/cmd/cryptsetup.rs b/src/cmd/cryptsetup.rs new file mode 100644 index 0000000..31e13db --- /dev/null +++ b/src/cmd/cryptsetup.rs @@ -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]) +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000..15f0b03 --- /dev/null +++ b/src/cmd/mod.rs @@ -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 { + 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 { + 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(()) +} diff --git a/src/cmd/zfs.rs b/src/cmd/zfs.rs new file mode 100644 index 0000000..f9de591 --- /dev/null +++ b/src/cmd/zfs.rs @@ -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> { + 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 { + run_command_with_output_pipe("zfs", vec!["send", "-R", qualified_name]) +} + +pub fn zfs_send_incremental(from: &str, to: &str) -> Result { + 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> { + 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) +} diff --git a/src/io.rs b/src/io.rs new file mode 100644 index 0000000..6fc2fc2 --- /dev/null +++ b/src/io.rs @@ -0,0 +1,15 @@ +use std::path::PathBuf; + +use anyhow::Result; + +pub fn read_var_from_file(filename: &str) -> Result { + 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> { + let content = std::fs::read_to_string(PathBuf::from(filename))?; + let s = content.trim(); + Ok(s.lines().map(|s| s.to_owned()).collect()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..84c1de3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod cmd; +pub mod io; +pub mod zfs; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..039bdbc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,184 @@ +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(()) +} + +fn export_backup_pool() -> Result<()> { + let pool_name = &read_var_from_file("backup_pool.txt")?; + zpool_export(pool_name)?; + Ok(()) +} + +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) -> 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) -> Result { + 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> { + 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) + } +} diff --git a/src/zfs.rs b/src/zfs.rs new file mode 100644 index 0000000..26d2f82 --- /dev/null +++ b/src/zfs.rs @@ -0,0 +1,229 @@ +use std::fmt::Write; +use std::{ + fmt::Display, + path::{Path, PathBuf}, + process::Child, +}; + +use anyhow::{Result, anyhow, bail}; + +use crate::cmd::zfs::{ + pool_exists, zfs_list_main_datasets, zfs_list_snapshots, zfs_receive, zfs_send_absolute, + zfs_send_incremental, zpool_import, +}; +use crate::io::read_vars_from_file; + +pub struct Pool { + name: String, + pub datasets: Vec, +} + +impl Pool { + pub fn new(arg: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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> { + 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> { + 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 { + 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> { + 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 { + 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) + } +}