From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 storage 33/76] multipath: publish per-node status
Date: Fri, 6 Nov 2026 00:31:08 +0100 [thread overview]
Message-ID: <20261105233336.2178064-34-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>
Path health can differ between nodes. Publish each node's map health,
path counts, tool and daemon availability, and configuration errors in
the cluster key-value store. Include wanted LUNs even without maps, the
number of unmanaged maps, and the count of unresolved LUN identities.
For storage-derived LUNs, also report whether the target still presents
them. Establish absence only after a session on every portal has
finished scanning and every discovered LUN is identified. This
distinguishes a withdrawn LUN from a map that has not assembled.
Include the iSCSI initiator name for target access rules, omitting
invalid names rather than truncating them. Resend unchanged reports once
a minute so readers can detect stale reports. Nodes without wanted LUNs
reuse a successful map listing for a minute.
The store limits values to 32 KiB, enough for roughly 200 LUNs. Larger
reports contain only counts per state, never a partial list that could
make omitted LUNs look missing.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
src/PVE/Multipath/Generator.pm | 28 +++-
src/PVE/Multipath/Identity.pm | 51 +++++-
src/PVE/Multipath/Status.pm | 184 +++++++++++++++++++++
src/PVE/Storage.pm | 2 +-
src/PVE/Storage/ISCSIPlugin.pm | 17 +-
src/test/run_lun_identity_tests.pl | 237 +++++++++++++++++++++++++---
src/test/run_multipath_api_tests.pl | 151 ++++++++++++++++++
src/test/run_multipath_tests.pl | 114 ++++++++++++-
8 files changed, 742 insertions(+), 42 deletions(-)
diff --git a/src/PVE/Multipath/Generator.pm b/src/PVE/Multipath/Generator.pm
index b7aec227..5a5ad96c 100644
--- a/src/PVE/Multipath/Generator.pm
+++ b/src/PVE/Multipath/Generator.pm
@@ -93,20 +93,37 @@ sub wwid_records_differ($a, $b) {
return $flat->($a) ne $flat->($b);
}
-# Storages can expose a LUN through different portal sets, so judge map health against the largest
-# expected path count.
+sub unresolved_lun_count($derived) {
+ my %luns = map { $_ => 1 } map { ($_->{unresolved} // [])->@* } values(($derived // {})->%*);
+ return scalar(keys %luns);
+}
+
+# Omit 'derived' when no source finds the LUN and at least one could not scan: a failed scan cannot
+# prove absence. Storages can expose a LUN through different portal sets, so judge map health
+# against the largest expected path count.
sub desired_wwid_info($desired, $derived) {
my $wwids = {};
for my $wwid (keys %$desired) {
my $info = {};
+ my ($found, $unseen) = (0, 0);
for my $source ($desired->{$wwid}->@*) {
my ($storeid) = $source =~ m/^storage:(.+)$/ or next;
push $info->{storages}->@*, $storeid;
- my $luns = ($derived->{$storeid} // {})->{wwids} // {};
- my $expected = ($luns->{$wwid} // {})->{'paths-expected'};
+ my $set = $derived->{$storeid} // {};
+ my $lun = ($set->{wwids} // {})->{$wwid};
+ if ($lun) {
+ $found = 1;
+ } elsif (!$set->{scanned}) {
+ $unseen = 1;
+ }
+ my $expected = ($lun // {})->{'paths-expected'};
$info->{'paths-expected'} = $expected
if defined($expected) && $expected > ($info->{'paths-expected'} // 0);
}
+ if ($info->{storages}) {
+ $info->{derived} = 1 if $found;
+ $info->{derived} = 0 if !$found && !$unseen;
+ }
$wwids->{$wwid} = $info;
}
return $wwids;
@@ -259,6 +276,8 @@ sub sync() {
PVE::Storage::derived_multipath_wwids(PVE::Storage::config());
};
return { $state->%*, error => "reading the storage configuration failed - $@" } if $@;
+ my $unresolved = unresolved_lun_count($derived);
+ $state->{unresolved} = $unresolved if $unresolved;
my (undef, undef, $desired) =
plan_wwid_changes(\%explicit, $derived, {}, $record);
@@ -287,6 +306,7 @@ sub sync() {
return {
wwids => $res->{wwids},
+ $unresolved ? (unresolved => $unresolved) : (),
defined($res->{error}) ? (error => $res->{error}) : (),
};
}
diff --git a/src/PVE/Multipath/Identity.pm b/src/PVE/Multipath/Identity.pm
index 2a8d3d20..86141e98 100644
--- a/src/PVE/Multipath/Identity.pm
+++ b/src/PVE/Multipath/Identity.pm
@@ -96,18 +96,35 @@ sub resolve($disks, $daemon, $naa_ok) {
return $luns;
}
-# The iSCSI sessions: { sid => { target, host } }.
+# SCANNED means the initial LUN scan after login finished.
my sub iscsi_sessions() {
my $res = {};
dir_glob_foreach(
"$SYSFS/class/iscsi_session",
'session(\d+)',
sub($ent, $sid) {
- my $target = sysfs_line("class/iscsi_session/$ent/targetname") // return;
- my (undef, $host) =
- dir_glob_regex("$SYSFS/class/iscsi_session/$ent/device", 'target(\d+):\d+:\d+');
- return if !defined($host);
- $res->{$sid} = { target => $target, host => $host };
+ my $dir = "class/iscsi_session/$ent";
+ my $target = sysfs_line("$dir/targetname") // return;
+ # the SCSI host of the session, also when it has no target left, after its last LUN
+ # was removed
+ my $device = abs_path("$SYSFS/$dir/device") // return;
+ my ($host) = $device =~ m|/host(\d+)/session\d+$| or return;
+ my $session = {
+ target => $target,
+ host => $host,
+ state => sysfs_line("$dir/state") // '',
+ scan => sysfs_line("$dir/target_state") // '',
+ portal => '',
+ };
+ my (undef, $conn) = dir_glob_regex("$SYSFS/$dir/device", '(connection\d+:\d+)');
+ if (defined($conn)) {
+ my $cdir = "$dir/device/$conn/iscsi_connection/$conn";
+ my $address = sysfs_line("$cdir/persistent_address") // '';
+ my $port = sysfs_line("$cdir/persistent_port") // '';
+ $address = "[$address]" if $address =~ m/:/;
+ $session->{portal} = "$address:$port";
+ }
+ $res->{$sid} = $session;
},
);
return $res;
@@ -191,10 +208,32 @@ sub collect(%param) {
return {
luns => $luns,
paths => $paths,
+ sessions => $sessions,
+ 'sessions-after' => iscsi_sessions(),
defined($error) ? (error => $error) : (),
};
}
+# Whether every portal of $portals has a logged-in session to $target in $ids (see collect()) that
+# had finished its LUN scan before the LUNs were collected and did not change until afterwards.
+sub portals_covered($ids, $target, $portals) {
+ my ($before, $after) = $ids->@{qw(sessions sessions-after)};
+ for my $portal (@$portals) {
+ my ($sid) = grep {
+ my $session = $before->{$_};
+ $session->{target} eq $target
+ && $session->{portal} eq $portal
+ && $session->{state} eq 'LOGGED_IN'
+ && $session->{scan} eq 'SCANNED'
+ } sort keys %$before;
+ return 0 if !defined($sid) || !$after->{$sid};
+ return 0 if grep {
+ $after->{$sid}->{$_} ne $before->{$sid}->{$_}
+ } keys $before->{$sid}->%*;
+ }
+ return scalar(@$portals) ? 1 : 0;
+}
+
sub target_luns($ids, $target) {
return [grep { $_->{target} eq $target } values $ids->{luns}->%*];
}
diff --git a/src/PVE/Multipath/Status.pm b/src/PVE/Multipath/Status.pm
index e3dccf08..cc8fe49d 100644
--- a/src/PVE/Multipath/Status.pm
+++ b/src/PVE/Multipath/Status.pm
@@ -6,7 +6,191 @@ use JSON;
use PVE::Multipath;
use PVE::Multipath::Config;
+# Include wanted WWIDs without assembled maps so failed assembly remains visible. Expected path
+# counts include paths the daemon no longer lists.
+sub status_luns($wanted, $maps) {
+ my $luns = {};
+ for my $wwid (keys %$wanted) {
+ my $info = $wanted->{$wwid};
+ $luns->{$wwid} = { map { defined($info->{$_}) ? ($_ => $info->{$_}) : () }
+ qw(storages paths-expected derived kept) };
+ }
+
+ my $unmanaged = 0;
+ for my $map (($maps // [])->@*) {
+ my $wwid = $map->{wwid} // next;
+ my $lun = $luns->{$wwid};
+ if (!$lun) {
+ $unmanaged++;
+ next;
+ }
+
+ my ($active, $total) = ($map->{'paths-active'} // 0, $map->{'paths-total'} // 0);
+ my $expected = $lun->{'paths-expected'};
+ $lun->{state} =
+ defined($expected)
+ ? PVE::Multipath::derive_health($active, $total, $expected)
+ : $map->{health};
+ $lun->{'paths-active'} = $active;
+ $lun->{'paths-total'} = $total;
+ $lun->{transport} = $map->{transport} if defined($map->{transport});
+ $lun->{size} = $map->{size} if defined($map->{size});
+ }
+ return ($luns, $unmanaged);
+}
+
+# The pmxcfs node KV store rejects values of 32 KiB and more.
+my $KV_VALUE_LIMIT = 30 * 1024;
+my $ERROR_LIMIT = 2048;
my $KV_KEY = 'multipath';
+my $status_json = JSON->new->canonical->utf8;
+
+our $KV_REFRESH_SECONDS = 60;
+our $KV_STALE_SECONDS = 3 * $KV_REFRESH_SECONDS;
+
+# Replace oversized details with a summary: a partial list is indistinguishable from missing LUNs.
+sub encode_status($status, $limit = undef) {
+ $limit //= $KV_VALUE_LIMIT;
+ my $data = { $status->%* };
+ for my $key (qw(apply-error report-error)) {
+ $data->{$key} = substr($data->{$key}, 0, $ERROR_LIMIT) . '...'
+ if defined($data->{$key}) && length($data->{$key}) > $ERROR_LIMIT;
+ }
+ my $encoded = $status_json->encode($data);
+ return $encoded if length($encoded) <= $limit;
+
+ my $luns = delete($data->{luns}) // {};
+ my $summary = { luns => scalar(keys %$luns) };
+ $summary->{ $_->{state} // 'missing' }++ for values %$luns;
+ $data->{summary} = $summary;
+ return $status_json->encode($data);
+}
+
+# Send on change, and unchanged once per refresh interval so readers can tell a current report from
+# the last one of a node that stopped reporting. A failed send is retried on the next call.
+my $last_kv;
+
+my sub update_node_kv($status) {
+ require PVE::Cluster;
+
+ my $content = $status_json->encode($status);
+ my $now = time();
+ return
+ if $last_kv
+ && $content eq $last_kv->{content}
+ && $now - $last_kv->{time} < $KV_REFRESH_SECONDS;
+
+ eval {
+ # the cluster library only warns when the send fails
+ local $SIG{__WARN__} = sub($line) { die $line };
+ PVE::Cluster::broadcast_node_kv($KV_KEY, encode_status({ $status->%*, time => $now }));
+ };
+ if (my $err = $@) {
+ warn "multipath: broadcasting the status failed - $err";
+ return;
+ }
+ $last_kv = { content => $content, time => $now };
+}
+
+# Unmanaged-map counts need no finer freshness than the broadcast refresh interval.
+my $idle_maps;
+
+# Reject malformed or oversized initiator names rather than truncate identities used in target ACLs.
+my sub valid_iscsi_name($name) {
+ return 0 if !defined($name) || length($name) > 223;
+ my $valid = $name =~ m/^(?:
+ iqn\.\d{4}-\d{2}\.[A-Za-z0-9][A-Za-z0-9._:-]*
+ | eui\.[0-9A-Fa-f]{16}
+ | naa\.[0-9A-Fa-f]{16}(?:[0-9A-Fa-f]{16})?
+ )$/x;
+ return $valid ? 1 : 0;
+}
+
+# Publish even without tools: the report's presence also signals support for the storage option.
+sub broadcast_status($apply) {
+ $apply //= {};
+ my $wanted = $apply->{wwids} // {};
+ my $status = {
+ supported => PVE::Multipath::is_supported() ? 1 : 0,
+ defined($apply->{error}) ? ('apply-error' => "$apply->{error}") : (),
+ $apply->{unresolved} ? ('unresolved-luns' => $apply->{unresolved}) : (),
+ };
+
+ require PVE::Storage::ISCSIPlugin;
+ my $initiator = eval { PVE::Storage::ISCSIPlugin::read_initiator_info() };
+ $status->{'iscsi-initiator-name'} = $initiator->{name}
+ if $initiator && valid_iscsi_name($initiator->{name});
+
+ my $maps = [];
+ if ($status->{supported}) {
+ $status->{running} = PVE::Multipath::is_running() ? 1 : 0;
+ if ($status->{running}) {
+ if (!%$wanted && $idle_maps && time() - $idle_maps->{time} < $KV_REFRESH_SECONDS) {
+ $maps = $idle_maps->{maps};
+ } else {
+ $maps = eval { PVE::Multipath::get_maps() };
+ if (my $err = $@) {
+ $status->{'report-error'} = "querying multipathd failed - $err";
+ $maps = [];
+ $idle_maps = undef;
+ } else {
+ $idle_maps = %$wanted ? undef : { time => time(), maps => $maps };
+ }
+ }
+ }
+ }
+
+ my ($luns, $unmanaged) = status_luns($wanted, $maps);
+ $status->{luns} = $luns;
+ $status->{unmanaged} = $unmanaged if $unmanaged;
+
+ eval { update_node_kv($status) };
+ warn "multipath: $@" if $@;
+}
+
+sub read_status_broadcasts() {
+ require PVE::Cluster;
+
+ my $now = time();
+ my $members = PVE::Cluster::get_members() // {};
+ my $raw = PVE::Cluster::get_node_kv($KV_KEY);
+ my $res = {};
+ for my $node (keys %$raw) {
+ my $decoded = eval { decode_json($raw->{$node}) };
+ next if ref($decoded) ne 'HASH';
+
+ my $entry = {
+ supported => $decoded->{supported} ? 1 : 0,
+ running => $decoded->{running} ? 1 : 0,
+ luns => ref($decoded->{luns}) eq 'HASH' ? $decoded->{luns} : undef,
+ time => $decoded->{time},
+ };
+ for my $key (qw(apply-error report-error unmanaged unresolved-luns summary)) {
+ $entry->{$key} = $decoded->{$key} if defined($decoded->{$key});
+ }
+ # a report of an older release has none, a buggy peer may send anything
+ $entry->{'iscsi-initiator-name'} = $decoded->{'iscsi-initiator-name'}
+ if valid_iscsi_name($decoded->{'iscsi-initiator-name'});
+
+ my $reason;
+ if (%$members && (!$members->{$node} || !$members->{$node}->{online})) {
+ $reason = 'offline';
+ } elsif (!defined($decoded->{time}) || $now - $decoded->{time} > $KV_STALE_SECONDS) {
+ $reason = 'stale';
+ } elsif (!$entry->{supported}) {
+ $reason = 'unsupported';
+ } elsif (!$entry->{running}) {
+ $reason = 'not-running';
+ } elsif (defined($entry->{'report-error'})) {
+ $reason = 'query-error';
+ }
+ $entry->{fresh} = defined($reason) ? 0 : 1;
+ $entry->{reason} = $reason if defined($reason);
+
+ $res->{$node} = $entry;
+ }
+ return $res;
+}
# Older nodes can drop the unknown multipath option when rewriting storage.cfg; offline nodes
# may also lack a report, so silence is not evidence of compatibility.
diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
index 88134473..8cbe9308 100755
--- a/src/PVE/Storage.pm
+++ b/src/PVE/Storage.pm
@@ -300,7 +300,7 @@ sub storage_has_feature {
}
# Retain empty entries on failure: absence means the storage is no longer enabled here, not merely
-# unreachable.
+# unreachable. scanned requires every portal's completed scan and identified LUNs.
sub derived_multipath_wwids {
my ($cfg, $ids) = @_;
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 4831d2d4..ad3afaf6 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -751,7 +751,8 @@ sub iscsi_device_list {
return $res;
}
-# Expect one path per node-record portal.
+# Expect one path per node-record portal. scanned requires completed, stable sessions on every
+# portal and all LUNs identified; an incomplete scan must not prove absence or remove owned entries.
sub multipath_lun_wwids {
my ($class, $scfg, $ids) = @_;
@@ -760,13 +761,19 @@ sub multipath_lun_wwids {
$ids //= PVE::Multipath::Identity::collect(targets => { $target => 1 });
my $luns = PVE::Multipath::Identity::target_luns($ids, $target);
- return $res if !scalar(@$luns);
- my $paths_expected = scalar(iscsi_portals($target, $scfg->{portal})->@*);
+ my $portals = iscsi_portals($target, $scfg->{portal});
+ my @unresolved;
for my $lun (@$luns) {
- next if !defined($lun->{wwid});
- $res->{wwids}->{ $lun->{wwid} } = { 'paths-expected' => $paths_expected };
+ if (!defined($lun->{wwid})) {
+ push @unresolved, "$target $lun->{lun}";
+ next;
+ }
+ $res->{wwids}->{ $lun->{wwid} } = { 'paths-expected' => scalar(@$portals) };
}
+ $res->{unresolved} = [sort @unresolved] if @unresolved;
+ $res->{scanned} = 1
+ if !@unresolved && PVE::Multipath::Identity::portals_covered($ids, $target, $portals);
return $res;
}
diff --git a/src/test/run_lun_identity_tests.pl b/src/test/run_lun_identity_tests.pl
index 33b92d7c..783fac18 100755
--- a/src/test/run_lun_identity_tests.pl
+++ b/src/test/run_lun_identity_tests.pl
@@ -260,18 +260,8 @@ my sub put {
close($fh);
}
-# An iSCSI session as the kernel's transport class shows it: its SCSI target below the session
-# device, which the session class links to.
+# the SCSI targets of the sessions, see session_device()
my %session_dirs;
-my sub session {
- my ($sid, $target, $scsi_target) = @_;
- my ($host) = $scsi_target =~ m/^(\d+):/;
- my $dir = "devices/platform/host$host/session$sid";
- make_path("$sys/$dir/target$scsi_target");
- put("class/iscsi_session/session$sid/targetname", "$target\n");
- symlink("$sys/$dir", "$sys/class/iscsi_session/session$sid/device") or die "$!\n";
- $session_dirs{$scsi_target} = "$dir/target$scsi_target";
-}
# A SCSI device, below the target of its session if it has one, listed on the SCSI bus.
my sub scsi_device {
@@ -322,9 +312,27 @@ my sub wwids_of {
};
}
+# an iSCSI session on its SCSI host, logged in to $portal and done with its scan for LUNs
+my sub session_device {
+ my ($sid, $host, $target, $portal, $id) = @_;
+ $id //= 0;
+ my $dir = "devices/platform/host$host/session$sid";
+ make_path("$sys/$dir/target$host:0:$id");
+ $session_dirs{"$host:0:$id"} = "$dir/target$host:0:$id";
+ my $class = "class/iscsi_session/session$sid";
+ put("$class/targetname", "$target\n");
+ put("$class/state", "LOGGED_IN\n");
+ put("$class/target_state", "SCANNED\n");
+ my ($address, $port) = $portal =~ m/^(.+):(\d+)$/;
+ my $conn = "$dir/connection$sid:0/iscsi_connection/connection$sid:0";
+ put("$conn/persistent_address", "$address\n");
+ put("$conn/persistent_port", "$port\n");
+ symlink("$sys/$dir", "$sys/$class/device") or die "$!\n";
+}
+
# session 1 to $T on host 7 with LUN 0 (a map) and LUN 1 (no map), session 2 to $O on host 8
-session(1, $T, '7:0:0');
-session(2, $O, '8:0:0');
+session_device(1, 7, $T, '192.0.2.10:3260');
+session_device(2, 8, $O, '192.0.2.20:3260');
disk_device('7:0:0:0', 'sdb', '8:16');
disk_device('7:0:0:1', 'sdc', '8:32');
disk_device('8:0:0:0', 'sdd', '8:48');
@@ -422,9 +430,9 @@ put('bus/scsi/devices/7:0:0:1/block/sdc/dev', "8:32\n");
# the only LUN of a third target: a path with a map, one that the daemon names differently
my $P = 'iqn.2003-01.org.linux-iscsi.third:sn.3';
-session(3, $P, '9:0:0');
+session_device(3, 9, $P, '192.0.2.30:3260');
# a second session to it on the same SCSI host, as offload initiators run them
-session(4, $P, '9:0:1');
+session_device(7, 9, $P, '192.0.2.31:3260', 1);
disk_device('9:0:0:0', 'sde', '8:64');
disk_device('9:0:1:0', 'sdf', '8:80');
hold('9:0:0:0', 'sde', 'dm-2', '3W9');
@@ -445,12 +453,12 @@ is_deeply(
# two targets through one SCSI host, as offload initiators run them: each LUN stays with the target
# of its own session
my ($Q, $R) = ('iqn.2003-01.org.linux-iscsi.fourth:sn.4', 'iqn.2003-01.org.linux-iscsi.fifth:sn.5');
-session(5, $Q, '10:0:0');
-session(6, $R, '10:0:1');
-disk_device('10:0:0:0', 'sdg', '8:96');
-disk_device('10:0:1:0', 'sdh', '8:112');
-hold('10:0:0:0', 'sdg', 'dm-3', '3WQ');
-hold('10:0:1:0', 'sdh', 'dm-4', '3WR');
+session_device(8, 11, $Q, '192.0.2.60:3260');
+session_device(9, 11, $R, '192.0.2.70:3260', 1);
+disk_device('11:0:0:0', 'sdg', '8:96');
+disk_device('11:0:1:0', 'sdh', '8:112');
+hold('11:0:0:0', 'sdg', 'dm-3', '3WQ');
+hold('11:0:1:0', 'sdh', 'dm-4', '3WR');
for my $case ([$Q, '3WQ', 'sdg'], [$R, '3WR', 'sdh']) {
my ($target, $wwid, $path) = @$case;
@@ -503,8 +511,8 @@ my $iscsi = Test::MockModule->new('PVE::Storage::ISCSIPlugin');
$iscsi->mock(iscsi_portals => sub { ['192.0.2.10:3260', '192.0.2.11:3260'] });
is_deeply(
PVE::Storage::ISCSIPlugin->multipath_lun_wwids({ target => $T, portal => '192.0.2.10' }, $ids),
- { wwids => { '3W0' => { 'paths-expected' => 2 } } },
- 'derived: the resolved LUNs, with a path per portal expected',
+ { wwids => { '3W0' => { 'paths-expected' => 2 } }, unresolved => ["$T 1"] },
+ 'derived: the resolved LUNs, with a path per portal expected, and the others',
);
my $identity = Test::MockModule->new('PVE::Multipath::Identity');
@@ -555,4 +563,187 @@ my $derived = PVE::Storage::derived_multipath_wwids({
is_deeply([sort keys %$derived], ['a', 'b'], 'derived: every storage with the option');
is($collected, 1, 'derived: the identities are collected once for all of them');
+# --- whether a storage looked at all its LUNs ---
+
+my sub session {
+ my ($sid, $target, $portal, $scan, %extra) = @_;
+ return (
+ $sid => {
+ target => $target,
+ host => $sid + 6,
+ state => $extra{state} // 'LOGGED_IN',
+ scan => $scan,
+ portal => $portal,
+ },
+ );
+}
+my $portals = ['192.0.2.10:3260', '192.0.2.11:3260'];
+my $both = { session(1, $T, $portals->[0], 'SCANNED'), session(2, $T, $portals->[1], 'SCANNED') };
+my sub covered {
+ my ($before, $after) = @_;
+ return PVE::Multipath::Identity::portals_covered(
+ { sessions => $before, 'sessions-after' => $after // $before },
+ $T,
+ $portals,
+ );
+}
+ok(covered($both), 'covered: every portal with a session that finished its scan');
+ok(!covered({ session(1, $T, $portals->[0], 'SCANNED') }),
+ 'covered: not with a portal without one');
+ok(
+ !covered({ $both->%*, session(2, $T, $portals->[1], 'ALLOCATED') }),
+ 'covered: not while the scan of a session still runs',
+);
+ok(
+ !covered({ $both->%*, session(2, $T, $portals->[1], '') }),
+ 'covered: not without the scan state',
+);
+ok(
+ !covered({ $both->%*, session(2, $T, $portals->[1], 'SCANNED', state => 'FAILED') }),
+ 'covered: not with a session that is not logged in',
+);
+ok(
+ !covered(
+ $both,
+ { session(1, $T, $portals->[0], 'SCANNED'), session(3, $T, $portals->[1], 'SCANNED') },
+ ),
+ 'covered: not if a session changed while the LUNs were collected',
+);
+ok(
+ !PVE::Multipath::Identity::portals_covered({ sessions => {}, 'sessions-after' => {} }, $T, []),
+ 'covered: not without any portal',
+);
+
+$ids = {
+ luns => PVE::Multipath::Identity::resolve(
+ { disk(0, 1, map => '3W0'), disk(1, 2) },
+ { line(2, 1, '3W1') },
+ $naa_ok,
+ ),
+ sessions => $both,
+ 'sessions-after' => $both,
+};
+$iscsi->mock(iscsi_portals => sub { $portals });
+my $scfg = { target => $T, portal => '192.0.2.10' };
+is_deeply(
+ PVE::Storage::ISCSIPlugin->multipath_lun_wwids($scfg, $ids),
+ {
+ wwids => { '3W0' => { 'paths-expected' => 2 }, '3W1' => { 'paths-expected' => 2 } },
+ scanned => 1,
+ },
+ 'derived: every LUN identified on every portal: looked at all of them',
+);
+$ids->{luns} = PVE::Multipath::Identity::resolve(
+ { disk(0, 1, map => '3W0'), disk(1, 2) },
+ { line(2, 1, '3X1', init => 'udev-missing') },
+ $naa_ok,
+);
+is_deeply(
+ PVE::Storage::ISCSIPlugin->multipath_lun_wwids($scfg, $ids),
+ { wwids => { '3W0' => { 'paths-expected' => 2 } }, unresolved => ["$T 1"] },
+ 'derived: a LUN not identified: the others still, but not looked at all of them',
+);
+$ids->{luns} = {};
+is_deeply(
+ PVE::Storage::ISCSIPlugin->multipath_lun_wwids($scfg, $ids),
+ { wwids => {}, scanned => 1 },
+ 'derived: no LUN left on a target that every portal reaches',
+);
+$ids->{sessions} =
+ { session(1, $T, $portals->[0], 'ALLOCATED'), session(2, $T, $portals->[1], 'SCANNED') };
+is_deeply(
+ PVE::Storage::ISCSIPlugin->multipath_lun_wwids($scfg, $ids),
+ { wwids => {} },
+ 'derived: but not while a scan of the target runs',
+);
+
+is(
+ PVE::Multipath::Generator::unresolved_lun_count({
+ a => { unresolved => ["$T 1", "$T 2"] },
+ b => { unresolved => ["$T 2"] },
+ c => {},
+ }),
+ 2,
+ 'unresolved: a LUN listed by two storages counts once',
+);
+
+# --- whether a storage looked at all its LUNs, from sysfs ---
+
+{
+ $iscsi->unmock_all();
+ $identity->unmock_all();
+ my $portal = '192.0.2.10:3260';
+ $iscsi->mock(iscsi_portals => sub { [$portal] });
+ my $scfg = { target => $T, portal => '192.0.2.10', multipath => 1 };
+ $reply = {
+ sdc => {
+ devt => '8:32',
+ hctl => '7:0:0:1',
+ init => 'ok',
+ target => $T,
+ wwid => '3600a09803832355367242f747a716b7a',
+ },
+ };
+ my sub derived { PVE::Storage::ISCSIPlugin->multipath_lun_wwids($scfg) }
+
+ ok(derived()->{scanned}, 'sysfs: every portal covered, every LUN identified');
+ $iscsi->mock(iscsi_portals => sub { [$portal, '192.0.2.11:3260'] });
+ ok(!derived()->{scanned}, 'sysfs: not with a portal without a session');
+ $iscsi->mock(iscsi_portals => sub { [$portal] });
+ put('class/iscsi_session/session1/target_state', "ALLOCATED\n");
+ ok(
+ !derived()->{scanned} && !derived()->{scanned},
+ 'sysfs: not while the scan still runs, again and again',
+ );
+ put('class/iscsi_session/session1/target_state', "SCANNED\n");
+ {
+ # the session goes through a recovery while the LUNs are collected
+ $multipath->mock(
+ query_path_identities => sub {
+ put('class/iscsi_session/session1/state', "FAILED\n");
+ return $reply;
+ },
+ );
+ ok(!derived()->{scanned}, 'sysfs: not if the session changed during the collection');
+ put('class/iscsi_session/session1/state', "LOGGED_IN\n");
+ $multipath->mock(query_path_identities =>
+ sub { $queries++; die $reply if !ref($reply); return $reply });
+ }
+
+ # removing the last LUN of a target also makes the kernel remove the session's SCSI target
+ my $empty = 'iqn.2003-01.org.linux-iscsi.empty:sn.4';
+ session_device(4, 10, $empty, '192.0.2.40:3260');
+ rmdir("$sys/devices/platform/host10/session4/target10:0:0") or die "$!\n";
+ $iscsi->mock(iscsi_portals => sub { ['192.0.2.40:3260'] });
+ is_deeply(
+ PVE::Storage::ISCSIPlugin->multipath_lun_wwids({
+ target => $empty,
+ portal => '192.0.2.40',
+ }),
+ { wwids => {}, scanned => 1 },
+ 'sysfs: a target without LUNs, all of them gone, looked at',
+ );
+}
+
+# --- the count of LUNs a node cannot identify, through its report ---
+
+{
+ my $cluster = Test::MockModule->new('PVE::Cluster');
+ my $sent;
+ $cluster->mock(
+ broadcast_node_kv => sub { $sent = $_[1] },
+ get_node_kv => sub { { lab1 => $sent } },
+ get_members => sub { { lab1 => { online => 1 } } },
+ rrd_dump => sub { {} },
+ );
+ $multipath->mock(is_supported => sub { 0 });
+ PVE::Multipath::Status::broadcast_status({ wwids => {}, unresolved => 2 });
+ is(
+ PVE::Multipath::Status::read_status_broadcasts()->{lab1}->{'unresolved-luns'},
+ 2,
+ 'report: the count of LUNs the node cannot identify',
+ );
+ $multipath->unmock('is_supported');
+}
+
done_testing();
diff --git a/src/test/run_multipath_api_tests.pl b/src/test/run_multipath_api_tests.pl
index fdd9ec39..3f0c8a92 100755
--- a/src/test/run_multipath_api_tests.pl
+++ b/src/test/run_multipath_api_tests.pl
@@ -22,6 +22,80 @@ use PVE::Storage;
use PVE::Storage::ISCSIPlugin;
use PVE::Multipath::Status;
+subtest 'every node sends a bounded status report' => sub {
+ my $kv = {};
+ my $cluster = Test::MockModule->new('PVE::Cluster');
+ $cluster->mock(
+ get_node_kv => sub {
+ +{ map { $_ => $kv->{$_} } keys %$kv };
+ },
+ );
+ $cluster->mock(get_members => sub { {} });
+ my $node = 'n1';
+ $cluster->mock(
+ broadcast_node_kv => sub {
+ die "oversized message\n" if length($_[1]) >= 32768;
+ $kv->{$node} = $_[1];
+ },
+ );
+ my $mp = Test::MockModule->new('PVE::Multipath');
+ my $supported = 0;
+ $mp->mock(is_supported => sub { $supported });
+ $mp->mock(is_running => sub { 1 });
+ my $maps = [];
+ $mp->mock(get_maps => sub { $maps });
+
+ PVE::Multipath::Status::broadcast_status({});
+ my $reports = PVE::Multipath::Status::read_status_broadcasts();
+ ok($reports->{n1}, 'a node without multipath-tools still reports');
+ is($reports->{n1}->{reason}, 'unsupported', 'and says why it has no map states');
+
+ $node = 'n2';
+ $supported = 1;
+ $maps = [{ wwid => 'w1', health => 'optimal', 'paths-active' => 2, 'paths-total' => 2 }];
+ PVE::Multipath::Status::broadcast_status({
+ wwids => { w1 => {}, w2 => { storages => ['san'] } } });
+ $reports = PVE::Multipath::Status::read_status_broadcasts();
+ ok($reports->{n2}->{fresh}, 'a node with a running daemon reports current states');
+ is($reports->{n2}->{luns}->{w1}->{state}, 'optimal', 'with the state of an assembled map');
+ is_deeply($reports->{n2}->{luns}->{w2}, { storages => ['san'] }, 'and the unassembled LUN');
+
+ $node = 'n3';
+ $mp->mock(get_maps => sub { die "query failed\n" });
+ PVE::Multipath::Status::broadcast_status({ wwids => { w1 => {} } });
+ $reports = PVE::Multipath::Status::read_status_broadcasts();
+ is(
+ $reports->{n3}->{reason},
+ 'query-error',
+ 'a failing query is not evidence of a missing map',
+ );
+ ok($reports->{n3}->{luns}->{w1}, 'and keeps the wanted LUNs listed');
+
+ $node = 'n5';
+ my $queries = 0;
+ $mp->mock(get_maps => sub { $queries++; die "query failed\n" });
+ PVE::Multipath::Status::broadcast_status({});
+ $mp->mock(get_maps => sub { $queries++; [] });
+ PVE::Multipath::Status::broadcast_status({});
+ is($queries, 2, 'a failed query of an idle node is not reused');
+ ok(
+ PVE::Multipath::Status::read_status_broadcasts()->{n5}->{fresh},
+ 'and the next one counts',
+ );
+ PVE::Multipath::Status::broadcast_status({});
+ is($queries, 2, 'while a successful one is');
+
+ $node = 'n4';
+ $mp->mock(get_maps => sub { [] });
+ my $wanted = { map { (sprintf('3600%032x', $_) => { storages => ['san'] }) } 1 .. 1000 };
+ PVE::Multipath::Status::broadcast_status({ wwids => $wanted, error => 'timeout ' x 10000 });
+ $reports = PVE::Multipath::Status::read_status_broadcasts();
+ ok($reports->{n4}->{fresh}, 'a node with more LUNs than fit still reports');
+ ok(!defined($reports->{n4}->{luns}), 'without a partial list');
+ is_deeply($reports->{n4}->{summary}, { luns => 1000, missing => 1000 }, 'but with counts');
+ ok(length($reports->{n4}->{'apply-error'}) < 2100, 'and a bounded apply error');
+};
+
subtest 'enabling the multipath option needs every node to know it' => sub {
my $cluster = Test::MockModule->new('PVE::Cluster');
my $inotify = Test::MockModule->new('PVE::INotify');
@@ -136,6 +210,83 @@ subtest 'the iSCSI initiator identity reader' => sub {
}
};
+subtest 'the report carries the iSCSI initiator name of the node' => sub {
+ my $kv = {};
+ my $cluster = Test::MockModule->new('PVE::Cluster');
+ $cluster->mock(get_node_kv => sub { +{%$kv} });
+ $cluster->mock(get_members => sub { {} });
+ $cluster->mock(rrd_dump => sub { {} });
+ $cluster->mock(broadcast_node_kv => sub { $kv->{n1} = $_[1] });
+ my $mp = Test::MockModule->new('PVE::Multipath');
+ my $supported = 0;
+ $mp->mock(is_supported => sub { $supported });
+ $mp->mock(is_running => sub { 1 });
+ $mp->mock(get_maps => sub { [] });
+ my $iscsi = Test::MockModule->new('PVE::Storage::ISCSIPlugin');
+ my $initiator;
+ $iscsi->mock(read_initiator_info => sub { $initiator });
+ my $sent = sub {
+ my ($apply) = @_;
+ PVE::Multipath::Status::broadcast_status($apply // {});
+ return PVE::Multipath::Status::read_status_broadcasts()->{n1};
+ };
+
+ my $iqn = 'iqn.2026-10.test.lab:n1';
+ $initiator = { name => $iqn };
+ is($sent->()->{'iscsi-initiator-name'}, $iqn, 'a node without multipath-tools sends it');
+
+ $supported = 1;
+ $initiator = { name => 'iqn.2026-10.test.lab:renamed', alias => 'n1' };
+ is(
+ $sent->()->{'iscsi-initiator-name'},
+ 'iqn.2026-10.test.lab:renamed',
+ 'a changed name goes out with the next report',
+ );
+
+ $initiator = undef;
+ ok(!exists($sent->()->{'iscsi-initiator-name'}), 'a removed one is gone from it');
+
+ $iscsi->mock(read_initiator_info => sub { die "unreadable\n" });
+ ok(!exists($sent->()->{'iscsi-initiator-name'}), 'an unreadable file only leaves it out');
+ $iscsi->mock(read_initiator_info => sub { $initiator });
+
+ my $invalid = [
+ ['iqn.2026-10.test.lab:' . ('x' x 210), 'a name too long to be an iSCSI name'],
+ ['iqn.bogus', 'an IQN without a date'],
+ ['iqn.2026-10.a b', 'one with a blank'],
+ ];
+ for my $case ($invalid->@*) {
+ $initiator = { name => $case->[0] };
+ ok(!exists($sent->()->{'iscsi-initiator-name'}), "is left out, not cut: $case->[1]");
+ }
+ for my $name ('eui.02004567A425678D', 'naa.52004567BA64678D') {
+ $initiator = { name => $name };
+ is($sent->()->{'iscsi-initiator-name'}, $name, "the other name formats: $name");
+ }
+
+ $initiator = { name => $iqn };
+ my $wanted = { map { (sprintf('3600%032x', $_) => { storages => ['san'] }) } 1 .. 1000 };
+ my $report = $sent->({ wwids => $wanted });
+ ok($report->{summary}, 'a node with too many LUNs sends a summary');
+ is($report->{'iscsi-initiator-name'}, $iqn, 'with the name');
+
+ $kv->{n1} = JSON::encode_json({ supported => 1, running => 1, luns => {}, time => time() });
+ ok(
+ !exists(
+ PVE::Multipath::Status::read_status_broadcasts()->{n1}->{'iscsi-initiator-name'}
+ ),
+ 'a report of an older release reads without one',
+ );
+ $kv->{n1} =
+ JSON::encode_json({ luns => {}, time => time(), 'iscsi-initiator-name' => ['x'] });
+ ok(
+ !exists(
+ PVE::Multipath::Status::read_status_broadcasts()->{n1}->{'iscsi-initiator-name'}
+ ),
+ 'and a garbled one from a peer is dropped',
+ );
+};
+
subtest 'an entry stays allow-listed while its map is in use' => sub {
my $mp = Test::MockModule->new('PVE::Multipath');
my $map = { wwid => 'w1', sysfs => 'dm-999999', 'paths-active' => 2 };
diff --git a/src/test/run_multipath_tests.pl b/src/test/run_multipath_tests.pl
index 992dd3dd..1c28ac86 100755
--- a/src/test/run_multipath_tests.pl
+++ b/src/test/run_multipath_tests.pl
@@ -3,6 +3,7 @@
use strict;
use warnings;
+use JSON;
use Test::MockModule;
use Test::More;
@@ -367,13 +368,35 @@ is_deeply(
PVE::Multipath::Generator::desired_wwid_info(
{ w1 => ['config', 'storage:a', 'storage:b'], w2 => ['config'] },
{
- a => { wwids => { w1 => { 'paths-expected' => 2 } } },
- b => { wwids => { w1 => { 'paths-expected' => 4 } } },
+ a => { wwids => { w1 => { 'paths-expected' => 2 } }, scanned => 1 },
+ b => { wwids => { w1 => { 'paths-expected' => 4 } }, scanned => 1 },
},
),
- { w1 => { storages => ['a', 'b'], 'paths-expected' => 4 }, w2 => {} },
+ { w1 => { storages => ['a', 'b'], 'paths-expected' => 4, derived => 1 }, w2 => {} },
'every deriving storage is named, the largest path expectation wins',
);
+is_deeply(
+ PVE::Multipath::Generator::desired_wwid_info(
+ {
+ gone => ['storage:a'],
+ unseen => ['storage:a', 'storage:b'],
+ once => ['storage:a', 'storage:b'],
+ failed => ['storage:c'],
+ },
+ {
+ a => { wwids => { once => {} }, scanned => 1 },
+ b => { wwids => {} },
+ c => { wwids => {}, error => 'boom' },
+ },
+ ),
+ {
+ gone => { storages => ['a'], derived => 0 },
+ unseen => { storages => ['a', 'b'] },
+ once => { storages => ['a', 'b'], derived => 1 },
+ failed => { storages => ['c'] },
+ },
+ 'derived: 0 only if every storage looked and none found it, unknown if one could not look',
+);
# --- WWID allow-list reconcile plan: only prune what PVE itself added, and only once every ---
# --- reason it was added for is gone from the configuration ---
@@ -632,4 +655,89 @@ is(
);
}
+# --- per-LUN status report ---
+my $wanted_all = { map { $_->{wwid} => {} } $maps->@* };
+my ($luns, $unmanaged) = PVE::Multipath::Status::status_luns($wanted_all, $maps);
+is_deeply([sort keys %$luns], [sort keys %$wanted_all], 'every wanted LUN is reported');
+is($unmanaged, 0, 'no unmanaged maps when everything is wanted');
+is($luns->{ $a->{wwid} }->{state}, 'optimal', 'optimal map reported as optimal');
+is($luns->{ $a->{wwid} }->{'paths-active'}, 2, 'active path count carried');
+is($luns->{ $a->{wwid} }->{'paths-total'}, 2, 'total path count carried');
+is($luns->{ $b->{wwid} }->{state}, 'degraded', 'degraded map reported as degraded');
+is($luns->{ $c->{wwid} }->{state}, 'failed', 'failed map reported as failed');
+ok(!exists $luns->{ $a->{wwid} }->{transport}, 'transport omitted when not derived');
+
+($luns, $unmanaged) = PVE::Multipath::Status::status_luns({}, []);
+is_deeply($luns, {}, 'nothing wanted and nothing assembled reports nothing');
+
+($luns, $unmanaged) = PVE::Multipath::Status::status_luns(
+ { $a->{wwid} => { storages => ['san1'], 'paths-expected' => 4 }, w404 => {} }, $maps,
+);
+is_deeply([sort keys %$luns], [sort($a->{wwid}, 'w404')], 'only wanted LUNs are listed');
+is($unmanaged, 2, 'other maps are counted as unmanaged');
+is(
+ $luns->{ $a->{wwid} }->{state},
+ 'degraded',
+ 'two active paths of four expected read as degraded, the missing ones are gone from the total',
+);
+is_deeply($luns->{ $a->{wwid} }->{storages}, ['san1'], 'the deriving storages are carried');
+is_deeply($luns->{w404}, {}, 'a wanted LUN without a map is listed without a state');
+($luns) =
+ PVE::Multipath::Status::status_luns({ w404 => { storages => ['san1'], derived => 0 } }, $maps);
+is_deeply(
+ $luns->{w404},
+ { storages => ['san1'], derived => 0 },
+ 'whether the storage still derives the LUN is carried',
+);
+($luns) = PVE::Multipath::Status::status_luns({ $a->{wwid} => { 'paths-expected' => 2 } }, $maps);
+is($luns->{ $a->{wwid} }->{state}, 'optimal', 'two of two expected stay optimal');
+
+my $enriched = [{
+ wwid => '3600x',
+ health => 'optimal',
+ 'paths-active' => 2,
+ 'paths-total' => 2,
+ transport => 'iscsi',
+ size => 34359738368,
+}];
+($luns) = PVE::Multipath::Status::status_luns({ '3600x' => { kept => 'LVM' } }, $enriched);
+is($luns->{'3600x'}->{transport}, 'iscsi', 'transport carried into the report');
+is($luns->{'3600x'}->{size}, 34359738368, 'size carried into the report');
+is($luns->{'3600x'}->{kept}, 'LVM', 'what keeps an unconfigured LUN is carried');
+
+# --- encoding within the KV size limit ---
+my $small = PVE::Multipath::Status::encode_status({
+ supported => 1,
+ running => 1,
+ luns => { w1 => { state => 'optimal' } },
+ 'apply-error' => 'x' x 5000,
+});
+my $decoded = decode_json($small);
+is_deeply($decoded->{luns}, { w1 => { state => 'optimal' } }, 'a small status keeps its LUNs');
+ok(length($decoded->{'apply-error'}) < 2100, 'an oversized apply error is cut down');
+
+my $many = {
+ map { (
+ sprintf('3600140500000000000000000000%04x', $_) => {
+ storages => ['san1'],
+ 'paths-expected' => 4,
+ state => $_ % 100 ? 'optimal' : 'degraded',
+ 'paths-active' => 4,
+ 'paths-total' => 4,
+ transport => 'iscsi',
+ size => 1099511627776,
+ },
+ ) } 0 .. 999
+};
+$many->{wNone} = {};
+my $big = PVE::Multipath::Status::encode_status({ supported => 1, running => 1, luns => $many });
+ok(length($big) <= 30 * 1024, 'a large status stays within the KV limit');
+$decoded = decode_json($big);
+ok(!exists $decoded->{luns}, 'a large status sends no partial LUN list');
+is_deeply(
+ $decoded->{summary},
+ { luns => 1001, optimal => 990, degraded => 10, missing => 1 },
+ 'but how many LUNs are in which state',
+);
+
done_testing();
--
2.47.3
next prev parent reply other threads:[~2026-11-06 0:58 UTC|newest]
Thread overview: 77+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-05 23:30 [PATCH v3 proxmox,proxmox-perl-rs,cluster,storage,docs,manager 00/76] SAN: cluster-wide multipath, SAN views and a guided storage setup Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox 01/76] add proxmox-multipath crate Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox-perl-rs 02/76] pve-rs: add PVE::RS::Multipath bindings for multipathd status parsing Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 cluster 03/76] cfs: observe the multipath configuration files Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 04/76] storage: distinguish status-only activation in plugins Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 05/76] lvm: find a shared volume group created by another node Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 06/76] storage: bound LVM status commands Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 07/76] iscsi: rate-limit session rescans per target Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 08/76] iscsi: list the LUNs of each session, not of its SCSI host Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 09/76] lvm: refuse to create a volume group on a device that holds data Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 10/76] lvm: refuse a base volume of a target that serves disk images Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 11/76] iscsi: refuse virtual devices other than multipath maps as volumes Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 12/76] diskmanage: collect disk transport type from lsblk Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 13/76] diskmanage: add helper to list multipath devices Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 14/76] diskmanage: qualify NVMe over fabrics transport Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 15/76] disks: list: add include-remote parameter Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 16/76] diskmanage: include iSCSI session devices in disk enumeration Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 17/76] diskmanage: link multipath member disks to their map device Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 18/76] iscsi: factor out session device map from device list Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 19/76] api: scan: add a SAN LUN inventory Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 20/76] pvesm: expose the " Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 21/76] disks: lvm: allow volume groups on SAN LUNs and multipath maps Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 22/76] multipath: add a library around multipathd Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 23/76] diskmanage: collect native NVMe multipath path state Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 24/76] api: scan: san-luns: report " Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 25/76] diskmanage: collect local block device users Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 26/76] api: add node-level iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 27/76] api: iscsi: add session logout with last-path protection Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 28/76] multipath: add a cluster-wide configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 29/76] iscsi: add an option to manage storage LUNs with multipath Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 30/76] lvm: activate a missing base volume before using the volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 31/76] multipath: apply the configuration on every node Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 32/76] iscsi: share the initiator identity reader Thomas Lamprecht
2026-11-05 23:31 ` Thomas Lamprecht [this message]
2026-11-05 23:31 ` [PATCH v3 storage 34/76] api: add endpoints for the cluster-wide multipath configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 35/76] api: multipath: add the cluster-wide health status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 36/76] multipath: collect identities of non-iSCSI SCSI paths Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 37/76] api: scan: san-luns: report the multipath state of each LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 38/76] api: scan: flag duplicate physical volumes on distinct LUNs Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 39/76] api: disks: recheck local users and retain wipe device references Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 40/76] iscsi: name plain LUNs after their identity link Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 41/76] api: disks: guard SAN wipes with stable identities and storage checks Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 42/76] api: scan: refresh SAN usage with bounded probes Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 43/76] storage: lvm: note the rescan after extending a shared volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 44/76] storage: document the cluster-wide multipath management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 45/76] api: ceph: osd: keep iSCSI LUNs out of OSD creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 46/76] pvestatd: track pending storage status commands across re-exec Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 47/76] ui: storage: factor out the scan picker error mask Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 48/76] ui: storage: nfs: show scan errors and empty results Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 49/76] ui: storage: allow changing the node for NFS/CIFS scans Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 50/76] ui: storage: add guided remote storage wizard with NFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 51/76] ui: storage wizard: add SMB/CIFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 52/76] ui: storage wizard: preserve settings after partial setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 54/76] ui: storage wizard: reconcile unanswered storage creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 55/76] ui: storage wizard: add ZFS over iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 56/76] ui: dc: storage: add remote storage wizard entry to the add menu Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 57/76] ui: node: add SAN LUNs panel Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 58/76] ui: san luns: show multipath path state Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 59/76] api: nodes: expose iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 60/76] pvenode: add iscsi commands Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 61/76] ui: node: san: add iSCSI target and session management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 62/76] pvestatd: apply the multipath configuration and report the node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 63/76] api: cluster: add the multipath endpoints Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 65/76] ui: storage: iscsi: add the multipath option Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 66/76] ui: san luns: show multipath state and manage LUN entries Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 67/76] ui: storage wizard: add iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 68/76] ui: storage wizard: iscsi: show cluster initiator names Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 69/76] ui: storage wizard: iscsi: enable multipath for shared LVM Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 70/76] ui: node: san: wipe a LUN that no storage uses Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 71/76] ui: storage wizard: iscsi: reuse existing target connections Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 72/76] ui: storage wizard: fibre channel: enable multipath for a LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 73/76] ui: storage wizard: let root@pam wipe a LUN with data Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 74/76] ui: storage wizard: do not offer LUNs in use for a new volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 75/76] ui: san: show the local users of a LUN in its usage tooltip Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 76/76] ui: san: check the other nodes for users before wiping a LUN Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261105233336.2178064-34-t.lamprecht@proxmox.com \
--to=t.lamprecht@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox