mirror of
https://github.com/xcat2/xcat-core.git
synced 2026-10-21 23:28:19 +00:00
230ef0de1b
A node in state boot or iscsiboot that boots from SAN gets the loader of its method from its own Kea classes on a client without iSCSI: BIOS for xnba, BIOS and UEFI for ipxe, and nothing without the local xNBA BIOS file. The local-boot class holds it for a client with iSCSI only, and keeps that condition when a later makedhcp writes the class again. The packet test checks both methods with kea-dhcp4. A firmware PXE client and an iPXE without iSCSI get the loader, and an iPXE with iSCSI gets no boot file, though the subnet offers its network scripts.
1831 lines
74 KiB
Perl
1831 lines
74 KiB
Perl
use strict;
|
|
use warnings;
|
|
## no critic (Modules::RequireFilenameMatchesPackage, TestingAndDebugging::ProhibitNoStrict, TestingAndDebugging::ProhibitNoWarnings)
|
|
no warnings 'once';
|
|
|
|
use FindBin;
|
|
use lib "$FindBin::Bin/../lib";
|
|
use lib "$FindBin::Bin/../../perl-xCAT";
|
|
use lib "$FindBin::Bin/../../xCAT-server/lib/perl";
|
|
|
|
use File::Temp qw(tempdir);
|
|
use Socket ();
|
|
use Test::More;
|
|
use XCAT::Test::File qw(repo_path);
|
|
|
|
BEGIN {
|
|
package xCAT::Table;
|
|
our $networks;
|
|
sub new {
|
|
my ( $class, $name ) = @_;
|
|
return $name eq 'networks' ? $networks : undef;
|
|
}
|
|
$INC{'xCAT/Table.pm'} = __FILE__;
|
|
|
|
package xCAT::TableUtils;
|
|
our $tftpdir;
|
|
sub getTftpDir { return $tftpdir; }
|
|
sub get_site_attribute { return; }
|
|
$INC{'xCAT/TableUtils.pm'} = __FILE__;
|
|
|
|
package xCAT::NetworkUtils;
|
|
sub import {
|
|
my $caller = caller;
|
|
no strict 'refs';
|
|
*{"${caller}::getipaddr"} = \&getipaddr;
|
|
}
|
|
sub getipaddr { return '10.0.0.1'; }
|
|
sub my_ip_facing { return ( 0, '10.0.0.1' ); }
|
|
sub thishostisnot { return 0; }
|
|
sub ip_forwarding_enabled { return 0; }
|
|
sub nodeonmynet { return 1; }
|
|
sub formatNetmask {
|
|
my ( $mask, $orig_type, $new_type ) = @_;
|
|
my $mask_number;
|
|
|
|
if ( $orig_type == 0 ) {
|
|
$mask_number = unpack( 'N', Socket::inet_aton($mask) );
|
|
} elsif ( $orig_type == 1 ) {
|
|
$mask_number = ( 2**$mask - 1 ) << ( 32 - $mask );
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
return Socket::inet_ntoa( pack( 'N', $mask_number ) ) if $new_type == 0;
|
|
if ( $new_type == 1 ) {
|
|
my $binary_mask = unpack( 'B32', pack( 'N', $mask_number ) );
|
|
return $binary_mask =~ tr/1/1/;
|
|
}
|
|
return;
|
|
}
|
|
sub isInSameSubnet {
|
|
my ( $ip1, $ip2, $mask, $mask_type ) = @_;
|
|
return unless $mask_type == 0;
|
|
|
|
my $mask_number = unpack( 'N', Socket::inet_aton($mask) );
|
|
my $ip1_number = unpack( 'N', Socket::inet_aton($ip1) );
|
|
my $ip2_number = unpack( 'N', Socket::inet_aton($ip2) );
|
|
return ( $ip1_number & $mask_number ) == ( $ip2_number & $mask_number );
|
|
}
|
|
$INC{'xCAT/NetworkUtils.pm'} = __FILE__;
|
|
|
|
package xCAT::ServiceNodeUtils;
|
|
sub getSNList { return; }
|
|
$INC{'xCAT/ServiceNodeUtils.pm'} = __FILE__;
|
|
|
|
package xCAT::NodeRange;
|
|
$INC{'xCAT/NodeRange.pm'} = __FILE__;
|
|
}
|
|
|
|
require xCAT::Utils;
|
|
{
|
|
no warnings 'redefine';
|
|
*xCAT::Utils::osver = sub { return 'rhels9'; };
|
|
*xCAT::Utils::runcmd = sub { return; };
|
|
}
|
|
|
|
$xCAT::TableUtils::tftpdir = '/srv/tftp';
|
|
my $source_dhcp_plugin = repo_path('xCAT-server/lib/xcat/plugins/dhcp.pm');
|
|
require $source_dhcp_plugin;
|
|
require xCAT::DHCP::Backend::Kea;
|
|
|
|
{
|
|
package DHCPKeaIntentBackend;
|
|
our @ISA = ('xCAT::DHCP::Backend::Kea');
|
|
sub host_cmds_hook_path { return '/test/libdhcp_host_cmds.so'; }
|
|
sub bootp_hook_path { return '/test/libdhcp_bootp.so'; }
|
|
}
|
|
|
|
{
|
|
package DHCPKeaIntentNetTable;
|
|
sub new {
|
|
my ( $class, $entry ) = @_;
|
|
return bless { entry => $entry }, $class;
|
|
}
|
|
sub getAllAttribs {
|
|
my ( $self, @attrs ) = @_;
|
|
return { domain => $self->{entry}{domain} } if @attrs == 1 && $attrs[0] eq 'domain';
|
|
return { %{ $self->{entry} } };
|
|
}
|
|
sub getAttribs {
|
|
my ($self) = @_;
|
|
return { %{ $self->{entry} } };
|
|
}
|
|
sub close { return; }
|
|
}
|
|
|
|
my %network_entry = (
|
|
net => '10.0.0.0',
|
|
mask => '255.255.255.0',
|
|
mgtifname => 'eth0',
|
|
dynamicrange => '10.0.0.100-10.0.0.150',
|
|
domain => 'cluster.test',
|
|
tftpserver => '<xcatmaster>',
|
|
);
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
local *xCAT::NetworkUtils::thishostisnot = sub { return 0; };
|
|
my $nettab = DHCPKeaIntentNetTable->new(\%network_entry);
|
|
my $subnet = xCAT_plugin::dhcp::kea_subnet4_intent(
|
|
$nettab, '10.0.0.0', '255.255.255.0', 'eth0', 0, 1, 80
|
|
);
|
|
my %classes = map { $_->{name} => $_ } @{ $subnet->{client_classes} };
|
|
ok($classes{'xcat-s390x-qemu-10.0.0.0_24'}, 'the Kea subnet includes QEMU s390x boot policy');
|
|
is(
|
|
$classes{'xcat-s390x-qemu-10.0.0.0_24'}{'option-data'}[0]{data},
|
|
's390x/10.0.0.0_24',
|
|
'the Kea subnet sends the s390x configuration name',
|
|
);
|
|
is_deeply(
|
|
[ grep { /^xcat-s390x-/ } @{ $subnet->{additional_client_classes} } ],
|
|
['xcat-s390x-qemu-10.0.0.0_24'],
|
|
'the s390x policy is evaluated only for its subnet',
|
|
);
|
|
|
|
# ONIE is answered per network for the same reason as s390x: the answer is
|
|
# a URL naming the management node on this network. The ISC path sends the
|
|
# same URL from its onie_vendor branch.
|
|
ok( $classes{'xcat-onie-10.0.0.0_24'}, 'the Kea subnet answers ONIE switches' );
|
|
is(
|
|
$classes{'xcat-onie-10.0.0.0_24'}{'option-data'}[0]{data},
|
|
'http://10.0.0.1/install/onie/onie-installer',
|
|
'the ONIE switch is pointed at the installer on this network',
|
|
);
|
|
is_deeply(
|
|
[ grep { /^xcat-onie-/ } @{ $subnet->{additional_client_classes} } ],
|
|
['xcat-onie-10.0.0.0_24'],
|
|
'the ONIE policy is evaluated only for its subnet',
|
|
);
|
|
# The local TFTP directory has no upstream loader, so the subnet offers no network script.
|
|
is_deeply( [ grep { /^xcat-ipxe-net-/ } @{ $subnet->{additional_client_classes} } ], [],
|
|
'without the upstream loader on this server, the Kea subnet names no network script' );
|
|
|
|
local *xCAT_plugin::dhcp::kea_ipxe_loader_flags = sub { return ( ipxe_bios => 1, ipxe_uefi => 1 ); };
|
|
$subnet = xCAT_plugin::dhcp::kea_subnet4_intent( $nettab, '10.0.0.0', '255.255.255.0', 'eth0', 0, 1, 80 );
|
|
%classes = map { $_->{name} => $_ } @{ $subnet->{client_classes} };
|
|
is_deeply(
|
|
[ grep { /^xcat-ipxe-net-/ } @{ $subnet->{additional_client_classes} } ],
|
|
[ 'xcat-ipxe-net-10.0.0.0_24-bios', 'xcat-ipxe-net-10.0.0.0_24-uefi' ],
|
|
'with it, the Kea subnet gives unknown x86 clients the network scripts of the upstream loader',
|
|
);
|
|
like( $classes{'xcat-ipxe-net-10.0.0.0_24-bios'}{'boot-file-name'}, qr{/tftpboot/xcat/ipxe/nets/10\.0\.0\.0_24$},
|
|
'from xcat/ipxe/nets' );
|
|
}
|
|
|
|
my @sysconfig_policy_cases = (
|
|
[ 'sles10', 0, 'SLES 10' ],
|
|
[ 'sles11', 1, 'SLES 11' ],
|
|
[ 'sles15.10', 1, 'SLES 15.10' ],
|
|
[ 'sles-sap15.6', 1, 'SLES for SAP 15.6' ],
|
|
[ 'opensuse-leap15.6', 1, 'openSUSE Leap 15.6' ],
|
|
[ 'opensuse_leap15.6', 1, 'underscored openSUSE Leap 15.6' ],
|
|
[ 'leap15.6', 1, 'short Leap 15.6' ],
|
|
[ 'rhel6', 0, 'RHEL 6' ],
|
|
[ 'rhel6.10', 0, 'RHEL 6.10' ],
|
|
[ 'rhel7', 1, 'RHEL 7' ],
|
|
[ 'rhels7.0', 1, 'RHEL Server 7.0' ],
|
|
[ 'rhel10', 1, 'RHEL 10' ],
|
|
[ 'RHEL7', 1, 'uppercase RHEL 7' ],
|
|
[ 'ubuntu24.04', 0, 'Ubuntu release' ],
|
|
[ 'debian12', 0, 'Debian release' ],
|
|
[ 'opensuse-tumbleweed', 0, 'openSUSE Tumbleweed release' ],
|
|
[ 'unknown', 0, 'unknown release' ],
|
|
[ undef, 0, 'undefined release' ],
|
|
);
|
|
|
|
foreach my $case (@sysconfig_policy_cases) {
|
|
my ( $os, $expected, $description ) = @{$case};
|
|
my $actual = xCAT_plugin::dhcp::dhcpd_sysconfig_uses_interface_key($os);
|
|
is( $actual, $expected, "$description keeps the dhcpd sysconfig policy" );
|
|
}
|
|
|
|
{
|
|
my $tmpdir = tempdir(CLEANUP => 1);
|
|
my $fake_ip = "$tmpdir/ip";
|
|
open(my $ip_fh, '>', $fake_ip) or die "Cannot write fake ip command: $!";
|
|
print {$ip_fh} "#!/bin/sh\n";
|
|
print {$ip_fh} "cat <<'EOF'\n";
|
|
print {$ip_fh} "default via 192.168.1.1 dev eth1 proto dhcp\n";
|
|
print {$ip_fh} "10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.1\n";
|
|
print {$ip_fh} "192.168.1.0/24 dev eth1 proto kernel scope link src 192.168.1.20\n";
|
|
print {$ip_fh} "EOF\n";
|
|
close($ip_fh);
|
|
chmod 0755, $fake_ip;
|
|
|
|
{
|
|
local $ENV{PATH} = $tmpdir;
|
|
is(
|
|
xCAT_plugin::dhcp::kea_command_path('ip'),
|
|
$fake_ip,
|
|
'DHCP route command lookup retains the first executable PATH match'
|
|
);
|
|
}
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT_plugin::dhcp::kea_command_path = sub {
|
|
my ($command) = @_;
|
|
return $fake_ip if $command eq 'ip';
|
|
return;
|
|
};
|
|
|
|
is_deeply(
|
|
[ xCAT_plugin::dhcp::local_ipv4_routes() ],
|
|
[
|
|
[ '0.0.0.0', 'eth1', '0.0.0.0', 'G' ],
|
|
[ '10.0.0.0', 'eth0', '255.255.255.0', '' ],
|
|
[ '192.168.1.0', 'eth1', '255.255.255.0', '' ],
|
|
],
|
|
'local IPv4 route detection prefers ip route output'
|
|
);
|
|
}
|
|
|
|
{
|
|
my $tmpdir = tempdir(CLEANUP => 1);
|
|
my $fake_netstat = "$tmpdir/netstat";
|
|
open(my $netstat_fh, '>', $fake_netstat) or die "Cannot write fake netstat command: $!";
|
|
print {$netstat_fh} "#!/bin/sh\n";
|
|
print {$netstat_fh} "cat <<'EOF'\n";
|
|
print {$netstat_fh} "Kernel IP routing table\n";
|
|
print {$netstat_fh} "Destination Gateway Genmask Flags MSS Window irtt Iface\n";
|
|
print {$netstat_fh} "0.0.0.0 192.168.1.1 0.0.0.0 UG 0 0 0 eth1\n";
|
|
print {$netstat_fh} "10.0.0.0 0.0.0.0 255.255.255.0 U 0 0 0 eth0\n";
|
|
print {$netstat_fh} "EOF\n";
|
|
close($netstat_fh);
|
|
chmod 0755, $fake_netstat;
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT_plugin::dhcp::kea_command_path = sub {
|
|
my ($command) = @_;
|
|
return $fake_netstat if $command eq 'netstat';
|
|
return;
|
|
};
|
|
|
|
is_deeply(
|
|
[ xCAT_plugin::dhcp::local_ipv4_routes() ],
|
|
[
|
|
[ '0.0.0.0', 'eth1', '0.0.0.0', 'UG' ],
|
|
[ '10.0.0.0', 'eth0', '255.255.255.0', 'U' ],
|
|
],
|
|
'local IPv4 route detection falls back to netstat output'
|
|
);
|
|
}
|
|
|
|
{
|
|
my $tmpdir = tempdir(CLEANUP => 1);
|
|
my $fake_ip = "$tmpdir/ip";
|
|
open(my $ip_fh, '>', $fake_ip) or die "Cannot write fake ip command: $!";
|
|
print {$ip_fh} "#!/bin/sh\n";
|
|
print {$ip_fh} "cat <<'EOF'\n";
|
|
print {$ip_fh} "0.0.0.0/0 dev eth0 proto kernel scope link\n";
|
|
print {$ip_fh} "128.0.0.0/1 dev eth1 proto kernel scope link\n";
|
|
print {$ip_fh} "192.0.2.0/24 dev eth24 proto kernel scope link\n";
|
|
print {$ip_fh} "198.51.100.7/32 dev eth32 proto kernel scope link\n";
|
|
print {$ip_fh} "203.0.113.0/not-a-prefix dev invalid proto kernel scope link\n";
|
|
print {$ip_fh} "EOF\n";
|
|
close($ip_fh);
|
|
chmod 0755, $fake_ip;
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT_plugin::dhcp::kea_command_path = sub {
|
|
my ($command) = @_;
|
|
return $fake_ip if $command eq 'ip';
|
|
return;
|
|
};
|
|
|
|
is_deeply(
|
|
[ xCAT_plugin::dhcp::local_ipv4_routes() ],
|
|
[
|
|
[ '0.0.0.0', 'eth0', '0.0.0.0', '' ],
|
|
[ '128.0.0.0', 'eth1', '128.0.0.0', '' ],
|
|
[ '192.0.2.0', 'eth24', '255.255.255.0', '' ],
|
|
[ '198.51.100.7', 'eth32', '255.255.255.255', '' ],
|
|
],
|
|
'local IPv4 route detection converts boundary prefixes and ignores malformed prefixes'
|
|
);
|
|
}
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
local *xCAT_plugin::dhcp::kea_ipv4_routes = sub {
|
|
return (
|
|
[ '10.0.0.0', 'eth0', '255.255.255.0', '' ],
|
|
[ '192.168.1.0', 'enp3s0', '255.255.255.0', '' ],
|
|
);
|
|
};
|
|
local *xCAT_plugin::dhcp::kea_boot_client_classes = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_option_defs = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_global_option_data = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_dhcp_lease_time = sub { return 43200; };
|
|
local *xCAT_plugin::dhcp::kea_control_agent_enabled = sub { return 0; };
|
|
|
|
local $xCAT::Table::networks = DHCPKeaIntentNetTable->new( \%network_entry );
|
|
|
|
my $intent = xCAT_plugin::dhcp::kea_build_dhcp4_intent( bless({}, 'DHCPKeaIntentBackend'), {} );
|
|
|
|
is_deeply( $intent->{interfaces}, ['eth0'], 'empty dhcpinterfaces infers the local provisioning interface' );
|
|
is( scalar @{ $intent->{subnets} }, 1, 'empty dhcpinterfaces still renders local routed subnet' );
|
|
is( $intent->{subnets}[0]{subnet}, '10.0.0.0/24', 'rendered subnet comes from local route' );
|
|
}
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
local *xCAT::NetworkUtils::thishostisnot = sub { return 0; };
|
|
|
|
my @prefix_cases = (
|
|
[ '0.0.0.0', '0.0.0.0', 0 ],
|
|
[ '128.0.0.0', '128.0.0.0', 1 ],
|
|
[ '192.0.2.0', '255.255.255.0', 24 ],
|
|
[ '198.51.100.7', '255.255.255.255', 32 ],
|
|
);
|
|
|
|
foreach my $case (@prefix_cases) {
|
|
my ( $net, $mask, $prefix ) = @$case;
|
|
my $nettab = DHCPKeaIntentNetTable->new(
|
|
{
|
|
%network_entry,
|
|
net => $net,
|
|
mask => $mask,
|
|
dynamicrange => undef,
|
|
gateway => undef,
|
|
}
|
|
);
|
|
my $subnet = xCAT_plugin::dhcp::kea_subnet4_intent( $nettab, $net, $mask, 'eth0', 0, 1, 80 );
|
|
is( $subnet->{subnet}, "$net/$prefix", "$mask renders as prefix $prefix" );
|
|
}
|
|
}
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
local *xCAT::NetworkUtils::thishostisnot = sub { return 0; };
|
|
|
|
my $same_subnet_table = DHCPKeaIntentNetTable->new(
|
|
{
|
|
%network_entry,
|
|
gateway => '10.0.0.254',
|
|
}
|
|
);
|
|
my $same_subnet = xCAT_plugin::dhcp::kea_subnet4_intent(
|
|
$same_subnet_table, '10.0.0.0', '255.255.255.0', 'eth0', 0, 1, 80
|
|
);
|
|
ok( !$same_subnet->{error}, 'gateway in the subnet remains valid' );
|
|
|
|
my $different_subnet_table = DHCPKeaIntentNetTable->new(
|
|
{
|
|
%network_entry,
|
|
gateway => '192.0.2.1',
|
|
}
|
|
);
|
|
my $different_subnet = xCAT_plugin::dhcp::kea_subnet4_intent(
|
|
$different_subnet_table, '10.0.0.0', '255.255.255.0', 'eth0', 0, 1, 80
|
|
);
|
|
is(
|
|
$different_subnet->{error},
|
|
'Specified gateway 192.0.2.1 is not valid for 10.0.0.0/255.255.255.0, must be on same network',
|
|
'gateway outside the subnet keeps the existing error'
|
|
);
|
|
}
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
local *xCAT_plugin::dhcp::kea_ipv4_routes = sub {
|
|
return ([ '10.0.0.0', 'eth0', '255.255.255.0', '' ]);
|
|
};
|
|
local *xCAT_plugin::dhcp::kea_boot_client_classes = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_option_defs = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_global_option_data = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_dhcp_lease_time = sub { return 43200; };
|
|
local *xCAT_plugin::dhcp::kea_control_agent_enabled = sub { return 1; };
|
|
|
|
my $backend = DHCPKeaIntentBackend->new(kea_socket_dir => '/run/kea-xcat-test');
|
|
|
|
local $xCAT::Table::networks = DHCPKeaIntentNetTable->new( \%network_entry );
|
|
my $dhcp4_intent = xCAT_plugin::dhcp::kea_build_dhcp4_intent( $backend, { eth0 => 1 } );
|
|
is(
|
|
$dhcp4_intent->{'control-socket'}{'socket-name'},
|
|
'/run/kea-xcat-test/kea4-ctrl-socket',
|
|
'DHCPv4 intent uses the backend-selected Control Agent socket path'
|
|
);
|
|
|
|
local $xCAT::Table::networks = DHCPKeaIntentNetTable->new(
|
|
{
|
|
%network_entry,
|
|
net => 'fd00::/64',
|
|
dynamicrange => undef,
|
|
}
|
|
);
|
|
my $dhcp6_intent = xCAT_plugin::dhcp::kea_build_dhcp6_intent( $backend, { eth0 => 1 } );
|
|
is(
|
|
$dhcp6_intent->{'control-socket'}{'socket-name'},
|
|
'/run/kea-xcat-test/kea6-ctrl-socket',
|
|
'DHCPv6 intent uses the backend-selected Control Agent socket path'
|
|
);
|
|
}
|
|
|
|
{
|
|
package DHCPKeaRegenerateBackend;
|
|
sub load_dhcp4_config {
|
|
$_[0]->{loads}++;
|
|
return { error => 'existing Kea configuration must not be loaded by makedhcp -n' };
|
|
}
|
|
sub write_dhcp4_config {
|
|
my ( $self, $intent, %opts ) = @_;
|
|
$self->{written_intent} = $intent;
|
|
$self->{write_options} = \%opts;
|
|
return {};
|
|
}
|
|
sub restart_services {
|
|
my ( $self, %opts ) = @_;
|
|
$self->{restart_options} = \%opts;
|
|
return {};
|
|
}
|
|
|
|
package main;
|
|
|
|
my $network_intent = {
|
|
interfaces => ['eth0'],
|
|
'client-classes' => [ { name => 'xcat-generic' } ],
|
|
subnets => [ { id => 1, subnet => '192.0.2.0/24' } ],
|
|
};
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT_plugin::dhcp::kea_build_dhcp4_intent = sub { return $network_intent; };
|
|
local *xCAT_plugin::dhcp::kea_build_dhcp6_intent = sub { return { subnets => [] }; };
|
|
local *xCAT_plugin::dhcp::kea_build_ddns_intent = sub { return; };
|
|
local *xCAT_plugin::dhcp::kea_control_agent_enabled = sub { return 0; };
|
|
local *xCAT::MsgUtils::message = sub { return; };
|
|
local *xCAT::MsgUtils::trace = sub { return; };
|
|
local $::XCATSITEVALS{externaldhcpservers};
|
|
|
|
my @errors;
|
|
my $capture_response = sub {
|
|
my $response = shift;
|
|
push @errors, @{ $response->{error} || [] };
|
|
};
|
|
my $saved_umask = umask;
|
|
my $saved_ignorecase = $Getopt::Long::ignorecase;
|
|
{
|
|
local @ARGV;
|
|
xCAT_plugin::dhcp::process_request(
|
|
{
|
|
_xcatpreprocessed => [0],
|
|
arg => [ '-q', '-a' ],
|
|
},
|
|
$capture_response
|
|
);
|
|
}
|
|
umask $saved_umask;
|
|
$Getopt::Long::ignorecase = $saved_ignorecase;
|
|
Getopt::Long::Configure('pass_through');
|
|
@errors = ();
|
|
|
|
my $backend = bless { loads => 0 }, 'DHCPKeaRegenerateBackend';
|
|
xCAT_plugin::dhcp::kea_process_request( $backend, {}, { n => 1 }, { eth0 => 1 }, 0 );
|
|
|
|
is( $backend->{loads}, 0, 'makedhcp -n does not parse the previous Kea configuration' );
|
|
is_deeply(
|
|
$backend->{written_intent},
|
|
$network_intent,
|
|
'makedhcp -n writes only the newly generated network intent'
|
|
);
|
|
ok( $backend->{write_options}{backup_existing}, 'makedhcp -n backs up the replaced Kea configuration' );
|
|
ok( $backend->{restart_options}{enable}, 'makedhcp -n enables and restarts Kea after replacement' );
|
|
is_deeply( \@errors, [], 'makedhcp -n replacement completes without errors' );
|
|
}
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
local *xCAT::NetworkUtils::thishostisnot = sub { return 1; };
|
|
|
|
my $nettab = DHCPKeaIntentNetTable->new(
|
|
{
|
|
%network_entry,
|
|
dhcpserver => 'service-node-a',
|
|
}
|
|
);
|
|
|
|
my $subnet = xCAT_plugin::dhcp::kea_subnet4_intent( $nettab, '10.0.0.0', '255.255.255.0', 'eth0', 0, 1, 80 );
|
|
ok( !defined( $subnet->{dynamicrange} ), 'non-owning Kea server does not render dynamic pool' );
|
|
}
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
local *xCAT::NetworkUtils::thishostisnot = sub { return 0; };
|
|
|
|
my $nettab = DHCPKeaIntentNetTable->new(
|
|
{
|
|
%network_entry,
|
|
dhcpserver => 'service-node-a',
|
|
}
|
|
);
|
|
|
|
my $subnet = xCAT_plugin::dhcp::kea_subnet4_intent( $nettab, '10.0.0.0', '255.255.255.0', 'eth0', 0, 1, 80 );
|
|
is( $subnet->{dynamicrange}, $network_entry{dynamicrange}, 'owning Kea server renders dynamic pool' );
|
|
}
|
|
|
|
{
|
|
# Regression: networks.nameservers / site.nameservers default to the
|
|
# <xcatmaster> placeholder. Kea D2 rejects a non-IP dns-servers ip-address,
|
|
# so kea_build_ddns_intent must resolve <xcatmaster> to the management IP
|
|
# facing the network (via my_ip_facing) before rendering DDNS domains.
|
|
no warnings 'redefine';
|
|
local *xCAT_plugin::dhcp::kea_ddns_enabled = sub { 1 };
|
|
local *xCAT_plugin::dhcp::kea_ddns_key = sub { ( 'HMAC-SHA256', 'YWJjMTIz' ); };
|
|
|
|
local $xCAT::Table::networks = DHCPKeaIntentNetTable->new(
|
|
{
|
|
%network_entry,
|
|
nameservers => '<xcatmaster>',
|
|
}
|
|
);
|
|
|
|
my $ddns_intent = xCAT_plugin::dhcp::kea_build_ddns_intent();
|
|
|
|
ok( $ddns_intent && !$ddns_intent->{error}, 'kea_build_ddns_intent succeeds with <xcatmaster> nameservers' );
|
|
ok( scalar @{ $ddns_intent->{forward_domains} || [] }, 'kea_build_ddns_intent renders a forward DDNS domain' );
|
|
ok( scalar @{ $ddns_intent->{reverse_domains} || [] }, 'kea_build_ddns_intent renders a reverse DDNS domain' );
|
|
|
|
my @dns_ips =
|
|
map { $_->{'ip-address'} }
|
|
map { @{ $_->{'dns-servers'} || [] } }
|
|
( @{ $ddns_intent->{forward_domains} || [] }, @{ $ddns_intent->{reverse_domains} || [] } );
|
|
|
|
ok( scalar @dns_ips, 'rendered DDNS domains carry dns-servers' );
|
|
foreach my $ip (@dns_ips) {
|
|
isnt( $ip, '<xcatmaster>', 'DDNS dns-server ip-address is never the literal <xcatmaster> placeholder' );
|
|
is( $ip, '10.0.0.1', 'DDNS dns-server ip-address resolves to the management IP facing the network' );
|
|
like( $ip, qr/^\d+\.\d+\.\d+\.\d+$/, 'DDNS dns-server ip-address is a valid IPv4 literal' );
|
|
}
|
|
}
|
|
|
|
{
|
|
# Regression: a service node (noderes.servicenode set, groups=service) must
|
|
# get a Kea host reservation exactly like a regular compute node. The Kea
|
|
# reservation builder loops over every requested node without filtering on
|
|
# service-node membership, so kea_build_node_reservations must emit an
|
|
# ip/mac/hostname reservation for it.
|
|
#
|
|
# This node names no server of its own -- its tftpserver is the
|
|
# <xcatmaster> placeholder and it has no xcatmaster -- so the address it is
|
|
# sent to is the subnet's, which a reservation states by saying nothing.
|
|
# Kea used to fall back to my_ip_facing here, which is a different answer
|
|
# from the one ISC gives the same node whenever networks.tftpserver names
|
|
# some third machine.
|
|
package DHCPKeaResTable;
|
|
sub new { my ( $class, $rows ) = @_; return bless { rows => $rows }, $class; }
|
|
sub getNodesAttribs {
|
|
my ( $self, $nodes, $attrs ) = @_;
|
|
my %out;
|
|
$out{$_} = [ $self->{rows}{$_} || {} ] for @$nodes;
|
|
return \%out;
|
|
}
|
|
sub close { return; }
|
|
|
|
package main;
|
|
|
|
my %res_tables = (
|
|
noderes => DHCPKeaResTable->new( { 'svc01' => { netboot => 'xnba', servicenode => '192.168.201.20', tftpserver => '<xcatmaster>' } } ),
|
|
chain => DHCPKeaResTable->new( { 'svc01' => {} } ),
|
|
nodetype => DHCPKeaResTable->new( { 'svc01' => { arch => 'x86_64', provmethod => 'install', os => 'rhels9' } } ),
|
|
iscsi => DHCPKeaResTable->new( {} ),
|
|
mac => DHCPKeaResTable->new( { 'svc01' => { mac => '42:d7:c0:a8:c9:15' } } ),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $res_tables{$name};
|
|
};
|
|
my $svc_getipaddr = sub {
|
|
my ( $host, %opt ) = @_;
|
|
return if $opt{OnlyV6};
|
|
return '192.168.201.21';
|
|
};
|
|
local *xCAT::NetworkUtils::getipaddr = $svc_getipaddr;
|
|
# dhcp.pm imports getipaddr into its own namespace at use-time, so override
|
|
# the imported copy as well.
|
|
local *xCAT_plugin::dhcp::getipaddr = $svc_getipaddr;
|
|
local *xCAT::NetworkUtils::my_ip_facing = sub { return ( 0, '192.168.201.20' ); };
|
|
local *xCAT_plugin::dhcp::ipIsDynamic = sub { return 0; };
|
|
|
|
my @errors;
|
|
local $xCAT_plugin::dhcp::callback = sub {
|
|
my $resp = shift;
|
|
push @errors, @{ $resp->{error} } if $resp->{error};
|
|
};
|
|
|
|
my $backend = bless {}, 'DHCPKeaResBackend';
|
|
{
|
|
package DHCPKeaResBackend;
|
|
sub subnet_id_for_ip { return 1; }
|
|
}
|
|
|
|
my $reservations = xCAT_plugin::dhcp::kea_build_node_reservations( $backend, {}, ['svc01'] );
|
|
|
|
is( scalar(@errors), 0, 'service node reservation builds without errors' );
|
|
is( scalar( @{ $reservations || [] } ), 1, 'service node yields exactly one Kea host reservation' );
|
|
my $r = $reservations->[0] || {};
|
|
is( $r->{'ip-address'}, '192.168.201.21', 'service node reservation carries the node IP' );
|
|
is( $r->{'hw-address'}, '42:d7:c0:a8:c9:15', 'service node reservation carries the node MAC' );
|
|
# S-74. The reservation names the node in two places and both are needed.
|
|
# The "hostname" field is the only name a client cannot displace: with the
|
|
# field absent, Kea 2.4.1 wrote the name the client advertised into the
|
|
# lease and into the DDNS update, and sent it back in option 12 as well, so
|
|
# a node calling itself anything took over the node's DNS record. The
|
|
# host-name option carries the unqualified name for a server that sends it
|
|
# verbatim.
|
|
is( $r->{hostname}, 'svc01',
|
|
'service node reservation carries the hostname field' );
|
|
is_deeply(
|
|
[ grep { $_->{name} eq 'host-name' } @{ $r->{'option-data'} || [] } ],
|
|
[ { name => 'host-name', data => 'svc01' } ],
|
|
'service node reservation carries its name as the host-name option'
|
|
);
|
|
ok( !exists $r->{'next-server'},
|
|
'a node that names no server of its own leaves next-server to the subnet' );
|
|
}
|
|
|
|
{
|
|
# Where a node is sent, in the order noderes states it. Both backends read
|
|
# this one answer; they used to have a copy each and the copies disagreed
|
|
# about every row but the first.
|
|
no warnings 'redefine';
|
|
local *xCAT::NetworkUtils::my_ip_facing = sub { return ( 0, '10.0.0.1' ); };
|
|
my @errors;
|
|
local $xCAT_plugin::dhcp::callback = sub {
|
|
my $resp = shift;
|
|
push @errors, @{ $resp->{error} || [] };
|
|
};
|
|
|
|
my @cases = (
|
|
[ { tftpserver => '192.0.2.10', xcatmaster => '192.0.2.20' },
|
|
[ '192.0.2.10', '192.0.2.10' ],
|
|
'the node\'s own tftpserver outranks its xcatmaster',
|
|
],
|
|
[ { tftpserver => '<xcatmaster>', xcatmaster => '192.0.2.20' },
|
|
[ '192.0.2.20', '192.0.2.20' ],
|
|
'the <xcatmaster> placeholder defers to the xcatmaster attribute',
|
|
],
|
|
[ { netboot => 'xnba', xcatmaster => '192.0.2.20' },
|
|
[ '192.0.2.20', '192.0.2.20' ],
|
|
'xcatmaster is honoured for every netboot method, not only petitboot and onie',
|
|
],
|
|
[ { netboot => 'xnba' },
|
|
[ '${next-server}', undef ],
|
|
'a node naming neither inherits the subnet\'s value',
|
|
],
|
|
[ {},
|
|
[ '${next-server}', undef ],
|
|
'and so does a node with no noderes entry to speak of',
|
|
],
|
|
[ { netboot => 'petitboot' },
|
|
[ '10.0.0.1', '10.0.0.1' ],
|
|
'petitboot needs an address to build its URL with, so it falls back to the facing interface',
|
|
],
|
|
[ { netboot => 'onie' },
|
|
[ '10.0.0.1', '10.0.0.1' ],
|
|
'and so does onie',
|
|
],
|
|
);
|
|
|
|
foreach my $case (@cases) {
|
|
my ( $nrent, $want, $why ) = @{$case};
|
|
is_deeply( [ xCAT_plugin::dhcp::next_server_for_node( 'n1', $nrent ) ], $want, $why );
|
|
}
|
|
|
|
is_deeply( [ xCAT_plugin::dhcp::next_server_for_node( 'n1', undef ) ],
|
|
[ '${next-server}', undef ], 'a node with no noderes row at all inherits the subnet too' );
|
|
|
|
is( scalar(@errors), 0, 'none of those are an error the operator has to read about' );
|
|
|
|
# An xcatmaster nobody can resolve is a misconfiguration, and silently
|
|
# sending the node somewhere else hides it.
|
|
@errors = ();
|
|
my @unresolvable = xCAT_plugin::dhcp::next_server_for_node( 'n1', { xcatmaster => 'no.such.host.invalid' } );
|
|
is( scalar(@unresolvable), 0, 'an unresolvable xcatmaster yields no address' );
|
|
like( ( $errors[0] || '' ), qr/xcatmaster/, 'and says which attribute to look at' );
|
|
}
|
|
|
|
my @normalized_mac_cases = (
|
|
[ 'Aa:Bb:Cc:Dd:Ee:Ff', 'aa:bb:cc:dd:ee:ff', 'six-octet colon MAC is lowercased' ],
|
|
[ '01-23-45-67-89-AB-CD', '01:23:45:67:89:ab:cd', 'seven-octet hyphen MAC is canonicalized' ],
|
|
[ '01:23:45:67:89:AB:CD:EF', '01:23:45:67:89:ab:cd:ef', 'eight-octet colon MAC is lowercased' ],
|
|
[ '01-23-45-67-89-AB-CD-EF-01', '01:23:45:67:89:ab:cd:ef:01', 'nine-octet hyphen MAC is canonicalized' ],
|
|
);
|
|
|
|
foreach my $case (@normalized_mac_cases) {
|
|
my ( $input, $expected, $description ) = @$case;
|
|
is( xCAT_plugin::dhcp::kea_normalize_mac($input), $expected, $description );
|
|
}
|
|
|
|
my @invalid_mac_cases = (
|
|
[ undef, 'undefined MAC is rejected' ],
|
|
[ '', 'empty MAC is rejected' ],
|
|
[ '00:11:22:33:44', 'five-octet MAC is rejected' ],
|
|
[ '00:11:22:33:44:55:66:77:88:99', 'ten-octet MAC is rejected' ],
|
|
[ '00:11-22:33:44:55', 'mixed MAC separators are rejected' ],
|
|
[ 'gg:11:22:33:44:55', 'non-hexadecimal MAC is rejected' ],
|
|
[ '001122334455', 'compact MAC is rejected' ],
|
|
[ "00:11:22:33:44:55\n", 'MAC with a trailing newline is rejected' ],
|
|
[ ' 00:11:22:33:44:55', 'MAC with leading whitespace is rejected' ],
|
|
);
|
|
|
|
foreach my $case (@invalid_mac_cases) {
|
|
my ( $input, $description ) = @$case;
|
|
ok( !defined( xCAT_plugin::dhcp::kea_normalize_mac($input) ), $description );
|
|
}
|
|
|
|
{
|
|
my %mac_tables = (
|
|
noderes => DHCPKeaResTable->new(
|
|
{
|
|
macnode => {},
|
|
duidnode => {},
|
|
}
|
|
),
|
|
chain => DHCPKeaResTable->new( {} ),
|
|
nodetype => DHCPKeaResTable->new( {} ),
|
|
iscsi => DHCPKeaResTable->new( {} ),
|
|
mac => DHCPKeaResTable->new(
|
|
{
|
|
macnode => {
|
|
mac => 'Aa-Bb-Cc-Dd-Ee-Ff!node6|01-23-45-67-89-AB-CD-EF-01!node9|not-a-mac!badmac',
|
|
},
|
|
duidnode => {
|
|
mac => 'not-a-mac!duid-alias',
|
|
},
|
|
}
|
|
),
|
|
vpd => DHCPKeaResTable->new(
|
|
{
|
|
duidnode => {
|
|
uuid => '00112233-4455-6677-8899-aabbccddeeff',
|
|
},
|
|
}
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $mac_tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::getipaddr = sub {
|
|
my ( $host, %opt ) = @_;
|
|
return '2001:db8::25' if $opt{OnlyV6};
|
|
return '192.0.2.25';
|
|
};
|
|
local *xCAT_plugin::dhcp::ipIsDynamic = sub { return 0; };
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
local *xCAT_plugin::dhcp::kea_boot_for_node = sub { return {}; };
|
|
|
|
my $backend = bless {}, 'DHCPKeaMacBackend';
|
|
{
|
|
package DHCPKeaMacBackend;
|
|
sub subnet_id_for_ip { return 1; }
|
|
}
|
|
|
|
my @errors;
|
|
my $capture_error = sub {
|
|
my $resp = shift;
|
|
push @errors, @{ $resp->{error} } if $resp->{error};
|
|
};
|
|
local *xCAT::MsgUtils::message = sub { return; };
|
|
local *xCAT::MsgUtils::trace = sub { return; };
|
|
my $saved_umask = umask;
|
|
my $saved_ignorecase = $Getopt::Long::ignorecase;
|
|
{
|
|
local @ARGV;
|
|
|
|
# A conflicting option pair initializes the plugin's lexical callback
|
|
# and returns before any DHCP backend or service work begins.
|
|
xCAT_plugin::dhcp::process_request(
|
|
{
|
|
_xcatpreprocessed => [0],
|
|
arg => [ '-q', '-a' ],
|
|
},
|
|
$capture_error
|
|
);
|
|
}
|
|
umask $saved_umask;
|
|
$Getopt::Long::ignorecase = $saved_ignorecase;
|
|
Getopt::Long::Configure('pass_through');
|
|
|
|
my $reservations4 = xCAT_plugin::dhcp::kea_build_node_reservations( $backend, {}, [ 'macnode', 'duidnode' ] );
|
|
is_deeply(
|
|
[ map { $_->{'hw-address'} } @$reservations4 ],
|
|
[ 'aa:bb:cc:dd:ee:ff', '01:23:45:67:89:ab:cd:ef:01' ],
|
|
'IPv4 reservations use canonical MAC addresses and omit malformed entries'
|
|
);
|
|
is_deeply(
|
|
\@errors,
|
|
[ 'Invalid mac address not-a-mac for macnode', 'Invalid mac address not-a-mac for duidnode' ],
|
|
'IPv4 reservations preserve invalid-MAC errors'
|
|
);
|
|
|
|
@errors = ();
|
|
my $reservations6 = xCAT_plugin::dhcp::kea_build_node_reservations6( $backend, {}, [ 'macnode', 'duidnode' ] );
|
|
is_deeply(
|
|
[ map { $_->{'hw-address'} } @$reservations6 ],
|
|
[ 'aa:bb:cc:dd:ee:ff', '01:23:45:67:89:ab:cd:ef:01' ],
|
|
'IPv6 reservations use canonical MAC addresses and omit malformed entries'
|
|
);
|
|
is_deeply(
|
|
\@errors,
|
|
[ 'Invalid mac address not-a-mac for macnode', 'Invalid mac address not-a-mac for duidnode' ],
|
|
'IPv6 reservations report malformed MACs even when a DUID is available'
|
|
);
|
|
ok( !grep( { $_->{duid} } @$reservations6 ), 'malformed MAC does not create a DUID-based IPv6 reservation' );
|
|
|
|
my $matches = xCAT_plugin::dhcp::kea_reservation_matches_for_nodes( [ 'macnode', 'duidnode' ] );
|
|
is_deeply(
|
|
[ map { $_->{'hw-address'} } grep { $_->{'hw-address'} } @$matches ],
|
|
[ 'aa:bb:cc:dd:ee:ff', '01:23:45:67:89:ab:cd:ef:01' ],
|
|
'query and delete matches use canonical MAC addresses and omit malformed entries'
|
|
);
|
|
ok(
|
|
grep( { ( $_->{hostname} || '' ) eq 'badmac' } @$matches ),
|
|
'query and delete retain alias matches when the associated MAC is malformed'
|
|
);
|
|
ok(
|
|
grep( { ( $_->{duid} || '' ) eq '00:04:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff' } @$matches ),
|
|
'query and delete retain DUID matches when the associated MAC is malformed'
|
|
);
|
|
}
|
|
|
|
{
|
|
my %lookup_tables = (
|
|
noderes => DHCPKeaResTable->new(
|
|
{
|
|
unresolved01 => {},
|
|
valid01 => {},
|
|
}
|
|
),
|
|
chain => DHCPKeaResTable->new( {} ),
|
|
nodetype => DHCPKeaResTable->new( {} ),
|
|
iscsi => DHCPKeaResTable->new( {} ),
|
|
mac => DHCPKeaResTable->new(
|
|
{
|
|
unresolved01 => { mac => '00:11:22:33:44:55' },
|
|
valid01 => { mac => '00:11:22:33:44:66' },
|
|
}
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $lookup_tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::getipaddr = sub {
|
|
my ($host) = @_;
|
|
return $host eq 'valid01' ? '192.0.2.30' : undef;
|
|
};
|
|
local *xCAT_plugin::dhcp::ipIsDynamic = sub { return 0; };
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
local *xCAT_plugin::dhcp::kea_boot_for_node = sub { return {}; };
|
|
local *xCAT::MsgUtils::message = sub { return; };
|
|
local *xCAT::MsgUtils::trace = sub { return; };
|
|
|
|
my ( @warnings, @errors );
|
|
my $capture_response = sub {
|
|
my $response = shift;
|
|
push @warnings, @{ $response->{warning} || [] };
|
|
push @errors, @{ $response->{error} || [] };
|
|
};
|
|
my $saved_umask = umask;
|
|
my $saved_ignorecase = $Getopt::Long::ignorecase;
|
|
{
|
|
local @ARGV;
|
|
xCAT_plugin::dhcp::process_request(
|
|
{
|
|
_xcatpreprocessed => [0],
|
|
arg => [ '-q', '-a' ],
|
|
},
|
|
$capture_response
|
|
);
|
|
}
|
|
umask $saved_umask;
|
|
$Getopt::Long::ignorecase = $saved_ignorecase;
|
|
Getopt::Long::Configure('pass_through');
|
|
@warnings = ();
|
|
@errors = ();
|
|
|
|
{
|
|
package DHCPKeaLookupBackend;
|
|
sub subnet_id_for_ip { return 1; }
|
|
}
|
|
my $backend = bless {}, 'DHCPKeaLookupBackend';
|
|
my $reservations = xCAT_plugin::dhcp::kea_build_node_reservations(
|
|
$backend,
|
|
{},
|
|
[ 'unresolved01', 'valid01' ]
|
|
);
|
|
|
|
if ( ref($reservations) eq 'HASH' && $reservations->{error} ) {
|
|
push @errors, $reservations->{error};
|
|
$reservations = [];
|
|
}
|
|
|
|
is_deeply(
|
|
[ map { $_->{hostname} } @$reservations ],
|
|
['valid01'],
|
|
'an unresolved hostname does not block later valid Kea reservations'
|
|
);
|
|
is_deeply(
|
|
\@warnings,
|
|
['The hostname unresolved01 of node unresolved01 could not be resolved.'],
|
|
'an unresolved Kea hostname reports the ISC-compatible warning'
|
|
);
|
|
is_deeply( \@errors, [], 'an unresolved Kea hostname does not abort the request' );
|
|
}
|
|
|
|
{
|
|
my %xnba_tables = (
|
|
noderes => DHCPKeaResTable->new( { xnba01 => { netboot => 'xnba' } } ),
|
|
mac => DHCPKeaResTable->new( { xnba01 => { mac => 'AA-BB-CC-DD-EE-FF' } } ),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $xnba_tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
|
|
my $classes = xCAT_plugin::dhcp::kea_node_client_classes_for_nodes(['xnba01'])->{classes};
|
|
my ($bios_class) = grep { $_->{name} =~ /-bios\z/ } @$classes;
|
|
ok( $bios_class, 'hyphenated xNBA MAC produces a BIOS client class' );
|
|
is(
|
|
$bios_class ? $bios_class->{'user-context'}{'xcat-mac'} : undef,
|
|
'aa:bb:cc:dd:ee:ff',
|
|
'xNBA client-class context stores the canonical MAC address'
|
|
);
|
|
}
|
|
|
|
{
|
|
# The xNBA and iPXE node classes depend on the netboot method of each node and on whether it
|
|
# boots from SAN, so each record their builder gets carries both.
|
|
my %tables = (
|
|
noderes => DHCPKeaResTable->new( {
|
|
ipxe01 => { netboot => 'ipxe' },
|
|
san01 => { netboot => 'ipxe' },
|
|
san02 => { netboot => 'ipxe' },
|
|
xnba01 => { netboot => 'xnba' },
|
|
grub01 => { netboot => 'grub2' },
|
|
} ),
|
|
mac => DHCPKeaResTable->new( {
|
|
ipxe01 => { mac => '52:54:00:00:20:01' },
|
|
san01 => { mac => '52:54:00:00:20:02' },
|
|
san02 => { mac => '52:54:00:00:20:03' },
|
|
xnba01 => { mac => '52:54:00:00:20:04' },
|
|
grub01 => { mac => '52:54:00:00:20:05' },
|
|
} ),
|
|
iscsi => DHCPKeaResTable->new( { san01 => { server => '192.0.2.5', target => 'iqn.2026-01.test:disk1' }, san02 => { server => '192.0.2.5' } } ),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
my $records;
|
|
local *xCAT::DHCP::BootPolicy::kea_xnba_node_classes = sub {
|
|
my ( $class, %opts ) = @_;
|
|
$records = $opts{nodes};
|
|
return [];
|
|
};
|
|
|
|
xCAT_plugin::dhcp::kea_node_client_classes_for_nodes( [qw(ipxe01 san01 san02 xnba01 grub01)] );
|
|
is_deeply(
|
|
{ map { $_->{node} => [ $_->{netboot}, $_->{iscsi} ] } @{ $records || [] } },
|
|
{ ipxe01 => [ 'ipxe', 0 ], san01 => [ 'ipxe', 1 ], san02 => [ 'ipxe', 0 ], xnba01 => [ 'xnba', 0 ] },
|
|
'ipxe and xnba nodes get node classes with their method, and an iscsi entry without a target is no SAN disk'
|
|
);
|
|
}
|
|
|
|
{
|
|
# Regression: a node whose mac table entry uses the *NOIP* sentinel for a
|
|
# secondary NIC (e.g. "mac1|mac2!*NOIP*") must still get exactly one Kea
|
|
# reservation -- for the real NIC only. The *NOIP* NIC intentionally has no
|
|
# IP, so it must be skipped the same way the ISC path skips it. Resolving
|
|
# the literal "*NOIP*" as a host would otherwise emit a bogus second
|
|
# reservation (or, on branches that treat an unresolved reservation as
|
|
# fatal, abort makedhcp and leave the node with no reservation at all).
|
|
my %noip_tables = (
|
|
noderes => DHCPKeaResTable->new( { cn01 => { netboot => 'xnba', tftpserver => '<xcatmaster>' } } ),
|
|
chain => DHCPKeaResTable->new( { cn01 => {} } ),
|
|
nodetype => DHCPKeaResTable->new( { cn01 => { arch => 'x86_64', provmethod => 'install', os => 'rhels9' } } ),
|
|
iscsi => DHCPKeaResTable->new( {} ),
|
|
vpd => DHCPKeaResTable->new( {} ),
|
|
mac => DHCPKeaResTable->new(
|
|
{ cn01 => { mac => 'aa:bb:cc:dd:ee:01|aa:bb:cc:dd:ee:02!*NOIP*' } }
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $noip_tables{$name};
|
|
};
|
|
|
|
# Resolve *every* hostname (including the literal *NOIP*) so the only thing
|
|
# that can keep this to a single reservation is the explicit *NOIP* skip --
|
|
# this makes the guard independent of how unresolved names are handled.
|
|
my $noip_getipaddr = sub {
|
|
my ( $host, %opt ) = @_;
|
|
return '2001:db8::30' if $opt{OnlyV6};
|
|
return '192.0.2.30';
|
|
};
|
|
local *xCAT::NetworkUtils::getipaddr = $noip_getipaddr;
|
|
local *xCAT_plugin::dhcp::getipaddr = $noip_getipaddr;
|
|
local *xCAT_plugin::dhcp::ipIsDynamic = sub { return 0; };
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
local *xCAT_plugin::dhcp::kea_boot_for_node = sub { return {}; };
|
|
|
|
my @errors;
|
|
local $xCAT_plugin::dhcp::callback = sub {
|
|
my $resp = shift;
|
|
push @errors, @{ $resp->{error} } if $resp->{error};
|
|
};
|
|
|
|
my $backend = bless {}, 'DHCPKeaResBackend'; # subnet_id_for_ip defined above
|
|
|
|
my $res4 = xCAT_plugin::dhcp::kea_build_node_reservations( $backend, {}, ['cn01'] );
|
|
is( scalar(@errors), 0, 'NOIP secondary NIC does not raise an error (v4)' );
|
|
is( scalar( @{ $res4 || [] } ), 1, 'NOIP NIC skipped: exactly one IPv4 reservation' );
|
|
is( ( $res4->[0] || {} )->{'hw-address'}, 'aa:bb:cc:dd:ee:01', 'IPv4 reservation is for the real NIC, not the *NOIP* NIC' );
|
|
ok( !grep( { ( $_->{hostname} || '' ) eq '*NOIP*' } @{ $res4 || [] } ), 'no IPv4 reservation carries the *NOIP* sentinel as a hostname' );
|
|
|
|
@errors = ();
|
|
my $res6 = xCAT_plugin::dhcp::kea_build_node_reservations6( $backend, {}, ['cn01'] );
|
|
is( scalar(@errors), 0, 'NOIP secondary NIC does not raise an error (v6)' );
|
|
is( scalar( @{ $res6 || [] } ), 1, 'NOIP NIC skipped: exactly one IPv6 reservation' );
|
|
is( ( $res6->[0] || {} )->{'hw-address'}, 'aa:bb:cc:dd:ee:01', 'IPv6 reservation is for the real NIC, not the *NOIP* NIC' );
|
|
ok( !grep( { ( $_->{hostname} || '' ) eq '*NOIP*' } @{ $res6 || [] } ), 'no IPv6 reservation carries the *NOIP* sentinel as a hostname' );
|
|
}
|
|
|
|
{
|
|
my %range_tables = (
|
|
noderes => DHCPKeaResTable->new( { range01 => {} } ),
|
|
chain => DHCPKeaResTable->new( { range01 => {} } ),
|
|
nodetype => DHCPKeaResTable->new( { range01 => {} } ),
|
|
iscsi => DHCPKeaResTable->new( {} ),
|
|
vpd => DHCPKeaResTable->new( {} ),
|
|
mac => DHCPKeaResTable->new(
|
|
{
|
|
range01 => {
|
|
mac => '00:11:22:33:44:55!dynamic-host|00:11:22:33:44:66!static-host',
|
|
},
|
|
}
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $range_tables{$name};
|
|
};
|
|
my $use_ipv4_v6_fallback = 0;
|
|
local *xCAT_plugin::dhcp::getipaddr = sub {
|
|
my ( $host, %opt ) = @_;
|
|
if ( $opt{OnlyV6} ) {
|
|
return '192.0.2.150' if $use_ipv4_v6_fallback && $host eq 'dynamic-host';
|
|
return $host eq 'dynamic-host' ? '2001:db8::150' : '2001:db8::25';
|
|
}
|
|
return $host eq 'dynamic-host' ? '192.0.2.150' : '192.0.2.25';
|
|
};
|
|
local *xCAT_plugin::dhcp::ipIsDynamic = sub {
|
|
my ($ip) = @_;
|
|
return $ip eq '192.0.2.150' || $ip eq '2001:db8::150';
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return; };
|
|
local *xCAT_plugin::dhcp::kea_boot_for_node = sub { return {}; };
|
|
local *xCAT::MsgUtils::message = sub { return; };
|
|
local *xCAT::MsgUtils::trace = sub { return; };
|
|
|
|
my @errors;
|
|
my $capture_response = sub {
|
|
my $response = shift;
|
|
push @errors, @{ $response->{error} || [] };
|
|
};
|
|
my $saved_umask = umask;
|
|
my $saved_ignorecase = $Getopt::Long::ignorecase;
|
|
{
|
|
local @ARGV;
|
|
xCAT_plugin::dhcp::process_request(
|
|
{
|
|
_xcatpreprocessed => [0],
|
|
arg => [ '-q', '-a' ],
|
|
},
|
|
$capture_response
|
|
);
|
|
}
|
|
umask $saved_umask;
|
|
$Getopt::Long::ignorecase = $saved_ignorecase;
|
|
Getopt::Long::Configure('pass_through');
|
|
@errors = ();
|
|
|
|
my $backend = bless {}, 'DHCPKeaResBackend';
|
|
my $reservations4 = xCAT_plugin::dhcp::kea_build_node_reservations( $backend, {}, ['range01'] );
|
|
is_deeply(
|
|
[ map { $_->{'ip-address'} } @$reservations4 ],
|
|
['192.0.2.25'],
|
|
'Kea IPv4 omits a reservation whose address is in a dynamic range'
|
|
);
|
|
is_deeply(
|
|
\@errors,
|
|
[
|
|
'Node range01 has IP 192.0.2.150 which is inside the DHCP dynamic range. '
|
|
. 'Move the node IP outside the dynamic range or adjust the range in the networks table.'
|
|
],
|
|
'Kea IPv4 reports the dynamic-range conflict and continues with later interfaces'
|
|
);
|
|
|
|
@errors = ();
|
|
my $reservations6 = xCAT_plugin::dhcp::kea_build_node_reservations6( $backend, {}, ['range01'] );
|
|
is_deeply(
|
|
[ map { $_->{'ip-addresses'}->[0] } @$reservations6 ],
|
|
['2001:db8::25'],
|
|
'Kea IPv6 omits a reservation whose address is in a dynamic range'
|
|
);
|
|
is_deeply(
|
|
\@errors,
|
|
[
|
|
'Node range01 has IPv6 address 2001:db8::150 which is inside the DHCP dynamic range. '
|
|
. 'Move the node IP outside the dynamic range or adjust the range in the networks table.'
|
|
],
|
|
'Kea IPv6 reports the dynamic-range conflict and continues with later interfaces'
|
|
);
|
|
|
|
$use_ipv4_v6_fallback = 1;
|
|
@errors = ();
|
|
$reservations6 = xCAT_plugin::dhcp::kea_build_node_reservations6( $backend, {}, ['range01'] );
|
|
is_deeply(
|
|
[ map { $_->{'ip-addresses'}->[0] } @$reservations6 ],
|
|
['2001:db8::25'],
|
|
'Kea IPv6 still omits a dynamic IPv4 fallback result'
|
|
);
|
|
is_deeply(
|
|
\@errors,
|
|
[
|
|
'Node range01 has IPv6 address 192.0.2.150 which is inside the DHCP dynamic range. '
|
|
. 'Move the node IP outside the dynamic range or adjust the range in the networks table.'
|
|
],
|
|
'Kea IPv6 preserves dynamic-range checking when OnlyV6 falls back to IPv4'
|
|
);
|
|
}
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
my ( @checked, @responses );
|
|
local *xCAT_plugin::dhcp::ipIsDynamic = sub {
|
|
my ($ip) = @_;
|
|
push @checked, $ip;
|
|
return $ip eq '192.0.2.150' || $ip eq '2001:db8::150';
|
|
};
|
|
my $capture_response = sub { push @responses, shift; };
|
|
|
|
ok(
|
|
!xCAT_plugin::dhcp::_reject_dynamic_node_ip( 'range01', '192.0.2.25', 'IPv4', $capture_response ),
|
|
'an IPv4 address outside the dynamic range is accepted'
|
|
);
|
|
ok(
|
|
xCAT_plugin::dhcp::_reject_dynamic_node_ip( 'range01', '192.0.2.150', 'IPv4', $capture_response ),
|
|
'an IPv4 address inside the dynamic range is rejected'
|
|
);
|
|
ok(
|
|
xCAT_plugin::dhcp::_reject_dynamic_node_ip( 'range01', '2001:db8::150', 'IPv6', $capture_response ),
|
|
'an IPv6 address inside the dynamic range is rejected'
|
|
);
|
|
is_deeply(
|
|
\@checked,
|
|
[ '192.0.2.25', '192.0.2.150', '2001:db8::150' ],
|
|
'the shared helper checks each caller-approved address'
|
|
);
|
|
is_deeply(
|
|
\@responses,
|
|
[
|
|
{
|
|
error => [
|
|
'Node range01 has IP 192.0.2.150 which is inside the DHCP dynamic range. '
|
|
. 'Move the node IP outside the dynamic range or adjust the range in the networks table.'
|
|
],
|
|
errorcode => [1],
|
|
},
|
|
{
|
|
error => [
|
|
'Node range01 has IPv6 address 2001:db8::150 which is inside the DHCP dynamic range. '
|
|
. 'Move the node IP outside the dynamic range or adjust the range in the networks table.'
|
|
],
|
|
errorcode => [1],
|
|
},
|
|
],
|
|
'dynamic IPv4 and IPv6 addresses keep the existing callback payloads'
|
|
);
|
|
}
|
|
|
|
{
|
|
# A client that speaks BOOTP and not DHCP. ISC serves it from
|
|
# "range dynamic-bootp"; Kea answers it only with the bootp hook loaded,
|
|
# and without the hook such a machine times out for ever with nothing on
|
|
# the wire to say why.
|
|
no warnings 'redefine';
|
|
local *xCAT::NetworkUtils::thishostisnot = sub { return 0; };
|
|
local *xCAT_plugin::dhcp::kea_boot_client_classes = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_option_defs = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_global_option_data = sub { return []; };
|
|
local *xCAT_plugin::dhcp::kea_dhcp_lease_time = sub { return 43200; };
|
|
local *xCAT_plugin::dhcp::kea_control_agent_enabled = sub { return 0; };
|
|
local $xCAT::Table::networks = DHCPKeaIntentNetTable->new( \%network_entry );
|
|
|
|
my @warnings;
|
|
local $xCAT_plugin::dhcp::callback = sub {
|
|
my $resp = shift;
|
|
push @warnings, @{ $resp->{warning} } if $resp->{warning};
|
|
};
|
|
|
|
my $served = xCAT_plugin::dhcp::kea_build_dhcp4_intent(
|
|
DHCPKeaIntentBackend->new(), { eth0 => 1 } );
|
|
is_deeply(
|
|
$served->{'hooks-libraries'},
|
|
[ { library => '/test/libdhcp_bootp.so' } ],
|
|
'the bootp hook is loaded so a BOOTP-only client is answered',
|
|
);
|
|
is_deeply( \@warnings, [], 'and nothing is reported when it is there' );
|
|
|
|
# The Control Agent hook and the BOOTP hook are separate decisions, and
|
|
# both belong in the list rather than one replacing the other.
|
|
{
|
|
local *xCAT_plugin::dhcp::kea_control_agent_enabled = sub { return 1; };
|
|
my $both = xCAT_plugin::dhcp::kea_build_dhcp4_intent(
|
|
DHCPKeaIntentBackend->new( kea_socket_dir => '/run/kea-xcat-test' ),
|
|
{ eth0 => 1 } );
|
|
is_deeply(
|
|
[ map { $_->{library} } @{ $both->{'hooks-libraries'} } ],
|
|
[ '/test/libdhcp_host_cmds.so', '/test/libdhcp_bootp.so' ],
|
|
'loading one hook does not drop the other',
|
|
);
|
|
}
|
|
|
|
{
|
|
package DHCPKeaNoBootpBackend;
|
|
our @ISA = ('DHCPKeaIntentBackend');
|
|
sub bootp_hook_path { return; }
|
|
}
|
|
@warnings = ();
|
|
my $unserved = xCAT_plugin::dhcp::kea_build_dhcp4_intent(
|
|
DHCPKeaNoBootpBackend->new(), { eth0 => 1 } );
|
|
ok( !$unserved->{'hooks-libraries'},
|
|
'a hook that is not installed is not named in the configuration' );
|
|
is_deeply(
|
|
\@warnings,
|
|
['libdhcp_bootp.so was not found, so BOOTP-only clients will not be answered. Install the Kea hooks package to serve them.'],
|
|
'and the operator is told which clients that leaves unanswered',
|
|
);
|
|
}
|
|
|
|
{
|
|
# Two netboot methods the Kea path used to answer differently from ISC.
|
|
#
|
|
# nimol: ISC supersedes server.filename with /vios/nodes/<node>; Kea named no
|
|
# boot file, so a VIOS install got nothing to fetch.
|
|
#
|
|
# petitboot: ISC sends the conf-file option and nothing else. Kea also set
|
|
# boot-file-name, and petitboot acts on one when it sees it, sending the
|
|
# machine after a file that was never put there.
|
|
my $nimol = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'vios01', { netboot => 'nimol' }, undef, undef, undef, '192.0.2.1'
|
|
);
|
|
is( $nimol->{'boot-file-name'}, '/vios/nodes/vios01',
|
|
'a nimol node is given the boot file the ISC path supersedes' );
|
|
|
|
my $petitboot = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'pb01', { netboot => 'petitboot' }, undef, undef, undef, '192.0.2.1'
|
|
);
|
|
ok( !exists $petitboot->{'boot-file-name'},
|
|
'a petitboot node is named no boot file: the conf-file is the whole answer' );
|
|
my ($conf_file) = grep { $_->{name} eq 'conf-file' } @{ $petitboot->{'option-data'} };
|
|
is(
|
|
$conf_file ? $conf_file->{data} : undef,
|
|
'http://192.0.2.1/tftpboot/petitboot/pb01',
|
|
'the petitboot conf-file URL matches the ISC statement',
|
|
);
|
|
|
|
# Without a next server there is no URL to build, and a boot file name is
|
|
# still not an answer petitboot can use.
|
|
my $unserved = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'pb02', { netboot => 'petitboot' }, undef, undef, undef, undef
|
|
);
|
|
ok( !exists $unserved->{'boot-file-name'},
|
|
'a petitboot node with no next server is left alone rather than sent to TFTP' );
|
|
is_deeply(
|
|
[ grep { $_->{name} eq 'conf-file' } @{ $unserved->{'option-data'} } ],
|
|
[],
|
|
'no conf-file is invented without a next server',
|
|
);
|
|
}
|
|
|
|
{
|
|
# A ScaleMP hypervisor and an ISAN iSCSI initiator both need a different
|
|
# answer from the machine next to them, and on ISC both are an if/else in the
|
|
# node's host block. A Kea reservation outranks every class, so neither the
|
|
# pxe boot file nor an ISAN node's root path is reserved: what the
|
|
# reservation does not name, a class can decide.
|
|
my $pxe = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'cn01', { netboot => 'pxe' }, undef, undef, undef, '192.0.2.1'
|
|
);
|
|
ok( !exists $pxe->{'boot-file-name'},
|
|
'a pxe node reserves no boot file, so the ScaleMP class can win' );
|
|
|
|
my $iscsi = { server => '192.0.2.9', target => 'iqn.2024-01.test:cn01', lun => 0 };
|
|
my $plain = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'cn02', {}, undef, undef, $iscsi, '192.0.2.1'
|
|
);
|
|
my ($root_path) = grep { $_->{name} eq 'root-path' } @{ $plain->{'option-data'} };
|
|
is(
|
|
$root_path ? $root_path->{data} : undef,
|
|
'iscsi:192.0.2.9:6:3260:0:iqn.2024-01.test:cn01',
|
|
'without an initiator name there is no choice to make, so the root path is reserved',
|
|
);
|
|
|
|
my $named = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'cn03', {}, undef, undef, { %$iscsi, iname => 'iqn.2024-01.test:init' },
|
|
'192.0.2.1'
|
|
);
|
|
is_deeply(
|
|
[ grep { $_->{name} =~ /^(root-path|iscsi-initiator-iqn)$/ } @{ $named->{'option-data'} } ],
|
|
[],
|
|
'with one, nothing is reserved: an ISAN initiator must not be sent option 17',
|
|
);
|
|
}
|
|
|
|
{
|
|
# ...and the classes that carry what the reservation gave up.
|
|
my %tables = (
|
|
noderes => DHCPKeaResTable->new(
|
|
{ smp01 => { netboot => 'pxe' }, san01 => { netboot => 'pxe' } }
|
|
),
|
|
mac => DHCPKeaResTable->new(
|
|
{
|
|
smp01 => { mac => 'aa:bb:cc:dd:ee:01' },
|
|
san01 => { mac => 'aa:bb:cc:dd:ee:02' },
|
|
}
|
|
),
|
|
iscsi => DHCPKeaResTable->new(
|
|
{
|
|
san01 => {
|
|
server => '192.0.2.9',
|
|
target => 'iqn.2024-01.test:san01',
|
|
lun => 0,
|
|
iname => 'iqn.2024-01.test:init',
|
|
},
|
|
}
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
|
|
my $classes = xCAT_plugin::dhcp::kea_node_client_classes_for_nodes( [ 'smp01', 'san01' ] )->{classes};
|
|
my %by_name = map { $_->{name} => $_ } @$classes;
|
|
|
|
is( $by_name{'xcat-pxe-smp01-aabbccddee01-scalemp'}{'boot-file-name'},
|
|
'vsmp/pxelinux.0',
|
|
'a ScaleMP machine is handed the binary built for it' );
|
|
is( $by_name{'xcat-pxe-smp01-aabbccddee01'}{'boot-file-name'},
|
|
'pxelinux.0',
|
|
'and every other machine on that reservation keeps pxelinux.0' );
|
|
like( $by_name{'xcat-pxe-smp01-aabbccddee01'}{test}, qr/\Qnot (option[60].text == 'ScaleMP')\E/,
|
|
'the two are mutually exclusive: Kea has no else to fall into' );
|
|
|
|
# The empty container comes first: an encapsulated space travels only inside
|
|
# the option that encapsulates it, and option 43 carries no data of its own,
|
|
# so without it Kea sends neither sub-option. ISC builds the container from
|
|
# the sub-option declarations.
|
|
is_deeply(
|
|
$by_name{'xcat-iscsi-san01-aabbccddee02-isan'}{'option-data'},
|
|
[
|
|
{ name => 'isan-encap-opts' },
|
|
{ space => 'isan', name => 'iqn', data => 'iqn.2024-01.test:init' },
|
|
{ space => 'isan', name => 'root-path', data => 'iscsi:192.0.2.9:6:3260:0:iqn.2024-01.test:san01' },
|
|
],
|
|
'an ISAN initiator reads both values out of the vendor space',
|
|
);
|
|
is_deeply(
|
|
$by_name{'xcat-iscsi-san01-aabbccddee02'}{'option-data'},
|
|
[
|
|
{ name => 'root-path', data => 'iscsi:192.0.2.9:6:3260:0:iqn.2024-01.test:san01' },
|
|
{ name => 'iscsi-initiator-iqn', data => 'iqn.2024-01.test:init' },
|
|
],
|
|
'everything else gets the standard form ISC emits',
|
|
);
|
|
|
|
ok( !exists $by_name{'xcat-iscsi-smp01-aabbccddee01'},
|
|
'a node with no iscsi entry is given no iSCSI classes' );
|
|
|
|
# The option 43 space those two names live in has to be declared, or Kea
|
|
# rejects the configuration outright.
|
|
my %defs = map { ( $_->{space} . '/' . $_->{name} ) => $_ } @{ xCAT_plugin::dhcp::kea_option_defs() };
|
|
is( $defs{'dhcp4/isan-encap-opts'}{code}, 43, 'option 43 encapsulates the isan space' );
|
|
is( $defs{'dhcp4/isan-encap-opts'}{encapsulate}, 'isan', 'and says which space that is' );
|
|
is( $defs{'isan/iqn'}{code}, 203, 'the initiator name is sub-option 203' );
|
|
is( $defs{'isan/root-path'}{code}, 201, 'the root path is sub-option 201' );
|
|
}
|
|
|
|
{
|
|
# A NIC marked *NOIP* is meant to be answered with nothing. ISC writes
|
|
# "deny booting;"; Kea discards a packet assigned to the class named DROP,
|
|
# and nothing else will do -- skipping the reservation still leaves the
|
|
# architecture classes handing the interface a boot file.
|
|
my %tables = (
|
|
noderes => DHCPKeaResTable->new(
|
|
{ cn01 => { netboot => 'xnba' }, cn02 => { netboot => 'pxe' } }
|
|
),
|
|
mac => DHCPKeaResTable->new(
|
|
{
|
|
cn01 => { mac => 'aa:bb:cc:dd:ee:01|aa:bb:cc:dd:ee:11!*NOIP*' },
|
|
cn02 => { mac => 'aa:bb:cc:dd:ee:02!*NOIP*' },
|
|
}
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
|
|
my $config = { Dhcp4 => { 'client-classes' => [] } };
|
|
ok( xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, [ 'cn01', 'cn02' ] ),
|
|
'marking an interface *NOIP* changes the configuration' );
|
|
|
|
my ($drop) = grep { $_->{name} eq 'DROP' } @{ $config->{Dhcp4}{'client-classes'} };
|
|
ok( $drop, 'the MACs land in the one class Kea treats as a discard' );
|
|
is( $drop->{test},
|
|
'pkt4.mac == 0xaabbccddee11 or pkt4.mac == 0xaabbccddee02',
|
|
'every marked MAC in the range is named, and only those' );
|
|
|
|
# cn01's real NIC is untouched: the marking is per interface, not per node.
|
|
ok( ( grep { $_->{name} =~ /aabbccddee01/ } @{ $config->{Dhcp4}{'client-classes'} } ),
|
|
'the node\'s addressed NIC still gets its boot classes' );
|
|
|
|
# Re-running makedhcp for one node must not take the other node's
|
|
# interfaces out of the class they share.
|
|
xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, ['cn01'] );
|
|
($drop) = grep { $_->{name} eq 'DROP' } @{ $config->{Dhcp4}{'client-classes'} };
|
|
is( $drop->{test},
|
|
'pkt4.mac == 0xaabbccddee11 or pkt4.mac == 0xaabbccddee02',
|
|
'a makedhcp for one node leaves the rest of the cluster in the DROP class' );
|
|
|
|
# ...and makedhcp -d for a node takes only that node's out.
|
|
ok( xCAT_plugin::dhcp::kea_remove_node_client_classes( $config, ['cn02'] ),
|
|
'removing a node with a marked interface changes the configuration' );
|
|
($drop) = grep { $_->{name} eq 'DROP' } @{ $config->{Dhcp4}{'client-classes'} };
|
|
is( $drop->{test}, 'pkt4.mac == 0xaabbccddee11',
|
|
'and leaves the other node still dropped' );
|
|
|
|
xCAT_plugin::dhcp::kea_remove_node_client_classes( $config, ['cn01'] );
|
|
is_deeply(
|
|
[ grep { $_->{name} eq 'DROP' } @{ $config->{Dhcp4}{'client-classes'} } ],
|
|
[],
|
|
'with nothing left to drop the class goes rather than matching nothing',
|
|
);
|
|
}
|
|
|
|
{
|
|
# A node that has an operating system, and a Windows UEFI install waiting on
|
|
# proxyDHCP, both get no boot file. ISC writes filename = "" into the host
|
|
# block, which outranks the subnet chain.
|
|
#
|
|
# On Kea the empty boot-file-name below is necessary and not sufficient: Kea
|
|
# reads it as "not specified" and falls through to the classes. What stops
|
|
# them is the xcat-localboot class, asserted further down.
|
|
no warnings 'redefine';
|
|
local *xCAT_plugin::dhcp::proxydhcp = sub { return 1; };
|
|
|
|
foreach my $state (qw(boot iscsiboot)) {
|
|
my $booted = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'cn01', { netboot => 'xnba' }, { currstate => $state }, undef, undef, '192.0.2.1'
|
|
);
|
|
is( $booted->{'boot-file-name'}, '',
|
|
"a node in state $state is handed no boot file rather than left to the subnet" );
|
|
}
|
|
|
|
my $installing = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'win01', { netboot => 'xnba' }, { currstate => 'install' },
|
|
{ os => 'win2022' }, undef, '192.0.2.1'
|
|
);
|
|
is( $installing->{'boot-file-name'}, '',
|
|
'a Windows UEFI install names no boot file, which is what defers it to proxyDHCP' );
|
|
|
|
# ...and the same node on a Linux install is not deferred to anything.
|
|
my $linux = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'cn02', { netboot => 'pxe' }, { currstate => 'install' },
|
|
{ os => 'rhels9' }, undef, '192.0.2.1'
|
|
);
|
|
ok( !exists $linux->{'boot-file-name'},
|
|
'a Linux install is left to its classes as before' );
|
|
|
|
# An iSCSI node told to boot from disk still needs its root path: it is
|
|
# what the disk is.
|
|
my $iscsi = xCAT_plugin::dhcp::kea_boot_for_node(
|
|
'cn03', {}, { currstate => 'iscsiboot' }, undef,
|
|
{ server => '192.0.2.9', target => 'iqn.2024-01.test:cn03', lun => 0 },
|
|
'192.0.2.1'
|
|
);
|
|
is( $iscsi->{'boot-file-name'}, '', 'an iscsiboot node is handed no boot file' );
|
|
ok( ( grep { $_->{name} eq 'root-path' } @{ $iscsi->{'option-data'} } ),
|
|
'but it keeps the root path that says where its disk is' );
|
|
}
|
|
|
|
{
|
|
my %tables = (
|
|
noderes => DHCPKeaResTable->new(
|
|
{
|
|
booted => { netboot => 'xnba' },
|
|
win01 => { netboot => 'xnba' },
|
|
}
|
|
),
|
|
mac => DHCPKeaResTable->new(
|
|
{
|
|
booted => { mac => 'aa:bb:cc:dd:ee:03' },
|
|
win01 => { mac => 'aa:bb:cc:dd:ee:04' },
|
|
}
|
|
),
|
|
chain => DHCPKeaResTable->new(
|
|
{ booted => { currstate => 'boot' }, win01 => { currstate => 'install' } }
|
|
),
|
|
nodetype => DHCPKeaResTable->new(
|
|
{ booted => { os => 'rhels9' }, win01 => { os => 'win2022' } }
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
local *xCAT_plugin::dhcp::proxydhcp = sub { return 1; };
|
|
|
|
my $classes = xCAT_plugin::dhcp::kea_node_client_classes_for_nodes( [ 'booted', 'win01' ] )->{classes};
|
|
my %by_name = map { $_->{name} => $_ } @$classes;
|
|
|
|
is_deeply(
|
|
[ grep { /booted/ } keys %by_name ],
|
|
[],
|
|
'a node booting from disk is given no second stage to chainload',
|
|
);
|
|
|
|
my $deferral = $by_name{'xcat-proxydhcp-win01-aabbccddee04'};
|
|
ok( $deferral, 'the Windows UEFI node gets the class that tags its reply' );
|
|
is_deeply(
|
|
$deferral->{'option-data'},
|
|
[ { name => 'vendor-class-identifier', data => 'PXEClient', 'always-send' => 1 } ],
|
|
'the tag is what sends the firmware to the daemon on 4011',
|
|
);
|
|
like( $deferral->{test}, qr/option\[93\]\.hex == 0x0000 or option\[93\]\.hex == 0x0007 or option\[93\]\.hex == 0x0009/,
|
|
'and only the architectures ISC tags are tagged' );
|
|
ok( !exists $by_name{'xcat-xnba-win01-aabbccddee04-bios'},
|
|
'the node gets no xNBA class that would pre-empt the deferral' );
|
|
}
|
|
|
|
{
|
|
# spec.md S-31. Withholding the node's classes and writing an empty
|
|
# boot-file-name does not stop an installed node being netbooted: Kea reads
|
|
# the empty string as "not specified" and the shared classes hand it a loader
|
|
# on architecture alone. It then returns as an xNBA second stage and is
|
|
# answered with the network's script, reinstalling on every power cycle.
|
|
#
|
|
# So the MACs go into one class that every boot-naming class excludes.
|
|
my %tables = (
|
|
noderes => DHCPKeaResTable->new(
|
|
{
|
|
booted => { netboot => 'xnba' },
|
|
bootpx => { netboot => 'pxe' },
|
|
}
|
|
),
|
|
mac => DHCPKeaResTable->new(
|
|
{
|
|
booted => { mac => 'aa:bb:cc:dd:ee:05' },
|
|
bootpx => { mac => 'aa:bb:cc:dd:ee:06' },
|
|
}
|
|
),
|
|
chain => DHCPKeaResTable->new(
|
|
{
|
|
booted => { currstate => 'boot' },
|
|
bootpx => { currstate => 'boot' },
|
|
}
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
|
|
# A class of the shape the architecture classes have: it names a boot file
|
|
# and so must learn to exclude these nodes.
|
|
my $config = {
|
|
Dhcp4 => {
|
|
'client-classes' => [
|
|
{ name => 'xcat-bios', test => 'option[93].hex == 0x0000', 'boot-file-name' => 'xcat/xnba.kpxe' },
|
|
{ name => 'xcat-opal', test => "option[93].hex == 0x000e", 'option-data' => [] },
|
|
],
|
|
},
|
|
};
|
|
|
|
ok( xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, [ 'booted', 'bootpx' ] ),
|
|
'a node that is to boot from its disk changes the configuration' );
|
|
|
|
my @names = map { $_->{name} } @{ $config->{Dhcp4}{'client-classes'} };
|
|
my ($localboot) = grep { $_->{name} eq 'xcat-localboot' } @{ $config->{Dhcp4}{'client-classes'} };
|
|
ok( $localboot, 'the installed nodes land in a class of their own' );
|
|
is( $localboot->{test},
|
|
'pkt4.mac == 0xaabbccddee05 or pkt4.mac == 0xaabbccddee06',
|
|
'both netboot methods are in it' );
|
|
is( $names[0], 'xcat-localboot',
|
|
'and it is defined first, because Kea rejects a member() test naming a class below it' );
|
|
|
|
my %by_name = map { $_->{name} => $_ } @{ $config->{Dhcp4}{'client-classes'} };
|
|
is( $by_name{'xcat-bios'}{test},
|
|
"(option[93].hex == 0x0000) and not member('xcat-localboot')",
|
|
'a class that names a boot file stops matching them' );
|
|
is( $by_name{'xcat-opal'}{test}, 'option[93].hex == 0x000e',
|
|
'a class that names none is left alone' );
|
|
|
|
# Regenerating must not wrap the test one layer deeper every time.
|
|
xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, ['booted'] );
|
|
%by_name = map { $_->{name} => $_ } @{ $config->{Dhcp4}{'client-classes'} };
|
|
is( $by_name{'xcat-bios'}{test},
|
|
"(option[93].hex == 0x0000) and not member('xcat-localboot')",
|
|
'and running makedhcp again writes the same test, not a nested one' );
|
|
is( $by_name{'xcat-localboot'}{test},
|
|
'pkt4.mac == 0xaabbccddee05 or pkt4.mac == 0xaabbccddee06',
|
|
'a makedhcp for one node leaves the rest of the cluster in the class' );
|
|
|
|
# With the last of them gone the guard has to come off: a member() test
|
|
# naming a class that no longer exists is a configuration Kea refuses.
|
|
xCAT_plugin::dhcp::kea_remove_node_client_classes( $config, [ 'booted', 'bootpx' ] );
|
|
%by_name = map { $_->{name} => $_ } @{ $config->{Dhcp4}{'client-classes'} };
|
|
ok( !exists $by_name{'xcat-localboot'}, 'with no installed node left the class goes' );
|
|
is( $by_name{'xcat-bios'}{test}, 'option[93].hex == 0x0000',
|
|
'and the guard naming it goes with it' );
|
|
}
|
|
|
|
{
|
|
# An installed node that boots from SAN: its own classes give the loader of its method to a
|
|
# client without iSCSI, and the local-boot class keeps every boot file from a client with iSCSI.
|
|
my %tables = (
|
|
noderes => DHCPKeaResTable->new( { san01 => { netboot => 'ipxe' }, booted => { netboot => 'xnba' } } ),
|
|
mac => DHCPKeaResTable->new( { san01 => { mac => 'aa:bb:cc:dd:ee:07' }, booted => { mac => 'aa:bb:cc:dd:ee:05' } } ),
|
|
chain => DHCPKeaResTable->new( { san01 => { currstate => 'iscsiboot' }, booted => { currstate => 'boot' } } ),
|
|
iscsi => DHCPKeaResTable->new( { san01 => { server => '192.0.2.9', target => 'iqn.2024-01.test:san01', lun => 0 } } ),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
|
|
my $config = {
|
|
Dhcp4 => {
|
|
'client-classes' => [
|
|
{ name => 'xcat-bios', test => 'option[93].hex == 0x0000', 'boot-file-name' => 'xcat/ipxe/i386/undionly.kpxe' },
|
|
],
|
|
},
|
|
};
|
|
xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, [ 'san01', 'booted' ] );
|
|
my %by_name = map { $_->{name} => $_ } @{ $config->{Dhcp4}{'client-classes'} };
|
|
is( $by_name{'xcat-localboot'}{test},
|
|
'pkt4.mac == 0xaabbccddee05 or (pkt4.mac == 0xaabbccddee07 and option[175].option[17].exists)',
|
|
'an installed SAN node is in the local-boot class for a client with iSCSI only' );
|
|
is( $by_name{'xcat-ipxe-san01-aabbccddee07-bios-san'}{'boot-file-name'}, 'xcat/ipxe/i386/undionly.kpxe',
|
|
'and gets the upstream loader on a BIOS client without iSCSI' );
|
|
like( $by_name{'xcat-ipxe-san01-aabbccddee07-bios-san'}{test}, qr/\Qand not member('xcat-localboot')\E$/,
|
|
'under the guard that every boot-file class carries' );
|
|
is( $by_name{'xcat-ipxe-san01-aabbccddee07-uefi-san'}{'boot-file-name'}, 'xcat/ipxe/x86_64-sb/snponly-shim.efi',
|
|
'and the upstream UEFI loader on a UEFI client without iSCSI' );
|
|
|
|
xCAT_plugin::dhcp::kea_remove_node_client_classes( $config, ['san01'] );
|
|
%by_name = map { $_->{name} => $_ } @{ $config->{Dhcp4}{'client-classes'} };
|
|
is( $by_name{'xcat-localboot'}{test}, 'pkt4.mac == 0xaabbccddee05',
|
|
'removing the SAN node takes it out of the local-boot class' );
|
|
ok( !grep( { /-san$/ } keys %by_name ), 'and removes its classes' );
|
|
}
|
|
|
|
{
|
|
# A Windows UEFI install deferred to proxyDHCP gets an empty boot-file-name
|
|
# in its reservation, and Kea reads that as "not specified". So its MAC
|
|
# must be excluded from every class that names a boot file, or
|
|
# xcat-uefi-x64 hands it xcat/xnba.efi.
|
|
my %tables = (
|
|
noderes => DHCPKeaResTable->new(
|
|
{
|
|
win01 => { netboot => 'xnba' },
|
|
cn01 => { netboot => 'xnba' },
|
|
}
|
|
),
|
|
mac => DHCPKeaResTable->new(
|
|
{
|
|
win01 => { mac => 'aa:bb:cc:dd:ee:08' },
|
|
cn01 => { mac => 'aa:bb:cc:dd:ee:09' },
|
|
}
|
|
),
|
|
chain => DHCPKeaResTable->new(
|
|
{ win01 => { currstate => 'install' }, cn01 => { currstate => 'install' } }
|
|
),
|
|
nodetype => DHCPKeaResTable->new(
|
|
{ win01 => { os => 'win2022' }, cn01 => { os => 'rhels9' } }
|
|
),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
local *xCAT_plugin::dhcp::proxydhcp = sub { return 1; };
|
|
|
|
my $config = {
|
|
Dhcp4 => {
|
|
'client-classes' => xCAT::DHCP::BootPolicy->kea_client_classes( ipxe_bios => 1, ipxe_uefi => 1 ),
|
|
},
|
|
};
|
|
xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, [ 'win01', 'cn01' ] );
|
|
my %by_name = map { $_->{name} => $_ } @{ $config->{Dhcp4}{'client-classes'} };
|
|
|
|
my $excluded = $by_name{'xcat-localboot'};
|
|
ok( $excluded, 'a proxyDHCP node puts its MAC in the class the boot-file classes exclude' );
|
|
is( $excluded ? $excluded->{test} : undef, 'pkt4.mac == 0xaabbccddee08',
|
|
'the proxyDHCP node is in it, and the node that netboots is not' );
|
|
|
|
my @unguarded = sort map { $_->{name} }
|
|
grep { defined $_->{'boot-file-name'} and $_->{test} !~ /\Qnot member('xcat-localboot')\E$/ }
|
|
@{ $config->{Dhcp4}{'client-classes'} };
|
|
is_deeply( \@unguarded, [], 'no class that names a boot file can match the proxyDHCP node' );
|
|
|
|
is( $by_name{'xcat-uefi-x64'}{'boot-file-name'}, 'xcat/ipxe/x86_64-sb/snponly-shim.efi',
|
|
'the UEFI class still names the upstream UEFI loader for every other client' );
|
|
|
|
ok( $by_name{'xcat-proxydhcp-win01-aabbccddee08'},
|
|
'the proxyDHCP node keeps the class that tags its reply PXEClient' );
|
|
unlike( $by_name{'xcat-proxydhcp-win01-aabbccddee08'}{test} // '', qr/xcat-localboot/,
|
|
'and that class, which names no boot file, is not excluded from it' );
|
|
}
|
|
|
|
{
|
|
# makedhcp <node> writes the loaded config back through encode_config, not
|
|
# through the renderer. Kea rejects an option flag written as the number 1.
|
|
my %tables = (
|
|
noderes => DHCPKeaResTable->new( { win01 => { netboot => 'xnba' } } ),
|
|
mac => DHCPKeaResTable->new( { win01 => { mac => 'aa:bb:cc:dd:ee:0a' } } ),
|
|
chain => DHCPKeaResTable->new( { win01 => { currstate => 'install' } } ),
|
|
nodetype => DHCPKeaResTable->new( { win01 => { os => 'win2022' } } ),
|
|
);
|
|
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub {
|
|
my ( $class, $name ) = @_;
|
|
return $tables{$name};
|
|
};
|
|
local *xCAT_plugin::dhcp::next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); };
|
|
local *xCAT_plugin::dhcp::proxydhcp = sub { return 1; };
|
|
|
|
my $config = { Dhcp4 => { 'client-classes' => [] } };
|
|
xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, ['win01'] );
|
|
my $written = JSON::decode_json( xCAT::DHCP::Backend::Kea->new()->encode_config($config) );
|
|
my ($deferral) = grep { $_->{name} eq 'xcat-proxydhcp-win01-aabbccddee0a' }
|
|
@{ $written->{Dhcp4}{'client-classes'} };
|
|
ok( $deferral, 'the proxyDHCP class is written by a makedhcp for one node' );
|
|
is( ref( $deferral ? $deferral->{'option-data'}[0]{'always-send'} : undef ), 'JSON::PP::Boolean',
|
|
'and its always-send flag is written as a boolean, which is what Kea parses' );
|
|
}
|
|
|
|
# Without the noderes or mac table there are no node classes. kea_sync_node_client_classes reads
|
|
# all three lists of the answer, so each one must be present, empty.
|
|
{
|
|
no warnings 'redefine';
|
|
local *xCAT::Table::new = sub { return; };
|
|
is_deeply( xCAT_plugin::dhcp::kea_node_client_classes_for_nodes(['cn01']),
|
|
{ classes => [], noip => [], localboot => [] },
|
|
'with no noderes or mac table there are no classes, no NOIP MACs and no localboot MACs' );
|
|
my $config = { Dhcp4 => { 'client-classes' => [] } };
|
|
ok( eval { xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, ['cn01'] ); 1 },
|
|
'syncing the node client classes with no noderes or mac table does not die' ) or diag($@);
|
|
}
|
|
|
|
done_testing();
|