use std::process::Command; use eyre::{Context, Result}; const PROPERTY_TAG: &str = "zsnap:tag"; #[derive(Debug)] struct ZfsSnapshot { name: String, dataset: String, time: chrono::DateTime, tag: String, } pub fn snapshot_create(tag: &str, dataset: &str, recursive: bool) -> Result<()> { let time = chrono::Utc::now(); let name = format!("{}@{}", dataset, time.format("zsnap_%Y-%m-%d_%H-%M-%S")); let rec = if recursive { "-r" } else { "" }; let cmd = format!("zfs snapshot -o {PROPERTY_TAG}={tag} {rec} {name}"); let _output = run_shell_command(&cmd)?; Ok(()) } pub fn snapshot_prune(tag: &str, dataset: &str, recursive: bool, keep: usize) -> Result<()> { let mut snapshots = zfs_snapshot_list()? .into_iter() .filter(|s| s.dataset == dataset && s.tag == tag) .collect::>(); snapshots.sort_by_key(|s| s.time); snapshots.reverse(); tracing::trace!("snapshots for dataset '{dataset}': {snapshots:?}"); tracing::trace!("skipping {keep} snapshots"); for snapshot in snapshots.into_iter().skip(keep) { tracing::debug!(name = snapshot.name.as_str(), "destroying snapshot"); let rec = if recursive { "-r" } else { "" }; assert!(snapshot.name.contains('@')); // safe-guard to prevent accidental deletion of non-snapshots let cmd = format!("zfs destroy {} {}", rec, snapshot.name); let _output = run_shell_command(&cmd)?; } Ok(()) } fn zfs_snapshot_list() -> Result> { fn parse_line(line: &str) -> Option { let mut parts = line.split('\t'); let name = parts.next()?; let tag = parts.next()?; let mut parts = name.split('@'); let dataset = parts.next()?; let time = parts.next()?; let time = chrono::NaiveDateTime::parse_from_str(time, "zsnap_%Y-%m-%d_%H-%M-%S").ok()?; if tag == "-" { return None; } Some(ZfsSnapshot { name: name.to_string(), dataset: dataset.to_string(), time: time.and_utc(), tag: tag.to_string(), }) } let output = run_shell_command(&format!("zfs list -H -t snapshot -o name,{PROPERTY_TAG}"))?; Ok(output.lines().filter_map(parse_line).collect()) } fn run_shell_command(cmd: &str) -> Result { tracing::debug!("running shell command '{cmd}'"); let output = Command::new("/bin/sh") .arg("-c") .arg(cmd) .output() .with_context(|| format!("running shell command '{cmd}'"))?; if !output.status.success() { let stderr = String::from_utf8(output.stderr).context("converting shell output to utf8")?; let stderr = stderr.trim(); tracing::error!("shell command failed: '{stderr}'"); return Err(eyre::eyre!("shell command failed: '{stderr}'")); } let output = String::from_utf8(output.stdout).context("converting shell output to utf8")?; tracing::trace!("shell command output: '{output}'"); Ok(output) }