public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview
@ 2026-06-26 12:07 Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 01/13] multipath: add helper library and managed configuration Thomas Lamprecht
                   ` (12 more replies)
  0 siblings, 13 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

This is a proof of concept for better multipath support in PVE. I talked it
over with Friedrich back in May and put it together to get early feedback,
also because there is other multipath and iSCSI work going on (for example
Mira's storage mapping series).

Today multipath is set up by hand on each node, with nothing managing it
cluster-wide. This series tries to improve that by adding:

 - cluster-wide config: a new /etc/pve/multipath.cfg in pmxcfs, kept as a
   SectionConfig: a 'defaults' section for the global multipathd knobs plus
   one 'wwid' section per allow-listed LUN holding its optional alias and any
   per-LUN knobs. Free-form hardware overrides live in a separate
   /etc/pve/multipath-overrides.conf. pvestatd renders both into the local
   multipathd drop-ins, so you set it up once for the whole cluster. Map names
   stay WWID-based and equal on every node (user_friendly_names no,
   find_multipaths strict).

 - multipath as storage: a new 'multipath' storage type exposes the maps as
   raw volumes by WWID at the stable /dev/disk/by-id path. An LVM storage can
   use it as its 'base', so a shared volume group gets path redundancy with no
   manual device setup. This standalone type is deliberately provisional: the
   cleaner long-term shape is multipath as a capability toggled on the
   transport plugins (iSCSI/FC/NVMe-oF) rather than a peer storage type. It is
   kept standalone here to keep the POC self-contained, and because FC has no
   PVE transport plugin to hang such a capability off.

 - health overview: each node publishes its per-WWID map health into the
   pmxcfs KV store, and /cluster/multipath/status turns that into a per-WWID
   by per-node matrix with a rolled-up cluster-state. The web UI adds a
   Datacenter "Multipath" panel (table plus config editor) and a read-only
   per-node view under Disks. This matrix is the most generalizable piece;
   it is really a per-resource, per-node health roll-up and could become a
   small shared primitive that other features reuse.

Everything keys on the (global) WWID, never the node-local sdX or mpathN names.

Repo (build-)dependencies:
 pve-manager -> pve-storage -> pve-cluster.

I tested it on a three-node cluster against an iSCSI target with two portals:
the config reaches all nodes, the matrix flags a path fault on one node
(rolling that LUN up to 'degraded') while the rest stay healthy, and a guest
on the shared volume group migrates between nodes without copying its disk.
Still a fairly simple test, so more would be needed to be sure nothing is off.

Open points:

 - Health shows the paths a map has right now, so a node that fully lost a
   path (removed, not just failed) still looks fine on its last remaining
   path. The series does surface a node that lost all paths (one that is
   expected but silent) as missing, but not the "down to one of two" case;
   catching lost redundancy properly needs a notion of how many paths to
   expect, and I would like input on how to model that. The "expected nodes"
   set is derived here from where a multipath storage is enabled; sourcing it
   from the storage mapping series instead would be cleaner.
 - Upgrade and adoption: PVE rewrites /etc/multipath/wwids to match its
   allow-list, so it drops WWIDs that PVE did not add, which is risky on nodes
   whose multipath was set up by hand or that boot from SAN. There is also no
   migration from an existing multipath.conf, and a no-touch guarantee for
   boot-from-SAN devices needs thought.
 - The 'multipath' storage type is provisional. The alternative is to make path
   coalescing a property of the transport storages (multipath on iscsi, and
   in-kernel ANA on a future nvme-of) with LVM using the transport as its base,
   instead of a separate type. That gives fewer storages to set up for iSCSI, a
   natural home for NVMe-oF, and no extra peer type next to iscsi, at the cost
   of touching the transport plugins and the ongoing iSCSI/NVMe-oF work
   (Dietmar). I lean towards it as the target and would like opinions.
 - The pure logic (config parsing, health derivation, status aggregation) could
   move to rust crate(s) used from Perl via perlmod, with the cluster and
   multipathd glue wrapper code staying in Perl.
 - Whether the per-node trigger should stay in pvestatd or move to its own
   service or timer.
 - How this should fit with the storage mapping work.

pve-storage:

Thomas Lamprecht (7):
      multipath: add helper library and managed configuration
      api: disks: add read-only multipath status endpoint
      api: multipath: add cluster-wide configuration endpoints
      multipath: add storage plugin for multipath LUNs
      lvm: allow a multipath storage as the base device
      multipath: broadcast per-node map health to the cluster KV store
      api: multipath: add cluster-wide health status endpoint

 src/PVE/API2/Disks.pm              |   7 +
 src/PVE/API2/Disks/Makefile        |   1 +
 src/PVE/API2/Disks/Multipath.pm    | 206 ++++++++++++++
 src/PVE/API2/Makefile              |   1 +
 src/PVE/API2/Multipath.pm          | 538 +++++++++++++++++++++++++++++++++++++
 src/PVE/Makefile                   |   4 +
 src/PVE/Multipath.pm               | 447 ++++++++++++++++++++++++++++++
 src/PVE/Multipath/ClusterConfig.pm |  55 ++++
 src/PVE/Multipath/Config.pm        | 361 +++++++++++++++++++++++++
 src/PVE/Multipath/Generator.pm     | 147 ++++++++++
 src/PVE/Storage.pm                 |   2 +
 src/PVE/Storage/LVMPlugin.pm       |   7 +-
 src/PVE/Storage/Makefile           |   3 +-
 src/PVE/Storage/MultipathPlugin.pm | 186 +++++++++++++
 src/test/Makefile                  |   5 +-
 src/test/run_multipath_tests.pl    | 423 +++++++++++++++++++++++++++++
 16 files changed, 2388 insertions(+), 5 deletions(-)

pve-cluster:

Thomas Lamprecht (1):
      pmxcfs: track cluster-wide multipath configuration

 src/PVE/Cluster.pm  | 2 ++
 src/pmxcfs/status.c | 2 ++
 2 files changed, 4 insertions(+)

pve-manager:

Thomas Lamprecht (5):
      pvestatd: apply the cluster-wide multipath config on each node
      api: cluster: mount the multipath configuration endpoint
      pvestatd: broadcast multipath map health to the cluster
      ui: dc: add multipath health matrix and config editor
      ui: node: show multipath maps and their paths under Disks

 PVE/API2/Cluster.pm            |   7 +
 PVE/Service/pvestatd.pm        |  14 ++
 www/manager6/Makefile          |   2 +
 www/manager6/Utils.js          |  25 +++
 www/manager6/dc/Config.js      |   6 +
 www/manager6/dc/Multipath.js   | 371 +++++++++++++++++++++++++++++++++++++++++
 www/manager6/node/Config.js    |   7 +
 www/manager6/node/Multipath.js | 163 ++++++++++++++++++
 8 files changed, 595 insertions(+)




^ permalink raw reply	[flat|nested] 16+ messages in thread

* [PATCH storage 01/13] multipath: add helper library and managed configuration
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 14:43   ` Maximiliano Sandoval
  2026-06-26 12:07 ` [PATCH storage 02/13] api: disks: add read-only multipath status endpoint Thomas Lamprecht
                   ` (11 subsequent siblings)
  12 siblings, 1 reply; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Multipath on PVE is configured by hand and per node today, with nothing
that keeps it consistent across a cluster. Add the foundation for
managing it cluster-wide instead.

The library reads the assembled maps and their health from multipathd.
The configuration is a SectionConfig kept in pmxcfs: one 'defaults'
section for the global multipathd knobs, plus one 'wwid' section per
allow-listed LUN holding its optional alias and any per-LUN knobs.
Parameters are kebab-case and rendered to multipathd's snake_case
keywords, validated through the section schema so a bad value cannot
reach the generated drop-in.

The managed baseline is deliberately conservative: it only assembles
explicitly allow-listed LUNs and keeps map names stable and WWID-based,
so a device is named the same on every node and an LVM PV on it stays
stable cluster-wide. Hardware-specific tuning lives in a separate,
admin-owned override rather than in the generated baseline, and the two
are written to distinct drop-ins, as multipath does not accept a
repeated 'defaults' section in one file.

Parsing and generation stay in a pure module with no dependency on
PVE::Cluster, so they remain unit-testable and usable on a node whose
pve-cluster does not yet observe the new file; registering it in pmxcfs
needs the matching pve-cluster change.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 src/PVE/Makefile                   |   4 +
 src/PVE/Multipath.pm               | 282 ++++++++++++++++++++++
 src/PVE/Multipath/ClusterConfig.pm |  55 +++++
 src/PVE/Multipath/Config.pm        | 361 +++++++++++++++++++++++++++++
 src/PVE/Multipath/Generator.pm     | 148 ++++++++++++
 src/test/Makefile                  |   5 +-
 src/test/run_multipath_tests.pl    | 238 +++++++++++++++++++
 7 files changed, 1092 insertions(+), 1 deletion(-)
 create mode 100644 src/PVE/Multipath.pm
 create mode 100644 src/PVE/Multipath/ClusterConfig.pm
 create mode 100644 src/PVE/Multipath/Config.pm
 create mode 100644 src/PVE/Multipath/Generator.pm
 create mode 100755 src/test/run_multipath_tests.pl

diff --git a/src/PVE/Makefile b/src/PVE/Makefile
index 9e9f6aa..7ddd646 100644
--- a/src/PVE/Makefile
+++ b/src/PVE/Makefile
@@ -4,6 +4,10 @@
 install:
 	install -D -m 0644 Storage.pm ${DESTDIR}${PERLDIR}/PVE/Storage.pm
 	install -D -m 0644 Diskmanage.pm ${DESTDIR}${PERLDIR}/PVE/Diskmanage.pm
+	install -D -m 0644 Multipath.pm ${DESTDIR}${PERLDIR}/PVE/Multipath.pm
+	install -D -m 0644 Multipath/Config.pm ${DESTDIR}${PERLDIR}/PVE/Multipath/Config.pm
+	install -D -m 0644 Multipath/ClusterConfig.pm ${DESTDIR}${PERLDIR}/PVE/Multipath/ClusterConfig.pm
+	install -D -m 0644 Multipath/Generator.pm ${DESTDIR}${PERLDIR}/PVE/Multipath/Generator.pm
 	install -D -m 0644 CephConfig.pm ${DESTDIR}${PERLDIR}/PVE/CephConfig.pm
 	install -D -m 0644 GuestImport.pm ${DESTDIR}${PERLDIR}/PVE/GuestImport.pm
 	make -C Storage install
