2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-09-04 20:17:55 +00:00

test(confluent): exercise explicit-node paths through plugin

Replace source-shape extraction with real preprocess and command execution. Model missing table rows with the same return shape as xCAT::Table.

Signed-off-by: Vinícius Ferrão <2031761+viniciusferrao@users.noreply.github.com>
This commit is contained in:
Vinícius Ferrão
2026-09-02 19:34:37 -03:00
parent 17f5b06106
commit 20ff3588d4
2 changed files with 128 additions and 146 deletions
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use File::Spec;
use Test::More;
my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' );
my $plugin = File::Spec->catfile( $repo_root, 'xCAT-server/lib/xcat/plugins/confluent.pm' );
plan skip_all => "$plugin not found" unless -r $plugin;
open( my $fh, '<', $plugin ) or die "Unable to read $plugin: $!";
my $source = do { local $/; <$fh> };
close($fh);
# A stand-in for the nodehm table, so the selection logic can be exercised
# without a database. getNodesAttribs leaves out nodes that have no row, which
# is what the plugin sees for a node that was never given console attributes.
{
package StubNodehm;
sub new { my ( $class, %rows ) = @_; return bless { rows => {%rows} }, $class; }
sub getNodesAttribs {
my ( $self, $noderange, $attrs ) = @_;
my %out;
foreach my $node (@$noderange) {
next unless exists $self->{rows}{$node};
$out{$node} = [ { %{ $self->{rows}{$node} } } ];
}
return \%out;
}
sub getAllNodeAttribs {
my ( $self, $attrs ) = @_;
return map { { %{ $self->{rows}{$_} } } } sort keys %{ $self->{rows} };
}
}
# Extract the node-selection block from preprocess_request and run it directly,
# so this covers the shipped logic rather than a copy of it.
my ($block) = $source =~ m{
( my \s+ \@items; .*?
push \s+ \@nodes, \s* \$_->\{node\}; \s* \n \s* \} )
}sx;
ok( $block, 'the node-selection block was located in preprocess_request()' )
or BAIL_OUT('confluent.pm no longer matches the expected node-selection shape');
sub select_nodes {
my ( $noderange, %rows ) = @_;
my $hmtab = StubNodehm->new(%rows);
my $master = 'mn.example';
my %cons_hash;
my $code = 'sub { my ($noderange, $hmtab, $master, $cons_ref) = @_; my %cons_hash; '
. $block
. ' %$cons_ref = %cons_hash; return \@nodes; }';
my $sub = eval $code;
die "Unable to evaluate the extracted block: $@" if $@;
my $nodes = $sub->( $noderange, $hmtab, $master, \%cons_hash );
return ( $nodes, \%cons_hash );
}
my %rows = (
withcons => { node => 'withcons', cons => 'ipmi' },
withserial => { node => 'withserial', serialport => 0 },
nocons => { node => 'nocons' },
withserver => { node => 'withserver', cons => 'ipmi', conserver => 'sn1.example' },
);
# An explicitly named node is configured even with no console attributes, and
# even with no nodehm row at all. The administrator asked for it by name.
my ( $explicit ) = select_nodes( ['nocons'], %rows );
is_deeply( $explicit, ['nocons'], 'an explicitly named node with no console attributes is still configured' );
my ( $missing ) = select_nodes( ['neverdefined'], %rows );
is_deeply( $missing, ['neverdefined'], 'an explicitly named node with no nodehm row is configured under its own name' );
my ( $mixed ) = select_nodes( [ 'withcons', 'nocons' ], %rows );
is_deeply( [ sort @$mixed ], [ 'nocons', 'withcons' ], 'an explicit noderange keeps both console-configured and console-less nodes' );
# Scanning the whole table must not change: nodes with no console configuration
# are still skipped, so every node in the cluster is not swept into confluent.
my ( $all ) = select_nodes( undef, %rows );
is_deeply(
[ sort @$all ],
[ 'withcons', 'withserial', 'withserver' ],
'a full table scan still skips nodes that have no console configuration'
);
ok( !grep( { $_ eq 'nocons' } @$all ), 'a console-less node is not picked up by a full table scan' );
# Conserver routing is unaffected.
my ( undef, $cons_hash ) = select_nodes( [ 'withserver', 'withcons' ], %rows );
is_deeply( $cons_hash->{'sn1.example'}{nodes}, ['withserver'], 'a node keeps its explicit conserver' );
is_deeply( $cons_hash->{'mn.example'}{nodes}, ['withcons'], 'a node with no conserver falls back to the management node' );
# makeconfluentcfg looks the named nodes up a second time and reshapes the
# result. A node with no nodehm row yields an undefined entry there too, and
# without the node name it reaches confluent as an empty name rather than as
# the node that was asked for.
my ($adjust) = $source =~ m{
( my \s+ \@tmpcfgents1; \s*\n
\s* foreach \s+ my \s+ \$ent \s+ \(\@cfgents1\) .*?
\n \s* \} \n \s* \} \n )
}sx;
ok( $adjust, 'the explicit-node lookup adjustment was located in makeconfluentcfg()' )
or BAIL_OUT('confluent.pm no longer matches the expected lookup-adjustment shape');
my $adjust_sub = eval 'sub { my (@cfgents1) = @_; ' . $adjust . ' return \@tmpcfgents1; }';
die "Unable to evaluate the extracted adjustment: $@" if $@;
my $reshaped = $adjust_sub->(
{ withcons => [ { node => 'withcons', cons => 'ipmi' } ] },
{ neverdefined => [] },
);
is( scalar(@$reshaped), 2, 'both named nodes survive the lookup adjustment' );
my ($carried) = grep { ($_->{node} || '') eq 'neverdefined' } @$reshaped;
ok( $carried, 'a named node with no nodehm row keeps its name through the adjustment' );
ok(
!grep( { !defined( $_->{node} ) || $_->{node} eq '' } @$reshaped ),
'no entry reaches confluent with an empty node name'
);
done_testing();
+128 -22
View File
@@ -70,9 +70,8 @@ eval { require $plugin; 1 } or BAIL_OUT("confluent.pm did not load: $@");
foreach my $n (@{ $nodes || [] }) {
my $rows = exists $self->{rows}{$n} ? $self->{rows}{$n}
: $self->{any} ? [ $self->{any} ]
: undef;
next unless $rows;
$out{$n} = [ map { _project($_, $attrs) } @$rows ];
: [ undef ];
$out{$n} = [ map { defined($_) ? _project($_, $attrs) : undef } @$rows ];
}
return \%out;
}
@@ -123,6 +122,21 @@ eval { require $plugin; 1 } or BAIL_OUT("confluent.pm did not load: $@");
}
}
# Run plugin code with table, host, and message collaborators at the boundary.
sub with_tables {
my ($tables, $code) = @_;
no warnings qw(redefine once);
my $empty = TestTable->new();
my $nodelist = TestTable->new_uniform({ groups => 'all' });
local *xCAT::Table::new = sub {
my ($class, $name, @rest) = @_;
return $tables->{$name} if exists $tables->{$name};
return $nodelist if $name eq 'nodelist';
return $empty;
};
return $code->();
}
# Drive the real command. %tables maps a table name to its rows; @existing are
# the nodes confluent already holds, which selects create versus update.
sub run_command {
@@ -130,28 +144,39 @@ sub run_command {
my $client = TestConfluent->new(@{ $opt{existing} || [] });
my %tables = %{ $opt{tables} || {} };
no warnings qw(redefine once);
local $Confluent::Client::INSTANCE = $client;
local *xCAT::Utils::isServiceNode = sub { return 0 };
local *xCAT::PasswordUtils::getIPMIAuth = sub { return {} };
local *xCAT::SvrUtils::sendmsg = sub { return };
local *xCAT::MsgUtils::message = sub { return };
local *xCAT::NetworkUtils::determinehostname = sub { return ('mn') };
my $empty = TestTable->new();
my $nodelist = TestTable->new_uniform({ groups => 'all' });
local *xCAT::Table::new = sub {
my ($class, $name, @rest) = @_;
return $tables{$name} if exists $tables{$name};
return $nodelist if $name eq 'nodelist';
return $empty;
};
my $req = { command => ['makeconfluentcfg'], arg => [] };
$req->{node} = $opt{nodes} if $opt{nodes};
xCAT_plugin::confluent::makeconfluentcfg($req, sub { return });
no warnings qw(once);
local $Confluent::Client::INSTANCE = $client;
with_tables(\%tables, sub {
my $req = { command => ['makeconfluentcfg'], arg => [] };
$req->{node} = $opt{nodes} if $opt{nodes};
xCAT_plugin::confluent::makeconfluentcfg($req, sub { return });
});
return $client;
}
sub run_preprocess {
my (%opt) = @_;
my %tables = %{ $opt{tables} || {} };
no warnings qw(once);
local $::CONSERVER;
local $::LOCAL;
local $::HELP;
local $::VERSION;
local $::VERBOSE;
local $::DEBUG;
return with_tables(\%tables, sub {
my $args = $opt{confluent_only} ? ['-c'] : [];
my $req = {
command => ['makeconfluentcfg'],
arg => $args,
_xcatpreprocessed => [0],
};
$req->{node} = $opt{nodes} if exists $opt{nodes};
return xCAT_plugin::confluent::preprocess_request($req, sub { return });
});
}
sub params_for {
my ($client, $node) = @_;
my $s = $client->sent_for($node);
@@ -159,6 +184,63 @@ sub params_for {
return ($s->{parameters}, $s->{op});
}
# Explicit nodes remain in the request even without console attributes or a
# nodehm row, and conserver routing still uses the configured destination.
{
my $nodehm = TestTable->new(
withcons => [ { node => 'withcons', cons => 'ipmi' } ],
nocons => [ { node => 'nocons' } ],
withserver => [ { node => 'withserver', cons => 'ipmi', conserver => 'sn1' } ],
);
my $requests = run_preprocess(
nodes => [ 'withcons', 'nocons', 'neverdefined', 'withserver' ],
tables => { nodehm => $nodehm },
);
my ($management) = grep { $_->{_xcatdest} eq 'mn' } @{$requests};
my ($conserver) = grep { $_->{_xcatdest} eq 'sn1' } @{$requests};
is_deeply(
[ sort @{ $management->{node} } ],
[qw(neverdefined nocons withcons withserver)],
'an explicit noderange keeps console and console-less nodes',
);
is_deeply($conserver->{node}, ['withserver'],
'a node keeps its explicit conserver');
}
# A full-scan preprocess request keeps the legacy dispatch filter: only nodes
# with a console method or a serial port are assigned to a destination.
{
my $nodehm = TestTable->new(
withcons => [ { cons => 'ipmi' } ],
withserial => [ { serialport => 0 } ],
nocons => [ {} ],
withserver => [ { cons => 'ipmi', conserver => 'sn1' } ],
);
my $requests = run_preprocess(
confluent_only => 1,
tables => { nodehm => $nodehm },
);
my %nodes_by_destination = map {
$_->{_xcatdest} => [ sort @{ $_->{node} } ]
} @{$requests};
is_deeply($nodes_by_destination{mn}, [qw(withcons withserial)],
'full-scan dispatch excludes a console-less node');
is_deeply($nodes_by_destination{sn1}, ['withserver'],
'full-scan dispatch keeps explicit conserver routing');
}
# The second nodehm lookup must carry the requested name even when no row is
# defined, so the create request never receives an empty node name.
{
my $client = run_command(
nodes => ['neverdefined'],
tables => { nodehm => TestTable->new() },
);
my ($p) = params_for($client, 'neverdefined');
ok(defined($p), 'a named node with no nodehm row reaches confluent');
is($p->{name}, 'neverdefined', 'the create request carries the requested node name');
}
# A node cabled on two interfaces. Both must survive: keeping only one loses
# the port of the other, which is what this export exists to carry.
{
@@ -324,4 +406,28 @@ sub params_for {
is($p->{'net.eth2.switch'}, 'sw8', 'a full scan carries the switch topology');
}
# The noderange path reshapes nodepos and mp lookups before preparing the
# confluent request. Exercise both tables through that path.
{
my $client = run_command(
nodes => ['n9'],
tables => {
nodehm => TestTable->new(n9 => [ { node => 'n9', cons => 'ipmi' } ]),
nodepos => TestTable->new(n9 => [
{ node => 'n9', rack => 'r2', u => '21', room => 'west' },
]),
mp => TestTable->new(n9 => [
{ node => 'n9', mpa => 'chassis2', id => '6' },
]),
},
);
my ($p) = params_for($client, 'n9');
ok(defined($p), 'an explicit node with location rows is sent to confluent');
is($p->{'location.rack'}, 'r2', 'the nodepos row supplies the rack');
is($p->{'location.u'}, '21', 'the nodepos row supplies the rack unit');
is($p->{'location.room'}, 'west', 'the nodepos row supplies the room');
is($p->{'enclosure.manager'}, 'chassis2', 'the mp row supplies the enclosure manager');
is($p->{'enclosure.bay'}, '6', 'the mp row supplies the enclosure bay');
}
done_testing();