From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 576641FF0AF for ; Fri, 06 Nov 2026 01:53:06 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 21F1721904; Fri, 06 Nov 2026 01:51:03 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 storage 19/76] api: scan: add a SAN LUN inventory Date: Fri, 6 Nov 2026 00:30:54 +0100 Message-ID: <20261105233336.2178064-20-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com> References: <20261105233336.2178064-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926178525 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.326 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: UPTUTQJG2JOMDSUYLPLESGZHWO5P2V3V X-Message-ID-Hash: UPTUTQJG2JOMDSUYLPLESGZHWO5P2V3V X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Dietmar Maurer The storage wizard needs SAN devices suitable for shared LVM. The node disk list omits multipath maps and cannot alone associate devices with volume groups and configured storages. Return one entry per whole disk or multipath map, folding member paths into the map. Include transport, usage, volume-group names, and storage IDs so clients can identify candidates and exclude LUNs already used by configured storages. Also report storages using the LUN's target, preferring those serving raw VM disks, so clients can warn before initializing such a LUN. Signed-off-by: Dietmar Maurer [TL: build the inventory in the new PVE::Storage::SANScan module; leave out the placeholder 'unknown' that the disk scan fills in for a vendor, model, serial or WWN it could not read; attribute a LUN to any storage of its target, preferring one that serves disk images from it; name the path count paths-total; also allow reads with Sys.Audit on the node; clarify the transport description] Signed-off-by: Thomas Lamprecht --- src/PVE/API2/Storage/Scan.pm | 89 +++++++++++++++++++++++ src/PVE/Storage/Makefile | 3 +- src/PVE/Storage/SANScan.pm | 129 ++++++++++++++++++++++++++++++++++ src/test/Makefile | 5 +- src/test/run_san_api_tests.pl | 65 +++++++++++++++++ 5 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 src/PVE/Storage/SANScan.pm create mode 100755 src/test/run_san_api_tests.pl diff --git a/src/PVE/API2/Storage/Scan.pm b/src/PVE/API2/Storage/Scan.pm index a58bdd80..58570f46 100644 --- a/src/PVE/API2/Storage/Scan.pm +++ b/src/PVE/API2/Storage/Scan.pm @@ -9,9 +9,11 @@ use PVE::JSONSchema qw(get_standard_option); use PVE::RESTHandler; use PVE::SafeSyslog; +use PVE::Storage::ISCSIPlugin; use PVE::Storage::LVMPlugin; use PVE::Storage::LvmThinPlugin; use PVE::Storage::PBSPlugin; +use PVE::Storage::SANScan; use PVE::Storage; use base qw(PVE::RESTHandler); @@ -49,6 +51,7 @@ __PACKAGE__->register_method({ { method => 'lvm' }, { method => 'nfs' }, { method => 'pbs' }, + { method => 'san-luns' }, { method => 'zfs' }, ]; @@ -373,6 +376,92 @@ __PACKAGE__->register_method({ }, }); +__PACKAGE__->register_method({ + name => 'sanlunscan', + path => 'san-luns', + method => 'GET', + description => "List block devices (disks and multipath devices) together with their" + . " usage, for example as candidates for a SAN backed LVM volume group.", + protected => 1, + proxyto => "node", + permissions => { + check => [ + 'or', + ['perm', '/nodes/{node}', ['Sys.Audit']], + ['perm', '/storage', ['Datastore.Allocate']], + ], + }, + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + }, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => { + devpath => { + description => "The device path.", + type => 'string', + }, + size => { + description => "The device size in bytes.", + type => 'integer', + renderer => 'bytes', + optional => 1, + }, + transport => { + description => + "The device transport, for example 'sata', 'sas', 'usb', 'fc'," + . " 'iscsi', 'nvme', 'nvme-fc', or 'nvme-tcp'.", + type => 'string', + optional => 1, + }, + vendor => { type => 'string', optional => 1 }, + model => { type => 'string', optional => 1 }, + serial => { type => 'string', optional => 1 }, + wwn => { type => 'string', optional => 1 }, + 'paths-total' => { + description => "The number of paths of a multipath mapped device.", + type => 'integer', + optional => 1, + }, + usage => { + description => "How the device is currently used. 'lvm' means it is an LVM" + . " physical volume, see the 'vgname' property.", + type => 'string', + enum => ['unused', 'lvm', 'other'], + }, + vgname => { + description => "The name of the LVM volume group the device belongs to.", + type => 'string', + optional => 1, + }, + 'used-by-storage' => { + description => "The ID of an existing storage definition that uses this" + . " device, either through the LVM volume group on it or because it" + . " is a LUN of the storage's iSCSI target.", + type => 'string', + optional => 1, + }, + detail => { + description => + "Details about other usage, for example the file system type.", + type => 'string', + optional => 1, + }, + }, + }, + }, + code => sub { + my ($param) = @_; + + return PVE::Storage::SANScan::scan(PVE::Storage::config(), $param->{node}); + }, +}); + __PACKAGE__->register_method({ name => 'zfsscan', path => 'zfs', diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile index 47813553..f6906e5e 100644 --- a/src/PVE/Storage/Makefile +++ b/src/PVE/Storage/Makefile @@ -15,7 +15,8 @@ SOURCES= \ BTRFSPlugin.pm \ LvmThinPlugin.pm \ ESXiPlugin.pm \ - StatusCommand.pm + StatusCommand.pm \ + SANScan.pm .PHONY: install install: diff --git a/src/PVE/Storage/SANScan.pm b/src/PVE/Storage/SANScan.pm new file mode 100644 index 00000000..6e968f65 --- /dev/null +++ b/src/PVE/Storage/SANScan.pm @@ -0,0 +1,129 @@ +package PVE::Storage::SANScan; + +use v5.36; + +use PVE::Diskmanage; +use PVE::Storage; +use PVE::Storage::ISCSIPlugin; +use PVE::Storage::LVMPlugin; + +my sub collect_inventory($cfg, $node) { + my $disks = PVE::Diskmanage::get_disks(undef, 1, 0); + my $multipath = PVE::Diskmanage::get_multipath_disks(); + + # map PV device to VG name, PVs may sit on a partition of a listed device + my $vgs = PVE::Storage::LVMPlugin::lvm_vgs(1); + my $pv2vg = {}; + for my $vgname (sort keys %$vgs) { + for my $pv (@{ $vgs->{$vgname}->{pvs} // [] }) { + $pv2vg->{ $pv->{name} } = $vgname; + } + } + my $find_vg = sub($devpath) { + return $pv2vg->{$devpath} if defined($pv2vg->{$devpath}); + for my $pv (sort keys %$pv2vg) { + return $pv2vg->{$pv} if $pv =~ m/^\Q$devpath\Ep?\d+$/; + } + return undef; + }; + + my $vg_used_by = {}; + my $iscsi_target_used_by = {}; + my $serves_images = sub($sid) { + my $scfg = $cfg->{ids}->{$sid}; + return + $scfg->{content}->{images} + || $scfg->{type} eq 'iscsidirect' + || $scfg->{type} eq 'zfs'; + }; + my $attribute = sub($owners, $target, $sid) { + my $owner = $owners->{$target}; + $owners->{$target} = $sid + if !defined($owner) || ($serves_images->($sid) && !$serves_images->($owner)); + }; + for my $sid (sort keys %{ $cfg->{ids} }) { + my $scfg = $cfg->{ids}->{$sid}; + if (defined(my $vgname = $scfg->{vgname})) { + $vg_used_by->{$vgname} //= $sid; + } + if (defined(my $target = $scfg->{target})) { + $attribute->($iscsi_target_used_by, $target, $sid); + } + } + + # map kernel device name to the storage using its iSCSI target + my $iscsi_used_by = {}; + if (scalar(keys %$iscsi_target_used_by)) { + my $device_map = PVE::Storage::ISCSIPlugin::iscsi_session_device_map(); + for my $bdev (sort keys %$device_map) { + my $sid = $iscsi_target_used_by->{ $device_map->{$bdev}->{target} } // next; + $iscsi_used_by->{$bdev} = $sid; + } + } + return { + disks => $disks, + multipath => $multipath, + vg_used_by => $vg_used_by, + iscsi_target_used_by => $iscsi_target_used_by, + find_vg => $find_vg, + iscsi_used_by => $iscsi_used_by, + }; +} + +my sub build_lun_rows($cfg, $inventory) { + my ($disks, $multipath, $vg_used_by, $find_vg, $iscsi_used_by) = + $inventory->@{qw(disks multipath vg_used_by find_vg iscsi_used_by)}; + + my $records = []; + my $add_entry = sub($disk) { + my $entry = { devpath => $disk->{devpath}, size => $disk->{size} }; + # the disk scan fills in 'unknown' for what it could not read + $entry->{'paths-total'} = $disk->{paths} if defined($disk->{paths}); + for my $key (qw(transport vendor model serial wwn)) { + my $value = $disk->{$key}; + $entry->{$key} = $value if defined($value) && $value ne 'unknown'; + } + + if (!defined($disk->{used})) { + $entry->{usage} = 'unused'; + } elsif ($disk->{used} eq 'LVM') { + $entry->{usage} = 'lvm'; + if (defined(my $vgname = $find_vg->($disk->{devpath}))) { + $entry->{vgname} = $vgname; + if (defined(my $sid = $vg_used_by->{$vgname})) { + $entry->{'used-by-storage'} = $sid; + } + } + } else { + $entry->{usage} = 'other'; + $entry->{detail} = $disk->{used}; + } + if (!defined($entry->{'used-by-storage'})) { + my @members = + map { $_ =~ s|^/dev/||r } ($disk->{slaves} // [$disk->{devpath}])->@*; + for my $member (@members) { + if (defined(my $sid = $iscsi_used_by->{$member})) { + $entry->{'used-by-storage'} = $sid; + last; + } + } + } + push $records->@*, { row => $entry, disk => $disk }; + }; + for my $dev (sort keys %$disks) { + my $disk = $disks->{$dev}; + next if defined($disk->{multipath}); + $add_entry->($disk); + } + $add_entry->($multipath->{$_}) for sort keys %$multipath; + return $records; +} + +sub scan($cfg, $node) { + my $inventory = collect_inventory($cfg, $node); + my $records = build_lun_rows($cfg, $inventory); + + return [map { $_->{row} } $records->@*]; +} + +1; diff --git a/src/test/Makefile b/src/test/Makefile index ea11d399..26b6b12e 100644 --- a/src/test/Makefile +++ b/src/test/Makefile @@ -1,6 +1,6 @@ all: test -test: test_iscsi test_zfspoolplugin test_lvmplugin test_lvm_create test_lvm_lock_retry test_lvm_base_volume test_lvm_hints test_lvm_status test_status_command test_disklist test_disk_api test_bwlimit test_plugin test_ovf test_volume_access test_san_libraries +test: test_iscsi test_zfspoolplugin test_lvmplugin test_lvm_create test_lvm_lock_retry test_lvm_base_volume test_lvm_hints test_lvm_status test_status_command test_disklist test_disk_api test_bwlimit test_plugin test_ovf test_volume_access test_san_api test_san_libraries test_zfspoolplugin: run_test_zfspoolplugin.pl ./run_test_zfspoolplugin.pl @@ -47,6 +47,9 @@ test_ovf: run_ovf_tests.pl test_volume_access: run_volume_access_tests.pl ./run_volume_access_tests.pl +test_san_api: run_san_api_tests.pl + ./run_san_api_tests.pl + test_iscsi: run_iscsi_discovery_tests.pl run_iscsi_rescan_tests.pl run_iscsi_device_list_tests.pl run_iscsi_session_tests.pl run_iscsi_volume_tests.pl ./run_iscsi_discovery_tests.pl ./run_iscsi_rescan_tests.pl diff --git a/src/test/run_san_api_tests.pl b/src/test/run_san_api_tests.pl new file mode 100755 index 00000000..1490e3c2 --- /dev/null +++ b/src/test/run_san_api_tests.pl @@ -0,0 +1,65 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::MockModule; +use Test::More; + +use lib ('.', '..'); +use PVE::API2::Storage::Scan; + +subtest 'node SAN read permissions' => sub { + for my $case ( + ['PVE::API2::Storage::Scan', 'sanlunscan'], + ) { + my ($class, @methods) = @$case; + for my $name (@methods) { + is_deeply( + $class->map_method_by_name($name)->{permissions}->{check}, + [ + 'or', + ['perm', '/nodes/{node}', ['Sys.Audit']], + ['perm', '/storage', ['Datastore.Allocate']], + ], + "$class $name read gate", + ); + } + } +}; + +subtest 'SAN LUN path counts' => sub { + my $disk = Test::MockModule->new('PVE::Diskmanage'); + my $lvm = Test::MockModule->new('PVE::Storage::LVMPlugin'); + my $storage = Test::MockModule->new('PVE::Storage'); + my $disks = + { map { ("/dev/$_" => { devpath => "/dev/$_", size => 1024, multipath => 'm0' }) } + qw(sdb sdc) }; + $disk->mock( + get_disks => sub { $disks }, + get_multipath_disks => sub { + return { + '/dev/mapper/m0' => { + devpath => '/dev/mapper/m0', + size => 1024, + slaves => ['/dev/sdb', '/dev/sdc'], + paths => 2, + }, + }; + }, + ); + $lvm->mock(lvm_vgs => sub { {} }); + $storage->mock(config => sub { { ids => {} } }); + my $declared = + PVE::API2::Storage::Scan->map_method_by_name('sanlunscan')->{returns}->{items} + ->{properties}; + my $rows = PVE::API2::Storage::Scan->sanlunscan({ node => 'test' }); + my $by_dev = { map { ($_->{devpath} => $_) } @$rows }; + is($by_dev->{'/dev/mapper/m0'}->{'paths-total'}, 2, 'map path count'); + for my $row (@$rows) { + my @undeclared = grep { !$declared->{$_} } sort keys %$row; + is_deeply(\@undeclared, [], "$row->{devpath}: every field declared"); + } +}; + +done_testing(); -- 2.47.3