diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index 04082de..524f20a 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -1066,12 +1066,20 @@ Actions: - `list` - `status` +- `zfs` - `mount` - `unmount` - `configure` `list` returns parsed `/proc/mounts` and `/proc/partitions` data. +`zfs` is a read-only discovery action. It reports whether the ZFS kernel +module or command-line tools are present and, when available, returns `pools` +and `datasets` from `zpool list` and `zfs list`. Missing ZFS support returns +`available: false` with empty arrays rather than an error. + +`status` also includes the same `zfs` discovery object. + `status` payload: ```json @@ -1211,6 +1219,8 @@ Actions: - `interfaces` - `status` +- `dns` +- `routes` - `get_config` - `set_config` - `reload` @@ -1218,6 +1228,10 @@ Actions: `interfaces` returns interface names, MAC addresses, and operstate from `/sys/class/net`. +`dns` returns the current `/etc/resolv.conf` contents in `resolv_conf` (or +`null` when unavailable). `routes` returns parsed JSON output from +`ip -json route show`. Both actions are read-only. + `status` payload: ```json diff --git a/src/agent/modules/network.rs b/src/agent/modules/network.rs index 59742c3..0697ffd 100644 --- a/src/agent/modules/network.rs +++ b/src/agent/modules/network.rs @@ -37,6 +37,8 @@ const INFO: ModuleInfo = ModuleInfo { "plan", "interfaces", "status", + "dns", + "routes", "get_config", "set_config", "reload", @@ -67,6 +69,8 @@ actions!(Action [payload user] => { let args = payload.ip_args(); crate::cmd!({ &INFO, "status", user } "ip" => &args ; JSON) }, + Dns => Ok(jsonf! { "resolv_conf": read_optional("/etc/resolv.conf") }), + Routes => crate::cmd!({ &INFO, "routes", user } "ip" ["-json", "route", "show"] JSON), GetConfig: NetworkConfigRequest => { Ok(jsonf! { payload.path, "contents": payload.read()? }) }, @@ -98,6 +102,12 @@ actions!(Action [payload user] => { /// virtual or bond devices may not report an operstate), so a single unreadable /// file does not fail the whole snapshot. Results are sorted by name for stable /// output to the control plane. +fn read_optional(path: &str) -> Option { + fs::read_to_string(path) + .ok() + .map(|contents| contents.trim().to_owned()) +} + fn read_interfaces() -> Result> { let mut interfaces = Vec::new(); for entry in fs::read_dir("/sys/class/net").context("failed to read /sys/class/net")? { @@ -122,6 +132,12 @@ mod tests { use super::*; use crate::agent::module_support::SelinuxOptions; + #[test] + fn dns_read_returns_a_stable_shape() { + let response = Dns.handle(None).unwrap(); + assert!(response.get("resolv_conf").is_some()); + } + #[test] fn dry_run_set_config_does_not_write() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/agent/modules/storage.rs b/src/agent/modules/storage.rs index 4409b79..d7ea63c 100644 --- a/src/agent/modules/storage.rs +++ b/src/agent/modules/storage.rs @@ -10,6 +10,7 @@ use crate::prelude::*; use crate::agent::module_support::{SelinuxOptions, apply_selinux}; +use std::process::Command; /// Storage module entry point registered under feature `storage`. #[derive(Clone, Copy, Debug)] @@ -26,6 +27,7 @@ const INFO: ModuleInfo = ModuleInfo { "plan", "list", "status", + "zfs", "mount", "unmount", "configure", @@ -39,6 +41,13 @@ impl Mod for StorageModule { } fn handle(&self, action: &str, payload: Value, user: Option<&str>) -> Result { + if matches!(action, "capabilities" | "plan") { + let mut response = INFO + .metadata_response(action, &payload) + .expect("metadata action matched"); + response["zfs"] = discover_zfs(); + return Ok(response); + } Action::from_payload(action, payload)?.handle(user) } } @@ -57,8 +66,9 @@ actions!(Action [payload user] => { dry_run: bool, } => { let result = crate::cmd!({ &INFO, "status", user } "df" ["-h", payload.path.to_string_lossy().as_ref()] json)?; - Ok(jsonf! { result, payload.dry_run }) + Ok(jsonf! { result, payload.dry_run, "zfs": discover_zfs() }) }, + Zfs => Ok(discover_zfs()), Mount { source: String, target: String, @@ -108,6 +118,76 @@ actions!(Action [payload user] => { }, }); +/// Discover ZFS without making its absence an error. Distributions may expose +/// the tools in different locations, so invoking by PATH is preferable to +/// assuming `/usr/sbin` or `/sbin`. Both commands are read-only. +fn discover_zfs() -> Value { + let kernel_module = Path::new("/sys/module/zfs").exists() + || fs::read_to_string("/proc/modules") + .is_ok_and(|modules| modules.lines().any(|line| line.starts_with("zfs "))); + let (tooling, pools) = run_zfs_list( + "zpool", + &[ + "list", + "-H", + "-p", + "-o", + "name,size,alloc,free,frag,cap,dedupratio,health,altroot", + ], + &[ + "name", + "size", + "allocated", + "free", + "fragmentation", + "capacity", + "deduplication", + "health", + "altroot", + ], + ); + let (zfs_tooling, datasets) = run_zfs_list( + "zfs", + &[ + "list", + "-H", + "-p", + "-o", + "name,used,available,refer,mountpoint", + ], + &["name", "used", "available", "referenced", "mountpoint"], + ); + jsonf! { + "available": kernel_module || tooling || zfs_tooling, + kernel_module, + "tools": tooling || zfs_tooling, + pools, + datasets, + } +} + +fn run_zfs_list(program: &str, args: &[&str], fields: &[&str]) -> (bool, Vec) { + let output = match Command::new(program).args(args).output() { + Ok(output) if output.status.success() => output, + _ => return (false, Vec::new()), + }; + let rows = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let values: Vec<_> = line.split('\t').collect(); + (values.len() == fields.len()).then(|| { + fields + .iter() + .zip(values) + .map(|(field, value)| ((*field).to_owned(), Value::String(value.to_owned()))) + .collect::>() + }) + }) + .map(Value::Object) + .collect(); + (true, rows) +} + /// Default fstab location used when `configure` is invoked without an /// explicit `fstab_path`. fn default_fstab_path() -> PathBuf { @@ -190,6 +270,13 @@ fn append_fstab_entry(path: &PathBuf, entry: &str) -> Result<()> { mod tests { use super::*; + #[test] + fn absent_zfs_is_reported_without_failing() { + let discovery = discover_zfs(); + assert!(discovery["pools"].is_array()); + assert!(discovery["datasets"].is_array()); + } + #[test] fn parses_mounts_file() { let dir = tempfile::tempdir().unwrap();