diff --git a/Cargo.lock b/Cargo.lock index d48bec9..e16bc0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1179,6 +1179,7 @@ dependencies = [ "signal", "tempfile", "toml", + "zfs", ] [[package]] @@ -1839,14 +1840,6 @@ name = "zfs" version = "0.1.0" dependencies = [ "anyhow", - "chrono", - "clap", - "env_logger", - "log", - "serde", - "signal", - "tempfile", - "toml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6d399ed..58af426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ log = "0.4" serde = "1.0" serde_json = "1.0" signal = { path = "rust/signal" } +zfs = { path = "rust/zfs" } [workspace.lints.clippy] all = { level = "deny", priority = -1 } diff --git a/rust/zfs/Cargo.toml b/rust/zfs/Cargo.toml new file mode 100644 index 0000000..d02362f --- /dev/null +++ b/rust/zfs/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "zfs" +version = "0.1.0" +edition = "2024" +description = "List ZFS datasets and manage snapshots through the zfs executable" +license = "MIT" +publish = false + +[dependencies] +anyhow.workspace = true + +[lints] +workspace = true diff --git a/rust/zfs/src/lib.rs b/rust/zfs/src/lib.rs new file mode 100644 index 0000000..90e8c0f --- /dev/null +++ b/rust/zfs/src/lib.rs @@ -0,0 +1,100 @@ +//! Manage ZFS datasets and snapshots using the `zfs` executable on `PATH`. +//! +//! ```no_run +//! for snapshot in zfs::list_snapshots("pool/home")? { +//! println!("{snapshot}"); +//! } +//! # Ok::<(), anyhow::Error>(()) +//! ``` + +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +const ZFS_EXECUTABLE: &str = "zfs"; + +fn zfs_command(args: &[&str]) -> Result { + let output = Command::new(ZFS_EXECUTABLE) + .args(args) + .env("LC_ALL", "C") + .output() + .context("Failed to execute zfs")?; + if !output.status.success() { + let message = if output.stderr.is_empty() { + &output.stdout + } else { + &output.stderr + }; + bail!( + "zfs {} failed ({}): {}", + args.join(" "), + output.status, + String::from_utf8_lossy(message).trim() + ); + } + String::from_utf8(output.stdout).context("zfs returned invalid UTF-8") +} + +/// List filesystem datasets, excluding pool roots. +/// +/// # Errors +/// +/// Returns an error if executing ZFS or decoding its output fails. +pub fn list_datasets() -> Result> { + let output = zfs_command(&["list", "-Hp", "-t", "filesystem", "-o", "name"])?; + Ok(output + .lines() + .filter(|name| name.contains('/')) + .map(String::from) + .collect()) +} + +/// List snapshot names belonging directly to `dataset`, excluding child datasets. +/// +/// Returned names omit the `dataset@` prefix. +/// +/// # Errors +/// +/// Returns an error if executing ZFS or decoding its output fails. +pub fn list_snapshots(dataset: &str) -> Result> { + let output = zfs_command(&[ + "list", "-Hp", "-t", "snapshot", "-o", "name", "-d", "1", dataset, + ])?; + Ok(output + .lines() + .filter_map(|name| { + let (owner, snapshot) = name.split_once('@')?; + (owner == dataset).then(|| snapshot.to_owned()) + }) + .collect()) +} + +/// Create `dataset@snapshot`. +/// +/// # Errors +/// +/// Returns an error if executing ZFS or decoding its output fails. +pub fn create_snapshot(dataset: &str, snapshot: &str) -> Result<()> { + zfs_command(&["snapshot", &format!("{dataset}@{snapshot}")])?; + Ok(()) +} + +/// Delete `dataset@snapshot`. +/// +/// Returns `None` on success, or `Some("snapshot has dependent clones")` when +/// dependent clones prevent deletion. +/// +/// # Errors +/// +/// Returns an error for other ZFS failures, execution failures, or invalid UTF-8 output. +pub fn delete_snapshot(dataset: &str, snapshot: &str) -> Result> { + if let Err(error) = zfs_command(&["destroy", &format!("{dataset}@{snapshot}")]) { + if error.to_string().contains(&format!( + "cannot destroy '{dataset}@{snapshot}': snapshot has dependent clones" + )) { + return Ok(Some("snapshot has dependent clones")); + } + return Err(error); + } + Ok(None) +}