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:
@@ -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
|
||||
--------------------
|
||||
|
||||
|
||||
@@ -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 ) = @_;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ) = @_;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@^minimal-environment
|
||||
@Minimal Install
|
||||
kernel
|
||||
chrony
|
||||
net-tools
|
||||
nfs-utils
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user