add: version 1
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+16
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "zfs_backup"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.98"
|
||||
@@ -0,0 +1 @@
|
||||
backup
|
||||
@@ -0,0 +1 @@
|
||||
backup.dat
|
||||
@@ -0,0 +1,2 @@
|
||||
test
|
||||
test2
|
||||
@@ -0,0 +1 @@
|
||||
zroot
|
||||
@@ -0,0 +1 @@
|
||||
test
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cmd;
|
||||
pub mod io;
|
||||
pub mod zfs;
|
||||
+184
@@ -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<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)
|
||||
}
|
||||
}
|
||||
+229
@@ -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<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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user