From: Lukas Wagner <l.wagner@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH proxmox 06/26] disks: import from Proxmox Backup Server
Date: Thu, 12 Mar 2026 14:52:07 +0100 [thread overview]
Message-ID: <20260312135229.420729-7-l.wagner@proxmox.com> (raw)
In-Reply-To: <20260312135229.420729-1-l.wagner@proxmox.com>
This is based on the disks module from PBS and left unchanged.
The version has not been set to 1.0 yet since it seems like this crate
could use a bit a cleanup (custom error type instead of anyhow,
documentation).
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Cargo.toml | 6 +
proxmox-disks/Cargo.toml | 30 +
proxmox-disks/debian/changelog | 5 +
proxmox-disks/debian/control | 94 ++
proxmox-disks/debian/copyright | 18 +
proxmox-disks/debian/debcargo.toml | 7 +
proxmox-disks/src/lib.rs | 1396 ++++++++++++++++++++++++++++
proxmox-disks/src/lvm.rs | 60 ++
proxmox-disks/src/parse_helpers.rs | 52 ++
proxmox-disks/src/smart.rs | 227 +++++
proxmox-disks/src/zfs.rs | 205 ++++
proxmox-disks/src/zpool_list.rs | 294 ++++++
proxmox-disks/src/zpool_status.rs | 496 ++++++++++
13 files changed, 2890 insertions(+)
create mode 100644 proxmox-disks/Cargo.toml
create mode 100644 proxmox-disks/debian/changelog
create mode 100644 proxmox-disks/debian/control
create mode 100644 proxmox-disks/debian/copyright
create mode 100644 proxmox-disks/debian/debcargo.toml
create mode 100644 proxmox-disks/src/lib.rs
create mode 100644 proxmox-disks/src/lvm.rs
create mode 100644 proxmox-disks/src/parse_helpers.rs
create mode 100644 proxmox-disks/src/smart.rs
create mode 100644 proxmox-disks/src/zfs.rs
create mode 100644 proxmox-disks/src/zpool_list.rs
create mode 100644 proxmox-disks/src/zpool_status.rs
diff --git a/Cargo.toml b/Cargo.toml
index 97593a5d..8f3886bd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,7 @@ members = [
"proxmox-config-digest",
"proxmox-daemon",
"proxmox-deb-version",
+ "proxmox-disks",
"proxmox-dns-api",
"proxmox-fixed-string",
"proxmox-docgen",
@@ -112,6 +113,9 @@ mail-parser = "0.11"
md5 = "0.7.0"
native-tls = "0.2"
nix = "0.29"
+nom = "7"
+# used by proxmox-disks, can be replaced by OnceLock from std once it supports get_or_try_init
+once_cell = "1.3.1"
openssl = "0.10"
pam-sys = "0.5"
percent-encoding = "2.1"
@@ -139,6 +143,7 @@ tracing = "0.1"
tracing-journald = "0.3.1"
tracing-log = { version = "0.2", default-features = false }
tracing-subscriber = "0.3.16"
+udev = "0.9"
url = "2.2"
walkdir = "2"
zstd = "0.13"
@@ -154,6 +159,7 @@ proxmox-async = { version = "0.5.0", path = "proxmox-async" }
proxmox-base64 = { version = "1.0.0", path = "proxmox-base64" }
proxmox-compression = { version = "1.0.0", path = "proxmox-compression" }
proxmox-daemon = { version = "1.0.0", path = "proxmox-daemon" }
+proxmox-disks = { version = "0.1.0", path = "proxmox-daemon" }
proxmox-fixed-string = { version = "0.1.0", path = "proxmox-fixed-string" }
proxmox-http = { version = "1.0.5", path = "proxmox-http" }
proxmox-http-error = { version = "1.0.0", path = "proxmox-http-error" }
diff --git a/proxmox-disks/Cargo.toml b/proxmox-disks/Cargo.toml
new file mode 100644
index 00000000..29bf56fe
--- /dev/null
+++ b/proxmox-disks/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "proxmox-disks"
+description = "disk management and utilities"
+version = "0.1.0"
+
+authors.workspace = true
+edition.workspace = true
+exclude.workspace = true
+homepage.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+crossbeam-channel.workspace = true
+libc.workspace = true
+nix.workspace = true
+nom.workspace = true
+once_cell.workspace = true
+regex.workspace = true
+serde_json.workspace = true
+serde.workspace = true
+udev.workspace = true
+
+proxmox-io.workspace = true
+proxmox-lang.workspace = true
+proxmox-log.workspace = true
+proxmox-parallel-handler.workspace = true
+proxmox-schema = { workspace = true, features = [ "api-macro", "api-types" ] }
+proxmox-sys.workspace = true
diff --git a/proxmox-disks/debian/changelog b/proxmox-disks/debian/changelog
new file mode 100644
index 00000000..d41a2000
--- /dev/null
+++ b/proxmox-disks/debian/changelog
@@ -0,0 +1,5 @@
+rust-proxmox-disks (0.1.0-1) unstable; urgency=medium
+
+ * initial version.
+
+ -- Proxmox Support Team <support@proxmox.com> Tue, 10 Mar 2026 15:05:21 +0100
diff --git a/proxmox-disks/debian/control b/proxmox-disks/debian/control
new file mode 100644
index 00000000..2b5dfb68
--- /dev/null
+++ b/proxmox-disks/debian/control
@@ -0,0 +1,94 @@
+Source: rust-proxmox-disks
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
+ librust-crossbeam-channel-0.5+default-dev <!nocheck>,
+ librust-libc-0.2+default-dev (>= 0.2.107-~~) <!nocheck>,
+ librust-nix-0.29+default-dev <!nocheck>,
+ librust-nom-7+default-dev <!nocheck>,
+ librust-once-cell-1+default-dev (>= 1.3.1-~~) <!nocheck>,
+ librust-proxmox-io-1+default-dev (>= 1.2.1-~~) <!nocheck>,
+ librust-proxmox-lang-1+default-dev (>= 1.5-~~) <!nocheck>,
+ librust-proxmox-log-1+default-dev <!nocheck>,
+ librust-proxmox-parallel-handler-1+default-dev <!nocheck>,
+ librust-proxmox-schema-5+api-types-dev (>= 5.0.1-~~) <!nocheck>,
+ librust-proxmox-schema-5+default-dev (>= 5.0.1-~~) <!nocheck>,
+ librust-proxmox-sys-1+default-dev <!nocheck>,
+ librust-regex-1+default-dev (>= 1.5-~~) <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-json-1+default-dev <!nocheck>,
+ librust-udev-0.9+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: git://git.proxmox.com/git/proxmox.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: proxmox-disks
+
+Package: librust-proxmox-disks-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-anyhow-1+default-dev,
+ librust-crossbeam-channel-0.5+default-dev,
+ librust-libc-0.2+default-dev (>= 0.2.107-~~),
+ librust-nix-0.29+default-dev,
+ librust-nom-7+default-dev,
+ librust-once-cell-1+default-dev (>= 1.3.1-~~),
+ librust-proxmox-io-1+default-dev (>= 1.2.1-~~),
+ librust-proxmox-lang-1+default-dev (>= 1.5-~~),
+ librust-proxmox-log-1+default-dev,
+ librust-proxmox-parallel-handler-1+default-dev,
+ librust-proxmox-sys-1+default-dev,
+ librust-regex-1+default-dev (>= 1.5-~~),
+ librust-serde-1+default-dev,
+ librust-serde-json-1+default-dev,
+ librust-udev-0.9+default-dev
+Recommends:
+ librust-proxmox-disks+default-dev (= ${binary:Version})
+Suggests:
+ librust-proxmox-disks+api-types-dev (= ${binary:Version})
+Provides:
+ librust-proxmox-disks-0-dev (= ${binary:Version}),
+ librust-proxmox-disks-0.1-dev (= ${binary:Version}),
+ librust-proxmox-disks-0.1.0-dev (= ${binary:Version})
+Description: Disk management and utilities - Rust source code
+ Source code for Debianized Rust crate "proxmox-disks"
+
+Package: librust-proxmox-disks+api-types-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-proxmox-disks-dev (= ${binary:Version}),
+ librust-proxmox-schema-5+api-macro-dev (>= 5.0.1-~~),
+ librust-proxmox-schema-5+api-types-dev (>= 5.0.1-~~)
+Provides:
+ librust-proxmox-disks-0+api-types-dev (= ${binary:Version}),
+ librust-proxmox-disks-0.1+api-types-dev (= ${binary:Version}),
+ librust-proxmox-disks-0.1.0+api-types-dev (= ${binary:Version})
+Description: Disk management and utilities - feature "api-types"
+ This metapackage enables feature "api-types" for the Rust proxmox-disks crate,
+ by pulling in any additional dependencies needed by that feature.
+
+Package: librust-proxmox-disks+default-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-proxmox-disks-dev (= ${binary:Version}),
+ librust-proxmox-schema-5+api-types-dev (>= 5.0.1-~~),
+ librust-proxmox-schema-5+default-dev (>= 5.0.1-~~)
+Provides:
+ librust-proxmox-disks-0+default-dev (= ${binary:Version}),
+ librust-proxmox-disks-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-disks-0.1.0+default-dev (= ${binary:Version})
+Description: Disk management and utilities - feature "default"
+ This metapackage enables feature "default" for the Rust proxmox-disks crate, by
+ pulling in any additional dependencies needed by that feature.
diff --git a/proxmox-disks/debian/copyright b/proxmox-disks/debian/copyright
new file mode 100644
index 00000000..01138fa0
--- /dev/null
+++ b/proxmox-disks/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/proxmox-disks/debian/debcargo.toml b/proxmox-disks/debian/debcargo.toml
new file mode 100644
index 00000000..b7864cdb
--- /dev/null
+++ b/proxmox-disks/debian/debcargo.toml
@@ -0,0 +1,7 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+vcs_git = "git://git.proxmox.com/git/proxmox.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox.git"
diff --git a/proxmox-disks/src/lib.rs b/proxmox-disks/src/lib.rs
new file mode 100644
index 00000000..e6056c14
--- /dev/null
+++ b/proxmox-disks/src/lib.rs
@@ -0,0 +1,1396 @@
+//! Disk query/management utilities for.
+
+use std::collections::{HashMap, HashSet};
+use std::ffi::{OsStr, OsString};
+use std::io;
+use std::os::unix::ffi::{OsStrExt, OsStringExt};
+use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt};
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, LazyLock};
+
+use anyhow::{bail, format_err, Context as _, Error};
+use libc::dev_t;
+use once_cell::sync::OnceCell;
+
+use ::serde::{Deserialize, Serialize};
+
+use proxmox_lang::{io_bail, io_format_err};
+use proxmox_log::info;
+use proxmox_parallel_handler::ParallelHandler;
+use proxmox_schema::api;
+use proxmox_sys::linux::procfs::{mountinfo::Device, MountInfo};
+
+use proxmox_schema::api_types::{
+ BLOCKDEVICE_DISK_AND_PARTITION_NAME_REGEX, BLOCKDEVICE_NAME_REGEX, UUID_REGEX,
+};
+
+mod zfs;
+pub use zfs::*;
+mod zpool_status;
+pub use zpool_status::*;
+mod zpool_list;
+pub use zpool_list::*;
+mod lvm;
+pub use lvm::*;
+mod smart;
+pub use smart::*;
+
+mod parse_helpers;
+
+static ISCSI_PATH_REGEX: LazyLock<regex::Regex> =
+ LazyLock::new(|| regex::Regex::new(r"host[^/]*/session[^/]*").unwrap());
+
+/// Disk management context.
+///
+/// This provides access to disk information with some caching for faster querying of multiple
+/// devices.
+pub struct DiskManage {
+ mount_info: OnceCell<MountInfo>,
+ mounted_devices: OnceCell<HashSet<dev_t>>,
+}
+
+/// Information for a device as returned by lsblk.
+#[derive(Deserialize)]
+pub struct LsblkInfo {
+ /// Path to the device.
+ path: String,
+ /// Partition type GUID.
+ #[serde(rename = "parttype")]
+ partition_type: Option<String>,
+ /// File system label.
+ #[serde(rename = "fstype")]
+ file_system_type: Option<String>,
+ /// File system UUID.
+ uuid: Option<String>,
+}
+
+impl DiskManage {
+ /// Create a new disk management context.
+ pub fn new() -> Arc<Self> {
+ Arc::new(Self {
+ mount_info: OnceCell::new(),
+ mounted_devices: OnceCell::new(),
+ })
+ }
+
+ /// Get the current mount info. This simply caches the result of `MountInfo::read` from the
+ /// `proxmox::sys` module.
+ pub fn mount_info(&self) -> Result<&MountInfo, Error> {
+ self.mount_info.get_or_try_init(MountInfo::read)
+ }
+
+ /// Get a `Disk` from a device node (eg. `/dev/sda`).
+ pub fn disk_by_node<P: AsRef<Path>>(self: Arc<Self>, devnode: P) -> io::Result<Disk> {
+ let devnode = devnode.as_ref();
+
+ let meta = std::fs::metadata(devnode)?;
+ if (meta.mode() & libc::S_IFBLK) == libc::S_IFBLK {
+ self.disk_by_dev_num(meta.rdev())
+ } else {
+ io_bail!("not a block device: {:?}", devnode);
+ }
+ }
+
+ /// Get a `Disk` for a specific device number.
+ pub fn disk_by_dev_num(self: Arc<Self>, devnum: dev_t) -> io::Result<Disk> {
+ self.disk_by_sys_path(format!(
+ "/sys/dev/block/{}:{}",
+ unsafe { libc::major(devnum) },
+ unsafe { libc::minor(devnum) },
+ ))
+ }
+
+ /// Get a `Disk` for a path in `/sys`.
+ pub fn disk_by_sys_path<P: AsRef<Path>>(self: Arc<Self>, path: P) -> io::Result<Disk> {
+ let device = udev::Device::from_syspath(path.as_ref())?;
+ Ok(Disk {
+ manager: self,
+ device,
+ info: Default::default(),
+ })
+ }
+
+ /// Get a `Disk` for a name in `/sys/block/<name>`.
+ pub fn disk_by_name(self: Arc<Self>, name: &str) -> io::Result<Disk> {
+ let syspath = format!("/sys/block/{name}");
+ self.disk_by_sys_path(syspath)
+ }
+
+ /// Get a `Disk` for a name in `/sys/class/block/<name>`.
+ pub fn partition_by_name(self: Arc<Self>, name: &str) -> io::Result<Disk> {
+ let syspath = format!("/sys/class/block/{name}");
+ self.disk_by_sys_path(syspath)
+ }
+
+ /// Gather information about mounted disks:
+ fn mounted_devices(&self) -> Result<&HashSet<dev_t>, Error> {
+ self.mounted_devices
+ .get_or_try_init(|| -> Result<_, Error> {
+ let mut mounted = HashSet::new();
+
+ for (_id, mp) in self.mount_info()? {
+ let source = match mp.mount_source.as_deref() {
+ Some(s) => s,
+ None => continue,
+ };
+
+ let path = Path::new(source);
+ if !path.is_absolute() {
+ continue;
+ }
+
+ let meta = match std::fs::metadata(path) {
+ Ok(meta) => meta,
+ Err(ref err) if err.kind() == io::ErrorKind::NotFound => continue,
+ Err(other) => return Err(Error::from(other)),
+ };
+
+ if (meta.mode() & libc::S_IFBLK) != libc::S_IFBLK {
+ // not a block device
+ continue;
+ }
+
+ mounted.insert(meta.rdev());
+ }
+
+ Ok(mounted)
+ })
+ }
+
+ /// Information about file system type and used device for a path
+ ///
+ /// Returns tuple (fs_type, device, mount_source)
+ pub fn find_mounted_device(
+ &self,
+ path: &std::path::Path,
+ ) -> Result<Option<(String, Device, Option<OsString>)>, Error> {
+ let stat = nix::sys::stat::stat(path)?;
+ let device = Device::from_dev_t(stat.st_dev);
+
+ let root_path = std::path::Path::new("/");
+
+ for (_id, entry) in self.mount_info()? {
+ if entry.root == root_path && entry.device == device {
+ return Ok(Some((
+ entry.fs_type.clone(),
+ entry.device,
+ entry.mount_source.clone(),
+ )));
+ }
+ }
+
+ Ok(None)
+ }
+
+ /// Check whether a specific device node is mounted.
+ ///
+ /// Note that this tries to `stat` the sources of all mount points without caching the result
+ /// of doing so, so this is always somewhat expensive.
+ pub fn is_devnum_mounted(&self, dev: dev_t) -> Result<bool, Error> {
+ self.mounted_devices().map(|mounted| mounted.contains(&dev))
+ }
+}
+
+/// Queries (and caches) various information about a specific disk.
+///
+/// This belongs to a `Disks` and provides information for a single disk.
+pub struct Disk {
+ manager: Arc<DiskManage>,
+ device: udev::Device,
+ info: DiskInfo,
+}
+
+/// Helper struct (so we can initialize this with Default)
+///
+/// We probably want this to be serializable to the same hash type we use in perl currently.
+#[derive(Default)]
+struct DiskInfo {
+ size: OnceCell<u64>,
+ vendor: OnceCell<Option<OsString>>,
+ model: OnceCell<Option<OsString>>,
+ rotational: OnceCell<Option<bool>>,
+ // for perl: #[serde(rename = "devpath")]
+ ata_rotation_rate_rpm: OnceCell<Option<u64>>,
+ // for perl: #[serde(rename = "devpath")]
+ device_path: OnceCell<Option<PathBuf>>,
+ wwn: OnceCell<Option<OsString>>,
+ serial: OnceCell<Option<OsString>>,
+ // for perl: #[serde(skip_serializing)]
+ partition_table_type: OnceCell<Option<OsString>>,
+ // for perl: #[serde(skip_serializing)]
+ partition_entry_scheme: OnceCell<Option<OsString>>,
+ // for perl: #[serde(skip_serializing)]
+ partition_entry_uuid: OnceCell<Option<OsString>>,
+ // for perl: #[serde(skip_serializing)]
+ partition_entry_type: OnceCell<Option<OsString>>,
+ gpt: OnceCell<bool>,
+ // ???
+ bus: OnceCell<Option<OsString>>,
+ // ???
+ fs_type: OnceCell<Option<OsString>>,
+ // ???
+ has_holders: OnceCell<bool>,
+ // ???
+ is_mounted: OnceCell<bool>,
+}
+
+impl Disk {
+ /// Try to get the device number for this disk.
+ ///
+ /// (In udev this can fail...)
+ pub fn devnum(&self) -> Result<dev_t, Error> {
+ // not sure when this can fail...
+ self.device
+ .devnum()
+ .ok_or_else(|| format_err!("failed to get device number"))
+ }
+
+ /// Get the sys-name of this device. (The final component in the `/sys` path).
+ pub fn sysname(&self) -> &OsStr {
+ self.device.sysname()
+ }
+
+ /// Get the this disk's `/sys` path.
+ pub fn syspath(&self) -> &Path {
+ self.device.syspath()
+ }
+
+ /// Get the device node in `/dev`, if any.
+ pub fn device_path(&self) -> Option<&Path> {
+ //self.device.devnode()
+ self.info
+ .device_path
+ .get_or_init(|| self.device.devnode().map(Path::to_owned))
+ .as_ref()
+ .map(PathBuf::as_path)
+ }
+
+ /// Get the parent device.
+ pub fn parent(&self) -> Option<Self> {
+ self.device.parent().map(|parent| Self {
+ manager: self.manager.clone(),
+ device: parent,
+ info: Default::default(),
+ })
+ }
+
+ /// Read from a file in this device's sys path.
+ ///
+ /// Note: path must be a relative path!
+ pub fn read_sys(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
+ assert!(path.is_relative());
+
+ std::fs::read(self.syspath().join(path))
+ .map(Some)
+ .or_else(|err| {
+ if err.kind() == io::ErrorKind::NotFound {
+ Ok(None)
+ } else {
+ Err(err)
+ }
+ })
+ }
+
+ /// Convenience wrapper for reading a `/sys` file which contains just a simple `OsString`.
+ pub fn read_sys_os_str<P: AsRef<Path>>(&self, path: P) -> io::Result<Option<OsString>> {
+ Ok(self.read_sys(path.as_ref())?.map(|mut v| {
+ if Some(&b'\n') == v.last() {
+ v.pop();
+ }
+ OsString::from_vec(v)
+ }))
+ }
+
+ /// Convenience wrapper for reading a `/sys` file which contains just a simple utf-8 string.
+ pub fn read_sys_str<P: AsRef<Path>>(&self, path: P) -> io::Result<Option<String>> {
+ Ok(match self.read_sys(path.as_ref())? {
+ Some(data) => Some(String::from_utf8(data).map_err(io::Error::other)?),
+ None => None,
+ })
+ }
+
+ /// Convenience wrapper for unsigned integer `/sys` values up to 64 bit.
+ pub fn read_sys_u64<P: AsRef<Path>>(&self, path: P) -> io::Result<Option<u64>> {
+ Ok(match self.read_sys_str(path)? {
+ Some(data) => Some(data.trim().parse().map_err(io::Error::other)?),
+ None => None,
+ })
+ }
+
+ /// Get the disk's size in bytes.
+ pub fn size(&self) -> io::Result<u64> {
+ Ok(*self.info.size.get_or_try_init(|| {
+ self.read_sys_u64("size")?.map(|s| s * 512).ok_or_else(|| {
+ io_format_err!(
+ "failed to get disk size from {:?}",
+ self.syspath().join("size"),
+ )
+ })
+ })?)
+ }
+
+ /// Get the device vendor (`/sys/.../device/vendor`) entry if available.
+ pub fn vendor(&self) -> io::Result<Option<&OsStr>> {
+ Ok(self
+ .info
+ .vendor
+ .get_or_try_init(|| self.read_sys_os_str("device/vendor"))?
+ .as_ref()
+ .map(OsString::as_os_str))
+ }
+
+ /// Get the device model (`/sys/.../device/model`) entry if available.
+ pub fn model(&self) -> Option<&OsStr> {
+ self.info
+ .model
+ .get_or_init(|| self.device.property_value("ID_MODEL").map(OsStr::to_owned))
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Check whether this is a rotational disk.
+ ///
+ /// Returns `None` if there's no `queue/rotational` file, in which case no information is
+ /// known. `Some(false)` if `queue/rotational` is zero, `Some(true)` if it has a non-zero
+ /// value.
+ pub fn rotational(&self) -> io::Result<Option<bool>> {
+ Ok(*self
+ .info
+ .rotational
+ .get_or_try_init(|| -> io::Result<Option<bool>> {
+ Ok(self.read_sys_u64("queue/rotational")?.map(|n| n != 0))
+ })?)
+ }
+
+ /// Get the WWN if available.
+ pub fn wwn(&self) -> Option<&OsStr> {
+ self.info
+ .wwn
+ .get_or_init(|| self.device.property_value("ID_WWN").map(|v| v.to_owned()))
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Get the device serial if available.
+ pub fn serial(&self) -> Option<&OsStr> {
+ self.info
+ .serial
+ .get_or_init(|| {
+ self.device
+ .property_value("ID_SERIAL_SHORT")
+ .map(|v| v.to_owned())
+ })
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Get the ATA rotation rate value from udev. This is not necessarily the same as sysfs'
+ /// `rotational` value.
+ pub fn ata_rotation_rate_rpm(&self) -> Option<u64> {
+ *self.info.ata_rotation_rate_rpm.get_or_init(|| {
+ std::str::from_utf8(
+ self.device
+ .property_value("ID_ATA_ROTATION_RATE_RPM")?
+ .as_bytes(),
+ )
+ .ok()?
+ .parse()
+ .ok()
+ })
+ }
+
+ /// Get the partition table type, if any.
+ pub fn partition_table_type(&self) -> Option<&OsStr> {
+ self.info
+ .partition_table_type
+ .get_or_init(|| {
+ self.device
+ .property_value("ID_PART_TABLE_TYPE")
+ .map(|v| v.to_owned())
+ })
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Check if this contains a GPT partition table.
+ pub fn has_gpt(&self) -> bool {
+ *self.info.gpt.get_or_init(|| {
+ self.partition_table_type()
+ .map(|s| s == "gpt")
+ .unwrap_or(false)
+ })
+ }
+
+ /// Get the partitioning scheme of which this device is a partition.
+ pub fn partition_entry_scheme(&self) -> Option<&OsStr> {
+ self.info
+ .partition_entry_scheme
+ .get_or_init(|| {
+ self.device
+ .property_value("ID_PART_ENTRY_SCHEME")
+ .map(|v| v.to_owned())
+ })
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Check if this is a partition.
+ pub fn is_partition(&self) -> bool {
+ self.partition_entry_scheme().is_some()
+ }
+
+ /// Get the type of partition entry (ie. type UUID from the entry in the GPT partition table).
+ pub fn partition_entry_type(&self) -> Option<&OsStr> {
+ self.info
+ .partition_entry_type
+ .get_or_init(|| {
+ self.device
+ .property_value("ID_PART_ENTRY_TYPE")
+ .map(|v| v.to_owned())
+ })
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Get the partition entry UUID (ie. the UUID from the entry in the GPT partition table).
+ pub fn partition_entry_uuid(&self) -> Option<&OsStr> {
+ self.info
+ .partition_entry_uuid
+ .get_or_init(|| {
+ self.device
+ .property_value("ID_PART_ENTRY_UUID")
+ .map(|v| v.to_owned())
+ })
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Get the bus type used for this disk.
+ pub fn bus(&self) -> Option<&OsStr> {
+ self.info
+ .bus
+ .get_or_init(|| self.device.property_value("ID_BUS").map(|v| v.to_owned()))
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Attempt to guess the disk type.
+ pub fn guess_disk_type(&self) -> io::Result<DiskType> {
+ Ok(match self.rotational()? {
+ Some(false) => DiskType::Ssd,
+ Some(true) => DiskType::Hdd,
+ None => match self.ata_rotation_rate_rpm() {
+ Some(_) => DiskType::Hdd,
+ None => match self.bus() {
+ Some(bus) if bus == "usb" => DiskType::Usb,
+ _ => DiskType::Unknown,
+ },
+ },
+ })
+ }
+
+ /// Get the file system type found on the disk, if any.
+ ///
+ /// Note that `None` may also just mean "unknown".
+ pub fn fs_type(&self) -> Option<&OsStr> {
+ self.info
+ .fs_type
+ .get_or_init(|| {
+ self.device
+ .property_value("ID_FS_TYPE")
+ .map(|v| v.to_owned())
+ })
+ .as_ref()
+ .map(OsString::as_os_str)
+ }
+
+ /// Check if there are any "holders" in `/sys`. This usually means the device is in use by
+ /// another kernel driver like the device mapper.
+ pub fn has_holders(&self) -> io::Result<bool> {
+ Ok(*self
+ .info
+ .has_holders
+ .get_or_try_init(|| -> io::Result<bool> {
+ let mut subdir = self.syspath().to_owned();
+ subdir.push("holders");
+ for entry in std::fs::read_dir(subdir)? {
+ match entry?.file_name().as_bytes() {
+ b"." | b".." => (),
+ _ => return Ok(true),
+ }
+ }
+ Ok(false)
+ })?)
+ }
+
+ /// Check if this disk is mounted.
+ pub fn is_mounted(&self) -> Result<bool, Error> {
+ Ok(*self
+ .info
+ .is_mounted
+ .get_or_try_init(|| self.manager.is_devnum_mounted(self.devnum()?))?)
+ }
+
+ /// Read block device stats
+ ///
+ /// see <https://www.kernel.org/doc/Documentation/block/stat.txt>
+ pub fn read_stat(&self) -> std::io::Result<Option<BlockDevStat>> {
+ if let Some(stat) = self.read_sys(Path::new("stat"))? {
+ let stat = unsafe { std::str::from_utf8_unchecked(&stat) };
+ let stat: Vec<u64> = stat
+ .split_ascii_whitespace()
+ .map(|s| s.parse().unwrap_or_default())
+ .collect();
+
+ if stat.len() < 15 {
+ return Ok(None);
+ }
+
+ return Ok(Some(BlockDevStat {
+ read_ios: stat[0],
+ read_sectors: stat[2],
+ write_ios: stat[4] + stat[11], // write + discard
+ write_sectors: stat[6] + stat[13], // write + discard
+ io_ticks: stat[10],
+ }));
+ }
+ Ok(None)
+ }
+
+ /// List device partitions
+ pub fn partitions(&self) -> Result<HashMap<u64, Disk>, Error> {
+ let sys_path = self.syspath();
+ let device = self.sysname().to_string_lossy().to_string();
+
+ let mut map = HashMap::new();
+
+ for item in proxmox_sys::fs::read_subdir(libc::AT_FDCWD, sys_path)? {
+ let item = item?;
+ let name = match item.file_name().to_str() {
+ Ok(name) => name,
+ Err(_) => continue, // skip non utf8 entries
+ };
+
+ if !name.starts_with(&device) {
+ continue;
+ }
+
+ let mut part_path = sys_path.to_owned();
+ part_path.push(name);
+
+ let disk_part = self.manager.clone().disk_by_sys_path(&part_path)?;
+
+ if let Some(partition) = disk_part.read_sys_u64("partition")? {
+ map.insert(partition, disk_part);
+ }
+ }
+
+ Ok(map)
+ }
+}
+
+#[api()]
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+/// This is just a rough estimate for a "type" of disk.
+pub enum DiskType {
+ /// We know nothing.
+ Unknown,
+
+ /// May also be a USB-HDD.
+ Hdd,
+
+ /// May also be a USB-SSD.
+ Ssd,
+
+ /// Some kind of USB disk, but we don't know more than that.
+ Usb,
+}
+
+#[derive(Debug)]
+/// Represents the contents of the `/sys/block/<dev>/stat` file.
+pub struct BlockDevStat {
+ pub read_ios: u64,
+ pub read_sectors: u64,
+ pub write_ios: u64,
+ pub write_sectors: u64,
+ pub io_ticks: u64, // milliseconds
+}
+
+/// Use lsblk to read partition type uuids and file system types.
+pub fn get_lsblk_info() -> Result<Vec<LsblkInfo>, Error> {
+ let mut command = std::process::Command::new("lsblk");
+ command.args(["--json", "-o", "path,parttype,fstype,uuid"]);
+
+ let output = proxmox_sys::command::run_command(command, None)?;
+
+ let mut output: serde_json::Value = output.parse()?;
+
+ Ok(serde_json::from_value(output["blockdevices"].take())?)
+}
+
+/// Get set of devices with a file system label.
+///
+/// The set is indexed by using the unix raw device number (dev_t is u64)
+fn get_file_system_devices(lsblk_info: &[LsblkInfo]) -> Result<HashSet<u64>, Error> {
+ let mut device_set: HashSet<u64> = HashSet::new();
+
+ for info in lsblk_info.iter() {
+ if info.file_system_type.is_some() {
+ let meta = std::fs::metadata(&info.path)?;
+ device_set.insert(meta.rdev());
+ }
+ }
+
+ Ok(device_set)
+}
+
+#[api()]
+#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "lowercase")]
+/// What a block device partition is used for.
+pub enum PartitionUsageType {
+ /// Partition is not used (as far we can tell)
+ Unused,
+ /// Partition is used by LVM
+ LVM,
+ /// Partition is used by ZFS
+ ZFS,
+ /// Partition is ZFS reserved
+ ZfsReserved,
+ /// Partition is an EFI partition
+ EFI,
+ /// Partition is a BIOS partition
+ BIOS,
+ /// Partition contains a file system label
+ FileSystem,
+}
+
+#[api()]
+#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "lowercase")]
+/// What a block device (disk) is used for.
+pub enum DiskUsageType {
+ /// Disk is not used (as far we can tell)
+ Unused,
+ /// Disk is mounted
+ Mounted,
+ /// Disk is used by LVM
+ LVM,
+ /// Disk is used by ZFS
+ ZFS,
+ /// Disk is used by device-mapper
+ DeviceMapper,
+ /// Disk has partitions
+ Partitions,
+ /// Disk contains a file system label
+ FileSystem,
+}
+
+#[api()]
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Basic information about a partition
+pub struct PartitionInfo {
+ /// The partition name
+ pub name: String,
+ /// What the partition is used for
+ pub used: PartitionUsageType,
+ /// Is the partition mounted
+ pub mounted: bool,
+ /// The filesystem of the partition
+ pub filesystem: Option<String>,
+ /// The partition devpath
+ pub devpath: Option<String>,
+ /// Size in bytes
+ pub size: Option<u64>,
+ /// GPT partition
+ pub gpt: bool,
+ /// UUID
+ pub uuid: Option<String>,
+}
+
+#[api(
+ properties: {
+ used: {
+ type: DiskUsageType,
+ },
+ "disk-type": {
+ type: DiskType,
+ },
+ status: {
+ type: SmartStatus,
+ },
+ partitions: {
+ optional: true,
+ items: {
+ type: PartitionInfo
+ }
+ }
+ }
+)]
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Information about how a Disk is used
+pub struct DiskUsageInfo {
+ /// Disk name (`/sys/block/<name>`)
+ pub name: String,
+ pub used: DiskUsageType,
+ pub disk_type: DiskType,
+ pub status: SmartStatus,
+ /// Disk wearout
+ pub wearout: Option<f64>,
+ /// Vendor
+ pub vendor: Option<String>,
+ /// Model
+ pub model: Option<String>,
+ /// WWN
+ pub wwn: Option<String>,
+ /// Disk size
+ pub size: u64,
+ /// Serisal number
+ pub serial: Option<String>,
+ /// Partitions on the device
+ pub partitions: Option<Vec<PartitionInfo>>,
+ /// Linux device path (/dev/xxx)
+ pub devpath: Option<String>,
+ /// Set if disk contains a GPT partition table
+ pub gpt: bool,
+ /// RPM
+ pub rpm: Option<u64>,
+}
+
+fn scan_partitions(
+ disk_manager: Arc<DiskManage>,
+ lvm_devices: &HashSet<u64>,
+ zfs_devices: &HashSet<u64>,
+ device: &str,
+) -> Result<DiskUsageType, Error> {
+ let mut sys_path = std::path::PathBuf::from("/sys/block");
+ sys_path.push(device);
+
+ let mut used = DiskUsageType::Unused;
+
+ let mut found_lvm = false;
+ let mut found_zfs = false;
+ let mut found_mountpoints = false;
+ let mut found_dm = false;
+ let mut found_partitions = false;
+
+ for item in proxmox_sys::fs::read_subdir(libc::AT_FDCWD, &sys_path)? {
+ let item = item?;
+ let name = match item.file_name().to_str() {
+ Ok(name) => name,
+ Err(_) => continue, // skip non utf8 entries
+ };
+ if !name.starts_with(device) {
+ continue;
+ }
+
+ found_partitions = true;
+
+ let mut part_path = sys_path.clone();
+ part_path.push(name);
+
+ let data = disk_manager.clone().disk_by_sys_path(&part_path)?;
+
+ let devnum = data.devnum()?;
+
+ if lvm_devices.contains(&devnum) {
+ found_lvm = true;
+ }
+
+ if data.is_mounted()? {
+ found_mountpoints = true;
+ }
+
+ if data.has_holders()? {
+ found_dm = true;
+ }
+
+ if zfs_devices.contains(&devnum) {
+ found_zfs = true;
+ }
+ }
+
+ if found_mountpoints {
+ used = DiskUsageType::Mounted;
+ } else if found_lvm {
+ used = DiskUsageType::LVM;
+ } else if found_zfs {
+ used = DiskUsageType::ZFS;
+ } else if found_dm {
+ used = DiskUsageType::DeviceMapper;
+ } else if found_partitions {
+ used = DiskUsageType::Partitions;
+ }
+
+ Ok(used)
+}
+
+pub struct DiskUsageQuery {
+ smart: bool,
+ partitions: bool,
+}
+
+impl Default for DiskUsageQuery {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl DiskUsageQuery {
+ pub const fn new() -> Self {
+ Self {
+ smart: true,
+ partitions: false,
+ }
+ }
+
+ pub fn smart(&mut self, smart: bool) -> &mut Self {
+ self.smart = smart;
+ self
+ }
+
+ pub fn partitions(&mut self, partitions: bool) -> &mut Self {
+ self.partitions = partitions;
+ self
+ }
+
+ pub fn query(&self) -> Result<HashMap<String, DiskUsageInfo>, Error> {
+ get_disks(None, !self.smart, self.partitions)
+ }
+
+ pub fn find(&self, disk: &str) -> Result<DiskUsageInfo, Error> {
+ let mut map = get_disks(Some(vec![disk.to_string()]), !self.smart, self.partitions)?;
+ if let Some(info) = map.remove(disk) {
+ Ok(info)
+ } else {
+ bail!("failed to get disk usage info - internal error"); // should not happen
+ }
+ }
+
+ pub fn find_all(&self, disks: Vec<String>) -> Result<HashMap<String, DiskUsageInfo>, Error> {
+ get_disks(Some(disks), !self.smart, self.partitions)
+ }
+}
+
+fn get_partitions_info(
+ partitions: HashMap<u64, Disk>,
+ lvm_devices: &HashSet<u64>,
+ zfs_devices: &HashSet<u64>,
+ file_system_devices: &HashSet<u64>,
+ lsblk_infos: &[LsblkInfo],
+) -> Vec<PartitionInfo> {
+ partitions
+ .values()
+ .map(|disk| {
+ let devpath = disk
+ .device_path()
+ .map(|p| p.to_owned())
+ .map(|p| p.to_string_lossy().to_string());
+
+ let mut used = PartitionUsageType::Unused;
+
+ if let Ok(devnum) = disk.devnum() {
+ if lvm_devices.contains(&devnum) {
+ used = PartitionUsageType::LVM;
+ } else if zfs_devices.contains(&devnum) {
+ used = PartitionUsageType::ZFS;
+ } else if file_system_devices.contains(&devnum) {
+ used = PartitionUsageType::FileSystem;
+ }
+ }
+
+ let mounted = disk.is_mounted().unwrap_or(false);
+ let mut filesystem = None;
+ let mut uuid = None;
+ if let Some(devpath) = devpath.as_ref() {
+ for info in lsblk_infos.iter().filter(|i| i.path.eq(devpath)) {
+ uuid = info.uuid.clone().filter(|uuid| UUID_REGEX.is_match(uuid));
+ used = match info.partition_type.as_deref() {
+ Some("21686148-6449-6e6f-744e-656564454649") => PartitionUsageType::BIOS,
+ Some("c12a7328-f81f-11d2-ba4b-00a0c93ec93b") => PartitionUsageType::EFI,
+ Some("6a945a3b-1dd2-11b2-99a6-080020736631") => {
+ PartitionUsageType::ZfsReserved
+ }
+ _ => used,
+ };
+ if used == PartitionUsageType::FileSystem {
+ filesystem.clone_from(&info.file_system_type);
+ }
+ }
+ }
+
+ PartitionInfo {
+ name: disk.sysname().to_str().unwrap_or("?").to_string(),
+ devpath,
+ used,
+ mounted,
+ filesystem,
+ size: disk.size().ok(),
+ gpt: disk.has_gpt(),
+ uuid,
+ }
+ })
+ .collect()
+}
+
+/// Get disk usage information for multiple disks
+fn get_disks(
+ // filter - list of device names (without leading /dev)
+ disks: Option<Vec<String>>,
+ // do no include data from smartctl
+ no_smart: bool,
+ // include partitions
+ include_partitions: bool,
+) -> Result<HashMap<String, DiskUsageInfo>, Error> {
+ let disk_manager = DiskManage::new();
+
+ let lsblk_info = get_lsblk_info()?;
+
+ let zfs_devices =
+ zfs_devices(&lsblk_info, None).or_else(|err| -> Result<HashSet<u64>, Error> {
+ eprintln!("error getting zfs devices: {err}");
+ Ok(HashSet::new())
+ })?;
+
+ let lvm_devices = get_lvm_devices(&lsblk_info)?;
+
+ let file_system_devices = get_file_system_devices(&lsblk_info)?;
+
+ // fixme: ceph journals/volumes
+
+ let mut result = HashMap::new();
+ let mut device_paths = Vec::new();
+
+ for item in proxmox_sys::fs::scan_subdir(libc::AT_FDCWD, "/sys/block", &BLOCKDEVICE_NAME_REGEX)?
+ {
+ let item = item?;
+
+ let name = item.file_name().to_str().unwrap().to_string();
+
+ if let Some(ref disks) = disks {
+ if !disks.contains(&name) {
+ continue;
+ }
+ }
+
+ let sys_path = format!("/sys/block/{name}");
+
+ if let Ok(target) = std::fs::read_link(&sys_path) {
+ if let Some(target) = target.to_str() {
+ if ISCSI_PATH_REGEX.is_match(target) {
+ continue;
+ } // skip iSCSI devices
+ }
+ }
+
+ let disk = disk_manager.clone().disk_by_sys_path(&sys_path)?;
+
+ let devnum = disk.devnum()?;
+
+ let size = match disk.size() {
+ Ok(size) => size,
+ Err(_) => continue, // skip devices with unreadable size
+ };
+
+ let disk_type = match disk.guess_disk_type() {
+ Ok(disk_type) => disk_type,
+ Err(_) => continue, // skip devices with undetectable type
+ };
+
+ let mut usage = DiskUsageType::Unused;
+
+ if lvm_devices.contains(&devnum) {
+ usage = DiskUsageType::LVM;
+ }
+
+ match disk.is_mounted() {
+ Ok(true) => usage = DiskUsageType::Mounted,
+ Ok(false) => {}
+ Err(_) => continue, // skip devices with undetectable mount status
+ }
+
+ if zfs_devices.contains(&devnum) {
+ usage = DiskUsageType::ZFS;
+ }
+
+ let vendor = disk
+ .vendor()
+ .unwrap_or(None)
+ .map(|s| s.to_string_lossy().trim().to_string());
+
+ let model = disk.model().map(|s| s.to_string_lossy().into_owned());
+
+ let serial = disk.serial().map(|s| s.to_string_lossy().into_owned());
+
+ let devpath = disk
+ .device_path()
+ .map(|p| p.to_owned())
+ .map(|p| p.to_string_lossy().to_string());
+
+ device_paths.push((name.clone(), devpath.clone()));
+
+ let wwn = disk.wwn().map(|s| s.to_string_lossy().into_owned());
+
+ let partitions: Option<Vec<PartitionInfo>> = if include_partitions {
+ disk.partitions().map_or(None, |parts| {
+ Some(get_partitions_info(
+ parts,
+ &lvm_devices,
+ &zfs_devices,
+ &file_system_devices,
+ &lsblk_info,
+ ))
+ })
+ } else {
+ None
+ };
+
+ if usage != DiskUsageType::Mounted {
+ match scan_partitions(disk_manager.clone(), &lvm_devices, &zfs_devices, &name) {
+ Ok(part_usage) => {
+ if part_usage != DiskUsageType::Unused {
+ usage = part_usage;
+ }
+ }
+ Err(_) => continue, // skip devices if scan_partitions fail
+ };
+ }
+
+ if usage == DiskUsageType::Unused && file_system_devices.contains(&devnum) {
+ usage = DiskUsageType::FileSystem;
+ }
+
+ if usage == DiskUsageType::Unused && disk.has_holders()? {
+ usage = DiskUsageType::DeviceMapper;
+ }
+
+ let info = DiskUsageInfo {
+ name: name.clone(),
+ vendor,
+ model,
+ partitions,
+ serial,
+ devpath,
+ size,
+ wwn,
+ disk_type,
+ status: SmartStatus::Unknown,
+ wearout: None,
+ used: usage,
+ gpt: disk.has_gpt(),
+ rpm: disk.ata_rotation_rate_rpm(),
+ };
+
+ result.insert(name, info);
+ }
+
+ if !no_smart {
+ let (tx, rx) = crossbeam_channel::bounded(result.len());
+
+ let parallel_handler =
+ ParallelHandler::new("smartctl data", 4, move |device: (String, String)| {
+ match get_smart_data(Path::new(&device.1), false) {
+ Ok(smart_data) => tx.send((device.0, smart_data))?,
+ // do not fail the whole disk output just because smartctl couldn't query one
+ Err(err) => {
+ proxmox_log::error!("failed to gather smart data for {} – {err}", device.1)
+ }
+ }
+ Ok(())
+ });
+
+ for (name, path) in device_paths.into_iter() {
+ if let Some(p) = path {
+ parallel_handler.send((name, p))?;
+ }
+ }
+
+ parallel_handler.complete()?;
+ while let Ok(msg) = rx.recv() {
+ if let Some(value) = result.get_mut(&msg.0) {
+ value.wearout = msg.1.wearout;
+ value.status = msg.1.status;
+ }
+ }
+ }
+ Ok(result)
+}
+
+/// Try to reload the partition table
+pub fn reread_partition_table(disk: &Disk) -> Result<(), Error> {
+ let disk_path = match disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", disk.syspath()),
+ };
+
+ let mut command = std::process::Command::new("blockdev");
+ command.arg("--rereadpt");
+ command.arg(disk_path);
+
+ proxmox_sys::command::run_command(command, None)?;
+
+ Ok(())
+}
+
+/// Initialize disk by writing a GPT partition table
+pub fn inititialize_gpt_disk(disk: &Disk, uuid: Option<&str>) -> Result<(), Error> {
+ let disk_path = match disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", disk.syspath()),
+ };
+
+ let uuid = uuid.unwrap_or("R"); // R .. random disk GUID
+
+ let mut command = std::process::Command::new("sgdisk");
+ command.arg(disk_path);
+ command.args(["-U", uuid]);
+
+ proxmox_sys::command::run_command(command, None)?;
+
+ Ok(())
+}
+
+/// Wipes all labels, the first 200 MiB, and the last 4096 bytes of a disk/partition.
+/// If called with a partition, also sets the partition type to 0x83 'Linux filesystem'.
+pub fn wipe_blockdev(disk: &Disk) -> Result<(), Error> {
+ let disk_path = match disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", disk.syspath()),
+ };
+
+ let is_partition = disk.is_partition();
+
+ let mut to_wipe: Vec<PathBuf> = Vec::new();
+
+ let partitions_map = disk.partitions()?;
+ for part_disk in partitions_map.values() {
+ let part_path = match part_disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", part_disk.syspath()),
+ };
+ to_wipe.push(part_path.to_path_buf());
+ }
+
+ to_wipe.push(disk_path.to_path_buf());
+
+ info!("Wiping block device {}", disk_path.display());
+
+ let mut wipefs_command = std::process::Command::new("wipefs");
+ wipefs_command.arg("--all").args(&to_wipe);
+
+ let wipefs_output = proxmox_sys::command::run_command(wipefs_command, None)?;
+ info!("wipefs output: {wipefs_output}");
+
+ zero_disk_start_and_end(disk)?;
+
+ if is_partition {
+ // set the partition type to 0x83 'Linux filesystem'
+ change_parttype(disk, "8300")?;
+ }
+
+ Ok(())
+}
+
+pub fn zero_disk_start_and_end(disk: &Disk) -> Result<(), Error> {
+ let disk_path = match disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", disk.syspath()),
+ };
+
+ let disk_size = disk.size()?;
+ let file = std::fs::OpenOptions::new()
+ .write(true)
+ .custom_flags(libc::O_CLOEXEC | libc::O_DSYNC)
+ .open(disk_path)
+ .with_context(|| "failed to open device {disk_path:?} for writing")?;
+ let write_size = disk_size.min(200 * 1024 * 1024);
+ let zeroes = proxmox_io::boxed::zeroed(write_size as usize);
+ file.write_all_at(&zeroes, 0)
+ .with_context(|| "failed to wipe start of device {disk_path:?}")?;
+ if disk_size > write_size {
+ file.write_all_at(&zeroes[0..4096], disk_size - 4096)
+ .with_context(|| "failed to wipe end of device {disk_path:?}")?;
+ }
+ Ok(())
+}
+
+pub fn change_parttype(part_disk: &Disk, part_type: &str) -> Result<(), Error> {
+ let part_path = match part_disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", part_disk.syspath()),
+ };
+ if let Ok(stat) = nix::sys::stat::stat(part_path) {
+ let mut sgdisk_command = std::process::Command::new("sgdisk");
+ let major = unsafe { libc::major(stat.st_rdev) };
+ let minor = unsafe { libc::minor(stat.st_rdev) };
+ let partnum_path = &format!("/sys/dev/block/{major}:{minor}/partition");
+ let partnum: u32 = std::fs::read_to_string(partnum_path)?.trim_end().parse()?;
+ sgdisk_command.arg(format!("-t{partnum}:{part_type}"));
+ let part_disk_parent = match part_disk.parent() {
+ Some(disk) => disk,
+ None => bail!("disk {:?} has no node in /dev", part_disk.syspath()),
+ };
+ let part_disk_parent_path = match part_disk_parent.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", part_disk.syspath()),
+ };
+ sgdisk_command.arg(part_disk_parent_path);
+ let sgdisk_output = proxmox_sys::command::run_command(sgdisk_command, None)?;
+ info!("sgdisk output: {sgdisk_output}");
+ }
+ Ok(())
+}
+
+/// Create a single linux partition using the whole available space
+pub fn create_single_linux_partition(disk: &Disk) -> Result<Disk, Error> {
+ let disk_path = match disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", disk.syspath()),
+ };
+
+ let mut command = std::process::Command::new("sgdisk");
+ command.args(["-n1", "-t1:8300"]);
+ command.arg(disk_path);
+
+ proxmox_sys::command::run_command(command, None)?;
+
+ let mut partitions = disk.partitions()?;
+
+ match partitions.remove(&1) {
+ Some(partition) => Ok(partition),
+ None => bail!("unable to lookup device partition"),
+ }
+}
+
+#[api()]
+#[derive(Debug, Copy, Clone, Serialize, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "lowercase")]
+/// A file system type supported by our tooling.
+pub enum FileSystemType {
+ /// Linux Ext4
+ Ext4,
+ /// XFS
+ Xfs,
+}
+
+impl std::fmt::Display for FileSystemType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let text = match self {
+ FileSystemType::Ext4 => "ext4",
+ FileSystemType::Xfs => "xfs",
+ };
+ write!(f, "{text}")
+ }
+}
+
+impl std::str::FromStr for FileSystemType {
+ type Err = serde_json::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ use serde::de::IntoDeserializer;
+ Self::deserialize(s.into_deserializer())
+ }
+}
+
+/// Create a file system on a disk or disk partition
+pub fn create_file_system(disk: &Disk, fs_type: FileSystemType) -> Result<(), Error> {
+ let disk_path = match disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", disk.syspath()),
+ };
+
+ let fs_type = fs_type.to_string();
+
+ let mut command = std::process::Command::new("mkfs");
+ command.args(["-t", &fs_type]);
+ command.arg(disk_path);
+
+ proxmox_sys::command::run_command(command, None)?;
+
+ Ok(())
+}
+/// Block device name completion helper
+pub fn complete_disk_name(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
+ let dir =
+ match proxmox_sys::fs::scan_subdir(libc::AT_FDCWD, "/sys/block", &BLOCKDEVICE_NAME_REGEX) {
+ Ok(dir) => dir,
+ Err(_) => return vec![],
+ };
+
+ dir.flatten()
+ .map(|item| item.file_name().to_str().unwrap().to_string())
+ .collect()
+}
+
+/// Block device partition name completion helper
+pub fn complete_partition_name(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
+ let dir = match proxmox_sys::fs::scan_subdir(
+ libc::AT_FDCWD,
+ "/sys/class/block",
+ &BLOCKDEVICE_DISK_AND_PARTITION_NAME_REGEX,
+ ) {
+ Ok(dir) => dir,
+ Err(_) => return vec![],
+ };
+
+ dir.flatten()
+ .map(|item| item.file_name().to_str().unwrap().to_string())
+ .collect()
+}
+
+/// Read the FS UUID (parse blkid output)
+///
+/// Note: Calling blkid is more reliable than using the udev ID_FS_UUID property.
+pub fn get_fs_uuid(disk: &Disk) -> Result<String, Error> {
+ let disk_path = match disk.device_path() {
+ Some(path) => path,
+ None => bail!("disk {:?} has no node in /dev", disk.syspath()),
+ };
+
+ let mut command = std::process::Command::new("blkid");
+ command.args(["-o", "export"]);
+ command.arg(disk_path);
+
+ let output = proxmox_sys::command::run_command(command, None)?;
+
+ for line in output.lines() {
+ if let Some(uuid) = line.strip_prefix("UUID=") {
+ return Ok(uuid.to_string());
+ }
+ }
+
+ bail!("get_fs_uuid failed - missing UUID");
+}
+
+/// Mount a disk by its UUID and the mount point.
+pub fn mount_by_uuid(uuid: &str, mount_point: &Path) -> Result<(), Error> {
+ let mut command = std::process::Command::new("mount");
+ command.arg(format!("UUID={uuid}"));
+ command.arg(mount_point);
+
+ proxmox_sys::command::run_command(command, None)?;
+ Ok(())
+}
+
+/// Create bind mount.
+pub fn bind_mount(path: &Path, target: &Path) -> Result<(), Error> {
+ let mut command = std::process::Command::new("mount");
+ command.arg("--bind");
+ command.arg(path);
+ command.arg(target);
+
+ proxmox_sys::command::run_command(command, None)?;
+ Ok(())
+}
+
+/// Unmount a disk by its mount point.
+pub fn unmount_by_mountpoint(path: &Path) -> Result<(), Error> {
+ let mut command = std::process::Command::new("umount");
+ command.arg(path);
+
+ proxmox_sys::command::run_command(command, None)?;
+ Ok(())
+}
diff --git a/proxmox-disks/src/lvm.rs b/proxmox-disks/src/lvm.rs
new file mode 100644
index 00000000..1456a21c
--- /dev/null
+++ b/proxmox-disks/src/lvm.rs
@@ -0,0 +1,60 @@
+use std::collections::HashSet;
+use std::os::unix::fs::MetadataExt;
+use std::sync::LazyLock;
+
+use anyhow::Error;
+use serde_json::Value;
+
+use super::LsblkInfo;
+
+static LVM_UUIDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
+ let mut set = HashSet::new();
+ set.insert("e6d6d379-f507-44c2-a23c-238f2a3df928");
+ set
+});
+
+/// Get set of devices used by LVM (pvs).
+///
+/// The set is indexed by using the unix raw device number (dev_t is u64)
+pub fn get_lvm_devices(lsblk_info: &[LsblkInfo]) -> Result<HashSet<u64>, Error> {
+ const PVS_BIN_PATH: &str = "pvs";
+
+ let mut command = std::process::Command::new(PVS_BIN_PATH);
+ command.args([
+ "--reportformat",
+ "json",
+ "--noheadings",
+ "--readonly",
+ "-o",
+ "pv_name",
+ ]);
+
+ let output = proxmox_sys::command::run_command(command, None)?;
+
+ let mut device_set: HashSet<u64> = HashSet::new();
+
+ for info in lsblk_info.iter() {
+ if let Some(partition_type) = &info.partition_type {
+ if LVM_UUIDS.contains(partition_type.as_str()) {
+ let meta = std::fs::metadata(&info.path)?;
+ device_set.insert(meta.rdev());
+ }
+ }
+ }
+
+ let output: Value = output.parse()?;
+
+ match output["report"][0]["pv"].as_array() {
+ Some(list) => {
+ for info in list {
+ if let Some(pv_name) = info["pv_name"].as_str() {
+ let meta = std::fs::metadata(pv_name)?;
+ device_set.insert(meta.rdev());
+ }
+ }
+ }
+ None => return Ok(device_set),
+ }
+
+ Ok(device_set)
+}
diff --git a/proxmox-disks/src/parse_helpers.rs b/proxmox-disks/src/parse_helpers.rs
new file mode 100644
index 00000000..563866d6
--- /dev/null
+++ b/proxmox-disks/src/parse_helpers.rs
@@ -0,0 +1,52 @@
+use anyhow::{bail, Error};
+
+use nom::{
+ bytes::complete::take_while1,
+ combinator::all_consuming,
+ error::{ContextError, VerboseError},
+};
+
+pub(crate) type IResult<I, O, E = VerboseError<I>> = Result<(I, O), nom::Err<E>>;
+
+fn verbose_err<'a>(i: &'a str, ctx: &'static str) -> VerboseError<&'a str> {
+ VerboseError::add_context(i, ctx, VerboseError { errors: vec![] })
+}
+
+pub(crate) fn parse_error<'a>(
+ i: &'a str,
+ context: &'static str,
+) -> nom::Err<VerboseError<&'a str>> {
+ nom::Err::Error(verbose_err(i, context))
+}
+
+pub(crate) fn parse_failure<'a>(
+ i: &'a str,
+ context: &'static str,
+) -> nom::Err<VerboseError<&'a str>> {
+ nom::Err::Error(verbose_err(i, context))
+}
+
+/// Recognizes one or more non-whitespace characters
+pub(crate) fn notspace1(i: &str) -> IResult<&str, &str> {
+ take_while1(|c| !(c == ' ' || c == '\t' || c == '\n'))(i)
+}
+
+/// Parse complete input, generate verbose error message with line numbers
+pub(crate) fn parse_complete<'a, F, O>(what: &str, i: &'a str, parser: F) -> Result<O, Error>
+where
+ F: FnMut(&'a str) -> IResult<&'a str, O>,
+{
+ match all_consuming(parser)(i) {
+ Err(nom::Err::Error(err)) | Err(nom::Err::Failure(err)) => {
+ bail!(
+ "unable to parse {} - {}",
+ what,
+ nom::error::convert_error(i, err)
+ );
+ }
+ Err(err) => {
+ bail!("unable to parse {} - {}", what, err);
+ }
+ Ok((_, data)) => Ok(data),
+ }
+}
diff --git a/proxmox-disks/src/smart.rs b/proxmox-disks/src/smart.rs
new file mode 100644
index 00000000..1d41cee2
--- /dev/null
+++ b/proxmox-disks/src/smart.rs
@@ -0,0 +1,227 @@
+use std::sync::LazyLock;
+use std::{
+ collections::{HashMap, HashSet},
+ path::Path,
+};
+
+use ::serde::{Deserialize, Serialize};
+use anyhow::Error;
+
+use proxmox_schema::api;
+
+#[api()]
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+/// SMART status
+pub enum SmartStatus {
+ /// Smart tests passed - everything is OK
+ Passed,
+ /// Smart tests failed - disk has problems
+ Failed,
+ /// Unknown status
+ Unknown,
+}
+
+#[api()]
+#[derive(Debug, Serialize, Deserialize)]
+/// SMART Attribute
+pub struct SmartAttribute {
+ /// Attribute name
+ name: String,
+ // FIXME: remove value with next major release (PBS 3.0)
+ /// duplicate of raw - kept for API stability
+ value: String,
+ /// Attribute raw value
+ raw: String,
+ // the rest of the values is available for ATA type
+ /// ATA Attribute ID
+ #[serde(skip_serializing_if = "Option::is_none")]
+ id: Option<u64>,
+ /// ATA Flags
+ #[serde(skip_serializing_if = "Option::is_none")]
+ flags: Option<String>,
+ /// ATA normalized value (0..100)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ normalized: Option<f64>,
+ /// ATA worst
+ #[serde(skip_serializing_if = "Option::is_none")]
+ worst: Option<f64>,
+ /// ATA threshold
+ #[serde(skip_serializing_if = "Option::is_none")]
+ threshold: Option<f64>,
+}
+
+#[api(
+ properties: {
+ status: {
+ type: SmartStatus,
+ },
+ wearout: {
+ description: "Wearout level.",
+ type: f64,
+ optional: true,
+ },
+ attributes: {
+ description: "SMART attributes.",
+ type: Array,
+ items: {
+ type: SmartAttribute,
+ },
+ },
+ },
+)]
+#[derive(Debug, Serialize, Deserialize)]
+/// Data from smartctl
+pub struct SmartData {
+ pub status: SmartStatus,
+ pub wearout: Option<f64>,
+ pub attributes: Vec<SmartAttribute>,
+}
+
+/// Read smartctl data for a disk (/dev/XXX).
+pub fn get_smart_data(disk_path: &Path, health_only: bool) -> Result<SmartData, Error> {
+ const SMARTCTL_BIN_PATH: &str = "smartctl";
+
+ let mut command = std::process::Command::new(SMARTCTL_BIN_PATH);
+ command.arg("-H");
+ if !health_only {
+ command.args(["-A", "-j"]);
+ }
+
+ command.arg(disk_path);
+
+ let output = proxmox_sys::command::run_command(
+ command,
+ Some(
+ |exitcode| (exitcode & 0b0011) == 0, // only bits 0-1 are fatal errors
+ ),
+ )?;
+
+ let output: serde_json::Value = output.parse()?;
+
+ let mut wearout = None;
+
+ let mut attributes = Vec::new();
+ let mut wearout_candidates = HashMap::new();
+
+ // ATA devices
+ if let Some(list) = output["ata_smart_attributes"]["table"].as_array() {
+ for item in list {
+ let id = match item["id"].as_u64() {
+ Some(id) => id,
+ None => continue, // skip attributes without id
+ };
+
+ let name = match item["name"].as_str() {
+ Some(name) => name.to_string(),
+ None => continue, // skip attributes without name
+ };
+
+ let raw_value = match item["raw"]["string"].as_str() {
+ Some(value) => value.to_string(),
+ None => continue, // skip attributes without raw value
+ };
+
+ let flags = match item["flags"]["string"].as_str() {
+ Some(flags) => flags.to_string(),
+ None => continue, // skip attributes without flags
+ };
+
+ let normalized = match item["value"].as_f64() {
+ Some(v) => v,
+ None => continue, // skip attributes without normalize value
+ };
+
+ let worst = match item["worst"].as_f64() {
+ Some(v) => v,
+ None => continue, // skip attributes without worst entry
+ };
+
+ let threshold = match item["thresh"].as_f64() {
+ Some(v) => v,
+ None => continue, // skip attributes without threshold entry
+ };
+
+ if WEAROUT_FIELD_NAMES.contains(&name as &str) {
+ wearout_candidates.insert(name.clone(), normalized);
+ }
+
+ attributes.push(SmartAttribute {
+ name,
+ value: raw_value.clone(),
+ raw: raw_value,
+ id: Some(id),
+ flags: Some(flags),
+ normalized: Some(normalized),
+ worst: Some(worst),
+ threshold: Some(threshold),
+ });
+ }
+ }
+
+ if !wearout_candidates.is_empty() {
+ for field in WEAROUT_FIELD_ORDER {
+ if let Some(value) = wearout_candidates.get(field as &str) {
+ wearout = Some(*value);
+ break;
+ }
+ }
+ }
+
+ // NVME devices
+ if let Some(list) = output["nvme_smart_health_information_log"].as_object() {
+ for (name, value) in list {
+ if name == "percentage_used" {
+ // extract wearout from nvme text, allow for decimal values
+ if let Some(v) = value.as_f64() {
+ if v <= 100.0 {
+ wearout = Some(100.0 - v);
+ }
+ }
+ }
+ if let Some(value) = value.as_f64() {
+ attributes.push(SmartAttribute {
+ name: name.to_string(),
+ value: value.to_string(),
+ raw: value.to_string(),
+ id: None,
+ flags: None,
+ normalized: None,
+ worst: None,
+ threshold: None,
+ });
+ }
+ }
+ }
+
+ let status = match output["smart_status"]["passed"].as_bool() {
+ None => SmartStatus::Unknown,
+ Some(true) => SmartStatus::Passed,
+ Some(false) => SmartStatus::Failed,
+ };
+
+ Ok(SmartData {
+ status,
+ wearout,
+ attributes,
+ })
+}
+
+static WEAROUT_FIELD_ORDER: &[&str] = &[
+ "Media_Wearout_Indicator",
+ "SSD_Life_Left",
+ "Wear_Leveling_Count",
+ "Perc_Write/Erase_Ct_BC",
+ "Perc_Rated_Life_Remain",
+ "Remaining_Lifetime_Perc",
+ "Percent_Lifetime_Remain",
+ "Lifetime_Left",
+ "PCT_Life_Remaining",
+ "Lifetime_Remaining",
+ "Percent_Life_Remaining",
+ "Percent_Lifetime_Used",
+ "Perc_Rated_Life_Used",
+];
+
+static WEAROUT_FIELD_NAMES: LazyLock<HashSet<&'static str>> =
+ LazyLock::new(|| WEAROUT_FIELD_ORDER.iter().cloned().collect());
diff --git a/proxmox-disks/src/zfs.rs b/proxmox-disks/src/zfs.rs
new file mode 100644
index 00000000..0babb887
--- /dev/null
+++ b/proxmox-disks/src/zfs.rs
@@ -0,0 +1,205 @@
+use std::collections::HashSet;
+use std::os::unix::fs::MetadataExt;
+use std::path::PathBuf;
+use std::sync::{LazyLock, Mutex};
+
+use anyhow::{bail, Error};
+
+use proxmox_schema::const_regex;
+
+use super::*;
+
+static ZFS_UUIDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
+ let mut set = HashSet::new();
+ set.insert("6a898cc3-1dd2-11b2-99a6-080020736631"); // apple
+ set.insert("516e7cba-6ecf-11d6-8ff8-00022d09712b"); // bsd
+ set
+});
+
+fn get_pool_from_dataset(dataset: &str) -> &str {
+ if let Some(idx) = dataset.find('/') {
+ dataset[0..idx].as_ref()
+ } else {
+ dataset
+ }
+}
+
+/// Returns kernel IO-stats for zfs pools
+pub fn zfs_pool_stats(pool: &OsStr) -> Result<Option<BlockDevStat>, Error> {
+ let mut path = PathBuf::from("/proc/spl/kstat/zfs");
+ path.push(pool);
+ path.push("io");
+
+ let text = match proxmox_sys::fs::file_read_optional_string(&path)? {
+ Some(text) => text,
+ None => {
+ return Ok(None);
+ }
+ };
+
+ let lines: Vec<&str> = text.lines().collect();
+
+ if lines.len() < 3 {
+ bail!("unable to parse {:?} - got less than 3 lines", path);
+ }
+
+ // https://github.com/openzfs/zfs/blob/master/lib/libspl/include/sys/kstat.h#L578
+ // nread nwritten reads writes wtime wlentime wupdate rtime rlentime rupdate wcnt rcnt
+ // Note: w -> wait (wtime -> wait time)
+ // Note: r -> run (rtime -> run time)
+ // All times are nanoseconds
+ let stat: Vec<u64> = lines[2]
+ .split_ascii_whitespace()
+ .map(|s| s.parse().unwrap_or_default())
+ .collect();
+
+ let ticks = (stat[4] + stat[7]) / 1_000_000; // convert to milisec
+
+ let stat = BlockDevStat {
+ read_sectors: stat[0] >> 9,
+ write_sectors: stat[1] >> 9,
+ read_ios: stat[2],
+ write_ios: stat[3],
+ io_ticks: ticks,
+ };
+
+ Ok(Some(stat))
+}
+
+/// Get set of devices used by zfs (or a specific zfs pool)
+///
+/// The set is indexed by using the unix raw device number (dev_t is u64)
+pub fn zfs_devices(lsblk_info: &[LsblkInfo], pool: Option<String>) -> Result<HashSet<u64>, Error> {
+ let list = zpool_list(pool.as_ref(), true)?;
+
+ let mut device_set = HashSet::new();
+ for entry in list {
+ for device in entry.devices {
+ let meta = std::fs::metadata(device)?;
+ device_set.insert(meta.rdev());
+ }
+ }
+ if pool.is_none() {
+ for info in lsblk_info.iter() {
+ if let Some(partition_type) = &info.partition_type {
+ if ZFS_UUIDS.contains(partition_type.as_str()) {
+ let meta = std::fs::metadata(&info.path)?;
+ device_set.insert(meta.rdev());
+ }
+ }
+ }
+ }
+
+ Ok(device_set)
+}
+
+const ZFS_KSTAT_BASE_PATH: &str = "/proc/spl/kstat/zfs";
+const_regex! {
+ OBJSET_REGEX = r"^objset-0x[a-fA-F0-9]+$";
+}
+
+static ZFS_DATASET_OBJSET_MAP: LazyLock<Mutex<HashMap<String, (String, String)>>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+
+// parses /proc/spl/kstat/zfs/POOL/objset-ID files
+// they have the following format:
+//
+// 0 0 0x00 0 0000 00000000000 000000000000000000
+// name type data
+// dataset_name 7 pool/dataset
+// writes 4 0
+// nwritten 4 0
+// reads 4 0
+// nread 4 0
+// nunlinks 4 0
+// nunlinked 4 0
+//
+// we are only interested in the dataset_name, writes, nwrites, reads and nread
+fn parse_objset_stat(pool: &str, objset_id: &str) -> Result<(String, BlockDevStat), Error> {
+ let path = PathBuf::from(format!("{ZFS_KSTAT_BASE_PATH}/{pool}/{objset_id}"));
+
+ let text = match proxmox_sys::fs::file_read_optional_string(path)? {
+ Some(text) => text,
+ None => bail!("could not parse '{}' stat file", objset_id),
+ };
+
+ let mut dataset_name = String::new();
+ let mut stat = BlockDevStat {
+ read_sectors: 0,
+ write_sectors: 0,
+ read_ios: 0,
+ write_ios: 0,
+ io_ticks: 0,
+ };
+
+ for (i, line) in text.lines().enumerate() {
+ if i < 2 {
+ continue;
+ }
+
+ let mut parts = line.split_ascii_whitespace();
+ let name = parts.next();
+ parts.next(); // discard type
+ let value = parts.next().ok_or_else(|| format_err!("no value found"))?;
+ match name {
+ Some("dataset_name") => dataset_name = value.to_string(),
+ Some("writes") => stat.write_ios = value.parse().unwrap_or_default(),
+ Some("nwritten") => stat.write_sectors = value.parse::<u64>().unwrap_or_default() / 512,
+ Some("reads") => stat.read_ios = value.parse().unwrap_or_default(),
+ Some("nread") => stat.read_sectors = value.parse::<u64>().unwrap_or_default() / 512,
+ _ => {}
+ }
+ }
+
+ Ok((dataset_name, stat))
+}
+
+fn get_mapping(dataset: &str) -> Option<(String, String)> {
+ ZFS_DATASET_OBJSET_MAP
+ .lock()
+ .unwrap()
+ .get(dataset)
+ .map(|c| c.to_owned())
+}
+
+/// Updates the dataset <-> objset_map
+pub(crate) fn update_zfs_objset_map(pool: &str) -> Result<(), Error> {
+ let mut map = ZFS_DATASET_OBJSET_MAP.lock().unwrap();
+ map.clear();
+ let path = PathBuf::from(format!("{ZFS_KSTAT_BASE_PATH}/{pool}"));
+
+ proxmox_sys::fs::scandir(
+ libc::AT_FDCWD,
+ &path,
+ &OBJSET_REGEX,
+ |_l2_fd, filename, _type| {
+ let (name, _) = parse_objset_stat(pool, filename)?;
+ map.insert(name, (pool.to_string(), filename.to_string()));
+ Ok(())
+ },
+ )?;
+
+ Ok(())
+}
+
+/// Gets io stats for the dataset from /proc/spl/kstat/zfs/POOL/objset-ID
+pub fn zfs_dataset_stats(dataset: &str) -> Result<BlockDevStat, Error> {
+ let mut mapping = get_mapping(dataset);
+ if mapping.is_none() {
+ let pool = get_pool_from_dataset(dataset);
+ update_zfs_objset_map(pool)?;
+ mapping = get_mapping(dataset);
+ }
+ let (pool, objset_id) =
+ mapping.ok_or_else(|| format_err!("could not find objset id for dataset"))?;
+
+ match parse_objset_stat(&pool, &objset_id) {
+ Ok((_, stat)) => Ok(stat),
+ Err(err) => {
+ // on error remove dataset from map, it probably vanished or the
+ // mapping was incorrect
+ ZFS_DATASET_OBJSET_MAP.lock().unwrap().remove(dataset);
+ Err(err)
+ }
+ }
+}
diff --git a/proxmox-disks/src/zpool_list.rs b/proxmox-disks/src/zpool_list.rs
new file mode 100644
index 00000000..4083629f
--- /dev/null
+++ b/proxmox-disks/src/zpool_list.rs
@@ -0,0 +1,294 @@
+use anyhow::{bail, Error};
+
+use crate::parse_helpers::{notspace1, IResult};
+
+use nom::{
+ bytes::complete::{take_till, take_till1, take_while1},
+ character::complete::{char, digit1, line_ending, space0, space1},
+ combinator::{all_consuming, map_res, opt, recognize},
+ multi::many0,
+ sequence::{preceded, tuple},
+};
+
+#[derive(Debug, PartialEq)]
+pub struct ZFSPoolUsage {
+ pub size: u64,
+ pub alloc: u64,
+ pub free: u64,
+ pub dedup: f64,
+ pub frag: u64,
+}
+
+#[derive(Debug, PartialEq)]
+pub struct ZFSPoolInfo {
+ pub name: String,
+ pub health: String,
+ pub usage: Option<ZFSPoolUsage>,
+ pub devices: Vec<String>,
+}
+
+fn parse_optional_u64(i: &str) -> IResult<&str, Option<u64>> {
+ if let Some(rest) = i.strip_prefix('-') {
+ Ok((rest, None))
+ } else {
+ let (i, value) = map_res(recognize(digit1), str::parse)(i)?;
+ Ok((i, Some(value)))
+ }
+}
+
+fn parse_optional_f64(i: &str) -> IResult<&str, Option<f64>> {
+ if let Some(rest) = i.strip_prefix('-') {
+ Ok((rest, None))
+ } else {
+ let (i, value) = nom::number::complete::double(i)?;
+ Ok((i, Some(value)))
+ }
+}
+
+fn parse_pool_device(i: &str) -> IResult<&str, String> {
+ let (i, (device, _, _rest)) = tuple((
+ preceded(space1, take_till1(|c| c == ' ' || c == '\t')),
+ space1,
+ preceded(take_till(|c| c == '\n'), char('\n')),
+ ))(i)?;
+
+ Ok((i, device.to_string()))
+}
+
+fn parse_zpool_list_header(i: &str) -> IResult<&str, ZFSPoolInfo> {
+ // name, size, allocated, free, checkpoint, expandsize, fragmentation, capacity, dedupratio, health, altroot.
+
+ let (i, (text, size, alloc, free, _, _, frag, _, dedup, health, _altroot, _eol)) = tuple((
+ take_while1(|c| char::is_alphanumeric(c) || c == '-' || c == ':' || c == '_' || c == '.'), // name
+ preceded(space1, parse_optional_u64), // size
+ preceded(space1, parse_optional_u64), // allocated
+ preceded(space1, parse_optional_u64), // free
+ preceded(space1, notspace1), // checkpoint
+ preceded(space1, notspace1), // expandsize
+ preceded(space1, parse_optional_u64), // fragmentation
+ preceded(space1, notspace1), // capacity
+ preceded(space1, parse_optional_f64), // dedup
+ preceded(space1, notspace1), // health
+ opt(preceded(space1, notspace1)), // optional altroot
+ line_ending,
+ ))(i)?;
+
+ let status = if let (Some(size), Some(alloc), Some(free), Some(frag), Some(dedup)) =
+ (size, alloc, free, frag, dedup)
+ {
+ ZFSPoolInfo {
+ name: text.into(),
+ health: health.into(),
+ usage: Some(ZFSPoolUsage {
+ size,
+ alloc,
+ free,
+ frag,
+ dedup,
+ }),
+ devices: Vec::new(),
+ }
+ } else {
+ ZFSPoolInfo {
+ name: text.into(),
+ health: health.into(),
+ usage: None,
+ devices: Vec::new(),
+ }
+ };
+
+ Ok((i, status))
+}
+
+fn parse_zpool_list_item(i: &str) -> IResult<&str, ZFSPoolInfo> {
+ let (i, mut stat) = parse_zpool_list_header(i)?;
+ let (i, devices) = many0(parse_pool_device)(i)?;
+
+ for device_path in devices.into_iter().filter(|n| n.starts_with("/dev/")) {
+ stat.devices.push(device_path);
+ }
+
+ let (i, _) = many0(tuple((space0, char('\n'))))(i)?; // skip empty lines
+
+ Ok((i, stat))
+}
+
+/// Parse zpool list output
+///
+/// Note: This does not reveal any details on how the pool uses the devices, because
+/// the zpool list output format is not really defined...
+fn parse_zpool_list(i: &str) -> Result<Vec<ZFSPoolInfo>, Error> {
+ match all_consuming(many0(parse_zpool_list_item))(i) {
+ Err(nom::Err::Error(err)) | Err(nom::Err::Failure(err)) => {
+ bail!(
+ "unable to parse zfs list output - {}",
+ nom::error::convert_error(i, err)
+ );
+ }
+ Err(err) => {
+ bail!("unable to parse zfs list output - {}", err);
+ }
+ Ok((_, ce)) => Ok(ce),
+ }
+}
+
+/// Run zpool list and return parsed output
+///
+/// Devices are only included when run with verbose flags
+/// set. Without, device lists are empty.
+pub fn zpool_list(pool: Option<&String>, verbose: bool) -> Result<Vec<ZFSPoolInfo>, Error> {
+ // Note: zpools list verbose output can include entries for 'special', 'cache' and 'logs'
+ // and maybe other things.
+
+ let mut command = std::process::Command::new("zpool");
+ command.args(["list", "-H", "-p", "-P"]);
+
+ // Note: We do not use -o to define output properties, because zpool command ignores
+ // that completely for special vdevs and devices
+
+ if verbose {
+ command.arg("-v");
+ }
+
+ if let Some(pool) = pool {
+ command.arg(pool);
+ }
+
+ let output = proxmox_sys::command::run_command(command, None)?;
+
+ parse_zpool_list(&output)
+}
+
+#[test]
+fn test_zfs_parse_list() -> Result<(), Error> {
+ let output = "";
+
+ let data = parse_zpool_list(output)?;
+ let expect = Vec::new();
+
+ assert_eq!(data, expect);
+
+ let output = "btest 427349245952 405504 427348840448 - - 0 0 1.00 ONLINE -\n";
+ let data = parse_zpool_list(output)?;
+ let expect = vec![ZFSPoolInfo {
+ name: "btest".to_string(),
+ health: "ONLINE".to_string(),
+ devices: Vec::new(),
+ usage: Some(ZFSPoolUsage {
+ size: 427349245952,
+ alloc: 405504,
+ free: 427348840448,
+ dedup: 1.0,
+ frag: 0,
+ }),
+ }];
+
+ assert_eq!(data, expect);
+
+ let output = "\
+rpool 535260299264 402852388864 132407910400 - - 22 75 1.00 ONLINE -
+ /dev/disk/by-id/ata-Crucial_CT500MX200SSD1_154210EB4078-part3 498216206336 392175546368 106040659968 - - 22 78 - ONLINE
+special - - - - - - - - -
+ /dev/sda2 37044092928 10676842496 26367250432 - - 63 28 - ONLINE
+logs - - - - - - - - -
+ /dev/sda3 4831838208 1445888 4830392320 - - 0 0 - ONLINE
+
+";
+
+ let data = parse_zpool_list(output)?;
+ let expect = vec![
+ ZFSPoolInfo {
+ name: String::from("rpool"),
+ health: String::from("ONLINE"),
+ devices: vec![String::from(
+ "/dev/disk/by-id/ata-Crucial_CT500MX200SSD1_154210EB4078-part3",
+ )],
+ usage: Some(ZFSPoolUsage {
+ size: 535260299264,
+ alloc: 402852388864,
+ free: 132407910400,
+ dedup: 1.0,
+ frag: 22,
+ }),
+ },
+ ZFSPoolInfo {
+ name: String::from("special"),
+ health: String::from("-"),
+ devices: vec![String::from("/dev/sda2")],
+ usage: None,
+ },
+ ZFSPoolInfo {
+ name: String::from("logs"),
+ health: String::from("-"),
+ devices: vec![String::from("/dev/sda3")],
+ usage: None,
+ },
+ ];
+
+ assert_eq!(data, expect);
+
+ let output = "\
+b-test 427349245952 761856 427348484096 - - 0 0 1.00 ONLINE -
+ mirror 213674622976 438272 213674184704 - - 0 0 - ONLINE
+ /dev/sda1 - - - - - - - - ONLINE
+ /dev/sda2 - - - - - - - - ONLINE
+ mirror 213674622976 323584 213674299392 - - 0 0 - ONLINE
+ /dev/sda3 - - - - - - - - ONLINE
+ /dev/sda4 - - - - - - - - ONLINE
+logs - - - - - - - - -
+ /dev/sda5 213674622976 0 213674622976 - - 0 0 - ONLINE
+";
+
+ let data = parse_zpool_list(output)?;
+ let expect = vec![
+ ZFSPoolInfo {
+ name: String::from("b-test"),
+ health: String::from("ONLINE"),
+ usage: Some(ZFSPoolUsage {
+ size: 427349245952,
+ alloc: 761856,
+ free: 427348484096,
+ dedup: 1.0,
+ frag: 0,
+ }),
+ devices: vec![
+ String::from("/dev/sda1"),
+ String::from("/dev/sda2"),
+ String::from("/dev/sda3"),
+ String::from("/dev/sda4"),
+ ],
+ },
+ ZFSPoolInfo {
+ name: String::from("logs"),
+ health: String::from("-"),
+ usage: None,
+ devices: vec![String::from("/dev/sda5")],
+ },
+ ];
+
+ assert_eq!(data, expect);
+
+ let output = "\
+b.test 427349245952 761856 427348484096 - - 0 0 1.00 ONLINE -
+ mirror 213674622976 438272 213674184704 - - 0 0 - ONLINE
+ /dev/sda1 - - - - - - - - ONLINE
+";
+
+ let data = parse_zpool_list(output)?;
+ let expect = vec![ZFSPoolInfo {
+ name: String::from("b.test"),
+ health: String::from("ONLINE"),
+ usage: Some(ZFSPoolUsage {
+ size: 427349245952,
+ alloc: 761856,
+ free: 427348484096,
+ dedup: 1.0,
+ frag: 0,
+ }),
+ devices: vec![String::from("/dev/sda1")],
+ }];
+
+ assert_eq!(data, expect);
+
+ Ok(())
+}
diff --git a/proxmox-disks/src/zpool_status.rs b/proxmox-disks/src/zpool_status.rs
new file mode 100644
index 00000000..674dbe63
--- /dev/null
+++ b/proxmox-disks/src/zpool_status.rs
@@ -0,0 +1,496 @@
+use std::mem::{replace, take};
+
+use anyhow::{bail, Error};
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+use crate::parse_helpers::{notspace1, parse_complete, parse_error, parse_failure, IResult};
+
+use nom::{
+ bytes::complete::{tag, take_while, take_while1},
+ character::complete::{line_ending, space0, space1},
+ combinator::opt,
+ error::VerboseError,
+ multi::{many0, many1},
+ sequence::preceded,
+};
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ZFSPoolVDevState {
+ pub name: String,
+ pub lvl: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub state: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub read: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub write: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cksum: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub msg: Option<String>,
+}
+
+fn expand_tab_length(input: &str) -> usize {
+ input.chars().map(|c| if c == '\t' { 8 } else { 1 }).sum()
+}
+
+fn parse_zpool_status_vdev(i: &str) -> IResult<&str, ZFSPoolVDevState> {
+ let (n, indent) = space0(i)?;
+
+ let indent_len = expand_tab_length(indent);
+
+ if (indent_len & 1) != 0 {
+ return Err(parse_failure(n, "wrong indent length"));
+ }
+ let i = n;
+
+ let indent_level = (indent_len as u64) / 2;
+
+ let (i, vdev_name) = notspace1(i)?;
+
+ if let Ok((n, _)) = preceded(space0::<&str, VerboseError<&str>>, line_ending)(i) {
+ // special device
+ let vdev = ZFSPoolVDevState {
+ name: vdev_name.to_string(),
+ lvl: indent_level,
+ state: None,
+ read: None,
+ write: None,
+ cksum: None,
+ msg: None,
+ };
+ return Ok((n, vdev));
+ }
+
+ let (i, state) = preceded(space1, notspace1)(i)?;
+ if let Ok((n, _)) = preceded(space0::<&str, VerboseError<&str>>, line_ending)(i) {
+ // spares
+ let vdev = ZFSPoolVDevState {
+ name: vdev_name.to_string(),
+ lvl: indent_level,
+ state: Some(state.to_string()),
+ read: None,
+ write: None,
+ cksum: None,
+ msg: None,
+ };
+ return Ok((n, vdev));
+ }
+
+ let (i, read) = preceded(space1, nom::character::complete::u64)(i)?;
+ let (i, write) = preceded(space1, nom::character::complete::u64)(i)?;
+ let (i, cksum) = preceded(space1, nom::character::complete::u64)(i)?;
+ let (i, msg) = opt(preceded(space1, take_while(|c| c != '\n')))(i)?;
+ let (i, _) = line_ending(i)?;
+
+ let vdev = ZFSPoolVDevState {
+ name: vdev_name.to_string(),
+ lvl: indent_level,
+ state: Some(state.to_string()),
+ read: Some(read),
+ write: Some(write),
+ cksum: Some(cksum),
+ msg: msg.map(String::from),
+ };
+
+ Ok((i, vdev))
+}
+
+fn parse_zpool_status_tree(i: &str) -> IResult<&str, Vec<ZFSPoolVDevState>> {
+ // skip header
+ let (i, _) = tag("NAME")(i)?;
+ let (i, _) = space1(i)?;
+ let (i, _) = tag("STATE")(i)?;
+ let (i, _) = space1(i)?;
+ let (i, _) = tag("READ")(i)?;
+ let (i, _) = space1(i)?;
+ let (i, _) = tag("WRITE")(i)?;
+ let (i, _) = space1(i)?;
+ let (i, _) = tag("CKSUM")(i)?;
+ let (i, _) = line_ending(i)?;
+
+ // parse vdev list
+ many1(parse_zpool_status_vdev)(i)
+}
+
+fn space_indented_line(indent: usize) -> impl Fn(&str) -> IResult<&str, &str> {
+ move |i| {
+ let mut len = 0;
+ let mut n = i;
+ loop {
+ if n.starts_with('\t') {
+ len += 8;
+ } else if n.starts_with(' ') {
+ len += 1;
+ } else {
+ break;
+ }
+ n = &n[1..];
+ if len >= indent {
+ break;
+ }
+ }
+ if len != indent {
+ return Err(parse_error(i, "not correctly indented"));
+ }
+
+ take_while1(|c| c != '\n')(n)
+ }
+}
+
+fn parse_zpool_status_field(i: &str) -> IResult<&str, (String, String)> {
+ let (i, prefix) = take_while1(|c| c != ':')(i)?;
+ let (i, _) = tag(":")(i)?;
+ let (i, mut value) = take_while(|c| c != '\n')(i)?;
+ if value.starts_with(' ') {
+ value = &value[1..];
+ }
+
+ let (mut i, _) = line_ending(i)?;
+
+ let field = prefix.trim().to_string();
+
+ let prefix_len = expand_tab_length(prefix);
+
+ let indent: usize = prefix_len + 2;
+
+ let mut parse_continuation = opt(space_indented_line(indent));
+
+ let mut value = value.to_string();
+
+ if field == "config" {
+ let (n, _) = line_ending(i)?;
+ i = n;
+ }
+
+ loop {
+ let (n, cont) = parse_continuation(i)?;
+
+ if let Some(cont) = cont {
+ let (n, _) = line_ending(n)?;
+ i = n;
+ if !value.is_empty() {
+ value.push('\n');
+ }
+ value.push_str(cont);
+ } else {
+ if field == "config" {
+ let (n, _) = line_ending(i)?;
+ value.push('\n');
+ i = n;
+ }
+ break;
+ }
+ }
+
+ Ok((i, (field, value)))
+}
+
+pub fn parse_zpool_status_config_tree(i: &str) -> Result<Vec<ZFSPoolVDevState>, Error> {
+ parse_complete("zfs status config tree", i, parse_zpool_status_tree)
+}
+
+fn parse_zpool_status(input: &str) -> Result<Vec<(String, String)>, Error> {
+ parse_complete("zfs status output", input, many0(parse_zpool_status_field))
+}
+
+pub fn vdev_list_to_tree(vdev_list: &[ZFSPoolVDevState]) -> Result<Value, Error> {
+ indented_list_to_tree(vdev_list, |vdev| {
+ let node = serde_json::to_value(vdev).unwrap();
+ (node, vdev.lvl)
+ })
+}
+
+fn indented_list_to_tree<'a, T, F, I>(items: I, to_node: F) -> Result<Value, Error>
+where
+ T: 'a,
+ I: IntoIterator<Item = &'a T>,
+ F: Fn(&T) -> (Value, u64),
+{
+ struct StackItem {
+ node: Map<String, Value>,
+ level: u64,
+ children_of_parent: Vec<Value>,
+ }
+
+ let mut stack = Vec::<StackItem>::new();
+ // hold current node and the children of the current parent (as that's where we insert)
+ let mut cur = StackItem {
+ node: Map::<String, Value>::new(),
+ level: 0,
+ children_of_parent: Vec::new(),
+ };
+
+ for item in items {
+ let (node, node_level) = to_node(item);
+ let vdev_level = 1 + node_level;
+ let mut node = match node {
+ Value::Object(map) => map,
+ _ => bail!("to_node returned wrong type"),
+ };
+
+ node.insert("leaf".to_string(), Value::Bool(true));
+
+ // if required, go back up (possibly multiple levels):
+ while vdev_level < cur.level {
+ cur.children_of_parent.push(Value::Object(cur.node));
+ let mut parent = stack.pop().unwrap();
+ parent
+ .node
+ .insert("children".to_string(), Value::Array(cur.children_of_parent));
+ parent.node.insert("leaf".to_string(), Value::Bool(false));
+ cur = parent;
+
+ if vdev_level > cur.level {
+ // when we encounter misimatching levels like "0, 2, 1" instead of "0, 1, 2, 1"
+ bail!("broken indentation between levels");
+ }
+ }
+
+ if vdev_level > cur.level {
+ // indented further, push our current state and start a new "map"
+ stack.push(StackItem {
+ node: replace(&mut cur.node, node),
+ level: replace(&mut cur.level, vdev_level),
+ children_of_parent: take(&mut cur.children_of_parent),
+ });
+ } else {
+ // same indentation level, add to children of the previous level:
+ cur.children_of_parent
+ .push(Value::Object(replace(&mut cur.node, node)));
+ }
+ }
+
+ while !stack.is_empty() {
+ cur.children_of_parent.push(Value::Object(cur.node));
+ let mut parent = stack.pop().unwrap();
+ parent
+ .node
+ .insert("children".to_string(), Value::Array(cur.children_of_parent));
+ parent.node.insert("leaf".to_string(), Value::Bool(false));
+ cur = parent;
+ }
+
+ Ok(Value::Object(cur.node))
+}
+
+#[test]
+fn test_vdev_list_to_tree() {
+ const DEFAULT: ZFSPoolVDevState = ZFSPoolVDevState {
+ name: String::new(),
+ lvl: 0,
+ state: None,
+ read: None,
+ write: None,
+ cksum: None,
+ msg: None,
+ };
+
+ #[rustfmt::skip]
+ let input = vec![
+ //ZFSPoolVDevState { name: "root".to_string(), lvl: 0, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev1".to_string(), lvl: 1, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev1-disk1".to_string(), lvl: 2, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev1-disk2".to_string(), lvl: 2, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev2".to_string(), lvl: 1, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev2-g1".to_string(), lvl: 2, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev2-g1-d1".to_string(), lvl: 3, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev2-g1-d2".to_string(), lvl: 3, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev2-g2".to_string(), lvl: 2, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev3".to_string(), lvl: 1, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev4".to_string(), lvl: 1, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev4-g1".to_string(), lvl: 2, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev4-g1-d1".to_string(), lvl: 3, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev4-g1-d1-x1".to_string(), lvl: 4, ..DEFAULT },
+ ZFSPoolVDevState { name: "vdev4-g2".to_string(), lvl: 2, ..DEFAULT }, // up by 2
+ ];
+
+ const EXPECTED: &str = "{\
+ \"children\":[{\
+ \"children\":[{\
+ \"leaf\":true,\
+ \"lvl\":2,\"name\":\"vdev1-disk1\"\
+ },{\
+ \"leaf\":true,\
+ \"lvl\":2,\"name\":\"vdev1-disk2\"\
+ }],\
+ \"leaf\":false,\
+ \"lvl\":1,\"name\":\"vdev1\"\
+ },{\
+ \"children\":[{\
+ \"children\":[{\
+ \"leaf\":true,\
+ \"lvl\":3,\"name\":\"vdev2-g1-d1\"\
+ },{\
+ \"leaf\":true,\
+ \"lvl\":3,\"name\":\"vdev2-g1-d2\"\
+ }],\
+ \"leaf\":false,\
+ \"lvl\":2,\"name\":\"vdev2-g1\"\
+ },{\
+ \"leaf\":true,\
+ \"lvl\":2,\"name\":\"vdev2-g2\"\
+ }],\
+ \"leaf\":false,\
+ \"lvl\":1,\"name\":\"vdev2\"\
+ },{\
+ \"leaf\":true,\
+ \"lvl\":1,\"name\":\"vdev3\"\
+ },{\
+ \"children\":[{\
+ \"children\":[{\
+ \"children\":[{\
+ \"leaf\":true,\
+ \"lvl\":4,\"name\":\"vdev4-g1-d1-x1\"\
+ }],\
+ \"leaf\":false,\
+ \"lvl\":3,\"name\":\"vdev4-g1-d1\"\
+ }],\
+ \"leaf\":false,\
+ \"lvl\":2,\"name\":\"vdev4-g1\"\
+ },{\
+ \"leaf\":true,\
+ \"lvl\":2,\"name\":\"vdev4-g2\"\
+ }],\
+ \"leaf\":false,\
+ \"lvl\":1,\"name\":\"vdev4\"\
+ }],\
+ \"leaf\":false\
+ }";
+ let expected: Value =
+ serde_json::from_str(EXPECTED).expect("failed to parse expected json value");
+
+ let tree = vdev_list_to_tree(&input).expect("failed to turn valid vdev list into a tree");
+ assert_eq!(tree, expected);
+}
+
+pub fn zpool_status(pool: &str) -> Result<Vec<(String, String)>, Error> {
+ let mut command = std::process::Command::new("zpool");
+ command.args(["status", "-p", "-P", pool]);
+
+ let output = proxmox_sys::command::run_command(command, None)?;
+
+ parse_zpool_status(&output)
+}
+
+#[cfg(test)]
+fn test_parse(output: &str) -> Result<(), Error> {
+ let mut found_config = false;
+
+ for (k, v) in parse_zpool_status(output)? {
+ println!("<{k}> => '{v}'");
+ if k == "config" {
+ let vdev_list = parse_zpool_status_config_tree(&v)?;
+ let _tree = vdev_list_to_tree(&vdev_list);
+ found_config = true;
+ }
+ }
+ if !found_config {
+ bail!("got zpool status without config key");
+ }
+
+ Ok(())
+}
+
+#[test]
+fn test_zpool_status_parser() -> Result<(), Error> {
+ let output = r###" pool: tank
+ state: DEGRADED
+status: One or more devices could not be opened. Sufficient replicas exist for
+ the pool to continue functioning in a degraded state.
+action: Attach the missing device and online it using 'zpool online'.
+ see: http://www.sun.com/msg/ZFS-8000-2Q
+ scrub: none requested
+config:
+
+ NAME STATE READ WRITE CKSUM
+ tank DEGRADED 0 0 0
+ mirror-0 DEGRADED 0 0 0
+ c1t0d0 ONLINE 0 0 0
+ c1t2d0 ONLINE 0 0 0
+ c1t1d0 UNAVAIL 0 0 0 cannot open
+ mirror-1 DEGRADED 0 0 0
+ tank1 DEGRADED 0 0 0
+ tank2 DEGRADED 0 0 0
+
+errors: No known data errors
+"###;
+
+ test_parse(output)
+}
+
+#[test]
+fn test_zpool_status_parser2() -> Result<(), Error> {
+ // Note: this input create TABS
+ let output = r###" pool: btest
+ state: ONLINE
+ scan: none requested
+config:
+
+ NAME STATE READ WRITE CKSUM
+ btest ONLINE 0 0 0
+ mirror-0 ONLINE 0 0 0
+ /dev/sda1 ONLINE 0 0 0
+ /dev/sda2 ONLINE 0 0 0
+ mirror-1 ONLINE 0 0 0
+ /dev/sda3 ONLINE 0 0 0
+ /dev/sda4 ONLINE 0 0 0
+ logs
+ /dev/sda5 ONLINE 0 0 0
+
+errors: No known data errors
+"###;
+ test_parse(output)
+}
+
+#[test]
+fn test_zpool_status_parser3() -> Result<(), Error> {
+ let output = r###" pool: bt-est
+ state: ONLINE
+ scan: none requested
+config:
+
+ NAME STATE READ WRITE CKSUM
+ bt-est ONLINE 0 0 0
+ mirror-0 ONLINE 0 0 0
+ /dev/sda1 ONLINE 0 0 0
+ /dev/sda2 ONLINE 0 0 0
+ mirror-1 ONLINE 0 0 0
+ /dev/sda3 ONLINE 0 0 0
+ /dev/sda4 ONLINE 0 0 0
+ logs
+ /dev/sda5 ONLINE 0 0 0
+
+errors: No known data errors
+"###;
+
+ test_parse(output)
+}
+
+#[test]
+fn test_zpool_status_parser_spares() -> Result<(), Error> {
+ let output = r###" pool: tank
+ state: ONLINE
+ scan: none requested
+config:
+
+ NAME STATE READ WRITE CKSUM
+ tank ONLINE 0 0 0
+ mirror-0 ONLINE 0 0 0
+ /dev/sda1 ONLINE 0 0 0
+ /dev/sda2 ONLINE 0 0 0
+ mirror-1 ONLINE 0 0 0
+ /dev/sda3 ONLINE 0 0 0
+ /dev/sda4 ONLINE 0 0 0
+ logs
+ /dev/sda5 ONLINE 0 0 0
+ spares
+ /dev/sdb AVAIL
+ /dev/sdc AVAIL
+
+errors: No known data errors
+"###;
+
+ test_parse(output)
+}
--
2.47.3
next prev parent reply other threads:[~2026-03-12 14:03 UTC|newest]
Thread overview: 31+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-03-12 13:52 [PATCH datacenter-manager/proxmox{,-backup,-yew-comp} 00/26] metric collection for the PDM host Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 01/26] sys: procfs: don't read from sysfs during unit tests Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 02/26] parallel-handler: import code from Proxmox Backup Server Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 03/26] parallel-handler: introduce custom error type Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 04/26] parallel-handler: add documentation Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 05/26] parallel-handler: add simple unit-test suite Lukas Wagner
2026-03-12 13:52 ` Lukas Wagner [this message]
2026-03-16 13:13 ` [PATCH proxmox 06/26] disks: import from Proxmox Backup Server Arthur Bied-Charreton
2026-03-12 13:52 ` [PATCH proxmox 07/26] disks: fix typo in `initialize_gpt_disk` Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 08/26] disks: add parts of gather_disk_stats from PBS Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 09/26] disks: gate api macro behind 'api-types' feature Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 10/26] disks: clippy: collapse if-let chains where possible Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox 11/26] procfs: add helpers for querying pressure stall information Lukas Wagner
2026-03-16 13:25 ` Arthur Bied-Charreton
2026-03-12 13:52 ` [PATCH proxmox 12/26] time: use u64 parse helper from nom Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox-backup 13/26] tools: move ParallelHandler to new proxmox-parallel-handler crate Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox-backup 14/26] tools: replace disks module with proxmox-disks Lukas Wagner
2026-03-16 13:27 ` Arthur Bied-Charreton
2026-03-12 13:52 ` [PATCH proxmox-backup 15/26] metric collection: use blockdev_stat_for_path from proxmox_disks Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox-yew-comp 16/26] node status panel: add `children` property Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox-yew-comp 17/26] RRDGrid: fix size observer by attaching node reference to rendered container Lukas Wagner
2026-03-12 13:52 ` [PATCH proxmox-yew-comp 18/26] RRDGrid: add padding and increase gap between elements Lukas Wagner
2026-03-12 13:52 ` [PATCH datacenter-manager 19/26] metric collection: clarify naming for remote metric collection Lukas Wagner
2026-03-12 13:52 ` [PATCH datacenter-manager 20/26] metric collection: fix minor typo in error message Lukas Wagner
2026-03-12 13:52 ` [PATCH datacenter-manager 21/26] metric collection: collect PDM host metrics in a new collection task Lukas Wagner
2026-03-12 13:52 ` [PATCH datacenter-manager 22/26] api: fix /nodes/localhost/rrddata endpoint Lukas Wagner
2026-03-12 13:52 ` [PATCH datacenter-manager 23/26] pdm: node rrd data: rename 'total-time' to 'metric-collection-total-time' Lukas Wagner
2026-03-12 13:52 ` [PATCH datacenter-manager 24/26] pdm-api-types: add PDM host metric fields Lukas Wagner
2026-03-12 13:52 ` [PATCH datacenter-manager 25/26] ui: node status: add RRD graphs for PDM host metrics Lukas Wagner
2026-03-12 13:52 ` [PATCH datacenter-manager 26/26] ui: lxc/qemu/node: use RRD value render helpers Lukas Wagner
2026-03-16 13:42 ` [PATCH datacenter-manager/proxmox{,-backup,-yew-comp} 00/26] metric collection for the PDM host Arthur Bied-Charreton
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260312135229.420729-7-l.wagner@proxmox.com \
--to=l.wagner@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.