diff --git a/xCAT-test/autotest/testcase/integration/cases0 b/xCAT-test/autotest/testcase/integration/cases0 index 115e7030b..2795c560e 100644 --- a/xCAT-test/autotest/testcase/integration/cases0 +++ b/xCAT-test/autotest/testcase/integration/cases0 @@ -4,5 +4,5 @@ os:Linux label:mn_only,ci_test,integration cmd:prove -I/opt/xcat/lib/perl -I/opt/xcat/lib/perl/xCAT -r /opt/xcat/share/xcat/tools/autotest/integration check:rc==0 -check:output=~Files=8, +check:output=~Files=9, end diff --git a/xCAT-test/integration/README.md b/xCAT-test/integration/README.md index 7be6a5560..bfb2a93c0 100644 --- a/xCAT-test/integration/README.md +++ b/xCAT-test/integration/README.md @@ -27,8 +27,8 @@ against it. Note the `-I` flags: unlike the unit tests these run from the installed location, so they pick up xCAT modules from `/opt/xcat/lib/perl` rather than from a source tree. -The case checks `rc==0` and `output=~Files=8,`. The file count checks that `prove` -found all eight files, which catches a packaging regression or a test renamed away from +The case checks `rc==0` and `output=~Files=9,`. The file count checks that `prove` +found all nine files, which catches a packaging regression or a test renamed away from `.t` without updating the count. It does not prove that environment-gated cases ran; inspect the TAP output for skips. **Add to that number when you add a test.** A missing directory is already caught by `rc==0` -- `prove -r` on a path that does not exist exits @@ -49,6 +49,7 @@ A test belongs in `integration/` when it needs something the checkout cannot pro | `copycds_packages_integrity.t` | `/install` populated by a real `copycds` | | `dhcp_isc_config_validation.t` | a `dhcpd` binary that can read the generated config, as root | | `dhcp_isc_ipxe_leases.t` | `dhcpd` and `omshell`, root, and `ip netns` for a private network namespace | +| `dhcp_ipxe_dispatch.t` | `dhcpd` with `omshell`, or `kea-dhcp4`, root, and `ip netns` for two private network namespaces | | `dhcp_kea_config_validation.t` | a `kea-dhcp4` binary that can read the generated config | | `dhcp_kea_control_agent_smoke.t` | live `kea-dhcp4` and `kea-ctrl-agent`, root, and the Kea host-commands hook | | `html_form_runtime_dependency.t` | the installed `HTML::Form` module | diff --git a/xCAT-test/integration/dhcp_ipxe_dispatch.t b/xCAT-test/integration/dhcp_ipxe_dispatch.t new file mode 100644 index 000000000..8de7a3bd3 --- /dev/null +++ b/xCAT-test/integration/dhcp_ipxe_dispatch.t @@ -0,0 +1,357 @@ +#!/usr/bin/env perl +# makedhcp picks the boot file of an x86 client from its netboot method, its architecture, its user +# class and the iPXE feature options it reports in option 175. The test runs dhcpd and kea-dhcp4 in +# a network namespace with the rules that BootPolicy renders, sends relayed DHCPDISCOVER packets for +# each kind of client from a second namespace, and checks the boot file of each offer. +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; +use lib "$FindBin::Bin/lib"; + +use File::Copy qw(copy); +use File::Path qw(make_path); +use File::Temp qw(tempdir); +use JSON (); +use Test::More; + +use xCAT::CommandUtils; +use xCAT::DHCP::Backend::Kea; +use xCAT::DHCP::BootPolicy; +use xCAT::DHCP::OmapiPolicy; +use XCAT::Test::DHCP qw(start_daemon process_running stop_daemons diag_file); + +plan skip_all => 'root is required to run DHCP servers in network namespaces' unless $> == 0; +my ($dhcpd) = grep { -x $_ } qw(/usr/sbin/dhcpd /usr/local/sbin/dhcpd); +my $omapi = xCAT::DHCP::OmapiPolicy->settings( site_values => {} ); +my $kea_dhcp4 = xCAT::CommandUtils::find_executable('kea-dhcp4'); +my @backends = ( ( $dhcpd && -x $omapi->{omshell_path} ? 'isc' : () ), ( $kea_dhcp4 ? 'kea' : () ) ); +plan skip_all => 'dhcpd with omshell, or kea-dhcp4, is required' unless @backends; + +my $server_ns = "xcat-test-$$-s"; +my $client_ns = "xcat-test-$$-c"; +my ( @namespaces, %children ); +END { + stop_daemons( \%children ); + system( 'ip', 'netns', 'delete', $_ ) for @namespaces; +} +for my $namespace ( $server_ns, $client_ns ) { + plan skip_all => 'ip netns is required' unless system("ip netns add $namespace >/dev/null 2>&1") == 0; + push @namespaces, $namespace; +} +my $interface = "xt$$"; +for my $command ( + [ $server_ns, 'link', 'set', 'lo', 'up' ], + [ $client_ns, 'link', 'set', 'lo', 'up' ], + [ $server_ns, 'link', 'add', "${interface}s", 'type', 'veth', 'peer', 'name', "${interface}c", 'netns', $client_ns ], + [ $server_ns, 'address', 'add', '192.0.2.1/24', 'dev', "${interface}s" ], + [ $client_ns, 'address', 'add', '192.0.2.2/24', 'dev', "${interface}c" ], + [ $server_ns, 'link', 'set', "${interface}s", 'up' ], + [ $client_ns, 'link', 'set', "${interface}c", 'up' ], + ) +{ + my ( $namespace, @args ) = @$command; + system( 'ip', '-n', $namespace, @args ) == 0 or BAIL_OUT("ip -n $namespace @args failed"); +} + +# The nodes: one of each method that installs, one of each that boots from SAN, and, on ISC, one of +# each in the boot state of an iSCSI node. +my %xnba = ( name => 'cn01', mac => '52:54:00:00:10:01', ip => '192.0.2.11', netboot => 'xnba' ); +my %ipxe = ( name => 'cn04', mac => '52:54:00:00:10:04', ip => '192.0.2.14', netboot => 'ipxe' ); +my %xnba_san = ( name => 'cn03', mac => '52:54:00:00:10:03', ip => '192.0.2.13', netboot => 'xnba', iscsi => 1 ); +my %ipxe_san = ( name => 'cn06', mac => '52:54:00:00:10:06', ip => '192.0.2.16', netboot => 'ipxe', iscsi => 1 ); +my %xnba_iscsi = ( name => 'cn02', mac => '52:54:00:00:10:02', ip => '192.0.2.12', netboot => 'xnba', iscsi => 1 ); +my %ipxe_iscsi = ( name => 'cn05', mac => '52:54:00:00:10:05', ip => '192.0.2.15', netboot => 'ipxe', iscsi => 1 ); +my $unknown = '52:54:00:00:10:09'; +my $network = 'http://192.0.2.1/tftpboot/xcat/ipxe/nets/192.0.2.0_24'; +my %first = ( + xnba => { bios => 'xcat/xnba.kpxe', uefi => 'xcat/xnba.efi' }, + ipxe => { bios => 'xcat/ipxe/i386/undionly.kpxe', uefi => 'xcat/ipxe/x86_64-sb/snponly-shim.efi' }, +); + +# The option 175 of an iPXE build: its bus-id, then one byte for each feature. +my %feature = ( iscsi => 17, http => 19, bzimage => 24, pxe => 33, efi => 36 ); +sub ipxe { return [ [ 177, '01000000000000' ], map { [ $feature{$_}, '01' ] } @_ ] } + +# The rows of a node that installs: each client, and the boot file it gets. +sub node_rows { + my ($node) = @_; + my $method = $node->{netboot}; + my $script = "http://192.0.2.1/tftpboot/xcat/$method/nodes/$node->{name}"; + my %loader = %{ $first{$method} }; + my ( $bios_script, $uefi_script ) = $method eq 'ipxe' ? ( $script, "$script.uefi" ) : @loader{qw(bios uefi)}; + my %c = ( mac => $node->{mac} ); + # UEFI firmware reports architecture 7 or 9. The ISC host statements of an xnba node give nothing + # to a client on 9 that is not xNBA, as they did before, so these rows are for ipxe nodes. + my @arch9 = $method eq 'ipxe' ? ( + [ 'firmware PXE, UEFI arch 9', { arch => 9 }, $loader{uefi} ], + [ 'iPXE with HTTP and EFI, UEFI arch 9', { arch => 9, user_class => 'iPXE', ipxe => ipxe(qw(http efi)) }, $uefi_script ], + [ 'iPXE without EFI, UEFI arch 9', { arch => 9, user_class => 'iPXE', ipxe => ipxe(qw(http)) }, $loader{uefi} ], + ) : (); + return map { [ "$method node, $_->[0]", { %c, %{ $_->[1] } }, $_->[2] ] } @arch9, ( + [ 'firmware PXE, BIOS', { arch => 0 }, $loader{bios} ], + [ 'firmware PXE, UEFI', { arch => 7 }, $loader{uefi} ], + [ 'xNBA, BIOS', { arch => 0, user_class => 'xNBA', ipxe => ipxe(qw(http bzimage pxe iscsi)) }, $script ], + [ 'xNBA, UEFI arch 7', { arch => 7, user_class => 'xNBA', ipxe => ipxe(qw(http efi iscsi)) }, "$script.uefi" ], + [ 'xNBA, UEFI arch 9', { arch => 9, user_class => 'xNBA', ipxe => ipxe(qw(http efi iscsi)) }, "$script.uefi" ], + [ 'iPXE with HTTP, bzImage and PXE, BIOS', { arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage pxe)) }, $bios_script ], + [ 'iPXE without HTTP, BIOS', { arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(bzimage pxe)) }, $loader{bios} ], + [ 'iPXE without bzImage, BIOS', { arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http pxe)) }, $loader{bios} ], + [ 'iPXE without PXE, BIOS', { arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage)) }, $loader{bios} ], + [ 'iPXE with HTTP and EFI, UEFI', { arch => 7, user_class => 'iPXE', ipxe => ipxe(qw(http efi)) }, $uefi_script ], + [ 'iPXE without HTTP, UEFI', { arch => 7, user_class => 'iPXE', ipxe => ipxe(qw(efi)) }, $loader{uefi} ], + [ 'iPXE without EFI, UEFI', { arch => 7, user_class => 'iPXE', ipxe => ipxe(qw(http)) }, $loader{uefi} ], + [ 'iPXE without option 175, BIOS', { arch => 0, user_class => 'iPXE' }, $loader{bios} ], + [ 'iPXE with its sub-options reversed, BIOS', { arch => 0, user_class => 'iPXE', ipxe => [ reverse @{ ipxe(qw(http bzimage pxe)) } ] }, $bios_script ], + ); +} + +# The rows of a SAN node that installs: iPXE hooks its iSCSI root path first, so the script of an +# ipxe node needs iSCSI. An xnba node gives its script to xNBA only. +sub san_rows { + my ($node) = @_; + my $method = $node->{netboot}; + my $script = "http://192.0.2.1/tftpboot/xcat/$method/nodes/$node->{name}"; + my %loader = %{ $first{$method} }; + my $ipxe = $method eq 'ipxe'; + my %c = ( mac => $node->{mac} ); + return map { [ "$method SAN node installing, $_->[0]", { %c, %{ $_->[1] } }, $_->[2] ] } ( + [ 'xNBA, BIOS', { arch => 0, user_class => 'xNBA', ipxe => ipxe(qw(http bzimage pxe iscsi)) }, $script ], + [ 'iPXE with iSCSI, BIOS', { arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage pxe iscsi)) }, $ipxe ? $script : $loader{bios} ], + [ 'iPXE without iSCSI, BIOS', { arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage pxe)) }, $loader{bios} ], + [ 'iPXE with iSCSI, UEFI', { arch => 7, user_class => 'iPXE', ipxe => ipxe(qw(http efi iscsi)) }, $ipxe ? "$script.uefi" : $loader{uefi} ], + [ 'iPXE without iSCSI, UEFI', { arch => 7, user_class => 'iPXE', ipxe => ipxe(qw(http efi)) }, $loader{uefi} ], + ); +} + +my @node_rows = ( node_rows( \%xnba ), node_rows( \%ipxe ) ); +my @san_rows = ( san_rows( \%xnba_san ), san_rows( \%ipxe_san ) ); +my @network_rows = ( + [ 'unknown node, firmware PXE, BIOS', { mac => $unknown, arch => 0 }, $first{ipxe}{bios} ], + [ 'unknown node, firmware PXE, UEFI', { mac => $unknown, arch => 7 }, $first{ipxe}{uefi} ], + [ 'unknown node, iPXE with every feature, BIOS', { mac => $unknown, arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage pxe)) }, $network ], + [ 'unknown node, iPXE with every feature, UEFI', { mac => $unknown, arch => 7, user_class => 'iPXE', ipxe => ipxe(qw(http efi)) }, "$network.uefi" ], + [ 'unknown node, iPXE without HTTP, BIOS', { mac => $unknown, arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(bzimage pxe)) }, $first{ipxe}{bios} ], + [ 'unknown node, xNBA with every feature, BIOS', { mac => $unknown, arch => 0, user_class => 'xNBA', ipxe => ipxe(qw(http bzimage pxe)) }, $network ], +); +# In the boot state ISC gives a SAN client the empty file name. Kea keeps its own iSCSI handling of +# that state, so these rows are for ISC only. +my @iscsi_rows = ( + [ 'xnba iSCSI node, iPXE with iSCSI', { mac => $xnba_iscsi{mac}, arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage pxe iscsi)) }, '' ], + [ 'xnba iSCSI node, iPXE without iSCSI', { mac => $xnba_iscsi{mac}, arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage pxe)) }, '' ], + [ 'ipxe iSCSI node, iPXE with iSCSI', { mac => $ipxe_iscsi{mac}, arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage pxe iscsi)) }, '' ], + [ 'ipxe iSCSI node, iPXE without iSCSI', { mac => $ipxe_iscsi{mac}, arch => 0, user_class => 'iPXE', ipxe => ipxe(qw(http bzimage pxe)) }, $first{ipxe}{bios} ], + [ 'ipxe iSCSI node, firmware PXE, UEFI arch 7', { mac => $ipxe_iscsi{mac}, arch => 7 }, $first{ipxe}{uefi} ], + [ 'ipxe iSCSI node, firmware PXE, UEFI arch 9', { mac => $ipxe_iscsi{mac}, arch => 9 }, $first{ipxe}{uefi} ], + [ 'ipxe iSCSI node, iPXE with iSCSI, UEFI', { mac => $ipxe_iscsi{mac}, arch => 7, user_class => 'iPXE', ipxe => ipxe(qw(http efi iscsi)) }, '' ], +); + +sub boot_file { + my ($client) = @_; + my $json = JSON::encode_json( { %$client, relay => '192.0.2.2', server => '192.0.2.1' } ); + open( my $fh, '-|', 'ip', 'netns', 'exec', $client_ns, $^X, "-I$FindBin::Bin/lib", + '-MXCAT::Test::DHCP', '-e', 'XCAT::Test::DHCP::relay_discover_main(@ARGV)', $json ) + or die "Unable to run the DHCP client: $!"; + my $file = <$fh>; + close($fh); + chomp( $file //= 'NO-OFFER' ); + return $file; +} + +sub check_rows { + my ( $label, @rows ) = @_; + is( boot_file( $_->[1] ), $_->[2], "$label: $_->[0]" ) for @rows; +} + +sub wait_for_offers { + my ( $pid, $log ) = @_; + for ( 1 .. 20 ) { + last unless process_running( $pid, \%children ); + return 1 if boot_file( { mac => $unknown, arch => 0 } ) ne 'NO-OFFER'; + } + diag_file($log); + BAIL_OUT('the DHCP server did not answer'); +} + +# ---- ISC ------------------------------------------------------------------------------------------ +# The dhcpd AppArmor profile of Ubuntu reads only /etc/dhcp and writes only /var/lib/dhcp/dhcpd.leases*. +my $secret = 'eGNhdC10ZXN0LW9tYXBpLXNlY3JldC0wMQ=='; +my $isc_dir = tempdir( DIR => -d '/etc/dhcp' ? '/etc/dhcp' : '/etc', CLEANUP => 1 ); +my $leases = -d '/var/lib/dhcp' ? "/var/lib/dhcp/dhcpd.leases.xcat-test-$$" : "$isc_dir/dhcpd.leases"; +END { unlink glob("$leases*") if $leases } + +sub omshell { + my ($commands) = @_; + my $input = "$isc_dir/omshell.in"; + open( my $fh, '>', $input ) or die "Cannot create $input: $!"; + print {$fh} xCAT::DHCP::OmapiPolicy->omshell_preamble( $omapi, secret => $secret, port => 7911 ), "connect\n", $commands; + close($fh) or die "Cannot close $input: $!"; + return scalar `ip netns exec $server_ns $omapi->{omshell_path} < $input 2>&1`; +} + +sub start_isc { + my $configuration = "$isc_dir/dhcpd.conf"; + open( my $fh, '>', $configuration ) or die "Cannot create $configuration: $!"; + print {$fh} "option conf-file code 209 = text;\n", + "option space gpxe;\n", + "option gpxe-encap-opts code 175 = encapsulate gpxe;\n", + "option gpxe.bus-id code 177 = string;\n", + @{ xCAT::DHCP::BootPolicy->isc_ipxe_feature_option_lines() }, + "option user-class-identifier code 77 = string;\n", + "option client-architecture code 93 = unsigned integer 16;\n", + "option www-server code 114 = string;\n", + "default-lease-time 600;\nmax-lease-time 600;\nping-check false;\n", + "omapi-port 7911;\n", + "key $omapi->{key_name} {\n algorithm $omapi->{algorithm};\n secret \"$secret\";\n};\n", + "omapi-key $omapi->{key_name};\n", + "subnet 192.0.2.0 netmask 255.255.255.0 {\n", + " range 192.0.2.100 192.0.2.110;\n", + @{ xCAT::DHCP::BootPolicy->isc_client_architecture_lines( + next_server => '192.0.2.1', + portsuffix => '', + net => '192.0.2.0', + prefix => 24, + ) }, + "}\n"; + close($fh) or die "Cannot close $configuration: $!"; + + my $log = "$isc_dir/dhcpd.log"; + my $pid = start_daemon( undef, 'ip', $log, 'netns', 'exec', $server_ns, $dhcpd, '-f', '-d', + '-cf', $configuration, '-lf', $leases, '-pf', "$isc_dir/dhcpd.pid", "${interface}s" ); + $children{$pid} = 1; + return wait_for_offers( $pid, $log ); +} + +sub isc_hosts { + my %common = ( loader_present => 1, next_server => '192.0.2.1', portsuffix => '' ); + my @hosts = ( + [ \%xnba, { uefi => 1, currstate => 'install rhels9' } ], + [ \%ipxe, { uefi => 1, currstate => 'install rhels9' } ], + [ \%xnba_san, { uefi => 1, currstate => 'install rhels9' } ], + [ \%ipxe_san, { uefi => 1, currstate => 'install rhels9' } ], + [ \%xnba_iscsi, { currstate => 'boot' } ], + [ \%ipxe_iscsi, { currstate => 'boot' } ], + ); + my $commands = ''; + for my $host (@hosts) { + my ( $node, $opts ) = @$host; + my $statements = xCAT::DHCP::BootPolicy->isc_node_boot_statements( + %common, %$opts, netboot => $node->{netboot}, iscsi => $node->{iscsi}, node => $node->{name} ); + $commands .= "new host\nset name = \"$node->{name}\"\nset hardware-address = $node->{mac}\n" + . "set hardware-type = 1\nset ip-address = $node->{ip}\nset statements = \"$statements\"\ncreate\nclose\n"; + } + return omshell($commands); +} + +if ( grep { $_ eq 'isc' } @backends ) { + open( my $fresh, '>', $leases ) or die "Cannot create $leases: $!"; + close($fresh); + start_isc(); + my $created = isc_hosts(); + unlike( $created, qr/can't|error|eof in string|unknown token|not connected/i, 'ISC: OMAPI creates the hosts' ) + or diag($created); + check_rows( 'ISC', @node_rows, @network_rows, @san_rows, @iscsi_rows ); + + # dhcpd reads the host statements back from dhcpd.leases, where it saved them without parentheses. + stop_daemons( \%children ); + start_isc(); + check_rows( 'ISC after a restart', @node_rows, @san_rows, @iscsi_rows ); + stop_daemons( \%children ); +} + +# ---- Kea ------------------------------------------------------------------------------------------ +if ( grep { $_ eq 'kea' } @backends ) { + my $kea_dir = tempdir( CLEANUP => 1 ); + chmod 0755, $kea_dir or die "Unable to make $kea_dir traversable: $!"; + # The kea-dhcp4 AppArmor profile of Ubuntu writes only the files of the system service. No profile + # confines a copy of kea-dhcp4, so the test keeps its files in a temporary directory. + my $kea_copy = "$kea_dir/kea-dhcp4"; + copy( $kea_dhcp4, $kea_copy ) or die "Unable to copy $kea_dhcp4: $!"; + chmod 0755, $kea_copy or die "Unable to make $kea_copy executable: $!"; + make_path( "$kea_dir/run", "$kea_dir/data" ); + local $ENV{KEA_CONTROL_SOCKET_DIR} = "$kea_dir/run"; + local $ENV{KEA_DHCP_DATA_DIR} = "$kea_dir/data"; + local $ENV{KEA_PIDFILE_DIR} = "$kea_dir/run"; + local $ENV{KEA_LOCKFILE_DIR} = "$kea_dir/run"; + my $backend = xCAT::DHCP::Backend::Kea->new( kea_socket_dir => "$kea_dir/run" ); + + # The server has the upstream loader files, as ipxe-xcat installs them. + my %upstream = ( ipxe_bios => 1, ipxe_uefi => 1 ); + my $network_classes = xCAT::DHCP::BootPolicy->kea_xnba_network_classes( + net => '192.0.2.0', prefix => 24, next_server => '192.0.2.1', %upstream ); + my $run_kea = sub { + my ( $label, $option_defs, $globals, $xnba_efi, @rows ) = @_; + my $config = JSON::decode_json( $backend->render_dhcp4_config( + { + interfaces => ["${interface}s"], + 'lease-database' => { type => 'memfile', name => "$kea_dir/data/kea-leases4.csv", persist => JSON::false }, + 'option-def' => $option_defs, + 'client-classes' => [ + @{ xCAT::DHCP::BootPolicy->kea_xnba_node_classes( + xnba_kpxe => 1, + xnba_efi => $xnba_efi, + nodes => [ + map { { node => $_->{name}, mac => $_->{mac}, next_server => '192.0.2.1', netboot => $_->{netboot}, iscsi => $_->{iscsi} } } + \%xnba, \%ipxe, \%xnba_san, \%ipxe_san + ], + ) }, + @$globals, + @$network_classes, + ], + subnets => [ + { + id => 1, + subnet => '192.0.2.0/24', + dynamicrange => '192.0.2.100-192.0.2.110', + next_server => '192.0.2.1', + additional_client_classes => [ map { $_->{name} } @$network_classes ], + }, + ], + } + ) ); + + # The test sends relayed unicast packets, which need no raw socket. + $config->{Dhcp4}{'interfaces-config'}{'dhcp-socket-type'} = 'udp'; + my $path = "$kea_dir/kea-dhcp4.conf"; + open( my $fh, '>', $path ) or die "Cannot create $path: $!"; + print {$fh} JSON::encode_json($config); + close($fh) or die "Cannot close $path: $!"; + + my $log = "$kea_dir/kea-dhcp4.log"; + my $pid = start_daemon( undef, 'ip', $log, 'netns', 'exec', $server_ns, $kea_copy, '-c', $path ); + $children{$pid} = 1; + wait_for_offers( $pid, $log ); + check_rows( $label, @rows ); + stop_daemons( \%children ); + }; + + my @old_defs = ( { name => 'conf-file', code => 209, type => 'string', space => 'dhcp4' } ); + my @defs = ( @old_defs, @{ xCAT::DHCP::BootPolicy->kea_ipxe_option_defs() } ); + my $globals = xCAT::DHCP::BootPolicy->kea_client_classes(%upstream); + $run_kea->( 'Kea', \@defs, $globals, 1, @node_rows, @network_rows, @san_rows ); + + # Without the local xNBA UEFI file the UEFI clients of an xnba node still get that file, as in ISC, and + # not the upstream loader and the discovery script that the global classes give unknown clients. + $run_kea->( 'Kea, no xNBA UEFI file', \@defs, $globals, 0, map { [ "xnba node, $_->[0]", { mac => $xnba{mac}, %{ $_->[1] } }, $first{xnba}{uefi} ] } ( + [ 'firmware PXE, UEFI', { arch => 7 } ], + [ 'iPXE with HTTP and EFI, UEFI', { arch => 7, user_class => 'iPXE', ipxe => ipxe(qw(http efi)) } ], + ) ); + + # makedhcp without -n adds the iPXE feature options to the configuration of an older makedhcp -n, + # and keeps the x86 global classes that it wrote, which give xNBA to every client. + my $upgraded = { 'option-def' => [@old_defs] }; + xCAT::DHCP::BootPolicy->kea_declare_ipxe_features($upgraded); + my $xnba_test = "(option[77].exists and (option[77].text == 'xNBA' or option[77].hex == 0x784e4241 or substring(option[77].hex,1,4) == 'xNBA'))"; + my @old_globals = ( + { name => 'xcat-bios', test => "option[93].hex == 0x0000 and not ($xnba_test)", 'boot-file-name' => 'xcat/xnba.kpxe' }, + { + name => 'xcat-uefi-x64', + test => "(option[93].hex == 0x0007 or option[93].hex == 0x0009 or option[93].hex == 0x0010) and not ($xnba_test)", + 'boot-file-name' => 'xcat/xnba.efi', + }, + ); + $run_kea->( 'Kea, upgraded', $upgraded->{'option-def'}, \@old_globals, 1, grep { $_->[0] =~ /^ipxe / } @node_rows, @san_rows ); +} + +done_testing(); diff --git a/xCAT-test/integration/dhcp_kea_config_validation.t b/xCAT-test/integration/dhcp_kea_config_validation.t index fca95e1b0..20b031de0 100644 --- a/xCAT-test/integration/dhcp_kea_config_validation.t +++ b/xCAT-test/integration/dhcp_kea_config_validation.t @@ -135,6 +135,51 @@ ok( !$result->{error}, 'generated Kea DHCPv4 config validates with kea-dhcp4 -t' } unlink $path; +# The x86 boot classes as makedhcp renders them, with an xnba node, an ipxe node and an ipxe node that +# boots from SAN, and the option 175 definitions that the upstream loader classes test. +{ + my $boot_json = $backend->render_dhcp4_config( + { + interfaces => ['*'], + 'option-def' => [ + { name => 'conf-file', code => 209, type => 'string', space => 'dhcp4' }, + @{ xCAT::DHCP::BootPolicy->kea_ipxe_option_defs() }, + ], + 'client-classes' => [ + @{ xCAT::DHCP::BootPolicy->kea_xnba_node_classes( + xnba_kpxe => 1, + xnba_efi => 1, + nodes => [ + { node => 'node01', mac => '52:54:00:12:34:56', next_server => '192.168.122.1', httpport => 80, netboot => 'xnba' }, + { node => 'node02', mac => '52:54:00:12:34:57', next_server => '192.168.122.1', httpport => 80, netboot => 'ipxe' }, + { node => 'node03', mac => '52:54:00:12:34:58', next_server => '192.168.122.1', httpport => 80, netboot => 'ipxe', iscsi => 1 }, + ], + ) }, + @{ xCAT::DHCP::BootPolicy->kea_client_classes() }, + @{ xCAT::DHCP::BootPolicy->kea_xnba_network_classes( + net => '192.168.122.0', + prefix => 24, + next_server => '192.168.122.1', + httpport => 80, + ) }, + ], + subnets => [ + { + id => 1, + subnet => '192.168.122.0/24', + dynamicrange => '192.168.122.100-192.168.122.120', + next_server => '192.168.122.1', + }, + ], + } + ); + my $boot_path = write_validation_file( $boot_json, 'xcat-test-kea-dhcp4-boot' ); + my $boot_result = $backend->validate_dhcp4_config($boot_path); + ok( !$boot_result->{error}, 'the x86 boot classes of xnba and ipxe nodes validate with kea-dhcp4 -t' ) + or diag $boot_result->{error}; + unlink $boot_path; +} + SKIP: { skip 'kea-dhcp6 is not installed', 1 unless xCAT::CommandUtils::find_executable('kea-dhcp6'); my $dhcp6_json = $backend->render_dhcp6_config( diff --git a/xCAT-test/integration/lib/XCAT/Test/DHCP.pm b/xCAT-test/integration/lib/XCAT/Test/DHCP.pm index fb1ad7fcf..8ead7e69c 100644 --- a/xCAT-test/integration/lib/XCAT/Test/DHCP.pm +++ b/xCAT-test/integration/lib/XCAT/Test/DHCP.pm @@ -7,9 +7,12 @@ use strict; use warnings; use Exporter qw(import); +use IO::Select; +use IO::Socket::INET; use POSIX qw(WNOHANG _exit setgid setuid); +use Socket qw(inet_aton pack_sockaddr_in); use Test::More (); -use Time::HiRes qw(sleep); +use Time::HiRes qw(sleep time); our @EXPORT_OK = qw( start_daemon @@ -18,6 +21,10 @@ our @EXPORT_OK = qw( wait_for_socket wait_for_file diag_file + discover_packet + offer_boot_file + relay_discover + relay_discover_main ); sub start_daemon { @@ -126,4 +133,96 @@ sub diag_file { return; } +# A DHCPDISCOVER that a relay agent at $opts{relay} forwards for a PXE client. $opts{ipxe} lists the +# option 175 sub-options in the order to send, as [code, value] pairs. +sub discover_packet { + my (%opts) = @_; + + my $mac = pack( 'H12', join '', split /:/, $opts{mac} ); + my $packet = pack( 'C4 N n n a4 a4 a4 a4 a16 a64 a128 N', + 1, 1, 6, 1, $opts{xid}, 0, 0, "\0" x 4, "\0" x 4, "\0" x 4, inet_aton( $opts{relay} ), + $mac, '', '', 0x63825363 ); + + my @options = ( + [ 53, pack( 'C', 1 ) ], + [ 61, "\x01$mac" ], + [ 60, sprintf( 'PXEClient:Arch:%05d:UNDI:002001', $opts{arch} ) ], + [ 93, pack( 'n', $opts{arch} ) ], + [ 97, "\0" . substr( $mac x 3, 0, 16 ) ], + ); + push @options, [ 77, $opts{user_class} ] if defined $opts{user_class}; + push @options, [ 175, join '', map { pack( 'CC', $_->[0], length $_->[1] ) . $_->[1] } @{ $opts{ipxe} } ] + if $opts{ipxe}; + $packet .= pack( 'CC', $_->[0], length $_->[1] ) . $_->[1] for @options; + + return $packet . "\xff"; +} + +# The boot file of a DHCPOFFER for transaction $xid: option 67 when the server sends it, otherwise +# the file field. Nothing for any other packet. +sub offer_boot_file { + my ( $packet, $xid ) = @_; + + return if length($packet) < 240; + my ( $op, $got_xid, $file, $cookie ) = unpack( 'C x3 N x100 Z128 N', $packet ); + return if $op != 2 || $got_xid != $xid || $cookie != 0x63825363; + + my ( $type, $option67 ); + my $offset = 240; + while ( $offset < length $packet ) { + my $code = ord substr( $packet, $offset, 1 ); + last if $code == 255; + if ( $code == 0 ) { $offset++; next; } + my $length = ord substr( $packet, $offset + 1, 1 ); + my $value = substr( $packet, $offset + 2, $length ); + $type = ord $value if $code == 53; + ( $option67 = $value ) =~ s/\0+\z// if $code == 67; + $offset += 2 + $length; + } + return if !defined $type || $type != 2; + return defined $option67 ? $option67 : $file; +} + +# Send a relayed DHCPDISCOVER to the server and return the boot file of its DHCPOFFER, or undef when +# no offer comes. Each try waits 3 s, and there are 3 tries. +sub relay_discover { + my (%opts) = @_; + + my $socket = IO::Socket::INET->new( + LocalAddr => $opts{relay}, + LocalPort => 67, + Proto => 'udp', + ReuseAddr => 1, + ) or die "Unable to bind $opts{relay}:67: $!"; + my $server = pack_sockaddr_in( 67, inet_aton( $opts{server} ) ); + my $select = IO::Select->new($socket); + + for my $try ( 1 .. 3 ) { + my $xid = int( rand( 0xffffffff ) ); + send( $socket, discover_packet( %opts, xid => $xid ), 0, $server ) or die "Unable to send: $!"; + my $deadline = time + 3; + while ( ( my $left = $deadline - time ) > 0 ) { + last unless $select->can_read($left); + my $reply = ''; + recv( $socket, $reply, 1500, 0 ); + my $file = offer_boot_file( $reply, $xid ); + return $file if defined $file; + } + } + return; +} + +# Run relay_discover for the client that a JSON object describes, with each option 175 value in hex, +# and print the boot file or NO-OFFER. A test runs it in the client network namespace. +sub relay_discover_main { + my ($json) = @_; + + require JSON; + my $opts = JSON::decode_json($json); + $opts->{ipxe} = [ map { [ $_->[0], pack( 'H*', $_->[1] ) ] } @{ $opts->{ipxe} } ] if $opts->{ipxe}; + my $file = relay_discover(%$opts); + print defined $file ? "$file\n" : "NO-OFFER\n"; + return; +} + 1;