diff --git a/xCAT-test/unit/dhcp_sn_dispatch_scope.t b/xCAT-test/unit/dhcp_sn_dispatch_scope.t index 07299611d..044b265da 100644 --- a/xCAT-test/unit/dhcp_sn_dispatch_scope.t +++ b/xCAT-test/unit/dhcp_sn_dispatch_scope.t @@ -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(); diff --git a/xCAT-test/unit/fixtures/dhcp/dispatch.pl b/xCAT-test/unit/fixtures/dhcp/dispatch.pl new file mode 100644 index 000000000..7c90bf90f --- /dev/null +++ b/xCAT-test/unit/fixtures/dhcp/dispatch.pl @@ -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: $!";