2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-10-06 17:46:55 +00:00

fix(dhcp): give both backends the same answer for eight kinds of client

Eight of the parity decisions in the spec's Appendix A, where ISC dhcpd and
Kea answered the same frame differently and the operator never chose which
backend they got.

ISC gains two branches its if/else chain never had, so the client falls
through to /yaboot no longer:

  - 0x000c ppc64 is given /boot/grub2/grub2.ppc.  yaboot is not a UEFI
    loader and cannot boot one of these machines (decision 2).
  - 0x0010 is the same x86-64 UEFI firmware and the same loader as 0x0007,
    announced by a machine set to fetch it over HTTP (decision 3).

Kea gains what ISC has always had:

  - Etherboot, which predates option 93 and says what it is in option 60
    alone, is recognised and given the BIOS loader (decision 10).
  - onie_vendor is answered per subnet, not only per node: a switch
    announces it on its first boot, before anyone has defined it as a node
    (decision 11).
  - A client nothing else recognises is given /yaboot.  Kea has no else, so
    the condition is the negation of every architecture and vendor class
    another rule answers, rather than a dependence on class ordering
    (decision 7).
  - netboot=nimol is given /vios/nodes/<node> (decision 6).

and drops two answers ISC never gave:

  - No BIOS loader on disk no longer means pxelinux.0 in its place.  Naming
    a file that is not there costs the client a timeout it cannot diagnose,
    and a different loader boots something nobody asked for; the class is
    simply not written (decision 8).
  - netboot=petitboot sends the conf-file and nothing else.  petitboot acts
    on a boot file name when it sees one, so naming one as well sent the
    machine after a TFTP fetch that never happens on ISC (decision 12).
