feat: snapshot scheduling, retention pruning, cold replication
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
08b7f94e4c
commit
96293ef794
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user