1use std::collections::HashMap;
11use std::process::Command;
12use std::time::{Duration, Instant};
13
14use crate::snapshot::{ZfsDevice, ZfsPool};
15
16const ZPOOL_REFRESH: Duration = Duration::from_secs(30);
18
19#[derive(Debug, Clone, Default, PartialEq)]
21pub(crate) struct PoolStatus {
22 pub devices: Vec<ZfsDevice>,
23 pub scan: Option<String>,
24 pub data_errors: Option<u64>,
25}
26
27#[derive(Debug, Default)]
30struct Found {
31 status: HashMap<String, PoolStatus>,
32 sizes: HashMap<String, (u64, u64)>,
33}
34
35#[derive(Debug, Default)]
37pub struct ZpoolReader {
38 last: Option<(Instant, Found)>,
39}
40
41impl ZpoolReader {
42 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn annotate(&mut self, pools: &mut [ZfsPool], now: Instant) {
49 if pools.is_empty() {
50 return;
51 }
52 let fresh = self
53 .last
54 .as_ref()
55 .is_some_and(|(at, _)| now.saturating_duration_since(*at) < ZPOOL_REFRESH);
56 if !fresh {
57 let status = zpool(&["status", "-p"])
58 .map(|t| parse_status(&t))
59 .unwrap_or_default();
60 let sizes = zpool(&["list", "-p", "-H", "-o", "name,size,alloc"])
61 .map(|t| parse_list(&t))
62 .unwrap_or_default();
63 self.last = Some((now, Found { status, sizes }));
64 }
65 let Some((_, found)) = &self.last else {
66 return;
67 };
68 for pool in pools {
69 if let Some(status) = found.status.get(&pool.name) {
70 pool.devices = status.devices.clone();
71 pool.scan = status.scan.clone();
72 pool.data_errors = status.data_errors;
73 }
74 if let Some(&(size, allocated)) = found.sizes.get(&pool.name) {
75 pool.size_bytes = Some(size);
76 pool.allocated_bytes = Some(allocated);
77 }
78 }
79 }
80}
81
82fn zpool(args: &[&str]) -> Option<String> {
84 let output = Command::new("zpool")
85 .args(args)
86 .env("LC_ALL", "C")
87 .output()
88 .ok()
89 .filter(|out| out.status.success())?;
90 Some(String::from_utf8_lossy(&output.stdout).into_owned())
91}
92
93const SECTIONS: [&str; 5] = ["logs", "cache", "spares", "special", "dedup"];
95
96pub(crate) fn parse_status(text: &str) -> HashMap<String, PoolStatus> {
98 let mut pools = HashMap::new();
99 let mut name: Option<String> = None;
100 let mut status = PoolStatus::default();
101 let mut field = "";
103 let mut section = String::new();
104 let mut pool_indent: Option<usize> = None;
105 for line in text.lines() {
106 if let Some(rest) = line.trim_start().strip_prefix("pool:")
107 && !line.starts_with('\t')
108 {
109 if let Some(done) = name.take() {
110 pools.insert(done, std::mem::take(&mut status));
111 }
112 name = Some(rest.trim().to_owned());
113 field = "";
114 section.clear();
115 pool_indent = None;
116 continue;
117 }
118 if name.is_none() {
119 continue;
120 }
121 if !line.starts_with('\t') {
122 let Some((key, value)) = line.trim_start().split_once(':') else {
124 continue;
125 };
126 field = key.trim();
127 let value = value.trim();
128 match field {
129 "scan" => status.scan = Some(value.to_owned()),
130 "errors" => status.data_errors = parse_data_errors(value),
131 _ => {}
132 }
133 continue;
134 }
135 let body = &line[1..];
136 match field {
137 "scan" => {
139 if let Some(scan) = &mut status.scan
140 && body.contains("done")
141 {
142 scan.push_str(" · ");
143 scan.push_str(body.trim());
144 }
145 }
146 "config" => {
147 let indent = body.len() - body.trim_start().len();
148 let words: Vec<&str> = body.split_whitespace().collect();
149 let Some(&first) = words.first() else {
150 continue;
151 };
152 if first == "NAME" {
153 continue;
154 }
155 let Some(base) = pool_indent else {
157 pool_indent = Some(indent);
158 continue;
159 };
160 if indent == base && SECTIONS.contains(&first) && words.len() == 1 {
161 section = first.to_owned();
162 continue;
163 }
164 let depth = (indent.saturating_sub(base + 2) / 2) as u8;
165 let count = |i: usize| {
166 words
167 .get(i)
168 .and_then(|w| w.parse::<u64>().ok())
169 .unwrap_or(0)
170 };
171 status.devices.push(ZfsDevice {
172 name: first.to_owned(),
173 depth,
174 section: section.clone(),
175 state: words.get(1).map_or(String::new(), |s| (*s).to_owned()),
176 read_errors: count(2),
177 write_errors: count(3),
178 checksum_errors: count(4),
179 });
180 }
181 _ => {}
182 }
183 }
184 if let Some(done) = name {
185 pools.insert(done, status);
186 }
187 pools
188}
189
190fn parse_data_errors(value: &str) -> Option<u64> {
193 if value.starts_with("No known data errors") {
194 return Some(0);
195 }
196 value.split_whitespace().next()?.parse().ok()
197}
198
199pub(crate) fn parse_list(text: &str) -> HashMap<String, (u64, u64)> {
202 text.lines()
203 .filter_map(|line| {
204 let mut fields = line.split('\t');
205 let name = fields.next()?.to_owned();
206 let size = fields.next()?.parse().ok()?;
207 let allocated = fields.next()?.parse().ok()?;
208 Some((name, (size, allocated)))
209 })
210 .collect()
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn one(text: &str, pool: &str) -> PoolStatus {
218 parse_status(text).remove(pool).expect("pool parsed")
219 }
220
221 #[test]
222 fn reads_layout_errors_and_scrub() {
223 let tank = one(include_str!("testdata/zpool-status-checksum.txt"), "tank");
224 let names: Vec<(&str, u8)> = tank
225 .devices
226 .iter()
227 .map(|d| (d.name.as_str(), d.depth))
228 .collect();
229 assert_eq!(
230 names,
231 [
232 ("raidz1-0", 0),
233 ("virtio-disk1", 1),
234 ("virtio-disk2", 1),
235 ("virtio-disk3", 1)
236 ]
237 );
238 assert_eq!(tank.devices[2].checksum_errors, 491);
239 assert_eq!(tank.data_errors, Some(0));
240 assert!(
241 tank.scan
242 .as_deref()
243 .unwrap()
244 .starts_with("scrub repaired 27.7M")
245 );
246
247 let pool = ZfsPool {
248 name: "tank".to_owned(),
249 state: "ONLINE".to_owned(),
250 devices: tank.devices,
251 ..Default::default()
252 };
253 assert_eq!(pool.layout().as_deref(), Some("raidz1"));
254 let troubled: Vec<&str> = pool.troubled_devices().map(|d| d.name.as_str()).collect();
255 assert_eq!(troubled, ["virtio-disk2"]);
256 }
257
258 #[test]
259 fn reads_degraded_mirrors_data_errors_and_sections() {
260 let backup = one(include_str!("testdata/zpool-status-degraded.txt"), "backup");
261 assert_eq!(backup.devices[2].state, "OFFLINE");
262 assert!(backup.devices[2].is_unhealthy());
263
264 let tank = one(
265 include_str!("testdata/zpool-status-data-errors.txt"),
266 "tank",
267 );
268 assert_eq!(tank.data_errors, Some(442));
269 assert!(tank.scan.as_deref().unwrap().contains("with 442 errors"));
270
271 let logs = one(
272 include_str!("testdata/zpool-status-log-spare.txt"),
273 "backup",
274 );
275 let rows: Vec<(&str, &str, &str)> = logs
276 .devices
277 .iter()
278 .map(|d| (d.name.as_str(), d.section.as_str(), d.state.as_str()))
279 .collect();
280 assert_eq!(
281 rows,
282 [
283 ("mirror-0", "", "ONLINE"),
284 ("virtio-disk4", "", "ONLINE"),
285 ("virtio-disk5", "", "ONLINE"),
286 ("/var/tmp/log1", "logs", "ONLINE"),
287 ("/var/tmp/spare1", "spares", "AVAIL"),
288 ]
289 );
290 let scan = logs.scan.unwrap();
292 assert!(
293 scan.starts_with("scrub paused since") && scan.contains("75.76% done"),
294 "{scan}"
295 );
296 let pool = ZfsPool {
297 devices: logs.devices,
298 ..Default::default()
299 };
300 assert_eq!(pool.layout().as_deref(), Some("mirror"));
301 assert_eq!(
302 pool.troubled_devices().count(),
303 0,
304 "a waiting spare is fine"
305 );
306
307 let resilvered = one(
308 include_str!("testdata/zpool-status-resilvered.txt"),
309 "backup",
310 );
311 assert!(resilvered.scan.unwrap().starts_with("resilvered 36K"));
312 }
313
314 #[test]
315 fn several_pools_and_sizes() {
316 let text = format!(
317 "{}\n{}",
318 include_str!("testdata/zpool-status-degraded.txt"),
319 include_str!("testdata/zpool-status-checksum.txt")
320 );
321 let pools = parse_status(&text);
322 assert_eq!(pools.len(), 2);
323 let sizes = parse_list(include_str!("testdata/zpool-list.txt"));
324 assert_eq!(sizes["tank"], (2_952_790_016, 315_153_408));
325 assert!(parse_status("no pools available\n").is_empty());
326 let pool = |names: &[&str]| ZfsPool {
328 devices: names
329 .iter()
330 .map(|n| ZfsDevice {
331 name: (*n).to_owned(),
332 depth: 0,
333 state: "ONLINE".to_owned(),
334 ..Default::default()
335 })
336 .collect(),
337 ..Default::default()
338 };
339 assert_eq!(pool(&["sda", "sdb"]).layout().as_deref(), Some("stripe"));
340 assert_eq!(
341 pool(&["mirror-0", "mirror-1"]).layout().as_deref(),
342 Some("2 × mirror")
343 );
344 }
345}