This commit is contained in:
Daniel Hilst
2026-09-10 13:27:58 -03:00
parent 72bae42096
commit e7f7717627
5 changed files with 285 additions and 15 deletions
+103 -8
View File
@@ -8,19 +8,33 @@ sub kea_client_classes {
my $xnba_user_class = xnba_user_class_test();
my $uefi_x64_arch_match = uefi_x64_client_architecture_match_expr();
my $bios_boot = $opts{xnba_kpxe} ? 'xcat/xnba.kpxe' : 'pxelinux.0';
my $etherboot = etherboot_vendor_class_test();
# No substitute when the loader is not on disk. Naming a file the TFTP
# server does not have costs the client a timeout it cannot diagnose, and
# handing it a different loader boots something nobody asked for -- so the
# class is simply not written and the client is served an address alone.
my $bios_boot = $opts{xnba_kpxe} ? 'xcat/xnba.kpxe' : '';
my $uefi_boot = $opts{xnba_efi} ? 'xcat/xnba.efi' : '';
my @classes;
push @classes, @{ $opts{xnba_node_classes} || [] };
push @classes, (
{
name => 'xcat-bios',
test => "option[93].hex == 0x0000 and not ($xnba_user_class)",
'boot-file-name' => $bios_boot,
},
);
if ($bios_boot ne '') {
push @classes, (
{
name => 'xcat-bios',
test => "option[93].hex == 0x0000 and not ($xnba_user_class)",
'boot-file-name' => $bios_boot,
},
# Etherboot predates option 93: it says what it is in option 60 and
# nothing else, so the vendor class is the only thing to key on.
{
name => 'xcat-etherboot',
test => $etherboot,
'boot-file-name' => $bios_boot,
},
);
}
if ($uefi_boot ne '') {
push @classes, {
@@ -53,9 +67,48 @@ sub kea_client_classes {
},
);
push @classes, kea_fallback_client_class();
return \@classes;
}
#: Every client architecture some class in this file, or in the per-network
#: classes beside it, already answers. The fallback is what is left over.
my @RECOGNISED_ARCH_IDS = qw(
0x0000 0x0002 0x0007 0x0009 0x000b 0x000c 0x000e 0x0010 0x001b 0x001c 0x001f
);
# The answer for a client that said nothing any other rule recognised.
#
# ISC reaches this by falling off the end of an if/else chain, which Kea has no
# equivalent of: every class is evaluated on its own. So the condition is
# written out -- none of the architectures another class answers, and none of
# the vendor or user classes either -- rather than left to depend on which
# class Kea happens to consult first for a boot file name.
#
# /yaboot is a poor universal default, but it is the one xCAT has always had on
# ISC. What matters here is that both backends give the same answer: a client
# left with an address and no boot file cannot tell it was served at all.
sub kea_fallback_client_class {
my @recognised = map { "option[93].hex == $_" } @RECOGNISED_ARCH_IDS;
push @recognised, etherboot_vendor_class_test(), onie_vendor_class_test(),
xnba_user_class_test();
return {
name => 'xcat-fallback',
test => join( ' and ', map { "not ($_)" } @recognised ),
'boot-file-name' => '/yaboot',
};
}
sub etherboot_vendor_class_test {
return "option[60].text == 'Etherboot-5.4'";
}
sub onie_vendor_class_test {
return "substring(option[60].text,0,11) == 'onie_vendor'";
}
# Architectures whose UEFI firmware can also boot over HTTP, by DHCP client
# architecture id (RFC 4578 and the IANA registry). An HTTP boot client wants the
# boot file as a URL and only accepts the offer when the reply is tagged
@@ -130,6 +183,39 @@ sub kea_s390x_network_classes {
];
}
# The installer URL an ONIE switch is offered, per subnet.
#
# A switch announces onie_vendor on its very first boot, which is necessarily
# before anyone has defined it as a node -- so a URL that only a node
# definition can produce is one the switch can never reach. ISC writes this
# into every subnet; this is the same answer, per network because the URL
# carries the address of the management node serving it.
sub kea_onie_network_classes {
my ( $class, %opts ) = @_;
return [] unless $opts{net} && defined( $opts{prefix} ) && $opts{next_server};
my $httpport = $opts{httpport} || '80';
my $portsuffix = ( $httpport eq '80' ) ? '' : ":$httpport";
my $name = "xcat-onie-$opts{net}_$opts{prefix}";
$name =~ s{[^A-Za-z0-9_.-]}{_}gxms;
return [
{
name => $name,
test => onie_vendor_class_test(),
additional_only => 1,
'option-data' => [
{
name => 'www-server',
data => "http://$opts{next_server}$portsuffix/install/onie/onie-installer",
'always-send' => 1,
},
],
},
];
}
# The user class a chainloaded second stage announces itself with, as an ISC
# dhcpd condition.
#
@@ -182,10 +268,19 @@ sub isc_client_architecture_lines {
" filename \"xcat/xnba.efi\";\n",
" } else if option client-architecture = 00:09 { #x86_64 uefi alternative id\n ",
" filename \"xcat/xnba.efi\";\n",
# 0x0010 is the same x86-64 UEFI firmware and the same loader as 0x0007,
# announced by a machine set to fetch it over HTTP. Without the branch
# a mainstream client falls through to /yaboot.
" } else if option client-architecture = 00:10 { #x86_64 uefi http boot\n ",
" filename \"xcat/xnba.efi\";\n",
" } else if option client-architecture = 00:02 { #ia64\n ",
" filename \"elilo.efi\";\n",
" } else if option client-architecture = 00:0b { #aaarch64\n ",
" filename \"boot/grub2/grub2.aarch64\";\n",
# yaboot, which is what a ppc64 client fell through to without this
# branch, is not a UEFI loader and cannot boot one of these machines.
" } else if option client-architecture = 00:0c { #ppc64 grub2\n ",
" filename \"/boot/grub2/grub2.ppc\";\n",
" } else if option client-architecture = 00:1b { #riscv64 uefi\n ",
" filename \"boot/grub2/grub2.riscv64\";\n",
" } else if option client-architecture = 00:1c { #riscv64 uefi http boot\n ",
+14 -3
View File
@@ -3496,6 +3496,14 @@ sub kea_subnet4_intent
prefix => $prefix,
)
};
push @client_classes, @{
xCAT::DHCP::BootPolicy->kea_onie_network_classes(
net => $net,
prefix => $prefix,
next_server => $tftp,
httpport => $httpport,
)
};
if (@client_classes) {
$subnet{additional_client_classes} = [ map { $_->{name} } @client_classes ];
$subnet{client_classes} = \@client_classes;
@@ -3971,11 +3979,14 @@ sub kea_boot_for_node
$boot{'boot-file-name'} = "/yb/node/yaboot-$node";
} elsif ($netboot and $netboot =~ /^grub2[-]?.*$/) {
$boot{'boot-file-name'} = "/boot/grub2/grub2-$node";
} elsif ($netboot and $netboot eq 'nimol') {
$boot{'boot-file-name'} = "/vios/nodes/$node";
} elsif ($netboot and $netboot eq 'petitboot') {
if ($nxtsrv) {
my $petitboot_conf = "http://$nxtsrv$portsuffix/tftpboot/petitboot/$node";
$boot{'boot-file-name'} = $petitboot_conf;
push @{ $boot{'option-data'} }, { name => 'conf-file', data => $petitboot_conf };
# The conf-file and nothing else. petitboot acts on a boot file
# name if it sees one, so naming one as well sends the machine
# after a TFTP fetch that never happens on the other backend.
push @{ $boot{'option-data'} }, { name => 'conf-file', data => "http://$nxtsrv$portsuffix/tftpboot/petitboot/$node" };
}
} elsif ($netboot and $netboot eq 'onie') {
my $onie_url = kea_onie_url_for_node($node, $ntent, $nxtsrv, $httpport);
+87 -4
View File
@@ -11,11 +11,16 @@ use xCAT::DHCP::BootPolicy;
my $fallback_classes = xCAT::DHCP::BootPolicy->kea_client_classes();
is( scalar @$fallback_classes, 5, 'Kea boot policy omits xNBA classes when xNBA loaders are unavailable' );
my %fallback_by_name = map { $_->{name} => $_ } @$fallback_classes;
is( $fallback_by_name{'xcat-bios'}{'boot-file-name'}, 'pxelinux.0', 'BIOS clients fall back to pxelinux.0 without xNBA loaders' );
# Naming a loader that is not on disk costs the client a timeout it cannot
# diagnose, and pxelinux.0 in its place boots something nobody asked for. With
# no BIOS loader present the class is simply not written, and such a client is
# served an address and told nothing to fetch.
ok( !exists $fallback_by_name{'xcat-bios'}, 'no BIOS class is written when the BIOS loader is not there' );
ok( !exists $fallback_by_name{'xcat-etherboot'}, 'and no Etherboot class either, since it names the same file' );
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, 8, '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' );
@@ -212,10 +217,88 @@ is(
'the boot loader of the architecture is what is looked for'
);
unlike(
join( ' ', map { $_->{test} } @$classes ),
qr/0x001c/,
join( ' ', grep { defined } map { $_->{'boot-file-name'} } @$classes ),
qr{http://},
'the global class list keeps HTTP boot out: it needs the address of the management node',
);
# The fallback names 0x001c only to stand out of its way, which is not the same
# as answering it.
foreach my $global (@$classes) {
isnt( $global->{test}, 'option[93].hex == 0x001c',
"$global->{name} does not answer the HTTP boot architecture globally" );
}
# Etherboot predates option 93 entirely: it announces itself in option 60 and
# says nothing about its architecture. ISC has always keyed on that vendor
# class; Kea keyed on option 93 alone, so an Etherboot ROM asking for a BIOS
# loader was served an address and told nothing to fetch.
is( $by_name{'xcat-etherboot'}{test}, "option[60].text == 'Etherboot-5.4'",
'Etherboot is recognised by the only thing it says about itself' );
is( $by_name{'xcat-etherboot'}{'boot-file-name'}, 'xcat/xnba.kpxe',
'and is given the same BIOS loader as an option 93 BIOS client' );
# ISC ends its if/else chain with a bare `filename "/yaboot";`, so a client
# announcing an architecture nothing matched still leaves with something to
# fetch. Kea evaluates every class on its own and has no else, so the same
# answer has to be written as the negation of everything else that answers.
my $fallback = $by_name{'xcat-fallback'};
ok( $fallback, 'the class list ends with the answer for an unrecognised client' );
is( $fallback->{'boot-file-name'}, '/yaboot',
'which is the boot file the ISC chain falls through to' );
foreach my $arch (qw(0x0000 0x0002 0x0007 0x0009 0x000b 0x000c 0x000e 0x0010 0x001b 0x001c 0x001f)) {
like( $fallback->{test}, qr/\Qnot (option[93].hex == $arch)\E/,
"the fallback stands out of the way of client architecture $arch" );
}
like( $fallback->{test}, qr/\Qnot (option[60].text == 'Etherboot-5.4')\E/,
'and out of the way of Etherboot, which names no architecture' );
like( $fallback->{test}, qr/\Qnot (substring(option[60].text,0,11) == 'onie_vendor')\E/,
'and of ONIE, which is answered per network' );
like( $fallback->{test}, qr/\Qoption[77]\E/,
'and of a chainloaded xNBA second stage' );
my $exclusions = () = $fallback->{test} =~ /\bnot \(/g;
my $conjunctions = () = $fallback->{test} =~ /\) and not \(/g;
is( $conjunctions, $exclusions - 1,
'every exclusion holds at once: one class matching is enough to disqualify the fallback' );
is( $classes->[-1]{name}, 'xcat-fallback',
'the fallback is written last, after every class it defers to' );
# ONIE carries the address of the management node in a URL, so like the other
# URL-bearing policy it belongs to the network rather than the global list.
my $onie = xCAT::DHCP::BootPolicy->kea_onie_network_classes(
net => '10.0.0.0',
prefix => 24,
next_server => '10.0.0.1',
);
is_deeply(
$onie,
[
{
name => 'xcat-onie-10.0.0.0_24',
test => "substring(option[60].text,0,11) == 'onie_vendor'",
additional_only => 1,
'option-data' => [
{
name => 'www-server',
data => 'http://10.0.0.1/install/onie/onie-installer',
'always-send' => 1,
},
],
},
],
'an ONIE switch is pointed at the installer over HTTP, as the ISC path does',
);
is(
xCAT::DHCP::BootPolicy->kea_onie_network_classes(
net => '10.0.0.0', prefix => 24, next_server => '10.0.0.1', httpport => 8080,
)->[0]{'option-data'}[0]{data},
'http://10.0.0.1:8080/install/onie/onie-installer',
'a non-default HTTP port is carried in the ONIE installer URL',
);
is_deeply(
xCAT::DHCP::BootPolicy->kea_onie_network_classes( net => '10.0.0.0', prefix => 24 ),
[],
'no ONIE class without a next server: there would be no address to point at',
);
my $s390x = xCAT::DHCP::BootPolicy->kea_s390x_network_classes(
net => '10.0.0.0',
+24
View File
@@ -42,6 +42,23 @@ like(
'QEMU s390x receives its network configuration',
);
# Two architectures the chain did not name, so a client announcing either fell
# through to the /yaboot default: a ppc64 machine, which cannot boot yaboot at
# all, and an x86-64 UEFI machine set to fetch its loader over HTTP, which is
# the same firmware and the same loader as 0x0007. The Kea policy has always
# matched both (xcat-ppc64, uefi_x64_client_architecture_match_expr), so until
# now the same machine booted on one backend and not on the other.
like(
$rendered,
qr/client-architecture = 00:0c \{ #ppc64 grub2\n\s+filename "\/boot\/grub2\/grub2\.ppc";/,
'a ppc64 client is given grub2.ppc rather than the yaboot fallback',
);
like(
$rendered,
qr/client-architecture = 00:10 \{ #x86_64 uefi http boot\n\s+filename "xcat\/xnba\.efi";/,
'the x86-64 UEFI HTTP boot id is given the same loader as 0x0007',
);
my @riscv_ids = $rendered =~ /client-architecture = (00:1[9a-e])/g;
is_deeply(
\@riscv_ids,
@@ -59,6 +76,13 @@ cmp_ok($aarch64_pos, '<', $tftp_pos, 'riscv64 follows the aarch64 branch');
cmp_ok($tftp_pos, '<', $http_pos, 'the TFTP branch precedes the HTTP branch');
cmp_ok($http_pos, '<', $opal_pos, 'the HTTP branch precedes the OPAL branch');
cmp_ok($http_pos, '<', $fallback_pos, 'the HTTP branch is reachable before the fallback');
my $ppc64_pos = index($rendered, 'client-architecture = 00:0c');
my $uefi_http_pos = index($rendered, 'client-architecture = 00:10');
cmp_ok($ppc64_pos, '>', -1, 'the ppc64 branch is rendered at all');
cmp_ok($ppc64_pos, '<', $fallback_pos, 'a ppc64 client never reaches the fallback');
cmp_ok($uefi_http_pos, '>', -1, 'the x86-64 UEFI HTTP branch is rendered at all');
cmp_ok($uefi_http_pos, '<', $fallback_pos, 'an HTTP-booting x86-64 client never reaches the fallback');
like($rendered, qr/filename "\/yaboot";\n\s*\}\n\z/, 'the policy ends with the existing yaboot fallback');
# The chainload test: a second stage announcing user class xNBA has to be
+57
View File
@@ -141,6 +141,21 @@ my %network_entry = (
['xcat-s390x-qemu-10.0.0.0_24'],
'the s390x policy is evaluated only for its subnet',
);
# ONIE is answered per network for the same reason as s390x: the answer is
# a URL naming the management node on this network. The ISC path sends the
# same URL from its onie_vendor branch.
ok( $classes{'xcat-onie-10.0.0.0_24'}, 'the Kea subnet answers ONIE switches' );
is(
$classes{'xcat-onie-10.0.0.0_24'}{'option-data'}[0]{data},
'http://10.0.0.1/install/onie/onie-installer',
'the ONIE switch is pointed at the installer on this network',
);
is_deeply(
[ grep { /^xcat-onie-/ } @{ $subnet->{additional_client_classes} } ],
['xcat-onie-10.0.0.0_24'],
'the ONIE policy is evaluated only for its subnet',
);
}
my @sysconfig_policy_cases = (
@@ -1073,4 +1088,46 @@ foreach my $case (@invalid_mac_cases) {
);
}
{
# Two netboot methods the Kea path used to answer differently from the ISC
# one, so the same node booted on one backend and not on the other.
#
# nimol: ISC supersedes server.filename with /vios/nodes/<node>; Kea named
# no boot file at all, so a VIOS install got nothing to fetch.
#
# petitboot: ISC sends the conf-file option and nothing else. Kea also set
# boot-file-name, and petitboot acts on a boot file name when it sees one,
# sending the machine after a TFTP fetch of a file that was never put there.
my $nimol = xCAT_plugin::dhcp::kea_boot_for_node(
'vios01', { netboot => 'nimol' }, undef, undef, undef, '192.0.2.1'
);
is( $nimol->{'boot-file-name'}, '/vios/nodes/vios01',
'a nimol node is given the boot file the ISC path supersedes' );
my $petitboot = xCAT_plugin::dhcp::kea_boot_for_node(
'pb01', { netboot => 'petitboot' }, undef, undef, undef, '192.0.2.1'
);
ok( !exists $petitboot->{'boot-file-name'},
'a petitboot node is named no boot file: the conf-file is the whole answer' );
my ($conf_file) = grep { $_->{name} eq 'conf-file' } @{ $petitboot->{'option-data'} };
is(
$conf_file ? $conf_file->{data} : undef,
'http://192.0.2.1/tftpboot/petitboot/pb01',
'the petitboot conf-file URL matches the ISC statement',
);
# Without a next server there is no URL to build, and a boot file name is
# still not an answer petitboot can use.
my $unserved = xCAT_plugin::dhcp::kea_boot_for_node(
'pb02', { netboot => 'petitboot' }, undef, undef, undef, undef
);
ok( !exists $unserved->{'boot-file-name'},
'a petitboot node with no next server is left alone rather than sent to TFTP' );
is_deeply(
[ grep { $_->{name} eq 'conf-file' } @{ $unserved->{'option-data'} } ],
[],
'no conf-file is invented without a next server',
);
}
done_testing();