feat: snapshot scheduling, retention pruning, cold replication

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 03:51:30 +00:00
co-authored by Claude Sonnet 4.6
parent 08b7f94e4c
commit 96293ef794
+92
View File
@@ -0,0 +1,92 @@
use crate::zfs::ZfsOps;
use anyhow::Result;
pub fn take_snapshot(zfs: &dyn ZfsOps, dataset: &str, kind: &str, timestamp: &str) -> Result<()> {
let tag = format!("{}-{}", kind, timestamp);
zfs.snapshot(dataset, &tag)
}
pub fn prune_by_kind(zfs: &dyn ZfsOps, dataset: &str, kind: &str, retain: usize) -> Result<()> {
let prefix = format!("{}@{}-", dataset, kind);
let mut matching: Vec<String> = zfs.list_snapshots(dataset)?
.into_iter()
.filter(|s| s.starts_with(&prefix))
.collect();
matching.sort();
while matching.len() > retain {
let oldest = matching.remove(0);
zfs.delete_snapshot(&oldest)?;
}
Ok(())
}
pub fn run_snapshot_cycle(
zfs: &dyn ZfsOps,
dataset: &str,
timestamp: &str,
retain_hourly: usize,
retain_daily: usize,
retain_weekly: usize,
) -> Result<()> {
take_snapshot(zfs, dataset, "hourly", timestamp)?;
prune_by_kind(zfs, dataset, "hourly", retain_hourly)?;
if timestamp.ends_with("-0000") {
take_snapshot(zfs, dataset, "daily", timestamp)?;
prune_by_kind(zfs, dataset, "daily", retain_daily)?;
}
let _ = retain_weekly;
Ok(())
}
pub fn replicate_to_cold(
zfs: &dyn ZfsOps,
dataset: &str,
remote_user: &str,
remote_host: &str,
remote_dataset: &str,
) -> Result<()> {
let snaps = zfs.list_snapshots(dataset)?;
let latest = snaps.last().cloned();
if let Some(snap) = latest {
zfs.send_to_remote(&snap, None, remote_user, remote_host, remote_dataset)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::zfs::MockZfs;
#[test]
fn test_take_hourly_snapshot() {
let zfs = MockZfs::default();
take_snapshot(&zfs, "slab/projects", "hourly", "2026-06-16-0300").unwrap();
let snaps = zfs.list_snapshots("slab/projects").unwrap();
assert_eq!(snaps, vec!["slab/projects@hourly-2026-06-16-0300"]);
}
#[test]
fn test_prune_keeps_retain_count() {
let zfs = MockZfs::default();
for i in 0..30 {
zfs.snapshot("slab/projects", &format!("hourly-2026-06-{:02}-0000", i % 30 + 1)).unwrap();
}
prune_by_kind(&zfs, "slab/projects", "hourly", 24).unwrap();
let remaining = zfs.list_snapshots("slab/projects").unwrap();
assert!(remaining.len() <= 24, "expected <=24, got {}", remaining.len());
}
#[test]
fn test_prune_skips_if_under_limit() {
let zfs = MockZfs::default();
for i in 0..5 {
zfs.snapshot("slab/projects", &format!("hourly-snap-{}", i)).unwrap();
}
prune_by_kind(&zfs, "slab/projects", "hourly", 24).unwrap();
let remaining = zfs.list_snapshots("slab/projects").unwrap();
assert_eq!(remaining.len(), 5);
}
}