all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 proxmox 01/76] add proxmox-multipath crate
Date: Fri,  6 Nov 2026 00:30:36 +0100	[thread overview]
Message-ID: <20261105233336.2178064-2-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>

SAN scans, node status reports and iSCSI storage activation need the
same view of the multipath maps and path states that multipathd
reports. Provide one typed model keyed on the LUN WWID, with the maps,
their member paths and a health verdict. It does not query the daemon
itself, so recorded replies can test it.

The model only carries what the daemon output can tell: byte size and
the transport of non-FC paths need sysfs and stay with the caller.
State strings keep the multipathd vocabulary verbatim. An ALUA standby
path with a ghost checker state counts as usable as long as
device-mapper keeps it active. The health derivation takes an optional
expected path count, as a path the transport already removed no longer
shows in the totals and would turn a degraded two-path map into a
seemingly optimal single-path one.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 Cargo.toml                             |   2 +
 proxmox-multipath/Cargo.toml           |  17 +
 proxmox-multipath/debian/changelog     |   7 +
 proxmox-multipath/debian/control       |  38 ++
 proxmox-multipath/debian/copyright     |  18 +
 proxmox-multipath/debian/debcargo.toml |   7 +
 proxmox-multipath/src/lib.rs           | 531 +++++++++++++++++++++++++
 7 files changed, 620 insertions(+)
 create mode 100644 proxmox-multipath/Cargo.toml
 create mode 100644 proxmox-multipath/debian/changelog
 create mode 100644 proxmox-multipath/debian/control
 create mode 100644 proxmox-multipath/debian/copyright
 create mode 100644 proxmox-multipath/debian/debcargo.toml
 create mode 100644 proxmox-multipath/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index 79b0fcbd..5c204a48 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ members = [
     "proxmox-login",
     "proxmox-metrics",
     "proxmox-match-expression",
+    "proxmox-multipath",
     "proxmox-network-api",
     "proxmox-network-types",
     "proxmox-node-status",
@@ -182,6 +183,7 @@ proxmox-lang = { version = "1.5", path = "proxmox-lang" }
 proxmox-log = { version = "1.0.0", path = "proxmox-log" }
 proxmox-login = { version = "1.0.0", path = "proxmox-login" }
 proxmox-match-expression = { version = "0.1", path = "proxmox-match-expression" }
+proxmox-multipath = { version = "0.1.0", path = "proxmox-multipath" }
 proxmox-network-types = { version = "1.2.0", path = "proxmox-network-types" }
 proxmox-parallel-handler = { version = "1.0.0", path = "proxmox-parallel-handler" }
 proxmox-pgp = { version = "1.0.0", path = "proxmox-pgp" }
diff --git a/proxmox-multipath/Cargo.toml b/proxmox-multipath/Cargo.toml
new file mode 100644
index 00000000..dc1a1e45
--- /dev/null
+++ b/proxmox-multipath/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "proxmox-multipath"
+description = "device-mapper multipath maps and multipathd status parsing"
+version = "0.1.0"
+
+authors.workspace = true
+edition.workspace = true
+exclude.workspace = true
+homepage.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
diff --git a/proxmox-multipath/debian/changelog b/proxmox-multipath/debian/changelog
new file mode 100644
index 00000000..ff80b64e
--- /dev/null
+++ b/proxmox-multipath/debian/changelog
@@ -0,0 +1,7 @@
+rust-proxmox-multipath (0.1.0-1) trixie; urgency=medium
+
+  * initial release: normalized model of device-mapper multipath maps, a
+    parser for the JSON output of multipathd, and health derivation from the
+    path states
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 03 Sep 2026 21:30:00 +0200
diff --git a/proxmox-multipath/debian/control b/proxmox-multipath/debian/control
new file mode 100644
index 00000000..a2c1f488
--- /dev/null
+++ b/proxmox-multipath/debian/control
@@ -0,0 +1,38 @@
+Source: rust-proxmox-multipath
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native (>= 1.88) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>,
+ librust-serde-json-1+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-multipath
+
+Package: librust-proxmox-multipath-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-anyhow-1+default-dev,
+ librust-serde-1+default-dev,
+ librust-serde-1+derive-dev,
+ librust-serde-json-1+default-dev
+Provides:
+ librust-proxmox-multipath+default-dev (= ${binary:Version}),
+ librust-proxmox-multipath-0-dev (= ${binary:Version}),
+ librust-proxmox-multipath-0+default-dev (= ${binary:Version}),
+ librust-proxmox-multipath-0.1-dev (= ${binary:Version}),
+ librust-proxmox-multipath-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-multipath-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-multipath-0.1.0+default-dev (= ${binary:Version})
+Description: Device-mapper multipath maps and multipathd status parsing - Rust source code
+ Source code for Debianized Rust crate "proxmox-multipath"
diff --git a/proxmox-multipath/debian/copyright b/proxmox-multipath/debian/copyright
new file mode 100644
index 00000000..01138fa0
--- /dev/null
+++ b/proxmox-multipath/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-multipath/debian/debcargo.toml b/proxmox-multipath/debian/debcargo.toml
new file mode 100644
index 00000000..b7864cdb
--- /dev/null
+++ b/proxmox-multipath/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-multipath/src/lib.rs b/proxmox-multipath/src/lib.rs
new file mode 100644
index 00000000..7be46b17
--- /dev/null
+++ b/proxmox-multipath/src/lib.rs
@@ -0,0 +1,531 @@
+//! Normalized model of device-mapper multipath maps and a parser for the JSON status output of
+//! `multipathd`.
+//!
+//! Everything keys on the SCSI/NAA WWID, which `multipathd` reports as the map's `uuid`. Map names
+//! (`mpathN` or configured aliases) and the `sdX` member paths are node-local and unstable; the
+//! WWID is not, so it is what a cluster-wide health view or a consumer waiting for a map binds to.
+//!
+//! The crate is pure: it turns text into typed values and derives health from path states, so it
+//! can be tested against recorded `multipathd show maps json` replies. Querying the daemon and
+//! adding live-only information (byte size, transport from sysfs) is left to the caller.
+
+#![deny(missing_docs)]
+
+use std::fmt;
+use std::str::FromStr;
+
+use anyhow::{Error, bail, format_err};
+use serde::{Deserialize, Deserializer, Serialize};
+
+/// Health of a map, derived from the states of its member paths.
+#[non_exhaustive]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Health {
+    /// Every path is active and no expected path is missing.
+    Optimal,
+    /// The map is usable, but some paths are down or missing.
+    Degraded,
+    /// No active path is left.
+    Failed,
+}
+
+impl Health {
+    /// The lowercase name of the state, as used in configuration and APIs.
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Health::Optimal => "optimal",
+            Health::Degraded => "degraded",
+            Health::Failed => "failed",
+        }
+    }
+}
+
+impl fmt::Display for Health {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
+impl FromStr for Health {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(match s {
+            "optimal" => Health::Optimal,
+            "degraded" => Health::Degraded,
+            "failed" => Health::Failed,
+            other => bail!("unknown multipath health state '{other}'"),
+        })
+    }
+}
+
+/// Derive the health of a map from its path counts.
+///
+/// A path `multipathd` still lists but marks as failed lowers `active` below `total`. A path the
+/// transport already removed (a Fibre Channel remote port past its `dev_loss_tmo`, for example)
+/// is gone from `total` too, so the current counts alone cannot tell a two-path map that lost a
+/// path from a single-path map. Pass the number of paths the map should have as `expected`
+/// whenever the transport configuration knows it, and such a map stays degraded.
+pub fn derive_health(active: u64, total: u64, expected: Option<u64>) -> Health {
+    if total == 0 || active == 0 {
+        return Health::Failed;
+    }
+    if active < total {
+        return Health::Degraded;
+    }
+    if expected.is_some_and(|expected| total < expected) {
+        return Health::Degraded;
+    }
+    Health::Optimal
+}
+
+/// Transport a path is attached through.
+#[non_exhaustive]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Transport {
+    /// Fibre Channel.
+    Fc,
+    /// iSCSI (any of the open-iscsi transports).
+    Iscsi,
+    /// Serial Attached SCSI.
+    Sas,
+}
+
+/// One member path of a map.
+///
+/// The `*_state` fields carry the vocabulary of `multipathd` verbatim: the device-mapper state is
+/// `active`, `failed` or `undef`, the sysfs device state `running`, `offline` or `unknown`, and
+/// the checker state `ready`, `faulty`, `ghost` for an ALUA standby port, and so on. Fields that
+/// `multipathd` leaves empty or prints as `[undef]` are `None`.
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Path {
+    /// Kernel block device name, for example `sdb`.
+    pub dev: String,
+    /// The state device-mapper sees.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub dm_state: Option<String>,
+    /// The state the kernel block layer sees.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub dev_state: Option<String>,
+    /// The result of the last path checker run.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub check_state: Option<String>,
+    /// Path priority.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub priority: Option<i64>,
+    /// WWPN of the host (initiator) port, Fibre Channel only.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub host_wwpn: Option<String>,
+    /// WWPN of the target port, Fibre Channel only.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub target_wwpn: Option<String>,
+    /// Host adapter the path goes through: its PCI or CCW bus ID, or the local IP address for
+    /// iSCSI.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub host_adapter: Option<String>,
+    /// The transport, as far as the daemon output alone tells it: a target WWPN means Fibre
+    /// Channel. Other transports need the sysfs topology of the path device.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub transport: Option<Transport>,
+}
+
+impl Path {
+    /// Whether device-mapper has this path active, also in a priority group not currently in use.
+    pub fn is_active(&self) -> bool {
+        self.dm_state.as_deref() == Some("active")
+    }
+}
+
+/// A priority group of a map.
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct PathGroup {
+    /// The group number, starting at 1.
+    pub group: u64,
+    /// The device-mapper state of the group, for example `active` or `enabled`.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub dm_state: Option<String>,
+    /// The group priority.
+    pub priority: i64,
+    /// The member paths.
+    pub paths: Vec<Path>,
+}
+
+/// A multipath map, that is one LUN with all its paths.
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Map {
+    /// The WWID of the LUN, the cluster-wide stable identity of the map.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub wwid: Option<String>,
+    /// The node-local map name (`mpathN` or the configured alias).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub name: Option<String>,
+    /// The kernel device name of the map, for example `dm-3`.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sysfs: Option<String>,
+    /// The device-mapper state of the map.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub dm_state: Option<String>,
+    /// Path failures counted by `multipathd`; resetting the map statistics clears the count.
+    pub path_faults: u64,
+    /// Number of member paths over all groups.
+    pub paths_total: u64,
+    /// Number of member paths device-mapper has active, over all groups.
+    pub paths_active: u64,
+    /// Health derived from the path states, without an expected path count.
+    pub health: Health,
+    /// The priority groups with their paths.
+    pub path_groups: Vec<PathGroup>,
+}
+
+impl Map {
+    /// Iterate over the paths of all groups.
+    pub fn paths(&self) -> impl Iterator<Item = &Path> {
+        self.path_groups.iter().flat_map(|group| group.paths.iter())
+    }
+}
+
+/// `multipathd` renders unset string fields as the literal `[undef]` and sometimes as the empty
+/// string; neither is a value.
+fn clean(value: Option<String>) -> Option<String> {
+    value.filter(|value| !value.is_empty() && value != "[undef]")
+}
+
+/// `multipathd` prints `[unknown]` for a Fibre Channel port name it cannot read from sysfs.
+fn clean_wwpn(value: Option<String>) -> Option<String> {
+    clean(value).filter(|value| value != "[unknown]")
+}
+
+/// `multipathd` prints numbers as JSON integers; integer strings are accepted as well.
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum NumberOrString {
+    Number(i64),
+    String(String),
+}
+
+fn deserialize_number<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let value = Option::<NumberOrString>::deserialize(deserializer)?;
+    Ok(match value {
+        None => None,
+        Some(NumberOrString::Number(number)) => Some(number),
+        Some(NumberOrString::String(string)) => match string.trim() {
+            "" | "[undef]" => None,
+            trimmed => Some(trimmed.parse().map_err(serde::de::Error::custom)?),
+        },
+    })
+}
+
+#[derive(Deserialize)]
+struct RawPath {
+    dev: Option<String>,
+    dm_st: Option<String>,
+    dev_st: Option<String>,
+    chk_st: Option<String>,
+    #[serde(default, deserialize_with = "deserialize_number")]
+    pri: Option<i64>,
+    host_wwpn: Option<String>,
+    target_wwpn: Option<String>,
+    host_adapter: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct RawGroup {
+    #[serde(default, deserialize_with = "deserialize_number")]
+    group: Option<i64>,
+    dm_st: Option<String>,
+    #[serde(default, deserialize_with = "deserialize_number")]
+    pri: Option<i64>,
+    #[serde(default)]
+    paths: Vec<RawPath>,
+}
+
+#[derive(Deserialize)]
+struct RawMap {
+    name: Option<String>,
+    uuid: Option<String>,
+    sysfs: Option<String>,
+    dm_st: Option<String>,
+    #[serde(default, deserialize_with = "deserialize_number")]
+    path_faults: Option<i64>,
+    #[serde(default)]
+    path_groups: Vec<RawGroup>,
+}
+
+#[derive(Deserialize)]
+struct RawOutput {
+    #[serde(default)]
+    maps: Vec<RawMap>,
+}
+
+fn is_wwpn(value: &str) -> bool {
+    value
+        .strip_prefix("0x")
+        .is_some_and(|hex| !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit()))
+}
+
+impl From<RawPath> for Path {
+    fn from(raw: RawPath) -> Self {
+        let target_wwpn = clean_wwpn(raw.target_wwpn);
+        // only a real WWPN means Fibre Channel: the field is present but unset for iSCSI
+        let transport = target_wwpn
+            .as_deref()
+            .is_some_and(is_wwpn)
+            .then_some(Transport::Fc);
+        Path {
+            dev: raw.dev.unwrap_or_default(),
+            dm_state: clean(raw.dm_st),
+            dev_state: clean(raw.dev_st),
+            check_state: clean(raw.chk_st),
+            priority: raw.pri,
+            host_wwpn: clean_wwpn(raw.host_wwpn),
+            target_wwpn,
+            host_adapter: clean(raw.host_adapter),
+            transport,
+        }
+    }
+}
+
+impl From<RawMap> for Map {
+    fn from(raw: RawMap) -> Self {
+        let path_groups: Vec<PathGroup> = raw
+            .path_groups
+            .into_iter()
+            .map(|group| PathGroup {
+                group: group.group.unwrap_or(0).max(0) as u64,
+                dm_state: clean(group.dm_st),
+                priority: group.pri.unwrap_or(0),
+                paths: group.paths.into_iter().map(Path::from).collect(),
+            })
+            .collect();
+        let paths_total = path_groups
+            .iter()
+            .map(|group| group.paths.len() as u64)
+            .sum();
+        let paths_active = path_groups
+            .iter()
+            .flat_map(|group| group.paths.iter())
+            .filter(|path| path.is_active())
+            .count() as u64;
+        Map {
+            wwid: clean(raw.uuid),
+            name: clean(raw.name),
+            sysfs: clean(raw.sysfs),
+            dm_state: clean(raw.dm_st),
+            path_faults: raw.path_faults.unwrap_or(0).max(0) as u64,
+            paths_total,
+            paths_active,
+            health: derive_health(paths_active, paths_total, None),
+            path_groups,
+        }
+    }
+}
+
+/// Parse the output of `multipathd show maps json` into normalized maps, in the daemon's order.
+pub fn parse_maps_json(json: &str) -> Result<Vec<Map>, Error> {
+    let output: RawOutput = serde_json::from_str(json)
+        .map_err(|err| format_err!("could not parse multipathd maps JSON: {err}"))?;
+    Ok(output.maps.into_iter().map(Map::from).collect())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // three maps, one per health state, in the output format of multipathd
+    const THREE_STATES: &str = r#"{
+       "major_version": 0,
+       "minor_version": 1,
+       "maps": [
+          {
+             "name": "mpatha",
+             "uuid": "3600140500a1b2c3d4e5f6a7b8c9d0e1f",
+             "sysfs": "dm-0",
+             "dm_st": "active",
+             "paths": 2,
+             "path_faults": 0,
+             "path_groups": [
+                {
+                   "group": 1,
+                   "dm_st": "active",
+                   "pri": 50,
+                   "paths": [
+                      { "dev": "sdb", "dm_st": "active", "dev_st": "running", "chk_st": "ready", "pri": 50, "host_wwpn": "0x21000024ff7d6a1c", "target_wwpn": "0x500a098000000001" },
+                      { "dev": "sdc", "dm_st": "active", "dev_st": "running", "chk_st": "ready", "pri": 50, "host_wwpn": "0x21000024ff7d6a1d", "target_wwpn": "0x500a098000000002" }
+                   ]
+                }
+             ]
+          },
+          {
+             "name": "mpathb",
+             "uuid": "360014050aabbccddeeff00112233445566",
+             "sysfs": "dm-1",
+             "dm_st": "active",
+             "paths": 2,
+             "path_faults": 3,
+             "path_groups": [
+                {
+                   "group": 1,
+                   "dm_st": "active",
+                   "pri": 10,
+                   "paths": [
+                      { "dev": "sdd", "dm_st": "active", "dev_st": "running", "chk_st": "ready", "pri": 10 }
+                   ]
+                },
+                {
+                   "group": 2,
+                   "dm_st": "enabled",
+                   "pri": 0,
+                   "paths": [
+                      { "dev": "sde", "dm_st": "failed", "dev_st": "offline", "chk_st": "faulty", "pri": 0 }
+                   ]
+                }
+             ]
+          },
+          {
+             "name": "mpathc",
+             "uuid": "36001405ffffffffffffffffffffffffff",
+             "sysfs": "dm-2",
+             "dm_st": "active",
+             "paths": 1,
+             "path_groups": [
+                {
+                   "group": 1,
+                   "dm_st": "enabled",
+                   "pri": 0,
+                   "paths": [
+                      { "dev": "sdf", "dm_st": "failed", "dev_st": "offline", "chk_st": "faulty", "pri": 0, "target_wwpn": "[undef]", "host_adapter": "[undef]" }
+                   ]
+                }
+             ]
+          }
+       ]
+    }"#;
+
+    #[test]
+    fn parses_every_health_state() {
+        let maps = parse_maps_json(THREE_STATES).unwrap();
+        assert_eq!(maps.len(), 3);
+
+        let a = &maps[0];
+        assert_eq!(a.wwid.as_deref(), Some("3600140500a1b2c3d4e5f6a7b8c9d0e1f"));
+        assert_eq!(a.name.as_deref(), Some("mpatha"));
+        assert_eq!(a.sysfs.as_deref(), Some("dm-0"));
+        assert_eq!((a.paths_active, a.paths_total), (2, 2));
+        assert_eq!(a.health, Health::Optimal);
+        assert_eq!(a.path_faults, 0);
+        assert_eq!(a.path_groups.len(), 1);
+        let path = &a.path_groups[0].paths[0];
+        assert_eq!(path.target_wwpn.as_deref(), Some("0x500a098000000001"));
+        assert_eq!(path.host_wwpn.as_deref(), Some("0x21000024ff7d6a1c"));
+        assert_eq!(path.transport, Some(Transport::Fc));
+        assert_eq!(path.priority, Some(50));
+
+        let b = &maps[1];
+        assert_eq!((b.paths_active, b.paths_total), (1, 2));
+        assert_eq!(b.health, Health::Degraded);
+        assert_eq!(b.path_faults, 3);
+        assert_eq!(b.path_groups[1].dm_state.as_deref(), Some("enabled"));
+
+        let c = &maps[2];
+        assert_eq!((c.paths_active, c.paths_total), (0, 1));
+        assert_eq!(c.health, Health::Failed);
+        let path = &c.path_groups[0].paths[0];
+        assert_eq!(path.target_wwpn, None, "'[undef]' is cleaned away");
+        assert_eq!(path.host_adapter, None);
+        assert_eq!(
+            path.transport, None,
+            "'[undef]' target_wwpn does not imply fc"
+        );
+    }
+
+    #[test]
+    fn alua_standby_paths_count_as_active() {
+        // an ALUA standby port: device-mapper keeps the path active, the checker reports ghost
+        let json = r#"{ "maps": [ { "uuid": "3600a", "path_groups": [
+            { "group": 1, "pri": 50, "paths": [ { "dev": "sda", "dm_st": "active", "chk_st": "ready" } ] },
+            { "group": 2, "pri": 10, "paths": [ { "dev": "sdb", "dm_st": "active", "chk_st": "ghost" } ] }
+        ] } ] }"#;
+        let maps = parse_maps_json(json).unwrap();
+        assert_eq!((maps[0].paths_active, maps[0].paths_total), (2, 2));
+        assert_eq!(maps[0].health, Health::Optimal);
+    }
+
+    #[test]
+    fn unreadable_fc_port_names_are_unset() {
+        let json = r#"{ "maps": [ { "uuid": "3600a", "path_groups": [ { "group": 1, "pri": 50, "paths": [
+            { "dev": "sda", "dm_st": "active", "host_wwpn": "[unknown]", "target_wwpn": "[unknown]" }
+        ] } ] } ] }"#;
+        let maps = parse_maps_json(json).unwrap();
+        let path = &maps[0].path_groups[0].paths[0];
+        assert_eq!(path.host_wwpn, None);
+        assert_eq!(path.target_wwpn, None);
+        assert_eq!(path.transport, None);
+    }
+
+    #[test]
+    fn numbers_may_come_as_strings() {
+        let json = r#"{ "maps": [ { "uuid": "3600a", "path_faults": "7", "path_groups": [
+            { "group": "1", "pri": "50", "paths": [ { "dev": "sda", "dm_st": "active", "pri": "50" } ] }
+        ] } ] }"#;
+        let maps = parse_maps_json(json).unwrap();
+        assert_eq!(maps[0].path_faults, 7);
+        assert_eq!(maps[0].path_groups[0].group, 1);
+        assert_eq!(maps[0].path_groups[0].priority, 50);
+        assert_eq!(maps[0].path_groups[0].paths[0].priority, Some(50));
+    }
+
+    #[test]
+    fn empty_and_invalid_input() {
+        assert!(
+            parse_maps_json(r#"{ "major_version": 0, "maps": [] }"#)
+                .unwrap()
+                .is_empty()
+        );
+        assert!(parse_maps_json("{}").unwrap().is_empty());
+        let err = parse_maps_json("not json").unwrap_err();
+        assert!(
+            err.to_string()
+                .starts_with("could not parse multipathd maps JSON")
+        );
+    }
+
+    #[test]
+    fn serializes_with_kebab_case_keys() {
+        let maps = parse_maps_json(THREE_STATES).unwrap();
+        let value = serde_json::to_value(&maps[2]).unwrap();
+        assert_eq!(value["paths-active"], 0);
+        assert_eq!(value["health"], "failed");
+        let path = &value["path-groups"][0]["paths"][0];
+        assert_eq!(path["dm-state"], "failed");
+        assert_eq!(path["check-state"], "faulty");
+        assert!(
+            path.get("target-wwpn").is_none(),
+            "unset fields are omitted"
+        );
+    }
+
+    #[test]
+    fn health_derivation() {
+        assert_eq!(derive_health(0, 0, None), Health::Failed);
+        assert_eq!(derive_health(0, 2, None), Health::Failed);
+        assert_eq!(derive_health(1, 2, None), Health::Degraded);
+        assert_eq!(derive_health(2, 2, None), Health::Optimal);
+        // a path the transport removed no longer counts in total, only the expectation shows it
+        assert_eq!(derive_health(1, 1, Some(2)), Health::Degraded);
+        assert_eq!(derive_health(2, 2, Some(2)), Health::Optimal);
+        assert_eq!(derive_health(3, 3, Some(2)), Health::Optimal);
+        assert_eq!("degraded".parse::<Health>().unwrap(), Health::Degraded);
+        assert!("bogus".parse::<Health>().is_err());
+    }
+}
-- 
2.47.3




  reply	other threads:[~2026-11-06  0:51 UTC|newest]

