feat: add colibri-zfs, colibri-pf crates + MCP tools + wiki tools
New crates: - colibri-zfs: Snapshot::list(), destroy(), destroy_all(), older_than() Structured ZFS snapshot lifecycle without shell parsing - colibri-pf: list_rules(), list_states() Structured PF firewall state inspection New MCP tools (6): - colibri_zfs_list_snapshots: list snapshots for a dataset - colibri_zfs_destroy_snapshot: destroy a snapshot by name - colibri_pf_list_rules: active PF firewall rules - colibri_pf_list_states: PF state table entries - colibri_wiki_search: search wiki pages with line excerpts - colibri_wiki_page: read wiki page content in full Tool count: 12 → 18
This commit is contained in:
parent
c05ca3a7b7
commit
dd45200692
10 changed files with 544 additions and 4 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -386,6 +386,8 @@ dependencies = [
|
|||
"clap",
|
||||
"colibri-client",
|
||||
"colibri-daemon",
|
||||
"colibri-pf",
|
||||
"colibri-zfs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
|
@ -394,6 +396,15 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-pf"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-runtime"
|
||||
version = "0.12.0"
|
||||
|
|
@ -437,6 +448,15 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-zfs"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/clawdie"]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/clawdie"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
|
|
|
|||
|
|
@ -354,8 +354,10 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) {
|
|||
let payload_line = serde_json::to_string(&payload).unwrap_or_default();
|
||||
let mut child = match std::process::Command::new("ssh")
|
||||
.args([
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
&mother_host,
|
||||
"report-task-cost",
|
||||
])
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
colibri-client = { path = "../colibri-client" }
|
||||
colibri-daemon = { path = "../colibri-daemon" }
|
||||
colibri-pf = { path = "../colibri-pf" }
|
||||
colibri-zfs = { path = "../colibri-zfs" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@
|
|||
//! | `colibri_create_task` | write-gated| Create a task |
|
||||
//! | `colibri_intake_task` | write-gated| Submit intake task with capabilities|
|
||||
//! | `colibri_set_cost_mode` | write-gated | Switch cost mode (fast/smart/max) |
|
||||
//! | `colibri_zfs_list_snapshots` | read-only | ZFS snapshot listing |
|
||||
//! | `colibri_zfs_destroy_snapshot` | write-gated | Destroy a ZFS snapshot |
|
||||
//! | `colibri_pf_list_rules` | read-only | Active PF firewall rules |
|
||||
//! | `colibri_pf_list_states` | read-only | PF state table entries |
|
||||
//! | `colibri_wiki_search` | read-only | Search wiki pages |
|
||||
//! | `colibri_wiki_page` | read-only | Read wiki page content |
|
||||
//! | `colibri_get_task` | read-only | Task details with cost data |
|
||||
//! | `colibri_list_task_costs` | read-only | All tasks with cost (dashboard) |
|
||||
//!
|
||||
|
|
@ -168,6 +174,62 @@ pub fn tool_list() -> Vec<Value> {
|
|||
}
|
||||
})),
|
||||
),
|
||||
// ── Infrastructure tools ──
|
||||
json_tool(
|
||||
"colibri_zfs_list_snapshots",
|
||||
"List ZFS snapshots for a dataset. Returns structured data: name, used_bytes, refer_bytes, creation, age_hours.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dataset": { "type": "string", "description": "ZFS dataset name, e.g. 'zroot/home/clawdie'" }
|
||||
},
|
||||
"required": ["dataset"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_zfs_destroy_snapshot",
|
||||
"Destroy a ZFS snapshot by full name. Requires ZFS destroy permission.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Full snapshot name, e.g. 'zroot/home@autosnap_2026-06-27_09:00:00_hourly'" }
|
||||
},
|
||||
"required": ["name"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_pf_list_rules",
|
||||
"List active PF firewall rules. Returns array of rule strings.",
|
||||
None,
|
||||
),
|
||||
json_tool(
|
||||
"colibri_pf_list_states",
|
||||
"List active PF state table entries: protocol, source, destination, state.",
|
||||
None,
|
||||
),
|
||||
// ── Wiki tools ──
|
||||
json_tool(
|
||||
"colibri_wiki_search",
|
||||
"Search Colibri wiki pages for a keyword. Returns matching page names and line excerpts.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search keyword or phrase" }
|
||||
},
|
||||
"required": ["query"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_wiki_page",
|
||||
"Read a Colibri wiki page in full. Returns the complete markdown content.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": { "type": "string", "description": "Wiki page name without .md, e.g. 'cost-model' or 'sl/cost-model'" }
|
||||
},
|
||||
"required": ["page"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_external_mcp_servers",
|
||||
"List configured external MCP servers from COLIBRI_MCP_EXTERNAL_CONFIG",
|
||||
|
|
@ -324,6 +386,53 @@ pub async fn dispatch_tool(
|
|||
let all_tasks = client.list_tasks(status).await.map_err(map_client_error)?;
|
||||
Ok(tool_text(all_tasks))
|
||||
}
|
||||
// ── Infrastructure dispatch ──
|
||||
"colibri_zfs_list_snapshots" => {
|
||||
let dataset = require_string(arguments, "dataset")?;
|
||||
let snaps = colibri_zfs::Snapshot::list(&dataset)
|
||||
.map_err(|e| McpError::internal(format!("zfs: {e}")))?;
|
||||
Ok(tool_text(serde_json::to_value(&snaps).unwrap_or_default()))
|
||||
}
|
||||
"colibri_zfs_destroy_snapshot" => {
|
||||
let name = require_string(arguments, "name")?;
|
||||
let snap = colibri_zfs::Snapshot {
|
||||
name,
|
||||
dataset: String::new(),
|
||||
tag: String::new(),
|
||||
used_bytes: 0,
|
||||
refer_bytes: 0,
|
||||
creation: String::new(),
|
||||
};
|
||||
snap.destroy()
|
||||
.map_err(|e| McpError::internal(format!("zfs destroy: {e}")))?;
|
||||
Ok(tool_text(serde_json::json!({"destroyed": true})))
|
||||
}
|
||||
"colibri_pf_list_rules" => {
|
||||
let rules =
|
||||
colibri_pf::list_rules().map_err(|e| McpError::internal(format!("pf: {e}")))?;
|
||||
Ok(tool_text(serde_json::to_value(&rules).unwrap_or_default()))
|
||||
}
|
||||
"colibri_pf_list_states" => {
|
||||
let states =
|
||||
colibri_pf::list_states().map_err(|e| McpError::internal(format!("pf: {e}")))?;
|
||||
Ok(tool_text(serde_json::to_value(&states).unwrap_or_default()))
|
||||
}
|
||||
// ── Wiki dispatch ──
|
||||
"colibri_wiki_search" => {
|
||||
let query = require_string(arguments, "query")?;
|
||||
let results = wiki_search(&query);
|
||||
Ok(tool_text(
|
||||
serde_json::to_value(&results).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
"colibri_wiki_page" => {
|
||||
let page = require_string(arguments, "page")?;
|
||||
let content = wiki_page(&page)
|
||||
.ok_or_else(|| McpError::not_found(format!("wiki page not found: {page}")))?;
|
||||
Ok(tool_text(
|
||||
serde_json::json!({"page": page, "content": content}),
|
||||
))
|
||||
}
|
||||
"colibri_external_mcp_servers" => {
|
||||
let registry = external::load_registry_if_present(&config.external_config_path).await?;
|
||||
Ok(tool_text(serde_json::json!({
|
||||
|
|
@ -588,3 +697,70 @@ impl StdioHandler {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiki helpers ──
|
||||
|
||||
fn wiki_search(query: &str) -> Vec<serde_json::Value> {
|
||||
let wiki_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/wiki");
|
||||
let q = query.to_lowercase();
|
||||
let mut results = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&wiki_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(true, |e| e != "md") {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let matches: Vec<String> = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| l.to_lowercase().contains(&q))
|
||||
.take(3)
|
||||
.map(|(i, l)| format!("L{}: {}", i + 1, l.trim()))
|
||||
.collect();
|
||||
if !matches.is_empty() {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
results.push(serde_json::json!({
|
||||
"page": name,
|
||||
"matches": matches
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also search sl/ subdirectory
|
||||
let sl_dir = wiki_dir.join("sl");
|
||||
if let Ok(entries) = std::fs::read_dir(&sl_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(true, |e| e != "md") {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let matches: Vec<String> = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| l.to_lowercase().contains(&q))
|
||||
.take(3)
|
||||
.map(|(i, l)| format!("L{}: {}", i + 1, l.trim()))
|
||||
.collect();
|
||||
if !matches.is_empty() {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
results.push(serde_json::json!({
|
||||
"page": format!("sl/{}", name),
|
||||
"matches": matches
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn wiki_page(page: &str) -> Option<String> {
|
||||
let wiki_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/wiki");
|
||||
let path = wiki_dir.join(format!("{page}.md"));
|
||||
std::fs::read_to_string(&path).ok()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,5 +255,5 @@ fn tool_list_has_all_phase1_tools() {
|
|||
assert!(names.contains(&"colibri_list_task_costs"));
|
||||
|
||||
assert!(names.contains(&"colibri_get_task"));
|
||||
assert_eq!(names.len(), 12);
|
||||
assert_eq!(names.len(), 18);
|
||||
}
|
||||
|
|
|
|||
10
crates/colibri-pf/Cargo.toml
Normal file
10
crates/colibri-pf/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "colibri-pf"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
86
crates/colibri-pf/src/lib.rs
Normal file
86
crates/colibri-pf/src/lib.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! colibri-pf — structured PF firewall inspection for Colibri agents.
|
||||
//!
|
||||
//! Wraps `pfctl(8)` output into Rust structs. Read-only operations
|
||||
//! work without root; state/rule manipulation requires privileges.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PfRule {
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PfState {
|
||||
pub proto: String,
|
||||
pub src: String,
|
||||
pub dst: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("pfctl failed: {0}")]
|
||||
Command(String),
|
||||
}
|
||||
|
||||
/// List active PF rules (equivalent to `pfctl -s rules`).
|
||||
pub fn list_rules() -> Result<Vec<String>, Error> {
|
||||
let output = Command::new("pfctl")
|
||||
.args(["-s", "rules"])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("pfctl: {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(Error::Command(
|
||||
String::from_utf8_lossy(&output.stderr).into(),
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('@'))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// List active PF states (equivalent to `pfctl -s state`).
|
||||
pub fn list_states() -> Result<Vec<PfState>, Error> {
|
||||
let output = Command::new("pfctl")
|
||||
.args(["-s", "state"])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("pfctl: {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(Error::Command(
|
||||
String::from_utf8_lossy(&output.stderr).into(),
|
||||
));
|
||||
}
|
||||
let mut states = Vec::new();
|
||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("all") {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
states.push(PfState {
|
||||
proto: parts[0].to_string(),
|
||||
src: format!("{} {}", parts[1], parts.get(2).unwrap_or(&"")),
|
||||
dst: format!("{} {}", parts[3], parts.get(4).unwrap_or(&"")),
|
||||
state: parts.last().unwrap_or(&"").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_rules_does_not_panic() {
|
||||
// pfctl may not be present (Linux CI), but the function should not panic.
|
||||
let _ = list_rules();
|
||||
let _ = list_states();
|
||||
}
|
||||
}
|
||||
10
crates/colibri-zfs/Cargo.toml
Normal file
10
crates/colibri-zfs/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "colibri-zfs"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
234
crates/colibri-zfs/src/lib.rs
Normal file
234
crates/colibri-zfs/src/lib.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
//! colibri-zfs — structured ZFS snapshot lifecycle for Colibri agents.
|
||||
//!
|
||||
//! Wraps `zfs(8)` output into Rust structs so MCP tools can reason about
|
||||
//! snapshots without shell parsing. ZFS operations run on the host — the
|
||||
//! daemon runs as user `colibri`, which needs `zfs allow` delegations or
|
||||
//! `sudo` for destructive operations.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use colibri_zfs::Snapshot;
|
||||
//! let snaps = Snapshot::list("zroot/home/clawdie").unwrap();
|
||||
//! for s in &snaps {
|
||||
//! println!("{} {} {}", s.name, s.used_bytes, s.creation);
|
||||
//! }
|
||||
//! // Destroy snapshots older than 24h
|
||||
//! let old: Vec<_> = snaps.into_iter()
|
||||
//! .filter(|s| s.age_hours() > 24.0)
|
||||
//! .collect();
|
||||
//! Snapshot::destroy_all(&old).unwrap();
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// A single ZFS snapshot as reported by `zfs list -Hp -t snapshot`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
/// Full snapshot name, e.g. "zroot/home/clawdie@autosnap_2026-06-27_09:00:00_hourly"
|
||||
pub name: String,
|
||||
/// Dataset portion, e.g. "zroot/home/clawdie"
|
||||
pub dataset: String,
|
||||
/// Snapshot tag, e.g. "autosnap_2026-06-27_09:00:00_hourly"
|
||||
pub tag: String,
|
||||
/// Space used by this snapshot (bytes). Note: ZFS `used` includes
|
||||
/// space uniquely held by this snapshot after accounting for clones
|
||||
/// and child snapshots.
|
||||
pub used_bytes: u64,
|
||||
/// Space referenced (bytes) — total data accessible in this snapshot,
|
||||
/// shared or not.
|
||||
pub refer_bytes: u64,
|
||||
/// Snapshot creation time as reported by ZFS.
|
||||
pub creation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("zfs command failed: {0}")]
|
||||
Command(String),
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// List all snapshots for a dataset and its children.
|
||||
/// Uses `zfs list -Hp -t snapshot -o name,used,refer,creation -r <dataset>`.
|
||||
pub fn list(dataset: &str) -> Result<Vec<Self>, Error> {
|
||||
let output = Command::new("zfs")
|
||||
.args([
|
||||
"list",
|
||||
"-Hp",
|
||||
"-t",
|
||||
"snapshot",
|
||||
"-o",
|
||||
"name,used,refer,creation",
|
||||
"-r",
|
||||
dataset,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("zfs list: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::Command(format!("zfs list failed: {stderr}")));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut snaps = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.splitn(4, '\t').collect();
|
||||
if parts.len() < 4 {
|
||||
return Err(Error::Parse(format!("unexpected zfs output: {line}")));
|
||||
}
|
||||
let full_name = parts[0].to_string();
|
||||
let (ds, tag) = full_name
|
||||
.split_once('@')
|
||||
.ok_or_else(|| Error::Parse(format!("no @ in snapshot name: {full_name}")))?;
|
||||
snaps.push(Snapshot {
|
||||
dataset: ds.to_string(),
|
||||
tag: tag.to_string(),
|
||||
name: full_name,
|
||||
used_bytes: parts[1].parse().unwrap_or(0),
|
||||
refer_bytes: parts[2].parse().unwrap_or(0),
|
||||
creation: parts[3].to_string(),
|
||||
});
|
||||
}
|
||||
Ok(snaps)
|
||||
}
|
||||
|
||||
/// Destroy a single snapshot. Requires ZFS destroy permission.
|
||||
pub fn destroy(&self) -> Result<(), Error> {
|
||||
let output = Command::new("zfs")
|
||||
.args(["destroy", &self.name])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("zfs destroy {}: {e}", self.name)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::Command(format!("zfs destroy: {stderr}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Destroy multiple snapshots. Each failure is collected and returned.
|
||||
pub fn destroy_all(snaps: &[Snapshot]) -> Result<(), Error> {
|
||||
let mut errors = Vec::new();
|
||||
for s in snaps {
|
||||
if let Err(e) = s.destroy() {
|
||||
errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Command(errors.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Age of the snapshot in hours, parsed from the creation timestamp.
|
||||
/// Returns 0.0 if the timestamp cannot be parsed.
|
||||
pub fn age_hours(&self) -> f64 {
|
||||
parse_zfs_timestamp(&self.creation)
|
||||
.and_then(|created| SystemTime::now().duration_since(created).ok())
|
||||
.map(|d| d.as_secs_f64() / 3600.0)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Filter to snapshots older than `hours`.
|
||||
pub fn older_than(snaps: &[Snapshot], hours: f64) -> Vec<Snapshot> {
|
||||
snaps
|
||||
.iter()
|
||||
.filter(|s| s.age_hours() > hours)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a ZFS timestamp like "Mon Jun 23 01:15 2026" into SystemTime.
|
||||
/// ZFS outputs creation time in `date(1)` format when not using `-p`.
|
||||
fn parse_zfs_timestamp(s: &str) -> Option<SystemTime> {
|
||||
// With -p flag, ZFS outputs Unix seconds (e.g. "1719270900")
|
||||
if let Ok(secs) = s.parse::<u64>() {
|
||||
return UNIX_EPOCH.checked_add(Duration::from_secs(secs));
|
||||
}
|
||||
// Fallback: try standard date format
|
||||
if let Ok(t) = chrono_parse(s) {
|
||||
return Some(t);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn chrono_parse(_s: &str) -> Result<SystemTime, ()> {
|
||||
// Avoid pulling in chrono as a dependency. If the seconds parse fails,
|
||||
// we return 0.0 hours — the age_hours() caller handles this gracefully.
|
||||
Err(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snapshot_parse_basic() {
|
||||
let line = "zroot/home@test\t1024000\t2048000\t1719270900";
|
||||
// Parse is tested indirectly via Snapshot::list which parses lines.
|
||||
// Unit test validates age_hours with a known timestamp.
|
||||
let s = Snapshot {
|
||||
name: "zroot/home@test".into(),
|
||||
dataset: "zroot/home".into(),
|
||||
tag: "test".into(),
|
||||
used_bytes: 1_024_000,
|
||||
refer_bytes: 2_048_000,
|
||||
creation: "1719270900".into(),
|
||||
};
|
||||
// This timestamp is in the past, so age should be positive.
|
||||
assert!(s.age_hours() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_than_filter() {
|
||||
let ancient = Snapshot {
|
||||
name: "pool@old".into(),
|
||||
dataset: "pool".into(),
|
||||
tag: "old".into(),
|
||||
used_bytes: 1000,
|
||||
refer_bytes: 2000,
|
||||
creation: "1".into(), // epoch start — very old
|
||||
};
|
||||
let recent = Snapshot {
|
||||
name: "pool@new".into(),
|
||||
dataset: "pool".into(),
|
||||
tag: "new".into(),
|
||||
used_bytes: 1000,
|
||||
refer_bytes: 2000,
|
||||
creation: format!(
|
||||
"{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
),
|
||||
};
|
||||
let all = vec![ancient.clone(), recent];
|
||||
let old = Snapshot::older_than(&all, 1.0);
|
||||
assert_eq!(old.len(), 1);
|
||||
assert_eq!(old[0].tag, "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_nonexistent_is_error() {
|
||||
let s = Snapshot {
|
||||
name: "zroot/nonexistent@definitely_not_real_2026".into(),
|
||||
dataset: "zroot/nonexistent".into(),
|
||||
tag: "definitely_not_real_2026".into(),
|
||||
used_bytes: 0,
|
||||
refer_bytes: 0,
|
||||
creation: "0".into(),
|
||||
};
|
||||
assert!(s.destroy().is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue