Skip to main content

rigtop_core/linux/
zpool.rs

1//! Details of ZFS pools from `zpool status -p` and `zpool list -p`: the
2//! vdev layout, each device's state and error counts, the last scrub or
3//! resilver and permanent data errors. Both work without root wherever
4//! `/dev/zfs` is open to everyone (the default on Ubuntu and others).
5//!
6//! The pools themselves (and their health) come from the kernel module's
7//! statistics ([`super::zfs`]); `zpool` is only run when there are pools,
8//! at most every [`ZPOOL_REFRESH`].
9
10use std::collections::HashMap;
11use std::process::Command;
12use std::time::{Duration, Instant};
13
14use crate::snapshot::{ZfsDevice, ZfsPool};
15
16/// How often `zpool` is run again.
17const ZPOOL_REFRESH: Duration = Duration::from_secs(30);
18
19/// What `zpool status` tells about one pool.
20#[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/// What one run of `zpool` found: each pool's status, and its size and
28/// allocated bytes.
29#[derive(Debug, Default)]
30struct Found {
31    status: HashMap<String, PoolStatus>,
32    sizes: HashMap<String, (u64, u64)>,
33}
34
35/// Pool details, cached between refreshes.
36#[derive(Debug, Default)]
37pub struct ZpoolReader {
38    last: Option<(Instant, Found)>,
39}
40
41impl ZpoolReader {
42    /// Creates a reader with an empty cache.
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Adds the details `zpool` gives to `pools` (from the kernel module).
48    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
82/// Runs `zpool` with `args` in the C locale; its output, if it succeeded.
83fn 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
93/// Sections of a pool's configuration after its data vdevs.
94const SECTIONS: [&str; 5] = ["logs", "cache", "spares", "special", "dedup"];
95
96/// Each pool of `zpool status -p` output, by name.
97pub(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    // The field a continuation line (starting with a tab) belongs to.
102    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            // "  scan: ...", "config:", "errors: ..."
123            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            // The progress of a running scan: keep the "% done" part.
138            "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                // The pool's own row comes first, at the outermost indent.
156                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
190/// The count of `errors: 442 data errors, use '-v' for a list`; 0 for
191/// `No known data errors`.
192fn 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
199/// Size and allocated bytes by pool, from `zpool list -p -H -o
200/// name,size,alloc`.
201pub(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        // A paused scrub keeps its progress.
291        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        // Devices straight under the pool: a stripe; two mirrors.
327        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}