From 9910f4b92f4135079ec7c24255294c3da21889e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:14:29 -0300 Subject: [PATCH] test(dhcp): cover OMAPI command runner --- xCAT-test/unit/dhcp_omapi_runner.t | 273 +++++++++++++++++++ xCAT-test/unit/dhcp_omapi_runner_callers.t | 302 +++++++++++++++++++++ 2 files changed, 575 insertions(+) create mode 100644 xCAT-test/unit/dhcp_omapi_runner.t create mode 100644 xCAT-test/unit/dhcp_omapi_runner_callers.t diff --git a/xCAT-test/unit/dhcp_omapi_runner.t b/xCAT-test/unit/dhcp_omapi_runner.t new file mode 100644 index 000000000..1370b67dd --- /dev/null +++ b/xCAT-test/unit/dhcp_omapi_runner.t @@ -0,0 +1,273 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use Config; +use Errno qw(EAGAIN); +use File::Spec; +use File::Temp qw(tempdir); +use Test::More; + +use xCAT::DHCP::OmapiRunner; + +{ + package XCAT::Test::FastOmapiRunner; + use parent 'xCAT::DHCP::OmapiRunner'; + + our $COMMAND_DIRECTORY; + + sub _completion_attempts { + return 1000; + } + + sub _termination_attempts { + return 100; + } + + sub _poll_interval { + return 0.01; + } + + sub _completion_delay { + return 0.01; + } + + sub _command_directory { + my $class = shift; + return $COMMAND_DIRECTORY || $class->SUPER::_command_directory(); + } +} + +{ + package XCAT::Test::ReadyOmapiRunner; + use parent -norequire, 'XCAT::Test::FastOmapiRunner'; + use POSIX qw(WNOHANG); + use Time::HiRes qw(sleep); + + our $READY_MARKER; + our $COMPLETION_ATTEMPTS = 10; + our $TERMINATION_ATTEMPTS = 1000; + + sub _fork { + my $pid = fork(); + return $pid unless $pid; + + for ( 1 .. 1000 ) { + return $pid if $READY_MARKER && -f $READY_MARKER; + die 'OMAPI test child exited before reaching its ready state' + if waitpid( $pid, WNOHANG ) == $pid; + sleep 0.01; + } + + kill 'KILL', $pid; + waitpid( $pid, 0 ); + die 'OMAPI test child did not reach its ready state'; + } + + sub _completion_attempts { + return $COMPLETION_ATTEMPTS; + } + + sub _termination_attempts { + return $TERMINATION_ATTEMPTS; + } +} + +{ + package XCAT::Test::ForkErrorOmapiRunner; + use parent -norequire, 'XCAT::Test::FastOmapiRunner'; + use Errno qw(EAGAIN); + + sub _fork { + $! = EAGAIN; + return; + } +} + +sub write_executable { + my ( $path, $contents ) = @_; + + open( my $fh, '>', $path ) or die "Unable to create $path: $!"; + print {$fh} $contents or die "Unable to write $path: $!"; + close($fh) or die "Unable to close $path: $!"; + chmod 0755, $path or die "Unable to make $path executable: $!"; +} + +sub write_command_file { + my ( $directory, $contents ) = @_; + + my $command = XCAT::Test::FastOmapiRunner->open_command_file($directory); + print { $command->{handle} } $contents or die "Unable to write $command->{path}: $!"; + close( $command->{handle} ) or die "Unable to close $command->{path}: $!"; + return $command->{path}; +} + +sub cleanup_command_file { + my ($path) = @_; + + ok( unlink($path), 'the caller can remove the completed command file' ); + ok( !-e $path, 'the command file is gone after caller cleanup' ); +} + +my $workspace = tempdir( CLEANUP => 1 ); +my $command_directory = File::Spec->catdir( $workspace, 'commands' ); +my $capture = File::Spec->catfile( $workspace, 'captured-input' ); +my $success = File::Spec->catfile( $workspace, 'success' ); + +is( + xCAT::DHCP::OmapiRunner->_command_directory(), + '/tmp/xcat', + 'the production command directory remains /tmp/xcat' +); +is( xCAT::DHCP::OmapiRunner->_completion_attempts(), 100, 'the completion window remains 100 polls' ); +is( xCAT::DHCP::OmapiRunner->_termination_attempts(), 20, 'the TERM grace period remains 20 polls' ); +is( xCAT::DHCP::OmapiRunner->_poll_interval(), 0.1, 'the process poll interval remains 0.1 seconds' ); +is( xCAT::DHCP::OmapiRunner->_completion_delay(), 1.0, 'the post-completion delay remains one second' ); + +my $default_command; +{ + local $XCAT::Test::FastOmapiRunner::COMMAND_DIRECTORY = $command_directory; + $default_command = XCAT::Test::FastOmapiRunner->open_command_file(); +} +is( ref($default_command), 'HASH', 'the command file is returned as a named record' ); +ok( $default_command->{handle}, 'the command record includes its writable handle' ); +like( + $default_command->{path}, + qr{\A\Q$command_directory\E/omshell\.}, + 'omitting the directory uses the configured command directory' +); +close( $default_command->{handle} ) or die "Unable to close $default_command->{path}: $!"; +unlink $default_command->{path} or die "Unable to remove $default_command->{path}: $!"; + +write_executable( + $success, + <<"SCRIPT" +#!$Config{perlpath} +use strict; +use warnings; +my \$contents = do { local \$/; }; +open(my \$fh, '>', \$ENV{OMAPI_TEST_CAPTURE}) or die \$!; +print {\$fh} \$contents or die \$!; +close(\$fh) or die \$!; +print "discarded stdout\n"; +warn "discarded stderr\n"; +SCRIPT +); + +my $command_file = write_command_file( $command_directory, "connect\nclose\n" ); +ok( -d $command_directory, 'the command directory is created when absent' ); +ok( -f $command_file, 'a persistent command file is created for omshell' ); + +local $ENV{OMAPI_TEST_CAPTURE} = $capture; +my $parent_stdout = File::Spec->catfile( $workspace, 'parent-stdout' ); +my $parent_stderr = File::Spec->catfile( $workspace, 'parent-stderr' ); +my $success_status; +{ + open( my $saved_stdout, '>&', \*STDOUT ) or die "Unable to preserve stdout: $!"; + open( my $saved_stderr, '>&', \*STDERR ) or die "Unable to preserve stderr: $!"; + open( STDOUT, '>', $parent_stdout ) or die "Unable to create $parent_stdout: $!"; + open( STDERR, '>', $parent_stderr ) or die "Unable to create $parent_stderr: $!"; + $success_status = XCAT::Test::FastOmapiRunner->run_command_file( $command_file, $success ); + open( STDOUT, '>&', $saved_stdout ) or die "Unable to restore stdout: $!"; + open( STDERR, '>&', $saved_stderr ) or die "Unable to restore stderr: $!"; + close($saved_stdout) or die "Unable to close preserved stdout: $!"; + close($saved_stderr) or die "Unable to close preserved stderr: $!"; +} +is( $success_status, 'completed', 'a normally exiting command is reported as completed' ); + +open( my $capture_fh, '<', $capture ) or die "Unable to read $capture: $!"; +my $captured = do { local $/; <$capture_fh> }; +close($capture_fh) or die "Unable to close $capture: $!"; +is( $captured, "connect\nclose\n", 'the command file is connected to child stdin' ); +is( -s $parent_stdout, 0, 'child stdout is redirected away from the caller' ); +is( -s $parent_stderr, 0, 'child stderr is redirected away from the caller' ); +ok( -e $command_file, 'the runner leaves cleanup timing to its caller' ); +cleanup_command_file($command_file); + +$command_file = write_command_file( $command_directory, "connect\n" ); +is( + XCAT::Test::FastOmapiRunner->run_command_file( + $command_file, File::Spec->catfile( $workspace, 'missing-omshell' ) + ), + 'completed', + 'legacy exec failure remains a completed child process' +); +cleanup_command_file($command_file); + +$command_file = write_command_file( $command_directory, "connect\n" ); +$! = 0; +my $fork_status = XCAT::Test::ForkErrorOmapiRunner->run_command_file( $command_file, $success ); +my $fork_errno = 0 + $!; +is( $fork_status, 'fork_error', 'fork failure is reported separately from child completion' ); +is( $fork_errno, EAGAIN, 'fork failure leaves the operating-system error available to the caller' ); +ok( -e $command_file, 'fork failure preserves caller-owned cleanup ordering' ); +cleanup_command_file($command_file); + +my $term_marker = File::Spec->catfile( $workspace, 'term-seen' ); +my $term_ready = File::Spec->catfile( $workspace, 'term-ready' ); +my $term_aware = File::Spec->catfile( $workspace, 'term-aware' ); +write_executable( + $term_aware, + <<"SCRIPT" +#!$Config{perlpath} +use strict; +use warnings; +\$SIG{TERM} = sub { + open(my \$fh, '>', \$ENV{OMAPI_TEST_MARKER}) or die \$!; + print {\$fh} "TERM\n" or die \$!; + close(\$fh) or die \$!; + exit 0; +}; +open(my \$ready_fh, '>', \$ENV{OMAPI_TEST_READY}) or die \$!; +print {\$ready_fh} "ready\n" or die \$!; +close(\$ready_fh) or die \$!; +do { local \$/; }; +while (1) { select undef, undef, undef, 0.1; } +SCRIPT +); + +$command_file = write_command_file( $command_directory, "connect\n" ); +my $term_status; +{ + local $ENV{OMAPI_TEST_MARKER} = $term_marker; + local $ENV{OMAPI_TEST_READY} = $term_ready; + local $XCAT::Test::ReadyOmapiRunner::READY_MARKER = $term_ready; + $term_status = XCAT::Test::ReadyOmapiRunner->run_command_file( $command_file, $term_aware ); +} +is( $term_status, 'terminated', 'a hung command that handles TERM is reported as terminated' ); +ok( -f $term_marker, 'the timed-out command receives TERM before any KILL' ); +cleanup_command_file($command_file); + +my $kill_ready = File::Spec->catfile( $workspace, 'kill-ready' ); +my $term_ignoring = File::Spec->catfile( $workspace, 'term-ignoring' ); +write_executable( + $term_ignoring, + <<"SCRIPT" +#!$Config{perlpath} +use strict; +use warnings; +\$SIG{TERM} = 'IGNORE'; +open(my \$fh, '>', \$ENV{OMAPI_TEST_READY}) or die \$!; +print {\$fh} "ready\n" or die \$!; +close(\$fh) or die \$!; +do { local \$/; }; +while (1) { select undef, undef, undef, 0.1; } +SCRIPT +); + +$command_file = write_command_file( $command_directory, "connect\n" ); +my $kill_status; +{ + local $ENV{OMAPI_TEST_READY} = $kill_ready; + local $XCAT::Test::ReadyOmapiRunner::READY_MARKER = $kill_ready; + local $XCAT::Test::ReadyOmapiRunner::TERMINATION_ATTEMPTS = 10; + $kill_status = XCAT::Test::ReadyOmapiRunner->run_command_file( $command_file, $term_ignoring ); +} +is( $kill_status, 'killed', 'a hung command that ignores TERM is reported as killed' ); +ok( -f $kill_ready, 'the TERM-ignoring command reached its wait state before KILL' ); +cleanup_command_file($command_file); + +done_testing(); diff --git a/xCAT-test/unit/dhcp_omapi_runner_callers.t b/xCAT-test/unit/dhcp_omapi_runner_callers.t new file mode 100644 index 000000000..0892d8a91 --- /dev/null +++ b/xCAT-test/unit/dhcp_omapi_runner_callers.t @@ -0,0 +1,302 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../xCAT-server/lib"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use Config; +use File::Path qw(make_path); +use File::Spec; +use File::Temp qw(tempdir); +use IPC::Open3; +use Symbol qw(gensym); +use Test::More; + +use xCAT::DHCP::OmapiRunner; + +$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; +} + +sub write_file { + my ( $path, $contents ) = @_; + + open( my $fh, '>', $path ) or die "Unable to create $path: $!"; + print {$fh} $contents or die "Unable to write $path: $!"; + close($fh) or die "Unable to close $path: $!"; +} + +sub read_file { + my ($path) = @_; + + open( my $fh, '<', $path ) or die "Unable to read $path: $!"; + my $contents = do { local $/; <$fh> }; + close($fh) or die "Unable to close $path: $!"; + return $contents; +} + +my $workspace = tempdir( CLEANUP => 1 ); +my $plugin_command_directory = File::Spec->catdir( $workspace, 'plugin-commands' ); + +{ + my $command = xCAT::DHCP::OmapiRunner->open_command_file($plugin_command_directory); + my @open_arguments; + my ( $handle, $writer ); + + { + no warnings qw(once redefine); + local *xCAT::DHCP::OmapiRunner::open_command_file = sub { + my ( $class, @arguments ) = @_; + @open_arguments = @arguments; + return $command; + }; + ( $handle, $writer ) = xCAT_plugin::dhcp::_open_omshell_writer( + { omshell_path => '/usr/bin/omshell' } + ); + } + + is_deeply( \@open_arguments, [], 'makedhcp uses the runner default command directory' ); + is( fileno($handle), fileno( $command->{handle} ), 'makedhcp returns the runner command handle' ); + is_deeply( + $writer, + { command_file => $command->{path}, omshell_path => '/usr/bin/omshell' }, + 'makedhcp retains the command path and omshell executable for closing' + ); + close($handle) or die "Unable to close $command->{path}: $!"; + unlink $command->{path} or die "Unable to remove $command->{path}: $!"; +} + +{ + my @writer; + { + no warnings qw(once redefine); + local *xCAT::DHCP::OmapiRunner::open_command_file = sub { return; }; + @writer = xCAT_plugin::dhcp::_open_omshell_writer( + { omshell_path => '/usr/bin/omshell' } + ); + } + is_deeply( \@writer, [], 'makedhcp propagates command-file creation failure' ); +} + +foreach my $status (qw(completed terminated killed fork_error)) { + subtest "makedhcp handles $status" => sub { + my $command = xCAT::DHCP::OmapiRunner->open_command_file($plugin_command_directory); + my @logs; + my @run_arguments; + + { + no warnings qw(once redefine); + local *xCAT::DHCP::OmapiRunner::run_command_file = sub { + my ( $class, @arguments ) = @_; + @run_arguments = @arguments; + return $status; + }; + local *xCAT_plugin::dhcp::syslog = sub { push @logs, [@_]; }; + xCAT_plugin::dhcp::_close_omshell_writer( + $command->{handle}, + { command_file => $command->{path}, omshell_path => '/usr/bin/omshell' } + ); + } + + is_deeply( + \@run_arguments, + [ $command->{path}, '/usr/bin/omshell' ], + 'makedhcp passes the command path and executable in order' + ); + ok( !-e $command->{path}, 'makedhcp removes the command file after the runner returns' ); + if ( $status eq 'completed' ) { + is_deeply( \@logs, [], 'makedhcp does not log a completed command' ); + } else { + is_deeply( + \@logs, + [ [ 'local4|err', 'omshell did not complete while updating DHCP reservations' ] ], + 'makedhcp logs a non-completed command once' + ); + } + }; +} + +my $fake_root = File::Spec->catdir( $workspace, 'fake-xcat' ); +my $fake_perl = File::Spec->catdir( $fake_root, 'lib', 'perl' ); +my $fake_dhcp = File::Spec->catdir( $fake_perl, 'xCAT', 'DHCP' ); +my $dhcpop_command_directory = File::Spec->catdir( $workspace, 'dhcpop-commands' ); +make_path( $fake_dhcp, $dhcpop_command_directory ); + +write_file( + File::Spec->catfile( $fake_dhcp, 'Backend.pm' ), + <<'MODULE' +package xCAT::DHCP::Backend; +use strict; +use warnings; + +sub new_backend { + return {}; +} + +1; +MODULE +); + +write_file( + File::Spec->catfile( $fake_dhcp, 'OmapiPolicy.pm' ), + <<'MODULE' +package xCAT::DHCP::OmapiPolicy; +use strict; +use warnings; + +sub settings { + return { key_name => 'omapi', omshell_path => '/usr/bin/omshell' }; +} + +sub omshell_preamble { + return "server 127.0.0.1\n"; +} + +1; +MODULE +); + +write_file( + File::Spec->catfile( $fake_perl, 'xCAT', 'Table.pm' ), + <<'MODULE' +package xCAT::Table; +use strict; +use warnings; + +sub new { + my $class = shift; + return bless {}, $class; +} + +sub getAttribs { + return { password => 'secret' }; +} + +1; +MODULE +); + +write_file( + File::Spec->catfile( $fake_dhcp, 'OmapiRunner.pm' ), + <<'MODULE' +package xCAT::DHCP::OmapiRunner; +use strict; +use warnings; + +use Errno qw(EAGAIN); +use File::Temp qw(tempfile); + +sub open_command_file { + my ( $handle, $path ) = tempfile( + 'omshell.XXXXXX', + DIR => $ENV{OMAPI_TEST_COMMAND_DIRECTORY}, + UNLINK => 0, + ); + open( my $record, '>', $ENV{OMAPI_TEST_PATH_RECORD} ) or die $!; + print {$record} $path or die $!; + close($record) or die $!; + return { handle => $handle, path => $path }; +} + +sub run_command_file { + my ( $class, $path, $omshell_path ) = @_; + open( my $command, '<', $path ) or die $!; + my $contents = do { local $/; <$command> }; + close($command) or die $!; + open( my $capture, '>', $ENV{OMAPI_TEST_CAPTURE} ) or die $!; + print {$capture} $contents or die $!; + close($capture) or die $!; + open( my $omshell_capture, '>', $ENV{OMAPI_TEST_OMSHELL_CAPTURE} ) or die $!; + print {$omshell_capture} $omshell_path or die $!; + close($omshell_capture) or die $!; + $! = EAGAIN if $ENV{OMAPI_TEST_STATUS} eq 'fork_error'; + return $ENV{OMAPI_TEST_STATUS}; +} + +1; +MODULE +); + +my $dhcpop = File::Spec->catfile( $FindBin::Bin, '..', '..', 'xCAT-server', 'share', 'xcat', 'tools', 'dhcpop' ); + +sub run_dhcpop { + my ($status) = @_; + my $capture = File::Spec->catfile( $workspace, "dhcpop-$status-capture" ); + my $omshell_capture = File::Spec->catfile( $workspace, "dhcpop-$status-omshell" ); + my $path_record = File::Spec->catfile( $workspace, "dhcpop-$status-path" ); + + local $ENV{XCATROOT} = $fake_root; + local $ENV{OMAPI_TEST_CAPTURE} = $capture; + local $ENV{OMAPI_TEST_COMMAND_DIRECTORY} = $dhcpop_command_directory; + local $ENV{OMAPI_TEST_OMSHELL_CAPTURE} = $omshell_capture; + local $ENV{OMAPI_TEST_PATH_RECORD} = $path_record; + local $ENV{OMAPI_TEST_STATUS} = $status; + + my ( $child_in, $child_out ); + my $child_err = gensym; + my $pid = open3( + $child_in, + $child_out, + $child_err, + $Config{perlpath}, + $dhcpop, + '-r', + '-n', + 'node01', + ); + close($child_in); + my $stdout = do { local $/; <$child_out> } // ''; + my $stderr = do { local $/; <$child_err> } // ''; + waitpid( $pid, 0 ); + my $exit_status = $? >> 8; + my $command_path = read_file($path_record); + + return { + command => read_file($capture), + command_path => $command_path, + exit_status => $exit_status, + omshell_path => read_file($omshell_capture), + stderr => $stderr, + stdout => $stdout, + }; +} + +my $expected_command = <<'COMMAND'; +server 127.0.0.1 +connect +new host +set name = "node01" +open +remove +close +COMMAND + +foreach my $status (qw(completed terminated killed fork_error)) { + subtest "dhcpop handles $status" => sub { + my $result = run_dhcpop($status); + + is( $result->{command}, $expected_command, 'dhcpop sends the expected OMAPI commands' ); + is( $result->{omshell_path}, '/usr/bin/omshell', 'dhcpop passes the configured executable' ); + is( $result->{stdout}, '', 'dhcpop does not write to stdout' ); + if ( $status eq 'fork_error' ) { + isnt( $result->{exit_status}, 0, 'dhcpop fails when the runner cannot fork' ); + like( $result->{stderr}, qr/Unable to start omshell:/, 'dhcpop reports the fork failure' ); + ok( -e $result->{command_path}, 'dhcpop preserves its legacy fork-failure cleanup ordering' ); + unlink $result->{command_path} or die "Unable to remove $result->{command_path}: $!"; + } else { + is( $result->{exit_status}, 0, "dhcpop accepts the $status runner result" ); + is( $result->{stderr}, '', 'dhcpop does not report an accepted runner result' ); + ok( !-e $result->{command_path}, 'dhcpop removes the command file after an accepted result' ); + } + }; +} + +done_testing();