diff --git a/docs/source/developers/guides/code/kea_dhcp_backend_plan.rst b/docs/source/developers/guides/code/kea_dhcp_backend_plan.rst index b5b8c93ca..3e1b0b409 100644 --- a/docs/source/developers/guides/code/kea_dhcp_backend_plan.rst +++ b/docs/source/developers/guides/code/kea_dhcp_backend_plan.rst @@ -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 -------------------- diff --git a/perl-xCAT/xCAT/DHCP/Backend/Kea.pm b/perl-xCAT/xCAT/DHCP/Backend/Kea.pm index 24d26dee0..8b1e7c328 100644 --- a/perl-xCAT/xCAT/DHCP/Backend/Kea.pm +++ b/perl-xCAT/xCAT/DHCP/Backend/Kea.pm @@ -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 ) = @_; diff --git a/perl-xCAT/xCAT/DHCP/BootPolicy.pm b/perl-xCAT/xCAT/DHCP/BootPolicy.pm index 7e3602c6c..1d918e5b3 100644 --- a/perl-xCAT/xCAT/DHCP/BootPolicy.pm +++ b/perl-xCAT/xCAT/DHCP/BootPolicy.pm @@ -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; diff --git a/xCAT-server/lib/xcat/plugins/dhcp.pm b/xCAT-server/lib/xcat/plugins/dhcp.pm index f458e8923..35f1bd89e 100644 --- a/xCAT-server/lib/xcat/plugins/dhcp.pm +++ b/xCAT-server/lib/xcat/plugins/dhcp.pm @@ -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 ) = @_; diff --git a/xCAT-server/share/xcat/install/rh/compute.rhels10.pkglist b/xCAT-server/share/xcat/install/rh/compute.rhels10.pkglist index 1123dcf8d..c2807b215 100644 --- a/xCAT-server/share/xcat/install/rh/compute.rhels10.pkglist +++ b/xCAT-server/share/xcat/install/rh/compute.rhels10.pkglist @@ -1,4 +1,5 @@ -@^minimal-environment +@Minimal Install +kernel chrony net-tools nfs-utils diff --git a/xCAT-test/unit/dhcp_boot_policy.t b/xCAT-test/unit/dhcp_boot_policy.t index 550e77131..d7bcf3b93 100644 --- a/xCAT-test/unit/dhcp_boot_policy.t +++ b/xCAT-test/unit/dhcp_boot_policy.t @@ -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(); diff --git a/xCAT-test/unit/dhcp_kea_config_validation.t b/xCAT-test/unit/dhcp_kea_config_validation.t index 633d7acb1..6c4dca530 100644 --- a/xCAT-test/unit/dhcp_kea_config_validation.t +++ b/xCAT-test/unit/dhcp_kea_config_validation.t @@ -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', }, ], diff --git a/xCAT-test/unit/dhcp_kea_renderer.t b/xCAT-test/unit/dhcp_kea_renderer.t index 75f76f153..2a5a4d37a 100644 --- a/xCAT-test/unit/dhcp_kea_renderer.t +++ b/xCAT-test/unit/dhcp_kea_renderer.t @@ -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( {