diff --git a/perl-xCAT/xCAT/DHCP/BootPolicy.pm b/perl-xCAT/xCAT/DHCP/BootPolicy.pm index 4aedabd18..49cda8e20 100644 --- a/perl-xCAT/xCAT/DHCP/BootPolicy.pm +++ b/perl-xCAT/xCAT/DHCP/BootPolicy.pm @@ -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 ", diff --git a/xCAT-server/lib/xcat/plugins/dhcp.pm b/xCAT-server/lib/xcat/plugins/dhcp.pm index 710aba157..843b64365 100644 --- a/xCAT-server/lib/xcat/plugins/dhcp.pm +++ b/xCAT-server/lib/xcat/plugins/dhcp.pm @@ -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); diff --git a/xCAT-test/unit/dhcp_boot_policy.t b/xCAT-test/unit/dhcp_boot_policy.t index 6430caad2..169f49c85 100644 --- a/xCAT-test/unit/dhcp_boot_policy.t +++ b/xCAT-test/unit/dhcp_boot_policy.t @@ -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', diff --git a/xCAT-test/unit/dhcp_isc_client_arch.t b/xCAT-test/unit/dhcp_isc_client_arch.t index dddc04fe1..cff7e192c 100644 --- a/xCAT-test/unit/dhcp_isc_client_arch.t +++ b/xCAT-test/unit/dhcp_isc_client_arch.t @@ -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 diff --git a/xCAT-test/unit/dhcp_kea_plugin_intent.t b/xCAT-test/unit/dhcp_kea_plugin_intent.t index 95c4dbe35..34e71145d 100644 --- a/xCAT-test/unit/dhcp_kea_plugin_intent.t +++ b/xCAT-test/unit/dhcp_kea_plugin_intent.t @@ -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/; 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();