diff --git a/xCAT-test/integration/dhcp_kea_config_validation.t b/xCAT-test/integration/dhcp_kea_config_validation.t index 7e011c2ed..fca95e1b0 100644 --- a/xCAT-test/integration/dhcp_kea_config_validation.t +++ b/xCAT-test/integration/dhcp_kea_config_validation.t @@ -10,6 +10,7 @@ use Test::More; use xCAT::CommandUtils; use xCAT::DHCP::Backend::Kea; +use xCAT::DHCP::BootPolicy; my $kea_dhcp4 = xCAT::CommandUtils::find_executable('kea-dhcp4'); plan skip_all => 'kea-dhcp4 is not installed' unless $kea_dhcp4; @@ -116,6 +117,23 @@ my $result = $backend->validate_dhcp4_config($path); ok( !$result->{error}, 'generated Kea DHCPv4 config validates with kea-dhcp4 -t' ) or diag $result->{error}; +# makedhcp without -n adds the iPXE feature options to the configuration of an older makedhcp -n, and +# writes it back. The next makedhcp reads the upgraded configuration. +{ + my $source = $path; + my @written; + for my $label ( 'an upgraded', 'a current' ) { + my $loaded = $backend->load_dhcp4_config($source); + xCAT::DHCP::BootPolicy->kea_declare_ipxe_features( $loaded->{Dhcp4} ); + $source = write_validation_file( $backend->encode_config($loaded), 'xcat-test-kea-dhcp4-upgraded' ); + push @written, $source; + my $check = $backend->validate_dhcp4_config($source); + ok( !$check->{error}, "makedhcp writes $label Kea DHCPv4 config that validates with kea-dhcp4 -t" ) + or diag $check->{error}; + } + unlink @written; +} + unlink $path; SKIP: { skip 'kea-dhcp6 is not installed', 1 unless xCAT::CommandUtils::find_executable('kea-dhcp6'); diff --git a/xCAT-test/unit/dhcp_ipxe_feature_upgrade.t b/xCAT-test/unit/dhcp_ipxe_feature_upgrade.t new file mode 100644 index 000000000..0d6f314ee --- /dev/null +++ b/xCAT-test/unit/dhcp_ipxe_feature_upgrade.t @@ -0,0 +1,315 @@ +#!/usr/bin/env perl +# A package upgrade keeps the DHCP configuration that an older makedhcp -n wrote, without the iPXE +# feature options. makedhcp without -n must add them before dhcpd or Kea gets a rule that tests them: +# dhcpd rejects such a rule, and Kea never finds a sub-option of option 175 without its definition. +use strict; +use warnings; +## no critic (TestingAndDebugging::ProhibitNoWarnings) +no warnings 'once'; + +use FindBin; +use lib "$FindBin::Bin/../../xCAT-server/lib"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use B (); +use File::Temp qw(tempdir); +use JSON (); +use Test::More; + +use xCAT::DHCP::Backend::Kea; +use xCAT::DHCP::BootPolicy; + +$ENV{XCATCFG} ||= 'SQLite:/tmp'; +my $source_dhcp_plugin = "$FindBin::Bin/../../xCAT-server/lib/xcat/plugins/dhcp.pm"; +if ( -f $source_dhcp_plugin ) { + require $source_dhcp_plugin; +} else { + require xCAT_plugin::dhcp; +} + +my @features = @{ xCAT::DHCP::BootPolicy->isc_ipxe_feature_option_lines() }; +my $kea_defs = xCAT::DHCP::BootPolicy->kea_ipxe_option_defs(); + +# JSON::XS, and JSON::PP before Perl 5.36, write a number that Perl has used as a string as a JSON +# string, and Kea rejects an option code that is a string. +sub string_codes { return grep { B::svref_2object( \$_->{code} )->FLAGS & B::SVp_POK } @{ $_[0] }; } + +# The header of an ISC configuration that makedhcp -n wrote before the iPXE feature options. +my @old_header = ( + "#xCAT generated dhcp configuration\n", + "\n", + "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", + "option user-class-identifier code 77 = string;\n", + "subnet 192.0.2.0 netmask 255.255.255.0 {\n", + "} # 192.0.2.0/255.255.255.0 subnet_end\n", +); + +{ + my @conf = @old_header; + is_deeply( [ xCAT::DHCP::BootPolicy->isc_declare_ipxe_features( \@conf ) ], [ scalar @features, undef ], + 'ISC: an old configuration gets every iPXE feature option' ); + is_deeply( \@conf, [ @old_header[ 0 .. 3 ], @features, @old_header[ 4 .. $#old_header ] ], + 'ISC: after the gpxe option space, with the other lines unchanged' ); + + my @again = @conf; + is_deeply( [ xCAT::DHCP::BootPolicy->isc_declare_ipxe_features( \@again ) ], [ 0, undef ], 'ISC: a second run adds nothing' ); + is_deeply( \@again, \@conf, 'ISC: and leaves the configuration unchanged' ); + + my ($http) = grep { /^option gpxe\.http / } @features; + my @partial = ( @old_header[ 0 .. 3 ], $http, @old_header[ 4 .. $#old_header ] ); + is_deeply( [ xCAT::DHCP::BootPolicy->isc_declare_ipxe_features( \@partial ) ], [ @features - 1, undef ], + 'ISC: a configuration that declares one option gets the others' ); + is( scalar( grep { $_ eq $http } @partial ), 1, 'ISC: and keeps a single declaration of it' ); + + # dhcpd reads declarations that start with blanks or have more than one blank between the words. + my @indented = map { /^option / ? " $_" : $_ } @old_header; + s/^ option space gpxe;/\toption space gpxe ;/ for @indented; + ( my $spaced_http = $http ) =~ s/^option (\S+) code/ option \t$1 code/; + my @spaced = ( @indented[ 0 .. 3 ], $spaced_http, @indented[ 4 .. $#indented ] ); + is_deeply( [ xCAT::DHCP::BootPolicy->isc_declare_ipxe_features( \@spaced ) ], [ @features - 1, undef ], + 'ISC: a configuration with indented declarations gets the missing ones' ); + is_deeply( [ @spaced[ 0 .. 3 ] ], [ @indented[ 0 .. 3 ] ], 'ISC: after its gpxe option space' ); + is( scalar( grep { /gpxe\.http/ } @spaced ), 1, 'ISC: and keeps its own declaration of an option' ); + + my @no_space = grep { !/gpxe/ } @old_header; + my @unchanged = @no_space; + is_deeply( [ xCAT::DHCP::BootPolicy->isc_declare_ipxe_features( \@no_space ) ], [ 0, 'it declares no gpxe option space' ], + 'ISC: a configuration without the gpxe option space gets an error' ); + is_deeply( \@no_space, \@unchanged, 'ISC: and stays unchanged' ); +} + +{ + my @old_defs = ( { name => 'conf-file', code => 209, type => 'string', space => 'dhcp4' } ); + my $dhcp4 = JSON::decode_json( JSON::encode_json( { 'option-def' => \@old_defs } ) ); + is_deeply( [ xCAT::DHCP::BootPolicy->kea_declare_ipxe_features($dhcp4) ], [ scalar @$kea_defs, undef ], + 'Kea: old option definitions get option 175 and every iPXE feature option' ); + is_deeply( $dhcp4->{'option-def'}, [ @old_defs, @$kea_defs ], 'Kea: after the definitions it had' ); + is_deeply( [ string_codes( $dhcp4->{'option-def'} ) ], [], 'Kea: and every option code stays a number' ); + + is_deeply( [ xCAT::DHCP::BootPolicy->kea_declare_ipxe_features($dhcp4) ], [ 0, undef ], 'Kea: a second run adds nothing' ); + is( scalar @{ $dhcp4->{'option-def'} }, @old_defs + @$kea_defs, 'Kea: and duplicates no definition' ); + + my $empty = {}; + is_deeply( [ xCAT::DHCP::BootPolicy->kea_declare_ipxe_features($empty) ], [ scalar @$kea_defs, undef ], + 'Kea: a configuration without option definitions gets them' ); + is_deeply( $empty->{'option-def'}, $kea_defs, 'Kea: in a new option-def list' ); + + my $encapsulating = { name => 'site-175', code => 175, type => 'empty', space => 'dhcp4', encapsulate => 'gpxe' }; + my $custom = { 'option-def' => [$encapsulating] }; + is_deeply( [ xCAT::DHCP::BootPolicy->kea_declare_ipxe_features($custom) ], [ @$kea_defs - 1, undef ], + 'Kea: a configuration whose option 175 encapsulates the gpxe space gets only the feature options' ); + is_deeply( $custom->{'option-def'}, [ $encapsulating, @{$kea_defs}[ 1 .. $#$kea_defs ] ], 'Kea: and keeps its option 175' ); + + my @binary = ( @old_defs, { name => 'site-175', code => 175, type => 'binary', space => 'dhcp4' } ); + my $opaque = { 'option-def' => [@binary] }; + my ( $added, $error ) = xCAT::DHCP::BootPolicy->kea_declare_ipxe_features($opaque); + is( $added, 0, 'Kea: a configuration whose option 175 encapsulates nothing gets no definition' ); + like( $error, qr/\bsite-175 gives option 175 no gpxe encapsulation.*Run makedhcp -n/, 'Kea: but an error that names it and the fix' ); + is_deeply( $opaque->{'option-def'}, \@binary, 'Kea: and keeps its definitions, which option data can name' ); +} + +# makedhcp writes the ISC configuration and restarts dhcpd at once, because OMAPI gives the running +# dhcpd host statements before the end of the command. +{ + my $dir = tempdir( CLEANUP => 1 ); + my @restarts; + no warnings 'redefine'; + local *xCAT::Utils::restartservice = sub { shift if $_[0] && $_[0] eq 'xCAT::Utils'; push @restarts, $_[0]; return 0; }; + + my @conf = @old_header; + my $path = "$dir/dhcpd.conf"; + is( xCAT_plugin::dhcp::_isc_declare_ipxe_features( \@conf, $path ), undef, 'ISC: makedhcp adds the options without error' ); + my $written = do { local ( @ARGV, $/ ) = ($path); <> }; + is( $written, join( '', @conf ), 'ISC: makedhcp writes the configuration with the options' ); + like( $written, qr/^option gpxe\.http code 19 = unsigned integer 8;$/m, 'ISC: which declares the HTTP feature' ); + is_deeply( \@restarts, ['dhcp'], 'ISC: makedhcp restarts dhcpd once' ); + ok( !-e "$path.ipxe-restart", 'ISC: and removes the restart marker once dhcpd has restarted' ); + + @restarts = (); + my $current = "$dir/current.conf"; + is( xCAT_plugin::dhcp::_isc_declare_ipxe_features( [@conf], $current ), undef, 'ISC: a current configuration gives no error' ); + ok( !-e $current, 'ISC: makedhcp does not write a current configuration' ); + is_deeply( \@restarts, [], 'ISC: and does not restart dhcpd' ); + + my $unwritable = "$dir/conf.d"; + mkdir $unwritable or die "Cannot create $unwritable: $!"; + like( xCAT_plugin::dhcp::_isc_declare_ipxe_features( [@old_header], $unwritable ), qr/^Unable to add the iPXE feature options to \Q$unwritable\E: /, + 'ISC: makedhcp reports a configuration it cannot write' ); + is_deeply( \@restarts, [], 'ISC: and does not restart dhcpd' ); + ok( -e "$unwritable.ipxe-restart", 'ISC: but marks the restart before it writes the configuration' ); + + my $bare = "$dir/bare.conf"; + like( xCAT_plugin::dhcp::_isc_declare_ipxe_features( [ grep { !/gpxe/ } @old_header ], $bare ), + qr/^Unable to add the iPXE feature options to \Q$bare\E: it declares no gpxe option space\. Run makedhcp -n\.$/, + 'ISC: makedhcp reports a configuration without the gpxe option space' ); + ok( !-e $bare && !@restarts, 'ISC: and neither writes it nor restarts dhcpd' ); + + # A run whose restart fails leaves the options in the file and the running dhcpd without them. + my $failed = "$dir/failed.conf"; + { + local *xCAT::Utils::restartservice = sub { return 1; }; + like( xCAT_plugin::dhcp::_isc_declare_ipxe_features( [@old_header], $failed ), + qr/^Unable to restart the DHCP server after adding the iPXE feature options/, 'ISC: makedhcp reports a restart that fails' ); + } + ok( -e "$failed.ipxe-restart", 'ISC: and keeps the restart marker' ); + open( my $fh, '<', $failed ) or die "Cannot read $failed: $!"; + my @retry = <$fh>; + close($fh); + @restarts = (); + is( xCAT_plugin::dhcp::_isc_declare_ipxe_features( \@retry, $failed ), undef, + 'ISC: the next run, with the options already in the file, gives no error' ); + is_deeply( \@restarts, ['dhcp'], 'ISC: and restarts dhcpd before it changes a host' ); + ok( !-e "$failed.ipxe-restart", 'ISC: and then removes the marker' ); +} + +# makedhcp stops before it changes a subnet or a host when dhcpd has not loaded the options: OMAPI +# removes a host before it adds it again. process_request also sets the callback of the Kea path. +my @responses; +{ + package DHCPUpgradeISCBackend; + sub name { return 'isc'; } + + package DHCPUpgradeTable; + sub getNodeAttribs { return; } + sub getAllAttribs { return; } + sub getAttribs { return { password => 'dGVzdA==' }; } + sub close { return; } + + package main; + + my $dir = tempdir( CLEANUP => 1 ); + my ( @restarts, @reached ); + no warnings 'redefine'; + local *xCAT::DHCP::Backend::new_backend = sub { return bless {}, 'DHCPUpgradeISCBackend'; }; + local *xCAT::Utils::isServiceNode = sub { return 0; }; + local *xCAT::Utils::isLinux = sub { return 1; }; + local *xCAT::Utils::checkservicestatus = sub { return 0; }; + local *xCAT::Table::new = sub { return bless {}, 'DHCPUpgradeTable'; }; + local *xCAT::NetworkUtils::determinehostname = sub { return ('mn'); }; + local *xCAT::TableUtils::get_site_attribute = sub { return; }; + local *xCAT::MsgUtils::trace = sub { return; }; + local *xCAT_plugin::dhcp::addnic = sub { push @reached, 'addnic'; }; + local *xCAT_plugin::dhcp::addnet = sub { push @reached, 'addnet'; }; + local *xCAT_plugin::dhcp::writeout = sub { push @reached, 'writeout'; }; + local *xCAT_plugin::dhcp::_open_omshell_writer = sub { push @reached, 'omshell'; return; }; + local $::XCATSITEVALS{externaldhcpservers}; + local $xCAT_plugin::dhcp::dhcpconffile = "$dir/dhcpd.conf"; + + my $makedhcp = sub { + my ($restart) = @_; + local *xCAT::Utils::restartservice = sub { push @restarts, 'dhcp'; return $restart; }; + @responses = @reached = @restarts = (); + my $umask = umask; + eval { + xCAT_plugin::dhcp::process_request( { _xcatpreprocessed => [1], node => ['cn1'], arg => [] }, sub { push @responses, @_; } ); + 1; + } or push @reached, "died: $@"; + umask $umask; + return join ' ', map { @{ $_->{error} || [] } } @responses; + }; + my $write = sub { + my ( $read_only, @lines ) = @_; + @lines = @old_header unless @lines; + my $conf = $xCAT_plugin::dhcp::dhcpconffile; + chmod 0600, $conf; + open( my $fh, '>', $conf ) or die "Cannot create $conf: $!"; + print {$fh} @lines; + close($fh) or die "Cannot close $conf: $!"; + chmod 0400, $conf if $read_only; + }; + + $write->(0); + like( $makedhcp->(1), qr/Unable to restart the DHCP server after adding the iPXE feature options/, + 'ISC: makedhcp reports a dhcpd that does not restart with the options' ); + is_deeply( \@reached, [], 'ISC: and changes no subnet or host' ); + + $write->( 0, grep { !/gpxe/ } @old_header ); + like( $makedhcp->(0), qr/: it declares no gpxe option space\. Run makedhcp -n\./, + 'ISC: makedhcp reports a configuration without the gpxe option space' ); + is_deeply( [ \@restarts, \@reached ], [ [], [] ], 'ISC: and neither restarts dhcpd nor changes a subnet or host' ); + + # makedhcp -a before makedhcp -n writes a new configuration, which declares the options. The run + # stops at the node loop, which this test does not cover. + my $newconfig = \&xCAT_plugin::dhcp::newconfig; + for my $case ( [ 'no dhcpd.conf' ], [ 'a dhcpd.conf that xCAT did not write', "ddns-update-style none;\n" ] ) { + my ( $label, @lines ) = @$case; + local *xCAT_plugin::dhcp::newconfig = sub { push @reached, 'newconfig'; return $newconfig->(@_); }; + local *xCAT::DBobjUtils::getnodetype = sub { die "node loop\n"; }; + unlink $xCAT_plugin::dhcp::dhcpconffile; + $write->( 0, @lines ) if @lines; + unlike( $makedhcp->(0), qr/iPXE feature options|gpxe/, "ISC: makedhcp with $label gives no iPXE option error" ); + ok( ( grep { $_ eq 'newconfig' } @reached ) && $reached[-1] eq "died: node loop\n", + 'ISC: and goes on to write a new configuration' ) or diag("reached: @reached"); + } + + SKIP: { + skip 'root writes a read-only file', 3 if $> == 0; + $write->(1); + like( $makedhcp->(0), qr/Unable to add the iPXE feature options/, 'ISC: makedhcp reports a configuration it cannot write' ); + is_deeply( \@restarts, [], 'ISC: and does not restart dhcpd' ); + is_deeply( \@reached, [], 'ISC: and changes no subnet or host' ); + } +} + +# Kea applies the host reservations through the Control Agent, but reads option definitions only +# from its configuration file. makedhcp loads that file and writes it back as JSON. +{ + package DHCPUpgradeKeaBackend; + our @ISA = ('xCAT::DHCP::Backend::Kea'); + sub load_dhcp4_config { my ($self) = @_; return $self->SUPER::load_dhcp4_config( $self->{path} ); } + sub upsert_reservations { return; } + sub encode_config { my ( $self, $config ) = @_; $self->{encoded} = $config; return $self->SUPER::encode_config($config); } + sub write_dhcp4_json { $_[0]->{written} = $_[1]; return {}; } + sub live_upsert_reservations { return { ok => 1 }; } + sub restart_services { $_[0]->{restarts}++; return {}; } + + package main; + + no warnings 'redefine'; + local *xCAT_plugin::dhcp::kea_build_dhcp4_intent = sub { return { subnets => [] }; }; + local *xCAT_plugin::dhcp::kea_build_dhcp6_intent = sub { return { subnets => [] }; }; + local *xCAT_plugin::dhcp::kea_build_ddns_intent = sub { return; }; + local *xCAT_plugin::dhcp::kea_expand_request_nodes = sub { return ['cn1']; }; + local *xCAT_plugin::dhcp::kea_build_node_reservations = sub { return []; }; + local *xCAT_plugin::dhcp::kea_sync_xnba_client_classes = sub { return 0; }; + local *xCAT_plugin::dhcp::kea_control_agent_enabled = sub { return 1; }; + local *xCAT_plugin::dhcp::kea_control_agent_live_enabled = sub { return 1; }; + local $::XCATSITEVALS{externaldhcpservers}; + + my $dir = tempdir( CLEANUP => 1 ); + my $makedhcp = sub { + my (@defs) = @_; + my $backend = DHCPUpgradeKeaBackend->new(); + $backend->{path} = "$dir/kea-dhcp4.conf"; + open( my $fh, '>', $backend->{path} ) or die "Cannot create $backend->{path}: $!"; + print {$fh} JSON::encode_json( { Dhcp4 => { subnet4 => [ { id => 1, subnet => '192.0.2.0/24' } ], 'option-def' => \@defs } } ); + close($fh) or die "Cannot close $backend->{path}: $!"; + xCAT_plugin::dhcp::kea_process_request( $backend, { node => ['cn1'] }, {}, {}, 0 ); + return $backend; + }; + + my @old_defs = ( { name => 'conf-file', code => 209, type => 'string', space => 'dhcp4' } ); + my $old = $makedhcp->(@old_defs); + is_deeply( JSON::decode_json( $old->{written} )->{Dhcp4}{'option-def'}, [ @old_defs, @$kea_defs ], + 'Kea: makedhcp adds the iPXE feature options to an old configuration' ); + is_deeply( [ string_codes( $old->{encoded}{Dhcp4}{'option-def'} ) ], [], 'Kea: and writes every option code as a number' ); + is( $old->{restarts}, 1, 'Kea: and restarts Kea to load them' ); + + @responses = (); + my $opaque = $makedhcp->( @old_defs, { name => 'site-175', code => 175, type => 'binary', space => 'dhcp4' } ); + like( join( ' ', map { @{ $_->{error} || [] } } @responses ), qr/\bsite-175 gives option 175 no gpxe encapsulation/, + 'Kea: makedhcp reports an option 175 without the gpxe encapsulation' ); + ok( !defined $opaque->{written} && !$opaque->{restarts}, 'Kea: and changes nothing' ); + + my $current = $makedhcp->( @old_defs, @$kea_defs ); + is( scalar @{ JSON::decode_json( $current->{written} )->{Dhcp4}{'option-def'} }, @old_defs + @$kea_defs, + 'Kea: a current configuration keeps its definitions' ); + is_deeply( [ string_codes( $current->{encoded}{Dhcp4}{'option-def'} ) ], [], 'Kea: and writes every option code as a number' ); + ok( !$current->{restarts}, 'Kea: and makedhcp updates its hosts without a restart' ); +} + +done_testing();