diff --git a/src/PVE/Multipath.pm b/src/PVE/Multipath.pm
new file mode 100644
index 0000000..59c1103
--- /dev/null
+++ b/src/PVE/Multipath.pm
@@ -0,0 +1,282 @@
+package PVE::Multipath;
+
+use strict;
+use warnings;
+
+use JSON qw(decode_json);
+
+use PVE::Tools qw(run_command file_read_firstline file_get_contents);
+
+use PVE::Multipath::Config;
+
+# Helper library around device-mapper multipath (multipathd). The single place that knows how to
+# talk to multipathd and turn its state into a normalized, stable structure for the rest of the
+# storage stack: health reporting, the API, and consumers that wait for a map before binding to it.
+#
+# Everything keys on the SCSI/NAA WWID, as reported by multipathd in the map's 'uuid' field. Map
+# names (mpathN / aliases) and the underlying 'sdX' paths are node-local and unstable; the WWID is
+# not.
+
+my $MULTIPATH = '/sbin/multipath';
+my $MULTIPATHD = '/sbin/multipathd';
+
+# health states for a single map, derived from its path states
+use constant {
+    HEALTH_OPTIMAL => 'optimal', # all paths active
+    HEALTH_DEGRADED => 'degraded', # some but not all paths active
+    HEALTH_FAILED => 'failed', # no active path left
+};
+
+my $supported;
+
+sub is_supported {
+    return $supported if defined($supported);
+    $supported = (-x $MULTIPATH && -x $MULTIPATHD) ? 1 : 0;
+    return $supported;
+}
+
+sub assert_supported {
+    die "no multipath support - please install 'multipath-tools'\n" if !is_supported();
+    return 1;
+}
+
+# Returns whether the multipathd daemon is reachable. Used for status output, so it never dies and
+# just reports 0 when multipath is unavailable or the daemon socket cannot be queried.
+sub is_running {
+    return 0 if !is_supported();
+
+    my $running = 0;
+    eval {
+        run_command(
+            [$MULTIPATHD, 'show', 'daemon'],
+            outfunc => sub {
+                my ($line) = @_;
+                # example: "pid 1234 idle" / "pid 1234 running"
+                $running = 1 if $line =~ m/^pid \d+ \S+/;
+            },
+            errfunc => sub { },
+        );
+    };
+    return $running;
+}
+
+# Runs `multipathd show <subcmd> json` and returns the raw output. Kept separate from parsing so
+# tests can feed recorded fixtures to the parse_*() functions without a running daemon.
+my sub query_multipathd_json {
+    my ($subcmd) = @_;
+
+    assert_supported();
+
+    my $output = '';
+    run_command(
+        [$MULTIPATHD, 'show', $subcmd, 'json'],
+        outfunc => sub { $output .= "$_[0]\n"; },
+        errfunc => sub { warn "$_[0]\n"; },
+    );
+
+    return $output;
+}
+
+my sub derive_health {
+    my ($paths_active, $paths_total) = @_;
+
+    return HEALTH_FAILED if !$paths_total || !$paths_active;
+    return HEALTH_OPTIMAL if $paths_active == $paths_total;
+    return HEALTH_DEGRADED;
+}
+
+my sub normalize_path {
+    my ($path) = @_;
+
+    my $res = {
+        dev => $path->{dev},
+        # 'active' or 'failed' - the state device-mapper sees
+        'dm-state' => $path->{dm_st},
+        # 'running', 'faulty' or 'offline' - the state the kernel block layer sees
+        'dev-state' => $path->{dev_st},
+        # path checker result, e.g. 'ready' / 'faulty' / 'ghost'
+        'check-state' => $path->{chk_st},
+    };
+    $res->{priority} = int($path->{pri}) if defined($path->{pri});
+
+    # multipathd renders unset string fields as the literal '[undef]'
+    my $wwpn = $path->{target_wwpn};
+    my $hba = $path->{host_adapter};
+    undef $wwpn if !defined($wwpn) || $wwpn eq '[undef]' || $wwpn eq '';
+    undef $hba if !defined($hba) || $hba eq '[undef]' || $hba eq '';
+    $res->{'target-wwpn'} = $wwpn if defined($wwpn);
+    $res->{'host-adapter'} = $hba if defined($hba);
+    # a real target WWPN (0x...) means Fibre Channel; iSCSI/SAS transport is derived from sysfs by
+    # get_maps(). Do NOT treat the field's mere presence as FC, multipathd reports it as '[undef]'
+    # for iSCSI.
+    $res->{transport} = 'fc' if defined($wwpn) && $wwpn =~ /^0x[0-9a-f]+$/i;
+
+    return $res;
+}
+
+# Turns the output of `multipathd show maps json` into a normalized list of maps. Pure (no I/O) on
+# purpose: it derives everything it can from the JSON alone, so it can be unit-tested against
+# recorded fixtures. Live-only bits (byte size, transport) are added by get_maps() below.
+sub parse_maps_json {
+    my ($json) = @_;
+
+    my $data = eval { decode_json($json) };
+    die "could not parse multipathd maps JSON: $@\n" if $@;
+
+    my $maps = [];
+    for my $map (($data->{maps} // [])->@*) {
+        my $path_groups = [];
+        my ($paths_total, $paths_active) = (0, 0);
+
+        for my $group (($map->{path_groups} // [])->@*) {
+            my $paths = [];
+            for my $path (($group->{paths} // [])->@*) {
+                my $normalized = normalize_path($path);
+                $paths_total++;
+                $paths_active++ if ($normalized->{'dm-state'} // '') eq 'active';
+                push $paths->@*, $normalized;
+            }
+            push $path_groups->@*,
+                {
+                    group => int($group->{group} // 0),
+                    'dm-state' => $group->{dm_st},
+                    priority => int($group->{pri} // 0),
+                    paths => $paths,
+                };
+        }
+
+        push $maps->@*, {
+            wwid => $map->{uuid},
+            name => $map->{name},
+            sysfs => $map->{sysfs}, # the 'dm-N' kernel name
+            'dm-state' => $map->{dm_st},
+            'paths-total' => $paths_total,
+            'paths-active' => $paths_active,
+            health => derive_health($paths_active, $paths_total),
+            'path-groups' => $path_groups,
+        };
+    }
+
+    return $maps;
+}
+
+# Best-effort byte size of a dm device from sysfs (Linux reports size in 512b sectors regardless of
+# the real block size).
+my sub dm_size_bytes {
+    my ($sysfs) = @_;
+
+    return undef if !$sysfs;
+    my $sectors = file_read_firstline("/sys/block/$sysfs/size");
+    return undef if !defined($sectors) || $sectors !~ m/^\d+$/;
+    return int($sectors) * 512;
+}
+
+my sub dir_has_entries {
+    my ($dir) = @_;
+
+    return 0 if !-d $dir;
+    opendir(my $dh, $dir) or return 0;
+    my @entries = grep { $_ ne '.' && $_ ne '..' } readdir($dh);
+    closedir($dh);
+    return scalar(@entries) ? 1 : 0;
+}
+
+# Best-effort transport of a single 'sdX' path from its sysfs topology; only iSCSI and SAS need it,
+# Fibre Channel is already set from the map JSON.
+my sub path_transport {
+    my ($dev) = @_;
+
+    return undef if !$dev;
+    my $link = readlink("/sys/block/$dev");
+    return undef if !$link;
+    return 'iscsi' if $link =~ m{/session\d+/};
+    return 'fc' if $link =~ m{/rport-\d+};
+    return 'sas' if $link =~ m{/end_device-};
+    return undef;
+}
+
+# Returns the normalized maps enriched with information that requires the local system (size, a
+# stable consumer path). Dies if multipath is not supported; callers that just want status should
+# guard with is_supported()/is_running().
+sub get_maps {
+    my $maps = parse_maps_json(query_multipathd_json('maps'));
+
+    for my $map ($maps->@*) {
+        $map->{size} = dm_size_bytes($map->{sysfs});
+        # WWID-stable path, present independently of the (node-local) map name
+        $map->{path} = "/dev/disk/by-id/dm-uuid-mpath-$map->{wwid}"
+            if defined($map->{wwid});
+        $map->{used} = dir_has_entries("/sys/block/$map->{sysfs}/holders")
+            if $map->{sysfs};
+
+        my %transports;
+        for my $group ($map->{'path-groups'}->@*) {
+            for my $path ($group->{paths}->@*) {
+                $path->{transport} //= path_transport($path->{dev});
+                $transports{ $path->{transport} } = 1 if defined($path->{transport});
+            }
+        }
+        # only expose a map-level transport when all paths agree on it
+        my @transports = keys %transports;
+        $map->{transport} = $transports[0] if scalar(@transports) == 1;
+    }
+
+    return $maps;
+}
+
+sub get_map_for_wwid {
+    my ($wwid) = @_;
+
+    for my $map (get_maps()->@*) {
+        return $map if defined($map->{wwid}) && $map->{wwid} eq $wwid;
+    }
+    return undef;
+}
+
+# Polls until a map for the given WWID exists, up to $timeout seconds. A consumer like the iSCSI
+# plugin uses this after a login or rescan to bind to the coalesced dm device rather than to a
+# transient single 'sdX' path.
+sub wait_for_map {
+    my ($wwid, $timeout) = @_;
+
+    $timeout //= 10;
+
+    my $deadline = time() + $timeout;
+    while (1) {
+        my $map = eval { get_map_for_wwid($wwid) };
+        return $map if $map;
+        return undef if time() >= $deadline;
+        sleep(1);
+    }
+}
+
+my $WWIDS_FILE = '/etc/multipath/wwids';
+
+# The managed allow-list of LUNs (WWIDs) to assemble into a map; with 'find_multipaths strict' only
+# these get multipathed.
+sub list_wwids {
+    return [] if !-e $WWIDS_FILE;
+    return PVE::Multipath::Config::parse_wwids(file_get_contents($WWIDS_FILE));
+}
+
+sub add_wwid {
+    my ($wwid) = @_;
+
+    assert_supported();
+    run_command([$MULTIPATH, '-a', $wwid]);
+}
+
+sub remove_wwid {
+    my ($wwid) = @_;
+
+    assert_supported();
+    run_command([$MULTIPATH, '-w', $wwid]);
+}
+
+# Re-read the configuration and rebuild maps accordingly, after a config or allow-list change.
+sub reconfigure {
+    assert_supported();
+    run_command([$MULTIPATHD, 'reconfigure']);
+}
+
+1;
diff --git a/src/PVE/Multipath/ClusterConfig.pm b/src/PVE/Multipath/ClusterConfig.pm
new file mode 100644
index 0000000..0b09c3f
--- /dev/null
+++ b/src/PVE/Multipath/ClusterConfig.pm
@@ -0,0 +1,55 @@
+package PVE::Multipath::ClusterConfig;
+
+use strict;
+use warnings;
+
+use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
+
+use PVE::Multipath::Config;
+
+# Cluster-wide multipath configuration, replicated by pmxcfs. The structured allow-list, aliases and
+# knobs live in multipath.cfg (a SectionConfig); the free-form hardware override text lives in a
+# separate plain file so it stays hand-editable and diffable.
+my $FILENAME = 'multipath.cfg';
+my $OVERRIDES_FILENAME = 'multipath-overrides.conf';
+
+cfs_register_file(
+    $FILENAME,
+    sub { PVE::Multipath::Config->parse_config(@_); },
+    sub { PVE::Multipath::Config->write_config(@_); },
+);
+
+cfs_register_file(
+    $OVERRIDES_FILENAME,
+    \&PVE::Multipath::Config::parse_overrides,
+    \&PVE::Multipath::Config::write_overrides,
+);
+
+sub read_config {
+    return cfs_read_file($FILENAME);
+}
+
+sub write_config {
+    my ($cfg) = @_;
+    cfs_write_file($FILENAME, $cfg);
+}
+
+sub read_overrides {
+    return cfs_read_file($OVERRIDES_FILENAME);
+}
+
+sub write_overrides {
+    my ($text) = @_;
+    cfs_write_file($OVERRIDES_FILENAME, $text);
+}
+
+sub lock_config {
+    my ($code, $errmsg) = @_;
+
+    cfs_lock_file($FILENAME, undef, $code);
+    if (my $err = $@) {
+        $errmsg ? die "$errmsg: $err" : die $err;
+    }
+}
+
+1;
diff --git a/src/PVE/Multipath/Config.pm b/src/PVE/Multipath/Config.pm
new file mode 100644
index 0000000..21ad72e
--- /dev/null
+++ b/src/PVE/Multipath/Config.pm
@@ -0,0 +1,361 @@
+package PVE::Multipath::Config;
+
+use strict;
+use warnings;
+
+use PVE::SectionConfig;
+
+use base qw(PVE::SectionConfig);
+
+# Parser and writer for the cluster-wide source of truth in pmxcfs (/etc/pve/multipath.cfg). It is a
+# SectionConfig: a single 'defaults' section for global multipathd knobs, plus one 'wwid' section
+# per allow-listed LUN holding its optional alias and per-LUN knobs. Free-form hardware overrides
+# (device {} entries) live in a separate plain file, see PVE::Multipath::ClusterConfig. Kept pure so
+# it stays unit-testable without PVE::Cluster.
+
+# Conservative, cluster-friendly defaults, applied when the 'defaults' section omits them:
+# - find_multipaths strict -> only explicitly allow-listed LUNs get assembled, so boot/root and
+#   unrelated disks stay untouched.
+# - user_friendly_names no -> the map name is the WWID, identical on every node, so an LVM PV on
+#   /dev/disk/by-id/dm-uuid-mpath-<wwid> is stable cluster-wide without a node-local bindings file.
+my $MANAGED_DEFAULTS = {
+    'find-multipaths' => 'strict',
+    'user-friendly-names' => 'no',
+    'polling-interval' => 5,
+};
+
+sub managed_defaults { return { $MANAGED_DEFAULTS->%* }; }
+
+# Knobs valid both globally (defaults) and per-LUN (a multipaths{} entry), per multipath.conf(5).
+my $shared_knobs = {
+    'no-path-retry' => {
+        type => 'string',
+        pattern => '(?:queue|fail|\d+)',
+        typetext => 'queue|fail|<count>',
+        description =>
+            "How to react when all paths are down: keep queuing, fail at once, or retry"
+            . " for the given number of polling intervals.",
+        optional => 1,
+    },
+    'path-grouping-policy' => {
+        type => 'string',
+        enum => [qw(failover multibus group_by_serial group_by_prio group_by_node_name)],
+        description => "How paths are grouped into priority groups.",
+        optional => 1,
+    },
+    failback => {
+        type => 'string',
+        pattern => '(?:manual|immediate|followover|\d+)',
+        typetext => 'manual|immediate|followover|<seconds>',
+        description => "When to fail back to a restored higher-priority path group.",
+        optional => 1,
+    },
+    'path-selector' => {
+        type => 'string',
+        maxLength => 64,
+        description => "Path selector algorithm used within a priority group, for example"
+            . " 'service-time 0'.",
+        optional => 1,
+    },
+};
+
+# Knobs that only make sense globally.
+my $defaults_only_knobs = {
+    'find-multipaths' => {
+        type => 'string',
+        enum => [qw(yes no strict greedy smart)],
+        default => 'strict',
+        description => "Which devices multipathd assembles into a map. 'strict' only takes"
+            . " explicitly allow-listed WWIDs.",
+        optional => 1,
+    },
+    'user-friendly-names' => {
+        type => 'string',
+        enum => [qw(yes no)],
+        default => 'no',
+        description => "Whether to use node-local mpathN names. Keep 'no' for stable WWID-based"
+            . " names across the cluster.",
+        optional => 1,
+    },
+    'polling-interval' => {
+        type => 'integer',
+        minimum => 1,
+        default => 5,
+        description => "Interval between path checks, in seconds.",
+        optional => 1,
+    },
+};
+
+# Knobs that only make sense per-LUN.
+my $wwid_only_knobs = {
+    alias => {
+        type => 'string',
+        pattern => '[a-zA-Z0-9][a-zA-Z0-9._-]*',
+        maxLength => 64,
+        description =>
+            "Human-readable map name for this WWID; multipathd uses it as the map name.",
+        optional => 1,
+    },
+    'rr-min-io-rq' => {
+        type => 'integer',
+        minimum => 1,
+        description =>
+            "Number of I/O requests to route to a path before switching, request-based.",
+        optional => 1,
+    },
+    'rr-weight' => {
+        type => 'string',
+        enum => [qw(priorities uniform)],
+        description => "Whether to weight paths by priority when balancing I/O.",
+        optional => 1,
+    },
+};
+
+my $defaultData = {
+    propertyList => {
+        type => { description => "Section type ('defaults' or 'wwid')." },
+        id => {
+            type => 'string',
+            description =>
+                "Section ID: the literal 'defaults', or a LUN WWID for 'wwid' sections.",
+            pattern => '[a-zA-Z0-9._:-]+',
+            maxLength => 128,
+        },
+        $shared_knobs->%*,
+        $defaults_only_knobs->%*,
+        $wwid_only_knobs->%*,
+    },
+};
+
+sub private { return $defaultData; }
+
+package PVE::Multipath::Config::Defaults;
+
+use base qw(PVE::Multipath::Config);
+
+sub type { return 'defaults'; }
+
+sub options {
+    return {
+        'find-multipaths' => { optional => 1 },
+        'user-friendly-names' => { optional => 1 },
+        'polling-interval' => { optional => 1 },
+        'no-path-retry' => { optional => 1 },
+        'path-grouping-policy' => { optional => 1 },
+        failback => { optional => 1 },
+        'path-selector' => { optional => 1 },
+    };
+}
+
+__PACKAGE__->register();
+
+package PVE::Multipath::Config::Wwid;
+
+use base qw(PVE::Multipath::Config);
+
+sub type { return 'wwid'; }
+
+sub options {
+    return {
+        alias => { optional => 1 },
+        'no-path-retry' => { optional => 1 },
+        'path-grouping-policy' => { optional => 1 },
+        failback => { optional => 1 },
+        'path-selector' => { optional => 1 },
+        'rr-min-io-rq' => { optional => 1 },
+        'rr-weight' => { optional => 1 },
+    };
+}
+
+__PACKAGE__->register();
+
+package PVE::Multipath::Config;
+
+__PACKAGE__->init();
+
+# multipathd subsections accept only these top-level keywords; the admin override file is checked
+# against them. 'multipaths' is generated from the wwid sections, so an admin block would collide.
+my $OVERRIDE_KEYWORDS =
+    { devices => 1, overrides => 1, defaults => 1, blacklist => 1, blacklist_exceptions => 1 };
+
+# Validate the free-form override text before it can break multipathd's parser cluster-wide. This is
+# a guard, not a full parse: balanced braces, only known top-level sections, and no 'multipaths'
+# block (that is generated from the wwid sections and a duplicate is fatal to multipathd).
+sub check_overrides {
+    my ($text) = @_;
+
+    return if !defined($text) || $text !~ /\S/;
+
+    my ($open, $close) = (0, 0);
+    for my $line (split(/\n/, $text)) {
+        next if $line =~ /^\s*#/;
+        $open += ($line =~ tr/{//);
+        $close += ($line =~ tr/}//);
+        if ($line =~ /^\s*(\w+)\s*\{/) {
+            my $kw = $1;
+            die "multipath overrides: 'multipaths' is managed via aliases, do not set it here\n"
+                if $kw eq 'multipaths';
+            die "multipath overrides: unknown top-level section '$kw'\n"
+                if $open - $close == 1 && !$OVERRIDE_KEYWORDS->{$kw};
+        }
+    }
+    die "multipath overrides: unbalanced braces\n" if $open != $close;
+
+    return;
+}
+
+# Read/write the separate, admin-owned override file (/etc/pve/multipath-overrides.conf). Stored and
+# rendered verbatim, so it stays hand-editable and diffable.
+sub parse_overrides {
+    my ($filename, $raw) = @_;
+    return $raw // '';
+}
+
+sub write_overrides {
+    my ($filename, $text) = @_;
+    $text //= '';
+    $text =~ s/\s+$//;
+    return length($text) ? "$text\n" : '';
+}
+
+my $MANAGED_HEADER =
+    "# This file is managed by Proxmox VE - do not edit by hand.\n"
+    . "# Hardware-/node-specific overrides belong in the override config.\n";
+
+# Renders a named section from a key => value hash, keys sorted for a stable, diffable result. The
+# config and API use kebab-case parameters, multipathd keywords are snake_case, so map '-' to '_'.
+my sub render_section {
+    my ($name, $kv) = @_;
+
+    my $out = "$name {\n";
+    for my $key (sort keys $kv->%*) {
+        (my $keyword = $key) =~ tr/-/_/;
+        $out .= "\t$keyword $kv->{$key}\n";
+    }
+    $out .= "}\n";
+    return $out;
+}
+
+# Builds the Proxmox-managed baseline drop-in (header + defaults section) from the effective global
+# knobs. Admin overrides are not merged in here: they go into a separate conf.d file, as multipath
+# rejects two 'defaults' blocks in one file (duplicate keyword) and drops the second.
+sub generate_managed_conf {
+    my ($defaults) = @_;
+    $defaults //= managed_defaults();
+
+    return $MANAGED_HEADER . "\n" . render_section('defaults', $defaults);
+}
+
+# The WWID allow-list file (/etc/multipath/wwids) holds one '/<wwid>/' per line; parse it and back.
+sub parse_wwids {
+    my ($text) = @_;
+
+    my $wwids = [];
+    for my $line (split(/\n/, $text // '')) {
+        next if $line =~ /^\s*#/;
+        next if $line =~ /^\s*$/;
+        if ($line =~ m{^/(.+)/\s*$}) {
+            push $wwids->@*, $1;
+        }
+    }
+    return $wwids;
+}
+
+sub format_wwids {
+    my ($wwids) = @_;
+
+    my $out = "# Multipath wwids, managed by Proxmox VE\n";
+    $out .= "/$_/\n" for sort $wwids->@*;
+    return $out;
+}
+
+# Builds a 'multipaths {}' block from the per-WWID sections (alias plus any per-LUN knobs); returns
+# the empty string when no WWID has an alias or a knob set.
+sub build_multipaths_block {
+    my ($wwid_opts) = @_;
+
+    my @entries = grep { %{ $wwid_opts->{$_} } } sort keys %$wwid_opts;
+    return '' if !@entries;
+
+    my $out = "multipaths {\n";
+    for my $wwid (@entries) {
+        my $opts = $wwid_opts->{$wwid};
+        $out .= "\tmultipath {\n";
+        $out .= "\t\twwid $wwid\n";
+        for my $key (sort keys %$opts) {
+            (my $keyword = $key) =~ tr/-/_/;
+            $out .= "\t\t$keyword $opts->{$key}\n";
+        }
+        $out .= "\t}\n";
+    }
+    $out .= "}\n";
+    return $out;
+}
+
+# The knob property definitions as an API parameter schema. Strip the schema 'default' so an update
+# that omits a knob leaves it unchanged instead of resetting it to the managed default.
+my sub api_schema {
+    my ($props) = @_;
+
+    my $res = {};
+    for my $key (keys %$props) {
+        $res->{$key} = { $props->{$key}->%* };
+        delete $res->{$key}->{default};
+    }
+    return $res;
+}
+
+# Settable global knobs (the 'defaults' section) as an API parameter schema.
+sub defaults_api_schema {
+    return api_schema({ $shared_knobs->%*, $defaults_only_knobs->%* });
+}
+
+# Settable per-WWID knobs (including the alias) as an API parameter schema.
+sub wwid_api_schema {
+    return api_schema({ $shared_knobs->%*, $wwid_only_knobs->%* });
+}
+
+# Effective global knobs: the 'defaults' section merged onto the conservative managed defaults.
+sub effective_defaults {
+    my ($cfg) = @_;
+
+    my $defaults = managed_defaults();
+    if (my $section = $cfg->{ids}->{defaults}) {
+        $defaults->{$_} = $section->{$_} for grep { $_ ne 'type' } keys %$section;
+    }
+    return $defaults;
+}
+
+# The allow-listed WWIDs, that is the ids of the 'wwid' sections.
+sub wwid_list {
+    my ($cfg) = @_;
+    return [sort grep { ($cfg->{ids}->{$_}->{type} // '') eq 'wwid' } keys $cfg->{ids}->%*];
+}
+
+# { wwid => alias } for the WWIDs that have one.
+sub aliases {
+    my ($cfg) = @_;
+
+    my $res = {};
+    for my $wwid (keys $cfg->{ids}->%*) {
+        my $section = $cfg->{ids}->{$wwid};
+        next if ($section->{type} // '') ne 'wwid';
+        $res->{$wwid} = $section->{alias} if defined($section->{alias});
+    }
+    return $res;
+}
+
+# { wwid => { alias?, knob => value, ... } }, the per-WWID input to build_multipaths_block().
+sub wwid_opts {
+    my ($cfg) = @_;
+
+    my $res = {};
+    for my $wwid (keys $cfg->{ids}->%*) {
+        my $section = $cfg->{ids}->{$wwid};
+        next if ($section->{type} // '') ne 'wwid';
+        $res->{$wwid} = { map { $_ => $section->{$_} } grep { $_ ne 'type' } keys %$section };
+    }
+    return $res;
+}
+
+1;
diff --git a/src/PVE/Multipath/Generator.pm b/src/PVE/Multipath/Generator.pm
new file mode 100644
index 0000000..0bcd37f
--- /dev/null
+++ b/src/PVE/Multipath/Generator.pm
@@ -0,0 +1,148 @@
+package PVE::Multipath::Generator;
+
+use strict;
+use warnings;
+
+use File::Path qw(make_path);
+
+use PVE::Tools qw(file_get_contents file_set_contents);
+
+use PVE::Multipath;
+use PVE::Multipath::Config;
+use PVE::Multipath::ClusterConfig;
+
+# Renders the effective node-local multipath configuration from the cluster-wide source of truth
+# (/etc/pve/multipath.cfg) and reloads multipathd when something changed.
+#
+# The rendered files live on the local filesystem, so they survive reboots and are available to
+# multipathd at boot even before pmxcfs is up; the last successful render is the boot-time fallback.
+
+# Proxmox-owned drop-ins; the admin's /etc/multipath.conf keeps its default 'config_dir
+# /etc/multipath/conf.d'. The managed baseline, the admin overrides, and the generated aliases each
+# get their own file so multipath merges them across files instead of hitting a duplicate section
+# keyword: two 'defaults' blocks in one file are rejected outright, and our 'multipaths' alias block
+# would clash with a 'multipaths' section in the overrides. The overrides file sorts after the
+# baseline, so an admin's defaults override it; the aliases file is a separate 'multipaths' block,
+# so its order does not matter.
+my $DEFAULTS_DROPIN = '/etc/multipath/conf.d/pve-defaults.conf';
+my $OVERRIDES_DROPIN = '/etc/multipath/conf.d/pve-overrides.conf';
+my $ALIASES_DROPIN = '/etc/multipath/conf.d/pve-aliases.conf';
+
+my sub write_if_changed {
+    my ($path, $content) = @_;
+
+    my $old = -e $path ? eval { file_get_contents($path) } : undef;
+    return 0 if defined($old) && $old eq $content;
+
+    my $dir = $path =~ s!/[^/]+$!!r;
+    make_path($dir) if !-d $dir;
+    file_set_contents($path, $content);
+    return 1;
+}
+
+my sub remove_if_present {
+    my ($path) = @_;
+
+    return 0 if !-e $path;
+    unlink($path) or die "could not remove '$path': $!\n";
+    return 1;
+}
+
+sub regenerate {
+    my ($cfg, $overrides) = @_;
+    $cfg //= PVE::Multipath::ClusterConfig::read_config();
+    $overrides //= PVE::Multipath::ClusterConfig::read_overrides();
+
+    my $changed = 0;
+
+    my $defaults = PVE::Multipath::Config::effective_defaults($cfg);
+    $changed = 1
+        if write_if_changed($DEFAULTS_DROPIN,
+            PVE::Multipath::Config::generate_managed_conf($defaults));
+
+    if (defined($overrides) && length($overrides)) {
+        my $content =
+            "# Managed by Proxmox VE - edit overrides in /etc/pve/multipath-overrides.conf.\n\n"
+            . "$overrides\n";
+        $changed = 1 if write_if_changed($OVERRIDES_DROPIN, $content);
+    } else {
+        $changed = 1 if remove_if_present($OVERRIDES_DROPIN);
+    }
+
+    my $block =
+        PVE::Multipath::Config::build_multipaths_block(PVE::Multipath::Config::wwid_opts($cfg));
+    if (length($block)) {
+        my $content =
+            "# Managed by Proxmox VE - edit aliases and per-LUN options in /etc/pve/multipath.cfg.\n\n"
+            . $block;
+        $changed = 1 if write_if_changed($ALIASES_DROPIN, $content);
+    } else {
+        $changed = 1 if remove_if_present($ALIASES_DROPIN);
+    }
+
+    # Bring the WWID allow-list (/etc/multipath/wwids) in line with the cluster config through
+    # multipath's own add/remove, so its on-disk format stays intact. Isolate each op: one failing
+    # WWID must not abort the whole pass, or it would stall every other WWID on every run; a failed
+    # op leaves the file unchanged and is retried next pass.
+    my %desired = map { $_ => 1 } PVE::Multipath::Config::wwid_list($cfg)->@*;
+    my %current = map { $_ => 1 } PVE::Multipath::list_wwids()->@*;
+
+    my @errors;
+    for my $wwid (sort keys %desired) {
+        next if $current{$wwid};
+        eval { PVE::Multipath::add_wwid($wwid); };
+        if (my $err = $@) {
+            push @errors, "adding WWID '$wwid' failed - $err";
+        } else {
+            $changed = 1;
+        }
+    }
+    for my $wwid (sort keys %current) {
+        next if $desired{$wwid};
+        eval { PVE::Multipath::remove_wwid($wwid); };
+        if (my $err = $@) {
+            push @errors, "removing WWID '$wwid' failed - $err";
+        } else {
+            $changed = 1;
+        }
+    }
+
+    # reload the daemon for whatever did converge, even if some ops failed
+    if ($changed && PVE::Multipath::is_running()) {
+        eval { PVE::Multipath::reconfigure(); };
+        push @errors, "reconfigure failed - $@" if $@;
+    }
+
+    die join('', @errors) if @errors;
+
+    return $changed;
+}
+
+# Safe periodic entry point for a status loop like pvestatd: a no-op when multipath is not in use on
+# this node, and never throws, so a caller stays a single guarded line and the same entry point
+# works from a systemd unit or CLI.
+sub sync {
+    return 0 if !PVE::Multipath::is_supported();
+
+    my $cfg = eval { PVE::Multipath::ClusterConfig::read_config() };
+    if (my $err = $@) {
+        warn "multipath: reading cluster config failed - $err";
+        return 0;
+    }
+    my $overrides = eval { PVE::Multipath::ClusterConfig::read_overrides() };
+
+    # stay out of the way unless the feature is in use: nothing configured cluster-wide and no
+    # local drop-in present
+    return 0
+        if !scalar(PVE::Multipath::Config::wwid_list($cfg)->@*)
+        && !(defined($overrides) && length($overrides))
+        && !$cfg->{ids}->{defaults}
+        && !-e $DEFAULTS_DROPIN;
+
+    my $changed = eval { regenerate($cfg, $overrides) };
+    warn "multipath: config sync failed - $@" if $@;
+
+    return $changed // 0;
+}
+
+1;
diff --git a/src/test/Makefile b/src/test/Makefile
index ee025bc..51c7360 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -1,6 +1,6 @@
 all: test
 
-test: test_zfspoolplugin test_lvmplugin test_disklist test_bwlimit test_plugin test_ovf test_volume_access
+test: test_zfspoolplugin test_lvmplugin test_disklist test_bwlimit test_plugin test_ovf test_volume_access test_multipath
 
 test_zfspoolplugin: run_test_zfspoolplugin.pl
 	./run_test_zfspoolplugin.pl
@@ -22,3 +22,6 @@ test_ovf: run_ovf_tests.pl
 
 test_volume_access: run_volume_access_tests.pl
 	./run_volume_access_tests.pl
+
+test_multipath: run_multipath_tests.pl
+	./run_multipath_tests.pl
diff --git a/src/test/run_multipath_tests.pl b/src/test/run_multipath_tests.pl
new file mode 100755
index 0000000..f710308
--- /dev/null
+++ b/src/test/run_multipath_tests.pl
@@ -0,0 +1,238 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use JSON;
+use Test::More;
+
+use lib ('.', '..');
+use PVE::Multipath;
+use PVE::Multipath::Config;
+
+# A recorded `multipathd show maps json` reply with three maps exercising each
+# health state: an all-active map, a partially-failed map and an all-failed map.
+my $maps_json = <<'EOF';
+{
+   "major_version": 0,
+   "minor_version": 1,
+   "maps": [
+      {
+         "name": "mpatha",
+         "uuid": "3600140500a1b2c3d4e5f6a7b8c9d0e1f",
+         "sysfs": "dm-0",
+         "dm_st": "active",
+         "paths": 2,
+         "path_groups": [
+            {
+               "group": 1,
+               "dm_st": "active",
+               "pri": 50,
+               "paths": [
+                  { "dev": "sdb", "dm_st": "active", "dev_st": "running", "chk_st": "ready", "pri": 50, "target_wwpn": "0x500a098000000001" },
+                  { "dev": "sdc", "dm_st": "active", "dev_st": "running", "chk_st": "ready", "pri": 50, "target_wwpn": "0x500a098000000002" }
+               ]
+            }
+         ]
+      },
+      {
+         "name": "mpathb",
+         "uuid": "360014050aabbccddeeff00112233445566",
+         "sysfs": "dm-1",
+         "dm_st": "active",
+         "paths": 2,
+         "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": "faulty", "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": "faulty", "chk_st": "faulty", "pri": 0, "target_wwpn": "[undef]", "host_adapter": "[undef]" }
+               ]
+            }
+         ]
+      }
+   ]
+}
+EOF
+
+my $maps = PVE::Multipath::parse_maps_json($maps_json);
+
+is(scalar($maps->@*), 3, 'parsed all three maps');
+
+my ($a, $b, $c) = $maps->@*;
+
+# fully healthy map
+is($a->{wwid}, '3600140500a1b2c3d4e5f6a7b8c9d0e1f', 'map a wwid taken from uuid');
+is($a->{name}, 'mpatha', 'map a name');
+is($a->{sysfs}, 'dm-0', 'map a sysfs name');
+is($a->{'paths-total'}, 2, 'map a counts both paths');
+is($a->{'paths-active'}, 2, 'map a has two active paths');
+is($a->{health}, 'optimal', 'map a is optimal');
+is(scalar($a->{'path-groups'}->@*), 1, 'map a has one path group');
+is(
+    $a->{'path-groups'}->[0]->{paths}->[0]->{'target-wwpn'},
+    '0x500a098000000001',
+    'FC target wwpn is preserved',
+);
+is(
+    $a->{'path-groups'}->[0]->{paths}->[0]->{transport},
+    'fc',
+    'transport derived as fc from a target wwpn',
+);
+
+# one failed path out of two
+is($b->{'paths-total'}, 2, 'map b counts both paths across groups');
+is($b->{'paths-active'}, 1, 'map b has one active path');
+is($b->{health}, 'degraded', 'map b is degraded');
+
+# no active path left
+is($c->{'paths-total'}, 1, 'map c counts its single path');
+is($c->{'paths-active'}, 0, 'map c has no active path');
+is($c->{health}, 'failed', 'map c is failed');
+ok(
+    !defined($c->{'path-groups'}->[0]->{paths}->[0]->{'target-wwpn'}),
+    "multipathd '[undef]' target_wwpn is cleaned away (not stored)",
+);
+ok(
+    !defined($c->{'path-groups'}->[0]->{paths}->[0]->{transport}),
+    "'[undef]' target_wwpn does not imply fc transport",
+);
+
+# empty / no maps must parse to an empty list, not die
+my $empty = PVE::Multipath::parse_maps_json('{ "major_version": 0, "maps": [] }');
+is_deeply($empty, [], 'no maps parses to empty list');
+
+# malformed input must die with a clear error
+eval { PVE::Multipath::parse_maps_json('not json') };
+ok($@ =~ m/could not parse multipathd maps JSON/, 'invalid JSON raises a clear error');
+
+# --- config generation / WWID allow-list ---
+my $conf = PVE::Multipath::Config::generate_managed_conf();
+like($conf, qr/managed by Proxmox VE/, 'managed conf carries the managed header');
+like($conf, qr/user_friendly_names no/, 'baseline sets user_friendly_names no');
+like($conf, qr/find_multipaths strict/, 'baseline opts in explicitly via find_multipaths strict');
+is(
+    scalar(() = $conf =~ /^defaults \{/mg),
+    1,
+    'baseline has exactly one defaults block (a second would be a duplicate-keyword error)',
+);
+
+my $wwids = PVE::Multipath::Config::parse_wwids("# Multipath wwids\n/3600abc/\n/3600def/\n");
+is_deeply($wwids, ['3600abc', '3600def'], 'parse_wwids extracts the wwids');
+like(
+    PVE::Multipath::Config::format_wwids(['3600def', '3600abc']),
+    qr{/3600abc/\n/3600def/},
+    'format_wwids sorts and slash-wraps',
+);
+
+# --- cluster config (pmxcfs source of truth): SectionConfig parse/write ---
+my $raw =
+    "defaults: defaults\n\tfind-multipaths strict\n\tno-path-retry queue\n\n"
+    . "wwid: 3600def\n\talias san-b-lun0\n\n"
+    . "wwid: 3600abc\n\talias san-a-lun0\n\tno-path-retry 18\n";
+my $cc = PVE::Multipath::Config->parse_config('multipath.cfg', $raw);
+is_deeply(
+    PVE::Multipath::Config::wwid_list($cc),
+    ['3600abc', '3600def'],
+    'wwid sections become the allow-list (sorted)',
+);
+is_deeply(
+    PVE::Multipath::Config::aliases($cc),
+    { '3600abc' => 'san-a-lun0', '3600def' => 'san-b-lun0' },
+    'aliases read from the wwid sections',
+);
+is(
+    PVE::Multipath::Config::effective_defaults($cc)->{'no-path-retry'},
+    'queue',
+    'defaults section knob is read',
+);
+is(
+    PVE::Multipath::Config::effective_defaults($cc)->{'user-friendly-names'},
+    'no',
+    'an unset defaults knob falls back to the managed default',
+);
+
+my $written = PVE::Multipath::Config->write_config('multipath.cfg', $cc);
+my $cc2 = PVE::Multipath::Config->parse_config('multipath.cfg', $written);
+is_deeply(
+    PVE::Multipath::Config::wwid_list($cc2),
+    ['3600abc', '3600def'],
+    'wwids survive the SectionConfig round-trip',
+);
+is_deeply(
+    PVE::Multipath::Config::aliases($cc2),
+    PVE::Multipath::Config::aliases($cc),
+    'aliases survive the round-trip',
+);
+is($cc2->{ids}->{'3600abc'}->{'no-path-retry'}, 18, 'a per-WWID knob survives the round-trip');
+
+is_deeply(
+    PVE::Multipath::Config::wwid_list(PVE::Multipath::Config->parse_config('multipath.cfg', '')),
+    [],
+    'an empty cluster config has no WWIDs',
+);
+
+# --- multipaths{} block (alias plus per-WWID knobs) ---
+my $block = PVE::Multipath::Config::build_multipaths_block({
+    '3600def' => { alias => 'san-b-lun0' },
+    '3600abc' => { alias => 'san-a-lun0', 'no-path-retry' => 18 },
+    '3600nul' => {},
+});
+like($block, qr/^multipaths \{/m, 'block opens with multipaths {');
+is(
+    scalar(() = $block =~ /^\tmultipath \{/mg),
+    2,
+    'one multipath{} per WWID that has an alias or a knob (the empty WWID is skipped)',
+);
+like(
+    $block,
+    qr/wwid 3600abc.*?alias san-a-lun0.*?no_path_retry 18/s,
+    'block carries the alias and the per-WWID knob',
+);
+my $abc_pos = index($block, 'wwid 3600abc');
+my $def_pos = index($block, 'wwid 3600def');
+ok($abc_pos < $def_pos, 'block emits entries in WWID-sorted order');
+is(PVE::Multipath::Config::build_multipaths_block({}), '', 'no WWIDs render to the empty string');
+
+# --- override guard ---
+eval { PVE::Multipath::Config::check_overrides("devices {\n\tdevice {\n\t\tvendor X\n\t}\n}\n") };
+is($@, '', 'a well-formed devices{} block passes the guard');
+eval { PVE::Multipath::Config::check_overrides("multipaths {\n}\n") };
+like($@, qr/managed via aliases/, 'a multipaths{} block is rejected, it is generated');
+eval { PVE::Multipath::Config::check_overrides("devices {\n") };
+like($@, qr/unbalanced braces/, 'unbalanced braces are rejected');
+eval { PVE::Multipath::Config::check_overrides("frobnicate {\n}\n") };
+like($@, qr/unknown top-level section/, 'an unknown top-level section is rejected');
+is(
+    PVE::Multipath::Config::write_overrides('x', "text  \n\n"),
+    "text\n",
+    'the overrides writer trims trailing whitespace',
+);
+
+done_testing();
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH storage 02/13] api: disks: add read-only multipath status endpoint
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 01/13] multipath: add helper library and managed configuration Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 03/13] api: multipath: add cluster-wide configuration endpoints Thomas Lamprecht
                   ` (10 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Expose multipath map state under /nodes/{node}/disks/multipath so the
web UI and operators can see map health, per-path transport and which
LVM volume group consumes a map, rather than parsing 'multipath -ll' by
hand.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 src/PVE/API2/Disks.pm           |   7 ++
 src/PVE/API2/Disks/Makefile     |   1 +
 src/PVE/API2/Disks/Multipath.pm | 206 ++++++++++++++++++++++++++++++++
 3 files changed, 214 insertions(+)
 create mode 100644 src/PVE/API2/Disks/Multipath.pm

diff --git a/src/PVE/API2/Disks.pm b/src/PVE/API2/Disks.pm
index e707a9e..984d890 100644
--- a/src/PVE/API2/Disks.pm
+++ b/src/PVE/API2/Disks.pm
@@ -14,6 +14,7 @@ use PVE::Tools qw(run_command);
 use PVE::API2::Disks::Directory;
 use PVE::API2::Disks::LVM;
 use PVE::API2::Disks::LVMThin;
+use PVE::API2::Disks::Multipath;
 use PVE::API2::Disks::ZFS;
 
 use PVE::RESTHandler;
@@ -34,6 +35,11 @@ __PACKAGE__->register_method({
     path => 'directory',
 });
 
+__PACKAGE__->register_method({
+    subclass => "PVE::API2::Disks::Multipath",
+    path => 'multipath',
+});
+
 __PACKAGE__->register_method({
     subclass => "PVE::API2::Disks::ZFS",
     path => 'zfs',
@@ -70,6 +76,7 @@ __PACKAGE__->register_method({
             { name => 'lvm' },
             { name => 'lvmthin' },
             { name => 'directory' },
+            { name => 'multipath' },
             { name => 'wipedisk' },
             { name => 'zfs' },
         ];
diff --git a/src/PVE/API2/Disks/Makefile b/src/PVE/API2/Disks/Makefile
index 9152aed..f6f8449 100644
--- a/src/PVE/API2/Disks/Makefile
+++ b/src/PVE/API2/Disks/Makefile
@@ -1,6 +1,7 @@
 
 SOURCES= LVM.pm\
 	 LVMThin.pm\
+	 Multipath.pm\
 	 ZFS.pm\
 	 Directory.pm
 
diff --git a/src/PVE/API2/Disks/Multipath.pm b/src/PVE/API2/Disks/Multipath.pm
new file mode 100644
index 0000000..5cf2d17
--- /dev/null
+++ b/src/PVE/API2/Disks/Multipath.pm
@@ -0,0 +1,206 @@
+package PVE::API2::Disks::Multipath;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::Multipath;
+use PVE::Storage::LVMPlugin;
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+my $path_groups_schema = {
+    type => 'array',
+    description => 'The path groups of the map, in priority order.',
+    items => {
+        type => 'object',
+        additionalProperties => 1,
+        properties => {
+            group => { type => 'integer' },
+            'dm-state' => { type => 'string', optional => 1 },
+            priority => { type => 'integer' },
+            paths => {
+                type => 'array',
+                items => {
+                    type => 'object',
+                    additionalProperties => 1,
+                    properties => {
+                        dev => {
+                            type => 'string',
+                            description => 'The underlying block device of the path.',
+                        },
+                        'dm-state' => {
+                            type => 'string',
+                            description => "Path state as seen by device-mapper "
+                                . "('active' or 'failed').",
+                            optional => 1,
+                        },
+                        'dev-state' => {
+                            type => 'string',
+                            description => "Path state as seen by the kernel block layer.",
+                            optional => 1,
+                        },
+                        'check-state' => {
+                            type => 'string',
+                            description => 'Result of the path checker.',
+                            optional => 1,
+                        },
+                        priority => { type => 'integer', optional => 1 },
+                        transport => {
+                            type => 'string',
+                            description => 'Transport of this path (iscsi, fc, sas).',
+                            optional => 1,
+                        },
+                    },
+                },
+            },
+        },
+    },
+};
+
+# Annotates each map with the LVM volume group sitting on it, if any; the map's PV shows up under
+# its /dev/mapper/<name> path in the VG -> PV listing.
+my sub annotate_lvm_usage {
+    my ($maps) = @_;
+
+    return if !scalar(@$maps);
+
+    my $pv_to_vg = {};
+    eval {
+        my $vgs = PVE::Storage::LVMPlugin::lvm_vgs(1);
+        for my $vgname (keys %$vgs) {
+            for my $pv ($vgs->{$vgname}->{pvs}->@*) {
+                $pv_to_vg->{ $pv->{name} } = $vgname;
+            }
+        }
+    };
+    warn $@ if $@;
+
+    for my $map (@$maps) {
+        next if !defined($map->{name});
+        my $vg = $pv_to_vg->{"/dev/mapper/$map->{name}"};
+        $map->{'used-by'} = "LVM VG '$vg'" if defined($vg);
+    }
+}
+
+__PACKAGE__->register_method({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    proxyto => 'node',
+    protected => 1,
+    permissions => {
+        check => ['perm', '/', ['Sys.Audit']],
+    },
+    description => "List and report the health of device-mapper multipath maps.",
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+        },
+    },
+    returns => {
+        type => 'object',
+        properties => {
+            supported => {
+                type => 'boolean',
+                description => "Whether multipath-tools is installed on the node.",
+            },
+            running => {
+                type => 'boolean',
+                description => "Whether the multipathd daemon is reachable.",
+            },
+            maps => {
+                type => 'array',
+                items => {
+                    type => 'object',
+                    additionalProperties => 1,
+                    properties => {
+                        wwid => {
+                            type => 'string',
+                            description => 'The WWID, the stable identity of the LUN.',
+                            optional => 1,
+                        },
+                        name => {
+                            type => 'string',
+                            description => 'The (node-local) multipath map name.',
+                            optional => 1,
+                        },
+                        path => {
+                            type => 'string',
+                            description => 'Stable WWID-based device path of the map.',
+                            optional => 1,
+                        },
+                        sysfs => {
+                            type => 'string',
+                            description => "The 'dm-N' kernel device name.",
+                            optional => 1,
+                        },
+                        size => {
+                            type => 'integer',
+                            description => 'Size of the map in bytes.',
+                            optional => 1,
+                        },
+                        health => {
+                            type => 'string',
+                            description => "Aggregated map health: 'optimal' (all paths "
+                                . "active), 'degraded' (some paths failed) or 'failed' "
+                                . "(no active path).",
+                            enum => ['optimal', 'degraded', 'failed'],
+                        },
+                        'dm-state' => { type => 'string', optional => 1 },
+                        'paths-total' => {
+                            type => 'integer',
+                            description => 'Total number of paths.',
+                        },
+                        'paths-active' => {
+                            type => 'integer',
+                            description => 'Number of currently active paths.',
+                        },
+                        transport => {
+                            type => 'string',
+                            description =>
+                                "Transport shared by all of the map's paths, if uniform.",
+                            optional => 1,
+                        },
+                        used => {
+                            type => 'boolean',
+                            description => 'Whether something sits on the map, such as an LVM'
+                                . ' physical volume.',
+                            optional => 1,
+                        },
+                        'used-by' => {
+                            type => 'string',
+                            description => 'What consumes the map, if known, such as an LVM'
+                                . ' volume group.',
+                            optional => 1,
+                        },
+                        'path-groups' => $path_groups_schema,
+                    },
+                },
+            },
+        },
+    },
+    code => sub {
+        my ($param) = @_;
+
+        my $supported = PVE::Multipath::is_supported();
+        my $running = $supported ? PVE::Multipath::is_running() : 0;
+
+        my $maps = [];
+        if ($running) {
+            $maps = PVE::Multipath::get_maps();
+            annotate_lvm_usage($maps);
+        }
+
+        return {
+            supported => $supported ? 1 : 0,
+            running => $running ? 1 : 0,
+            maps => $maps,
+        };
+    },
+});
+
+1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH storage 03/13] api: multipath: add cluster-wide configuration endpoints
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 01/13] multipath: add helper library and managed configuration Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 02/13] api: disks: add read-only multipath status endpoint Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 04/13] multipath: add storage plugin for multipath LUNs Thomas Lamprecht
                   ` (9 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Add a CRUD API over the cluster-wide multipath.cfg, mounted by
pve-manager at /cluster/multipath: read the effective configuration,
manage the WWID allow-list, set the global defaults and the per-LUN
knobs and aliases, and replace the verbatim override sections.

Updates take the settable knobs straight from the section schema, with
the schema 'default' stripped so omitting a knob leaves it unchanged
rather than resetting it; 'delete' is guarded to the settable
properties so it cannot strip a section's type and corrupt the file.
Applying the result on each node is handled separately by pvestatd via
the generator.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 src/PVE/API2/Makefile     |   1 +
 src/PVE/API2/Multipath.pm | 422 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 423 insertions(+)
 create mode 100644 src/PVE/API2/Multipath.pm

diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile
index fe316c5..002a3bd 100644
--- a/src/PVE/API2/Makefile
+++ b/src/PVE/API2/Makefile
@@ -3,5 +3,6 @@
 .PHONY: install
 install:
 	install -D -m 0644 Disks.pm ${DESTDIR}${PERLDIR}/PVE/API2/Disks.pm
+	install -D -m 0644 Multipath.pm ${DESTDIR}${PERLDIR}/PVE/API2/Multipath.pm
 	make -C Storage install
 	make -C Disks install
diff --git a/src/PVE/API2/Multipath.pm b/src/PVE/API2/Multipath.pm
new file mode 100644
index 0000000..6a165d5
--- /dev/null
+++ b/src/PVE/API2/Multipath.pm
@@ -0,0 +1,422 @@
+package PVE::API2::Multipath;
+
+use strict;
+use warnings;
+
+use PVE::Exception qw(raise_param_exc);
+use PVE::Storage;
+use PVE::Tools qw(extract_param);
+
+use PVE::Multipath::Config;
+use PVE::Multipath::ClusterConfig;
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+# WWID as exported by /dev/disk/by-id/dm-uuid-mpath-<wwid>; keep validation lenient, multipathd is
+# the authority on what is valid.
+my $WWID_RE = qr/^[a-zA-Z0-9._:-]+$/;
+
+# Map each multipath WWID to the storage that consumes it, read from the cluster-wide storage config
+# so the answer is identical on every node.
+my sub multipath_consumers {
+    my $consumers = {};
+
+    my $cfg = eval { PVE::Storage::config() };
+    return $consumers if !$cfg;
+
+    my $ids = $cfg->{ids} // {};
+    for my $storeid (sort keys %$ids) {
+        my $scfg = $ids->{$storeid};
+        next if ($scfg->{type} // '') ne 'lvm' || !defined($scfg->{base});
+
+        my ($baseid, $wwid) = eval { PVE::Storage::parse_volume_id($scfg->{base}, 1) };
+        next if !defined($baseid) || !defined($wwid);
+
+        my $basecfg = $ids->{$baseid};
+        next if !$basecfg || ($basecfg->{type} // '') ne 'multipath';
+
+        $consumers->{$wwid} = $storeid;
+    }
+
+    return $consumers;
+}
+
+# multipathd resolves an alias to a map name, so two WWIDs sharing one alias makes it drop a map
+# (the loser is order-dependent and only logged at level 1). Reject a collision up front.
+my sub assert_alias_free {
+    my ($cfg, $wwid, $alias) = @_;
+
+    for my $other (keys $cfg->{ids}->%*) {
+        next if $other eq $wwid;
+        my $section = $cfg->{ids}->{$other};
+        next if ($section->{type} // '') ne 'wwid';
+        die "alias '$alias' is already assigned to WWID '$other'\n"
+            if defined($section->{alias}) && $section->{alias} eq $alias;
+    }
+}
+
+# Apply a section update: set the given properties, then unset those named in $delete. Guard $delete
+# to the settable options and never set and delete the same key, so a stray 'delete=type' cannot
+# strip the section type and corrupt the file (as PVE::API2::Storage::Config guards it too).
+my sub apply_section_update {
+    my ($section, $param, $delete, $settable) = @_;
+
+    my @delete = split(/,/, $delete // '');
+    for my $key (@delete) {
+        raise_param_exc({ delete => "'$key' is not a settable property" }) if !$settable->{$key};
+        raise_param_exc({ $key => "cannot set and delete a property at the same time" })
+            if defined($param->{$key});
+    }
+    $section->{$_} = $param->{$_} for keys %$param;
+    delete $section->{$_} for @delete;
+}
+
+__PACKAGE__->register_method({
+    name => 'read',
+    path => '',
+    method => 'GET',
+    description => "Read the cluster-wide multipath configuration.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Audit']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {},
+    },
+    returns => {
+        type => 'object',
+        properties => {
+            defaults => {
+                type => 'object',
+                description => 'Effective global multipathd knobs.',
+                additionalProperties => 1,
+            },
+            wwids => {
+                type => 'array',
+                description => 'The multipath WWID allow-list.',
+                items => { type => 'string' },
+            },
+            aliases => {
+                type => 'object',
+                description => 'Per-WWID human-readable aliases.',
+                additionalProperties => { type => 'string' },
+            },
+            overrides => {
+                type => 'string',
+                description => 'Verbatim multipath.conf override sections.',
+                optional => 1,
+            },
+        },
+    },
+    code => sub {
+        my $cfg = PVE::Multipath::ClusterConfig::read_config();
+        my $overrides = PVE::Multipath::ClusterConfig::read_overrides();
+        return {
+            defaults => PVE::Multipath::Config::effective_defaults($cfg),
+            wwids => PVE::Multipath::Config::wwid_list($cfg),
+            aliases => PVE::Multipath::Config::aliases($cfg),
+            defined($overrides) && length($overrides) ? (overrides => $overrides) : (),
+        };
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'set_overrides',
+    path => '',
+    method => 'PUT',
+    protected => 1,
+    description => "Set the verbatim multipath.conf override sections, such as "
+        . "hardware-specific 'device {}' or 'overrides {}' blocks.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            overrides => {
+                type => 'string',
+                description => 'Verbatim override sections. Omit to clear them.',
+                maxLength => 128 * 1024,
+                optional => 1,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $overrides = $param->{overrides};
+        eval { PVE::Multipath::Config::check_overrides($overrides) };
+        raise_param_exc({ overrides => $@ }) if $@;
+
+        PVE::Multipath::ClusterConfig::write_overrides($overrides // '');
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'set_defaults',
+    path => 'defaults',
+    method => 'PUT',
+    protected => 1,
+    description => "Set the global multipathd defaults applied on every node.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            PVE::Multipath::Config::defaults_api_schema()->%*,
+            delete => {
+                type => 'string',
+                description => 'A list of settings to reset to their default, comma-separated.',
+                optional => 1,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $delete = extract_param($param, 'delete');
+        my $settable = PVE::Multipath::Config::defaults_api_schema();
+
+        PVE::Multipath::ClusterConfig::lock_config(
+            sub {
+                my $cfg = PVE::Multipath::ClusterConfig::read_config();
+                my $section = $cfg->{ids}->{defaults} //= { type => 'defaults' };
+                apply_section_update($section, $param, $delete, $settable);
+                PVE::Multipath::ClusterConfig::write_config($cfg);
+            },
+            "updating multipath defaults failed",
+        );
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'add_wwid',
+    path => 'wwid',
+    method => 'POST',
+    protected => 1,
+    description => "Add a WWID to the multipath allow-list.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            wwid => {
+                type => 'string',
+                description => 'The WWID of the LUN to manage via multipath.',
+                maxLength => 128,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $wwid = $param->{wwid};
+        raise_param_exc({ wwid => "does not look like a valid WWID" })
+            if $wwid !~ $WWID_RE;
+
+        PVE::Multipath::ClusterConfig::lock_config(
+            sub {
+                my $cfg = PVE::Multipath::ClusterConfig::read_config();
+                return if $cfg->{ids}->{$wwid};
+                $cfg->{ids}->{$wwid} = { type => 'wwid' };
+                PVE::Multipath::ClusterConfig::write_config($cfg);
+            },
+            "adding WWID '$wwid' failed",
+        );
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'update_wwid',
+    path => 'wwid/{wwid}',
+    method => 'PUT',
+    protected => 1,
+    description => "Set the alias and per-LUN multipathd knobs for an allow-listed WWID.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            wwid => {
+                type => 'string',
+                description => 'The WWID to update.',
+                maxLength => 128,
+            },
+            PVE::Multipath::Config::wwid_api_schema()->%*,
+            delete => {
+                type => 'string',
+                description => 'A list of settings to unset, comma-separated.',
+                optional => 1,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $wwid = extract_param($param, 'wwid');
+        my $delete = extract_param($param, 'delete');
+        my $settable = PVE::Multipath::Config::wwid_api_schema();
+
+        PVE::Multipath::ClusterConfig::lock_config(
+            sub {
+                my $cfg = PVE::Multipath::ClusterConfig::read_config();
+                my $section = $cfg->{ids}->{$wwid};
+                die "WWID '$wwid' is not on the allow-list, add it first\n" if !$section;
+
+                assert_alias_free($cfg, $wwid, $param->{alias}) if defined($param->{alias});
+
+                apply_section_update($section, $param, $delete, $settable);
+                PVE::Multipath::ClusterConfig::write_config($cfg);
+            },
+            "updating WWID '$wwid' failed",
+        );
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'remove_wwid',
+    path => 'wwid/{wwid}',
+    method => 'DELETE',
+    protected => 1,
+    description => "Remove a WWID from the multipath allow-list.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            wwid => {
+                type => 'string',
+                description => 'The WWID to remove.',
+                maxLength => 128,
+            },
+            force => {
+                type => 'boolean',
+                description => 'Remove even while a storage still consumes the LUN.',
+                optional => 1,
+                default => 0,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $wwid = $param->{wwid};
+
+        # dropping a consumed WWID flushes its map on the next sync, so the backing storage loses
+        # the device; refuse unless explicitly forced
+        if (!$param->{force}) {
+            my $storeid = multipath_consumers()->{$wwid};
+            die "WWID '$wwid' is still used by storage '$storeid', remove that"
+                . " storage first or pass 'force' to override\n"
+                if defined($storeid);
+        }
+
+        PVE::Multipath::ClusterConfig::lock_config(
+            sub {
+                my $cfg = PVE::Multipath::ClusterConfig::read_config();
+                delete $cfg->{ids}->{$wwid};
+                PVE::Multipath::ClusterConfig::write_config($cfg);
+            },
+            "removing WWID '$wwid' failed",
+        );
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'set_alias',
+    path => 'alias',
+    method => 'POST',
+    protected => 1,
+    description => "Set or replace the human-readable alias for an allow-listed WWID."
+        . " The alias becomes the dm-multipath map name on every node.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            wwid => {
+                type => 'string',
+                description => 'The WWID to label.',
+                maxLength => 128,
+            },
+            alias => PVE::Multipath::Config::wwid_api_schema()->{alias},
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $wwid = $param->{wwid};
+        my $alias = $param->{alias};
+        raise_param_exc({ wwid => "does not look like a valid WWID" })
+            if $wwid !~ $WWID_RE;
+
+        PVE::Multipath::ClusterConfig::lock_config(
+            sub {
+                my $cfg = PVE::Multipath::ClusterConfig::read_config();
+                die "WWID '$wwid' is not on the allow-list, add it first\n"
+                    if !$cfg->{ids}->{$wwid};
+                assert_alias_free($cfg, $wwid, $alias);
+                $cfg->{ids}->{$wwid}->{alias} = $alias;
+                PVE::Multipath::ClusterConfig::write_config($cfg);
+            },
+            "setting alias for WWID '$wwid' failed",
+        );
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'remove_alias',
+    path => 'alias/{wwid}',
+    method => 'DELETE',
+    protected => 1,
+    description => "Remove the alias for a multipath WWID.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            wwid => {
+                type => 'string',
+                description => 'The WWID whose alias to remove.',
+                maxLength => 128,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $wwid = $param->{wwid};
+        PVE::Multipath::ClusterConfig::lock_config(
+            sub {
+                my $cfg = PVE::Multipath::ClusterConfig::read_config();
+                delete $cfg->{ids}->{$wwid}->{alias} if $cfg->{ids}->{$wwid};
+                PVE::Multipath::ClusterConfig::write_config($cfg);
+            },
+            "removing alias for WWID '$wwid' failed",
+        );
+        return undef;
+    },
+});
+
+1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH storage 04/13] multipath: add storage plugin for multipath LUNs
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (2 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH storage 03/13] api: multipath: add cluster-wide configuration endpoints Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 05/13] lvm: allow a multipath storage as the base device Thomas Lamprecht
                   ` (8 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Expose the dm-multipath maps present on a node as a first-class storage
of raw volumes, each keyed by its WWID and reachable at the WWID-stable
path /dev/disk/by-id/dm-uuid-mpath-<wwid>. The maps are assembled by
multipathd from the cluster-wide allow-list, so the storage carries no
configuration of its own and only reflects what is already there.

This is meant primarily as the base of a shared LVM storage, giving a
volume group on a multipath LUN full path redundancy without any manual
device wiring.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 src/PVE/Storage.pm                 |   2 +
 src/PVE/Storage/Makefile           |   3 +-
 src/PVE/Storage/MultipathPlugin.pm | 186 +++++++++++++++++++++++++++++
 3 files changed, 190 insertions(+), 1 deletion(-)
 create mode 100644 src/PVE/Storage/MultipathPlugin.pm

diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
index 64ea9da..4daeecf 100755
--- a/src/PVE/Storage.pm
+++ b/src/PVE/Storage.pm
@@ -39,6 +39,7 @@ use PVE::Storage::ZFSPlugin;
 use PVE::Storage::PBSPlugin;
 use PVE::Storage::BTRFSPlugin;
 use PVE::Storage::ESXiPlugin;
+use PVE::Storage::MultipathPlugin;
 
 # Storage API version. Increment it on changes in storage API interface.
 use constant APIVER => 15;
@@ -64,6 +65,7 @@ PVE::Storage::ZFSPlugin->register();
 PVE::Storage::PBSPlugin->register();
 PVE::Storage::BTRFSPlugin->register();
 PVE::Storage::ESXiPlugin->register();
+PVE::Storage::MultipathPlugin->register();
 
 # load third-party plugins
 if (-d '/usr/share/perl5/PVE/Storage/Custom') {
diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile
index a67dc25..d76ec25 100644
--- a/src/PVE/Storage/Makefile
+++ b/src/PVE/Storage/Makefile
@@ -14,7 +14,8 @@ SOURCES= \
 	PBSPlugin.pm \
 	BTRFSPlugin.pm \
 	LvmThinPlugin.pm \
-	ESXiPlugin.pm
+	ESXiPlugin.pm \
+	MultipathPlugin.pm
 
 .PHONY: install
 install:
diff --git a/src/PVE/Storage/MultipathPlugin.pm b/src/PVE/Storage/MultipathPlugin.pm
new file mode 100644
index 0000000..91fc489
--- /dev/null
+++ b/src/PVE/Storage/MultipathPlugin.pm
@@ -0,0 +1,186 @@
+package PVE::Storage::MultipathPlugin;
+
+use strict;
+use warnings;
+
+use PVE::Multipath;
+use PVE::Storage::Plugin;
+
+use base qw(PVE::Storage::Plugin);
+
+# A block-LUN provider over device-mapper multipath: it exposes the maps present on the node as raw
+# volumes keyed by their WWID, reachable at the WWID-stable /dev/disk/by-id/dm-uuid-mpath-<wwid>
+# path. The maps are assembled by multipathd from the cluster-wide allow-list, so this storage has
+# no configuration of its own and only reflects what is there. Its main use is as the 'base' of a
+# shared LVM storage.
+
+sub type {
+    return 'multipath';
+}
+
+sub plugindata {
+    return {
+        content => [{ images => 1, none => 1 }, { images => 1 }],
+        format => [{ raw => 1 }, 'raw'],
+        select_existing => 1,
+        'sensitive-properties' => {},
+    };
+}
+
+sub properties {
+    return {};
+}
+
+sub options {
+    return {
+        nodes => { optional => 1 },
+        shared => { optional => 1 },
+        disable => { optional => 1 },
+        content => { optional => 1 },
+        bwlimit => { optional => 1 },
+    };
+}
+
+# the volume name is the LUN's WWID
+sub parse_volname {
+    my ($class, $volname) = @_;
+
+    if ($volname =~ m!^([a-zA-Z0-9._:-]+)$!) {
+        return ('images', $1, undef, undef, undef, undef, 'raw');
+    }
+
+    die "unable to parse multipath volume name '$volname'\n";
+}
+
+sub filesystem_path {
+    my ($class, $scfg, $volname, $snapname) = @_;
+
+    die "snapshot is not possible on multipath storage\n" if defined($snapname);
+
+    my ($vtype, $wwid, $vmid) = $class->parse_volname($volname);
+
+    my $path = "/dev/disk/by-id/dm-uuid-mpath-$wwid";
+
+    return wantarray ? ($path, $vmid, $vtype) : $path;
+}
+
+sub create_base {
+    die "can't create base images in multipath storage\n";
+}
+
+sub clone_image {
+    die "can't clone images in multipath storage\n";
+}
+
+sub alloc_image {
+    die "can't allocate space in multipath storage\n";
+}
+
+sub free_image {
+    die "can't free space in multipath storage\n";
+}
+
+sub list_images {
+    my ($class, $storeid, $scfg, $vmid, $vollist, $cache) = @_;
+
+    my $res = [];
+
+    # multipath LUNs have no owning guest
+    return $res if defined($vmid) && !$vollist;
+
+    $cache->{multipath_maps} //= eval { PVE::Multipath::get_maps() } // [];
+
+    for my $map ($cache->{multipath_maps}->@*) {
+        next if !defined($map->{wwid});
+
+        my $volid = "$storeid:$map->{wwid}";
+        if ($vollist) {
+            next if !grep { $_ eq $volid } @$vollist;
+        }
+
+        push @$res,
+            {
+                volid => $volid,
+                format => 'raw',
+                size => $map->{size} // 0,
+                vmid => 0,
+            };
+    }
+
+    return $res;
+}
+
+sub list_volumes {
+    my ($class, $storeid, $scfg, $vmid, $content_types) = @_;
+
+    my $res = $class->list_images($storeid, $scfg, $vmid);
+    $_->{content} = 'images' for $res->@*;
+
+    return $res;
+}
+
+sub status {
+    my ($class, $storeid, $scfg, $cache) = @_;
+
+    my $active = PVE::Multipath::is_running() ? 1 : 0;
+
+    return (0, 0, 0, $active);
+}
+
+sub activate_storage {
+    my ($class, $storeid, $scfg, $cache) = @_;
+
+    PVE::Multipath::assert_supported();
+    # nothing to log into: multipathd assembles the maps from the allow-list
+}
+
+sub deactivate_storage {
+    my ($class, $storeid, $scfg, $cache) = @_;
+
+    # the maps are shared infrastructure, do not tear them down
+}
+
+sub check_connection {
+    my ($class, $storeid, $scfg) = @_;
+
+    return PVE::Multipath::is_running() ? 1 : 0;
+}
+
+sub activate_volume {
+    my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
+
+    my ($vtype, $wwid) = $class->parse_volname($volname);
+
+    my $map = PVE::Multipath::wait_for_map($wwid);
+    die "multipath map for WWID '$wwid' is not present - is it in the multipath"
+        . " allow-list and are its paths up?\n"
+        if !$map;
+}
+
+sub deactivate_volume {
+    my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
+
+    # do not drop the map, it is shared infrastructure
+}
+
+sub volume_resize {
+    my ($class, $scfg, $storeid, $volname, $size, $running) = @_;
+
+    die "volume resize is not possible on multipath storage\n";
+}
+
+sub volume_has_feature {
+    my ($class, $scfg, $feature, $storeid, $volname, $snapname, $running) = @_;
+
+    return { copy => { current => 1 } }->{$feature}->{current};
+}
+
+sub volume_export_formats {
+    return ();
+}
+
+sub volume_import_formats {
+    return ();
+}
+
+1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH storage 05/13] lvm: allow a multipath storage as the base device
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (3 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH storage 04/13] multipath: add storage plugin for multipath LUNs Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 06/13] multipath: broadcast per-node map health to the cluster KV store Thomas Lamprecht
                   ` (7 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

on_add_hook only accepted an iscsi base when creating the volume group.
Accept the new multipath type as well, so a shared VG can sit on a
multipath LUN and inherit its path redundancy.

The map device is assembled on demand by multipathd, so activate the
base volume to ensure the map is present before pvcreate runs on it.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 src/PVE/Storage/LVMPlugin.pm | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/src/PVE/Storage/LVMPlugin.pm b/src/PVE/Storage/LVMPlugin.pm
index a313ecc..433c942 100644
--- a/src/PVE/Storage/LVMPlugin.pm
+++ b/src/PVE/Storage/LVMPlugin.pm
@@ -485,13 +485,14 @@ sub on_add_hook {
         my $basecfg = PVE::Storage::storage_config($cfg, $baseid, 1);
         die "base storage ID '$baseid' does not exist\n" if !$basecfg;
 
-        # we only support iscsi for now
-        die "unsupported base type '$basecfg->{type}'"
-            if $basecfg->{type} ne 'iscsi';
+        die "unsupported base type '$basecfg->{type}'\n"
+            if $basecfg->{type} ne 'iscsi' && $basecfg->{type} ne 'multipath';
 
         my $path = PVE::Storage::path($cfg, $base);
 
         PVE::Storage::activate_storage($cfg, $baseid);
+        # the multipath map must be assembled before we can pvcreate on it
+        PVE::Storage::activate_volumes($cfg, [$base]) if $basecfg->{type} eq 'multipath';
 
         lvm_create_volume_group($path, $scfg->{vgname}, $scfg->{shared});
     }
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH storage 06/13] multipath: broadcast per-node map health to the cluster KV store
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (4 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH storage 05/13] lvm: allow a multipath storage as the base device Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH storage 07/13] api: multipath: add cluster-wide health status endpoint Thomas Lamprecht
                   ` (6 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Map health is inherently per-node: each node has its own paths to the
same LUN, so whether a LUN has full path redundancy can only be told
per node. To make a cluster-wide view possible, reduce the local maps
to a small per-WWID summary and push it under the cluster KV key
'multipath' via pmxcfs.

A present value also signals that the node is actively multipathing:
clear the key when no maps are assembled, so the status aggregation can
combine just the active nodes without extra bookkeeping. The summary
stays well under the 32 KiB KV limit; the full per-path detail stays
behind the per-node disks/multipath API.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 src/PVE/Multipath.pm            | 56 ++++++++++++++++++++++++++++++++-
 src/test/run_multipath_tests.pl | 50 +++++++++++++++++++++++++++++
 2 files changed, 105 insertions(+), 1 deletion(-)

diff --git a/src/PVE/Multipath.pm b/src/PVE/Multipath.pm
index 59c1103..5647189 100644
--- a/src/PVE/Multipath.pm
+++ b/src/PVE/Multipath.pm
@@ -3,7 +3,7 @@ package PVE::Multipath;
 use strict;
 use warnings;
 
-use JSON qw(decode_json);
+use JSON qw(decode_json encode_json);
 
 use PVE::Tools qw(run_command file_read_firstline file_get_contents);
 
@@ -279,4 +279,58 @@ sub reconfigure {
     run_command([$MULTIPATHD, 'reconfigure']);
 }
 
+# Pure: reduce the rich get_maps() output to the compact per-WWID dict broadcast under the cluster
+# KV key 'multipath'. Holds only what the cluster-wide health matrix needs; the full per-path detail
+# stays available behind the per-node disks/multipath API.
+sub summarize_maps_for_broadcast {
+    my ($maps) = @_;
+
+    my $out = {};
+    for my $map ($maps->@*) {
+        next if !defined($map->{wwid});
+        $out->{ $map->{wwid} } = {
+            state => $map->{health},
+            'paths-active' => $map->{'paths-active'} // 0,
+            'paths-total' => $map->{'paths-total'} // 0,
+            defined($map->{transport}) ? (transport => $map->{transport}) : (),
+            defined($map->{size}) ? (size => $map->{size}) : (),
+        };
+    }
+    return $out;
+}
+
+# Push a compact per-WWID health snapshot into the cluster KV store under the key 'multipath'. A
+# present value also means "this node is actively multipathing", so clear the key when no maps are
+# assembled and the status aggregation then only combines the active nodes. Never throws, so it is
+# safe to call from a status loop where multipath is not the primary concern.
+sub broadcast_health {
+    require PVE::Cluster;
+
+    my $clear = sub {
+        eval { PVE::Cluster::broadcast_node_kv('multipath', undef) };
+        warn "multipath: clearing health broadcast failed - $@" if $@;
+    };
+
+    if (!is_running()) {
+        $clear->();
+        return;
+    }
+
+    my $maps = eval { get_maps() };
+    if (my $err = $@) {
+        warn "multipath: collecting maps for broadcast failed - $err";
+        return;
+    }
+
+    my $summary = summarize_maps_for_broadcast($maps);
+    if (!%$summary) {
+        $clear->();
+        return;
+    }
+
+    my $json = encode_json($summary);
+    eval { PVE::Cluster::broadcast_node_kv('multipath', $json) };
+    warn "multipath: health broadcast failed - $@" if $@;
+}
+
 1;
diff --git a/src/test/run_multipath_tests.pl b/src/test/run_multipath_tests.pl
index f710308..affec23 100755
--- a/src/test/run_multipath_tests.pl
+++ b/src/test/run_multipath_tests.pl
@@ -235,4 +235,54 @@ is(
     'the overrides writer trims trailing whitespace',
 );
 
+# --- broadcast summary (per-WWID condensation of get_maps for the cluster KV) ---
+my $summary = PVE::Multipath::summarize_maps_for_broadcast($maps);
+is_deeply(
+    [sort keys %$summary],
+    [sort map { $_->{wwid} } $maps->@*],
+    'every map with a WWID appears in the summary',
+);
+is($summary->{ $a->{wwid} }->{state}, 'optimal', 'optimal map summarized as optimal');
+is($summary->{ $a->{wwid} }->{'paths-active'}, 2, 'optimal map active path count carried');
+is($summary->{ $a->{wwid} }->{'paths-total'}, 2, 'optimal map total path count carried');
+is($summary->{ $b->{wwid} }->{state}, 'degraded', 'degraded map summarized as degraded');
+is($summary->{ $c->{wwid} }->{state}, 'failed', 'failed map summarized as failed');
+ok(
+    !exists $summary->{ $a->{wwid} }->{transport},
+    'transport omitted when not derived (get_maps fills it live)',
+);
+
+is_deeply(
+    PVE::Multipath::summarize_maps_for_broadcast([]),
+    {},
+    'empty maps list summarizes to empty hash (caller clears the KV)',
+);
+
+# transport/size propagate when the caller (get_maps) has set them
+my $enriched = [{
+    wwid => '3600x',
+    health => 'optimal',
+    'paths-active' => 2,
+    'paths-total' => 2,
+    transport => 'iscsi',
+    size => 34359738368,
+}];
+my $enr = PVE::Multipath::summarize_maps_for_broadcast($enriched);
+is($enr->{'3600x'}->{transport}, 'iscsi', 'transport carried into the summary');
+is($enr->{'3600x'}->{size}, 34359738368, 'size carried into the summary');
+
+# size budget: well under the 32 KiB pmxcfs KV limit even for many maps
+my $many = [
+    map { {
+        wwid => sprintf('3600140500000000000000000000%04x', $_),
+        health => 'optimal',
+        'paths-active' => 4,
+        'paths-total' => 4,
+        transport => 'iscsi',
+        size => 1099511627776,
+    } } 0 .. 99
+];
+my $big = JSON::encode_json(PVE::Multipath::summarize_maps_for_broadcast($many));
+ok(length($big) < 32 * 1024, "100-map summary (" . length($big) . " B) fits the KV size limit");
+
 done_testing();
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH storage 07/13] api: multipath: add cluster-wide health status endpoint
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (5 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH storage 06/13] multipath: broadcast per-node map health to the cluster KV store Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH cluster 08/13] pmxcfs: track cluster-wide multipath configuration Thomas Lamprecht
                   ` (5 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

A per-node view cannot tell whether a LUN is healthy across the whole
cluster. Add an endpoint that collects the per-node broadcasts and
combines them into a per-WWID by per-node matrix, rolled up to one
cluster-state per LUN.

The broadcasts are cross-checked against live membership, so a stale
value from an offline node reads as 'unknown' rather than as healthy.
The roll-up is taken over the nodes that are actively multipathing, so
a LUN that is optimal on three nodes but degraded on a fourth shows up
as degraded instead of hiding behind the healthy majority. A node where
a multipath storage is enabled but that broadcasts nothing is surfaced
as missing rather than vanishing from the matrix. Consuming storages
are labeled from the cluster storage config.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 src/PVE/API2/Multipath.pm       | 116 +++++++++++++++++++++++++++
 src/PVE/Multipath.pm            | 111 ++++++++++++++++++++++++++
 src/test/run_multipath_tests.pl | 135 ++++++++++++++++++++++++++++++++
 3 files changed, 362 insertions(+)

diff --git a/src/PVE/API2/Multipath.pm b/src/PVE/API2/Multipath.pm
index 6a165d5..5336d71 100644
--- a/src/PVE/API2/Multipath.pm
+++ b/src/PVE/API2/Multipath.pm
@@ -3,10 +3,14 @@ package PVE::API2::Multipath;
 use strict;
 use warnings;
 
+use JSON qw(decode_json);
+
+use PVE::Cluster;
 use PVE::Exception qw(raise_param_exc);
 use PVE::Storage;
 use PVE::Tools qw(extract_param);
 
+use PVE::Multipath;
 use PVE::Multipath::Config;
 use PVE::Multipath::ClusterConfig;
 
@@ -43,6 +47,33 @@ my sub multipath_consumers {
     return $consumers;
 }
 
+# The nodes where an allow-listed LUN is supposed to be assembled: those where a multipath storage
+# is enabled (its node restriction, or every cluster node when unrestricted). Read from the cluster
+# storage config so it is node-invariant.
+my sub multipath_expected_nodes {
+    my $expected = {};
+
+    my $cfg = eval { PVE::Storage::config() };
+    return $expected if !$cfg;
+
+    my $all_nodes;
+    my $ids = $cfg->{ids} // {};
+    for my $storeid (sort keys %$ids) {
+        my $scfg = $ids->{$storeid};
+        next if ($scfg->{type} // '') ne 'multipath';
+        next if $scfg->{disable};
+
+        if ($scfg->{nodes}) {
+            $expected->{$_} = 1 for keys $scfg->{nodes}->%*;
+        } else {
+            $all_nodes //= PVE::Cluster::get_nodelist();
+            $expected->{$_} = 1 for $all_nodes->@*;
+        }
+    }
+
+    return $expected;
+}
+
 # multipathd resolves an alias to a map name, so two WWIDs sharing one alias makes it drop a map
 # (the loser is order-dependent and only logged at level 1). Reject a collision up front.
 my sub assert_alias_free {
@@ -122,6 +153,91 @@ __PACKAGE__->register_method({
     },
 });
 
+__PACKAGE__->register_method({
+    name => 'status',
+    path => 'status',
+    method => 'GET',
+    protected => 1,
+    description => "Cluster-wide multipath health: a per-WWID by per-node matrix"
+        . " rolled up over the nodes that are actively multipathing.",
+    permissions => {
+        check => ['perm', '/', ['Sys.Audit']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {},
+    },
+    returns => {
+        type => 'array',
+        items => {
+            type => 'object',
+            additionalProperties => 1,
+            properties => {
+                wwid => { type => 'string', description => 'The LUN WWID.' },
+                alias => {
+                    type => 'string',
+                    description => 'The configured alias, if any.',
+                    optional => 1,
+                },
+                'used-by' => {
+                    type => 'string',
+                    description => 'The storage consuming this LUN, if any.',
+                    optional => 1,
+                },
+                size => {
+                    type => 'integer',
+                    description => 'LUN size in bytes, as reported by a node.',
+                    optional => 1,
+                },
+                'cluster-state' => {
+                    type => 'string',
+                    description => "Worst map state across the actively multipathing nodes:"
+                        . " 'optimal', 'degraded' (some paths down on a node), 'missing' (an"
+                        . " active node has not assembled it), 'failed' (no active path), or"
+                        . " 'unknown' (no active node reports it).",
+                    enum => ['optimal', 'degraded', 'missing', 'failed', 'unknown'],
+                },
+                nodes => {
+                    type => 'object',
+                    description => 'Per-node map state, keyed by node name.',
+                    additionalProperties => 1,
+                },
+            },
+        },
+    },
+    code => sub {
+        my $cfg = PVE::Multipath::ClusterConfig::read_config();
+
+        my $raw_kv = PVE::Cluster::get_node_kv('multipath');
+        my $node_kv = {};
+        for my $node (keys %$raw_kv) {
+            my $decoded = eval { decode_json($raw_kv->{$node}) };
+            $node_kv->{$node} = $decoded if $decoded;
+        }
+
+        my $expected = multipath_expected_nodes();
+
+        # resolve liveness for every node we might place in the matrix: those that broadcast and
+        # those a multipath storage expects (and that may be silent)
+        my $members = PVE::Cluster::get_members() // {};
+        my $online = {};
+        for my $node (keys %$node_kv, keys %$expected) {
+            # standalone clusters carry no member info; treat the reporter as live
+            $online->{$node} =
+                (!%$members || ($members->{$node} && $members->{$node}->{online})) ? 1 : 0;
+        }
+
+        return PVE::Multipath::aggregate_cluster_status(
+            PVE::Multipath::Config::wwid_list($cfg),
+            PVE::Multipath::Config::aliases($cfg),
+            multipath_consumers(),
+            $node_kv,
+            $online,
+            $expected,
+        );
+    },
+});
+
 __PACKAGE__->register_method({
     name => 'set_overrides',
     path => '',
diff --git a/src/PVE/Multipath.pm b/src/PVE/Multipath.pm
index 5647189..2b93d57 100644
--- a/src/PVE/Multipath.pm
+++ b/src/PVE/Multipath.pm
@@ -333,4 +333,115 @@ sub broadcast_health {
     warn "multipath: health broadcast failed - $@" if $@;
 }
 
+# Severity ordering for rolling per-node states up into a cluster state; a higher number is worse.
+# 'unknown' is a stale or offline node and never drives the roll-up, so it sits below 'optimal'.
+my $STATE_RANK = {
+    unknown => -1,
+    optimal => 0,
+    degraded => 1,
+    missing => 2,
+    failed => 3,
+};
+
+# Pure: fold the per-node health summaries (already JSON-decoded) into a per-WWID cluster matrix.
+# Inputs:
+#   $allow_wwids  arrayref, the cluster WWID allow-list
+#   $aliases      { wwid => name }
+#   $used_by      { wwid => storage-id } of consuming LVM storages
+#   $node_kv      { node => summary } as broadcast by broadcast_health()
+#   $online       { node => bool }; a node absent here counts as offline
+#   $expected     { node => 1 } nodes where multipath storage is enabled, so an
+#                 allow-listed LUN is supposed to be present there
+#
+# The cluster-state is rolled up over the nodes that should carry each LUN. A node that reports a
+# summary but lacks the LUN, or an expected node that reports nothing at all (it lost every path and
+# cleared its broadcast), is 'missing'; without the $expected set such a node would silently drop
+# out of the view instead of going red. A node that carries a stale broadcast while offline, or an
+# expected node that is offline, shows as 'unknown' and does not drive the roll-up.
+sub aggregate_cluster_status {
+    my ($allow_wwids, $aliases, $used_by, $node_kv, $online, $expected) = @_;
+
+    $allow_wwids //= [];
+    $aliases //= {};
+    $used_by //= {};
+    $node_kv //= {};
+    $online //= {};
+    $expected //= {};
+
+    my %allow = map { $_ => 1 } $allow_wwids->@*;
+
+    # report the allow-list plus any WWID a node actually sees
+    my %wwids = %allow;
+    for my $node (keys %$node_kv) {
+        $wwids{$_} = 1 for keys $node_kv->{$node}->%*;
+    }
+
+    my $res = [];
+    for my $wwid (sort keys %wwids) {
+        my $nodes = {};
+        my $worst = 'optimal';
+        my $have_active = 0;
+        my $size;
+
+        my $rank = sub {
+            my ($state) = @_;
+            $worst = $state if $STATE_RANK->{$state} > $STATE_RANK->{$worst};
+        };
+
+        for my $node (sort keys %$node_kv) {
+            my $entry = $node_kv->{$node}->{$wwid};
+
+            if (!$online->{$node}) {
+                $nodes->{$node} = { state => 'unknown' } if $entry;
+                next;
+            }
+
+            $have_active = 1;
+            if ($entry) {
+                $nodes->{$node} = {
+                    state => $entry->{state},
+                    'paths-active' => $entry->{'paths-active'},
+                    'paths-total' => $entry->{'paths-total'},
+                    defined($entry->{transport}) ? (transport => $entry->{transport}) : (),
+                };
+                $size //= $entry->{size};
+                $rank->($entry->{state});
+            } else {
+                # node is actively multipathing but has not assembled this LUN
+                $nodes->{$node} = { state => 'missing' };
+                $rank->('missing');
+            }
+        }
+
+        # A LUN on the allow-list should assemble on every node where a multipath storage is
+        # enabled. An expected node with no broadcast at all is missing the map (online) or
+        # unreachable (offline); fold it in so a node that lost all its paths surfaces instead of
+        # vanishing.
+        if ($allow{$wwid}) {
+            for my $node (sort keys %$expected) {
+                next if exists $nodes->{$node};
+                if ($online->{$node}) {
+                    $have_active = 1;
+                    $nodes->{$node} = { state => 'missing' };
+                    $rank->('missing');
+                } else {
+                    $nodes->{$node} = { state => 'unknown' };
+                }
+            }
+        }
+
+        push $res->@*,
+            {
+                wwid => $wwid,
+                defined($aliases->{$wwid}) ? (alias => $aliases->{$wwid}) : (),
+                defined($used_by->{$wwid}) ? ('used-by' => $used_by->{$wwid}) : (),
+                defined($size) ? (size => $size) : (),
+                'cluster-state' => $have_active ? $worst : 'unknown',
+                nodes => $nodes,
+            };
+    }
+
+    return $res;
+}
+
 1;
diff --git a/src/test/run_multipath_tests.pl b/src/test/run_multipath_tests.pl
index affec23..9e7e1db 100755
--- a/src/test/run_multipath_tests.pl
+++ b/src/test/run_multipath_tests.pl
@@ -285,4 +285,139 @@ my $many = [
 my $big = JSON::encode_json(PVE::Multipath::summarize_maps_for_broadcast($many));
 ok(length($big) < 32 * 1024, "100-map summary (" . length($big) . " B) fits the KV size limit");
 
+# --- cluster status aggregation ---
+my $node_kv = {
+    nodeA => {
+        wA =>
+            { state => 'optimal', 'paths-active' => 2, 'paths-total' => 2, transport => 'iscsi' },
+        wB => {
+            state => 'optimal',
+            'paths-active' => 2,
+            'paths-total' => 2,
+            transport => 'iscsi',
+            size => 42,
+        },
+    },
+    nodeB => {
+        wA =>
+            { state => 'degraded', 'paths-active' => 1, 'paths-total' => 2, transport => 'iscsi' },
+        # nodeB is active but does not see wB
+    },
+    nodeC => {
+        # stale broadcast from an offline node
+        wA => { state => 'optimal', 'paths-active' => 2, 'paths-total' => 2 },
+    },
+};
+my $agg = PVE::Multipath::aggregate_cluster_status(
+    ['wA', 'wB', 'wZ'], # allow-list incl. an unseen WWID
+    { wA => 'lun-a' }, # alias
+    { wB => 'mptank' }, # used-by
+    $node_kv,
+    { nodeA => 1, nodeB => 1, nodeC => 0 }, # nodeC offline
+);
+my %by_wwid = map { $_->{wwid} => $_ } $agg->@*;
+
+is_deeply([sort keys %by_wwid], ['wA', 'wB', 'wZ'], 'matrix covers allow-list and seen WWIDs');
+
+is($by_wwid{wA}->{alias}, 'lun-a', 'alias surfaced on the WWID row');
+is($by_wwid{wA}->{'cluster-state'}, 'degraded', 'degraded on one active node rolls up to degraded');
+is($by_wwid{wA}->{nodes}->{nodeA}->{state}, 'optimal', 'per-node optimal cell kept');
+is($by_wwid{wA}->{nodes}->{nodeB}->{state}, 'degraded', 'per-node degraded cell kept');
+is($by_wwid{wA}->{nodes}->{nodeC}->{state}, 'unknown',
+    'offline node with stale data shows unknown');
+
+is($by_wwid{wB}->{'used-by'}, 'mptank', 'consuming storage surfaced as used-by');
+is($by_wwid{wB}->{size}, 42, 'LUN size surfaced from a reporting node');
+is(
+    $by_wwid{wB}->{'cluster-state'},
+    'missing',
+    'active node not assembling the LUN rolls up to missing',
+);
+is(
+    $by_wwid{wB}->{nodes}->{nodeB}->{state},
+    'missing',
+    'missing marked on the active node lacking it',
+);
+
+is(
+    $by_wwid{wZ}->{'cluster-state'},
+    'missing',
+    'allow-listed WWID no active node assembled is missing everywhere',
+);
+is($by_wwid{wZ}->{nodes}->{nodeA}->{state}, 'missing', 'active node missing the allow-listed WWID');
+ok(!exists $by_wwid{wZ}->{nodes}->{nodeC}, 'offline node contributes no cell for an unseen WWID');
+
+# a WWID only an offline node ever reported, with no online active node, is unknown
+my $agg_off = PVE::Multipath::aggregate_cluster_status(
+    ['wA'],
+    {},
+    {},
+    { dead => { wA => { state => 'optimal', 'paths-active' => 2, 'paths-total' => 2 } } },
+    { dead => 0 },
+);
+is(
+    $agg_off->[0]->{'cluster-state'},
+    'unknown',
+    'no online active node leaves the cluster-state unknown',
+);
+is($agg_off->[0]->{nodes}->{dead}->{state}, 'unknown', 'stale offline node shown as unknown');
+
+# failure outranks degraded in the roll-up
+my $agg2 = PVE::Multipath::aggregate_cluster_status(
+    ['wA'],
+    {},
+    {},
+    {
+        n1 => { wA => { state => 'degraded', 'paths-active' => 1, 'paths-total' => 2 } },
+        n2 => { wA => { state => 'failed', 'paths-active' => 0, 'paths-total' => 2 } },
+    },
+    { n1 => 1, n2 => 1 },
+);
+is($agg2->[0]->{'cluster-state'}, 'failed', 'failed outranks degraded in the cluster roll-up');
+
+# --- expected-node set: a node that lost all paths (silent) must not vanish ---
+# nodeS is expected (a multipath storage is enabled there) and online, but
+# broadcasts nothing - e.g. every path to the SAN is down so it cleared its KV.
+my $exp_kv = {
+    nodeA => { wA => { state => 'optimal', 'paths-active' => 2, 'paths-total' => 2 } },
+};
+my $online = { nodeA => 1, nodeS => 1, nodeOff => 0 };
+my $expected = { nodeA => 1, nodeS => 1, nodeOff => 1 };
+my $eagg = PVE::Multipath::aggregate_cluster_status(
+    ['wA'], {}, {}, $exp_kv, $online, $expected,
+);
+my $row = $eagg->[0];
+is($row->{nodes}->{nodeA}->{state}, 'optimal', 'reporting node keeps its real state');
+is(
+    $row->{nodes}->{nodeS}->{state},
+    'missing',
+    'expected online but silent node shows missing instead of vanishing',
+);
+is($row->{nodes}->{nodeOff}->{state}, 'unknown', 'expected offline node shows unknown');
+is($row->{'cluster-state'}, 'missing', 'a silent expected node drags the cluster-state to missing');
+
+# without $expected the silent node would have been invisible (regression guard
+# for the old behavior, proving the new param is what surfaces it)
+my $noexp = PVE::Multipath::aggregate_cluster_status(['wA'], {}, {}, $exp_kv, $online);
+ok(
+    !exists $noexp->[0]->{nodes}->{nodeS},
+    'without the expected set the silent node is absent (the gap the param closes)',
+);
+is($noexp->[0]->{'cluster-state'}, 'optimal', 'and the cluster-state would falsely read optimal');
+
+# expected augmentation applies only to allow-listed WWIDs, not to a LUN that a
+# node merely happens to report off-list
+my $offlist = PVE::Multipath::aggregate_cluster_status(
+    [],
+    {},
+    {},
+    { nodeA => { wX => { state => 'optimal', 'paths-active' => 1, 'paths-total' => 1 } } },
+    { nodeA => 1, nodeS => 1 },
+    { nodeA => 1, nodeS => 1 },
+);
+ok(
+    !exists $offlist->[0]->{nodes}->{nodeS},
+    'non-allow-listed WWID does not synthesize missing cells for expected nodes',
+);
+
 done_testing();
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH cluster 08/13] pmxcfs: track cluster-wide multipath configuration
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (6 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH storage 07/13] api: multipath: add cluster-wide health status endpoint Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH manager 09/13] pvestatd: apply the cluster-wide multipath config on each node Thomas Lamprecht
                   ` (4 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

pve-storage registers two cluster-wide files via cfs_register_file: the
managed /etc/pve/multipath.cfg (the WWID allow-list plus the global and
per-LUN multipathd knobs) and /etc/pve/multipath-overrides.conf for the
free-form, admin-owned hardware override sections. Both need to be
observed and tracked for change notification, so they are replicated
across the cluster and consumers are notified on change.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 src/PVE/Cluster.pm  | 2 ++
 src/pmxcfs/status.c | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/src/PVE/Cluster.pm b/src/PVE/Cluster.pm
index 034b78c..0a53549 100644
--- a/src/PVE/Cluster.pm
+++ b/src/PVE/Cluster.pm
@@ -50,6 +50,8 @@ my $observed = {
     'vzdump.conf' => 1,
     'jobs.cfg' => 1,
     'storage.cfg' => 1,
+    'multipath.cfg' => 1,
+    'multipath-overrides.conf' => 1,
     'datacenter.cfg' => 1,
     'replication.cfg' => 1,
     'corosync.conf' => 1,
diff --git a/src/pmxcfs/status.c b/src/pmxcfs/status.c
index 12a6c46..70dcc72 100644
--- a/src/pmxcfs/status.c
+++ b/src/pmxcfs/status.c
@@ -80,6 +80,8 @@ static memdb_change_t memdb_change_array[] = {
     {.path = "corosync.conf"},
     {.path = "corosync.conf.new"},
     {.path = "storage.cfg"},
+    {.path = "multipath.cfg"},
+    {.path = "multipath-overrides.conf"},
     {.path = "user.cfg"},
     {.path = "domains.cfg"},
     {.path = "notifications.cfg"},
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH manager 09/13] pvestatd: apply the cluster-wide multipath config on each node
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (7 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH cluster 08/13] pmxcfs: track cluster-wide multipath configuration Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH manager 10/13] api: cluster: mount the multipath configuration endpoint Thomas Lamprecht
                   ` (3 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Write out the cluster-wide multipath.cfg on each node once per status
loop: build the local multipathd drop-ins and reload multipathd when
they change. The work is a cheap no-op unless multipath is in use, and
the logic is kept out of pvestatd itself, so this is just a thin
trigger.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 PVE/Service/pvestatd.pm | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/PVE/Service/pvestatd.pm b/PVE/Service/pvestatd.pm
index 34f9da71f..bd4dc4d81 100755
--- a/PVE/Service/pvestatd.pm
+++ b/PVE/Service/pvestatd.pm
@@ -20,6 +20,7 @@ use PVE::RS::SDN::Fabrics;
 use PVE::NodeConfig;
 use PVE::Cluster qw(cfs_read_file);
 use PVE::Storage;
+use PVE::Multipath::Generator;
 use PVE::QemuServer;
 use PVE::QemuServer::Monitor;
 use PVE::LXC;
@@ -848,6 +849,12 @@ sub update_status {
     $err = $@;
     syslog('err', "storage status update error: $err") if $err;
 
+    # apply the cluster-wide multipath config on this node; a cheap no-op unless multipath is in
+    # use, and the logic is kept out of pvestatd so this stays a thin trigger.
+    eval { PVE::Multipath::Generator::sync(); };
+    $err = $@;
+    syslog('err', "multipath config sync error: $err") if $err;
+
     eval { remove_stale_lxc_consoles(); };
     $err = $@;
     syslog('err', "lxc console cleanup error: $err") if $err;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH manager 10/13] api: cluster: mount the multipath configuration endpoint
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (8 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH manager 09/13] pvestatd: apply the cluster-wide multipath config on each node Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH manager 11/13] pvestatd: broadcast multipath map health to the cluster Thomas Lamprecht
                   ` (2 subsequent siblings)
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Expose pve-storage's cluster-wide multipath configuration API at
/cluster/multipath, next to the other cluster-level config endpoints.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 PVE/API2/Cluster.pm | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm
index 4e5efbfd9..576840181 100644
--- a/PVE/API2/Cluster.pm
+++ b/PVE/API2/Cluster.pm
@@ -35,6 +35,7 @@ use PVE::API2::Cluster::Qemu;
 use PVE::API2::ClusterConfig;
 use PVE::API2::Firewall::Cluster;
 use PVE::API2::HAConfig;
+use PVE::API2::Multipath;
 use PVE::API2::ReplicationConfig;
 
 my $have_sdn;
@@ -100,6 +101,11 @@ __PACKAGE__->register_method({
     path => 'ceph',
 });
 
+__PACKAGE__->register_method({
+    subclass => "PVE::API2::Multipath",
+    path => 'multipath',
+});
+
 __PACKAGE__->register_method({
     subclass => "PVE::API2::Cluster::Jobs",
     path => 'jobs',
@@ -169,6 +175,7 @@ __PACKAGE__->register_method({
             { name => 'log' },
             { name => 'mapping' },
             { name => 'metrics' },
+            { name => 'multipath' },
             { name => 'notifications' },
             { name => 'nextid' },
             { name => 'options' },
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH manager 11/13] pvestatd: broadcast multipath map health to the cluster
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (9 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH manager 10/13] api: cluster: mount the multipath configuration endpoint Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor Thomas Lamprecht
  2026-06-26 12:07 ` [PATCH manager 13/13] ui: node: show multipath maps and their paths under Disks Thomas Lamprecht
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Publish this node's per-WWID map health into the cluster KV store on
each status loop, so the datacenter-wide health matrix can combine it
without querying every node live. The helper clears the key when this
node is not multipathing, keeping the matrix scoped to the active
nodes. Guarded and self-contained like the nearby config sync.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 PVE/Service/pvestatd.pm | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/PVE/Service/pvestatd.pm b/PVE/Service/pvestatd.pm
index bd4dc4d81..b2106ee30 100755
--- a/PVE/Service/pvestatd.pm
+++ b/PVE/Service/pvestatd.pm
@@ -20,6 +20,7 @@ use PVE::RS::SDN::Fabrics;
 use PVE::NodeConfig;
 use PVE::Cluster qw(cfs_read_file);
 use PVE::Storage;
+use PVE::Multipath;
 use PVE::Multipath::Generator;
 use PVE::QemuServer;
 use PVE::QemuServer::Monitor;
@@ -855,6 +856,12 @@ sub update_status {
     $err = $@;
     syslog('err', "multipath config sync error: $err") if $err;
 
+    # publish this node's multipath map health into the cluster KV store so the datacenter-wide
+    # health matrix can roll it up; clears itself when not in use.
+    eval { PVE::Multipath::broadcast_health(); };
+    $err = $@;
+    syslog('err', "multipath health broadcast error: $err") if $err;
+
     eval { remove_stale_lxc_consoles(); };
     $err = $@;
     syslog('err', "lxc console cleanup error: $err") if $err;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (10 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH manager 11/13] pvestatd: broadcast multipath map health to the cluster Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  2026-06-26 14:05   ` Maximiliano Sandoval
  2026-06-26 12:07 ` [PATCH manager 13/13] ui: node: show multipath maps and their paths under Disks Thomas Lamprecht
  12 siblings, 1 reply; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Give cluster operators a single place to answer the question that no
per-node view can: for each multipath LUN, is every node that should see
it seeing it with full path redundancy? The grid loads the cluster
status and lays out a WWID by node matrix, coloring each cell by the
per-node map state and the combined cluster state, so a LUN that is
degraded on just one node stands out at a glance. The same panel manages
the cluster config: the WWID allow-list, per-WWID aliases, and the
verbatim hardware override sections. Node columns are built from the
loaded data, since a node only appears once it is actively multipathing.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 www/manager6/Makefile        |   1 +
 www/manager6/Utils.js        |  25 +++
 www/manager6/dc/Config.js    |   6 +
 www/manager6/dc/Multipath.js | 371 +++++++++++++++++++++++++++++++++++
 4 files changed, 403 insertions(+)
 create mode 100644 www/manager6/dc/Multipath.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d4dd3f351..1a3b845c8 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -183,6 +183,7 @@ JSSRC= 							\
 	dc/Guests.js					\
 	dc/Health.js					\
 	dc/Log.js					\
+	dc/Multipath.js					\
 	dc/NodeView.js					\
 	dc/OptionView.js				\
 	dc/PermissionView.js				\
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 040b5ae01..3b5044e1d 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -274,6 +274,31 @@ Ext.define('PVE.Utils', {
             return '<i class="fa fa-' + iconCls + '"></i> ' + value;
         },
 
+        // Maps a multipath map state (optimal/degraded/missing/failed/unknown) to an icon; shared
+        // by the datacenter health matrix and the node view.
+        render_multipath_health: function (value) {
+            if (typeof value === 'undefined') {
+                return '';
+            }
+            let iconCls = 'question-circle';
+            switch (value) {
+                case 'optimal':
+                    iconCls = 'check-circle good';
+                    break;
+                case 'degraded':
+                    iconCls = 'exclamation-circle warning';
+                    break;
+                case 'missing':
+                case 'failed':
+                    iconCls = 'times-circle critical';
+                    break;
+                case 'unknown':
+                default:
+                    iconCls = 'question-circle faded';
+            }
+            return '<i class="fa fa-' + iconCls + '"></i> ' + value;
+        },
+
         validateZfsBlocksize: function (value) {
             if (!value) {
                 return true;
diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index e17066368..dfba75e8a 100644
--- a/www/manager6/dc/Config.js
+++ b/www/manager6/dc/Config.js
@@ -120,6 +120,12 @@ Ext.define('PVE.dc.Config', {
                     iconCls: 'fa fa-gear',
                     itemId: 'options',
                 },
+                {
+                    xtype: 'pveDcMultipath',
+                    title: gettext('Multipath'),
+                    iconCls: 'fa fa-road',
+                    itemId: 'multipath',
+                },
             );
         }
 
diff --git a/www/manager6/dc/Multipath.js b/www/manager6/dc/Multipath.js
new file mode 100644
index 000000000..70f561f83
--- /dev/null
+++ b/www/manager6/dc/Multipath.js
@@ -0,0 +1,371 @@
+Ext.define('PVE-dc-multipath-status', {
+    extend: 'Ext.data.Model',
+    fields: ['wwid', 'alias', 'used-by', 'size', 'cluster-state', 'nodes'],
+    idProperty: 'wwid',
+});
+
+// Edit window to add a WWID to the cluster-wide multipath allow-list.
+Ext.define('PVE.dc.MultipathWwidEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveDcMultipathWwidEdit',
+
+    subject: gettext('Multipath LUN'),
+    isCreate: true,
+    method: 'POST',
+    url: '/cluster/multipath/wwid',
+
+    items: [
+        {
+            xtype: 'proxmoxtextfield',
+            name: 'wwid',
+            fieldLabel: 'WWID',
+            allowBlank: false,
+            emptyText: '3600140500a1b2c3d4e5f6a7b8c9d0e1f',
+        },
+    ],
+});
+
+// Edit window to set or replace a WWID's alias.
+Ext.define('PVE.dc.MultipathAliasEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveDcMultipathAliasEdit',
+
+    subject: gettext('Multipath Alias'),
+    isCreate: true,
+    method: 'POST',
+    url: '/cluster/multipath/alias',
+
+    initComponent: function () {
+        let me = this;
+
+        if (!me.wwid) {
+            throw 'no wwid specified';
+        }
+
+        Ext.apply(me, {
+            items: [
+                {
+                    xtype: 'displayfield',
+                    name: 'wwid',
+                    fieldLabel: 'WWID',
+                    value: me.wwid,
+                    submitValue: true,
+                },
+                {
+                    xtype: 'proxmoxtextfield',
+                    name: 'alias',
+                    fieldLabel: gettext('Alias'),
+                    allowBlank: false,
+                    value: me.alias,
+                    emptyText: 'san-a-lun0',
+                },
+            ],
+        });
+
+        me.callParent();
+    },
+});
+
+// Edit window for the verbatim multipath.conf override sections (hardware- and SAN-specific
+// 'device {}' / 'overrides {}' blocks). Loads the current config and writes the overrides back.
+Ext.define('PVE.dc.MultipathOverridesEdit', {
+    extend: 'Proxmox.window.Edit',
+    xtype: 'pveDcMultipathOverridesEdit',
+
+    subject: gettext('Multipath Overrides'),
+    width: 600,
+    autoLoad: true,
+    method: 'PUT',
+    url: '/cluster/multipath',
+
+    items: [
+        {
+            xtype: 'textarea',
+            name: 'overrides',
+            fieldLabel: gettext('Overrides'),
+            height: 320,
+            fieldStyle: 'font-family: monospace;',
+            emptyText:
+                "device {\n\tvendor \"...\"\n\tproduct \"...\"\n\tpath_grouping_policy ...\n}",
+        },
+    ],
+});
+
+Ext.define('PVE.dc.MultipathView', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pveDcMultipath',
+
+    stateful: true,
+    stateId: 'grid-dc-multipath',
+
+    emptyText: gettext('No multipath LUNs configured or reported.'),
+
+    viewModel: {
+        data: {
+            wwid: '',
+            alias: '',
+        },
+    },
+
+    controller: {
+        xclass: 'Ext.app.ViewController',
+
+        reload: function () {
+            this.getView().getStore().load();
+        },
+
+        selectedRecord: function () {
+            let sel = this.getView().getSelection();
+            return sel.length ? sel[0] : undefined;
+        },
+
+        addWwid: function () {
+            let me = this;
+            Ext.create('PVE.dc.MultipathWwidEdit', {
+                taskDone: () => me.reload(),
+                autoShow: true,
+            });
+        },
+
+        setAlias: function () {
+            let me = this;
+            let rec = me.selectedRecord();
+            if (!rec) {
+                return;
+            }
+            Ext.create('PVE.dc.MultipathAliasEdit', {
+                wwid: rec.data.wwid,
+                alias: rec.data.alias,
+                taskDone: () => me.reload(),
+                autoShow: true,
+            });
+        },
+
+        removeAlias: function () {
+            let me = this;
+            let rec = me.selectedRecord();
+            if (!rec || !rec.data.alias) {
+                return;
+            }
+            Proxmox.Utils.API2Request({
+                url: `/cluster/multipath/alias/${encodeURIComponent(rec.data.wwid)}`,
+                method: 'DELETE',
+                waitMsgTarget: me.getView(),
+                failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+                success: () => me.reload(),
+            });
+        },
+
+        removeWwid: function () {
+            let me = this;
+            let rec = me.selectedRecord();
+            if (!rec) {
+                return;
+            }
+            let wwid = rec.data.wwid;
+            let usedBy = rec.data['used-by'];
+            let msg = usedBy
+                ? Ext.String.format(
+                      gettext(
+                          "WWID '{0}' is still used by storage '{1}'. Removing it"
+                              + ' drops the multipath map and the storage loses its'
+                              + ' device. Remove anyway?',
+                      ),
+                      wwid,
+                      usedBy,
+                  )
+                : Ext.String.format(
+                      gettext("Remove WWID '{0}' from the multipath allow-list?"),
+                      wwid,
+                  );
+            Ext.Msg.confirm(gettext('Confirm'), msg, function (btn) {
+                if (btn !== 'yes') {
+                    return;
+                }
+                Proxmox.Utils.API2Request({
+                    url: `/cluster/multipath/wwid/${encodeURIComponent(wwid)}`,
+                    method: 'DELETE',
+                    params: usedBy ? { force: 1 } : undefined,
+                    waitMsgTarget: me.getView(),
+                    failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
+                    success: () => me.reload(),
+                });
+            });
+        },
+
+        editOverrides: function () {
+            let me = this;
+            Ext.create('PVE.dc.MultipathOverridesEdit', {
+                taskDone: () => me.reload(),
+                autoShow: true,
+            });
+        },
+
+        // Rebuild the per-node columns from the loaded matrix; nodes only appear once they
+        // actively multipath, so the column set is data-driven.
+        onStoreLoad: function (store, records) {
+            let me = this;
+            let view = me.getView();
+
+            let nodes = {};
+            (records || []).forEach((rec) => {
+                Ext.Object.each(rec.data.nodes || {}, (node) => {
+                    nodes[node] = true;
+                });
+            });
+            let nodeList = Object.keys(nodes).sort();
+
+            if (Ext.Array.equals(nodeList, view.shownNodes || [])) {
+                return;
+            }
+            view.shownNodes = nodeList;
+
+            let nodeColumns = nodeList.map((node) => ({
+                text: node,
+                align: 'center',
+                width: 120,
+                sortable: false,
+                menuDisabled: true,
+                renderer: function (val, meta, rec) {
+                    let st = (rec.data.nodes || {})[node];
+                    if (!st) {
+                        return '<span style="opacity:0.5">-</span>';
+                    }
+                    let out = PVE.Utils.render_multipath_health(st.state);
+                    if (st['paths-total'] !== undefined) {
+                        out += ` (${st['paths-active']}/${st['paths-total']})`;
+                    }
+                    return out;
+                },
+            }));
+
+            view.reconfigure(undefined, view.baseColumns.concat(nodeColumns));
+        },
+
+        control: {
+            '#': {
+                selectionchange: function (grid, selected) {
+                    let vm = this.getViewModel();
+                    let rec = selected.length ? selected[0] : undefined;
+                    vm.set('wwid', rec ? rec.data.wwid : '');
+                    vm.set('alias', rec && rec.data.alias ? rec.data.alias : '');
+                },
+            },
+        },
+    },
+
+    baseColumns: [
+        {
+            text: 'WWID',
+            dataIndex: 'wwid',
+            flex: 2,
+        },
+        {
+            text: gettext('Alias'),
+            dataIndex: 'alias',
+            width: 140,
+            renderer: (v) => v || '',
+        },
+        {
+            text: gettext('Used by'),
+            dataIndex: 'used-by',
+            width: 120,
+            renderer: (v) => v || '',
+        },
+        {
+            text: gettext('Size'),
+            dataIndex: 'size',
+            width: 90,
+            align: 'right',
+            renderer: (v) => (v ? Proxmox.Utils.format_size(v) : ''),
+        },
+        {
+            text: gettext('Cluster Health'),
+            dataIndex: 'cluster-state',
+            width: 130,
+            renderer: PVE.Utils.render_multipath_health,
+        },
+    ],
+
+    initComponent: function () {
+        let me = this;
+
+        let caps = Ext.state.Manager.get('GuiCap');
+        let canModify = !!(caps && caps.dc && caps.dc['Sys.Modify']);
+
+        me.columns = me.baseColumns;
+
+        // health is live status: poll while the panel is shown so a path going down surfaces
+        // without a manual reload
+        me.store = Ext.create('Proxmox.data.UpdateStore', {
+            storeid: 'dc-multipath-status',
+            interval: 3000,
+            model: 'PVE-dc-multipath-status',
+            proxy: {
+                type: 'proxmox',
+                url: '/api2/json/cluster/multipath/status',
+            },
+            sorters: [{ property: 'wwid' }],
+        });
+
+        // a row selection only gates the modify actions when the user may modify
+        let selectionGate = canModify ? { bind: { disabled: '{!wwid}' } } : { disabled: true };
+
+        me.tbar = [
+            {
+                text: gettext('Reload'),
+                iconCls: 'fa fa-refresh',
+                handler: 'reload',
+            },
+            {
+                text: gettext('Add WWID'),
+                iconCls: 'fa fa-plus-circle',
+                disabled: !canModify,
+                handler: 'addWwid',
+            },
+            Ext.apply(
+                {
+                    text: gettext('Set Alias'),
+                    iconCls: 'fa fa-tag',
+                    handler: 'setAlias',
+                },
+                selectionGate,
+            ),
+            Ext.apply(
+                {
+                    text: gettext('Remove'),
+                    iconCls: 'fa fa-bars',
+                    menu: [
+                        {
+                            text: gettext('Remove WWID'),
+                            iconCls: 'fa fa-fw fa-trash-o',
+                            handler: 'removeWwid',
+                        },
+                        {
+                            text: gettext('Remove Alias'),
+                            iconCls: 'fa fa-fw fa-eraser',
+                            bind: { disabled: '{!alias}' },
+                            handler: 'removeAlias',
+                        },
+                    ],
+                },
+                selectionGate,
+            ),
+            '->',
+            {
+                text: gettext('Edit Overrides'),
+                iconCls: 'fa fa-pencil',
+                disabled: !canModify,
+                handler: 'editOverrides',
+            },
+        ];
+
+        me.callParent();
+
+        Proxmox.Utils.monStoreErrors(me, me.store, true);
+        me.mon(me.store, 'load', me.getController().onStoreLoad, me.getController());
+        me.on('activate', () => me.store.startUpdate());
+        me.on('deactivate', () => me.store.stopUpdate());
+        me.on('destroy', () => me.store.stopUpdate());
+    },
+});
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH manager 13/13] ui: node: show multipath maps and their paths under Disks
  2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
                   ` (11 preceding siblings ...)
  2026-06-26 12:07 ` [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor Thomas Lamprecht
@ 2026-06-26 12:07 ` Thomas Lamprecht
  12 siblings, 0 replies; 16+ messages in thread
From: Thomas Lamprecht @ 2026-06-26 12:07 UTC (permalink / raw)
  To: pve-devel

Add a read-only per-node view of the device-mapper multipath maps and
their individual paths, the detail behind the datacenter health matrix.
Each map expands to its paths with their device, state and transport, so
when the matrix flags a node as degraded an operator can see exactly
which path is down without dropping to the shell.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 www/manager6/Makefile          |   1 +
 www/manager6/node/Config.js    |   7 ++
 www/manager6/node/Multipath.js | 163 +++++++++++++++++++++++++++++++++
 3 files changed, 171 insertions(+)
 create mode 100644 www/manager6/node/Multipath.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 1a3b845c8..9b5125a66 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -245,6 +245,7 @@ JSSRC= 							\
 	node/Directory.js				\
 	node/LVM.js					\
 	node/LVMThin.js					\
+	node/Multipath.js				\
 	node/StatusView.js				\
 	node/Subscription.js				\
 	node/Summary.js					\
diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js
index f6cd87492..29a6b9a77 100644
--- a/www/manager6/node/Config.js
+++ b/www/manager6/node/Config.js
@@ -355,6 +355,13 @@ Ext.define('PVE.node.Config', {
                     groups: ['storage'],
                     xtype: 'pveZFSList',
                 },
+                {
+                    xtype: 'pveMultipathStatus',
+                    title: gettext('Multipath'),
+                    itemId: 'multipath',
+                    iconCls: 'fa fa-road',
+                    groups: ['storage'],
+                },
                 {
                     xtype: 'pveNodeCephStatus',
                     title: 'Ceph',
diff --git a/www/manager6/node/Multipath.js b/www/manager6/node/Multipath.js
new file mode 100644
index 000000000..51c257d42
--- /dev/null
+++ b/www/manager6/node/Multipath.js
@@ -0,0 +1,163 @@
+Ext.define('PVE-node-multipath-map', {
+    extend: 'Ext.data.Model',
+    idProperty: 'wwid',
+    fields: [
+        'wwid',
+        'name',
+        'health',
+        'transport',
+        'used-by',
+        'path-groups',
+        { name: 'paths-active', type: 'number' },
+        { name: 'paths-total', type: 'number' },
+        { name: 'size', type: 'number' },
+        {
+            // pre-rendered path detail for the row expander; keeps the hyphenated map keys out
+            // of the XTemplate and recomputes when the paths change
+            name: 'pathsHtml',
+            calculate: function (data) {
+                let html = '';
+                (data['path-groups'] || []).forEach((pg) => {
+                    (pg.paths || []).forEach((p) => {
+                        let state = p['dm-state'] || '';
+                        let icon = state === 'active' ? 'check-circle good' : 'times-circle critical';
+                        let extra = [p['dev-state'], p.transport].filter((v) => v).join(', ');
+                        html +=
+                            '<div style="padding:1px 0;">'
+                            + `<i class="fa fa-fw fa-${icon}"></i> `
+                            + `${Ext.htmlEncode(p.dev || '')} - ${Ext.htmlEncode(state)}`
+                            + (extra ? ` (${Ext.htmlEncode(extra)})` : '')
+                            + '</div>';
+                    });
+                });
+                return html;
+            },
+        },
+    ],
+});
+
+// Read-only per-node view of the device-mapper multipath maps and their paths, the detail behind
+// the datacenter-wide health matrix. The map rows update in place via a DiffStore, so live
+// path-state changes do not flicker or collapse the expanded rows.
+Ext.define('PVE.node.MultipathStatus', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pveMultipathStatus',
+
+    stateful: true,
+    stateId: 'grid-node-multipath',
+
+    emptyText: gettext('No multipath maps on this node.'),
+
+    plugins: [
+        {
+            ptype: 'rowexpander',
+            rowBodyTpl: ['<div style="padding:2px 0 6px 36px;">{pathsHtml}</div>'],
+        },
+    ],
+
+    columns: [
+        {
+            text: gettext('Name'),
+            dataIndex: 'name',
+            flex: 1,
+            renderer: (v, meta, rec) => v || rec.data.wwid,
+        },
+        {
+            text: 'WWID',
+            dataIndex: 'wwid',
+            flex: 1,
+        },
+        {
+            text: gettext('Health'),
+            dataIndex: 'health',
+            width: 130,
+            renderer: PVE.Utils.render_multipath_health,
+        },
+        {
+            text: gettext('Paths'),
+            width: 90,
+            align: 'right',
+            renderer: (v, meta, rec) => `${rec.data['paths-active']}/${rec.data['paths-total']}`,
+        },
+        {
+            text: gettext('Transport'),
+            dataIndex: 'transport',
+            width: 90,
+            renderer: (v) => v || '',
+        },
+        {
+            text: gettext('Size'),
+            dataIndex: 'size',
+            width: 100,
+            align: 'right',
+            renderer: (v) => (v ? Proxmox.Utils.format_size(v) : ''),
+        },
+        {
+            text: gettext('Used by'),
+            dataIndex: 'used-by',
+            flex: 1,
+            renderer: (v) => v || '',
+        },
+    ],
+
+    initComponent: function () {
+        let me = this;
+
+        me.nodename = me.pveSelNode.data.node;
+        if (!me.nodename) {
+            throw 'no node name specified';
+        }
+
+        let rstore = Ext.create('Proxmox.data.UpdateStore', {
+            interval: 3000,
+            storeid: `node-multipath-${me.nodename}`,
+            model: 'PVE-node-multipath-map',
+            proxy: {
+                type: 'proxmox',
+                url: `/api2/json/nodes/${me.nodename}/disks/multipath`,
+                // the endpoint wraps the maps in a supported/running envelope
+                reader: { rootProperty: 'data.maps' },
+            },
+        });
+
+        me.store = Ext.create('Proxmox.data.DiffStore', {
+            rstore: rstore,
+            sorters: [{ property: 'wwid' }],
+        });
+
+        me.tbar = [
+            {
+                text: gettext('Reload'),
+                iconCls: 'fa fa-refresh',
+                handler: () => rstore.load(),
+            },
+        ];
+
+        me.callParent();
+
+        Proxmox.Utils.monStoreErrors(me, rstore, true);
+
+        // the empty grid means different things (no maps, daemon down, not installed); pull the
+        // reason from the envelope to guide the operator
+        me.mon(rstore, 'load', function (store, records, success) {
+            if (!success) {
+                return;
+            }
+            let raw = rstore.getProxy().getReader().rawData || {};
+            let info = raw.data || raw;
+            let text;
+            if (!info.supported) {
+                text = gettext('multipath-tools is not installed on this node.');
+            } else if (!info.running) {
+                text = gettext('The multipathd daemon is not running.');
+            } else {
+                text = gettext('No multipath maps on this node.');
+            }
+            me.setEmptyText(text);
+        });
+
+        me.on('activate', () => rstore.startUpdate());
+        me.on('deactivate', () => rstore.stopUpdate());
+        me.on('destroy', () => rstore.stopUpdate());
+    },
+});
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* Re: [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor
  2026-06-26 12:07 ` [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor Thomas Lamprecht
@ 2026-06-26 14:05   ` Maximiliano Sandoval
  0 siblings, 0 replies; 16+ messages in thread
From: Maximiliano Sandoval @ 2026-06-26 14:05 UTC (permalink / raw)
  To: Thomas Lamprecht; +Cc: pve-devel

Thomas Lamprecht <t.lamprecht@proxmox.com> writes:

> Give cluster operators a single place to answer the question that no
> per-node view can: for each multipath LUN, is every node that should see
> it seeing it with full path redundancy? The grid loads the cluster
> status and lays out a WWID by node matrix, coloring each cell by the
> per-node map state and the combined cluster state, so a LUN that is
> degraded on just one node stands out at a glance. The same panel manages
> the cluster config: the WWID allow-list, per-WWID aliases, and the
> verbatim hardware override sections. Node columns are built from the
> loaded data, since a node only appears once it is actively multipathing.
>
> Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
> ---
>  www/manager6/Makefile        |   1 +
>  www/manager6/Utils.js        |  25 +++
>  www/manager6/dc/Config.js    |   6 +
>  www/manager6/dc/Multipath.js | 371 +++++++++++++++++++++++++++++++++++
>  4 files changed, 403 insertions(+)
>  create mode 100644 www/manager6/dc/Multipath.js
>
> diff --git a/www/manager6/Makefile b/www/manager6/Makefile
> index d4dd3f351..1a3b845c8 100644
> --- a/www/manager6/Makefile
> +++ b/www/manager6/Makefile
> @@ -183,6 +183,7 @@ JSSRC= 							\
>  	dc/Guests.js					\
>  	dc/Health.js					\
>  	dc/Log.js					\
> +	dc/Multipath.js					\
>  	dc/NodeView.js					\
>  	dc/OptionView.js				\
>  	dc/PermissionView.js				\
> diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
> index 040b5ae01..3b5044e1d 100644
> --- a/www/manager6/Utils.js
> +++ b/www/manager6/Utils.js
> @@ -274,6 +274,31 @@ Ext.define('PVE.Utils', {
>              return '<i class="fa fa-' + iconCls + '"></i> ' + value;
>          },
>  
> +        // Maps a multipath map state (optimal/degraded/missing/failed/unknown) to an icon; shared
> +        // by the datacenter health matrix and the node view.
> +        render_multipath_health: function (value) {
> +            if (typeof value === 'undefined') {
> +                return '';
> +            }
> +            let iconCls = 'question-circle';
> +            switch (value) {
> +                case 'optimal':
> +                    iconCls = 'check-circle good';
> +                    break;
> +                case 'degraded':
> +                    iconCls = 'exclamation-circle warning';
> +                    break;
> +                case 'missing':
> +                case 'failed':
> +                    iconCls = 'times-circle critical';
> +                    break;
> +                case 'unknown':
> +                default:
> +                    iconCls = 'question-circle faded';
> +            }
> +            return '<i class="fa fa-' + iconCls + '"></i> ' + value;
> +        },
> +
>          validateZfsBlocksize: function (value) {
>              if (!value) {
>                  return true;
> diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
> index e17066368..dfba75e8a 100644
> --- a/www/manager6/dc/Config.js
> +++ b/www/manager6/dc/Config.js
> @@ -120,6 +120,12 @@ Ext.define('PVE.dc.Config', {
>                      iconCls: 'fa fa-gear',
>                      itemId: 'options',
>                  },
> +                {
> +                    xtype: 'pveDcMultipath',
> +                    title: gettext('Multipath'),
> +                    iconCls: 'fa fa-road',
> +                    itemId: 'multipath',
> +                },
>              );
>          }
>  
> diff --git a/www/manager6/dc/Multipath.js b/www/manager6/dc/Multipath.js
> new file mode 100644
> index 000000000..70f561f83
> --- /dev/null
> +++ b/www/manager6/dc/Multipath.js
> @@ -0,0 +1,371 @@
> +Ext.define('PVE-dc-multipath-status', {
> +    extend: 'Ext.data.Model',
> +    fields: ['wwid', 'alias', 'used-by', 'size', 'cluster-state', 'nodes'],
> +    idProperty: 'wwid',
> +});
> +
> +// Edit window to add a WWID to the cluster-wide multipath allow-list.
> +Ext.define('PVE.dc.MultipathWwidEdit', {
> +    extend: 'Proxmox.window.Edit',
> +    xtype: 'pveDcMultipathWwidEdit',
> +
> +    subject: gettext('Multipath LUN'),
> +    isCreate: true,
> +    method: 'POST',
> +    url: '/cluster/multipath/wwid',
> +
> +    items: [
> +        {
> +            xtype: 'proxmoxtextfield',
> +            name: 'wwid',
> +            fieldLabel: 'WWID',
> +            allowBlank: false,
> +            emptyText: '3600140500a1b2c3d4e5f6a7b8c9d0e1f',
> +        },
> +    ],
> +});
> +
> +// Edit window to set or replace a WWID's alias.
> +Ext.define('PVE.dc.MultipathAliasEdit', {
> +    extend: 'Proxmox.window.Edit',
> +    xtype: 'pveDcMultipathAliasEdit',
> +
> +    subject: gettext('Multipath Alias'),
> +    isCreate: true,
> +    method: 'POST',
> +    url: '/cluster/multipath/alias',
> +
> +    initComponent: function () {
> +        let me = this;
> +
> +        if (!me.wwid) {
> +            throw 'no wwid specified';
> +        }
> +
> +        Ext.apply(me, {
> +            items: [
> +                {
> +                    xtype: 'displayfield',
> +                    name: 'wwid',
> +                    fieldLabel: 'WWID',
> +                    value: me.wwid,
> +                    submitValue: true,
> +                },
> +                {
> +                    xtype: 'proxmoxtextfield',
> +                    name: 'alias',
> +                    fieldLabel: gettext('Alias'),
> +                    allowBlank: false,
> +                    value: me.alias,
> +                    emptyText: 'san-a-lun0',
> +                },
> +            ],
> +        });
> +
> +        me.callParent();
> +    },
> +});
> +
> +// Edit window for the verbatim multipath.conf override sections (hardware- and SAN-specific
> +// 'device {}' / 'overrides {}' blocks). Loads the current config and writes the overrides back.
> +Ext.define('PVE.dc.MultipathOverridesEdit', {
> +    extend: 'Proxmox.window.Edit',
> +    xtype: 'pveDcMultipathOverridesEdit',
> +
> +    subject: gettext('Multipath Overrides'),
> +    width: 600,
> +    autoLoad: true,
> +    method: 'PUT',
> +    url: '/cluster/multipath',
> +
> +    items: [
> +        {
> +            xtype: 'textarea',
> +            name: 'overrides',
> +            fieldLabel: gettext('Overrides'),
> +            height: 320,
> +            fieldStyle: 'font-family: monospace;',
> +            emptyText:
> +                "device {\n\tvendor \"...\"\n\tproduct \"...\"\n\tpath_grouping_policy ...\n}",
> +        },
> +    ],
> +});
> +
> +Ext.define('PVE.dc.MultipathView', {
> +    extend: 'Ext.grid.Panel',
> +    xtype: 'pveDcMultipath',
> +
> +    stateful: true,
> +    stateId: 'grid-dc-multipath',
> +
> +    emptyText: gettext('No multipath LUNs configured or reported.'),
> +
> +    viewModel: {
> +        data: {
> +            wwid: '',
> +            alias: '',
> +        },
> +    },
> +
> +    controller: {
> +        xclass: 'Ext.app.ViewController',
> +
> +        reload: function () {
> +            this.getView().getStore().load();
> +        },
> +
> +        selectedRecord: function () {
> +            let sel = this.getView().getSelection();
> +            return sel.length ? sel[0] : undefined;
> +        },
> +
> +        addWwid: function () {
> +            let me = this;
> +            Ext.create('PVE.dc.MultipathWwidEdit', {
> +                taskDone: () => me.reload(),
> +                autoShow: true,
> +            });
> +        },
> +
> +        setAlias: function () {
> +            let me = this;
> +            let rec = me.selectedRecord();
> +            if (!rec) {
> +                return;
> +            }
> +            Ext.create('PVE.dc.MultipathAliasEdit', {
> +                wwid: rec.data.wwid,
> +                alias: rec.data.alias,
> +                taskDone: () => me.reload(),
> +                autoShow: true,
> +            });
> +        },
> +
> +        removeAlias: function () {
> +            let me = this;
> +            let rec = me.selectedRecord();
> +            if (!rec || !rec.data.alias) {
> +                return;
> +            }
> +            Proxmox.Utils.API2Request({
> +                url: `/cluster/multipath/alias/${encodeURIComponent(rec.data.wwid)}`,
> +                method: 'DELETE',
> +                waitMsgTarget: me.getView(),
> +                failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
> +                success: () => me.reload(),
> +            });
> +        },
> +
> +        removeWwid: function () {
> +            let me = this;
> +            let rec = me.selectedRecord();
> +            if (!rec) {
> +                return;
> +            }
> +            let wwid = rec.data.wwid;
> +            let usedBy = rec.data['used-by'];
> +            let msg = usedBy
> +                ? Ext.String.format(
> +                      gettext(
> +                          "WWID '{0}' is still used by storage '{1}'. Removing it"
> +                              + ' drops the multipath map and the storage loses its'
> +                              + ' device. Remove anyway?',
> +                      ),
> +                      wwid,
> +                      usedBy,
> +                  )
> +                : Ext.String.format(
> +                      gettext("Remove WWID '{0}' from the multipath allow-list?"),
> +                      wwid,
> +                  );
> +            Ext.Msg.confirm(gettext('Confirm'), msg, function (btn) {
> +                if (btn !== 'yes') {
> +                    return;
> +                }
> +                Proxmox.Utils.API2Request({
> +                    url: `/cluster/multipath/wwid/${encodeURIComponent(wwid)}`,
> +                    method: 'DELETE',
> +                    params: usedBy ? { force: 1 } : undefined,
> +                    waitMsgTarget: me.getView(),
> +                    failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
> +                    success: () => me.reload(),
> +                });
> +            });
> +        },
> +
> +        editOverrides: function () {
> +            let me = this;
> +            Ext.create('PVE.dc.MultipathOverridesEdit', {
> +                taskDone: () => me.reload(),
> +                autoShow: true,
> +            });
> +        },
> +
> +        // Rebuild the per-node columns from the loaded matrix; nodes only appear once they
> +        // actively multipath, so the column set is data-driven.
> +        onStoreLoad: function (store, records) {
> +            let me = this;
> +            let view = me.getView();
> +
> +            let nodes = {};
> +            (records || []).forEach((rec) => {
> +                Ext.Object.each(rec.data.nodes || {}, (node) => {
> +                    nodes[node] = true;
> +                });
> +            });
> +            let nodeList = Object.keys(nodes).sort();
> +
> +            if (Ext.Array.equals(nodeList, view.shownNodes || [])) {
> +                return;
> +            }
> +            view.shownNodes = nodeList;
> +
> +            let nodeColumns = nodeList.map((node) => ({
> +                text: node,
> +                align: 'center',
> +                width: 120,
> +                sortable: false,
> +                menuDisabled: true,
> +                renderer: function (val, meta, rec) {
> +                    let st = (rec.data.nodes || {})[node];
> +                    if (!st) {
> +                        return '<span style="opacity:0.5">-</span>';
> +                    }
> +                    let out = PVE.Utils.render_multipath_health(st.state);
> +                    if (st['paths-total'] !== undefined) {
> +                        out += ` (${st['paths-active']}/${st['paths-total']})`;
> +                    }
> +                    return out;
> +                },
> +            }));
> +
> +            view.reconfigure(undefined, view.baseColumns.concat(nodeColumns));
> +        },
> +
> +        control: {
> +            '#': {
> +                selectionchange: function (grid, selected) {
> +                    let vm = this.getViewModel();
> +                    let rec = selected.length ? selected[0] : undefined;
> +                    vm.set('wwid', rec ? rec.data.wwid : '');
> +                    vm.set('alias', rec && rec.data.alias ? rec.data.alias : '');
> +                },
> +            },
> +        },
> +    },
> +
> +    baseColumns: [
> +        {
> +            text: 'WWID',
> +            dataIndex: 'wwid',
> +            flex: 2,
> +        },
> +        {
> +            text: gettext('Alias'),
> +            dataIndex: 'alias',
> +            width: 140,
> +            renderer: (v) => v || '',
> +        },
> +        {
> +            text: gettext('Used by'),

Imo this should be 'Used By' since there is another Header
Capitalization below. In Header Capitalization 2-3 letter words are
generally not capitalized, however, the last word in a sentence is
generally capitalized.

[1] https://en.wikipedia.org/wiki/Title_case

> +            dataIndex: 'used-by',
> +            width: 120,
> +            renderer: (v) => v || '',
> +        },
> +        {
> +            text: gettext('Size'),
> +            dataIndex: 'size',
> +            width: 90,
> +            align: 'right',
> +            renderer: (v) => (v ? Proxmox.Utils.format_size(v) : ''),
> +        },
> +        {
> +            text: gettext('Cluster Health'),
> +            dataIndex: 'cluster-state',
> +            width: 130,
> +            renderer: PVE.Utils.render_multipath_health,
> +        },
> +    ],
> +
> +    initComponent: function () {
> +        let me = this;
> +
> +        let caps = Ext.state.Manager.get('GuiCap');
> +        let canModify = !!(caps && caps.dc && caps.dc['Sys.Modify']);
> +
> +        me.columns = me.baseColumns;
> +
> +        // health is live status: poll while the panel is shown so a path going down surfaces
> +        // without a manual reload
> +        me.store = Ext.create('Proxmox.data.UpdateStore', {
> +            storeid: 'dc-multipath-status',
> +            interval: 3000,
> +            model: 'PVE-dc-multipath-status',
> +            proxy: {
> +                type: 'proxmox',
> +                url: '/api2/json/cluster/multipath/status',
> +            },
> +            sorters: [{ property: 'wwid' }],
> +        });
> +
> +        // a row selection only gates the modify actions when the user may modify
> +        let selectionGate = canModify ? { bind: { disabled: '{!wwid}' } } : { disabled: true };
> +
> +        me.tbar = [
> +            {
> +                text: gettext('Reload'),
> +                iconCls: 'fa fa-refresh',
> +                handler: 'reload',
> +            },
> +            {
> +                text: gettext('Add WWID'),
> +                iconCls: 'fa fa-plus-circle',
> +                disabled: !canModify,
> +                handler: 'addWwid',
> +            },
> +            Ext.apply(
> +                {
> +                    text: gettext('Set Alias'),
> +                    iconCls: 'fa fa-tag',
> +                    handler: 'setAlias',
> +                },
> +                selectionGate,
> +            ),
> +            Ext.apply(
> +                {
> +                    text: gettext('Remove'),
> +                    iconCls: 'fa fa-bars',
> +                    menu: [
> +                        {
> +                            text: gettext('Remove WWID'),
> +                            iconCls: 'fa fa-fw fa-trash-o',
> +                            handler: 'removeWwid',
> +                        },
> +                        {
> +                            text: gettext('Remove Alias'),
> +                            iconCls: 'fa fa-fw fa-eraser',
> +                            bind: { disabled: '{!alias}' },
> +                            handler: 'removeAlias',
> +                        },
> +                    ],
> +                },
> +                selectionGate,
> +            ),
> +            '->',
> +            {
> +                text: gettext('Edit Overrides'),
> +                iconCls: 'fa fa-pencil',
> +                disabled: !canModify,
> +                handler: 'editOverrides',
> +            },
> +        ];
> +
> +        me.callParent();
> +
> +        Proxmox.Utils.monStoreErrors(me, me.store, true);
> +        me.mon(me.store, 'load', me.getController().onStoreLoad, me.getController());
> +        me.on('activate', () => me.store.startUpdate());
> +        me.on('deactivate', () => me.store.stopUpdate());
> +        me.on('destroy', () => me.store.stopUpdate());
> +    },
> +});

-- 
Maximiliano




^ permalink raw reply	[flat|nested] 16+ messages in thread

* Re: [PATCH storage 01/13] multipath: add helper library and managed configuration
  2026-06-26 12:07 ` [PATCH storage 01/13] multipath: add helper library and managed configuration Thomas Lamprecht
@ 2026-06-26 14:43   ` Maximiliano Sandoval
  0 siblings, 0 replies; 16+ messages in thread
From: Maximiliano Sandoval @ 2026-06-26 14:43 UTC (permalink / raw)
  To: Thomas Lamprecht; +Cc: pve-devel

Thomas Lamprecht <t.lamprecht@proxmox.com> writes:

> Multipath on PVE is configured by hand and per node today, with nothing
> that keeps it consistent across a cluster. Add the foundation for
> managing it cluster-wide instead.
>
> The library reads the assembled maps and their health from multipathd.
> The configuration is a SectionConfig kept in pmxcfs: one 'defaults'
> section for the global multipathd knobs, plus one 'wwid' section per
> allow-listed LUN holding its optional alias and any per-LUN knobs.
> Parameters are kebab-case and rendered to multipathd's snake_case
> keywords, validated through the section schema so a bad value cannot
> reach the generated drop-in.
>
> The managed baseline is deliberately conservative: it only assembles
> explicitly allow-listed LUNs and keeps map names stable and WWID-based,
> so a device is named the same on every node and an LVM PV on it stays
> stable cluster-wide. Hardware-specific tuning lives in a separate,
> admin-owned override rather than in the generated baseline, and the two
> are written to distinct drop-ins, as multipath does not accept a
> repeated 'defaults' section in one file.
>
> Parsing and generation stay in a pure module with no dependency on
> PVE::Cluster, so they remain unit-testable and usable on a node whose
> pve-cluster does not yet observe the new file; registering it in pmxcfs
> needs the matching pve-cluster change.
>
> Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
> ---
>  src/PVE/Makefile                   |   4 +
>  src/PVE/Multipath.pm               | 282 ++++++++++++++++++++++
>  src/PVE/Multipath/ClusterConfig.pm |  55 +++++
>  src/PVE/Multipath/Config.pm        | 361 +++++++++++++++++++++++++++++
>  src/PVE/Multipath/Generator.pm     | 148 ++++++++++++
>  src/test/Makefile                  |   5 +-
>  src/test/run_multipath_tests.pl    | 238 +++++++++++++++++++
>  7 files changed, 1092 insertions(+), 1 deletion(-)
>  create mode 100644 src/PVE/Multipath.pm
>  create mode 100644 src/PVE/Multipath/ClusterConfig.pm
>  create mode 100644 src/PVE/Multipath/Config.pm
>  create mode 100644 src/PVE/Multipath/Generator.pm
>  create mode 100755 src/test/run_multipath_tests.pl
>
> diff --git a/src/PVE/Makefile b/src/PVE/Makefile
> index 9e9f6aa..7ddd646 100644
> --- a/src/PVE/Makefile
> +++ b/src/PVE/Makefile
> @@ -4,6 +4,10 @@
>  install:
>  	install -D -m 0644 Storage.pm ${DESTDIR}${PERLDIR}/PVE/Storage.pm
>  	install -D -m 0644 Diskmanage.pm ${DESTDIR}${PERLDIR}/PVE/Diskmanage.pm
> +	install -D -m 0644 Multipath.pm ${DESTDIR}${PERLDIR}/PVE/Multipath.pm
> +	install -D -m 0644 Multipath/Config.pm ${DESTDIR}${PERLDIR}/PVE/Multipath/Config.pm
> +	install -D -m 0644 Multipath/ClusterConfig.pm ${DESTDIR}${PERLDIR}/PVE/Multipath/ClusterConfig.pm
> +	install -D -m 0644 Multipath/Generator.pm ${DESTDIR}${PERLDIR}/PVE/Multipath/Generator.pm
>  	install -D -m 0644 CephConfig.pm ${DESTDIR}${PERLDIR}/PVE/CephConfig.pm
>  	install -D -m 0644 GuestImport.pm ${DESTDIR}${PERLDIR}/PVE/GuestImport.pm
>  	make -C Storage install
> diff --git a/src/PVE/Multipath.pm b/src/PVE/Multipath.pm
> new file mode 100644
> index 0000000..59c1103
> --- /dev/null
> +++ b/src/PVE/Multipath.pm
> @@ -0,0 +1,282 @@
> +package PVE::Multipath;
> +
> +use strict;
> +use warnings;
> +
> +use JSON qw(decode_json);
> +
> +use PVE::Tools qw(run_command file_read_firstline file_get_contents);
> +
> +use PVE::Multipath::Config;
> +
> +# Helper library around device-mapper multipath (multipathd). The single place that knows how to
> +# talk to multipathd and turn its state into a normalized, stable structure for the rest of the
> +# storage stack: health reporting, the API, and consumers that wait for a map before binding to it.
> +#
> +# Everything keys on the SCSI/NAA WWID, as reported by multipathd in the map's 'uuid' field. Map
> +# names (mpathN / aliases) and the underlying 'sdX' paths are node-local and unstable; the WWID is
> +# not.
> +
> +my $MULTIPATH = '/sbin/multipath';
> +my $MULTIPATHD = '/sbin/multipathd';
> +
> +# health states for a single map, derived from its path states
> +use constant {
> +    HEALTH_OPTIMAL => 'optimal', # all paths active
> +    HEALTH_DEGRADED => 'degraded', # some but not all paths active
> +    HEALTH_FAILED => 'failed', # no active path left
> +};
> +
> +my $supported;
> +
> +sub is_supported {
> +    return $supported if defined($supported);
> +    $supported = (-x $MULTIPATH && -x $MULTIPATHD) ? 1 : 0;
> +    return $supported;
> +}
> +
> +sub assert_supported {
> +    die "no multipath support - please install 'multipath-tools'\n" if !is_supported();
> +    return 1;
> +}
> +
> +# Returns whether the multipathd daemon is reachable. Used for status output, so it never dies and
> +# just reports 0 when multipath is unavailable or the daemon socket cannot be queried.
> +sub is_running {
> +    return 0 if !is_supported();
> +
> +    my $running = 0;
> +    eval {
> +        run_command(
> +            [$MULTIPATHD, 'show', 'daemon'],
> +            outfunc => sub {
> +                my ($line) = @_;
> +                # example: "pid 1234 idle" / "pid 1234 running"
> +                $running = 1 if $line =~ m/^pid \d+ \S+/;
> +            },
> +            errfunc => sub { },
> +        );
> +    };
> +    return $running;
> +}
> +
> +# Runs `multipathd show <subcmd> json` and returns the raw output. Kept separate from parsing so
> +# tests can feed recorded fixtures to the parse_*() functions without a running daemon.
> +my sub query_multipathd_json {
> +    my ($subcmd) = @_;
> +
> +    assert_supported();
> +
> +    my $output = '';
> +    run_command(
> +        [$MULTIPATHD, 'show', $subcmd, 'json'],
> +        outfunc => sub { $output .= "$_[0]\n"; },
> +        errfunc => sub { warn "$_[0]\n"; },
> +    );
> +
> +    return $output;
> +}
> +
> +my sub derive_health {
> +    my ($paths_active, $paths_total) = @_;
> +
> +    return HEALTH_FAILED if !$paths_total || !$paths_active;
> +    return HEALTH_OPTIMAL if $paths_active == $paths_total;
> +    return HEALTH_DEGRADED;
> +}
> +
> +my sub normalize_path {
> +    my ($path) = @_;
> +
> +    my $res = {
> +        dev => $path->{dev},
> +        # 'active' or 'failed' - the state device-mapper sees
> +        'dm-state' => $path->{dm_st},
> +        # 'running', 'faulty' or 'offline' - the state the kernel block layer sees
> +        'dev-state' => $path->{dev_st},
> +        # path checker result, e.g. 'ready' / 'faulty' / 'ghost'
> +        'check-state' => $path->{chk_st},
> +    };
> +    $res->{priority} = int($path->{pri}) if defined($path->{pri});
> +
> +    # multipathd renders unset string fields as the literal '[undef]'
> +    my $wwpn = $path->{target_wwpn};
> +    my $hba = $path->{host_adapter};
> +    undef $wwpn if !defined($wwpn) || $wwpn eq '[undef]' || $wwpn eq '';
> +    undef $hba if !defined($hba) || $hba eq '[undef]' || $hba eq '';
> +    $res->{'target-wwpn'} = $wwpn if defined($wwpn);
> +    $res->{'host-adapter'} = $hba if defined($hba);
> +    # a real target WWPN (0x...) means Fibre Channel; iSCSI/SAS transport is derived from sysfs by
> +    # get_maps(). Do NOT treat the field's mere presence as FC, multipathd reports it as '[undef]'
> +    # for iSCSI.
> +    $res->{transport} = 'fc' if defined($wwpn) && $wwpn =~ /^0x[0-9a-f]+$/i;
> +
> +    return $res;
> +}
> +
> +# Turns the output of `multipathd show maps json` into a normalized list of maps. Pure (no I/O) on
> +# purpose: it derives everything it can from the JSON alone, so it can be unit-tested against
> +# recorded fixtures. Live-only bits (byte size, transport) are added by get_maps() below.
> +sub parse_maps_json {
> +    my ($json) = @_;
> +
> +    my $data = eval { decode_json($json) };
> +    die "could not parse multipathd maps JSON: $@\n" if $@;
> +
> +    my $maps = [];
> +    for my $map (($data->{maps} // [])->@*) {
> +        my $path_groups = [];
> +        my ($paths_total, $paths_active) = (0, 0);
> +
> +        for my $group (($map->{path_groups} // [])->@*) {
> +            my $paths = [];
> +            for my $path (($group->{paths} // [])->@*) {
> +                my $normalized = normalize_path($path);
> +                $paths_total++;
> +                $paths_active++ if ($normalized->{'dm-state'} // '') eq 'active';
> +                push $paths->@*, $normalized;
> +            }
> +            push $path_groups->@*,
> +                {
> +                    group => int($group->{group} // 0),
> +                    'dm-state' => $group->{dm_st},
> +                    priority => int($group->{pri} // 0),
> +                    paths => $paths,
> +                };
> +        }
> +
> +        push $maps->@*, {
> +            wwid => $map->{uuid},
> +            name => $map->{name},
> +            sysfs => $map->{sysfs}, # the 'dm-N' kernel name
> +            'dm-state' => $map->{dm_st},
> +            'paths-total' => $paths_total,
> +            'paths-active' => $paths_active,
> +            health => derive_health($paths_active, $paths_total),
> +            'path-groups' => $path_groups,
> +        };
> +    }
> +
> +    return $maps;
> +}
> +
> +# Best-effort byte size of a dm device from sysfs (Linux reports size in 512b sectors regardless of
> +# the real block size).
> +my sub dm_size_bytes {
> +    my ($sysfs) = @_;
> +
> +    return undef if !$sysfs;
> +    my $sectors = file_read_firstline("/sys/block/$sysfs/size");
> +    return undef if !defined($sectors) || $sectors !~ m/^\d+$/;
> +    return int($sectors) * 512;
> +}
> +
> +my sub dir_has_entries {
> +    my ($dir) = @_;
> +
> +    return 0 if !-d $dir;
> +    opendir(my $dh, $dir) or return 0;
> +    my @entries = grep { $_ ne '.' && $_ ne '..' } readdir($dh);
> +    closedir($dh);
> +    return scalar(@entries) ? 1 : 0;
> +}
> +
> +# Best-effort transport of a single 'sdX' path from its sysfs topology; only iSCSI and SAS need it,
> +# Fibre Channel is already set from the map JSON.
> +my sub path_transport {
> +    my ($dev) = @_;
> +
> +    return undef if !$dev;
> +    my $link = readlink("/sys/block/$dev");
> +    return undef if !$link;
> +    return 'iscsi' if $link =~ m{/session\d+/};
> +    return 'fc' if $link =~ m{/rport-\d+};
> +    return 'sas' if $link =~ m{/end_device-};
> +    return undef;
> +}
> +
> +# Returns the normalized maps enriched with information that requires the local system (size, a
> +# stable consumer path). Dies if multipath is not supported; callers that just want status should
> +# guard with is_supported()/is_running().
> +sub get_maps {
> +    my $maps = parse_maps_json(query_multipathd_json('maps'));
> +
> +    for my $map ($maps->@*) {
> +        $map->{size} = dm_size_bytes($map->{sysfs});
> +        # WWID-stable path, present independently of the (node-local) map name
> +        $map->{path} = "/dev/disk/by-id/dm-uuid-mpath-$map->{wwid}"
> +            if defined($map->{wwid});
> +        $map->{used} = dir_has_entries("/sys/block/$map->{sysfs}/holders")
> +            if $map->{sysfs};
> +
> +        my %transports;
> +        for my $group ($map->{'path-groups'}->@*) {
> +            for my $path ($group->{paths}->@*) {
> +                $path->{transport} //= path_transport($path->{dev});
> +                $transports{ $path->{transport} } = 1 if defined($path->{transport});
> +            }
> +        }
> +        # only expose a map-level transport when all paths agree on it
> +        my @transports = keys %transports;
> +        $map->{transport} = $transports[0] if scalar(@transports) == 1;
> +    }
> +
> +    return $maps;
> +}
> +
> +sub get_map_for_wwid {
> +    my ($wwid) = @_;
> +
> +    for my $map (get_maps()->@*) {
> +        return $map if defined($map->{wwid}) && $map->{wwid} eq $wwid;
> +    }
> +    return undef;
> +}
> +
> +# Polls until a map for the given WWID exists, up to $timeout seconds. A consumer like the iSCSI
> +# plugin uses this after a login or rescan to bind to the coalesced dm device rather than to a
> +# transient single 'sdX' path.
> +sub wait_for_map {
> +    my ($wwid, $timeout) = @_;
> +
> +    $timeout //= 10;
> +
> +    my $deadline = time() + $timeout;
> +    while (1) {
> +        my $map = eval { get_map_for_wwid($wwid) };
> +        return $map if $map;
> +        return undef if time() >= $deadline;
> +        sleep(1);
> +    }
> +}
> +
> +my $WWIDS_FILE = '/etc/multipath/wwids';
> +
> +# The managed allow-list of LUNs (WWIDs) to assemble into a map; with 'find_multipaths strict' only
> +# these get multipathed.
> +sub list_wwids {

Together with wwid_list this is a bit of a confusing name, could it be,
e.g. "sub list_etc_multipath_wwids"?

> +    return [] if !-e $WWIDS_FILE;
> +    return PVE::Multipath::Config::parse_wwids(file_get_contents($WWIDS_FILE));
> +}
> +
> +sub add_wwid {
> +    my ($wwid) = @_;
> +
> +    assert_supported();
> +    run_command([$MULTIPATH, '-a', $wwid]);
> +}
> +
> +sub remove_wwid {
> +    my ($wwid) = @_;
> +
> +    assert_supported();
> +    run_command([$MULTIPATH, '-w', $wwid]);
> +}
> +
> +# Re-read the configuration and rebuild maps accordingly, after a config or allow-list change.
> +sub reconfigure {
> +    assert_supported();
> +    run_command([$MULTIPATHD, 'reconfigure']);
> +}
> +
> +1;
> diff --git a/src/PVE/Multipath/ClusterConfig.pm b/src/PVE/Multipath/ClusterConfig.pm
> new file mode 100644
> index 0000000..0b09c3f
> --- /dev/null
> +++ b/src/PVE/Multipath/ClusterConfig.pm
> @@ -0,0 +1,55 @@
> +package PVE::Multipath::ClusterConfig;
> +
> +use strict;
> +use warnings;
> +
> +use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
> +
> +use PVE::Multipath::Config;
> +
> +# Cluster-wide multipath configuration, replicated by pmxcfs. The structured allow-list, aliases and
> +# knobs live in multipath.cfg (a SectionConfig); the free-form hardware override text lives in a
> +# separate plain file so it stays hand-editable and diffable.
> +my $FILENAME = 'multipath.cfg';
> +my $OVERRIDES_FILENAME = 'multipath-overrides.conf';
> +
> +cfs_register_file(
> +    $FILENAME,
> +    sub { PVE::Multipath::Config->parse_config(@_); },
> +    sub { PVE::Multipath::Config->write_config(@_); },
> +);
> +
> +cfs_register_file(
> +    $OVERRIDES_FILENAME,
> +    \&PVE::Multipath::Config::parse_overrides,
> +    \&PVE::Multipath::Config::write_overrides,
> +);
> +
> +sub read_config {
> +    return cfs_read_file($FILENAME);
> +}
> +
> +sub write_config {
> +    my ($cfg) = @_;
> +    cfs_write_file($FILENAME, $cfg);
> +}
> +
> +sub read_overrides {
> +    return cfs_read_file($OVERRIDES_FILENAME);
> +}
> +
> +sub write_overrides {
> +    my ($text) = @_;
> +    cfs_write_file($OVERRIDES_FILENAME, $text);
> +}
> +
> +sub lock_config {
> +    my ($code, $errmsg) = @_;
> +
> +    cfs_lock_file($FILENAME, undef, $code);
> +    if (my $err = $@) {
> +        $errmsg ? die "$errmsg: $err" : die $err;
> +    }
> +}
> +
> +1;
> diff --git a/src/PVE/Multipath/Config.pm b/src/PVE/Multipath/Config.pm
> new file mode 100644
> index 0000000..21ad72e
> --- /dev/null
> +++ b/src/PVE/Multipath/Config.pm
> @@ -0,0 +1,361 @@
> +package PVE::Multipath::Config;
> +
> +use strict;
> +use warnings;
> +
> +use PVE::SectionConfig;
> +
> +use base qw(PVE::SectionConfig);
> +
> +# Parser and writer for the cluster-wide source of truth in pmxcfs (/etc/pve/multipath.cfg). It is a
> +# SectionConfig: a single 'defaults' section for global multipathd knobs, plus one 'wwid' section
> +# per allow-listed LUN holding its optional alias and per-LUN knobs. Free-form hardware overrides
> +# (device {} entries) live in a separate plain file, see PVE::Multipath::ClusterConfig. Kept pure so
> +# it stays unit-testable without PVE::Cluster.
> +
> +# Conservative, cluster-friendly defaults, applied when the 'defaults' section omits them:
> +# - find_multipaths strict -> only explicitly allow-listed LUNs get assembled, so boot/root and
> +#   unrelated disks stay untouched.
> +# - user_friendly_names no -> the map name is the WWID, identical on every node, so an LVM PV on
> +#   /dev/disk/by-id/dm-uuid-mpath-<wwid> is stable cluster-wide without a node-local bindings file.
> +my $MANAGED_DEFAULTS = {
> +    'find-multipaths' => 'strict',
> +    'user-friendly-names' => 'no',
> +    'polling-interval' => 5,
> +};
> +
> +sub managed_defaults { return { $MANAGED_DEFAULTS->%* }; }
> +
> +# Knobs valid both globally (defaults) and per-LUN (a multipaths{} entry), per multipath.conf(5).
> +my $shared_knobs = {
> +    'no-path-retry' => {
> +        type => 'string',
> +        pattern => '(?:queue|fail|\d+)',
> +        typetext => 'queue|fail|<count>',
> +        description =>
> +            "How to react when all paths are down: keep queuing, fail at once, or retry"
> +            . " for the given number of polling intervals.",
> +        optional => 1,
> +    },
> +    'path-grouping-policy' => {
> +        type => 'string',
> +        enum => [qw(failover multibus group_by_serial group_by_prio group_by_node_name)],
> +        description => "How paths are grouped into priority groups.",
> +        optional => 1,
> +    },
> +    failback => {
> +        type => 'string',
> +        pattern => '(?:manual|immediate|followover|\d+)',
> +        typetext => 'manual|immediate|followover|<seconds>',
> +        description => "When to fail back to a restored higher-priority path group.",
> +        optional => 1,
> +    },
> +    'path-selector' => {
> +        type => 'string',
> +        maxLength => 64,
> +        description => "Path selector algorithm used within a priority group, for example"
> +            . " 'service-time 0'.",
> +        optional => 1,
> +    },
> +};
> +
> +# Knobs that only make sense globally.
> +my $defaults_only_knobs = {
> +    'find-multipaths' => {
> +        type => 'string',
> +        enum => [qw(yes no strict greedy smart)],
> +        default => 'strict',
> +        description => "Which devices multipathd assembles into a map. 'strict' only takes"
> +            . " explicitly allow-listed WWIDs.",
> +        optional => 1,
> +    },
> +    'user-friendly-names' => {
> +        type => 'string',
> +        enum => [qw(yes no)],
> +        default => 'no',
> +        description => "Whether to use node-local mpathN names. Keep 'no' for stable WWID-based"
> +            . " names across the cluster.",
> +        optional => 1,
> +    },
> +    'polling-interval' => {
> +        type => 'integer',
> +        minimum => 1,
> +        default => 5,
> +        description => "Interval between path checks, in seconds.",
> +        optional => 1,
> +    },
> +};
> +
> +# Knobs that only make sense per-LUN.
> +my $wwid_only_knobs = {
> +    alias => {
> +        type => 'string',
> +        pattern => '[a-zA-Z0-9][a-zA-Z0-9._-]*',
> +        maxLength => 64,
> +        description =>
> +            "Human-readable map name for this WWID; multipathd uses it as the map name.",
> +        optional => 1,
> +    },
> +    'rr-min-io-rq' => {
> +        type => 'integer',
> +        minimum => 1,
> +        description =>
> +            "Number of I/O requests to route to a path before switching, request-based.",
> +        optional => 1,
> +    },
> +    'rr-weight' => {
> +        type => 'string',
> +        enum => [qw(priorities uniform)],
> +        description => "Whether to weight paths by priority when balancing I/O.",
> +        optional => 1,
> +    },
> +};
> +
> +my $defaultData = {
> +    propertyList => {
> +        type => { description => "Section type ('defaults' or 'wwid')." },
> +        id => {
> +            type => 'string',
> +            description =>
> +                "Section ID: the literal 'defaults', or a LUN WWID for 'wwid' sections.",
> +            pattern => '[a-zA-Z0-9._:-]+',
> +            maxLength => 128,
> +        },
> +        $shared_knobs->%*,
> +        $defaults_only_knobs->%*,
> +        $wwid_only_knobs->%*,
> +    },
> +};
> +
> +sub private { return $defaultData; }
> +
> +package PVE::Multipath::Config::Defaults;
> +
> +use base qw(PVE::Multipath::Config);
> +
> +sub type { return 'defaults'; }
> +
> +sub options {
> +    return {
> +        'find-multipaths' => { optional => 1 },
> +        'user-friendly-names' => { optional => 1 },
> +        'polling-interval' => { optional => 1 },
> +        'no-path-retry' => { optional => 1 },
> +        'path-grouping-policy' => { optional => 1 },
> +        failback => { optional => 1 },
> +        'path-selector' => { optional => 1 },
> +    };
> +}
> +
> +__PACKAGE__->register();
> +
> +package PVE::Multipath::Config::Wwid;
> +
> +use base qw(PVE::Multipath::Config);
> +
> +sub type { return 'wwid'; }
> +
> +sub options {
> +    return {
> +        alias => { optional => 1 },
> +        'no-path-retry' => { optional => 1 },
> +        'path-grouping-policy' => { optional => 1 },
> +        failback => { optional => 1 },
> +        'path-selector' => { optional => 1 },
> +        'rr-min-io-rq' => { optional => 1 },
> +        'rr-weight' => { optional => 1 },
> +    };
> +}
> +
> +__PACKAGE__->register();
> +
> +package PVE::Multipath::Config;
> +
> +__PACKAGE__->init();
> +
> +# multipathd subsections accept only these top-level keywords; the admin override file is checked
> +# against them. 'multipaths' is generated from the wwid sections, so an admin block would collide.
> +my $OVERRIDE_KEYWORDS =
> +    { devices => 1, overrides => 1, defaults => 1, blacklist => 1, blacklist_exceptions => 1 };
> +
> +# Validate the free-form override text before it can break multipathd's parser cluster-wide. This is
> +# a guard, not a full parse: balanced braces, only known top-level sections, and no 'multipaths'
> +# block (that is generated from the wwid sections and a duplicate is fatal to multipathd).
> +sub check_overrides {
> +    my ($text) = @_;
> +
> +    return if !defined($text) || $text !~ /\S/;
> +
> +    my ($open, $close) = (0, 0);
> +    for my $line (split(/\n/, $text)) {
> +        next if $line =~ /^\s*#/;
> +        $open += ($line =~ tr/{//);
> +        $close += ($line =~ tr/}//);
> +        if ($line =~ /^\s*(\w+)\s*\{/) {
> +            my $kw = $1;
> +            die "multipath overrides: 'multipaths' is managed via aliases, do not set it here\n"
> +                if $kw eq 'multipaths';
> +            die "multipath overrides: unknown top-level section '$kw'\n"
> +                if $open - $close == 1 && !$OVERRIDE_KEYWORDS->{$kw};
> +        }
> +    }
> +    die "multipath overrides: unbalanced braces\n" if $open != $close;
> +
> +    return;
> +}
> +
> +# Read/write the separate, admin-owned override file (/etc/pve/multipath-overrides.conf). Stored and
> +# rendered verbatim, so it stays hand-editable and diffable.
> +sub parse_overrides {
> +    my ($filename, $raw) = @_;
> +    return $raw // '';
> +}
> +
> +sub write_overrides {
> +    my ($filename, $text) = @_;
> +    $text //= '';
> +    $text =~ s/\s+$//;
> +    return length($text) ? "$text\n" : '';
> +}
> +
> +my $MANAGED_HEADER =
> +    "# This file is managed by Proxmox VE - do not edit by hand.\n"
> +    . "# Hardware-/node-specific overrides belong in the override config.\n";
> +
> +# Renders a named section from a key => value hash, keys sorted for a stable, diffable result. The
> +# config and API use kebab-case parameters, multipathd keywords are snake_case, so map '-' to '_'.
> +my sub render_section {
> +    my ($name, $kv) = @_;
> +
> +    my $out = "$name {\n";
> +    for my $key (sort keys $kv->%*) {
> +        (my $keyword = $key) =~ tr/-/_/;
> +        $out .= "\t$keyword $kv->{$key}\n";
> +    }
> +    $out .= "}\n";
> +    return $out;
> +}
> +
> +# Builds the Proxmox-managed baseline drop-in (header + defaults section) from the effective global
> +# knobs. Admin overrides are not merged in here: they go into a separate conf.d file, as multipath
> +# rejects two 'defaults' blocks in one file (duplicate keyword) and drops the second.
> +sub generate_managed_conf {
> +    my ($defaults) = @_;
> +    $defaults //= managed_defaults();
> +
> +    return $MANAGED_HEADER . "\n" . render_section('defaults', $defaults);
> +}
> +
> +# The WWID allow-list file (/etc/multipath/wwids) holds one '/<wwid>/' per line; parse it and back.
> +sub parse_wwids {
> +    my ($text) = @_;
> +
> +    my $wwids = [];
> +    for my $line (split(/\n/, $text // '')) {
> +        next if $line =~ /^\s*#/;
> +        next if $line =~ /^\s*$/;
> +        if ($line =~ m{^/(.+)/\s*$}) {
> +            push $wwids->@*, $1;
> +        }
> +    }
> +    return $wwids;
> +}
> +
> +sub format_wwids {
> +    my ($wwids) = @_;
> +
> +    my $out = "# Multipath wwids, managed by Proxmox VE\n";
> +    $out .= "/$_/\n" for sort $wwids->@*;
> +    return $out;
> +}
> +
> +# Builds a 'multipaths {}' block from the per-WWID sections (alias plus any per-LUN knobs); returns
> +# the empty string when no WWID has an alias or a knob set.
> +sub build_multipaths_block {
> +    my ($wwid_opts) = @_;
> +
> +    my @entries = grep { %{ $wwid_opts->{$_} } } sort keys %$wwid_opts;
> +    return '' if !@entries;
> +
> +    my $out = "multipaths {\n";
> +    for my $wwid (@entries) {
> +        my $opts = $wwid_opts->{$wwid};
> +        $out .= "\tmultipath {\n";
> +        $out .= "\t\twwid $wwid\n";
> +        for my $key (sort keys %$opts) {
> +            (my $keyword = $key) =~ tr/-/_/;
> +            $out .= "\t\t$keyword $opts->{$key}\n";
> +        }
> +        $out .= "\t}\n";
> +    }
> +    $out .= "}\n";
> +    return $out;
> +}
> +
> +# The knob property definitions as an API parameter schema. Strip the schema 'default' so an update
> +# that omits a knob leaves it unchanged instead of resetting it to the managed default.
> +my sub api_schema {
> +    my ($props) = @_;
> +
> +    my $res = {};
> +    for my $key (keys %$props) {
> +        $res->{$key} = { $props->{$key}->%* };
> +        delete $res->{$key}->{default};
> +    }
> +    return $res;
> +}
> +
> +# Settable global knobs (the 'defaults' section) as an API parameter schema.
> +sub defaults_api_schema {
> +    return api_schema({ $shared_knobs->%*, $defaults_only_knobs->%* });
> +}
> +
> +# Settable per-WWID knobs (including the alias) as an API parameter schema.
> +sub wwid_api_schema {
> +    return api_schema({ $shared_knobs->%*, $wwid_only_knobs->%* });
> +}
> +
> +# Effective global knobs: the 'defaults' section merged onto the conservative managed defaults.
> +sub effective_defaults {
> +    my ($cfg) = @_;
> +
> +    my $defaults = managed_defaults();
> +    if (my $section = $cfg->{ids}->{defaults}) {
> +        $defaults->{$_} = $section->{$_} for grep { $_ ne 'type' } keys %$section;
> +    }
> +    return $defaults;
> +}
> +
> +# The allow-listed WWIDs, that is the ids of the 'wwid' sections.
> +sub wwid_list {
> +    my ($cfg) = @_;
> +    return [sort grep { ($cfg->{ids}->{$_}->{type} // '') eq 'wwid' } keys $cfg->{ids}->%*];
> +}
> +
> +# { wwid => alias } for the WWIDs that have one.
> +sub aliases {
> +    my ($cfg) = @_;
> +
> +    my $res = {};
> +    for my $wwid (keys $cfg->{ids}->%*) {
> +        my $section = $cfg->{ids}->{$wwid};
> +        next if ($section->{type} // '') ne 'wwid';
> +        $res->{$wwid} = $section->{alias} if defined($section->{alias});
> +    }
> +    return $res;
> +}
> +
> +# { wwid => { alias?, knob => value, ... } }, the per-WWID input to build_multipaths_block().
> +sub wwid_opts {
> +    my ($cfg) = @_;
> +
> +    my $res = {};
> +    for my $wwid (keys $cfg->{ids}->%*) {
> +        my $section = $cfg->{ids}->{$wwid};
> +        next if ($section->{type} // '') ne 'wwid';
> +        $res->{$wwid} = { map { $_ => $section->{$_} } grep { $_ ne 'type' } keys %$section };
> +    }
> +    return $res;
> +}
> +
> +1;
> diff --git a/src/PVE/Multipath/Generator.pm b/src/PVE/Multipath/Generator.pm
> new file mode 100644
> index 0000000..0bcd37f
> --- /dev/null
> +++ b/src/PVE/Multipath/Generator.pm
> @@ -0,0 +1,148 @@
> +package PVE::Multipath::Generator;
> +
> +use strict;
> +use warnings;
> +
> +use File::Path qw(make_path);
> +
> +use PVE::Tools qw(file_get_contents file_set_contents);
> +
> +use PVE::Multipath;
> +use PVE::Multipath::Config;
> +use PVE::Multipath::ClusterConfig;
> +
> +# Renders the effective node-local multipath configuration from the cluster-wide source of truth
> +# (/etc/pve/multipath.cfg) and reloads multipathd when something changed.
> +#
> +# The rendered files live on the local filesystem, so they survive reboots and are available to
> +# multipathd at boot even before pmxcfs is up; the last successful render is the boot-time fallback.
> +
> +# Proxmox-owned drop-ins; the admin's /etc/multipath.conf keeps its default 'config_dir
> +# /etc/multipath/conf.d'. The managed baseline, the admin overrides, and the generated aliases each
> +# get their own file so multipath merges them across files instead of hitting a duplicate section
> +# keyword: two 'defaults' blocks in one file are rejected outright, and our 'multipaths' alias block
> +# would clash with a 'multipaths' section in the overrides. The overrides file sorts after the
> +# baseline, so an admin's defaults override it; the aliases file is a separate 'multipaths' block,
> +# so its order does not matter.
> +my $DEFAULTS_DROPIN = '/etc/multipath/conf.d/pve-defaults.conf';
> +my $OVERRIDES_DROPIN = '/etc/multipath/conf.d/pve-overrides.conf';
> +my $ALIASES_DROPIN = '/etc/multipath/conf.d/pve-aliases.conf';
> +
> +my sub write_if_changed {
> +    my ($path, $content) = @_;
> +
> +    my $old = -e $path ? eval { file_get_contents($path) } : undef;
> +    return 0 if defined($old) && $old eq $content;
> +
> +    my $dir = $path =~ s!/[^/]+$!!r;
> +    make_path($dir) if !-d $dir;
> +    file_set_contents($path, $content);
> +    return 1;
> +}
> +
> +my sub remove_if_present {
> +    my ($path) = @_;
> +
> +    return 0 if !-e $path;
> +    unlink($path) or die "could not remove '$path': $!\n";
> +    return 1;
> +}
> +
> +sub regenerate {
> +    my ($cfg, $overrides) = @_;
> +    $cfg //= PVE::Multipath::ClusterConfig::read_config();
> +    $overrides //= PVE::Multipath::ClusterConfig::read_overrides();
> +
> +    my $changed = 0;
> +
> +    my $defaults = PVE::Multipath::Config::effective_defaults($cfg);
> +    $changed = 1
> +        if write_if_changed($DEFAULTS_DROPIN,
> +            PVE::Multipath::Config::generate_managed_conf($defaults));
> +
> +    if (defined($overrides) && length($overrides)) {
> +        my $content =
> +            "# Managed by Proxmox VE - edit overrides in /etc/pve/multipath-overrides.conf.\n\n"
> +            . "$overrides\n";
> +        $changed = 1 if write_if_changed($OVERRIDES_DROPIN, $content);
> +    } else {
> +        $changed = 1 if remove_if_present($OVERRIDES_DROPIN);
> +    }
> +
> +    my $block =
> +        PVE::Multipath::Config::build_multipaths_block(PVE::Multipath::Config::wwid_opts($cfg));
> +    if (length($block)) {
> +        my $content =
> +            "# Managed by Proxmox VE - edit aliases and per-LUN options in /etc/pve/multipath.cfg.\n\n"
> +            . $block;
> +        $changed = 1 if write_if_changed($ALIASES_DROPIN, $content);
> +    } else {
> +        $changed = 1 if remove_if_present($ALIASES_DROPIN);
> +    }
> +
> +    # Bring the WWID allow-list (/etc/multipath/wwids) in line with the cluster config through
> +    # multipath's own add/remove, so its on-disk format stays intact. Isolate each op: one failing
> +    # WWID must not abort the whole pass, or it would stall every other WWID on every run; a failed
> +    # op leaves the file unchanged and is retried next pass.
> +    my %desired = map { $_ => 1 } PVE::Multipath::Config::wwid_list($cfg)->@*;
> +    my %current = map { $_ => 1 } PVE::Multipath::list_wwids()->@*;
> +
> +    my @errors;
> +    for my $wwid (sort keys %desired) {
> +        next if $current{$wwid};
> +        eval { PVE::Multipath::add_wwid($wwid); };
> +        if (my $err = $@) {
> +            push @errors, "adding WWID '$wwid' failed - $err";
> +        } else {
> +            $changed = 1;
> +        }
> +    }
> +    for my $wwid (sort keys %current) {
> +        next if $desired{$wwid};
> +        eval { PVE::Multipath::remove_wwid($wwid); };
> +        if (my $err = $@) {
> +            push @errors, "removing WWID '$wwid' failed - $err";
> +        } else {
> +            $changed = 1;
> +        }
> +    }
> +
> +    # reload the daemon for whatever did converge, even if some ops failed
> +    if ($changed && PVE::Multipath::is_running()) {
> +        eval { PVE::Multipath::reconfigure(); };
> +        push @errors, "reconfigure failed - $@" if $@;
> +    }
> +
> +    die join('', @errors) if @errors;
> +
> +    return $changed;
> +}
> +
> +# Safe periodic entry point for a status loop like pvestatd: a no-op when multipath is not in use on
> +# this node, and never throws, so a caller stays a single guarded line and the same entry point
> +# works from a systemd unit or CLI.
> +sub sync {
> +    return 0 if !PVE::Multipath::is_supported();
> +
> +    my $cfg = eval { PVE::Multipath::ClusterConfig::read_config() };
> +    if (my $err = $@) {
> +        warn "multipath: reading cluster config failed - $err";
> +        return 0;
> +    }
> +    my $overrides = eval { PVE::Multipath::ClusterConfig::read_overrides() };
> +
> +    # stay out of the way unless the feature is in use: nothing configured cluster-wide and no
> +    # local drop-in present
> +    return 0
> +        if !scalar(PVE::Multipath::Config::wwid_list($cfg)->@*)
> +        && !(defined($overrides) && length($overrides))
> +        && !$cfg->{ids}->{defaults}
> +        && !-e $DEFAULTS_DROPIN;
> +
> +    my $changed = eval { regenerate($cfg, $overrides) };
> +    warn "multipath: config sync failed - $@" if $@;
> +
> +    return $changed // 0;
> +}
> +
> +1;
> diff --git a/src/test/Makefile b/src/test/Makefile
> index ee025bc..51c7360 100644
> --- a/src/test/Makefile
> +++ b/src/test/Makefile
> @@ -1,6 +1,6 @@
>  all: test
>  
> -test: test_zfspoolplugin test_lvmplugin test_disklist test_bwlimit test_plugin test_ovf test_volume_access
> +test: test_zfspoolplugin test_lvmplugin test_disklist test_bwlimit test_plugin test_ovf test_volume_access test_multipath
>  
>  test_zfspoolplugin: run_test_zfspoolplugin.pl
>  	./run_test_zfspoolplugin.pl
> @@ -22,3 +22,6 @@ test_ovf: run_ovf_tests.pl
>  
>  test_volume_access: run_volume_access_tests.pl
>  	./run_volume_access_tests.pl
> +
> +test_multipath: run_multipath_tests.pl
> +	./run_multipath_tests.pl
> diff --git a/src/test/run_multipath_tests.pl b/src/test/run_multipath_tests.pl
> new file mode 100755
> index 0000000..f710308
> --- /dev/null
> +++ b/src/test/run_multipath_tests.pl
> @@ -0,0 +1,238 @@
> +#!/usr/bin/perl
> +
> +use strict;
> +use warnings;
> +
> +use JSON;
> +use Test::More;
> +
> +use lib ('.', '..');
> +use PVE::Multipath;
> +use PVE::Multipath::Config;
> +
> +# A recorded `multipathd show maps json` reply with three maps exercising each
> +# health state: an all-active map, a partially-failed map and an all-failed map.
> +my $maps_json = <<'EOF';
> +{
> +   "major_version": 0,
> +   "minor_version": 1,
> +   "maps": [
> +      {
> +         "name": "mpatha",
> +         "uuid": "3600140500a1b2c3d4e5f6a7b8c9d0e1f",
> +         "sysfs": "dm-0",
> +         "dm_st": "active",
> +         "paths": 2,
> +         "path_groups": [
> +            {
> +               "group": 1,
> +               "dm_st": "active",
> +               "pri": 50,
> +               "paths": [
> +                  { "dev": "sdb", "dm_st": "active", "dev_st": "running", "chk_st": "ready", "pri": 50, "target_wwpn": "0x500a098000000001" },
> +                  { "dev": "sdc", "dm_st": "active", "dev_st": "running", "chk_st": "ready", "pri": 50, "target_wwpn": "0x500a098000000002" }
> +               ]
> +            }
> +         ]
> +      },
> +      {
> +         "name": "mpathb",
> +         "uuid": "360014050aabbccddeeff00112233445566",
> +         "sysfs": "dm-1",
> +         "dm_st": "active",
> +         "paths": 2,
> +         "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": "faulty", "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": "faulty", "chk_st": "faulty", "pri": 0, "target_wwpn": "[undef]", "host_adapter": "[undef]" }
> +               ]
> +            }
> +         ]
> +      }
> +   ]
> +}
> +EOF
> +
> +my $maps = PVE::Multipath::parse_maps_json($maps_json);
> +
> +is(scalar($maps->@*), 3, 'parsed all three maps');
> +
> +my ($a, $b, $c) = $maps->@*;
> +
> +# fully healthy map
> +is($a->{wwid}, '3600140500a1b2c3d4e5f6a7b8c9d0e1f', 'map a wwid taken from uuid');
> +is($a->{name}, 'mpatha', 'map a name');
> +is($a->{sysfs}, 'dm-0', 'map a sysfs name');
> +is($a->{'paths-total'}, 2, 'map a counts both paths');
> +is($a->{'paths-active'}, 2, 'map a has two active paths');
> +is($a->{health}, 'optimal', 'map a is optimal');
> +is(scalar($a->{'path-groups'}->@*), 1, 'map a has one path group');
> +is(
> +    $a->{'path-groups'}->[0]->{paths}->[0]->{'target-wwpn'},
> +    '0x500a098000000001',
> +    'FC target wwpn is preserved',
> +);
> +is(
> +    $a->{'path-groups'}->[0]->{paths}->[0]->{transport},
> +    'fc',
> +    'transport derived as fc from a target wwpn',
> +);
> +
> +# one failed path out of two
> +is($b->{'paths-total'}, 2, 'map b counts both paths across groups');
> +is($b->{'paths-active'}, 1, 'map b has one active path');
> +is($b->{health}, 'degraded', 'map b is degraded');
> +
> +# no active path left
> +is($c->{'paths-total'}, 1, 'map c counts its single path');
> +is($c->{'paths-active'}, 0, 'map c has no active path');
> +is($c->{health}, 'failed', 'map c is failed');
> +ok(
> +    !defined($c->{'path-groups'}->[0]->{paths}->[0]->{'target-wwpn'}),
> +    "multipathd '[undef]' target_wwpn is cleaned away (not stored)",
> +);
> +ok(
> +    !defined($c->{'path-groups'}->[0]->{paths}->[0]->{transport}),
> +    "'[undef]' target_wwpn does not imply fc transport",
> +);
> +
> +# empty / no maps must parse to an empty list, not die
> +my $empty = PVE::Multipath::parse_maps_json('{ "major_version": 0, "maps": [] }');
> +is_deeply($empty, [], 'no maps parses to empty list');
> +
> +# malformed input must die with a clear error
> +eval { PVE::Multipath::parse_maps_json('not json') };
> +ok($@ =~ m/could not parse multipathd maps JSON/, 'invalid JSON raises a clear error');
> +
> +# --- config generation / WWID allow-list ---
> +my $conf = PVE::Multipath::Config::generate_managed_conf();
> +like($conf, qr/managed by Proxmox VE/, 'managed conf carries the managed header');
> +like($conf, qr/user_friendly_names no/, 'baseline sets user_friendly_names no');
> +like($conf, qr/find_multipaths strict/, 'baseline opts in explicitly via find_multipaths strict');
> +is(
> +    scalar(() = $conf =~ /^defaults \{/mg),
> +    1,
> +    'baseline has exactly one defaults block (a second would be a duplicate-keyword error)',
> +);
> +
> +my $wwids = PVE::Multipath::Config::parse_wwids("# Multipath wwids\n/3600abc/\n/3600def/\n");
> +is_deeply($wwids, ['3600abc', '3600def'], 'parse_wwids extracts the wwids');
> +like(
> +    PVE::Multipath::Config::format_wwids(['3600def', '3600abc']),
> +    qr{/3600abc/\n/3600def/},
> +    'format_wwids sorts and slash-wraps',
> +);
> +
> +# --- cluster config (pmxcfs source of truth): SectionConfig parse/write ---
> +my $raw =
> +    "defaults: defaults\n\tfind-multipaths strict\n\tno-path-retry queue\n\n"
> +    . "wwid: 3600def\n\talias san-b-lun0\n\n"
> +    . "wwid: 3600abc\n\talias san-a-lun0\n\tno-path-retry 18\n";
> +my $cc = PVE::Multipath::Config->parse_config('multipath.cfg', $raw);
> +is_deeply(
> +    PVE::Multipath::Config::wwid_list($cc),
> +    ['3600abc', '3600def'],
> +    'wwid sections become the allow-list (sorted)',
> +);
> +is_deeply(
> +    PVE::Multipath::Config::aliases($cc),
> +    { '3600abc' => 'san-a-lun0', '3600def' => 'san-b-lun0' },
> +    'aliases read from the wwid sections',
> +);
> +is(
> +    PVE::Multipath::Config::effective_defaults($cc)->{'no-path-retry'},
> +    'queue',
> +    'defaults section knob is read',
> +);
> +is(
> +    PVE::Multipath::Config::effective_defaults($cc)->{'user-friendly-names'},
> +    'no',
> +    'an unset defaults knob falls back to the managed default',
> +);
> +
> +my $written = PVE::Multipath::Config->write_config('multipath.cfg', $cc);
> +my $cc2 = PVE::Multipath::Config->parse_config('multipath.cfg', $written);
> +is_deeply(
> +    PVE::Multipath::Config::wwid_list($cc2),
> +    ['3600abc', '3600def'],
> +    'wwids survive the SectionConfig round-trip',
> +);
> +is_deeply(
> +    PVE::Multipath::Config::aliases($cc2),
> +    PVE::Multipath::Config::aliases($cc),
> +    'aliases survive the round-trip',
> +);
> +is($cc2->{ids}->{'3600abc'}->{'no-path-retry'}, 18, 'a per-WWID knob survives the round-trip');
> +
> +is_deeply(
> +    PVE::Multipath::Config::wwid_list(PVE::Multipath::Config->parse_config('multipath.cfg', '')),
> +    [],
> +    'an empty cluster config has no WWIDs',
> +);
> +
> +# --- multipaths{} block (alias plus per-WWID knobs) ---
> +my $block = PVE::Multipath::Config::build_multipaths_block({
> +    '3600def' => { alias => 'san-b-lun0' },
> +    '3600abc' => { alias => 'san-a-lun0', 'no-path-retry' => 18 },
> +    '3600nul' => {},
> +});
> +like($block, qr/^multipaths \{/m, 'block opens with multipaths {');
> +is(
> +    scalar(() = $block =~ /^\tmultipath \{/mg),
> +    2,
> +    'one multipath{} per WWID that has an alias or a knob (the empty WWID is skipped)',
> +);
> +like(
> +    $block,
> +    qr/wwid 3600abc.*?alias san-a-lun0.*?no_path_retry 18/s,
> +    'block carries the alias and the per-WWID knob',
> +);
> +my $abc_pos = index($block, 'wwid 3600abc');
> +my $def_pos = index($block, 'wwid 3600def');
> +ok($abc_pos < $def_pos, 'block emits entries in WWID-sorted order');
> +is(PVE::Multipath::Config::build_multipaths_block({}), '', 'no WWIDs render to the empty string');
> +
> +# --- override guard ---
> +eval { PVE::Multipath::Config::check_overrides("devices {\n\tdevice {\n\t\tvendor X\n\t}\n}\n") };
> +is($@, '', 'a well-formed devices{} block passes the guard');
> +eval { PVE::Multipath::Config::check_overrides("multipaths {\n}\n") };
> +like($@, qr/managed via aliases/, 'a multipaths{} block is rejected, it is generated');
> +eval { PVE::Multipath::Config::check_overrides("devices {\n") };
> +like($@, qr/unbalanced braces/, 'unbalanced braces are rejected');
> +eval { PVE::Multipath::Config::check_overrides("frobnicate {\n}\n") };
> +like($@, qr/unknown top-level section/, 'an unknown top-level section is rejected');
> +is(
> +    PVE::Multipath::Config::write_overrides('x', "text  \n\n"),
> +    "text\n",
> +    'the overrides writer trims trailing whitespace',
> +);
> +
> +done_testing();

-- 
Maximiliano




^ permalink raw reply	[flat|nested] 16+ messages in thread

end of thread, other threads:[~2026-06-26 14:43 UTC | newest]

Thread overview: 16+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 01/13] multipath: add helper library and managed configuration Thomas Lamprecht
2026-06-26 14:43   ` Maximiliano Sandoval
2026-06-26 12:07 ` [PATCH storage 02/13] api: disks: add read-only multipath status endpoint Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 03/13] api: multipath: add cluster-wide configuration endpoints Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 04/13] multipath: add storage plugin for multipath LUNs Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 05/13] lvm: allow a multipath storage as the base device Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 06/13] multipath: broadcast per-node map health to the cluster KV store Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 07/13] api: multipath: add cluster-wide health status endpoint Thomas Lamprecht
2026-06-26 12:07 ` [PATCH cluster 08/13] pmxcfs: track cluster-wide multipath configuration Thomas Lamprecht
2026-06-26 12:07 ` [PATCH manager 09/13] pvestatd: apply the cluster-wide multipath config on each node Thomas Lamprecht
2026-06-26 12:07 ` [PATCH manager 10/13] api: cluster: mount the multipath configuration endpoint Thomas Lamprecht
2026-06-26 12:07 ` [PATCH manager 11/13] pvestatd: broadcast multipath map health to the cluster Thomas Lamprecht
2026-06-26 12:07 ` [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor Thomas Lamprecht
2026-06-26 14:05   ` Maximiliano Sandoval
2026-06-26 12:07 ` [PATCH manager 13/13] ui: node: show multipath maps and their paths under Disks Thomas Lamprecht

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal