diff --git a/perl-xCAT/xCAT/DHCP/BootPolicy.pm b/perl-xCAT/xCAT/DHCP/BootPolicy.pm index 8c0b1eb3e..c722608ae 100644 --- a/perl-xCAT/xCAT/DHCP/BootPolicy.pm +++ b/perl-xCAT/xCAT/DHCP/BootPolicy.pm @@ -300,50 +300,122 @@ sub isc_client_architecture_lines { my $maskbits = $opts{prefix} // ''; my $xnba = $class->isc_xnba_user_class_test(); - return [ - " if $xnba and option client-architecture = 00:00 { #x86, xCAT Network Boot Agent\n", - " always-broadcast on;\n", - " filename = \"http://$tftp$portsuffix/tftpboot/xcat/xnba/nets/${net}_${maskbits}\";\n", - " } else if $xnba and option client-architecture = 00:09 { #x86, xCAT Network Boot Agent\n", - " filename = \"http://$tftp$portsuffix/tftpboot/xcat/xnba/nets/${net}_${maskbits}.uefi\";\n", - " } else if $xnba and option client-architecture = 00:07 { #x86-64 UEFI, xCAT Network Boot Agent\n", - " filename = \"http://$tftp$portsuffix/tftpboot/xcat/xnba/nets/${net}_${maskbits}.uefi\";\n", - " } else if option client-architecture = 00:00 { #x86\n", - " filename \"xcat/xnba.kpxe\";\n", - " } else if option vendor-class-identifier = \"Etherboot-5.4\" { #x86\n", - " filename \"xcat/xnba.kpxe\";\n", - " } else if option client-architecture = 00:07 { #x86_64 uefi\n ", - " 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 ", + # Which loaders are actually on disk. A branch that names a file the TFTP + # server does not have costs the client a full timeout it has no way to + # diagnose, so the branch is left out and the client falls through to + # whatever the chain answers next. The Kea side has always worked this way + # (kea_client_classes takes the same two flags, kea_httpboot_network_classes + # the same probe); until now ISC named all of them unconditionally. + my $present = $opts{loader_present} || sub { return 1 }; + my $tftpdir = $opts{tftpdir} || '/tftpboot'; + $tftpdir =~ s{/+$}{}; + my $kpxe = $present->("$tftpdir/xcat/xnba.kpxe"); + my $efi = $present->("$tftpdir/xcat/xnba.efi"); + + # Each entry is the head of one branch and the statements inside it. They + # are chained afterwards so that dropping one still leaves a well-formed + # if/else if chain -- the first branch present has to be the `if`. + my @branches; + + if ($kpxe) { + push @branches, [ + "$xnba and option client-architecture = 00:00 { #x86, xCAT Network Boot Agent\n", + " always-broadcast on;\n", + " filename = \"http://$tftp$portsuffix/tftpboot/xcat/xnba/nets/${net}_${maskbits}\";\n", + ]; + } + if ($efi) { + push @branches, [ + "$xnba and option client-architecture = 00:09 { #x86, xCAT Network Boot Agent\n", + " filename = \"http://$tftp$portsuffix/tftpboot/xcat/xnba/nets/${net}_${maskbits}.uefi\";\n", + ], + [ + "$xnba and option client-architecture = 00:07 { #x86-64 UEFI, xCAT Network Boot Agent\n", + " filename = \"http://$tftp$portsuffix/tftpboot/xcat/xnba/nets/${net}_${maskbits}.uefi\";\n", + ]; + } + if ($kpxe) { + push @branches, [ + "option client-architecture = 00:00 { #x86\n", + " filename \"xcat/xnba.kpxe\";\n", + ], + [ + "option vendor-class-identifier = \"Etherboot-5.4\" { #x86\n", + " filename \"xcat/xnba.kpxe\";\n", + ]; + } + if ($efi) { + push @branches, [ + "option client-architecture = 00:07 { #x86_64 uefi\n ", + " filename \"xcat/xnba.efi\";\n", + ], + [ + "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. + [ + "option client-architecture = 00:10 { #x86_64 uefi http boot\n ", + " filename \"xcat/xnba.efi\";\n", + ]; + } + + push @branches, [ + "option client-architecture = 00:02 { #ia64\n ", " filename \"elilo.efi\";\n", - " } else if option client-architecture = 00:0b { #aaarch64\n ", + ], + [ + "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 ", + ], + + # 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. + [ + "option client-architecture = 00:0c { #ppc64 grub2\n ", " filename \"/boot/grub2/grub2.ppc\";\n", - " } else if option client-architecture = 00:1b { #riscv64 uefi\n ", + ], + [ + "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 ", - " option vendor-class-identifier \"HTTPClient\";\n", - " filename \"http://$tftp$portsuffix/tftpboot/boot/grub2/grub2.riscv64\";\n", - " } else if option client-architecture = 00:1f { #QEMU s390x\n ", + ]; + + if ( $present->("$tftpdir/boot/grub2/grub2.riscv64") ) { + push @branches, [ + "option client-architecture = 00:1c { #riscv64 uefi http boot\n ", + " option vendor-class-identifier \"HTTPClient\";\n", + " filename \"http://$tftp$portsuffix/tftpboot/boot/grub2/grub2.riscv64\";\n", + ]; + } + + push @branches, [ + "option client-architecture = 00:1f { #QEMU s390x\n ", " option conf-file = \"s390x/${net}_${maskbits}\";\n", - " } else if option client-architecture = 00:0e { #OPAL-v3\n ", + ], + [ + "option client-architecture = 00:0e { #OPAL-v3\n ", " option conf-file = \"http://$tftp$portsuffix/tftpboot/pxelinux.cfg/p/${net}_${maskbits}\";\n", - " } else if substring (option vendor-class-identifier,0,11) = \"onie_vendor\" { #for onie on cumulus switch\n", + ], + [ + "substring (option vendor-class-identifier,0,11) = \"onie_vendor\" { #for onie on cumulus switch\n", " option www-server = \"http://$tftp$portsuffix/install/onie/onie-installer\";\n", - " } else if substring(filename,0,1) = null { #otherwise, provide yaboot if the client isn't specific\n ", + ], + [ + "substring(filename,0,1) = null { #otherwise, provide yaboot if the client isn't specific\n ", " filename \"/yaboot\";\n", - " }\n", - ]; + ]; + + my @lines; + foreach my $branch (@branches) { + my ( $head, @body ) = @$branch; + push @lines, ( @lines ? " } else if " : " if " ) . $head, @body; + } + push @lines, " }\n"; + + return \@lines; } sub kea_xnba_node_classes { @@ -469,6 +541,72 @@ sub kea_iscsi_node_classes { return \@classes; } +# The tag that hands a client to the proxyDHCP daemon. +# +# Windows UEFI firmware that is offered no boot file, but sees option 60 set to +# PXEClient, goes and asks the daemon listening on port 4011 for one. ISC does +# this in the node's own host block, for the three architecture ids its +# firmware announces; anything else on that node is simply given no boot file. +# +# The empty boot file is the reservation's job -- it outranks every class -- so +# what is left for the class is the tag, and the architectures it is meant for. +sub kea_proxydhcp_node_classes { + my ( $class, %opts ) = @_; + + my $nodes = $opts{nodes} || []; + my $arches = join ' or ', + map { "option[93].hex == $_" } qw(0x0000 0x0007 0x0009); + my @classes; + + foreach my $node (@$nodes) { + next unless $node->{node} && $node->{mac}; + push @classes, { + name => _node_class_base( 'proxydhcp', $node->{node}, $node->{mac} ), + test => _mac_test( $node->{mac} ) . " and ($arches)", + 'option-data' => [ + { + name => 'vendor-class-identifier', + data => 'PXEClient', + 'always-send' => 1, + }, + ], + 'user-context' => _node_user_context( $node, 'proxydhcp-deferral' ), + }; + } + + return \@classes; +} + +# The MACs that are to be answered with nothing at all. +# +# ISC writes `deny booting;` into the host block of a NIC marked *NOIP* in the +# mac table, and dhcpd then says nothing to it. Kea has one way to do that: a +# packet assigned to a class named exactly DROP is discarded. Nothing else +# about the name is special, and there can only be one of it, so every such +# MAC in the cluster shares the class and the user-context records whose they +# are, so a later makedhcp for one node can rebuild it without losing the rest. +# +# Skipping the reservation is not enough on its own: the subnet-wide classes +# match on architecture and would still hand the interface a boot file, which +# is the whole thing the marking exists to prevent. +sub kea_drop_client_class { + my ( $class, %opts ) = @_; + + my @macs = grep { $_->{node} && $_->{mac} } @{ $opts{macs} || [] }; + return unless @macs; + + my @sorted = sort { $a->{node} cmp $b->{node} or $a->{mac} cmp $b->{mac} } @macs; + + return { + name => 'DROP', + test => join( ' or ', map { _mac_test( $_->{mac} ) } @sorted ), + 'user-context' => { + 'xcat-purpose' => 'noip-drop', + 'xcat-macs' => [ map { { node => $_->{node}, mac => lc( $_->{mac} ) } } @sorted ], + }, + }; +} + #: The encapsulated space ISC declares as "option space isan" -- option 43 #: carrying the initiator name in 203 and the root path in 201. sub kea_isan_option_defs { diff --git a/xCAT-server/lib/xcat/plugins/dhcp.pm b/xCAT-server/lib/xcat/plugins/dhcp.pm index 190c14e42..cfe7b9879 100644 --- a/xCAT-server/lib/xcat/plugins/dhcp.pm +++ b/xCAT-server/lib/xcat/plugins/dhcp.pm @@ -3717,7 +3717,20 @@ sub kea_sync_node_client_classes my ( $config, $nodes ) = @_; my $changed = kea_remove_node_client_classes($config, $nodes); - my $classes = kea_node_client_classes_for_nodes($nodes); + my $generated = kea_node_client_classes_for_nodes($nodes); + + # The remove above took this node range's MACs out of the DROP class; these + # are the ones it is to have from now on, added to whatever the rest of the + # cluster already had there. + if (@{ $generated->{noip} }) { + kea_set_drop_client_class( + $config, + [ @{ kea_drop_client_class_macs($config) }, @{ $generated->{noip} } ] + ); + $changed = 1; + } + + my $classes = $generated->{classes}; return $changed unless @$classes; $config->{Dhcp4} ||= {}; @@ -3727,11 +3740,37 @@ sub kea_sync_node_client_classes return 1; } +sub kea_drop_client_class_macs +{ + my ($config) = @_; + + foreach my $class ( @{ ( $config->{Dhcp4} || {} )->{'client-classes'} || [] } ) { + next unless ( $class->{name} || '' ) eq 'DROP'; + return ( $class->{'user-context'} || {} )->{'xcat-macs'} || []; + } + return []; +} + +sub kea_set_drop_client_class +{ + my ( $config, $macs ) = @_; + + $config->{Dhcp4} ||= {}; + my @classes = grep { ( $_->{name} || '' ) ne 'DROP' } + @{ $config->{Dhcp4}{'client-classes'} || [] }; + my $drop = xCAT::DHCP::BootPolicy->kea_drop_client_class( macs => $macs ); + push @classes, $drop if $drop; + $config->{Dhcp4}{'client-classes'} = \@classes; + + return; +} + #: Every per-node class this plugin generates carries one of these, so a node #: that changes netboot method loses the classes the old one wrote. my %KEA_NODE_CLASS_PURPOSES = map { $_ => 1 } qw( xnba-second-stage pxe-vendor + proxydhcp-deferral iscsi-initiator ); @@ -3755,6 +3794,16 @@ sub kea_remove_node_client_classes } $config->{Dhcp4}{'client-classes'} = \@kept if $changed; + + # A node's *NOIP* interfaces share one DROP class with the rest of the + # cluster, so removing them means rewriting it rather than dropping it. + my $macs = kea_drop_client_class_macs($config); + my @kept_macs = grep { !$nodes{ $_->{node} || '' } } @$macs; + if ( scalar(@kept_macs) != scalar(@$macs) ) { + kea_set_drop_client_class( $config, \@kept_macs ); + $changed = 1; + } + return $changed; } @@ -3764,12 +3813,16 @@ sub kea_node_client_classes_for_nodes my $nrtab = xCAT::Table->new('noderes'); my $mactab = xCAT::Table->new('mac'); - return [] unless $nrtab && $mactab; + return { classes => [], noip => [] } unless $nrtab && $mactab; my $iscsitab = xCAT::Table->new('iscsi', -create => 0); + my $chaintab = xCAT::Table->new('chain', -create => 0); + my $nodetypetab = xCAT::Table->new('nodetype', -create => 0); my $nrents = $nrtab->getNodesAttribs($nodes, [ 'tftpserver', 'netboot', 'proxydhcp', 'xcatmaster', 'servicenode' ]); my $macents = $mactab->getNodesAttribs($nodes, ['mac']); my $ients = $iscsitab ? $iscsitab->getNodesAttribs($nodes, [qw(server target lun iname)]) : undef; + my $chainents = $chaintab ? $chaintab->getNodesAttribs($nodes, ['currstate']) : undef; + my $ntents = $nodetypetab ? $nodetypetab->getNodesAttribs($nodes, [qw(os provmethod arch)]) : undef; my $httpport = "80"; my @hports = xCAT::TableUtils->get_site_attribute("httpport"); if ($hports[0]) { @@ -3779,10 +3832,22 @@ sub kea_node_client_classes_for_nodes my @xnba; my @pxe; my @iscsi; + my @noip; + my @proxydhcp; foreach my $node (@$nodes) { my $nrent = $nrents && $nrents->{$node} ? $nrents->{$node}->[0] : undef; my $netboot = $nrent ? $nrent->{netboot} : undef; + # A node that is to boot from disk, or that is waiting on the proxyDHCP + # daemon, is given no loader at all -- so it is given no boot classes + # either. ISC suppresses the same thing by leaving the second-stage + # branches out of the node's host block. + my $intent = kea_node_boot_intent( + $nrent, + $chainents && $chainents->{$node} ? $chainents->{$node}->[0] : undef, + $ntents && $ntents->{$node} ? $ntents->{$node}->[0] : undef, + ); + my $macent = $macents && $macents->{$node} ? $macents->{$node}->[0] : undef; next unless $macent && $macent->{mac}; @@ -3792,12 +3857,26 @@ sub kea_node_client_classes_for_nodes my $iname = ( $ient and $ient->{server} and $ient->{target} ) ? $ient->{iname} : undef; foreach my $mace (split(/\|/, $macent->{mac})) { - my ($mac) = split(/!/, $mace); + my ( $mac, $hname ) = split(/!/, $mace); $mac = kea_normalize_mac($mac); next unless $mac; my %record = ( node => $node, mac => $mac ); - if ($netboot and $netboot eq 'xnba' and $nxtsrv) { + # The interface is marked as having no address on purpose. ISC + # answers it with "deny booting;"; the Kea equivalent is the DROP + # class, which the caller merges with the rest of the cluster's. + if ( defined($hname) and $hname eq '*NOIP*' ) { + push @noip, {%record}; + next; + } + + if ( $intent eq 'proxydhcp' ) { + push @proxydhcp, {%record}; + } elsif ( $intent eq 'disk' ) { + + # Nothing: the reservation names an empty boot file and that + # outranks anything a class could say. + } elsif ($netboot and $netboot eq 'xnba' and $nxtsrv) { push @xnba, { %record, next_server => $nxtsrv, httpport => $httpport }; } elsif ($netboot and $netboot eq 'pxe') { push @pxe, {%record}; @@ -3810,14 +3889,40 @@ sub kea_node_client_classes_for_nodes } } - return [ - @{ xCAT::DHCP::BootPolicy->kea_xnba_node_classes( - nodes => \@xnba, - xnba_efi => -f "$tftpdir/xcat/xnba.efi" ? 1 : 0, - ) }, - @{ xCAT::DHCP::BootPolicy->kea_pxe_node_classes( nodes => \@pxe ) }, - @{ xCAT::DHCP::BootPolicy->kea_iscsi_node_classes( nodes => \@iscsi ) }, - ]; + return { + classes => [ + @{ xCAT::DHCP::BootPolicy->kea_xnba_node_classes( + nodes => \@xnba, + xnba_efi => -f "$tftpdir/xcat/xnba.efi" ? 1 : 0, + ) }, + @{ xCAT::DHCP::BootPolicy->kea_pxe_node_classes( nodes => \@pxe ) }, + @{ xCAT::DHCP::BootPolicy->kea_proxydhcp_node_classes( nodes => \@proxydhcp ) }, + @{ xCAT::DHCP::BootPolicy->kea_iscsi_node_classes( nodes => \@iscsi ) }, + ], + noip => \@noip, + }; +} + +# What the node has been told to do next, as far as this reply is concerned. +# +# 'disk' -- chain.currstate is boot or iscsiboot: it has an operating +# system now and must be left to start it. +# 'proxydhcp' -- a Windows install or winshell on UEFI firmware, with the +# proxyDHCP daemon running to answer it on 4011. +# 'netboot' -- everything else, which is to be given a loader. +sub kea_node_boot_intent +{ + my ( $nrent, $chainent, $ntent ) = @_; + + my $currstate = ( $chainent && defined $chainent->{currstate} ) ? $chainent->{currstate} : ''; + return 'disk' if $currstate eq 'boot' or $currstate eq 'iscsiboot'; + + my $douefi = ( $ntent and $ntent->{os} ) ? check_uefi_support($ntent) : 1; + if ( ( $douefi == 2 and $currstate =~ /^install/ ) or $currstate =~ /^winshell/ ) { + return 'proxydhcp' if proxydhcp($nrent); + } + + return 'netboot'; } sub kea_iscsi_root_path @@ -4020,6 +4125,7 @@ sub kea_boot_for_node my %boot = ( 'option-data' => [] ); my $netboot = $nrent ? $nrent->{netboot} : undef; + my $intent = kea_node_boot_intent( $nrent, $chainent, $ntent ); # A node with an initiator name has to choose between the ISAN vendor form # and the standard one, and a reservation's option-data outranks any class, @@ -4033,7 +4139,16 @@ sub kea_boot_for_node # netboot=pxe is absent from this chain for the same reason: a ScaleMP # machine has to be able to win, and only a class can outrank nothing. # kea_pxe_node_classes writes both halves of that choice. - if ($netboot and $netboot eq 'yaboot') { + if ( $intent ne 'netboot' ) { + + # A node told to boot from disk, and a Windows UEFI install waiting on + # the proxyDHCP daemon, are both to be handed no boot file. ISC writes + # filename = "" into the node's own host block, which outranks the + # subnet; the reservation is what outranks a class on Kea, so the empty + # name has to be set here or the architecture classes answer instead + # and the node netboots forever. + $boot{'boot-file-name'} = ''; + } elsif ($netboot and $netboot eq 'yaboot') { $boot{'boot-file-name'} = "/yb/node/yaboot-$node"; } elsif ($netboot and $netboot =~ /^grub2[-]?.*$/) { $boot{'boot-file-name'} = "/boot/grub2/grub2-$node"; @@ -4722,11 +4837,12 @@ sub addnet # $lstatements = 'if exists gpxe.bus-id { filename = \"\"; } else if exists client-architecture { filename = \"xcat/xnba.kpxe\"; } '.$lstatements; push @netent, @{ xCAT::DHCP::BootPolicy->isc_client_architecture_lines( - next_server => $tftp, - portsuffix => $portsuffix, - tftpdir => $tftpdir, - net => $net, - prefix => $maskbits, + next_server => $tftp, + portsuffix => $portsuffix, + tftpdir => $tftpdir, + net => $net, + prefix => $maskbits, + loader_present => sub { -e $_[0] }, ) }; if ($range) { diff --git a/xCAT-test/unit/dhcp_isc_client_arch.t b/xCAT-test/unit/dhcp_isc_client_arch.t index 9933b4fe7..00f1969e0 100644 --- a/xCAT-test/unit/dhcp_isc_client_arch.t +++ b/xCAT-test/unit/dhcp_isc_client_arch.t @@ -152,4 +152,68 @@ is( 'and both backends land on the same number', ); +# A loader that is not on disk is not named. Naming one costs the client a +# full TFTP timeout it cannot diagnose, and the Kea side has always left the +# class out for exactly that reason -- so the same missing file has to produce +# the same silence on both backends. +{ + my @asked; + my %present = map { $_ => 1 } ( + '/srv/tftp/xcat/xnba.efi', + '/srv/tftp/boot/grub2/grub2.riscv64', + ); + my $partial = join '', @{ xCAT::DHCP::BootPolicy->isc_client_architecture_lines( + next_server => '192.0.2.10', + portsuffix => '', + tftpdir => '/srv/tftp', + net => '192.0.2.0', + prefix => 24, + loader_present => sub { push @asked, $_[0]; return $present{ $_[0] } }, + ) }; + + unlike( $partial, qr/xnba\.kpxe/, + 'a BIOS client is not sent after a kpxe loader that was never built' ); + like( $partial, qr/xnba\.efi/, + 'and the UEFI loader that is there is still offered' ); + + # The second stage is fetched over HTTP, but it is the first stage that + # asks for it, so it is gated on the same file. + unlike( $partial, qr{/xcat/xnba/nets/192\.0\.2\.0_24"}, + 'no BIOS second stage is advertised without the first stage to reach it' ); + like( $partial, qr{/xcat/xnba/nets/192\.0\.2\.0_24\.uefi"}, + 'the UEFI second stage is advertised, because its first stage exists' ); + + is( scalar( grep { $_ eq '/srv/tftp/boot/grub2/grub2.riscv64' } @asked ), 1, + 'the riscv64 HTTP branch is probed under the configured tftp directory' ); + + # Whatever is dropped, what is left has to still be one chain: dhcpd + # rejects a leading "} else if" and refuses to start. + like( $partial, qr/\A if /, 'the first surviving branch opens the chain' ); + unlike( $partial, qr/\n \} else if [^\n]*\n\s*\}\n \} else if /, + 'no branch is left dangling between two chains' ); + is( scalar( () = $partial =~ /^ \}\n/mg ), 1, + 'and the chain is closed exactly once' ); +} + +{ + # Nothing on disk at all: the architecture branches that name a file xCAT + # never builds stay, and the client that matches none of them still ends up + # at the fallback rather than at a parse error. + my $bare = join '', @{ xCAT::DHCP::BootPolicy->isc_client_architecture_lines( + next_server => '192.0.2.10', + portsuffix => '', + tftpdir => '/srv/tftp', + net => '192.0.2.0', + prefix => 24, + loader_present => sub { return 0 }, + ) }; + + like( $bare, qr/\A if option client-architecture = 00:02 /, + 'the chain opens on the first branch that survives' ); + like( $bare, qr/filename "\/yaboot";\n\s*\}\n\z/, + 'and still ends at the fallback' ); + unlike( $bare, qr/HTTPClient/, + 'the riscv64 HTTP branch goes with the image it would have served' ); +} + done_testing(); diff --git a/xCAT-test/unit/dhcp_kea_plugin_intent.t b/xCAT-test/unit/dhcp_kea_plugin_intent.t index b4f50a8a4..8f5867ffc 100644 --- a/xCAT-test/unit/dhcp_kea_plugin_intent.t +++ b/xCAT-test/unit/dhcp_kea_plugin_intent.t @@ -858,7 +858,7 @@ foreach my $case (@invalid_mac_cases) { }; local *xCAT_plugin::dhcp::kea_next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); }; - my $classes = xCAT_plugin::dhcp::kea_node_client_classes_for_nodes(['xnba01']); + my $classes = xCAT_plugin::dhcp::kea_node_client_classes_for_nodes(['xnba01'])->{classes}; my ($bios_class) = grep { $_->{name} =~ /-bios\z/ } @$classes; ok( $bios_class, 'hyphenated xNBA MAC produces a BIOS client class' ); is( @@ -1257,7 +1257,7 @@ foreach my $case (@invalid_mac_cases) { }; local *xCAT_plugin::dhcp::kea_next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); }; - my $classes = xCAT_plugin::dhcp::kea_node_client_classes_for_nodes( [ 'smp01', 'san01' ] ); + my $classes = xCAT_plugin::dhcp::kea_node_client_classes_for_nodes( [ 'smp01', 'san01' ] )->{classes}; my %by_name = map { $_->{name} => $_ } @$classes; is( $by_name{'xcat-pxe-smp01-aabbccddee01-scalemp'}{'boot-file-name'}, @@ -1298,4 +1298,163 @@ foreach my $case (@invalid_mac_cases) { is( $defs{'isan/root-path'}{code}, 201, 'the root path is sub-option 201' ); } +{ + # A NIC marked *NOIP* in the mac table is meant to be answered with + # nothing. ISC writes "deny booting;" into its host block; Kea discards a + # packet assigned to the class named DROP, and nothing else will do -- + # skipping the reservation still leaves the subnet-wide architecture + # classes handing the interface a boot file. + my %tables = ( + noderes => DHCPKeaResTable->new( + { cn01 => { netboot => 'xnba' }, cn02 => { netboot => 'pxe' } } + ), + mac => DHCPKeaResTable->new( + { + cn01 => { mac => 'aa:bb:cc:dd:ee:01|aa:bb:cc:dd:ee:11!*NOIP*' }, + cn02 => { mac => 'aa:bb:cc:dd:ee:02!*NOIP*' }, + } + ), + ); + + no warnings 'redefine'; + local *xCAT::Table::new = sub { + my ( $class, $name ) = @_; + return $tables{$name}; + }; + local *xCAT_plugin::dhcp::kea_next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); }; + + my $config = { Dhcp4 => { 'client-classes' => [] } }; + ok( xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, [ 'cn01', 'cn02' ] ), + 'marking an interface *NOIP* changes the configuration' ); + + my ($drop) = grep { $_->{name} eq 'DROP' } @{ $config->{Dhcp4}{'client-classes'} }; + ok( $drop, 'the MACs land in the one class Kea treats as a discard' ); + is( $drop->{test}, + 'pkt4.mac == 0xaabbccddee11 or pkt4.mac == 0xaabbccddee02', + 'every marked MAC in the range is named, and only those' ); + + # cn01's real NIC is untouched: the marking is per interface, not per node. + ok( ( grep { $_->{name} =~ /aabbccddee01/ } @{ $config->{Dhcp4}{'client-classes'} } ), + 'the node\'s addressed NIC still gets its boot classes' ); + + # Re-running makedhcp for one node must not take the other node's + # interfaces out of the class they share. + xCAT_plugin::dhcp::kea_sync_node_client_classes( $config, ['cn01'] ); + ($drop) = grep { $_->{name} eq 'DROP' } @{ $config->{Dhcp4}{'client-classes'} }; + is( $drop->{test}, + 'pkt4.mac == 0xaabbccddee11 or pkt4.mac == 0xaabbccddee02', + 'a makedhcp for one node leaves the rest of the cluster in the DROP class' ); + + # ...and makedhcp -d for a node takes only that node's out. + ok( xCAT_plugin::dhcp::kea_remove_node_client_classes( $config, ['cn02'] ), + 'removing a node with a marked interface changes the configuration' ); + ($drop) = grep { $_->{name} eq 'DROP' } @{ $config->{Dhcp4}{'client-classes'} }; + is( $drop->{test}, 'pkt4.mac == 0xaabbccddee11', + 'and leaves the other node still dropped' ); + + xCAT_plugin::dhcp::kea_remove_node_client_classes( $config, ['cn01'] ); + is_deeply( + [ grep { $_->{name} eq 'DROP' } @{ $config->{Dhcp4}{'client-classes'} } ], + [], + 'with nothing left to drop the class goes rather than matching nothing', + ); +} + +{ + # A node that has an operating system now, and a Windows UEFI install + # waiting on the proxyDHCP daemon, both have to be handed no boot file. + # ISC writes filename = "" into the node's host block, which outranks the + # subnet chain. On Kea only a reservation outranks a class, so if the + # reservation stays silent the subnet's architecture classes answer instead + # and an installed node netboots forever. + no warnings 'redefine'; + local *xCAT_plugin::dhcp::proxydhcp = sub { return 1; }; + + foreach my $state (qw(boot iscsiboot)) { + my $booted = xCAT_plugin::dhcp::kea_boot_for_node( + 'cn01', { netboot => 'xnba' }, { currstate => $state }, undef, undef, '192.0.2.1' + ); + is( $booted->{'boot-file-name'}, '', + "a node in state $state is handed no boot file rather than left to the subnet" ); + } + + my $installing = xCAT_plugin::dhcp::kea_boot_for_node( + 'win01', { netboot => 'xnba' }, { currstate => 'install' }, + { os => 'win2022' }, undef, '192.0.2.1' + ); + is( $installing->{'boot-file-name'}, '', + 'a Windows UEFI install names no boot file, which is what defers it to proxyDHCP' ); + + # ...and the same node on a Linux install is not deferred to anything. + my $linux = xCAT_plugin::dhcp::kea_boot_for_node( + 'cn02', { netboot => 'pxe' }, { currstate => 'install' }, + { os => 'rhels9' }, undef, '192.0.2.1' + ); + ok( !exists $linux->{'boot-file-name'}, + 'a Linux install is left to its classes as before' ); + + # An iSCSI node told to boot from disk still needs its root path: it is + # what the disk is. + my $iscsi = xCAT_plugin::dhcp::kea_boot_for_node( + 'cn03', {}, { currstate => 'iscsiboot' }, undef, + { server => '192.0.2.9', target => 'iqn.2024-01.test:cn03', lun => 0 }, + '192.0.2.1' + ); + is( $iscsi->{'boot-file-name'}, '', 'an iscsiboot node is handed no boot file' ); + ok( ( grep { $_->{name} eq 'root-path' } @{ $iscsi->{'option-data'} } ), + 'but it keeps the root path that says where its disk is' ); +} + +{ + my %tables = ( + noderes => DHCPKeaResTable->new( + { + booted => { netboot => 'xnba' }, + win01 => { netboot => 'xnba' }, + } + ), + mac => DHCPKeaResTable->new( + { + booted => { mac => 'aa:bb:cc:dd:ee:03' }, + win01 => { mac => 'aa:bb:cc:dd:ee:04' }, + } + ), + chain => DHCPKeaResTable->new( + { booted => { currstate => 'boot' }, win01 => { currstate => 'install' } } + ), + nodetype => DHCPKeaResTable->new( + { booted => { os => 'rhels9' }, win01 => { os => 'win2022' } } + ), + ); + + no warnings 'redefine'; + local *xCAT::Table::new = sub { + my ( $class, $name ) = @_; + return $tables{$name}; + }; + local *xCAT_plugin::dhcp::kea_next_server_for_node = sub { return ( '192.0.2.1', '192.0.2.1' ); }; + local *xCAT_plugin::dhcp::proxydhcp = sub { return 1; }; + + my $classes = xCAT_plugin::dhcp::kea_node_client_classes_for_nodes( [ 'booted', 'win01' ] )->{classes}; + my %by_name = map { $_->{name} => $_ } @$classes; + + is_deeply( + [ grep { /booted/ } keys %by_name ], + [], + 'a node booting from disk is given no second stage to chainload', + ); + + my $deferral = $by_name{'xcat-proxydhcp-win01-aabbccddee04'}; + ok( $deferral, 'the Windows UEFI node gets the class that tags its reply' ); + is_deeply( + $deferral->{'option-data'}, + [ { name => 'vendor-class-identifier', data => 'PXEClient', 'always-send' => 1 } ], + 'the tag is what sends the firmware to the daemon on 4011', + ); + like( $deferral->{test}, qr/option\[93\]\.hex == 0x0000 or option\[93\]\.hex == 0x0007 or option\[93\]\.hex == 0x0009/, + 'and only the architectures ISC tags are tagged' ); + ok( !exists $by_name{'xcat-xnba-win01-aabbccddee04-bios'}, + 'the node gets no xNBA class that would pre-empt the deferral' ); +} + done_testing();