2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-05-05 16:49:08 +00:00

Refine Kea xNBA handling and validation notes

This commit is contained in:
Vinícius Ferrão
2026-04-23 11:14:01 -03:00
parent 8399d88509
commit fcd22757d2
8 changed files with 312 additions and 18 deletions

View File

@@ -325,6 +325,28 @@ Open test infrastructure details to confirm:
* whether nested or privileged test guests can run DHCP client and PXE tests
* cleanup expectations for temporary VMs, networks, and storage volumes
Manual Validation Snapshot
--------------------------
As of April 23, 2026, the branch has been exercised on KVM guests across ISC
and Kea backends:
* EL10 plus Kea on x86_64: passed end-to-end xNBA netboot with a Rocky 10.1
compute image. The node fetched the xNBA script, kernel, initrd, and rootimg,
then reached xCAT ``netbooting`` state.
* Ubuntu 24.04 plus Kea on x86_64: passed xNBA shell boot and full netboot
image fetch. The node downloaded the node script, Genesis artifacts, and the
generated root image, and Kea reservation queries succeeded.
* EL9 plus ISC on x86_64: passed legacy ISC plus xNBA shell boot, including
DHCP, TFTP, node-script handoff, and Genesis fetch.
* Ubuntu 22.04 plus ISC on x86_64: passed legacy ISC DHCP, TFTP, generated
xNBA network script, and Genesis fetch. Per-node OMAPI reservation updates on
Jammy still fail with ``omshell`` descriptor errors and appear to be a
preexisting Ubuntu-specific ISC issue outside the Kea scope.
* EL10 plus Kea on ppc64le: Kea and the Kea boot handoff reached xCAT Genesis,
but Genesis failed loading ``genesis.kernel.ppc64`` because of a preexisting
invalid ELF image issue unrelated to Kea.
Implementation Order
--------------------

View File

