feat: ZFS trait abstraction with SystemZfs and MockZfs

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 03:50:24 +00:00
co-authored by Claude Sonnet 4.6
parent 08b7f94e4c
commit 965eef4db5
+159
View File
@@ -0,0 +1,159 @@
use anyhow::{bail, Context, Result};
use std::sync::{Arc, Mutex};
pub fn snapshot_name(dataset: &str, kind: &str, timestamp: &str) -> String {
format!("{}@{}-{}", dataset, kind, timestamp)
}
pub trait ZfsOps: Send + Sync {
fn snapshot(&self, dataset: &str, tag: &str) -> Result<()>;
fn list_snapshots(&self, dataset: &str) -> Result<Vec<String>>;
fn delete_snapshot(&self, full_name: &str) -> Result<()>;
fn send_to_remote(&self, snapshot: &str, incremental_from: Option<&str>,
remote_user: &str, remote_host: &str,
remote_dataset: &str) -> Result<()>;
fn clone_snapshot(&self, snapshot: &str, dest_dataset: &str) -> Result<()>;
}
pub struct SystemZfs;
impl ZfsOps for SystemZfs {
fn snapshot(&self, dataset: &str, tag: &str) -> Result<()> {
let name = format!("{}@{}", dataset, tag);
let out = std::process::Command::new("zfs")
.args(["snapshot", &name])
.output()
.context("running zfs snapshot")?;
if !out.status.success() {
bail!("zfs snapshot failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(())
}
fn list_snapshots(&self, dataset: &str) -> Result<Vec<String>> {
let out = std::process::Command::new("zfs")
.args(["list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset])
.output()
.context("running zfs list")?;
if !out.status.success() {
bail!("zfs list failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect())
}
fn delete_snapshot(&self, full_name: &str) -> Result<()> {
let out = std::process::Command::new("zfs")
.args(["destroy", full_name])
.output()
.context("running zfs destroy")?;
if !out.status.success() {
bail!("zfs destroy failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(())
}
fn send_to_remote(&self, snapshot: &str, incremental_from: Option<&str>,
remote_user: &str, remote_host: &str,
remote_dataset: &str) -> Result<()> {
let mut send_args = vec!["send".to_string()];
if let Some(prev) = incremental_from {
send_args.push("-i".to_string());
send_args.push(prev.to_string());
}
send_args.push(snapshot.to_string());
let cmd = format!(
"zfs {} | ssh {}@{} zfs receive -F {}",
send_args.join(" "), remote_user, remote_host, remote_dataset
);
let out = std::process::Command::new("sh")
.args(["-c", &cmd])
.output()
.context("running zfs send | ssh")?;
if !out.status.success() {
bail!("zfs send failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(())
}
fn clone_snapshot(&self, snapshot: &str, dest_dataset: &str) -> Result<()> {
let out = std::process::Command::new("zfs")
.args(["clone", snapshot, dest_dataset])
.output()
.context("running zfs clone")?;
if !out.status.success() {
bail!("zfs clone failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(())
}
}
#[derive(Default, Clone)]
pub struct MockZfs {
snapshots: Arc<Mutex<Vec<String>>>,
}
impl ZfsOps for MockZfs {
fn snapshot(&self, dataset: &str, tag: &str) -> Result<()> {
self.snapshots.lock().unwrap().push(format!("{}@{}", dataset, tag));
Ok(())
}
fn list_snapshots(&self, dataset: &str) -> Result<Vec<String>> {
Ok(self.snapshots.lock().unwrap()
.iter()
.filter(|s| s.starts_with(&format!("{}@", dataset)))
.cloned()
.collect())
}
fn delete_snapshot(&self, full_name: &str) -> Result<()> {
self.snapshots.lock().unwrap().retain(|s| s != full_name);
Ok(())
}
fn send_to_remote(&self, _snap: &str, _incr: Option<&str>,
_user: &str, _host: &str, _dest: &str) -> Result<()> {
Ok(())
}
fn clone_snapshot(&self, snapshot: &str, dest_dataset: &str) -> Result<()> {
self.snapshots.lock().unwrap()
.push(format!("{} -> {}", snapshot, dest_dataset));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snapshot_name_format() {
let name = snapshot_name("slab/projects", "hourly", "2026-06-16-0300");
assert_eq!(name, "slab/projects@hourly-2026-06-16-0300");
}
#[test]
fn test_mock_zfs_snapshot() {
let zfs = MockZfs::default();
zfs.snapshot("slab/projects", "test-snap").unwrap();
let snaps = zfs.list_snapshots("slab/projects").unwrap();
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0], "slab/projects@test-snap");
}
#[test]
fn test_mock_zfs_delete() {
let zfs = MockZfs::default();
zfs.snapshot("slab/projects", "s1").unwrap();
zfs.snapshot("slab/projects", "s2").unwrap();
zfs.delete_snapshot("slab/projects@s1").unwrap();
let snaps = zfs.list_snapshots("slab/projects").unwrap();
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0], "slab/projects@s2");
}
}