all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 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




  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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal