From 89f5d7a8b8bbf24e9e0235deb6fd14fc223ef8ec Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:17:23 -0300 Subject: [PATCH 1/2] test(xcat-core): cover *NOIP* NICs in Kea DHCP reservation builders Add a regression case to dhcp_kea_plugin_intent.t for a node whose mac table entry uses the *NOIP* sentinel on a secondary NIC ("mac1|mac2!*NOIP*"). The mocked getipaddr resolves every name (including the literal *NOIP*), so the only thing that can keep the node to a single reservation is an explicit *NOIP* skip in kea_node_reservations() and kea_node_reservations6(). The test asserts exactly one v4 and one v6 reservation, for the real NIC, with no reservation carrying *NOIP* as a hostname -- covering both the "bogus second reservation" and the "unresolved reservation aborts makedhcp" failure modes. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 52cdf7fe369e129fcc946e81c2bf23143e41287b) --- xCAT-test/unit/dhcp_kea_plugin_intent.t | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/xCAT-test/unit/dhcp_kea_plugin_intent.t b/xCAT-test/unit/dhcp_kea_plugin_intent.t index afb6d739b..0f9cea96a 100644 --- a/xCAT-test/unit/dhcp_kea_plugin_intent.t +++ b/xCAT-test/unit/dhcp_kea_plugin_intent.t @@ -599,4 +599,65 @@ foreach my $case (@invalid_mac_cases) { ); } +{ + # 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 => '' } } ), + 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::kea_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' ); +} + done_testing(); From 7a52283ba033d884edb718fa14bd658550bb1e77 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:04:39 -0300 Subject: [PATCH 2/2] fix(xcat-core): skip *NOIP* NICs when building Kea DHCP reservations makedhcp using the Kea backend fails for any node whose mac table entry uses the *NOIP* sentinel to mark a secondary NIC that has no IP address (e.g. "mac1|mac2!*NOIP*|mac3!*NOIP*"). kea_node_reservations() and kea_node_reservations6() split each NIC's hostname and pass it straight to getipaddr(), so the literal string "*NOIP*" is treated as a host to resolve. It cannot resolve, and the reservation is reported as unresolved -- which aborts the whole makedhcp run, leaving the node with no Kea reservation at all and therefore no DHCP lease. This breaks provisioning on distros that use the Kea backend (e.g. EL10, which has no ISC dhcp-server): the node never gets an address and is unreachable, while the ISC backend has always handled *NOIP* and is unaffected. Skip *NOIP* NICs in both the DHCPv4 and DHCPv6 reservation builders, the same way the ISC path does -- there is no address to reserve for them. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 9125d4da3ce26a4595fa3bb2a9e716648ffb6a08) --- xCAT-server/lib/xcat/plugins/dhcp.pm | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/xCAT-server/lib/xcat/plugins/dhcp.pm b/xCAT-server/lib/xcat/plugins/dhcp.pm index 396370d9a..ccb865afa 100644 --- a/xCAT-server/lib/xcat/plugins/dhcp.pm +++ b/xCAT-server/lib/xcat/plugins/dhcp.pm @@ -3165,6 +3165,11 @@ sub kea_node_reservations my ( $mac, $hname ) = split(/!/, $mace); $hname ||= $node; next unless $mac; + # A NIC whose hostname is the *NOIP* sentinel intentionally has no IP + # (e.g. secondary interfaces in the mac table). Skip it: there is no + # address to reserve, and trying to resolve "*NOIP*" would otherwise be + # treated as an unresolved reservation. This mirrors the ISC path. + next if $hname eq '*NOIP*'; my $normalized_mac = kea_normalize_mac($mac); unless ($normalized_mac) { $callback->({ error => ["Invalid mac address $mac for $node"], errorcode => [1] }); @@ -3315,6 +3320,11 @@ sub kea_node_reservations6 my ( $mac, $hname ) = split(/!/, $mace); $hname ||= $node; next unless $mac; + # A NIC whose hostname is the *NOIP* sentinel intentionally has no IP + # (e.g. secondary interfaces in the mac table). Skip it: there is no + # address to reserve, and trying to resolve "*NOIP*" would otherwise be + # treated as an unresolved reservation. This mirrors the ISC path. + next if $hname eq '*NOIP*'; my $normalized_mac = kea_normalize_mac($mac); unless ($normalized_mac) { $callback->({ error => ["Invalid mac address $mac for $node"], errorcode => [1] });