From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id DA3BF1FF0AF for ; Fri, 06 Nov 2026 01:51:37 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 77B5321797; Fri, 06 Nov 2026 01:50:58 +0100 (CET) From: Thomas Lamprecht 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 Message-ID: <20261105233336.2178064-2-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com> References: <20261105233336.2178064-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926177293 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.333 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_SHORT 0.001 Use of a URL Shortener for very short URL RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: WYT4BRFPI5PHIIICL46M5MBRUTSG72CX X-Message-ID-Hash: WYT4BRFPI5PHIIICL46M5MBRUTSG72CX X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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 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 , + rustc:native (>= 1.88) , + libstd-rust-dev , + librust-anyhow-1+default-dev , + librust-serde-1+default-dev , + librust-serde-1+derive-dev , + librust-serde-json-1+default-dev +Maintainer: Proxmox Support Team +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 +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 . 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 " + +[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 { + 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) -> 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, + /// The state the kernel block layer sees. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dev_state: Option, + /// The result of the last path checker run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub check_state: Option, + /// Path priority. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// WWPN of the host (initiator) port, Fibre Channel only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_wwpn: Option, + /// WWPN of the target port, Fibre Channel only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_wwpn: Option, + /// 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, + /// 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, +} + +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, + /// The group priority. + pub priority: i64, + /// The member paths. + pub paths: Vec, +} + +/// 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, + /// The node-local map name (`mpathN` or the configured alias). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// The kernel device name of the map, for example `dm-3`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sysfs: Option, + /// The device-mapper state of the map. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dm_state: Option, + /// 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, +} + +impl Map { + /// Iterate over the paths of all groups. + pub fn paths(&self) -> impl Iterator { + 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) -> Option { + 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) -> Option { + 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, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::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, + dm_st: Option, + dev_st: Option, + chk_st: Option, + #[serde(default, deserialize_with = "deserialize_number")] + pri: Option, + host_wwpn: Option, + target_wwpn: Option, + host_adapter: Option, +} + +#[derive(Deserialize)] +struct RawGroup { + #[serde(default, deserialize_with = "deserialize_number")] + group: Option, + dm_st: Option, + #[serde(default, deserialize_with = "deserialize_number")] + pri: Option, + #[serde(default)] + paths: Vec, +} + +#[derive(Deserialize)] +struct RawMap { + name: Option, + uuid: Option, + sysfs: Option, + dm_st: Option, + #[serde(default, deserialize_with = "deserialize_number")] + path_faults: Option, + #[serde(default)] + path_groups: Vec, +} + +#[derive(Deserialize)] +struct RawOutput { + #[serde(default)] + maps: Vec, +} + +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 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 for Map { + fn from(raw: RawMap) -> Self { + let path_groups: Vec = 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, 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::().unwrap(), Health::Degraded); + assert!("bogus".parse::().is_err()); + } +} -- 2.47.3