pub mod btrfs;
#[cfg(target_os = "linux")]
pub mod external;
pub mod zfs;
use crate::{BtmCfg, STEP_CNT, SnapAlgo};
use ruc::{cmd::exec, *};
pub(crate) trait SnapDriver {
fn list_snapshots_cmd(cfg: &BtmCfg) -> String;
fn parse_snapshot_line(cfg: &BtmCfg, line: &str) -> Option<u64>;
fn create_snapshot_cmd(volume: &str, idx: u64) -> String;
fn rollback_cmd(volume: &str, idx: u64) -> String;
fn destroy_cmd(volume: &str, idx: u64) -> String;
fn check_volume_cmd(volume: &str) -> String;
fn destroy_snapshots(volume: &str, indexes: &[u64]) -> Result<()> {
let mut failed = 0usize;
for idx in indexes {
if info!(exec(&Self::destroy_cmd(volume, *idx))).is_err() {
failed += 1;
}
}
if failed == 0 {
Ok(())
} else {
Err(eg!(
"{} of {} snapshot deletions failed",
failed,
indexes.len()
))
}
}
}
pub(crate) fn parse_exact_snapshot(volume: &str, line: &str) -> Option<u64> {
let idx = line.trim().strip_prefix(volume)?.strip_prefix('@')?;
if idx.is_empty() || !idx.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
idx.parse().ok()
}
const DESTROY_BATCH_LIMIT: usize = 64;
pub(crate) fn destroy_batched<D: SnapDriver>(
volume: &str,
indexes: &[u64],
batch_cmd: fn(&str, &[u64]) -> String,
) -> Result<()> {
let mut failed = 0usize;
for chunk in indexes.chunks(DESTROY_BATCH_LIMIT) {
if info!(exec(&batch_cmd(volume, chunk))).is_ok() {
continue;
}
for idx in chunk {
if info!(exec(&D::destroy_cmd(volume, *idx))).is_err() {
failed += 1;
}
}
}
if failed == 0 {
Ok(())
} else {
Err(eg!(
"{} of {} snapshot deletions failed",
failed,
indexes.len()
))
}
}
#[inline(always)]
fn idx_aligned(idx: u64, itv: u64) -> bool {
(u64::MAX - idx).is_multiple_of(itv)
}
pub(crate) fn gen_snapshot<D: SnapDriver>(cfg: &BtmCfg, idx: u64) -> Result<()> {
cfg.validate_params().c(d!())?;
if !idx_aligned(idx, cfg.itv) {
return Ok(());
}
let snaps = sorted_snapshots::<D>(cfg).c(d!())?;
if snaps.contains(&idx) {
return Err(eg!("Snapshot {} already exists!", idx));
}
clean_outdated::<D>(cfg, &snaps).c(d!())?;
let cmd = D::create_snapshot_cmd(&cfg.volume, idx);
exec(&cmd).c(d!()).map(|_| ())
}
pub(crate) fn sorted_snapshots<D: SnapDriver>(cfg: &BtmCfg) -> Result<Vec<u64>> {
let cmd = D::list_snapshots_cmd(cfg);
let output = exec(&cmd).c(d!())?;
let mut res = output
.lines()
.filter_map(|l| D::parse_snapshot_line(cfg, l))
.collect::<Vec<u64>>();
res.sort_unstable_by(|a, b| b.cmp(a));
res.dedup();
Ok(res)
}
pub(crate) fn rollback_target(snaps_asc: &[u64], idx: Option<u64>, strict: bool) -> Result<u64> {
let last = match snaps_asc.last() {
Some(l) => *l,
None => return Err(eg!("no snapshots")),
};
let idx = match idx {
Some(i) => i,
None => return Ok(last),
};
match snaps_asc.binary_search(&idx) {
Ok(_) => Ok(idx),
Err(_) if strict => Err(eg!("specified height does not exist")),
Err(0) => Err(eg!("all snapshots are newer than the requested height")),
Err(i) => Ok(snaps_asc[i - 1]),
}
}
pub(crate) fn rollback<D: SnapDriver>(cfg: &BtmCfg, idx: Option<i128>, strict: bool) -> Result<()> {
let mut snaps = sorted_snapshots::<D>(cfg).c(d!())?;
snaps.reverse();
let idx = match idx {
Some(i) => Some(u64::try_from(i).c(d!("snapshot index must be non-negative"))?),
None => None,
};
let target = rollback_target(&snaps, idx, strict).c(d!())?;
let cmd = D::rollback_cmd(&cfg.volume, target);
exec(&cmd).c(d!()).map(|_| ())
}
pub(crate) fn check<D: SnapDriver>(volume: &str) -> Result<()> {
let cmd = D::check_volume_cmd(volume);
exec(&cmd).c(d!()).map(|_| ())
}
pub(crate) fn clean_all<D: SnapDriver>(cfg: &BtmCfg, kept: usize) -> Result<()> {
let snaps = sorted_snapshots::<D>(cfg).c(d!())?;
if kept < snaps.len() {
D::destroy_snapshots(&cfg.volume, &snaps[kept..]).c(d!())?;
}
Ok(())
}
#[inline(always)]
fn clean_outdated<D: SnapDriver>(cfg: &BtmCfg, snaps_desc: &[u64]) -> Result<()> {
match cfg.algo {
SnapAlgo::Fair => {
let to_del = fair_to_delete(snaps_desc, cfg.get_cap() as usize);
if !to_del.is_empty() {
info_omit!(D::destroy_snapshots(&cfg.volume, to_del));
}
}
SnapAlgo::Fade => {
let to_del = fade_to_delete(snaps_desc, cfg.itv, cfg.get_cap() as usize);
if !to_del.is_empty() {
info_omit!(D::destroy_snapshots(&cfg.volume, &to_del));
}
}
}
Ok(())
}
pub(crate) fn fair_to_delete(snaps_desc: &[u64], cap: usize) -> &[u64] {
if cap < snaps_desc.len() {
&snaps_desc[cap..]
} else {
&[]
}
}
pub(crate) fn fade_to_delete(snaps_desc: &[u64], itv: u64, cap: usize) -> Vec<u64> {
let chunk_size = cap / STEP_CNT;
if 1 + chunk_size > snaps_desc.len() {
return vec![];
}
let mut to_del = vec![];
let mut pair: (&[u64], &[u64]) = (&snaps_desc[..0], snaps_desc);
for denominator in (0..STEP_CNT as u32).map(|n| itv.pow(1 + n)) {
pair = if chunk_size < pair.1.len() {
pair.1.split_at(chunk_size)
} else {
(pair.1, &[])
};
pair.0.iter().for_each(|n| {
if !idx_aligned(*n, denominator) {
to_del.push(*n);
}
});
}
to_del.extend_from_slice(fair_to_delete(snaps_desc, cap));
to_del
}
#[cfg(test)]
mod tests {
use super::btrfs::Btrfs;
use super::zfs::Zfs;
use super::*;
use crate::SnapMode;
fn cfg_with_volume(volume: &str, mode: SnapMode) -> BtmCfg {
BtmCfg {
itv: 1,
cap: 100,
cap_clean_kept: 0,
mode,
algo: SnapAlgo::Fair,
volume: volume.to_owned(),
}
}
#[test]
fn idx_alignment() {
assert!(idx_aligned(u64::MAX, 10));
assert!(idx_aligned(u64::MAX - 10, 10));
assert!(!idx_aligned(u64::MAX - 5, 10));
(0..100u64).for_each(|i| assert!(idx_aligned(i, 1)));
}
#[test]
fn rollback_target_selection() {
let snaps = [10u64, 20, 30];
assert!(rollback_target(&[], None, false).is_err());
assert_eq!(30, rollback_target(&snaps, None, true).unwrap());
assert_eq!(20, rollback_target(&snaps, Some(20), true).unwrap());
assert_eq!(20, rollback_target(&snaps, Some(25), false).unwrap());
assert_eq!(30, rollback_target(&snaps, Some(99), false).unwrap());
assert!(rollback_target(&snaps, Some(25), true).is_err());
assert!(rollback_target(&snaps, Some(5), false).is_err());
}
#[test]
fn fair_selection() {
let snaps = [50u64, 40, 30, 20, 10];
assert!(fair_to_delete(&snaps, 5).is_empty());
assert!(fair_to_delete(&snaps, 9).is_empty());
assert_eq!([20u64, 10].as_slice(), fair_to_delete(&snaps, 3));
assert_eq!(snaps.as_slice(), fair_to_delete(&snaps, 0));
}
#[test]
fn fade_selection() {
let snaps: Vec<u64> = (0..=21u64).rev().collect();
let mut to_del = fade_to_delete(&snaps, 2, 20);
to_del.sort_unstable();
let expected: Vec<u64> = (0..=20u64).filter(|n| ![15, 19].contains(n)).collect();
assert_eq!(expected, to_del);
assert!(fade_to_delete(&[5, 4], 2, 20).is_empty());
let mut to_del = fade_to_delete(&snaps, 1, 20);
to_del.sort_unstable();
assert_eq!(vec![0u64, 1], to_del);
}
#[test]
fn zfs_parse_snapshot_line() {
let cfg = cfg_with_volume("tank/igp24", SnapMode::Zfs);
let parse = |l| Zfs::parse_snapshot_line(&cfg, l);
assert_eq!(Some(449), parse("tank/igp24@449"));
assert_eq!(Some(0), parse("tank/igp24@0"));
assert_eq!(None, parse("tank/igp24@20260706-predeploy"));
assert_eq!(None, parse("tank/igp24@backup"));
assert_eq!(None, parse("tank/igp24/child@449"));
assert_eq!(None, parse("tank/igp24@+449"));
assert_eq!(None, parse("tank/igp24@99999999999999999999999999"));
assert_eq!(None, parse("tank/igp24@"));
assert_eq!(None, parse(""));
}
#[test]
fn btrfs_parse_snapshot_line() {
let cfg = cfg_with_volume("/btrfs/data", SnapMode::Btrfs);
let parse = |l| Btrfs::parse_snapshot_line(&cfg, l);
assert_eq!(Some(123), parse("/btrfs/data@123"));
assert_eq!(None, parse("/btrfs/stuff/data@77"));
assert_eq!(None, parse("/other/data@123"));
assert_eq!(None, parse("/btrfs/other@123"));
assert_eq!(None, parse("/btrfs/data/child@449"));
assert_eq!(None, parse("/btrfs/data@2026-predeploy"));
assert_eq!(None, parse("/btrfs/data@+449"));
assert_eq!(None, parse("/btrfs/data@99999999999999999999999999"));
assert_eq!(None, parse("/btrfs/data@"));
assert_eq!(None, parse("garbage"));
assert_eq!(None, parse(""));
}
#[test]
fn command_strings() {
let cfg = cfg_with_volume("tank/data", SnapMode::Zfs);
assert_eq!(
"zfs list -H -t snapshot -d 1 -o name tank/data",
Zfs::list_snapshots_cmd(&cfg)
);
assert_eq!(
"zfs snapshot tank/data@7",
Zfs::create_snapshot_cmd("tank/data", 7)
);
assert_eq!(
"zfs rollback -r tank/data@7",
Zfs::rollback_cmd("tank/data", 7)
);
assert_eq!("zfs destroy tank/data@7", Zfs::destroy_cmd("tank/data", 7));
assert!(!Zfs::check_volume_cmd("tank/data").contains("create"));
assert_eq!(
"zfs destroy tank/data@3,2,1",
super::zfs::batch_destroy_cmd("tank/data", &[3, 2, 1])
);
assert_eq!(
"btrfs subvolume delete /d/v@3 /d/v@2",
super::btrfs::batch_destroy_cmd("/d/v", &[3, 2])
);
let cfg = cfg_with_volume("/btrfs/data", SnapMode::Btrfs);
assert_eq!(
"btrfs subvolume show /btrfs/data >/dev/null \
&& shopt -s nullglob \
&& for s in /btrfs/data@*; do printf '%s\\n' \"$s\"; done",
Btrfs::list_snapshots_cmd(&cfg)
);
assert!(!Btrfs::check_volume_cmd("/btrfs/data").contains("create"));
let cfg = cfg_with_volume("/btrfs", SnapMode::Btrfs);
let cmd = Btrfs::list_snapshots_cmd(&cfg);
assert!(cmd.contains("btrfs subvolume show /btrfs "));
assert!(cmd.contains("/btrfs@*"));
assert!(!cmd.contains("list"));
let cfg = cfg_with_volume("data", SnapMode::Btrfs);
let cmd = Btrfs::list_snapshots_cmd(&cfg);
assert!(cmd.contains("btrfs subvolume show data "));
assert!(cmd.contains("data@*"));
}
#[test]
fn degenerate_cfg_rejected_before_exec() {
let mut cfg = cfg_with_volume("tank/data", SnapMode::Zfs);
cfg.itv = 0;
assert!(gen_snapshot::<Zfs>(&cfg, 42).is_err());
let mut cfg = cfg_with_volume("tank/data", SnapMode::Zfs);
cfg.cap = 0;
assert!(gen_snapshot::<Zfs>(&cfg, 42).is_err());
let cfg = cfg_with_volume("tank/data; rm -rf /", SnapMode::Zfs);
assert!(gen_snapshot::<Zfs>(&cfg, 42).is_err());
let cfg = cfg_with_volume("-o exec=evil", SnapMode::Btrfs);
assert!(gen_snapshot::<Btrfs>(&cfg, 42).is_err());
}
#[test]
fn btrfs_rollback_never_deletes_live_volume_first() {
let cmd = Btrfs::rollback_cmd("/btrfs/data", 7);
let create = cmd.find("btrfs subvolume snapshot /btrfs/data@7").unwrap();
let delete_live = cmd.find("btrfs subvolume delete /btrfs/data ").unwrap();
assert!(create < delete_live);
assert!(!cmd.contains("2>/dev/null"));
assert!(!cmd.contains(';'));
}
#[test]
fn fade_degenerates_below_step_cnt() {
let snaps: Vec<u64> = (0..10u64).rev().collect();
let mut to_del = fade_to_delete(&snaps, 2, 4);
to_del.sort_unstable();
assert_eq!(vec![0u64, 1, 2, 3, 4, 5], to_del);
}
}