2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-09-26 01:34:05 +00:00

Merge pull request #7859 from VersatusHPC/test/r26-dhcp-dispatch-behavior

test(dhcp): verify service-node dispatch requests
This commit is contained in:
Daniel Hilst
2026-09-24 22:50:18 -03:00
committed by GitHub
2 changed files with 272 additions and 47 deletions
+171 -47
View File
@@ -3,56 +3,180 @@ use strict;
use warnings;
use FindBin;
use File::Spec;
use lib "$FindBin::Bin/../lib";
use Capture::Tiny qw(capture);
use JSON qw(decode_json encode_json);
use Storable qw(dclone);
use Test::More;
use XCAT::Test::File qw(repo_path);
my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' );
my $plugin = File::Spec->catfile( $repo_root, 'xCAT-server/lib/xcat/plugins/dhcp.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);
# The dispatch loop that fans a node-targeted makedhcp out to service nodes.
my ($loop) = $source =~ m{
( my \s+ %servingsn .*? foreach \s+ my \s+ \$s \s+ \(\@snlist\) .*? \n \s* \} \n )
}sx;
ok( $loop, 'the service node dispatch loop was located' )
or BAIL_OUT('dhcp.pm no longer matches the expected dispatch shape');
like(
$loop,
qr/getSNformattedhash\(\\\@nodes/,
'the named nodes are mapped to their service nodes'
);
like(
$loop,
qr/next \s+ if \s+ \(keys\(%servingsn\) \s* && \s* !exists\(\$servingsn\{\$s\}\)\)/x,
'a service node serving none of the named nodes is skipped'
my $driver = repo_path('xCAT-test/unit/fixtures/dhcp/dispatch.pl');
my @cases = (
{
name => 'named nodes reach only their DHCP service nodes',
nodes => [qw(node1 node2)],
service_nodes => [qw(sn3 sn2 sn1)],
noderes => { node1 => { servicenode => 'sn1' }, node2 => { servicenode => 'sn2' } },
destinations => [ undef, qw(sn2 sn1) ],
networks => [ { net => '192.0.2.0', dynamicrange => '192.0.2.100-192.0.2.200', dhcpserver => 'sn1' } ],
},
{
name => 'one serving node excludes unrelated servers',
nodes => ['node2'],
noderes => { node2 => { servicenode => 'sn2', xcatmaster => 'sn1' } },
destinations => [ undef, 'sn2' ],
},
{
name => 'service-node pools reach both serving DHCP nodes',
nodes => ['node1'],
noderes => { node1 => { servicenode => 'sn1,sn2' } },
destinations => [ undef, qw(sn1 sn2) ],
},
{
name => 'nodes without resource rows fall back to the manager',
nodes => ['node1'],
destinations => [undef],
},
{
name => 'manager mapping is not an empty mapping',
nodes => ['node1'],
noderes => { node1 => {} },
destinations => [undef],
},
{
name => 'an unmapped node with no site master retains all-server fallback',
nodes => ['node1'],
site_master => '',
destinations => [ undef, qw(sn1 sn2 sn3) ],
messages => [ [ 'SW', "site.master is not set!\n" ] ],
},
{
name => 'a mapping outside the DHCP inventory stays local',
nodes => ['node1'],
noderes => { node1 => { servicenode => 'sn4' } },
destinations => [undef],
},
{
name => 'preprocessed requests are returned without another fan-out',
nodes => ['node1'],
preprocessed => 1,
incoming_destination => 'sn1',
noderes => { node1 => { servicenode => 'sn2' } },
destinations => [undef],
},
{
name => 'network regeneration reaches every DHCP server',
args => ['-n'],
noderes => { node1 => { servicenode => 'sn1' } },
destinations => [ undef, qw(sn1 sn2 sn3) ],
},
{
name => 'network regeneration ignores a supplied noderange',
args => ['-n'],
nodes => ['node1'],
output_nodes => [],
noderes => { node1 => { servicenode => 'sn1' } },
destinations => [ undef, qw(sn1 sn2 sn3) ],
},
{
name => 'a service node is not sent its own single-node request',
nodes => ['sn1'],
noderes => { sn1 => { servicenode => 'sn1' } },
destinations => [undef],
},
{
name => 'a service node in a multi-node request is not excluded',
nodes => [qw(sn1 node1)],
noderes => { sn1 => { servicenode => 'sn1' }, node1 => { servicenode => 'sn1' } },
destinations => [ undef, 'sn1' ],
},
{
name => 'service-node origin reaches the manager and other serving nodes',
nodes => ['node1'],
is_service_node => 1,
local_names => [qw(sn2 192.0.2.2)],
service_nodes => [qw(sn1 192.0.2.2 sn3)],
noderes => { node1 => { servicenode => 'sn1,192.0.2.2' } },
destinations => [ undef, qw(mn sn1) ],
},
{
name => 'a manager does not apply the service-node self guard',
nodes => ['node1'],
local_names => ['sn1'],
noderes => { node1 => { servicenode => 'sn1' } },
destinations => [ undef, 'sn1' ],
},
{
name => 'without DHCP service nodes only the local request remains',
nodes => ['node1'],
service_nodes => [],
destinations => [undef],
},
{
name => 'local-only mode suppresses remote dispatch',
args => ['-l'],
nodes => ['node1'],
noderes => { node1 => { servicenode => 'sn1' } },
destinations => [undef],
},
{
name => 'a service node without a DHCP hierarchy stays local',
nodes => ['node1'],
service_nodes => [],
is_service_node => 1,
local_names => ['sn1'],
destinations => [undef],
},
{
name => 'an incomplete hierarchical network prevents dispatch',
nodes => ['node1'],
networks => [ { net => '192.0.2.0', dynamicrange => '192.0.2.100-192.0.2.200' } ],
destinations => [],
errors => [ { error => ['Hierarchy requested, therefore networks.dhcpserver must be set for net=192.0.2.0'], errorcode => [1] } ],
},
);
# Regenerating the networks must still reach every dhcp server, because a
# dynamic range does not belong to any node.
like(
$loop,
qr/unless \s* \(\$opt\{n\}\)/x,
'network regeneration is exempt from the node based restriction'
);
# Empty mapping must fall back to the previous fan-out rather than silently
# dispatching to nothing.
my ($guard) = $loop =~ /(next\s+if\s+\(keys\(%servingsn\)[^\n]*)/;
like(
$guard,
qr/keys\(%servingsn\)\s*&&/,
'an unmapped noderange still reaches every service node'
);
# The pre-existing conditions in the loop must survive.
like( $loop, qr/scalar \@nodes == 1 and \$nodes\[0\] eq \$s/, 'the self dispatch guard is retained' );
like( $loop, qr/\$issn && exists\(\$iphash\{\$s\}\)/, 'the service node self skip is retained' );
for my $case (@cases) {
subtest $case->{name} => sub {
my $request = {
command => ['makedhcp'],
arg => $case->{args} || [],
username => ['operator'],
_xcatpreprocessed => [ $case->{preprocessed} || 0 ],
environment => ['sentinel=value'],
};
$request->{node} = $case->{nodes} if exists $case->{nodes};
$request->{_xcatdest} = $case->{incoming_destination} if exists $case->{incoming_destination};
my @expected;
for my $destination (@{ $case->{destinations} }) {
my $outgoing = dclone($request);
$outgoing->{node} = $case->{output_nodes} || $case->{nodes} || [];
$outgoing->{_xcatpreprocessed} = [1];
$outgoing->{_xcatdest} = $destination if defined $destination;
push @expected, $outgoing;
}
my %fixture = (
request => $request,
service_nodes => $case->{service_nodes} || [qw(sn1 sn2 sn3)],
noderes => $case->{noderes} || {},
local_names => $case->{local_names} || ['mn'],
is_service_node => $case->{is_service_node} || 0,
site_master => exists($case->{site_master}) ? $case->{site_master} : 'mn',
networks => $case->{networks} || [],
);
my ( $stdout, $stderr, $status ) = capture {
system( $^X, $driver, encode_json(\%fixture) );
};
is( $status, 0, 'the plugin runs successfully' );
is( $stderr, '', 'the plugin produces no stderr' );
return if $status;
my $result = eval { decode_json($stdout) };
is( ref($result), 'HASH', 'the driver reports a result' );
return unless ref($result) eq 'HASH';
is_deeply( $result->{requests}, \@expected, 'destinations, order and request contents match' );
is_deeply( $result->{errors}, $case->{errors} || [], 'callback errors match' );
is_deeply( $result->{messages}, $case->{messages} || [], 'diagnostics match' );
};
}
done_testing();
+101
View File
@@ -0,0 +1,101 @@
package main;
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/../../../lib";
use lib "$FindBin::Bin/../../../../perl-xCAT";
use File::Temp qw(tempdir);
use JSON qw(decode_json encode_json);
use XCAT::Test::File qw(repo_path);
my $fixture = decode_json(shift @ARGV);
my @messages;
my @errors;
BEGIN {
package xCAT::Table;
sub new {
my ( $class, $table ) = @_;
die "Unexpected table $table" unless $table eq 'networks' || $table eq 'noderes';
return bless { table => $table }, $class;
}
sub getAllEntries {
my ($self) = @_;
die 'Expected networks table' unless $self->{table} eq 'networks';
return $fixture->{networks} || [];
}
sub getNodesAttribs {
my ( $self, $nodes, $attributes ) = @_;
die 'Expected noderes table' unless $self->{table} eq 'noderes';
my %result;
for my $node (@$nodes) {
my $row = $fixture->{noderes}->{$node};
$result{$node} = defined($row)
? [ { map { $_ => $row->{$_} } @$attributes } ]
: [undef];
}
return \%result;
}
sub close { return; }
$INC{'xCAT/Table.pm'} = __FILE__;
package xCAT::TableUtils;
sub getTftpDir { return '/tftpboot'; }
sub get_site_Master { return $fixture->{site_master}; }
sub get_site_attribute {
my ( $class, $attribute ) = @_;
return ($fixture->{site_master}) if $attribute eq 'master';
return (0) if $attribute eq 'disjointdhcps';
die "Unexpected site attribute $attribute";
}
$INC{'xCAT/TableUtils.pm'} = __FILE__;
package xCAT::NetworkUtils;
use Exporter qw(import);
our @EXPORT_OK = qw(getipaddr);
sub getipaddr { die 'Unexpected address lookup'; }
sub determinehostname { return @{ $fixture->{local_names} || ['mn'] }; }
sub nodeonmynet { return 1; }
$INC{'xCAT/NetworkUtils.pm'} = __FILE__;
package xCAT::Utils;
sub osver { return 'rhels9'; }
sub isServiceNode { return $fixture->{is_service_node} || 0; }
$INC{'xCAT/Utils.pm'} = __FILE__;
package xCAT::SvrUtils;
$INC{'xCAT/SvrUtils.pm'} = __FILE__;
package xCAT::NodeRange;
$INC{'xCAT/NodeRange.pm'} = __FILE__;
package xCAT::MsgUtils;
sub trace { return; }
sub message {
my ( $class, $level, $message ) = @_;
push @messages, [ $level, $message ];
return;
}
$INC{'xCAT/MsgUtils.pm'} = __FILE__;
}
$ENV{XCATROOT} = tempdir( CLEANUP => 1 );
require xCAT::ServiceNodeUtils;
{
no warnings qw(once redefine);
*xCAT::ServiceNodeUtils::getSNList = sub {
my ( $class, $service ) = @_;
die 'Expected DHCP service inventory' unless $service eq 'dhcpserver';
return @{ $fixture->{service_nodes} };
};
}
my $plugin = repo_path('xCAT-server/lib/xcat/plugins/dhcp.pm');
require $plugin;
my $requests = xCAT_plugin::dhcp::preprocess_request(
$fixture->{request}, sub { push @errors, $_[0]; }
);
print encode_json({ requests => $requests, errors => \@errors, messages => \@messages })
or die "Unable to report dispatch result: $!";