Initial Commit

This commit is contained in:
2021-10-08 00:27:58 +02:00
commit 0104e69657
14 changed files with 1341 additions and 0 deletions

3
src/actions.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod list;
pub mod show_plan;
pub mod sync;

18
src/actions/list.rs Normal file
View 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
View 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
View 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
View File

27
src/local.rs Normal file
View 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
View 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
View 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
View 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
View File