@@ -208,7 +208,7 @@ sub load_dhcp4_config {
my $content = <$fh>;
close($fh);
my $json = eval { decode_json($content) };
my $json = eval { decode_json( _strip_json_comments($content) ) };
return { error => "Unable to parse $path as JSON: $@" } if $@;
$json->{Dhcp4}{subnet4} ||= [];
@@ -226,7 +226,7 @@ sub load_dhcp6_config {
my $content = <$fh>;
close($fh);
my $json = eval { decode_json($content) };
my $json = eval { decode_json( _strip_json_comments($content) ) };
return { error => "Unable to parse $path as JSON: $@" } if $@;
$json->{Dhcp6}{subnet6} ||= [];
@@ -324,6 +324,59 @@ sub encode_config {
return JSON->new->canonical->pretty->encode($config);
}
sub _strip_json_comments {
my ($content) = @_;
my $out = '';
my $in_string = 0;
my $escaped = 0;
my $length = length($content);
for ( my $idx = 0; $idx < $length; $idx++ ) {
my $char = substr( $content, $idx, 1 );
my $next = $idx + 1 < $length ? substr( $content, $idx + 1, 1 ) : '';
if ($in_string) {
$out .= $char;
if ($escaped) {
$escaped = 0;
} elsif ($char eq '\\') {
$escaped = 1;
} elsif ($char eq '"') {
$in_string = 0;
}
next;
}
if ($char eq '"') {
$in_string = 1;
$out .= $char;
next;
}
if ($char eq '/' && $next eq '/') {
$idx += 2;
$idx++ while $idx < $length && substr( $content, $idx, 1 ) !~ /\n/;
$out .= "\n" if $idx < $length;
next;
}
if ($char eq '/' && $next eq '*') {
$idx += 2;
while ( $idx < $length - 1 && substr( $content, $idx, 2 ) ne '*/' ) {
$out .= "\n" if substr( $content, $idx, 1 ) eq "\n";
$idx++;
}
$idx++ if $idx < $length;
next;
}
$out .= $char;
}
return $out;
}
sub write_ctrl_agent_config {
my ( $self, $intent, %opts ) = @_;

View File

@@ -6,22 +6,17 @@ use warnings;
sub kea_client_classes {
my ( $class, %opts ) = @_;
my $xnba_user_class = xnba_user_class_test();
my $bios_boot = $opts{xnba_kpxe} ? 'xcat/xnba.kpxe' : 'pxelinux.0';
my $uefi_boot = $opts{xnba_efi} ? 'xcat/xnba.efi' : '';
my @classes;
if ( $opts{xnba_kpxe} ) {
push @classes, {
name => 'xcat-xnba-bios',
test => "option[77].text == 'xNBA' and option[93].hex == 0x0000",
'boot-file-name' => 'xcat/xnba.kpxe',
};
}
push @classes, @{ $opts{xnba_node_classes} || [] };
push @classes, (
{
name => 'xcat-bios',
test => 'option[93].hex == 0x0000',
test => "option[93].hex == 0x0000 and not ($xnba_user_class)",
'boot-file-name' => $bios_boot,
},
);
@@ -29,7 +24,7 @@ sub kea_client_classes {
if ($uefi_boot ne '') {
push @classes, {
name => 'xcat-uefi-x64',
test => 'option[93].hex == 0x0007 or option[93].hex == 0x0009',
test => "(option[93].hex == 0x0007 or option[93].hex == 0x0009) and not ($xnba_user_class)",
'boot-file-name' => $uefi_boot,
};
}
@@ -55,4 +50,72 @@ sub kea_client_classes {
return \@classes;
}
sub kea_xnba_node_classes {
my ( $class, %opts ) = @_;
my $nodes = $opts{nodes} || [];
my $xnba_user_class = xnba_user_class_test();
my @classes;
foreach my $node (@$nodes) {
next unless $node->{node} && $node->{mac} && $node->{next_server};
my $class_base = _xnba_class_base( $node->{node}, $node->{mac} );
my $mac_test = _mac_test( $node->{mac} );
my $base_url = 'http://' . $node->{next_server} . ':' . ( $node->{httpport} || '80' ) . '/tftpboot/xcat/xnba/nodes/' . $node->{node};
push @classes, {
name => "$class_base-bios",
test => "$xnba_user_class and option[93].hex == 0x0000 and $mac_test",
'boot-file-name' => $base_url,
'user-context' => _xnba_user_context($node),
};
if ( $opts{xnba_efi} ) {
push @classes, {
name => "$class_base-uefi",
test => "$xnba_user_class and option[93].hex == 0x0009 and $mac_test",
'boot-file-name' => "$base_url.uefi",
'user-context' => _xnba_user_context($node),
};
}
}
return \@classes;
}
sub xnba_user_class_test {
return "(option[77].exists and (option[77].text == 'xNBA' or option[77].hex == 0x784e4241 or substring(option[77].hex,1,4) == 'xNBA'))";
}
sub _xnba_class_base {
my ( $node, $mac ) = @_;
my $safe_node = $node;
$safe_node =~ s/[^A-Za-z0-9_.-]/_/g;
my $safe_mac = lc($mac);
$safe_mac =~ s/[^0-9a-f]//g;
return "xcat-xnba-$safe_node-$safe_mac";
}
sub _mac_test {
my ($mac) = @_;
my $mac_hex = lc($mac);
$mac_hex =~ s/[^0-9a-f]//g;
return "pkt4.mac == 0x$mac_hex";
}
sub _xnba_user_context {
my ($node) = @_;
return {
'xcat-purpose' => 'xnba-second-stage',
'xcat-node' => $node->{node},
'xcat-mac' => lc( $node->{mac} ),
};
}
1;

View File

@@ -2291,14 +2291,17 @@ sub kea_process_request
my @deleted6;
my $reservations4 = [];
my $reservations6 = [];
my $client_classes_changed = 0;
if ($opt->{d}) {
foreach my $match (@{ kea_reservation_matches_for_nodes($nodes) }) {
push @deleted4, @{ $backend->delete_reservations($loaded4, $match) };
push @deleted6, @{ $backend->delete_reservations($loaded6, $match) } if $loaded6;
}
$client_classes_changed = kea_remove_xnba_client_classes($loaded4, $nodes);
} else {
$reservations4 = kea_build_node_reservations($backend, $loaded4, $nodes);
$backend->upsert_reservations($loaded4, $reservations4);
$client_classes_changed = kea_sync_xnba_client_classes($loaded4, $nodes);
if ($loaded6) {
$reservations6 = kea_build_node_reservations6($backend, $loaded6, $nodes);
$backend->upsert_reservations($loaded6, $reservations6);
@@ -2338,7 +2341,7 @@ sub kea_process_request
}
}
unless ($live_ok) {
unless ($live_ok && !$client_classes_changed) {
my $restart = $backend->restart_services(ipv6 => $using_dhcp6, ctrl_agent => kea_control_agent_enabled(), ddns => $using_ddns);
if ($restart->{error}) {
$callback->({ error => [ $restart->{error} ], errorcode => [1] });
@@ -2917,6 +2920,104 @@ sub kea_node_reservations
return \@reservations;
}
sub kea_sync_xnba_client_classes
{
my ( $config, $nodes ) = @_;
my $changed = kea_remove_xnba_client_classes($config, $nodes);
my $classes = kea_xnba_client_classes_for_nodes($nodes);
return $changed unless @$classes;
$config->{Dhcp4} ||= {};
my @existing = @{ $config->{Dhcp4}{'client-classes'} || [] };
$config->{Dhcp4}{'client-classes'} = [ @$classes, @existing ];
return 1;
}
sub kea_remove_xnba_client_classes
{
my ( $config, $nodes ) = @_;
return 0 unless $config && $config->{Dhcp4};
my $classes = $config->{Dhcp4}{'client-classes'} || [];
my %nodes = map { $_ => 1 } @$nodes;
my @kept;
my $changed = 0;
foreach my $class (@$classes) {
my $context = $class->{'user-context'} || {};
if ( ( $context->{'xcat-purpose'} || '' ) eq 'xnba-second-stage' && $nodes{ $context->{'xcat-node'} || '' } ) {
$changed = 1;
next;
}
push @kept, $class;
}
$config->{Dhcp4}{'client-classes'} = \@kept if $changed;
return $changed;
}
sub kea_xnba_client_classes_for_nodes
{
my ($nodes) = @_;
my $nrtab = xCAT::Table->new('noderes');
my $mactab = xCAT::Table->new('mac');
return [] unless $nrtab && $mactab;
my $nrents = $nrtab->getNodesAttribs($nodes, [ 'tftpserver', 'netboot', 'proxydhcp', 'xcatmaster', 'servicenode' ]);
my $macents = $mactab->getNodesAttribs($nodes, ['mac']);
my $httpport = "80";
my @hports = xCAT::TableUtils->get_site_attribute("httpport");
if ($hports[0]) {
$httpport = $hports[0];
}
my @records;
foreach my $node (@$nodes) {
my $nrent = $nrents && $nrents->{$node} ? $nrents->{$node}->[0] : undef;
next unless $nrent && $nrent->{netboot} && $nrent->{netboot} eq 'xnba';
my $macent = $macents && $macents->{$node} ? $macents->{$node}->[0] : undef;
next unless $macent && $macent->{mac};
my ( $nxtsrv ) = kea_next_server_for_node($node, $nrent);
next unless $nxtsrv;
foreach my $mace (split(/\|/, $macent->{mac})) {
my ($mac) = split(/!/, $mace);
$mac = kea_normalize_mac($mac);
next unless $mac;
push @records, {
node => $node,
mac => $mac,
next_server => $nxtsrv,
httpport => $httpport,
};
}
}
return xCAT::DHCP::BootPolicy->kea_xnba_node_classes(
nodes => \@records,
xnba_efi => -f "$tftpdir/xcat/xnba.efi" ? 1 : 0,
);
}
sub kea_normalize_mac
{
my ($mac) = @_;
return unless $mac;
return unless $mac =~ /^[0-9a-fA-F]{2}(-[0-9a-fA-F]{2}){5,8}$|^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5,8}$/;
if (index($mac, ':') == -1) {
$mac = lc($mac);
$mac =~ s/(\w{2})/$1:/g;
$mac =~ s/:$//;
}
return lc($mac);
}
sub kea_node_reservations6
{
my ( $backend, $config, $node ) = @_;

View File

@@ -1,4 +1,5 @@
@^minimal-environment
@Minimal Install
kernel
chrony
net-tools
nfs-utils

View File

@@ -15,13 +15,44 @@ is( $fallback_by_name{'xcat-bios'}{'boot-file-name'}, 'pxelinux.0', 'BIOS client
ok( !exists $fallback_by_name{'xcat-xnba-bios'}, 'xNBA user-class is not advertised without xNBA kpxe' );
my $classes = xCAT::DHCP::BootPolicy->kea_client_classes(xnba_kpxe => 1, xnba_efi => 1);
is( scalar @$classes, 6, 'Kea boot policy renders expected xNBA client classes' );
is( scalar @$classes, 5, 'Kea boot policy renders expected xNBA client classes' );
my %by_name = map { $_->{name} => $_ } @$classes;
is( $by_name{'xcat-bios'}{'boot-file-name'}, 'xcat/xnba.kpxe', 'BIOS clients receive xNBA kpxe' );
like( $by_name{'xcat-bios'}{test}, qr/not \(\(option\[77\]\.exists/, 'generic BIOS class excludes xNBA second-stage clients' );
like( $by_name{'xcat-uefi-x64'}{test}, qr/0x0007/, 'UEFI x64 class matches architecture 7' );
like( $by_name{'xcat-uefi-x64'}{test}, qr/0x0009/, 'UEFI x64 class matches architecture 9' );
like( $by_name{'xcat-uefi-x64'}{test}, qr/not \(\(option\[77\]\.exists/, 'generic UEFI class excludes xNBA second-stage clients' );
is( $by_name{'xcat-aarch64'}{'boot-file-name'}, 'boot/grub2/grub2.aarch64', 'AArch64 clients receive grub2 boot file' );
is( $by_name{'xcat-ppc64'}{'boot-file-name'}, '/boot/grub2/grub2.ppc', 'POWER clients receive grub2 Open Firmware boot file' );
my $xnba_classes = xCAT::DHCP::BootPolicy->kea_xnba_node_classes(
xnba_efi => 1,
nodes => [
{
node => 'cn01',
mac => '52:54:4b:10:00:11',
next_server => '10.241.10.1',
httpport => '80',
},
],
);
is( scalar @$xnba_classes, 2, 'xNBA node policy renders BIOS and UEFI second-stage classes' );
my %xnba_by_name = map { $_->{name} => $_ } @$xnba_classes;
my $xnba_bios = $xnba_by_name{'xcat-xnba-cn01-52544b100011-bios'};
ok( $xnba_bios, 'xNBA BIOS second-stage class is named by node and MAC' );
like( $xnba_bios->{test}, qr/option\[77\]\.text == 'xNBA'/, 'xNBA second-stage class matches text user-class' );
like( $xnba_bios->{test}, qr/substring\(option\[77\]\.hex,1,4\) == 'xNBA'/, 'xNBA second-stage class matches tuple-encoded user-class' );
like( $xnba_bios->{test}, qr/pkt4\.mac == 0x52544b100011/, 'xNBA second-stage class matches the node MAC' );
is( $xnba_bios->{'boot-file-name'}, 'http://10.241.10.1:80/tftpboot/xcat/xnba/nodes/cn01', 'xNBA BIOS class returns the node script URL' );
is( $xnba_bios->{'user-context'}{'xcat-purpose'}, 'xnba-second-stage', 'xNBA class carries removable user-context' );
is( $xnba_by_name{'xcat-xnba-cn01-52544b100011-uefi'}{'boot-file-name'}, 'http://10.241.10.1:80/tftpboot/xcat/xnba/nodes/cn01.uefi', 'xNBA UEFI class returns the UEFI node script URL' );
my $combined_classes = xCAT::DHCP::BootPolicy->kea_client_classes(
xnba_kpxe => 1,
xnba_efi => 1,
xnba_node_classes => $xnba_classes,
);
is( $combined_classes->[0]{name}, 'xcat-xnba-cn01-52544b100011-bios', 'node-specific xNBA classes have priority over generic boot classes' );
done_testing();

View File

@@ -28,13 +28,13 @@ my $json = $backend->render_dhcp4_config(
],
'client-classes' => [
{
name => 'xcat-xnba-bios',
test => "option[77].text == 'xNBA' and option[93].hex == 0x0000",
'boot-file-name' => 'xcat/xnba.kpxe',
name => 'xcat-xnba-node01-525400123456-bios',
test => "(option[77].exists and (option[77].text == 'xNBA' or option[77].hex == 0x784e4241 or substring(option[77].hex,1,4) == 'xNBA')) and option[93].hex == 0x0000 and pkt4.mac == 0x525400123456",
'boot-file-name' => 'http://192.168.122.1:80/tftpboot/xcat/xnba/nodes/node01',
},
{
name => 'xcat-uefi-x64',
test => 'option[93].hex == 0x0007 or option[93].hex == 0x0009',
test => "(option[93].hex == 0x0007 or option[93].hex == 0x0009) and not ((option[77].exists and (option[77].text == 'xNBA' or option[77].hex == 0x784e4241 or substring(option[77].hex,1,4) == 'xNBA')))",
'boot-file-name' => 'xcat/xnba.efi',
},
],

View File

@@ -118,6 +118,29 @@ 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 $comment_dir = tempdir(CLEANUP => 1);
my $commented_config = "$comment_dir/kea-dhcp4.conf";
open( my $comment_fh, '>', $commented_config ) or die "Unable to write $commented_config: $!";
print {$comment_fh} <<'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.
}
]
}
}
COMMENTED_JSON
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(
{