Initial Commit
This commit is contained in:
3
src/actions.rs
Normal file
3
src/actions.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod list;
|
||||
pub mod show_plan;
|
||||
pub mod sync;
|
||||
18
src/actions/list.rs
Normal file
18
src/actions/list.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use crate::{local, planner, remote, Opt};
|
||||
|
||||
pub fn run(opt: &Opt) -> anyhow::Result<()> {
|
||||
info!("listing backup entries");
|
||||
|
||||
let local_list = local::file_list(opt)?;
|
||||
let session = remote::connect(opt)?;
|
||||
let remote_list = remote::file_list(opt, &session)?;
|
||||
|
||||
let presence = planner::presence(&local_list, &remote_list);
|
||||
|
||||
println!("found {} files", presence.len());
|
||||
for (item, location) in presence {
|
||||
println!("- {}: {:?}", item, location);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
27
src/actions/show_plan.rs
Normal file
27
src/actions/show_plan.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use crate::{local, planner, remote, Opt};
|
||||
|
||||
pub fn run(opt: &Opt) -> anyhow::Result<()> {
|
||||
info!("showing backup plan");
|
||||
|
||||
let local_list = local::file_list(opt)?;
|
||||
let session = remote::connect(opt)?;
|
||||
let remote_list = remote::file_list(opt, &session)?;
|
||||
|
||||
let plan = planner::plan(&local_list, &remote_list);
|
||||
let presence = planner::presence(&local_list, &remote_list);
|
||||
|
||||
println!(
|
||||
"found {} out of {} folders that need backup",
|
||||
plan.transfers.len(),
|
||||
presence.len(),
|
||||
);
|
||||
|
||||
if !plan.transfers.is_empty() {
|
||||
println!("plan:");
|
||||
for (item, item_plan) in plan.transfers {
|
||||
println!("- {}: {:?}", item, item_plan);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
104
src/actions/sync.rs
Normal file
104
src/actions/sync.rs
Normal file
@ -0,0 +1,104 @@
|
||||
use crate::local;
|
||||
use crate::planner::{self, TransferKind};
|
||||
use crate::remote;
|
||||
use crate::Opt;
|
||||
use ssh2::Session;
|
||||
use std::io::{self, Read};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
pub fn run(opt: &Opt, sync_all: bool) -> anyhow::Result<()> {
|
||||
// TODO: currently we only sync the latest local files
|
||||
// --all will force a sync of ALL files on local which does not exist on remote
|
||||
if sync_all {
|
||||
error!("backup --all is not yet implemented");
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
info!("generating backup plan");
|
||||
let local_list = local::file_list(opt)?;
|
||||
let session = remote::connect(opt)?;
|
||||
let remote_list = remote::file_list(opt, &session)?;
|
||||
|
||||
let plan = planner::plan(&local_list, &remote_list);
|
||||
|
||||
if plan.transfers.is_empty() {
|
||||
info!("nothing to do");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("performing backup plan");
|
||||
for (item, transfer) in plan.transfers {
|
||||
let parent = match transfer {
|
||||
TransferKind::DeltaFrom(parent) => Some(
|
||||
local_list
|
||||
.get(&parent)
|
||||
.unwrap_or_else(|| &remote_list[&parent])
|
||||
.as_str(),
|
||||
),
|
||||
TransferKind::Full => None,
|
||||
};
|
||||
|
||||
send_snapshot(opt, &session, &local_list[&item], parent)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_snapshot(
|
||||
opt: &Opt,
|
||||
session: &Session,
|
||||
snapshot: &str,
|
||||
parent: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
info!("[{}] transmitting delta", snapshot);
|
||||
|
||||
// spawn btrfs send
|
||||
let mut send = Command::new("btrfs")
|
||||
.arg("send")
|
||||
.args(parent.iter().flat_map(|parent| ["-p", parent]))
|
||||
.arg(snapshot)
|
||||
.current_dir(&opt.path)
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// capture btrfs send output
|
||||
let mut send_stdout = send
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::format_err!("failed to take stdout"))?;
|
||||
|
||||
// start btrfs receive
|
||||
let remote_path = opt
|
||||
.remote
|
||||
.path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::format_err!("path not utf-8"))?;
|
||||
let mut receive = session.channel_session()?;
|
||||
receive.exec(&format!(r#"btrfs receive "{}""#, remote_path,))?;
|
||||
|
||||
// pipe send to receive
|
||||
let num_bytes = io::copy(&mut send_stdout, &mut receive)?;
|
||||
info!("[{}] sent {} bytes", snapshot, num_bytes);
|
||||
|
||||
// wait for send to complete
|
||||
let local_out = send.wait_with_output()?;
|
||||
if !local_out.status.success() {
|
||||
let stderr = std::str::from_utf8(&local_out.stderr)
|
||||
.unwrap_or("failed to parse stderr, not valid utf8");
|
||||
anyhow::bail!("btrfs send failed\nstderr:\n{}", stderr);
|
||||
}
|
||||
|
||||
// wait for receive to complete
|
||||
receive.send_eof()?;
|
||||
let mut remote_err = String::new();
|
||||
receive.stderr().read_to_string(&mut remote_err)?;
|
||||
receive.wait_close()?;
|
||||
let status = receive.exit_status()?;
|
||||
if status != 0 {
|
||||
anyhow::bail!("btrfs receive failed\nstderr:\n{}", remote_err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
0
src/file.rs
Normal file
0
src/file.rs
Normal file
27
src/local.rs
Normal file
27
src/local.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use crate::FileList;
|
||||
use crate::Opt;
|
||||
use chrono::DateTime;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::*;
|
||||
|
||||
pub fn file_list(opt: &Opt) -> anyhow::Result<FileList> {
|
||||
let mut list = BTreeMap::new();
|
||||
|
||||
for entry in read_dir(&opt.path)? {
|
||||
let entry = entry?;
|
||||
|
||||
if !entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = match entry.file_name().into_string() {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let date = DateTime::parse_from_rfc3339(&name)?;
|
||||
list.insert(date, name);
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
71
src/main.rs
Normal file
71
src/main.rs
Normal file
@ -0,0 +1,71 @@
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
mod actions;
|
||||
mod local;
|
||||
mod planner;
|
||||
mod remote;
|
||||
mod snapshot;
|
||||
|
||||
use actions::{list, show_plan, sync};
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use clap::{AppSettings, Clap};
|
||||
use remote::Remote;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub type TimeStamp = DateTime<FixedOffset>;
|
||||
pub type FileList = BTreeMap<TimeStamp, String>;
|
||||
|
||||
/// Backup btrfs snapshots over SSH
|
||||
#[derive(Clap)]
|
||||
#[clap(setting = AppSettings::ColoredHelp)]
|
||||
pub struct Opt {
|
||||
/// The path of the backup directory on the local filesystem
|
||||
#[clap(short = 'l', long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// The ssh remote for the backup (user@host:port:path)
|
||||
#[clap(short, long)]
|
||||
remote: Remote,
|
||||
|
||||
/// The SSH privkey file for the remote
|
||||
#[clap(long)]
|
||||
privkey: PathBuf,
|
||||
|
||||
/// The password for the SSH privkey file
|
||||
#[clap(long)]
|
||||
privkey_pass: Option<String>,
|
||||
|
||||
#[clap(subcommand)]
|
||||
action: Action,
|
||||
}
|
||||
|
||||
#[derive(Clap)]
|
||||
pub enum Action {
|
||||
/// Perform a backup
|
||||
Backup {
|
||||
#[clap(long)]
|
||||
all: bool,
|
||||
},
|
||||
|
||||
/// Generate and show a backup plan
|
||||
ShowPlan,
|
||||
|
||||
/// List all backups, and where they reside
|
||||
List,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let opt = Opt::parse();
|
||||
|
||||
pretty_env_logger::init();
|
||||
|
||||
match opt.action {
|
||||
Action::Backup { all } => sync::run(&opt, all)?,
|
||||
Action::ShowPlan => show_plan::run(&opt)?,
|
||||
Action::List => list::run(&opt)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
81
src/planner.rs
Normal file
81
src/planner.rs
Normal file
@ -0,0 +1,81 @@
|
||||
use crate::{FileList, TimeStamp};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Presence {
|
||||
Local,
|
||||
Remote,
|
||||
LocalAndRemote,
|
||||
}
|
||||
|
||||
/// For every local file that is not in the remote, return a backup plan.
|
||||
pub fn presence(local: &FileList, remote: &FileList) -> BTreeMap<TimeStamp, Presence> {
|
||||
let mut presence = BTreeMap::new();
|
||||
|
||||
for &file in local.keys() {
|
||||
presence.insert(file, Presence::Local);
|
||||
}
|
||||
|
||||
for &file in remote.keys() {
|
||||
let entry = presence.entry(file).or_insert(Presence::Remote);
|
||||
|
||||
if let Presence::Local = *entry {
|
||||
*entry = Presence::LocalAndRemote;
|
||||
}
|
||||
}
|
||||
|
||||
presence
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum TransferKind {
|
||||
Full,
|
||||
DeltaFrom(TimeStamp),
|
||||
}
|
||||
|
||||
pub struct Plan {
|
||||
/// The most recent common file
|
||||
pub last_common: Option<TimeStamp>,
|
||||
|
||||
/// The list of planned transfers
|
||||
pub transfers: BTreeMap<TimeStamp, TransferKind>,
|
||||
}
|
||||
|
||||
/// For every local file that is not in the remote, return a backup plan.
|
||||
///
|
||||
/// The backup plans may depend on each other, so they must be executed in order.
|
||||
pub fn plan(local: &FileList, remote: &FileList) -> Plan {
|
||||
// go through the local files in order, starting with the latest
|
||||
let upload_list: BTreeSet<_> = local
|
||||
.keys()
|
||||
.rev()
|
||||
// keep going while the file doesn't exist in the remote
|
||||
.take_while(|ts| !remote.contains_key(ts))
|
||||
.collect();
|
||||
|
||||
// find the closest parent file of the first planned upload
|
||||
let head_item = upload_list.iter().next().copied();
|
||||
let last_common = head_item
|
||||
.and_then(|&first| local.range(..first).last())
|
||||
.map(|(&entry, _)| entry);
|
||||
let head_item_plan = last_common
|
||||
.map(TransferKind::DeltaFrom)
|
||||
.unwrap_or(TransferKind::Full);
|
||||
|
||||
let tail_items_plan = upload_list
|
||||
.iter()
|
||||
.skip(1)
|
||||
.zip(upload_list.iter())
|
||||
.map(|(&&child, &&parent)| (child, TransferKind::DeltaFrom(parent)));
|
||||
|
||||
let transfers = head_item
|
||||
.iter()
|
||||
.map(|&&head| (head, head_item_plan))
|
||||
.chain(tail_items_plan)
|
||||
.collect();
|
||||
|
||||
Plan {
|
||||
transfers,
|
||||
last_common,
|
||||
}
|
||||
}
|
||||
82
src/remote.rs
Normal file
82
src/remote.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use crate::FileList;
|
||||
use crate::Opt;
|
||||
use chrono::DateTime;
|
||||
use ssh2::Session;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub struct Remote {
|
||||
pub username: String,
|
||||
pub remote: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn connect(opt: &Opt) -> anyhow::Result<Session> {
|
||||
let stream = TcpStream::connect(&opt.remote.remote)?;
|
||||
let mut session = Session::new()?;
|
||||
session.set_tcp_stream(stream);
|
||||
session.handshake()?;
|
||||
info!(
|
||||
r#"connecting to {}@{}"#,
|
||||
opt.remote.username, opt.remote.remote,
|
||||
);
|
||||
session.userauth_pubkey_file(
|
||||
&opt.remote.username,
|
||||
None,
|
||||
&opt.privkey,
|
||||
opt.privkey_pass.as_deref(),
|
||||
)?;
|
||||
if !session.authenticated() {
|
||||
anyhow::bail!("ssh not authenticated");
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn file_list(opt: &Opt, session: &Session) -> anyhow::Result<FileList> {
|
||||
let mut channel = session.channel_session()?;
|
||||
channel.exec(&format!(
|
||||
r#"ls -1N "{}""#,
|
||||
opt.remote
|
||||
.path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::format_err!("path not utf-8"))?
|
||||
))?;
|
||||
let mut output = String::new();
|
||||
channel.read_to_string(&mut output)?;
|
||||
|
||||
let mut list = BTreeMap::new();
|
||||
for file in output.lines() {
|
||||
let date = DateTime::parse_from_rfc3339(file)?;
|
||||
//let path = PathBuf::from(file);
|
||||
list.insert(date, file.to_string());
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
impl FromStr for Remote {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
// user@remote:path
|
||||
// remote = host:port
|
||||
fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> {
|
||||
let (s, path) = s
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| anyhow::format_err!("Missing ...:path"))?;
|
||||
let path = PathBuf::from_str(path)?;
|
||||
|
||||
let (username, remote) = s
|
||||
.split_once('@')
|
||||
.ok_or_else(|| anyhow::format_err!("Missing user@..."))?;
|
||||
|
||||
Ok(Remote {
|
||||
username: username.to_string(),
|
||||
remote: remote.to_string(),
|
||||
path,
|
||||
})
|
||||
}
|
||||
}
|
||||
0
src/snapshot.rs
Normal file
0
src/snapshot.rs
Normal file
Reference in New Issue
Block a user