2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-09-04 12:07:56 +00:00
Files
xcat-core/xCAT-test/unit/dhcp_kea_renderer.t
T
Vinícius Ferrão bcbc4c9c25 test(dhcp): cover shared version comparison
(cherry picked from commit 0ddc19e74b)
2026-07-19 20:07:15 +00:00

468 lines
20 KiB
Perl

use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/../../perl-xCAT";
use File::Temp qw/tempdir/;
use JSON;
use Test::More;
use xCAT::DHCP::Backend::Kea;
my $backend = xCAT::DHCP::Backend::Kea->new( kea_version => '2.4.1' );
my @additional_class_syntax_cases = (
[ '2.7.3', 0, 'Kea release below 2.7.4 uses legacy additional-class fields' ],
[ '2.7.4', 1, 'Kea 2.7.4 uses modern additional-class fields' ],
[ '2.10', 1, 'Kea two-digit minor release uses modern additional-class fields' ],
[ '2.7', 0, 'Kea release with fewer components stays below 2.7.4' ],
[ '2.7.4.0', 1, 'Kea release with a trailing zero component meets the minimum' ],
[ '2.7.4-rc1', 1, 'Kea suffix above the 2.7.4 numeric core keeps modern fields' ],
[ '2.7.3-rc1', 0, 'Kea suffix below the 2.7.4 numeric core keeps legacy fields' ],
[ '', 0, 'empty Kea version keeps legacy additional-class fields' ],
[ 'not-a-version', 0, 'malformed Kea version keeps legacy additional-class fields' ],
[ '2.7foo', 0, 'digit-leading malformed Kea version keeps legacy additional-class fields' ],
[ 'v2.7.4', 0, 'prefixed Kea version keeps legacy additional-class fields' ],
);
foreach my $case (@additional_class_syntax_cases) {
my ( $version, $modern, $description ) = @$case;
my $version_backend = xCAT::DHCP::Backend::Kea->new( kea_version => $version );
is( $version_backend->_use_modern_additional_class_syntax(), $modern, $description );
}
my $unit_dir = tempdir( CLEANUP => 1 );
foreach my $unit (qw/kea-dhcp4-server.service kea-dhcp6-server.service kea-dhcp-ddns-server.service kea-ctrl-agent.service/) {
open( my $unit_fh, '>', "$unit_dir/$unit" ) or die "Unable to write $unit_dir/$unit: $!";
close($unit_fh);
}
my $service_backend = xCAT::DHCP::Backend::Kea->new( service_unit_dirs => [$unit_dir] );
is( $service_backend->_kea_service('kea-dhcp4'), 'kea-dhcp4-server', 'Debian-style DHCPv4 service name is detected' );
is( $service_backend->_kea_service('kea-dhcp6'), 'kea-dhcp6-server', 'Debian-style DHCPv6 service name is detected' );
is( $service_backend->_kea_service('kea-dhcp-ddns'), 'kea-dhcp-ddns-server', 'Debian-style DHCP-DDNS service name is detected' );
is( $service_backend->_kea_service('kea-ctrl-agent'), 'kea-ctrl-agent', 'control agent service keeps canonical name' );
open( my $canonical_unit_fh, '>', "$unit_dir/kea-dhcp4.service" ) or die "Unable to write canonical Kea unit: $!";
close($canonical_unit_fh);
is( $service_backend->_kea_service('kea-dhcp4'), 'kea-dhcp4', 'canonical Kea service name is preferred when present' );
my $json = $backend->render_dhcp4_config(
{
interfaces => ['eth0'],
valid_lifetime => '600',
subnets => [
{
id => '1',
subnet => '10.0.0.0/24',
interface => 'eth0',
dynamicrange => '10.0.0.100-10.0.0.120;10.0.0.130,10.0.0.140',
next_server => '10.0.0.1',
additional_client_classes => ['xcat-opal-v3-10.0.0.0-24'],
option_data => [
{ name => 'routers', data => '10.0.0.1' },
{ name => 'domain-name-servers', data => '10.0.0.2, 10.0.0.3' },
{ name => 'domain-name', data => 'cluster.example.com' },
],
reservations => [
{
'hw-address' => 'aa:bb:cc:dd:ee:ff',
'ip-address' => '10.0.0.10',
hostname => 'node01',
},
],
},
],
'client-classes' => [
{
name => 'xcat-uefi-x64',
test => 'option[93].hex == 0x0007',
'boot-file-name' => 'xcat/xnba.efi',
},
{
name => 'xcat-opal-v3-10.0.0.0-24',
test => 'option[93].hex == 0x000e',
additional_only => JSON::true,
'option-data' => [
{ name => 'conf-file', data => 'http://10.0.0.1/tftpboot/pxelinux.cfg/p/10.0.0.0_24' },
],
},
],
}
);
my $config = decode_json($json);
ok( $config->{Dhcp4}, 'renderer creates a Dhcp4 document' );
is_deeply( $config->{Dhcp4}{'interfaces-config'}{interfaces}, ['eth0'], 'interfaces are rendered' );
is( $config->{Dhcp4}{'valid-lifetime'}, 600, 'valid lifetime is rendered' );
is( $config->{Dhcp4}{'lease-database'}{type}, 'memfile', 'memfile lease backend is the default' );
is( $config->{Dhcp4}{'reservations-in-subnet'}, JSON::true, 'subnet host reservations are enabled by default' );
is( $config->{Dhcp4}{'reservations-out-of-pool'}, JSON::true, 'out-of-pool host reservations are enabled for xCAT static addresses' );
is( $config->{Dhcp4}{'match-client-id'}, JSON::false, 'DHCPv4 leases match MAC reservations when client-id changes across boot stages' );
my $subnet = $config->{Dhcp4}{subnet4}[0];
is( $subnet->{id}, 1, 'subnet id is rendered' );
is( $subnet->{subnet}, '10.0.0.0/24', 'subnet CIDR is rendered' );
is( $subnet->{interface}, 'eth0', 'subnet interface is rendered' );
is( $subnet->{'next-server'}, '10.0.0.1', 'next-server is rendered' );
is_deeply(
$subnet->{pools},
[
{ pool => '10.0.0.100 - 10.0.0.120' },
{ pool => '10.0.0.130 - 10.0.0.140' },
],
'dynamicrange is rendered as Kea pools'
);
is_deeply(
$subnet->{'option-data'},
[
{ name => 'routers', data => '10.0.0.1' },
{ name => 'domain-name-servers', data => '10.0.0.2, 10.0.0.3' },
{ name => 'domain-name', data => 'cluster.example.com' },
],
'subnet option-data is preserved'
);
is_deeply(
$subnet->{'require-client-classes'},
['xcat-opal-v3-10.0.0.0-24'],
'subnet requests second-pass OPAL class evaluation for subnet-specific conf-file'
);
is_deeply(
$subnet->{reservations},
[
{
'hw-address' => 'aa:bb:cc:dd:ee:ff',
'ip-address' => '10.0.0.10',
hostname => 'node01',
},
],
'host reservations are preserved'
);
is_deeply(
$config->{Dhcp4}{'client-classes'},
[
{
name => 'xcat-uefi-x64',
test => 'option[93].hex == 0x0007',
'boot-file-name' => 'xcat/xnba.efi',
},
{
name => 'xcat-opal-v3-10.0.0.0-24',
test => 'option[93].hex == 0x000e',
'only-if-required' => JSON::true,
'option-data' => [
{ name => 'conf-file', data => 'http://10.0.0.1/tftpboot/pxelinux.cfg/p/10.0.0.0_24' },
],
},
],
'client classes are preserved, including subnet-specific OPAL conf-file class'
);
my $modern_backend = xCAT::DHCP::Backend::Kea->new( kea_version => '3.0.1' );
my $modern_json = $modern_backend->render_dhcp4_config(
{
subnets => [
{
id => 3,
subnet => '10.0.2.0/24',
pools => [],
additional_client_classes => ['xcat-opal-v3-10.0.2.0-24'],
},
],
'client-classes' => [
{
name => 'xcat-opal-v3-10.0.2.0-24',
test => 'option[93].hex == 0x000e',
additional_only => JSON::true,
},
],
}
);
my $modern_config = decode_json($modern_json);
my $modern_subnet = $modern_config->{Dhcp4}{subnet4}[0];
my $modern_class = $modern_config->{Dhcp4}{'client-classes'}[0];
is_deeply(
$modern_subnet->{'evaluate-additional-classes'},
['xcat-opal-v3-10.0.2.0-24'],
'Kea 3.x renders modern subnet additional-class evaluation field'
);
ok( !exists $modern_subnet->{'require-client-classes'}, 'Kea 3.x output omits deprecated subnet additional-class field' );
is( $modern_class->{'only-in-additional-list'}, JSON::true, 'Kea 3.x renders modern class additional-evaluation flag' );
ok( !exists $modern_class->{'only-if-required'}, 'Kea 3.x output omits deprecated class additional-evaluation flag' );
my $empty_boot_json = $backend->render_dhcp4_config(
{
subnets => [
{
id => 2,
subnet => '10.0.1.0/24',
pools => [],
next_server => '0.0.0.0',
boot_file_name => '',
},
],
}
);
my $empty_boot_subnet = decode_json($empty_boot_json)->{Dhcp4}{subnet4}[0];
is( $empty_boot_subnet->{'next-server'}, '0.0.0.0', 'false-looking next-server value is preserved' );
is( $empty_boot_subnet->{'boot-file-name'}, '', 'empty boot-file-name is preserved' );
my $reservation_policy_json = $backend->render_dhcp4_config(
{
'reservations-in-subnet' => 0,
'reservations-out-of-pool' => 0,
'match-client-id' => 1,
subnets => [
{
id => 9,
subnet => '10.0.9.0/24',
pools => [],
},
],
}
);
my $reservation_policy_config = decode_json($reservation_policy_json);
is( $reservation_policy_config->{Dhcp4}{'reservations-in-subnet'}, JSON::false, 'reservation in-subnet policy can be overridden' );
is( $reservation_policy_config->{Dhcp4}{'reservations-out-of-pool'}, JSON::false, 'reservation out-of-pool policy can be overridden' );
is( $reservation_policy_config->{Dhcp4}{'match-client-id'}, JSON::true, 'client-id lease matching policy can be overridden' );
my $client_id_policy_json = $backend->render_dhcp4_config(
{
interfaces => ['eth0'],
match_client_id => 1,
subnets => [],
}
);
my $client_id_policy_config = decode_json($client_id_policy_json);
is( $client_id_policy_config->{Dhcp4}{'match-client-id'}, JSON::true, 'DHCPv4 client-id matching can be explicitly restored' );
my $comment_dir = tempdir(CLEANUP => 1);
my $commented_config = "$comment_dir/kea-dhcp4.conf";
my $commented_content = <<'COMMENTED_JSON';
// Packaged Kea configs may contain comments before xCAT rewrites them.
{
"Dhcp4": {
"valid-lifetime": 600,
"subnet4": [
{
"id": 1,
"subnet": "10.20.0.0/24",
"pools": [], // Keep URLs such as "http://server/path" intact.
}
],
"loggers": [
{
"name": "kea-dhcp4",
"output-options": [
{
"output": "stdout",
"pattern": "%-5p %m\n",
// "flush": false
},
],
},
],
}
}
COMMENTED_JSON
open( my $comment_fh, '>', $commented_config ) or die "Unable to write $commented_config: $!";
print {$comment_fh} $commented_content;
close($comment_fh);
my $loaded_commented = $backend->load_dhcp4_config($commented_config);
ok( !$loaded_commented->{error}, 'Kea DHCPv4 loader accepts packaged JSON comments' );
is( $loaded_commented->{Dhcp4}{subnet4}[0]{subnet}, '10.20.0.0/24', 'commented Kea config is decoded' );
my $reservation_config = decode_json(
$backend->render_dhcp4_config(
{
subnets => [
{
id => 10,
subnet => '10.10.0.0/24',
pools => [],
},
],
}
)
);
$backend->upsert_reservations(
$reservation_config,
[
{
'subnet-id' => 10,
'hw-address' => '00:11:22:33:44:55',
'ip-address' => '10.10.0.12',
hostname => 'node12',
},
]
);
is( scalar @{ $reservation_config->{Dhcp4}{subnet4}[0]{reservations} }, 1, 'reservation is added to matching subnet' );
is_deeply( $reservation_config->{Dhcp4}{subnet4}[0]{pools}, [], 'out-of-pool static reservation does not require a dynamic pool' );
$backend->upsert_reservations(
$reservation_config,
[
{
'subnet-id' => 10,
'hw-address' => '00:11:22:33:44:55',
'ip-address' => '10.10.0.13',
hostname => 'node12',
},
]
);
is( scalar @{ $reservation_config->{Dhcp4}{subnet4}[0]{reservations} }, 1, 'matching reservation is replaced, not duplicated' );
is( $reservation_config->{Dhcp4}{subnet4}[0]{reservations}[0]{'ip-address'}, '10.10.0.13', 'replacement reservation is stored' );
my $subnet_id = $backend->subnet_id_for_ip( $reservation_config, '10.10.0.13' );
is( $subnet_id, 10, 'subnet lookup by IPv4 address finds the reservation subnet' );
my $found = $backend->query_reservations( $reservation_config, { hostname => 'node12' } );
is( scalar @$found, 1, 'reservation query finds hostname match' );
is( $found->[0]{'subnet-id'}, 10, 'reservation query includes subnet id' );
my $deleted = $backend->delete_reservations( $reservation_config, { 'hw-address' => '00:11:22:33:44:55' } );
is( scalar @$deleted, 1, 'reservation delete returns deleted reservation' );
is( scalar @{ $reservation_config->{Dhcp4}{subnet4}[0]{reservations} }, 0, 'reservation is removed from config' );
my $hookdir = tempdir(CLEANUP => 1);
my $hook = "$hookdir/libdhcp_host_cmds.so";
open(my $hookfh, '>', $hook) or die "Unable to create fake hook: $!";
close($hookfh);
is( $backend->host_cmds_hook_path($hook), $hook, 'host commands hook lookup accepts an explicit existing path' );
my $backend_without_default_hooks = xCAT::DHCP::Backend::Kea->new(host_cmds_hook_paths => []);
is( $backend_without_default_hooks->host_cmds_hook_path("$hookdir/missing.so"), undef, 'host commands hook lookup returns undef when no hook exists' );
my $backupdir = tempdir(CLEANUP => 1);
my $config_path = "$backupdir/kea-dhcp4.conf";
open(my $configfh, '>', $config_path) or die "Unable to create fake config: $!";
print $configfh '{"old":true}';
close($configfh);
my $write_result = $backend->write_dhcp4_json('{"new":true}', path => $config_path, skip_validate => 1, backup_existing => 1);
ok( !$write_result->{error}, 'write_dhcp4_json succeeds with backup_existing' );
is( $write_result->{backup}, "$config_path.xcatbak", 'backup path is reported' );
my $config_mode = ( stat($config_path) )[2] & oct('7777');
ok( $config_mode == oct('640') || $config_mode == oct('644'), 'written config is readable by the Kea service user' );
open(my $backupfh, '<', "$config_path.xcatbak") or die "Unable to read backup config: $!";
my $backup_content = <$backupfh>;
close($backupfh);
is( $backup_content, '{"old":true}', 'existing Kea config is backed up before replacement' );
my $dhcp6_json = $backend->render_dhcp6_config(
{
interfaces => ['eth0'],
valid_lifetime => '700',
preferred_lifetime => '500',
subnets => [
{
id => '11',
subnet => '2001:db8:1::/64',
dynamicrange => '2001:db8:1::100/120',
option_data => [
{ name => 'dns-servers', data => '2001:db8:1::1' },
{ name => 'domain-search', data => 'cluster.example.com' },
],
reservations => [
{
duid => '00:04:52:54:00:12:34:56',
'ip-addresses' => ['2001:db8:1::50'],
hostname => 'nodev6',
},
],
},
],
}
);
my $dhcp6_config = decode_json($dhcp6_json);
ok( $dhcp6_config->{Dhcp6}, 'renderer creates a Dhcp6 document' );
is( $dhcp6_config->{Dhcp6}{subnet6}[0]{subnet}, '2001:db8:1::/64', 'DHCPv6 subnet is rendered' );
is( $dhcp6_config->{Dhcp6}{'valid-lifetime'}, 700, 'DHCPv6 valid lifetime is numeric' );
is( $dhcp6_config->{Dhcp6}{'preferred-lifetime'}, 500, 'DHCPv6 preferred lifetime is numeric' );
is( $dhcp6_config->{Dhcp6}{subnet6}[0]{id}, 11, 'DHCPv6 subnet id is numeric' );
is( $dhcp6_config->{Dhcp6}{subnet6}[0]{reservations}[0]{duid}, '00:04:52:54:00:12:34:56', 'DHCPv6 DUID reservation is rendered' );
my $ddns_json = $backend->render_ddns_config(
{
port => '53001',
'dns-server-timeout' => '500',
'tsig-keys' => [
{ name => 'xcat_key', algorithm => 'HMAC-SHA256', secret => 'abc123==' },
],
forward_domains => [
{
name => 'cluster.example.com.',
'key-name' => 'xcat_key',
'dns-servers' => [ { 'ip-address' => '10.0.0.1', port => 53 } ],
},
],
reverse_domains => [
{
name => '0.0.10.in-addr.arpa.',
'key-name' => 'xcat_key',
'dns-servers' => [ { 'ip-address' => '10.0.0.1', port => 53 } ],
},
],
}
);
my $ddns_config = decode_json($ddns_json);
ok( $ddns_config->{DhcpDdns}, 'renderer creates a DhcpDdns document' );
is( $ddns_config->{DhcpDdns}{port}, 53001, 'DDNS port is numeric' );
is( $ddns_config->{DhcpDdns}{'dns-server-timeout'}, 500, 'DDNS timeout is numeric' );
is( $ddns_config->{DhcpDdns}{'forward-ddns'}{'ddns-domains'}[0]{name}, 'cluster.example.com.', 'DDNS forward domain is rendered' );
my $ctrl_agent_config = decode_json($backend->render_ctrl_agent_config({ 'http-port' => '8000' }));
is( $ctrl_agent_config->{'Control-agent'}{'http-port'}, 8000, 'Control Agent HTTP port is numeric' );
my $runtime_socket_dir = "$unit_dir/run/kea";
mkdir "$unit_dir/run" or die "Unable to create $unit_dir/run: $!";
mkdir $runtime_socket_dir or die "Unable to create $runtime_socket_dir: $!";
my $runtime_socket_backend = xCAT::DHCP::Backend::Kea->new( kea_socket_dirs => [ $runtime_socket_dir, "$unit_dir/var/run/kea" ] );
my $runtime_socket_config = decode_json($runtime_socket_backend->render_ctrl_agent_config({}));
is( $runtime_socket_config->{'Control-agent'}{'control-sockets'}{dhcp4}{'socket-name'}, "$runtime_socket_dir/kea4-ctrl-socket", 'Control Agent socket uses the detected runtime directory' );
my $legacy_socket_backend = xCAT::DHCP::Backend::Kea->new( kea_socket_dirs => [] );
my $legacy_socket_config = decode_json($legacy_socket_backend->render_ctrl_agent_config({}));
is( $legacy_socket_config->{'Control-agent'}{'control-sockets'}{dhcp4}{'socket-name'}, '/var/run/kea/kea4-ctrl-socket', 'Control Agent socket falls back to the legacy runtime path when no runtime directory exists' );
my $socket_backend = xCAT::DHCP::Backend::Kea->new( kea_socket_dir => '/run/kea' );
my $ctrl_agent_socket_config = decode_json($socket_backend->render_ctrl_agent_config({ dhcp6 => 1, ddns => 1 }));
is( $ctrl_agent_socket_config->{'Control-agent'}{'control-sockets'}{dhcp4}{'socket-name'}, '/run/kea/kea4-ctrl-socket', 'Control Agent DHCPv4 socket uses the detected Kea socket directory' );
is( $ctrl_agent_socket_config->{'Control-agent'}{'control-sockets'}{dhcp6}{'socket-name'}, '/run/kea/kea6-ctrl-socket', 'Control Agent DHCPv6 socket uses the detected Kea socket directory' );
is( $ctrl_agent_socket_config->{'Control-agent'}{'control-sockets'}{d2}{'socket-name'}, '/run/kea/kea-ddns-ctrl-socket', 'Control Agent DDNS socket uses the detected Kea socket directory' );
my $explicit_socket_config = decode_json($socket_backend->render_ctrl_agent_config({ 'dhcp4-socket' => '/tmp/kea4.sock' }));
is( $explicit_socket_config->{'Control-agent'}{'control-sockets'}{dhcp4}{'socket-name'}, '/tmp/kea4.sock', 'explicit Control Agent socket path overrides the default' );
my @commands;
my $ca_backend = xCAT::DHCP::Backend::Kea->new(
control_agent_handler => sub {
my ($payload) = @_;
push @commands, $payload;
return { ok => 1, result => 0, response => { result => 0 } };
},
);
my $live_result = $ca_backend->live_upsert_reservations(
[
{
'subnet-id' => 10,
'hw-address' => '00:11:22:33:44:55',
'ip-address' => '10.10.0.20',
hostname => 'node20',
'boot-file-name' => 'pxelinux.0',
},
]
);
ok( !$live_result->{error}, 'live reservation upsert succeeds through injected Control Agent handler' );
is( $commands[0]{command}, 'reservation-del', 'live upsert deletes any existing reservation first' );
is( $commands[0]{arguments}{'operation-target'}, 'memory', 'live delete targets runtime memory' );
is( $commands[1]{command}, 'reservation-add', 'live upsert adds reservation through host-commands' );
is( $commands[1]{arguments}{reservation}{'subnet-id'}, 10, 'live add includes subnet id in reservation body' );
is_deeply( $commands[1]{service}, ['dhcp4'], 'live add targets dhcp4 service by default' );
done_testing();