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 111D81FF0AF for ; Fri, 06 Nov 2026 01:54:54 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 0F29121AB7; Fri, 06 Nov 2026 01:51:11 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 storage 25/76] diskmanage: collect local block device users Date: Fri, 6 Nov 2026 00:31:00 +0100 Message-ID: <20261105233336.2178064-26-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: 1793926178888 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.165 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) POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes 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: 3YA76R4BIGTF5ZRJI62Q63DQGN4WMTET X-Message-ID-Hash: 3YA76R4BIGTF5ZRJI62Q63DQGN4WMTET 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: Destructive operations must reject devices with local users, but each caller checked only some forms of use. Share a check covering holders recursively, partitions, mounts, swap, loop and btrfs devices, LIO and nvmet exports, and processes with the device open. Failed probes are errors, not evidence that a device is unused. Run this check before each SAN volume-group initialization write through the disk API and storage-add base-volume path. A retry for the same volume group remains write-free. LVM-thin keeps its existing policy. Signed-off-by: Thomas Lamprecht --- src/PVE/Diskmanage.pm | 147 +++++++++++++++ src/PVE/Storage/LVMPlugin.pm | 17 +- src/test/Makefile | 6 +- src/test/disk_api_test.pm | 26 --- src/test/run_block_device_users_tests.pl | 134 +++++++++++++ src/test/run_san_lvm_create_tests.pl | 230 +++++++++++++++++++++++ 6 files changed, 531 insertions(+), 29 deletions(-) create mode 100755 src/test/run_block_device_users_tests.pl create mode 100755 src/test/run_san_lvm_create_tests.pl diff --git a/src/PVE/Diskmanage.pm b/src/PVE/Diskmanage.pm index a7eb2b21..655cee37 100644 --- a/src/PVE/Diskmanage.pm +++ b/src/PVE/Diskmanage.pm @@ -6,6 +6,7 @@ use warnings; use PVE::ProcFSTools; use Data::Dumper; use Cwd qw(abs_path); +use Errno qw(ENOENT ESRCH); use Fcntl ':mode'; use File::Basename; use File::stat; @@ -59,6 +60,152 @@ sub assert_blockdev { return 1; } +# Device numbers also identify aliases and open descriptors after a device node was renamed. +sub block_device_number { + my ($path) = @_; + + my @st = CORE::stat($path); + die "unable to stat '$path' - $!\n" if !@st; + return undef if !S_ISBLK($st[2]); + return PVE::Tools::dev_t_major($st[6]) . ':' . PVE::Tools::dev_t_minor($st[6]); +} + +sub block_device_users { + my ($devices, $options) = @_; + $options //= {}; + my $sys = $options->{sysfs} // '/sys'; + my $proc = $options->{procfs} // '/proc'; + + my $entries = sub { + my ($dir, $optional) = @_; + if (!opendir(my $dh, $dir)) { + return [] if $optional && ($! == ENOENT || $! == ESRCH); + die "unable to list '$dir' - $!\n"; + } else { + my @names = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh); + closedir($dh) or die "unable to close '$dir' - $!\n"; + return \@names; + } + }; + my $read = sub { + my ($file) = @_; + my $value = file_read_firstline($file); + die "unable to read '$file' - missing value\n" if !defined($value); + return $value; + }; + my ($numbers, $seen, $users) = ({}, {}, []); + my $visit; + $visit = sub { + my ($name) = @_; + die "invalid block device name '$name'\n" if $name !~ m/^[a-zA-Z0-9_.!-]+$/; + return if $seen->{$name}++; + my $dir = "$sys/class/block/$name"; + my $dev = $read->("$dir/dev"); + die "invalid device number for '$name'\n" if $dev !~ m/^\d+:\d+$/; + $numbers->{$dev} = $name; + for my $part ($entries->($dir)->@*) { + next if !-e "$dir/$part/partition"; + my $number = $read->("$dir/$part/partition"); + die "invalid partition number for '$part'\n" if $number !~ m/^[1-9]\d*$/; + $visit->($part); + } + for my $holder ($entries->("$dir/holders")->@*) { + push $users->@*, "$name held by $holder"; + $visit->($holder); + } + }; + $visit->($_) for sort $devices->@*; + undef $visit; + return $users if !%$numbers; + + my $mountinfo = file_get_contents("$proc/self/mountinfo"); + die "unable to check mounts - empty mountinfo\n" if !$mountinfo; + for my $line (split(/\n/, $mountinfo)) { + my (undef, undef, $dev, undef, $target) = split(/\s+/, $line); + die "unable to check mounts - malformed mountinfo\n" + if !defined($target) || $dev !~ m/^\d+:\d+$/ || $line !~ m/ - /; + push $users->@*, "$numbers->{$dev} mounted at $target" if defined($numbers->{$dev}); + } + + my $by_path = sub { + my ($path, $what) = @_; + return if $path eq ''; + $path =~ s/\\([0-7]{3})/chr(oct($1))/ge; + my $dev = block_device_number($path); + push $users->@*, "$numbers->{$dev} $what" + if defined($dev) && defined($numbers->{$dev}); + }; + my $swaps = file_get_contents("$proc/swaps"); + die "unable to check swap - malformed swaps\n" if $swaps !~ s/^Filename\s+[^\n]*\n//; + for my $line (split(/\n/, $swaps)) { + my ($path) = $line =~ m/^(\/\S+)\s+/; + die "unable to check swap - malformed entry\n" if !defined($path); + $by_path->($path, 'used as swap'); + } + + for my $loop (grep { /^loop\d+$/ } $entries->("$sys/class/block")->@*) { + next if !-d "$sys/class/block/$loop/loop"; + $by_path->($read->("$sys/class/block/$loop/loop/backing_file"), "backs $loop"); + } + for my $fsid ($entries->("$sys/fs/btrfs", 1)->@*) { + next if $fsid !~ m/^[0-9a-f-]{36}$/; + my $dir = "$sys/fs/btrfs/$fsid/devices"; + for my $device ($entries->($dir)->@*) { + my $dev = $read->("$dir/$device/dev"); + die "invalid btrfs device number\n" if $dev !~ m/^\d+:\d+$/; + push $users->@*, "$numbers->{$dev} is a device of btrfs file system $fsid" + if defined($numbers->{$dev}); + } + } + for my $target ( + ['nvmet', "$sys/kernel/config/nvmet/subsystems"], + ['target_core_mod', "$sys/kernel/config/target/core"], + ) { + my ($module, $dir) = $target->@*; + my $optional = !-d "$sys/module/$module"; + for my $name ($entries->($dir, $optional)->@*) { + if ($module eq 'nvmet') { + for my $ns ($entries->("$dir/$name/namespaces")->@*) { + $by_path->( + $read->("$dir/$name/namespaces/$ns/device_path"), + "exported by NVMe target subsystem '$name'", + ); + } + } elsif ($name =~ m/^(?:iblock|pscsi)_\d+$/) { + for my $store ($entries->("$dir/$name")->@*) { + next if !-d "$dir/$name/$store"; + $by_path->( + $read->("$dir/$name/$store/udev_path"), + "exported by LIO backstore '$store'", + ); + } + } + } + } + + for my $pid (grep { /^\d+$/ && $_ != $$ } $entries->($proc)->@*) { + for my $fd ($entries->("$proc/$pid/fd", 1)->@*) { + my $file = "$proc/$pid/fd/$fd"; + my $target = readlink($file); + if (!defined($target)) { + next if $! == ENOENT || $! == ESRCH; + die "unable to read '$file' - $!\n"; + } + # A process can open a device node outside /dev, including in another mount namespace. + next if $target !~ m|^/|; + my $dev = eval { block_device_number($file) }; + if (my $err = $@) { + next if $! == ENOENT || $! == ESRCH; + die $err; + } + next if !defined($dev) || !defined($numbers->{$dev}); + my $comm = file_read_firstline("$proc/$pid/comm") // '?'; + push $users->@*, "$comm (PID $pid) has $numbers->{$dev} open"; + } + } + return $users; +} + sub init_disk { my ($disk, $uuid) = @_; diff --git a/src/PVE/Storage/LVMPlugin.pm b/src/PVE/Storage/LVMPlugin.pm index 916f1b8f..723ed555 100644 --- a/src/PVE/Storage/LVMPlugin.pm +++ b/src/PVE/Storage/LVMPlugin.pm @@ -166,6 +166,13 @@ sub lvm_create_volume_group { my $check_instance = sub { $guard->{verify}->() if $guard && $guard->{verify} }; my $check_safe = sub { $check_instance->(); + if ($san) { + my $users = PVE::Diskmanage::block_device_users( + [basename($device)], { 'partition-maps' => 1 }, + ); + die "cannot use '$device' - " . join(', ', $users->@*) . "\n" if $users->@*; + $check_instance->(); + } }; my $refuse_data = sub { my (@types) = @_; @@ -1089,7 +1096,15 @@ sub on_add_hook { # The storage API runs this hook under the cluster-wide storage configuration lock, which # the disk API also takes for shared devices, so two nodes cannot initialize the same LUN. - lvm_create_volume_group($path, $scfg->{vgname}, $scfg->{shared}); + lvm_create_volume_group( + $path, + $scfg->{vgname}, + $scfg->{shared}, + { + reference => $path, + san => 1, + }, + ); } assert_discard_supported($scfg->{vgname}, $scfg->{'on-volume-remove'}, $scfg->{nodes}); diff --git a/src/test/Makefile b/src/test/Makefile index c287ebdd..9962bdfd 100644 --- a/src/test/Makefile +++ b/src/test/Makefile @@ -9,9 +9,10 @@ test_lvmplugin: run_test_lvmplugin.pl run_test_lvm_throughput.pl ./run_test_lvmplugin.pl ./run_test_lvm_throughput.pl -test_lvm_create: run_test_lvm_create.pl run_test_lvm_base_conflicts.pl +test_lvm_create: run_test_lvm_create.pl run_test_lvm_base_conflicts.pl run_san_lvm_create_tests.pl ./run_test_lvm_create.pl ./run_test_lvm_base_conflicts.pl + ./run_san_lvm_create_tests.pl test_lvm_lock_retry: run_test_lvm_lock_retry.pl ./run_test_lvm_lock_retry.pl @@ -29,8 +30,9 @@ test_status_command: run_status_command_tests.pl run_status_command_contention_t ./run_status_command_tests.pl ./run_status_command_contention_tests.pl -test_disklist: run_disk_tests.pl +test_disklist: run_disk_tests.pl run_block_device_users_tests.pl ./run_disk_tests.pl + ./run_block_device_users_tests.pl test_disk_api: run_disk_api_tests.pl ./run_disk_api_tests.pl diff --git a/src/test/disk_api_test.pm b/src/test/disk_api_test.pm index 893b4e04..d1fc6465 100644 --- a/src/test/disk_api_test.pm +++ b/src/test/disk_api_test.pm @@ -162,32 +162,6 @@ subtest 'a volume group on a map is created under the storage configuration lock ], 'a map or SAN LUN is initialized under the cluster-wide lock, a local disk is not', ); - @created = (); - $disk->mock(assert_disk_unused => sub { die "device is already in use\n" }); - eval { - PVE::API2::Disks::LVM->create({ - node => 'n1', - name => 'vg', - device => '/dev/sdc', - }); - }; - like($@, qr/already in use/, 'disk API rejects a used inventory entry'); - is_deeply(\@created, [], 'used inventory prevents initialization'); - $disk->mock(assert_disk_unused => sub { }); - $lvm->mock(lvm_vgs => sub { { vg => {} } }); - eval { - PVE::API2::Disks::LVM->create({ - node => 'n1', - name => 'vg', - device => '/dev/sdc', - }); - }; - like( - $@, - qr/volume group with name 'vg' already exists/, - 'stale inventory does not permit an existing VG', - ); - is_deeply(\@created, [], 'existing VG prevents initialization'); $disk->unmock($_) for qw( locked_disk_action verify_blockdev_path assert_disk_unused is_partition diff --git a/src/test/run_block_device_users_tests.pl b/src/test/run_block_device_users_tests.pl new file mode 100755 index 00000000..5a27b503 --- /dev/null +++ b/src/test/run_block_device_users_tests.pl @@ -0,0 +1,134 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use File::Path qw(make_path remove_tree); +use File::Temp qw(tempdir); +use Test::MockModule; +use Test::More; + +use lib ('.', '..'); +use PVE::Diskmanage; + +my $root = tempdir(CLEANUP => 1); +my $options = { sysfs => "$root/sys", procfs => "$root/proc" }; +my $disk = Test::MockModule->new('PVE::Diskmanage'); +my %numbers = ('/dev/alias' => '8:1'); +$disk->mock( + block_device_number => sub { + my ($path) = @_; + die "stat failed\n" if !exists($numbers{$path}); + return $numbers{$path}; + }, +); + +my sub write_file { + my ($path, $value) = @_; + my ($dir) = "$root$path" =~ m{^(.*)/[^/]+$}; + make_path($dir); + open(my $fh, '>', "$root$path") or die $!; + print $fh $value; + close($fh) or die $!; +} + +my sub device { + my ($name, $number, $parent) = @_; + write_file("/sys/class/block/$name/dev", "$number\n"); + make_path("$root/sys/class/block/$name/holders"); + if ($parent) { + write_file("/sys/class/block/$name/partition", "1\n"); + symlink("../$name", "$root/sys/class/block/$parent/$name") or die $!; + } +} + +my sub users { + return PVE::Diskmanage::block_device_users(['sda'], $options); +} + +write_file('/proc/self/mountinfo', "1 0 0:1 / / rw - rootfs rootfs rw\n"); +write_file('/proc/swaps', "Filename\tType\tSize\tUsed\tPriority\n"); +device('sda', '8:0'); +device('sda1', '8:1', 'sda'); +is_deeply(users(), [], 'unused disk and partition'); +write_file('/proc/self/mountinfo', "1 0 8:1 / /mnt rw - ext4 /dev/alias rw\n"); +is_deeply(users(), ['sda1 mounted at /mnt'], 'mounts matched by number'); +write_file('/proc/self/mountinfo', "1 0 0:1 / / rw - rootfs rootfs rw\n"); +write_file('/proc/swaps', "Filename\tType\tSize\tUsed\tPriority\n/dev/alias partition 1 0 -2\n"); +is_deeply(users(), ['sda1 used as swap'], 'swap alias'); +write_file('/proc/swaps', "Filename\tType\tSize\tUsed\tPriority\n"); + +device('dm-0', '253:0'); +device('dm-1', '253:1', 'dm-0'); +symlink('../../dm-0', "$root/sys/class/block/sda/holders/dm-0") or die $!; +is_deeply(users(), ['sda held by dm-0'], 'recursive holder'); +write_file('/proc/self/mountinfo', "1 0 253:1 / /mapped rw - ext4 /dev/mapper/part rw\n"); +is_deeply(users(), ['sda held by dm-0', 'dm-1 mounted at /mapped'], 'map partition by attribute'); +write_file('/proc/self/mountinfo', "1 0 0:1 / / rw - rootfs rootfs rw\n"); + +make_path("$root/proc/999999/fd"); +symlink('/dev/alias', "$root/proc/999999/fd/3") or die $!; +$numbers{"$root/proc/999999/fd/3"} = '8:1'; +write_file('/proc/999999/comm', "qemu\n"); +is_deeply( + PVE::Diskmanage::block_device_users( + ['sda'], $options, + ), + ['sda held by dm-0', 'qemu (PID 999999) has sda1 open'], + 'holders and direct users of a member partition are both listed', +); +unlink("$root/proc/999999/fd/3") or die $!; +symlink('/srv/raw-disk', "$root/proc/999999/fd/3") or die $!; +is_deeply( + PVE::Diskmanage::block_device_users( + ['sda'], $options, + ), + ['sda held by dm-0', 'qemu (PID 999999) has sda1 open'], + 'device node outside /dev is not silently skipped', +); +unlink("$root/proc/999999/fd/3") or die $!; + +write_file('/sys/class/block/loop0/loop/backing_file', "/dev/alias\n"); +write_file('/sys/kernel/config/nvmet/subsystems/test/namespaces/1/device_path', "/dev/alias\n"); +write_file('/sys/kernel/config/target/core/iblock_0/test/udev_path', "/dev/alias\n"); +my $fsid = '00000000-0000-0000-0000-000000000001'; +write_file("/sys/fs/btrfs/$fsid/devices/sda1/dev", "8:1\n"); +is_deeply( + users(), + [ + 'sda held by dm-0', + 'sda1 backs loop0', + "sda1 is a device of btrfs file system $fsid", + "sda1 exported by NVMe target subsystem 'test'", + "sda1 exported by LIO backstore 'test'", + ], + 'kernel users without process descriptors', +); + +unlink("$root/sys/class/block/sda1/dev") or die $!; +eval { users() }; +like($@, qr/missing value/, 'missing partition identity fails closed'); +write_file('/sys/class/block/sda1/dev', "8:1\n"); +remove_tree("$root/sys/class/block/sda1/holders"); +eval { users() }; +like($@, qr/unable to list/, 'missing holders directory fails closed'); +make_path("$root/sys/class/block/sda1/holders"); +write_file('/proc/self/mountinfo', 'garbage'); +eval { users() }; +like($@, qr/malformed mountinfo/, 'bad mountinfo fails closed'); +write_file('/proc/self/mountinfo', "1 0 0:1 / / rw - rootfs rootfs rw\n"); +write_file('/proc/swaps', 'garbage'); +eval { users() }; +like($@, qr/malformed swaps/, 'bad swaps fails closed'); +write_file('/proc/swaps', "Filename\tType\tSize\tUsed\tPriority\n"); +$numbers{'/dev/alias'} = undef; +delete $numbers{'/dev/alias'}; +eval { users() }; +like($@, qr/stat failed/, 'unreadable backing device fails closed'); +$numbers{'/dev/alias'} = '8:1'; +remove_tree("$root/sys/kernel/config/nvmet"); +make_path("$root/sys/module/nvmet"); +eval { users() }; +like($@, qr/unable to list.*nvmet/, 'loaded target without readable configfs fails closed'); + +done_testing(); diff --git a/src/test/run_san_lvm_create_tests.pl b/src/test/run_san_lvm_create_tests.pl new file mode 100755 index 00000000..200fd7cb --- /dev/null +++ b/src/test/run_san_lvm_create_tests.pl @@ -0,0 +1,230 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use File::Basename qw(basename); +use File::Path qw(make_path); +use File::Temp qw(tempdir); +use JSON qw(encode_json); +use Storable qw(dclone); +use Test::MockModule; +use Test::More; + +use lib ('.', '..'); +use PVE::API2::Disks::LVM; +use PVE::API2::Storage::Config; + +my $disk = Test::MockModule->new('PVE::Diskmanage'); +my $storage = Test::MockModule->new('PVE::Storage'); +my $lvm = Test::MockModule->new('PVE::Storage::LVMPlugin'); +my $rpc = Test::MockModule->new('PVE::RPCEnvironment'); +my $inotify = Test::MockModule->new('PVE::INotify'); +my $tools = Test::MockModule->new('PVE::Tools'); +my $glob = \&PVE::Tools::dir_glob_foreach; +my $users = \&PVE::Diskmanage::block_device_users; +my ($root, $resolved, $worker, $cfg, $local, $pv, $during_probe, $inventory_used); +my (@locks, @writes); +my $uuid = 'aaaaaa-bbbb-cccc-dddd-eeee-ffff-gggggg'; +my $canonical = '/dev/mapper/lun'; +my sub put { + my ($path, $value) = @_; + my ($dir) = "$root/$path" =~ m{^(.*)/[^/]+$}; + make_path($dir); + open(my $fh, '>', "$root/$path") or die $!; + print $fh "$value\n"; + close($fh) or die $!; +} +my sub locked { + my ($name, $code) = @_; + push @locks, $name; + my $res = eval { $code->() }; + my $err = $@; + pop @locks; + die $err if $err; + return $res; +} +my sub written { + my ($tool, $path) = @_; + push @writes, [$tool, $path]; +} +{ + + package SanLVMTestRPC; + sub get_user { 'root@pam' } + sub check { 1 } + sub fork_worker { $worker = $_[4]; 'test-upid' } +} +$rpc->mock(get => sub { bless {}, 'SanLVMTestRPC' }); +$inotify->mock(nodename => sub { 'n1' }); +$disk->mock( + verify_blockdev_path => sub { $_[0] =~ m{^/dev/mapper/} ? $resolved : $_[0] }, + is_san_device => sub { !$local }, + is_multipath_device => sub { !$local && $_[0] =~ /dm-/ }, + is_partition => sub { 0 }, + block_device_users => sub { + $users->($_[0], { %{ $_[1] // {} }, sysfs => "$root/sys", procfs => "$root/proc" }); + }, + block_device_number => sub { + my $path = readlink($_[0]) // $_[0]; + return PVE::Tools::file_read_firstline( + "$root/sys/class/block/" . basename($path) . '/dev'); + }, + is_mounted => sub { undef }, + has_holder => sub { undef }, + assert_disk_unused => sub { die "device '$_[0]' is already in use\n" if $inventory_used }, + locked_disk_action => sub { locked('disk', $_[0]) }, + udevadm_trigger => sub { }, +); +$storage->mock( + lock_storage_config => sub { + die "no quorum\n" if $local; + ok( + !@locks || join('>', @locks) eq 'disk', + 'storage lock never precedes a nested disk lock', + ); + locked('storage', $_[0]); + }, + config => + sub { is($locks[-1], 'storage', 'configuration read under storage lock'); dclone($cfg) }, + write_config => sub { }, + path => sub { $canonical }, + activate_storage => sub { }, + activate_volumes => sub { }, +); +$tools->mock( + dir_glob_foreach => sub { + my $path = $_[0]; + $path =~ s{^/sys}{$root/sys}; + $glob->($path, $_[1], $_[2]); + }, +); +$lvm->mock( + file_read_firstline => sub { + my $path = $_[0]; + $path =~ s{^/sys}{$root/sys}; + PVE::Tools::file_read_firstline($path); + }, + abs_path => sub { $_[0] }, + clear_first_sector => sub { written('clear', $_[0]) }, + lvm_vgs => sub { $pv eq 'same' ? { vg => {} } : {} }, + assert_discard_supported => sub { }, + run_command => sub { + my ($cmd, %opt) = @_; + if ($cmd->[0] eq 'wipefs') { + $opt{outfunc} + ->(encode_json({ signatures => $pv eq 'blank' ? [] : [{ type => 'LVM2_member' }] }) + ); + } elsif ($cmd->[0] eq '/usr/bin/file') { + $opt{outfunc}->("$cmd->[-1]: " . ($pv eq 'blank' ? 'data' : 'LVM2 PV')); + $during_probe->() if $during_probe; + } elsif ($cmd->[0] eq '/sbin/pvs') { + $opt{outfunc}->("$resolved:8192:" + . ($pv eq 'same' ? 'vg' : '') + . ":$uuid:" + . ($pv eq 'same' ? '1' : '0')); + } else { + written($cmd->[0], $cmd->[-1]); + $pv = 'orphan' if $cmd->[0] eq '/sbin/pvcreate'; + } + }, +); +my sub reset_case { + $root = tempdir(CLEANUP => 1); + for my $name ('dm-900', 'vdz') { + put("sys/class/block/$name/dev", $name eq 'vdz' ? '252:0' : '253:900'); + put("sys/class/block/$name/size", '8192'); + make_path("$root/sys/class/block/$name/holders"); + } + put('sys/class/block/dm-900/dm/uuid', 'mpath-test'); + put('sys/class/block/dm-900/slaves/sda/device/state', 'running'); + put('proc/self/mountinfo', '1 0 0:1 / / rw - rootfs rootfs rw'); + put('proc/swaps', "Filename\tType\tSize\tUsed\tPriority"); + ($resolved, $local, $pv) = ('/dev/dm-900', 0, 'blank'); + ($during_probe, $worker) = (undef, undef); + $cfg = + { ids => { base => { type => 'iscsi', target => 'iqn.test', content => { none => 1 } } } }; + @writes = (); + @locks = (); + $inventory_used = 0; +} +my sub queue { + PVE::API2::Disks::LVM->create({ node => 'n1', name => 'vg', device => $_[0] // $canonical }); +} +my sub run_route { + my ($route) = @_; + eval { + if ($route eq 'disk') { + queue(); + $worker->(); + } else { + PVE::API2::Storage::Config->create({ + type => 'lvm', + storage => 'new', + vgname => 'vg', + base => 'base:0.0.1.lun', + }); + } + }; + return $@; +} +for my $route ('disk', 'base') { + reset_case(); + make_path("$root/proc/999999/fd"); + symlink('/dev/dm-900', "$root/proc/999999/fd/3") or die $!; + put('proc/999999/comm', 'qemu'); + like( + run_route($route), + qr/cannot use.*qemu .* has dm-900 open/, + "$route raw map opener refused", + ); + is_deeply(\@writes, [], 'raw user receives no destructive command'); + reset_case(); + unlink("$root/proc/swaps") or die $!; + like(run_route($route), qr{can't open '.*/proc/swaps'}, "$route inconclusive users refuse"); + is_deeply(\@writes, [], 'inconclusive users receive no destructive command'); + + for my $kind ('blank', 'orphan', 'same') { + reset_case(); + $pv = $kind; + if ($kind eq 'same' && $route eq 'disk') { + $inventory_used = 1; + like( + run_route($route), + qr/device .* is already in use/, + 'disk API rejects the used inventory entry for an existing VG', + ); + is_deeply(\@writes, [], 'used disk inventory prevents every write'); + $inventory_used = 0; + like( + run_route($route), + qr/volume group with name 'vg' already exists/, + 'disk API rejects an existing VG even with a stale unused hint', + ); + is_deeply(\@writes, [], 'existing VG prevents every disk API write'); + } else { + is(run_route($route), '', "$route $kind succeeds"); + is( + scalar(@writes), + $kind eq 'same' ? 0 : 3, + 'only the base-route same-VG retry succeeds without writes', + ); + } + } +} +reset_case(); +$local = 1; +$resolved = '/dev/vdz'; +queue('/dev/vdz'); +eval { $worker->() }; +is($@, '', 'local initialization works without quorum'); +is(scalar(@writes), 3, 'local behavior keeps its writes'); +reset_case(); +$local = 1; +queue('/dev/vdz'); +$local = 0; +eval { $worker->() }; +like($@, qr/cannot use.*device topology changed/, 'queued local-to-SAN transition refuses'); +is_deeply(\@writes, [], 'transition does not bypass storage lock'); + +done_testing(); -- 2.47.3