Thread overview: 77+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-05 23:30 [PATCH v3 proxmox,proxmox-perl-rs,cluster,storage,docs,manager 00/76] SAN: cluster-wide multipath, SAN views and a guided storage setup Thomas Lamprecht
2026-11-05 23:30 ` Thomas Lamprecht [this message]
2026-11-05 23:30 ` [PATCH v3 proxmox-perl-rs 02/76] pve-rs: add PVE::RS::Multipath bindings for multipathd status parsing Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 cluster 03/76] cfs: observe the multipath configuration files Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 04/76] storage: distinguish status-only activation in plugins Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 05/76] lvm: find a shared volume group created by another node Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 06/76] storage: bound LVM status commands Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 07/76] iscsi: rate-limit session rescans per target Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 08/76] iscsi: list the LUNs of each session, not of its SCSI host Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 09/76] lvm: refuse to create a volume group on a device that holds data Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 10/76] lvm: refuse a base volume of a target that serves disk images Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 11/76] iscsi: refuse virtual devices other than multipath maps as volumes Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 12/76] diskmanage: collect disk transport type from lsblk Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 13/76] diskmanage: add helper to list multipath devices Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 14/76] diskmanage: qualify NVMe over fabrics transport Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 15/76] disks: list: add include-remote parameter Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 16/76] diskmanage: include iSCSI session devices in disk enumeration Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 17/76] diskmanage: link multipath member disks to their map device Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 18/76] iscsi: factor out session device map from device list Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 19/76] api: scan: add a SAN LUN inventory Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 20/76] pvesm: expose the " Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 21/76] disks: lvm: allow volume groups on SAN LUNs and multipath maps Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 22/76] multipath: add a library around multipathd Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 23/76] diskmanage: collect native NVMe multipath path state Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 24/76] api: scan: san-luns: report " Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 25/76] diskmanage: collect local block device users Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 26/76] api: add node-level iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 27/76] api: iscsi: add session logout with last-path protection Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 28/76] multipath: add a cluster-wide configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 29/76] iscsi: add an option to manage storage LUNs with multipath Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 30/76] lvm: activate a missing base volume before using the volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 31/76] multipath: apply the configuration on every node Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 32/76] iscsi: share the initiator identity reader Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 33/76] multipath: publish per-node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 34/76] api: add endpoints for the cluster-wide multipath configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 35/76] api: multipath: add the cluster-wide health status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 36/76] multipath: collect identities of non-iSCSI SCSI paths Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 37/76] api: scan: san-luns: report the multipath state of each LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 38/76] api: scan: flag duplicate physical volumes on distinct LUNs Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 39/76] api: disks: recheck local users and retain wipe device references Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 40/76] iscsi: name plain LUNs after their identity link Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 41/76] api: disks: guard SAN wipes with stable identities and storage checks Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 42/76] api: scan: refresh SAN usage with bounded probes Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 43/76] storage: lvm: note the rescan after extending a shared volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 44/76] storage: document the cluster-wide multipath management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 45/76] api: ceph: osd: keep iSCSI LUNs out of OSD creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 46/76] pvestatd: track pending storage status commands across re-exec Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 47/76] ui: storage: factor out the scan picker error mask Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 48/76] ui: storage: nfs: show scan errors and empty results Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 49/76] ui: storage: allow changing the node for NFS/CIFS scans Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 50/76] ui: storage: add guided remote storage wizard with NFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 51/76] ui: storage wizard: add SMB/CIFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 52/76] ui: storage wizard: preserve settings after partial setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 54/76] ui: storage wizard: reconcile unanswered storage creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 55/76] ui: storage wizard: add ZFS over iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 56/76] ui: dc: storage: add remote storage wizard entry to the add menu Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 57/76] ui: node: add SAN LUNs panel Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 58/76] ui: san luns: show multipath path state Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 59/76] api: nodes: expose iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 60/76] pvenode: add iscsi commands Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 61/76] ui: node: san: add iSCSI target and session management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 62/76] pvestatd: apply the multipath configuration and report the node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 63/76] api: cluster: add the multipath endpoints Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 65/76] ui: storage: iscsi: add the multipath option Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 66/76] ui: san luns: show multipath state and manage LUN entries Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 67/76] ui: storage wizard: add iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 68/76] ui: storage wizard: iscsi: show cluster initiator names Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 69/76] ui: storage wizard: iscsi: enable multipath for shared LVM Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 70/76] ui: node: san: wipe a LUN that no storage uses Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 71/76] ui: storage wizard: iscsi: reuse existing target connections Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 72/76] ui: storage wizard: fibre channel: enable multipath for a LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 73/76] ui: storage wizard: let root@pam wipe a LUN with data Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 74/76] ui: storage wizard: do not offer LUNs in use for a new volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 75/76] ui: san: show the local users of a LUN in its usage tooltip Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 76/76] ui: san: check the other nodes for users before wiping a LUN Thomas Lamprecht

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=20261105233336.2178064-2-t.lamprecht@proxmox.com \
    --to=t.lamprecht@proxmox.com \
    --cc=pve-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.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal