From df045d4c63d8e082754360ac64ac1e4bf1d3c85c Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Sun, 11 Oct 2026 16:51:18 -0400 Subject: [PATCH 1/3] feat(rust): add shared Signal client and workspace configuration --- Cargo.lock | 170 +++++++++++++++++++++++++++++++++++ Cargo.toml | 29 ++++++ clippy.toml | 4 + rust/signal/Cargo.toml | 17 ++++ rust/signal/src/lib.rs | 78 ++++++++++++++++ rust/signal/tests/send.rs | 147 ++++++++++++++++++++++++++++++ rust/van_weather/Cargo.toml | 21 +++-- rust/van_weather/package.nix | 22 ++++- rust/van_weather/src/main.rs | 43 +++++++-- 9 files changed, 508 insertions(+), 23 deletions(-) create mode 100644 clippy.toml create mode 100644 rust/signal/Cargo.toml create mode 100644 rust/signal/src/lib.rs create mode 100644 rust/signal/tests/send.rs diff --git a/Cargo.lock b/Cargo.lock index 04a6d6d..d48bec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,28 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -381,6 +403,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -613,6 +641,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -684,6 +722,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -971,6 +1015,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -1061,6 +1118,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1079,6 +1145,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal" +version = "0.1.0" +dependencies = [ + "anyhow", + "libc", + "serde_json", +] + [[package]] name = "slab" version = "0.4.12" @@ -1091,6 +1166,21 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "snapshot-manager" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "env_logger", + "log", + "serde", + "signal", + "tempfile", + "toml", +] + [[package]] name = "socket2" version = "0.6.5" @@ -1161,6 +1251,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -1230,6 +1333,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -1345,6 +1487,7 @@ dependencies = [ "clap", "env_logger", "log", + "num-traits", "reqwest", "serde", "serde_json", @@ -1590,6 +1733,18 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "writeable" version = "0.6.4" @@ -1679,6 +1834,21 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zfs" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "env_logger", + "log", + "serde", + "signal", + "tempfile", + "toml", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index fad950a..6d399ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,32 @@ resolver = "2" members = ["rust/*"] + +[workspace.dependencies] +anyhow = "1.0" +chrono = "0.4" +clap = "4.5" +env_logger = "0.11" +log = "0.4" +serde = "1.0" +serde_json = "1.0" +signal = { path = "rust/signal" } + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } # UM, ACTUALLY +nursery = { level = "deny", priority = -1 } # BETA LINTS +# DENY PANICS +unwrap_used = "deny" +expect_used = "deny" +indexing_slicing = "deny" +arithmetic_side_effects = "deny" +unreachable = "deny" +unimplemented = "deny" +unchecked_time_subtraction = "deny" +todo = "deny" +string_slice = "deny" +panic_in_result_fn = "deny" +panic = "deny" +exit = "deny" +as_conversions = "deny" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..4b6c79f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/rust/signal/Cargo.toml b/rust/signal/Cargo.toml new file mode 100644 index 0000000..5c4b11c --- /dev/null +++ b/rust/signal/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "signal" +version = "0.1.0" +edition = "2024" +description = "Send Signal alerts through the local signal-cli JSON-RPC daemon" +license = "MIT" +publish = false + +[dependencies] +anyhow.workspace = true +serde_json.workspace = true + +[target.'cfg(target_os = "linux")'.dev-dependencies] +libc = "0.2" + +[lints] +workspace = true diff --git a/rust/signal/src/lib.rs b/rust/signal/src/lib.rs new file mode 100644 index 0000000..942f50a --- /dev/null +++ b/rust/signal/src/lib.rs @@ -0,0 +1,78 @@ +//! Send alerts through the local signal-cli daemon on Unix systems. +//! +//! ```no_run +//! use signal::Signal; +//! +//! let client = Signal { +//! from_phone: "+15555550100".into(), +//! }; +//! client.send("+15555550101", "Service failed")?; +//! client.send("+15555550102", "Service recovered")?; +//! # Ok::<(), anyhow::Error>(()) +//! ``` + +use std::{ + io::{BufRead, BufReader, Write}, + os::unix::net::UnixStream, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; + +/// A sending account for alerts through `/run/signal-cli/socket`. +pub struct Signal { + /// The phone number of an account registered with the local daemon. + pub from_phone: String, +} + +impl Signal { + /// Send a message to `to_phone` and wait for the corresponding JSON-RPC response. + /// + /// Socket reads and writes use a four-second timeout. + /// + /// # Errors + /// + /// Returns an error if connecting, writing, or reading fails, the daemon + /// returns invalid JSON or an RPC error, or it closes before responding. + pub fn send(&self, to_phone: &str, body: &str) -> Result<()> { + let mut stream = UnixStream::connect("/run/signal-cli/socket") + .context("Failed to connect to Signal RPC socket")?; + let timeout = Duration::from_secs(4); + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + let request = json!({ + "jsonrpc": "2.0", + "method": "send", + "params": { + "account": self.from_phone, + "recipient": [to_phone], + "message": body, + }, + "id": "signal-alert", + }); + writeln!(stream, "{request}").context("Failed to send Signal RPC request")?; + let mut reader = BufReader::new(stream); + let mut line = String::new(); + loop { + line.clear(); + if reader + .read_line(&mut line) + .context("Failed to read Signal RPC response")? + == 0 + || !line.ends_with('\n') + { + bail!("Signal RPC socket closed before returning a response"); + } + let response: Value = + serde_json::from_str(&line).context("Invalid Signal RPC response")?; + if response.get("id").and_then(Value::as_str) != Some("signal-alert") { + continue; + } + if let Some(error) = response.get("error").filter(|value| !value.is_null()) { + bail!("Signal RPC returned an error: {error}"); + } + return Ok(()); + } + } +} diff --git a/rust/signal/tests/send.rs b/rust/signal/tests/send.rs new file mode 100644 index 0000000..e6de6f1 --- /dev/null +++ b/rust/signal/tests/send.rs @@ -0,0 +1,147 @@ +#![cfg(test)] +#![cfg(target_os = "linux")] + +use std::{ + cell::RefCell, + io::{self, BufRead, BufReader, Write}, + os::{fd::AsRawFd, unix::net::UnixStream}, + thread, + time::{Duration, Instant}, +}; + +use serde_json::{Value, json}; +use signal::Signal; + +thread_local! { + static CONNECTION: RefCell> = const { RefCell::new(None) }; +} + +// Patch libc's connect only in this integration-test executable. Each test +// supplies its own socket pair; no call can reach the real Signal daemon. +// SAFETY: This symbol uses libc's connect ABI and preserves its return/errno +// convention. It never delegates to a real connection attempt. +#[unsafe(no_mangle)] +unsafe extern "C" fn connect( + socket: libc::c_int, + address: *const libc::sockaddr, + length: libc::socklen_t, +) -> libc::c_int { + let path_offset = std::mem::offset_of!(libc::sockaddr_un, sun_path); + let expected = b"/run/signal-cli/socket\0"; + if !address.is_null() + && let Some(path_end) = path_offset.checked_add(expected.len()) + && usize::try_from(length).is_ok_and(|length| length >= path_end) + { + // SAFETY: connect's caller supplies an address valid for length bytes. + // The bounds check above covers the entire pathname slice. + let path = unsafe { + std::slice::from_raw_parts(address.cast::().add(path_offset), expected.len()) + }; + if path == expected + && let Some(stream) = CONNECTION.with_borrow_mut(Option::take) + { + // SAFETY: Both descriptors are live and owned by this thread. + // dup2 replaces the unconnected socket with the test endpoint. + return if unsafe { libc::dup2(stream.as_raw_fd(), socket) } < 0 { + -1 + } else { + 0 + }; + } + } + // SAFETY: __errno_location returns this thread's valid errno pointer. + unsafe { *libc::__errno_location() = libc::ECONNREFUSED }; + -1 +} + +fn test_connection() -> UnixStream { + let (client, server) = UnixStream::pair().unwrap(); + CONNECTION.with_borrow_mut(|connection| assert!(connection.replace(client).is_none())); + server +} + +fn signal() -> Signal { + Signal { + from_phone: "1234567890".into(), + } +} + +#[test] +fn reuses_sender_for_different_recipients_and_ignores_unrelated_responses() { + let client = signal(); + for (to_phone, body) in [ + ("0987654321", "failure\nwith details"), + ("+15555550102", "another failure"), + ] { + let server = test_connection(); + let task = thread::spawn(move || { + let mut reader = BufReader::new(server); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!( + request, + json!({ + "jsonrpc": "2.0", "method": "send", "id": "signal-alert", + "params": { "account": "1234567890", "recipient": [to_phone], "message": body }, + }) + ); + reader + .get_mut() + .write_all(b"{\"method\":\"receive\"}\n{\"id\":\"other\"}\n{\"id\":\"signal-") + .unwrap(); + reader + .get_mut() + .write_all(b"alert\",\"result\":{}}\n") + .unwrap(); + }); + client.send(to_phone, body).unwrap(); + task.join().unwrap(); + } +} + +#[test] +fn rejects_rpc_errors_invalid_json_and_premature_close() { + for (reply, expected) in [ + ( + "{\"id\":\"signal-alert\",\"error\":{\"code\":-1}}\n", + "Signal RPC returned an error", + ), + ("invalid\n", "Invalid Signal RPC response"), + ("", "socket closed"), + ("{\"id\":\"signal-alert\"}", "socket closed"), + ] { + let server = test_connection(); + let task = thread::spawn(move || { + let mut reader = BufReader::new(server); + reader.read_line(&mut String::new()).unwrap(); + reader.get_mut().write_all(reply.as_bytes()).unwrap(); + }); + let error = signal().send("0987654321", "failure").unwrap_err(); + assert!(format!("{error:#}").contains(expected), "{error:#}"); + task.join().unwrap(); + } +} + +#[test] +fn reports_connection_failure() { + let error = signal().send("0987654321", "failure").unwrap_err(); + assert_eq!(error.to_string(), "Failed to connect to Signal RPC socket"); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + io::ErrorKind::ConnectionRefused + ); +} + +#[test] +fn unresponsive_server_times_out() { + let _server = test_connection(); + let start = Instant::now(); + let error = signal().send("0987654321", "failure").unwrap_err(); + assert_eq!(error.to_string(), "Failed to read Signal RPC response"); + assert!(matches!( + error.downcast_ref::().unwrap().kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + )); + assert!(start.elapsed() >= Duration::from_secs(4)); +} diff --git a/rust/van_weather/Cargo.toml b/rust/van_weather/Cargo.toml index b42328a..0f06717 100644 --- a/rust/van_weather/Cargo.toml +++ b/rust/van_weather/Cargo.toml @@ -6,16 +6,15 @@ description = "Fetch privacy-masked weather for a van and publish it to Home Ass license = "MIT" [dependencies] -anyhow = "1.0" -chrono = "0.4" -clap = { version = "4.5", features = ["derive", "env"] } -env_logger = "0.11" -log = "0.4" +anyhow.workspace = true +chrono.workspace = true +clap = { workspace = true, features = ["derive", "env"] } +env_logger.workspace = true +log.workspace = true +num-traits = "0.2" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -[lints.clippy] -all = "deny" -pedantic = "deny" +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +[lints] +workspace = true diff --git a/rust/van_weather/package.nix b/rust/van_weather/package.nix index de30db6..6a9fa09 100644 --- a/rust/van_weather/package.nix +++ b/rust/van_weather/package.nix @@ -1,10 +1,26 @@ -{ rustPlatform }: +{ lib, rustPlatform }: rustPlatform.buildRustPackage { pname = "van-weather"; version = "0.1.0"; - src = ./.; + src = lib.fileset.toSource { + root = ../..; + fileset = lib.fileset.unions [ + ../../Cargo.toml + ../../Cargo.lock + ../../clippy.toml + ../. + ]; + }; - cargoLock.lockFile = ./Cargo.lock; + cargoLock.lockFile = ../../Cargo.lock; + cargoBuildFlags = [ + "-p" + "van-weather" + ]; + cargoTestFlags = [ + "-p" + "van-weather" + ]; meta = { description = "Privacy-masked van weather publisher for Home Assistant"; diff --git a/rust/van_weather/src/main.rs b/rust/van_weather/src/main.rs index fbc3993..0c83068 100644 --- a/rust/van_weather/src/main.rs +++ b/rust/van_weather/src/main.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use clap::Parser; use log::{error, info}; +use num_traits::ToPrimitive; use reqwest::{ StatusCode, Url, blocking::{Client, ClientBuilder}, @@ -25,7 +26,7 @@ const fn decimal_factor(decimals: u32) -> f64 { let mut remaining = decimals; while remaining > 0 { factor *= 10.0; - remaining -= 1; + remaining = remaining.saturating_sub(1); } factor } @@ -468,18 +469,25 @@ fn weather_updates(weather: &Weather) -> Vec<(String, Value)> { } fn sensor(entity_id: &str, state: Option, attributes: Value) -> Option<(String, Value)> { - state.map(|state| { - let mut payload = serde_json::Map::new(); - payload.insert("state".to_owned(), state); - payload.insert("attributes".to_owned(), attributes); - (entity_id.to_owned(), Value::Object(payload)) - }) + let state = state?; + let mut payload = serde_json::Map::new(); + payload.insert("state".to_owned(), state); + payload.insert("attributes".to_owned(), attributes); + Some((entity_id.to_owned(), Value::Object(payload))) } -#[allow(clippy::cast_possible_truncation)] fn percent(value: Option) -> i64 { // Preserve Python's int(probability * 100) behavior for Home Assistant. - (value.unwrap_or_default() * 100.0) as i64 + let value = value.unwrap_or_default() * 100.0; + value.to_i64().unwrap_or_else(|| { + if value.is_nan() { + 0 + } else if value.is_sign_negative() { + i64::MIN + } else { + i64::MAX + } + }) } #[cfg(test)] @@ -562,4 +570,21 @@ mod tests { assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON); assert!((mask_coordinate(-122.4194) - (-122.4)).abs() < f64::EPSILON); } + + #[test] + fn percentages_truncate_and_preserve_saturation_for_extreme_values() { + for (value, expected) in [ + (None, 0), + (Some(0.129), 12), + (Some(-0.129), -12), + (Some(1.0), 100), + (Some(f64::NAN), 0), + (Some(f64::MAX), i64::MAX), + (Some(f64::MIN), i64::MIN), + (Some(f64::INFINITY), i64::MAX), + (Some(f64::NEG_INFINITY), i64::MIN), + ] { + assert_eq!(percent(value), expected); + } + } } -- 2.55.0 From 704999b94cda0f79936308dae048fc443141cee2 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Sun, 11 Oct 2026 17:28:49 -0400 Subject: [PATCH 2/3] feat(zfs): add reusable dataset and snapshot operations --- Cargo.lock | 9 +--- Cargo.toml | 1 + rust/zfs/Cargo.toml | 13 ++++++ rust/zfs/src/lib.rs | 100 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 rust/zfs/Cargo.toml create mode 100644 rust/zfs/src/lib.rs 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) +} -- 2.55.0 From 1ccba91d2ada9c4c786ac5fd999f72f02c2a3fa8 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Sun, 11 Oct 2026 20:03:49 -0400 Subject: [PATCH 3/3] feat(snapshot-manager): migrate snapshot management to Rust --- Cargo.toml | 4 +- common/optional/zfs/default.nix | 2 - common/optional/zfs/snapshot.nix | 50 +-- python/tools/snapshot_manager.py | 161 ---------- rust/rust-source.nix | 10 + rust/snapshot_manager/Cargo.toml | 23 ++ rust/snapshot_manager/package.nix | 25 ++ rust/snapshot_manager/src/main.rs | 383 +++++++++++++++++++++++ rust/snapshot_manager/tests/cli.rs | 202 ++++++++++++ rust/snapshot_manager/tests/fixtures/zfs | 12 + rust/snapshot_manager/tests/manager.rs | 290 +++++++++++++++++ rust/van_weather/package.nix | 10 +- systems/jeeves/runners/nix_builder.nix | 1 + tests/test_snapshot_manager.py | 204 ------------ treefmt.toml | 5 + 15 files changed, 958 insertions(+), 424 deletions(-) delete mode 100644 python/tools/snapshot_manager.py create mode 100644 rust/rust-source.nix create mode 100644 rust/snapshot_manager/Cargo.toml create mode 100644 rust/snapshot_manager/package.nix create mode 100644 rust/snapshot_manager/src/main.rs create mode 100644 rust/snapshot_manager/tests/cli.rs create mode 100755 rust/snapshot_manager/tests/fixtures/zfs create mode 100644 rust/snapshot_manager/tests/manager.rs delete mode 100644 tests/test_snapshot_manager.py diff --git a/Cargo.toml b/Cargo.toml index 58af426..e05df8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,8 @@ zfs = { path = "rust/zfs" } [workspace.lints.clippy] all = { level = "deny", priority = -1 } -pedantic = { level = "deny", priority = -1 } # UM, ACTUALLY -nursery = { level = "deny", priority = -1 } # BETA LINTS +pedantic = { level = "deny", priority = -1 } # UM, ACTUALLY +nursery = { level = "deny", priority = -1 } # BETA LINTS # DENY PANICS unwrap_used = "deny" expect_used = "deny" diff --git a/common/optional/zfs/default.nix b/common/optional/zfs/default.nix index a18e8e6..825f26b 100644 --- a/common/optional/zfs/default.nix +++ b/common/optional/zfs/default.nix @@ -1,5 +1,4 @@ { - inputs, lib, pkgs, ... @@ -15,7 +14,6 @@ services = { snapshot_manager = { enable = lib.mkDefault true; - PYTHONPATH = "${inputs.self}/"; }; zfs = { diff --git a/common/optional/zfs/snapshot.nix b/common/optional/zfs/snapshot.nix index ba07f3e..dbd4198 100644 --- a/common/optional/zfs/snapshot.nix +++ b/common/optional/zfs/snapshot.nix @@ -1,5 +1,4 @@ { - inputs, pkgs, lib, config, @@ -7,23 +6,18 @@ }: let cfg = config.services.snapshot_manager; - snapshotManagerPackages = ps: with ps; [ typer ]; + snapshotManager = pkgs.callPackage ../../../rust/snapshot_manager/package.nix { }; in { options = { services.snapshot_manager = { enable = lib.mkEnableOption "ZFS snapshot manager"; + signalAlerts = lib.mkEnableOption "Signal failure alerts for the snapshot manager"; path = lib.mkOption { type = lib.types.path; default = ./snapshot_config.toml; description = "Path to the snapshot_manager TOML config."; }; - PYTHONPATH = lib.mkOption { - type = lib.types.str; - description = '' - the PYTHONPATH to use for the snapshot_manager service. - ''; - }; EnvironmentFile = lib.mkOption { type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str); default = null; @@ -36,40 +30,7 @@ in }; config = lib.mkIf cfg.enable { - nixpkgs.overlays = [ - (final: _prev: { - snapshot_manager_python = final.python314.withPackages snapshotManagerPackages; - snapshot_manager_test_python = final.python314.withPackages ( - ps: - snapshotManagerPackages ps - ++ (with ps; [ - pyfakefs - pytest - pytest-asyncio - pytest-mock - pytest-xdist - ]) - ); - snapshot_manager_tests = - final.runCommand "snapshot-manager-tests" - { - nativeBuildInputs = [ final.snapshot_manager_test_python ]; - } - '' - export HOME="$TMPDIR" - cd ${inputs.self} - pytest \ - -o cache_dir="$TMPDIR/pytest-cache" \ - tests/test_common.py \ - tests/test_signal_alert.py \ - tests/test_snapshot_manager.py \ - tests/test_zfs.py - touch "$out" - ''; - }) - ]; - - system.checks = [ pkgs.snapshot_manager_tests ]; + system.checks = [ snapshotManager ]; systemd = { services.snapshot_manager = { @@ -77,12 +38,9 @@ in requires = [ "zfs-import.target" ]; after = [ "zfs-import.target" ]; path = [ pkgs.zfs ]; - environment = { - PYTHONPATH = cfg.PYTHONPATH; - }; serviceConfig = { Type = "oneshot"; - ExecStart = "${pkgs.snapshot_manager_python}/bin/python -m python.tools.snapshot_manager ${lib.escapeShellArg cfg.path}"; + ExecStart = "${snapshotManager}/bin/snapshot-manager ${lib.escapeShellArg cfg.path} --signal-alerts ${lib.boolToString cfg.signalAlerts}"; } // lib.optionalAttrs (cfg.EnvironmentFile != null) { EnvironmentFile = cfg.EnvironmentFile; diff --git a/python/tools/snapshot_manager.py b/python/tools/snapshot_manager.py deleted file mode 100644 index 90eb478..0000000 --- a/python/tools/snapshot_manager.py +++ /dev/null @@ -1,161 +0,0 @@ -"""snapshot_manager.""" - -from __future__ import annotations - -import logging -import sys -import tomllib -from functools import cache -from os import getenv -from pathlib import Path # noqa: TC003 This is required for the typer CLI -from re import compile as re_compile -from re import search - -import typer - -from python.common import configure_logger, utcnow -from python.signal_alert import signal_alert -from python.zfs import Dataset, get_datasets - -logger = logging.getLogger(__name__) - - -def main(config_file: Path) -> None: - """Main.""" - configure_logger(level="DEBUG") - logger.info("Starting snapshot_manager") - - from_phone = getenv("SIGNAL_ALERT_FROM_PHONE") - to_phone = getenv("SIGNAL_ALERT_TO_PHONE") - if not from_phone: - error = "SIGNAL_ALERT_FROM_PHONE environment variable is not set" - raise ValueError(error) - if not to_phone: - error = "SIGNAL_ALERT_TO_PHONE environment variable is not set" - raise ValueError(error) - - try: - time_stamp = get_time_stamp() - - for dataset in get_datasets(): - status = dataset.create_snapshot(time_stamp) - logger.debug(f"{status=}") - if status != "snapshot created": - msg = f"{dataset.name} failed to create snapshot {time_stamp}" - logger.error(msg) - signal_alert(msg, from_phone, to_phone) - continue - count_lookup = get_count_lookup(config_file, dataset.name) - logger.info(f"using {count_lookup} for {dataset.name}") - get_snapshots_to_delete(dataset, count_lookup, from_phone, to_phone) - except Exception: - logger.exception("snapshot_manager failed") - signal_alert("snapshot_manager failed", from_phone, to_phone) - sys.exit(1) - else: - logger.info("snapshot_manager completed") - - -def get_count_lookup(config_file: Path, dataset_name: str) -> dict[str, int]: - """Get the count lookup. - - Args: - config_file (Path): The path to the configuration file. - dataset_name (str): The name of the dataset. - - Returns: - dict[str, int]: The count lookup. - """ - config_data = load_config_data(config_file) - - return config_data.get(dataset_name, get_default_config(config_data)) - - -def get_default_config(config_data: dict[str, dict[str, int]]) -> dict[str, int]: - """Get the default configuration. - - Args: - config_data (dict[str, dict[str, int]]): The configuration data. - - Returns: - dict[str, int]: The default configuration. - """ - return config_data.get( - "default", - {"15_min": 4, "hourly": 12, "daily": 0, "monthly": 0}, - ) - - -@cache -def load_config_data(config_file: Path) -> dict[str, dict[str, int]]: - """Load a TOML configuration file. - - Args: - config_file (Path): The path to the configuration file. - - Returns: - dict: The configuration data. - """ - return tomllib.loads(config_file.read_text()) - - -def get_snapshots_to_delete( - dataset: Dataset, - count_lookup: dict[str, int], - from_phone: str, - to_phone: str, -) -> None: - """Get snapshots to delete. - - Args: - dataset (Dataset): the dataset - count_lookup (dict[str, int]): the count lookup - from_phone (str): The Signal account sending alerts. - to_phone (str): The Signal account receiving alerts. - """ - snapshots = dataset.get_snapshots() - - logger.info(f"calculating snapshots for {dataset.name} to be deleted") - if not snapshots: - logger.info(f"{dataset.name} has no snapshots") - return - - filters = ( - ("15_min", re_compile(r"auto_\d{10}(?:15|30|45)")), - ("hourly", re_compile(r"auto_\d{8}(?!00)\d{2}00")), - ("daily", re_compile(r"auto_\d{6}(?!01)\d{2}0000")), - ("monthly", re_compile(r"auto_\d{6}010000")), - ) - - for filter_name, snapshot_filter in filters: - logger.debug(f"{filter_name=}\n{snapshot_filter=}") - - filtered_snapshots = sorted(snapshot.name for snapshot in snapshots if search(snapshot_filter, snapshot.name)) - - logger.debug(f"{filtered_snapshots=}") - - snapshots_wanted = count_lookup[filter_name] - snapshots_being_deleted = filtered_snapshots[:-snapshots_wanted] if snapshots_wanted > 0 else filtered_snapshots - - logger.info(f"{snapshots_being_deleted} are being deleted") - for snapshot in snapshots_being_deleted: - if error := dataset.delete_snapshot(snapshot): - error_message = f"{dataset.name}@{snapshot} failed to delete: {error}" - signal_alert(error_message, from_phone, to_phone) - logger.error(error_message) - - -def get_time_stamp() -> str: - """Get the time stamp.""" - now = utcnow() - nearest_15_min = now.replace(minute=(now.minute - (now.minute % 15))) - return nearest_15_min.strftime("auto_%Y%m%d%H%M") - - -def cli() -> None: - """CLI.""" - typer.run(main) - - -if __name__ == "__main__": - cli() diff --git a/rust/rust-source.nix b/rust/rust-source.nix new file mode 100644 index 0000000..1c66051 --- /dev/null +++ b/rust/rust-source.nix @@ -0,0 +1,10 @@ +{ lib }: +lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.unions [ + ../Cargo.toml + ../Cargo.lock + ../clippy.toml + ./. + ]; +} diff --git a/rust/snapshot_manager/Cargo.toml b/rust/snapshot_manager/Cargo.toml new file mode 100644 index 0000000..d35732a --- /dev/null +++ b/rust/snapshot_manager/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "snapshot-manager" +version = "0.1.0" +edition = "2024" +description = "Create and rotate ZFS snapshots with Signal failure alerts" +license = "MIT" + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap = { workspace = true, features = ["derive", "env"] } +env_logger.workspace = true +log.workspace = true +serde = { workspace = true, features = ["derive"] } +signal.workspace = true +toml = "0.9" +zfs.workspace = true + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/rust/snapshot_manager/package.nix b/rust/snapshot_manager/package.nix new file mode 100644 index 0000000..1edb64a --- /dev/null +++ b/rust/snapshot_manager/package.nix @@ -0,0 +1,25 @@ +{ lib, rustPlatform }: +rustPlatform.buildRustPackage { + pname = "snapshot-manager"; + version = "0.1.0"; + src = import ../rust-source.nix { inherit lib; }; + + cargoLock.lockFile = ../../Cargo.lock; + cargoBuildFlags = [ + "-p" + "snapshot-manager" + ]; + cargoTestFlags = [ + "-p" + "snapshot-manager" + "-p" + "signal" + "-p" + "zfs" + ]; + + meta = { + description = "ZFS snapshot manager with Signal failure alerts"; + mainProgram = "snapshot-manager"; + }; +} diff --git a/rust/snapshot_manager/src/main.rs b/rust/snapshot_manager/src/main.rs new file mode 100644 index 0000000..00bafd0 --- /dev/null +++ b/rust/snapshot_manager/src/main.rs @@ -0,0 +1,383 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, Timelike, Utc}; +use clap::Parser; +use log::{error, info}; +use serde::Deserialize; +use signal::Signal; + +#[derive(Parser)] +#[command(version, about)] +struct Args { + /// TOML file containing default and per-dataset snapshot retention counts. + config_file: PathBuf, + + /// Send Signal failure alerts (requires both phone numbers). + #[arg(long, env = "SIGNAL_ALERTS", default_value_t = false, action = clap::ArgAction::Set)] + signal_alerts: bool, + + /// Signal account sending failure alerts. + #[arg( + long, + env = "SIGNAL_ALERT_FROM_PHONE", + hide_env_values = true, + required_if_eq("signal_alerts", "true"), + value_parser = clap::builder::NonEmptyStringValueParser::new() + )] + signal_alert_from_phone: Option, + + /// Signal recipient for failure alerts. + #[arg( + long, + env = "SIGNAL_ALERT_TO_PHONE", + hide_env_values = true, + required_if_eq("signal_alerts", "true"), + value_parser = clap::builder::NonEmptyStringValueParser::new() + )] + signal_alert_to_phone: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Retention { + #[serde(rename = "15_min")] + quarter_hourly: usize, + hourly: usize, + daily: usize, + monthly: usize, +} + +impl Default for Retention { + fn default() -> Self { + Self { + quarter_hourly: 4, + hourly: 12, + daily: 0, + monthly: 0, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(transparent)] +struct Config(HashMap); + +impl Config { + fn load(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read configuration {}", path.display()))?; + toml::from_str(&contents) + .with_context(|| format!("Invalid configuration {}", path.display())) + } + + fn retention_for(&self, dataset: &str) -> Retention { + self.0 + .get(dataset) + .or_else(|| self.0.get("default")) + .copied() + .unwrap_or_default() + } +} + +fn main() -> ExitCode { + let args = Args::parse(); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + info!("Starting snapshot_manager"); + let signal = if args.signal_alerts { + args.signal_alert_from_phone + .map(|from_phone| Signal { from_phone }) + } else { + None + }; + let alerts = signal.as_ref().zip(args.signal_alert_to_phone.as_deref()); + match run(&args.config_file, alerts) { + Ok(()) => { + info!("snapshot_manager completed"); + ExitCode::SUCCESS + } + Err(error) => { + error!("{error:#}"); + ExitCode::FAILURE + } + } +} + +fn run(config_file: &Path, alerts: Option<(&Signal, &str)>) -> Result<()> { + let result = (|| { + // Validate all retention settings before making any changes to ZFS. + let config = Config::load(config_file)?; + let snapshot_time = timestamp(Utc::now()); + for dataset_name in zfs::list_datasets()? { + if let Err(error) = zfs::create_snapshot(&dataset_name, &snapshot_time) { + let message = format!("{dataset_name} failed to create snapshot {snapshot_time}"); + error!("{message}: {error:#}"); + send_alert(alerts, &message)?; + continue; + } + remove_old_snapshots(&config, alerts, &dataset_name) + .context("Failed to remove old snapshots")?; + } + Ok(()) + })(); + if let Err(error) = &result { + error!("snapshot_manager failed: {error:#}"); + if let Err(alert_error) = send_alert(alerts, "snapshot_manager failed") { + error!("Failed to send failure alert: {alert_error:#}"); + } + } + result +} + +fn timestamp(now: DateTime) -> String { + let minute = now.minute().saturating_sub(now.minute() % 15); + format!("auto_{}{minute:02}", now.format("%Y%m%d%H")) +} + +fn remove_old_snapshots( + config: &Config, + alerts: Option<(&Signal, &str)>, + dataset_name: &str, +) -> Result<()> { + let retention = config.retention_for(dataset_name); + info!("Using {retention:?} for {dataset_name}"); + let snapshots = zfs::list_snapshots(dataset_name)?; + for snapshot in snapshots_to_delete(&snapshots, retention) { + info!("Deleting {dataset_name}@{snapshot}"); + if let Some(error) = zfs::delete_snapshot(dataset_name, snapshot)? { + let message = format!("{dataset_name}@{snapshot} failed to delete: {error}"); + error!("{message}"); + send_alert(alerts, &message)?; + } + } + Ok(()) +} + +fn snapshots_to_delete(snapshots: &[String], retention: Retention) -> Vec<&str> { + let mut tiers: [Vec<&str>; 4] = Default::default(); + for snapshot in snapshots { + if let Some(index) = bucket(snapshot) + && let Some(tier) = tiers.get_mut(index) + { + tier.push(snapshot); + } + } + + let counts = [ + retention.quarter_hourly, + retention.hourly, + retention.daily, + retention.monthly, + ]; + let mut deletions = Vec::new(); + for (mut tier, count) in tiers.into_iter().zip(counts) { + tier.sort_unstable(); + let delete_count = tier.len().saturating_sub(count); + deletions.extend(tier.into_iter().take(delete_count)); + } + deletions +} + +fn bucket(name: &str) -> Option { + let digits = name.strip_prefix("auto_")?; + if digits.len() != 12 || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let date = NaiveDate::from_ymd_opt( + digits.get(..4)?.parse().ok()?, + digits.get(4..6)?.parse().ok()?, + digits.get(6..8)?.parse().ok()?, + )?; + let time = NaiveTime::from_hms_opt( + digits.get(8..10)?.parse().ok()?, + digits.get(10..)?.parse().ok()?, + 0, + )?; + + // The tiers are disjoint: midnight belongs to daily, except on the first + // of the month, when it belongs to monthly. + match (time.minute(), time.hour(), date.day()) { + (15 | 30 | 45, _, _) => Some(0), + (0, 1..=23, _) => Some(1), + (0, 0, 2..=31) => Some(2), + (0, 0, 1) => Some(3), + _ => None, + } +} + +fn send_alert(alerts: Option<(&Signal, &str)>, body: &str) -> Result<()> { + if let Some((signal, to_phone)) = alerts { + signal.send(to_phone, body)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + + #[test] + fn dataset_overrides_default_and_other_datasets_fall_back() { + let config: Config = toml::from_str( + r#" + [default] + 15_min = 8 + hourly = 24 + daily = 0 + monthly = 0 + + ["pool/home"] + 15_min = 2 + hourly = 3 + daily = 4 + monthly = 5 + "#, + ) + .unwrap(); + assert_eq!( + config.retention_for("pool/home"), + Retention { + quarter_hourly: 2, + hourly: 3, + daily: 4, + monthly: 5, + } + ); + assert_eq!(config.retention_for("pool/other").hourly, 24); + assert_eq!(config.retention_for("pool/home/child").quarter_hourly, 8); + } + + #[test] + fn absent_default_uses_original_builtin_counts() { + let config: Config = toml::from_str("").unwrap(); + assert_eq!(config.retention_for("pool/home"), Retention::default()); + } + + #[test] + fn rejects_incomplete_negative_and_unknown_retention_settings() { + for settings in [ + "15_min = 1\nhourly = 2\ndaily = 3", + "15_min = -1\nhourly = 2\ndaily = 3\nmonthly = 4", + "15_min = 1\nhourly = 2\ndaily = 3\nmonthly = 4\nweekly = 5", + "15_min = 1.5\nhourly = 2\ndaily = 3\nmonthly = 4", + ] { + assert!(toml::from_str::(&format!("[default]\n{settings}")).is_err()); + } + } + + #[test] + fn timestamps_round_down_at_quarter_hour_boundaries() { + for (minute, expected) in [ + (0, "00"), + (14, "00"), + (15, "15"), + (29, "15"), + (30, "30"), + (44, "30"), + (45, "45"), + (59, "45"), + ] { + let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, minute, 59).unwrap(); + assert_eq!(timestamp(now), format!("auto_2026010100{expected}")); + } + } + + #[test] + fn tiers_are_disjoint_at_day_and_month_boundaries() { + for (name, expected) in [ + ("auto_202602010015", 0), + ("auto_202602010030", 0), + ("auto_202602010045", 0), + ("auto_202602010100", 1), + ("auto_202602012300", 1), + ("auto_202602020000", 2), + ("auto_202402290000", 2), + ("auto_202602010000", 3), + ("auto_202601010000", 3), + ] { + assert_eq!(bucket(name), Some(expected), "{name}"); + } + } + + #[test] + fn only_exact_valid_manager_names_are_eligible() { + for name in [ + "manual", + "manual_auto_202602010015", + "auto_202602010015_backup", + "auto_20260201001", + "auto_202602300000", + "auto_202602012400", + "auto_202602010060", + "auto_202602010005", + "auto_202602010015", + ] { + assert_eq!(bucket(name), None, "{name}"); + } + } + + #[test] + fn keeps_newest_in_each_tier_regardless_of_input_order() { + let snapshots = [ + "auto_202601010000", + "auto_202602020000", + "auto_202602020115", + "auto_202602020215", + "auto_202602020200", + "auto_202602020100", + "auto_202602030000", + "auto_202602010000", + "manual", + ] + .map(String::from); + let retention = Retention { + quarter_hourly: 1, + hourly: 1, + daily: 1, + monthly: 1, + }; + assert_eq!( + snapshots_to_delete(&snapshots, retention), + [ + "auto_202602020115", + "auto_202602020100", + "auto_202602020000", + "auto_202601010000", + ] + ); + } + + #[test] + fn zero_counts_delete_all_managed_snapshots_and_large_counts_keep_them() { + let snapshots = [ + "auto_202601010015", + "auto_202601010100", + "auto_202601020000", + "auto_202601010000", + "manual", + ] + .map(String::from); + let none = Retention { + quarter_hourly: 0, + hourly: 0, + daily: 0, + monthly: 0, + }; + assert_eq!(snapshots_to_delete(&snapshots, none).len(), 4); + let all = Retention { + quarter_hourly: 100, + hourly: 100, + daily: 100, + monthly: 100, + }; + assert!(snapshots_to_delete(&snapshots, all).is_empty()); + assert!(snapshots_to_delete(&[], none).is_empty()); + } +} diff --git a/rust/snapshot_manager/tests/cli.rs b/rust/snapshot_manager/tests/cli.rs new file mode 100644 index 0000000..9ce6589 --- /dev/null +++ b/rust/snapshot_manager/tests/cli.rs @@ -0,0 +1,202 @@ +#![cfg(test)] + +use std::{fs, os::unix::fs::symlink, path::Path, process::Command}; + +use tempfile::tempdir; + +fn command() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_snapshot-manager")); + command + .env_remove("SIGNAL_ALERTS") + .env_remove("SIGNAL_ALERT_FROM_PHONE") + .env_remove("SIGNAL_ALERT_TO_PHONE") + .env_remove("SNAPSHOT_TEST_SCRIPT") + .env_remove("RUST_LOG"); + command +} + +#[test] +fn help_works_without_phone_numbers() { + let output = command().arg("--help").output().unwrap(); + assert!(output.status.success()); + assert!( + String::from_utf8(output.stdout) + .unwrap() + .contains("") + ); +} + +#[test] +fn help_documents_environment_variables_without_exposing_phone_numbers() { + let output = command() + .env("SIGNAL_ALERT_FROM_PHONE", "+15555550101") + .env("SIGNAL_ALERT_TO_PHONE", "+15555550102") + .arg("--help") + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + for name in [ + "SIGNAL_ALERTS", + "SIGNAL_ALERT_FROM_PHONE", + "SIGNAL_ALERT_TO_PHONE", + ] { + assert!(stdout.contains(name), "{stdout}"); + } + assert!(!stdout.contains("+15555550101"), "{stdout}"); + assert!(!stdout.contains("+15555550102"), "{stdout}"); +} + +#[test] +fn config_argument_is_required() { + let output = command().output().unwrap(); + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn enabled_alerts_require_phone_numbers_before_running() { + for use_env in [false, true] { + for (from, to, expected) in [ + (None, None, "--signal-alert-from-phone"), + (Some(""), None, "--signal-alert-from-phone"), + (Some("+15555550101"), None, "--signal-alert-to-phone"), + (Some("+15555550101"), Some(""), "--signal-alert-to-phone"), + (None, Some("+15555550102"), "--signal-alert-from-phone"), + ] { + let mut command = command(); + if use_env { + command.env("SIGNAL_ALERTS", "true"); + } else { + command.args(["--signal-alerts", "true"]); + } + if let Some(from) = from { + command.env("SIGNAL_ALERT_FROM_PHONE", from); + } + if let Some(to) = to { + command.env("SIGNAL_ALERT_TO_PHONE", to); + } + let output = command.arg("unused.toml").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert_eq!(output.status.code(), Some(2), "{stderr}"); + assert!(stderr.contains(expected), "{stderr}"); + assert!(!stderr.contains("Starting snapshot_manager"), "{stderr}"); + } + } +} + +#[test] +fn enabled_alerts_accept_environment_variables_and_cli_overrides() { + let dir = tempdir().unwrap(); + let config = dir.path().join("config.toml"); + let scenario = dir.path().join("scenario"); + fs::write(&config, "").unwrap(); + // An empty dataset list completes successfully without sending an alert. + fs::write(&scenario, "").unwrap(); + symlink( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"), + dir.path().join("zfs"), + ) + .unwrap(); + + for use_cli in [false, true] { + let mut command = command(); + if use_cli { + command + .env("SIGNAL_ALERTS", "invalid") + .env("SIGNAL_ALERT_FROM_PHONE", "") + .env("SIGNAL_ALERT_TO_PHONE", "") + .args([ + "--signal-alerts", + "true", + "--signal-alert-from-phone", + "+15555550101", + "--signal-alert-to-phone", + "+15555550102", + ]); + } else { + command + .env("SIGNAL_ALERTS", "true") + .env("SIGNAL_ALERT_FROM_PHONE", "+15555550101") + .env("SIGNAL_ALERT_TO_PHONE", "+15555550102"); + } + let output = command + .current_dir(dir.path()) + .env("PATH", dir.path()) + .env("SNAPSHOT_TEST_SCRIPT", &scenario) + .arg(&config) + .output() + .unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(output.status.success(), "{stderr}"); + assert!(stderr.contains("snapshot_manager completed"), "{stderr}"); + } +} + +#[test] +fn disabled_alerts_allow_snapshot_failures_without_phone_numbers() { + let dir = tempdir().unwrap(); + let config = dir.path().join("config.toml"); + fs::write(&config, "").unwrap(); + symlink( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"), + dir.path().join("zfs"), + ) + .unwrap(); + + for (env_value, args) in [ + (None, vec![]), + (Some("false"), vec![]), + (None, vec!["--signal-alerts", "false"]), + (Some("true"), vec!["--signal-alerts", "false"]), + ] { + let mut command = command(); + if let Some(value) = env_value { + command.env("SIGNAL_ALERTS", value); + } + let output = command + .env("PATH", dir.path()) + .args(args) + .arg(&config) + .output() + .unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(output.status.success(), "{stderr}"); + assert!( + stderr.contains("pool/home failed to create snapshot"), + "{stderr}" + ); + assert!(stderr.contains("snapshot_manager completed"), "{stderr}"); + assert!(!stderr.contains("Signal"), "{stderr}"); + } +} + +#[test] +fn disabled_alerts_preserve_fatal_errors_without_phone_numbers() { + let dir = tempdir().unwrap(); + for args in [vec![], vec!["--signal-alerts", "false"]] { + let output = command() + .args(args) + .arg(dir.path().join("missing.toml")) + .output() + .unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert_eq!(output.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains("Failed to read configuration"), "{stderr}"); + assert!(!stderr.contains("SIGNAL_ALERT_"), "{stderr}"); + assert!(!stderr.contains("Failed to send failure alert"), "{stderr}"); + } +} + +#[test] +fn signal_alerts_requires_a_boolean_value() { + for use_env in [false, true] { + let mut command = command(); + if use_env { + command.env("SIGNAL_ALERTS", "invalid"); + } else { + command.args(["--signal-alerts", "invalid"]); + } + let output = command.arg("unused.toml").output().unwrap(); + assert_eq!(output.status.code(), Some(2)); + } +} diff --git a/rust/snapshot_manager/tests/fixtures/zfs b/rust/snapshot_manager/tests/fixtures/zfs new file mode 100755 index 0000000..ec2ed9e --- /dev/null +++ b/rust/snapshot_manager/tests/fixtures/zfs @@ -0,0 +1,12 @@ +#!/bin/sh +if [ -n "${SNAPSHOT_TEST_SCRIPT:-}" ]; then + printf '%s\n' "$*" >> calls + . "$SNAPSHOT_TEST_SCRIPT" + exit +fi + +case "$*" in + 'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;; + 'snapshot pool/home@'*) printf '%s\n' 'permission denied' >&2; exit 1 ;; + *) exit 99 ;; +esac diff --git a/rust/snapshot_manager/tests/manager.rs b/rust/snapshot_manager/tests/manager.rs new file mode 100644 index 0000000..5d973da --- /dev/null +++ b/rust/snapshot_manager/tests/manager.rs @@ -0,0 +1,290 @@ +#![cfg(test)] + +use std::{ + fs, + os::unix::fs::symlink, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +use tempfile::{TempDir, tempdir}; + +struct Fixture { + dir: TempDir, + config: PathBuf, +} + +impl Fixture { + fn new(script: &str) -> Self { + let dir = tempdir().unwrap(); + // Only the child process sees this PATH. The real zfs cannot be found. + symlink( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"), + dir.path().join("zfs"), + ) + .unwrap(); + // The executable is a checked-in fixture; scenario files are read by + // its shell, so parallel tests never execute a file being written. + fs::write(dir.path().join("scenario"), script).unwrap(); + let config = dir.path().join("config.toml"); + fs::write( + &config, + "[default]\n15_min = 1\nhourly = 0\ndaily = 0\nmonthly = 0\n", + ) + .unwrap(); + Self { dir, config } + } + + fn calls(&self) -> Vec { + fs::read_to_string(self.dir.path().join("calls")) + .unwrap_or_default() + .lines() + .map(String::from) + .collect() + } + + fn normalized_calls(&self) -> Vec { + self.calls() + .into_iter() + .map(|call| { + if call.starts_with("snapshot ") { + let (dataset, name) = call.split_once('@').unwrap(); + assert!(name.starts_with("auto_"), "{call}"); + format!("{dataset}@") + } else { + call + } + }) + .collect() + } + + fn run(&self) -> Output { + Command::new(env!("CARGO_BIN_EXE_snapshot-manager")) + .current_dir(self.dir.path()) + .env("PATH", self.dir.path()) + .env("SNAPSHOT_TEST_SCRIPT", self.dir.path().join("scenario")) + .env_remove("SIGNAL_ALERTS") + .env_remove("SIGNAL_ALERT_FROM_PHONE") + .env_remove("SIGNAL_ALERT_TO_PHONE") + .env_remove("RUST_LOG") + .arg(&self.config) + .output() + .unwrap() + } +} + +fn stderr(output: &Output) -> &str { + std::str::from_utf8(&output.stderr).unwrap() +} + +#[test] +fn creates_then_prunes_only_this_datasets_managed_snapshots() { + let fixture = Fixture::new( + r#" +case "$*" in + 'list -Hp -t filesystem -o name') + printf '%s\n' pool pool/home + printf '%s\n' 'warning on stderr' >&2 ;; + 'snapshot pool/home@auto_'*) ;; + 'list -Hp -t snapshot -o name -d 1 pool/home') + printf '%s\n' pool/home@auto_200002020145 pool/home@auto_200002020115 pool/home@manual pool/home@manual_auto_200001010015 pool/home/child@auto_200001010015 pool/other@auto_200001010015 ;; + 'destroy pool/home@auto_200002020115') ;; + *) exit 99 ;; +esac +"#, + ); + let output = fixture.run(); + assert!(output.status.success(), "{}", stderr(&output)); + assert_eq!( + fixture.normalized_calls(), + [ + "list -Hp -t filesystem -o name", + "snapshot pool/home@", + "list -Hp -t snapshot -o name -d 1 pool/home", + "destroy pool/home@auto_200002020115", + ] + ); +} + +#[test] +fn failed_or_duplicate_creation_skips_pruning_but_continues() { + for error in ["snapshot already exists", "permission denied"] { + let fixture = Fixture::new(&format!( + r#" +case "$*" in + 'list -Hp -t filesystem -o name') printf '%s\n' pool/home pool/other ;; + 'snapshot pool/home@auto_'*) printf '%s\n' '{error}' >&2; exit 1 ;; + 'snapshot pool/other@auto_'*) ;; + 'list -Hp -t snapshot -o name -d 1 pool/other') ;; + *) exit 99 ;; +esac +"# + )); + let output = fixture.run(); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stderr(&output).contains("pool/home failed to create snapshot auto_")); + assert!(stderr(&output).contains(error)); + assert_eq!( + fixture.normalized_calls(), + [ + "list -Hp -t filesystem -o name", + "snapshot pool/home@", + "snapshot pool/other@", + "list -Hp -t snapshot -o name -d 1 pool/other", + ] + ); + let calls = fixture.calls(); + assert_eq!( + calls[1].split_once('@').unwrap().1, + calls[2].split_once('@').unwrap().1 + ); + } +} + +#[test] +fn dependent_clone_failure_is_reported_and_remaining_deletions_continue() { + let fixture = Fixture::new( + r#" +case "$*" in + 'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;; + 'snapshot pool/home@auto_'*) ;; + 'list -Hp -t snapshot -o name -d 1 pool/home') + printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145 ;; + 'destroy pool/home@auto_200002020115') + printf '%s\n' "cannot destroy 'pool/home@auto_200002020115': snapshot has dependent clones" >&2; exit 1 ;; + 'destroy pool/home@auto_200002020130') ;; + *) exit 99 ;; +esac +"#, + ); + let output = fixture.run(); + assert!(output.status.success(), "{}", stderr(&output)); + assert!( + stderr(&output).contains( + "pool/home@auto_200002020115 failed to delete: snapshot has dependent clones" + ) + ); + assert_eq!( + fixture.calls().last().unwrap(), + "destroy pool/home@auto_200002020130" + ); +} + +#[test] +fn unexpected_destroy_failure_aborts_and_reports_the_error() { + let fixture = Fixture::new( + r#" +case "$1" in + list) + if [ "$4" = filesystem ]; then + printf '%s\n' pool/home + else + printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145 + fi ;; + snapshot) ;; + destroy) printf '%s\n' 'permission denied' >&2; exit 1 ;; +esac +"#, + ); + let output = fixture.run(); + assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); + assert!(stderr(&output).contains("permission denied")); + assert!(stderr(&output).contains("snapshot_manager failed")); + assert_eq!( + fixture.calls().last().unwrap(), + "destroy pool/home@auto_200002020115" + ); +} + +#[test] +fn failed_list_never_uses_partial_output() { + for stream in ["", " >&2"] { + let fixture = Fixture::new(&format!( + "printf '%s\\n' pool/home\nprintf '%s\\n' 'cannot list datasets'{stream}\nexit 1" + )); + let output = fixture.run(); + assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); + assert!(stderr(&output).contains("cannot list datasets")); + assert!(stderr(&output).contains("snapshot_manager failed")); + assert_eq!(fixture.calls(), ["list -Hp -t filesystem -o name"]); + } +} + +#[test] +fn failed_snapshot_listing_aborts_without_destroying_anything() { + let fixture = Fixture::new( + r#" +case "$*" in + 'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;; + 'snapshot pool/home@auto_'*) ;; + *) printf '%s\n' pool/home@auto_200002020115; exit 1 ;; +esac +"#, + ); + let output = fixture.run(); + assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); + assert!(stderr(&output).contains("snapshot_manager failed")); + assert_eq!(fixture.calls().len(), 3); +} + +#[test] +fn invalid_config_stops_before_any_zfs_commands() { + let fixture = Fixture::new("exit 99"); + fs::write( + &fixture.config, + "[default]\n15_min = -1\nhourly = 1\ndaily = 0\nmonthly = 0", + ) + .unwrap(); + let output = fixture.run(); + assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); + assert!(stderr(&output).contains("Invalid configuration")); + assert!(fixture.calls().is_empty()); +} + +#[test] +fn missing_zfs_executable_fails_and_reports_the_error() { + let fixture = Fixture::new("exit 99"); + fs::remove_file(fixture.dir.path().join("zfs")).unwrap(); + let output = fixture.run(); + assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); + assert!(stderr(&output).contains("Failed to execute zfs")); + assert!(stderr(&output).contains("snapshot_manager failed")); + assert!(fixture.calls().is_empty()); +} + +#[test] +fn dataset_names_are_passed_as_one_argument_without_a_shell() { + let fixture = Fixture::new( + r#" +case "$1" in + list) + [ "$*" = 'list -Hp -t filesystem -o name' ] || exit 98 + printf '%s\n' 'pool/two words;touch injected' ;; + snapshot) + [ "$#" -eq 2 ] || exit 97 + case "$2" in + 'pool/two words;touch injected@auto_'*) printf '%s\n' 'argument preserved' >&2; exit 1 ;; + *) exit 96 ;; + esac ;; + *) exit 99 ;; +esac +"#, + ); + let output = fixture.run(); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stderr(&output).contains("argument preserved")); + assert_eq!( + fixture.normalized_calls(), + [ + "list -Hp -t filesystem -o name", + "snapshot pool/two words;touch injected@" + ] + ); + assert!(fs::read_dir(fixture.dir.path()).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("injected") + })); +} diff --git a/rust/van_weather/package.nix b/rust/van_weather/package.nix index 6a9fa09..24a74d9 100644 --- a/rust/van_weather/package.nix +++ b/rust/van_weather/package.nix @@ -2,15 +2,7 @@ rustPlatform.buildRustPackage { pname = "van-weather"; version = "0.1.0"; - src = lib.fileset.toSource { - root = ../..; - fileset = lib.fileset.unions [ - ../../Cargo.toml - ../../Cargo.lock - ../../clippy.toml - ../. - ]; - }; + src = import ../rust-source.nix { inherit lib; }; cargoLock.lockFile = ../../Cargo.lock; cargoBuildFlags = [ diff --git a/systems/jeeves/runners/nix_builder.nix b/systems/jeeves/runners/nix_builder.nix index 03ea1e1..c9900c7 100644 --- a/systems/jeeves/runners/nix_builder.nix +++ b/systems/jeeves/runners/nix_builder.nix @@ -107,6 +107,7 @@ let nixos-rebuild nodejs ruff + rustfmt treefmt wget ]; diff --git a/tests/test_snapshot_manager.py b/tests/test_snapshot_manager.py deleted file mode 100644 index 30f6958..0000000 --- a/tests/test_snapshot_manager.py +++ /dev/null @@ -1,204 +0,0 @@ -"""test_snapshot_manager.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest - -from python.tools.snapshot_manager import get_snapshots_to_delete, get_time_stamp, load_config_data, main -from python.zfs.dataset import Dataset, Snapshot - -if TYPE_CHECKING: - from pyfakefs.fake_filesystem import FakeFilesystem - from pytest_mock import MockerFixture - -SNAPSHOT_MANAGER = "python.tools.snapshot_manager" -FROM_PHONE = "1234567890" -TO_PHONE = "0987654321" - - -def patch_phone_numbers(mocker: MockerFixture) -> None: - """Patch the Signal phone number environment variables.""" - phone_numbers = { - "SIGNAL_ALERT_FROM_PHONE": FROM_PHONE, - "SIGNAL_ALERT_TO_PHONE": TO_PHONE, - } - mocker.patch(f"{SNAPSHOT_MANAGER}.getenv", side_effect=phone_numbers.get) - - -def patch_utcnow(mocker: MockerFixture, datetime_value: datetime) -> None: - """patch_utcnow.""" - mocker.patch("python.tools.snapshot_manager.utcnow", return_value=datetime_value) - - -def create_mock_snapshot(mocker: MockerFixture, name: str) -> Snapshot: - """create_mock_snapshot.""" - mock_snapshot = mocker.MagicMock(spec=Snapshot) - mock_snapshot.name = name - - return mock_snapshot - - -def test_main(mocker: MockerFixture, fs: FakeFilesystem) -> None: - """Test main.""" - load_config_data.cache_clear() - patch_phone_numbers(mocker) - - mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00") - - mock_dataset = mocker.MagicMock(spec=Dataset) - mock_dataset.name = "test_dataset" - mock_dataset.create_snapshot.return_value = "snapshot created" - mock_get_datasets = mocker.patch(f"{SNAPSHOT_MANAGER}.get_datasets", return_value=(mock_dataset,)) - - mock_get_snapshots_to_delete = mocker.patch(f"{SNAPSHOT_MANAGER}.get_snapshots_to_delete") - mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - mock_snapshot_config_toml = '["default"]\n15_min = 8\nhourly = 24\ndaily = 0\nmonthly = 0\n' - fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml) - main(Path("/mock_snapshot_config.toml")) - - mock_signal_alert.assert_not_called() - mock_get_datasets.assert_called_once() - mock_get_snapshots_to_delete.assert_called_once_with( - mock_dataset, - { - "15_min": 8, - "hourly": 24, - "daily": 0, - "monthly": 0, - }, - FROM_PHONE, - TO_PHONE, - ) - - -def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem) -> None: - """Test main.""" - load_config_data.cache_clear() - patch_phone_numbers(mocker) - - mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00") - - mock_dataset = mocker.MagicMock(spec=Dataset) - mock_dataset.name = "test_dataset" - mock_dataset.create_snapshot.return_value = "snapshot not created" - mock_get_datasets = mocker.patch(f"{SNAPSHOT_MANAGER}.get_datasets", return_value=(mock_dataset,)) - - mock_get_snapshots_to_delete = mocker.patch(f"{SNAPSHOT_MANAGER}.get_snapshots_to_delete") - mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - mock_snapshot_config_toml = '["default"]\n15_min = 8\nhourly = 24\ndaily = 0\nmonthly = 0\n' - fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml) - main(Path("/mock_snapshot_config.toml")) - - mock_signal_alert.assert_called_once_with( - "test_dataset failed to create snapshot 2023-01-01T00:00:00", - FROM_PHONE, - TO_PHONE, - ) - mock_get_datasets.assert_called_once() - mock_get_snapshots_to_delete.assert_not_called() - - -def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None: - """Test main.""" - load_config_data.cache_clear() - patch_phone_numbers(mocker) - - mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00") - - mock_dataset = mocker.MagicMock(spec=Dataset) - mock_dataset.name = "test_dataset" - mock_dataset.create_snapshot.return_value = "snapshot created" - mock_get_datasets = mocker.patch(f"{SNAPSHOT_MANAGER}.get_datasets", side_effect=Exception("test")) - - mock_get_snapshots_to_delete = mocker.patch(f"{SNAPSHOT_MANAGER}.get_snapshots_to_delete") - mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - mock_snapshot_config_toml = '["default"]\n15_min = 8\nhourly = 24\ndaily = 0\nmonthly = 0\n' - fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml) - with pytest.raises(SystemExit) as pytest_wrapped_e: - main(Path("/mock_snapshot_config.toml")) - - assert isinstance(pytest_wrapped_e.value, SystemExit) - assert pytest_wrapped_e.value.code == 1 - mock_signal_alert.assert_called_once_with("snapshot_manager failed", FROM_PHONE, TO_PHONE) - mock_get_datasets.assert_called_once() - mock_get_snapshots_to_delete.assert_not_called() - - -def test_get_snapshots_to_delete(mocker: MockerFixture) -> None: - """test_get_snapshots_to_delete.""" - mock_snapshot_0 = create_mock_snapshot(mocker, "auto_202509150415") - mock_snapshot_1 = create_mock_snapshot(mocker, "auto_202509150415") - - mock_dataset = mocker.MagicMock(spec=Dataset) - mock_dataset.name = "test_dataset" - mock_dataset.get_snapshots.return_value = (mock_snapshot_0, mock_snapshot_1) - mock_dataset.delete_snapshot.return_value = None - - mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - - get_snapshots_to_delete( - mock_dataset, - {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}, - FROM_PHONE, - TO_PHONE, - ) - - mock_signal_alert.assert_not_called() - mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415") - - -def test_get_snapshots_to_delete_no_snapshot(mocker: MockerFixture) -> None: - """test_get_snapshots_to_delete_no_snapshot.""" - mock_dataset = mocker.MagicMock(spec=Dataset) - mock_dataset.name = "test_dataset" - mock_dataset.get_snapshots.return_value = () - mock_dataset.delete_snapshot.return_value = None - - mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - - get_snapshots_to_delete( - mock_dataset, - {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}, - FROM_PHONE, - TO_PHONE, - ) - - mock_signal_alert.assert_not_called() - mock_dataset.delete_snapshot.assert_not_called() - - -def test_get_snapshots_to_delete_errored(mocker: MockerFixture) -> None: - """test_get_snapshots_to_delete_errored.""" - mock_snapshot_0 = create_mock_snapshot(mocker, "auto_202509150415") - mock_snapshot_1 = create_mock_snapshot(mocker, "auto_202509150415") - - mock_dataset = mocker.MagicMock(spec=Dataset) - mock_dataset.name = "test_dataset" - mock_dataset.get_snapshots.return_value = (mock_snapshot_0, mock_snapshot_1) - mock_dataset.delete_snapshot.return_value = "snapshot has dependent clones" - - mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - - get_snapshots_to_delete( - mock_dataset, - {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}, - FROM_PHONE, - TO_PHONE, - ) - - mock_signal_alert.assert_called_once_with( - "test_dataset@auto_202509150415 failed to delete: snapshot has dependent clones", - FROM_PHONE, - TO_PHONE, - ) - mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415") - - -def test_get_time_stamp(mocker: MockerFixture) -> None: - """Test get_time_stamp.""" - patch_utcnow(mocker, datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC)) - assert get_time_stamp() == "auto_202301010000" diff --git a/treefmt.toml b/treefmt.toml index 8471893..e31c06b 100644 --- a/treefmt.toml +++ b/treefmt.toml @@ -13,6 +13,11 @@ command = "nixfmt" # Glob pattern of files to include includes = ["*.nix"] +[formatter.rustfmt] +command = "rustfmt" +options = ["--edition", "2024"] +includes = ["*.rs"] + [formatter.ruff-format] command = "ruff" options = ["format"] -- 2.55.0