2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-10-21 15:18:19 +00:00

test(provisioning): isolate install disk selection checks

This commit is contained in:
Vinícius Ferrão
2026-10-09 17:43:49 -03:00
parent d5da7b0b5c
commit 509a60def2
5 changed files with 295 additions and 181 deletions
+7
View File
@@ -29,6 +29,13 @@ jobs:
- name: Check Debian probe payload permissions
if: ${{ !cancelled() }}
run: bats xCAT-test/native/probe_debian_permissions.bats
- name: Test isolated system scripts
if: ${{ !cancelled() }}
run: |
workdir=$(sudo mktemp -d /tmp/xcat-script-tests.XXXXXX)
trap 'sudo rm -rf -- "$workdir"' EXIT
sudo cp -R xCAT-test xCAT-server perl-xCAT xCAT "$workdir/"
sudo sh -c 'cd "$1" && prove -v xCAT-test/native/getinstdisk_selection.t' sh "$workdir"
- name: Prepare xCAT syslog directory
if: ${{ !cancelled() }}
run: sudo install -d -o syslog -g adm -m 0750 /var/log/xcat
+51
View File
@@ -0,0 +1,51 @@
package XCAT::Test::Sandbox;
use strict;
use warnings;
use Exporter qw(import);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use XCAT::Test::Process qw(run_command);
our @EXPORT_OK = qw(sandbox_root sandbox_run);
sub sandbox_root {
my $root = tempdir(CLEANUP => 1);
make_path(map { "$root/$_" } qw(bin boot etc log target tmp));
return $root;
}
sub sandbox_run {
my ($root, @command) = @_;
my $options = ref($command[0]) eq 'HASH' ? shift @command : {};
my $bwrap = -x '/usr/bin/bwrap' ? '/usr/bin/bwrap' : '/bin/bwrap';
die 'Install bubblewrap to run this test' unless -x $bwrap;
my @sandbox = ($bwrap, '--unshare-all', '--die-with-parent', '--new-session',
'--tmpfs', '/', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/run',
'--setenv', 'PATH', '/fixture/bin:/usr/bin:/bin',
'--setenv', 'LC_ALL', 'C', '--bind', $root, '/fixture', '--chdir', '/fixture');
for my $directory (qw(/usr /bin /sbin /lib /lib64)) {
push @sandbox, '--ro-bind', $directory, $directory if -d $directory;
}
for my $mount (qw(boot etc target tmp)) {
push @sandbox, '--bind', "$root/$mount", "/$mount";
}
push @sandbox, '--bind', "$root/log", '/var/log';
push @sandbox, '--ro-bind', '/etc/alternatives', '/etc/alternatives' if -d '/etc/alternatives';
my @mounts;
for my $kind (qw(read_only writable)) {
my $flag = $kind eq 'read_only' ? '--ro-bind' : '--bind';
push @mounts, map { [$flag, $_, $options->{$kind}{$_}] }
keys %{$options->{$kind} // {}};
}
for my $mount (sort { length($a->[2]) <=> length($b->[2]) || $a->[2] cmp $b->[2] } @mounts) {
push @sandbox, @$mount;
}
for my $name (keys %{$options->{env} // {}}) {
push @sandbox, '--setenv', $name, $options->{env}{$name};
}
local %ENV;
return run_command(@sandbox, '/usr/bin/timeout', '--kill-after=5', '45', @command);
}
1;
+79
View File
@@ -0,0 +1,79 @@
package XCAT::Test::Template;
use strict;
use warnings;
use Exporter qw(import);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use File::Slurper qw(write_text);
use JSON::PP qw(encode_json);
use XCAT::Test::File qw(repo_path);
use XCAT::Test::Sandbox qw(sandbox_root sandbox_run);
our @EXPORT_OK = qw(template_database set_row render_template);
sub template_database {
my $dir = tempdir(CLEANUP => 1);
make_path("$dir/db", "$dir/install");
symlink(repo_path('xCAT/postscripts'), "$dir/install/postscripts") or die $!;
$ENV{XCATROOT} = repo_path('xCAT-server');
$ENV{XCATCFG} = "SQLite:$dir/db";
require xCAT::Table;
require xCAT::Template;
require xCAT::Postage;
my %site = (
installdir => "$dir/install", tftpdir => "$dir/tftpboot",
master => '192.0.2.1', timezone => 'UTC', domain => 'example.invalid',
xcatiport => 3002, xcatdport => 3001, httpport => 8080,
xcatdebugmode => 0, nodestatus => 1, secureroot => 1,
managedaddressmode => 'dhcp',
);
set_row('site', {key => $_}, {value => $site{$_}}) for keys %site;
%::XCATSITEVALS = %site;
set_row('nodelist', {node => 'node'}, {groups => 'all'});
set_row('noderes', {node => 'node'}, {
xcatmaster => '192.0.2.1', nfsserver => '192.0.2.1', installnic => 'mac',
});
set_row('mac', {node => 'node'}, {mac => '52:54:00:12:34:56'});
set_row('bootparams', {node => 'node'}, {kcmdline => 'console=ttyS0 quiet'});
return $dir;
}
sub set_row {
my ($name, $key, $values) = @_;
my $table = xCAT::Table->new($name, -create => 1) or die "Cannot create $name";
$table->setAttribs($key, $values);
$table->close();
return;
}
sub render_template {
my ($database, @arguments) = @_;
my $root = sandbox_root();
write_text("$root/arguments.json", encode_json(\@arguments));
write_text("$root/render.pl", <<'PERL');
use strict;
use warnings;
use File::Slurper qw(read_text);
use JSON::PP qw(decode_json);
use xCAT::Template;
use xCAT::Postage;
my $site = xCAT::Table->new('site');
my @attributes = $site->getAllAttribs('key', 'value');
%::XCATSITEVALS = map { $_->{key} => $_->{value} } @attributes;
$site->close();
my $arguments = decode_json(read_text('/fixture/arguments.json'));
my $error = xCAT::Template->subvars(@$arguments);
die "$error\n" if $error;
PERL
my $source = repo_path('.');
my ($status, $output) = sandbox_run($root, {
read_only => {$source => $source, repo_path('xCAT/postscripts') => '/install/postscripts'},
writable => {$database => $database},
env => {XCATROOT => $ENV{XCATROOT}, XCATCFG => $ENV{XCATCFG}},
}, $^X, '-I' . repo_path('perl-xCAT'), '-I' . repo_path('xCAT-server/lib/perl'), '/fixture/render.pl');
die "Template rendering failed ($status): $output" if $status;
return;
}
1;
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/../lib";
use lib "$FindBin::Bin/../../perl-xCAT", "$FindBin::Bin/../../xCAT-server/lib/perl";
use File::Path qw(make_path);
use File::Slurper qw(read_binary write_binary);
use Test::More;
use XCAT::Test::File qw(repo_path);
use XCAT::Test::Sandbox qw(sandbox_root sandbox_run);
use XCAT::Test::Template qw(template_database set_row render_template);
plan skip_all => 'disk selection requires Linux namespaces' unless $^O eq 'linux';
BAIL_OUT('run as root to create isolated dummy block devices') if $>;
my $script = repo_path('xCAT-server/share/xcat/install/scripts/getinstdisk');
BAIL_OUT("missing disk selector: $script") unless -r $script;
my $database;
sub selects {
my ($expected, $name, $disks, %options) = @_;
subtest $name => sub {
my $root = sandbox_root();
make_path("$root/props", "$root/dev/md");
my $partitions = "major minor #blocks name\n\n";
my $minor = 0;
for my $disk (sort keys %$disks) {
my $attr = $disks->{$disk};
$partitions .= sprintf " 8 %5d 524288000 %s\n", $minor++, $disk;
my $properties = "DEVTYPE=disk\n";
$properties .= "ID_WWN=$attr->{wwn}\n" if $attr->{wwn};
$properties .= "DEVPATH=$attr->{path}\n" if $attr->{path};
write_binary("$root/props/$disk.props", $properties);
my $size = $attr->{size} // 1024000000;
my $attributes = qq{ ATTRS{size}=="$size"\n};
$attributes .= qq{ DRIVERS=="$attr->{driver}"\n} if $attr->{driver};
write_binary("$root/props/$disk.attrs", $attributes);
}
write_binary("$root/partitions", $partitions);
write_binary("$root/bin/udevadm", <<'SH');
#!/bin/sh
name=
query=
while [ "$#" -gt 0 ]; do
case "$1" in
info) shift ;;
--name=*) name=${1#--name=}; shift ;;
--name|-n) name=$2; shift 2 ;;
--query=*) query=${1#--query=}; shift ;;
--query|-q) query=$2; shift 2 ;;
--attribute-walk|-a) query=attributes; shift ;;
*) exit 97 ;;
esac
done
name=${name#/dev/}
case "$query" in
property) cat "/fixture/props/$name.props" ;;
attributes) cat "/fixture/props/$name.attrs" ;;
*) exit 97 ;;
esac
SH
chmod 0755, "$root/bin/udevadm" or die $!;
for my $device (@{$options{blocks} // []}) {
system('mknod', "$root/dev/$device", 'b', '240', '0') == 0 or die "mknod: $?";
}
write_binary("$root/dev/$_", '') for @{$options{regular} // []};
my %mounts = ($script => '/getinstdisk', "$root/partitions" => '/proc/partitions');
$mounts{"$root/dev/$_"} = "/dev/$_" for @{$options{blocks} // []}, @{$options{regular} // []};
if ($options{no_awk}) {
write_binary("$root/bin/find", "#!/bin/sh\nexit 0\n");
write_binary("$root/bin/awk", "#!/bin/sh\nprintf called >>/fixture/awk-calls\nexit 97\n");
chmod 0755, "$root/bin/find", "$root/bin/awk" or die $!;
}
my $command = $options{logger} ?
'msgutil_r() { printf "%s\n" "$*" >> /fixture/warnings; }; . /getinstdisk' :
'. /getinstdisk';
if ($options{pre}) {
render_template($database, repo_path('xCAT-server/share/xcat/install/scripts/pre.rhels10'),
"$database/pre.sh", 'node', "$database/image.pkglist", '/install/rhels10/x86_64',
'rh', undef, {xcatmaster => '192.0.2.1'}, osarch => 'x86_64');
make_path("$root/libexec");
write_binary("$root/libexec/platform-python", "#!/bin/sh\nexit 0\n");
chmod 0755, "$root/libexec/platform-python" or die $!;
$mounts{"$root/libexec"} = '/usr/libexec';
$mounts{"$database/pre.sh"} = '/pre.sh';
for my $name (qw(logger vgs vgchange vgremove chown)) {
write_binary("$root/bin/$name", "#!/bin/sh\nexit 0\n");
chmod 0755, "$root/bin/$name" or die $!;
}
write_binary("$root/bin/mknod", "#!/bin/sh\n: >\"\$1\"\n");
chmod 0755, "$root/bin/mknod" or die $!;
$command = '. /pre.sh';
}
for my $pass (1 .. 2) {
write_binary("$root/tmp/xcat.install_disk", "/dev/not-selected\n");
my ($rc, $output) = sandbox_run($root, {read_only => \%mounts,
env => {MASTER_IP => '192.0.2.1', log_label => 'install'}},
$options{pre} ? '/bin/bash' : '/bin/sh', '-c', $command);
is($rc, 0, "pass $pass completes") or diag($output);
unlike($output, qr/msgutil_r/, "pass $pass needs no absent installer logger") unless $options{logger};
is(-f "$root/tmp/xcat.install_disk" ? scalar read_binary("$root/tmp/xcat.install_disk") : '',
"$expected\n", "pass $pass selects the expected disk");
ok(!-e "$root/tmp/xcat.getinstalldisk", "pass $pass removes scan state");
ok(!-e "$root/awk-calls", "pass $pass works without calling awk") if $options{no_awk};
if ($options{pre}) {
my $partition = "$root/tmp/partitionfile";
my @disk_rules = grep { /^ignoredisk\b/ } split /\n/,
(-f $partition ? scalar read_binary($partition) : '');
is($disk_rules[-1], "ignoredisk --only-use=$expected",
"pass $pass uses the selected disk in the rendered installer partition plan");
}
}
if ($options{logger}) {
is(scalar read_binary("$root/warnings"),
"192.0.2.1 warn Disk detection failed, defaulting to /dev/sda /var/log/xcat/xcat.log install\n" x 2,
'default selection reaches the installer logger');
}
};
return;
}
for my $no_awk (0, 1) {
subtest $no_awk ? 'without awk discovery' : 'with awk discovery' => sub {
my @cases = (
['/dev/sdb', 'direct disk before RAID', {sda => {driver => 'megaraid_sas'}, sdb => {driver => 'ahci'}}],
['/dev/sda', 'RAID alone', {sda => {driver => 'megaraid_sas'}}],
['/dev/sdb', 'direct disk before SAS', {sda => {driver => 'mpt3sas'}, sdb => {driver => 'ahci'}}],
['/dev/sda', 'SAS before unclassified driver', {sda => {driver => 'mpt3sas'}, sdb => {driver => 'virtio_blk'}}],
['/dev/nvme0n1', 'driverless NVMe', {nvme0n1 => {}}],
['/dev/sda', 'empty scan', {}],
['/dev/sdb', 'group wins over RAID WWN', {sda => {driver => 'megaraid_sas', wwn => '0x5001'}, sdb => {driver => 'ahci'}}],
['/dev/sda', 'group wins when scanned first', {sda => {driver => 'ahci'}, sdb => {driver => 'megaraid_sas', wwn => '0x5002'}}],
['/dev/sdb', 'WWN before no identifier', {sda => {driver => 'ahci'}, sdb => {driver => 'ahci', wwn => '0x5001'}}],
['/dev/sdb', 'lower WWN', {sda => {driver => 'ahci', wwn => '0x5002'}, sdb => {driver => 'ahci', wwn => '0x5001'}}],
['/dev/sda', 'path before no identifier', {sda => {driver => 'ahci', path => '/devices/pci/block/sda'}, sdb => {driver => 'ahci'}}],
['/dev/xvda', 'scanned Xen disk', {xvda => {driver => 'vbd'}}],
['/dev/xvdb', 'Xen driver ordering', {xvda => {driver => 'vbd'}, xvdb => {driver => 'ahci'}}],
['/dev/sdb', 'ignore small media', {sda => {driver => 'ahci', size => 100}, sdb => {driver => 'virtio_blk'}}],
);
selects(@$_, no_awk => $no_awk) for @cases;
};
}
selects('/dev/md/Volume0_0', 'VROC numbered fallback', {}, blocks => ['md/Volume0_0']);
selects('/dev/md/Volume0', 'VROC unnumbered fallback', {}, blocks => ['md/Volume0']);
selects('/dev/md/Volume0_0', 'VROC preference over other fallbacks', {}, blocks => ['md/Volume0_0', 'md/Volume0', 'xvda']);
selects('/dev/xvda', 'Xen fallback', {}, blocks => ['xvda']);
selects('/dev/sdb', 'scanned disk before fallback devices', {sdb => {driver => 'ahci'}}, blocks => ['md/Volume0_0', 'xvda']);
selects('/dev/sda', 'regular files are not block devices', {}, regular => ['md/Volume0_0', 'md/Volume0', 'xvda']);
selects('/dev/sda', 'default selection with installer logger', {}, logger => 1);
$database = template_database();
set_row('site', {key => 'nodestatus'}, {value => 0});
set_row('nodetype', {node => 'node'}, {os => 'rhels10', arch => 'x86_64', provmethod => 'install'});
write_binary("$database/image.pkglist", '');
selects('/dev/sdb', 'rendered RHEL 10 installer disk selection',
{sda => {driver => 'megaraid_sas'}, sdb => {driver => 'ahci'}}, pre => 1);
done_testing();
-181
View File
@@ -1,181 +0,0 @@
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use File::Spec;
use File::Temp qw(tempdir);
use Test::More;
my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' );
my $script_dir = File::Spec->catdir( $repo_root, 'xCAT-server', 'share', 'xcat', 'install', 'scripts' );
# One script serves every installer, so one file is exercised here.
my @scripts = map { File::Spec->catfile( $script_dir, $_ ) } qw(getinstdisk);
plan skip_all => 'getinstdisk not found' if grep { !-r $_ } @scripts;
our $script;
sub slurp {
open( my $fh, '<', $_[0] ) or die "Unable to read $_[0]: $!";
my $c = do { local $/; <$fh> };
close($fh);
return $c;
}
# The script reads /proc/partitions and writes under /tmp, so each scenario
# runs a copy with those paths moved into its own sandbox, and a stub udevadm
# serves the device properties from fixture files.
sub run_scenario {
my (%disk) = @_;
my $sandbox = tempdir( CLEANUP => 1 );
my $fixdir = "$sandbox/fix";
my $bindir = "$sandbox/bin";
mkdir $fixdir;
mkdir $bindir;
my $body = slurp($script);
$body =~ s{/proc/partitions}{$sandbox/partitions}g;
$body =~ s{/tmp/xcat\.install_disk}{$sandbox/xcat.install_disk}g;
$body =~ s{/tmp/xcat\.getinstalldisk}{$sandbox/xcat.getinstalldisk}g;
$body =~ s{/dev/md/Volume0}{$sandbox/md/Volume0}g;
$body =~ s{"/dev/xvda"}{"$sandbox/xvda"}g;
open( my $sh, '>', "$sandbox/getinstdisk" ) or die $!;
print $sh $body;
close($sh);
open( my $parts, '>', "$sandbox/partitions" ) or die $!;
print $parts "major minor #blocks name\n\n";
my $minor = 0;
for my $name ( sort keys %disk ) {
printf $parts " 8 %5d 524288000 %s\n", $minor++, $name;
}
close($parts);
for my $name ( sort keys %disk ) {
my %attr = %{ $disk{$name} };
open( my $props, '>', "$fixdir/$name.props" ) or die $!;
print $props "ID_WWN=$attr{wwn}\n" if $attr{wwn};
print $props "DEVPATH=$attr{path}\n" if $attr{path};
print $props "DEVTYPE=disk\n";
close($props);
open( my $attrs, '>', "$fixdir/$name.attrs" ) or die $!;
print $attrs qq{ ATTRS{size}=="1024000000"\n};
print $attrs qq{ DRIVERS=="$attr{driver}"\n} if $attr{driver};
my @models = $attr{models} ? @{ $attr{models} } : ( $attr{model} ? $attr{model} : () );
print $attrs qq{ ATTRS{model}=="$_"\n} for @models;
close($attrs);
}
open( my $udev, '>', "$bindir/udevadm" ) or die $!;
print $udev <<'UDEV';
#!/bin/sh
for a in "$@"; do
case "$a" in
--name=*) name=${a#--name=} ;;
esac
done
name=${name#/dev/}
case "$*" in
*--query=property*) cat "$FIXDIR/$name.props" 2>/dev/null ;;
*--attribute-walk*) cat "$FIXDIR/$name.attrs" 2>/dev/null ;;
esac
exit 0
UDEV
close($udev);
chmod 0755, "$bindir/udevadm";
local $ENV{FIXDIR} = $fixdir;
local $ENV{PATH} = "$bindir:$ENV{PATH}";
local $ENV{MASTER_IP} = '';
system("sh $sandbox/getinstdisk >$sandbox/log 2>&1");
my $chosen = -r "$sandbox/xcat.install_disk" ? slurp("$sandbox/xcat.install_disk") : '';
chomp $chosen;
return $chosen;
}
# Every scenario asserts against both copies of the script.
sub selects {
my ( $expected, $name, %disk ) = @_;
for my $candidate (@scripts) {
local $script = $candidate;
my $variant = ( File::Spec->splitpath($candidate) )[2];
is( run_scenario(%disk), $expected, "$name ($variant)" );
}
return;
}
# A direct attached disk wins over a RAID volume when both are present. The
# RAID volume sorts first by name, so the choice comes from the driver group.
selects( '/dev/sdb', 'the direct attached disk wins over the RAID volume',
sda => { driver => 'megaraid_sas' },
sdb => { driver => 'ahci' } );
# A server with only RAID volumes still selects one.
selects( '/dev/sda', 'a RAID volume is selected when nothing better exists',
sda => { driver => 'megaraid_sas' } );
# A SAS host adapter loses to a direct attached disk, and still wins over a
# driver with no group of its own.
selects( '/dev/sdb', 'the direct attached disk wins over the host adapter',
sda => { driver => 'mpt3sas' },
sdb => { driver => 'ahci' } );
selects( '/dev/sda', 'the host adapter wins over an unknown driver',
sda => { driver => 'mpt3sas' },
sdb => { driver => 'virtio_blk' } );
# A driverless NVMe device still gets selected from the last group.
selects( '/dev/nvme0n1', 'an NVMe device is selected from the last group',
nvme0n1 => {} );
# No usable disk falls back to the documented default.
selects( '/dev/sda', 'no disks fall back to the default' );
# The driver group decides before the identifier. A disk that reports no WWN
# used to be dropped when another disk reported one, or to be ignored because
# the readback only opened the files of the last identifier class seen.
selects( '/dev/sdb', 'the direct attached disk wins when only the RAID volume reports a WWN',
sda => { driver => 'megaraid_sas', wwn => '0x5000cca0aaaa0001' },
sdb => { driver => 'ahci' } );
selects( '/dev/sda', 'the direct attached disk wins when it is scanned first without a WWN',
sda => { driver => 'ahci' },
sdb => { driver => 'megaraid_sas', wwn => '0x5000cca0aaaa0002' } );
# Within one driver group the identifier decides, and a disk that reports one
# is preferred, because that name is stable across reboots.
selects( '/dev/sdb', 'the disk with a WWN wins inside the group',
sda => { driver => 'ahci' },
sdb => { driver => 'ahci', wwn => '0x5000cca0bbbb0001' } );
selects( '/dev/sdb', 'the lower WWN wins inside the group',
sda => { driver => 'ahci', wwn => '0x5000cca0bbbb0002' },
sdb => { driver => 'ahci', wwn => '0x5000cca0bbbb0001' } );
selects( '/dev/sda', 'a path is preferred over no identifier at all',
sda => { driver => 'ahci', path => '/devices/pci0000:00/0000:00:1f.2/ata1/host0/target0:0:0/0:0:0:0/block/sda' },
sdb => { driver => 'ahci' } );
# A Xen guest presents xvd names. The scan has to see them, because the
# fallback below it would take the first one without looking.
selects( '/dev/xvda', 'a Xen disk is scanned rather than assumed',
xvda => { driver => 'vbd' } );
selects( '/dev/xvdb', 'the better driver group wins among Xen disks',
xvda => { driver => 'vbd' },
xvdb => { driver => 'ahci' } );
# Every installer includes the one script, which carries the fallbacks and the
# logging that the separate RHEL 10 copy used to hold on its own.
my $common = slurp( $scripts[0] );
ok( !-e File::Spec->catfile( $script_dir, 'getinstdisk.rhels10' ),
'the RHEL 10 copy of the script is gone' );
like( slurp( File::Spec->catfile( $script_dir, 'pre.rhels10' ) ),
qr{/share/xcat/install/scripts/getinstdisk#},
'the RHEL 10 installer includes the common script' );
like( $common, qr{-b /dev/md/Volume0_0}, 'the common script keeps the VROC fallback' );
like( $common, qr{-b "/dev/xvda"}, 'the common script carries the Xen fallback' );
like( $common, qr{command -v msgutil_r [^\n]*\n\s*msgutil_r },
'the failure log is guarded, because one installer does not define it' );
done_testing();