From 084d776d26a6674d5bbccb14a5050b1e49c2bb4d Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:13:18 -0300 Subject: [PATCH] test(xcat-core): capture the el10 Genesis image shipping no openssl The legacy Genesis image built on el10 carries no openssl command. getcert waits for one with no bound, so the node reports no destiny and never boots. Nothing in the build or in the suite sees the hole. genesis_base_spec_buildrequires.t reads the spec and requires an unconditional BuildRequires on openssl, and requires that no %{_target_cpu} is read after BuildArch: noarch, where rpm has already set it to noarch. genesis_payload_verification.t drives verify-genesis-payload with a dracut module and a payload, and requires every command the module installs at the top level of install() to be present. A name installed under a condition is not required, and a module the verifier reads no names from is a usage error. genesis_getcert_missing_openssl.t runs getcert with a PATH that holds stubs and no openssl, under a harness timeout. Ten assertions fail on this commit: getcert never stops, and the verifier and the spec do not know about openssl. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .../unit/genesis_base_spec_buildrequires.t | 42 ++++++++++ .../unit/genesis_getcert_missing_openssl.t | 81 +++++++++++++++++++ xCAT-test/unit/genesis_payload_verification.t | 71 +++++++++++++++- 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 xCAT-test/unit/genesis_base_spec_buildrequires.t create mode 100644 xCAT-test/unit/genesis_getcert_missing_openssl.t diff --git a/xCAT-test/unit/genesis_base_spec_buildrequires.t b/xCAT-test/unit/genesis_base_spec_buildrequires.t new file mode 100644 index 000000000..621f3a001 --- /dev/null +++ b/xCAT-test/unit/genesis_base_spec_buildrequires.t @@ -0,0 +1,42 @@ +#!/usr/bin/env perl +# The genesis spec is the build root manifest: what it does not build-require, the buildroot +# only holds by accident, and dracut_install then installs nothing. +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../lib"; +use Test::More; + +use XCAT::Test::File qw(repo_path slurp_repo_file); + +my $relative = 'xCAT-genesis-builder/xCAT-genesis-base.spec'; +plan skip_all => "$relative not found" unless -f repo_path($relative); +plan tests => 4; + +my @lines = split /\n/, slurp_repo_file($relative); + +# getcert, getdestiny, getipmi and getadapter all run the openssl command. el8 and el9 +# held it in the buildroot as a dependency of something else; el10 does not. +my @openssl = grep { /^BuildRequires:\s*openssl\s*$/ } @lines; +is(scalar(@openssl), 1, 'the spec build-requires openssl'); + +my ($buildarch) = grep { $lines[$_] =~ /^BuildArch:\s*noarch/ } 0 .. $#lines; +ok(defined $buildarch, 'the spec sets BuildArch: noarch'); + +# rpm reads the spec a second time with the target set to noarch, so %{_target_cpu} is +# "noarch" from BuildArch onwards. %{tarch} keeps the real architecture. +my @late_target_cpu = grep { $lines[$_] =~ /_target_cpu/ } ($buildarch + 1) .. $#lines; +is(scalar(@late_target_cpu), 0, + '%{_target_cpu} is not read after BuildArch: noarch') + or diag(join "\n", map { ($_ + 1) . ": $lines[$_]" } @late_target_cpu); + +my ($openssl_line) = grep { $lines[$_] =~ /^BuildRequires:\s*openssl\s*$/ } 0 .. $#lines; +my $guarded = 0; +if (defined $openssl_line) { + for my $i (reverse 0 .. $openssl_line - 1) { + last if $lines[$i] =~ /^%endif/; + $guarded = 1, last if $lines[$i] =~ /^%if/; + } +} +is($guarded, 0, 'openssl is build-required on every release'); diff --git a/xCAT-test/unit/genesis_getcert_missing_openssl.t b/xCAT-test/unit/genesis_getcert_missing_openssl.t new file mode 100644 index 000000000..4e7398d9c --- /dev/null +++ b/xCAT-test/unit/genesis_getcert_missing_openssl.t @@ -0,0 +1,81 @@ +#!/usr/bin/env perl +# Drive getcert with openssl absent, and with a certificate key that is not ready yet. +# doxcat runs getcert in the foreground and ignores its status, so a wait with no bound stops +# the boot and prints nothing. +use strict; +use warnings; + +use File::Path qw(make_path); +use File::Slurper qw(read_text write_text); +use File::Temp qw(tempdir); +use FindBin; +use lib "$FindBin::Bin/../lib"; +use Test::More; + +use XCAT::Test::File qw(repo_path); + +my $getcert = repo_path('xCAT-genesis-scripts/usr/bin/getcert'); +plan skip_all => 'getcert not found' unless -f $getcert; +plan tests => 7; + +my $tmpdir = tempdir(CLEANUP => 1); + +# The el10 legacy image ships no openssl. getcert must say so and give up. +my $bin = stub_dir(openssl => undef); +my ($status, $out) = run_getcert($bin, 10, 60); +isnt($status, 124, 'getcert without openssl stops on its own') or diag($out); +isnt($status, 0, 'getcert without openssl reports a failure'); +like($out, qr/openssl/, 'getcert names openssl'); + +# doxcat writes /etc/xcat/certkey.pem in the background, so the first requests can fail. +# getcert must keep asking, then give up and say why. +my $counter = "$tmpdir/req-count"; +$bin = stub_dir(openssl => "always-fails", counter => $counter); +($status, $out) = run_getcert($bin, 30, 5); +isnt($status, 124, 'getcert with an unusable key stops on its own') or diag($out); +isnt($status, 0, 'getcert with an unusable key reports a failure'); +my $tries = -f $counter ? scalar(() = read_text($counter) =~ /req/g) : 0; +cmp_ok($tries, '>', 1, "getcert retries the certificate request ($tries tries)"); +like($out, qr/certkey\.pem/, 'getcert names the key it could not use'); + +#--- +# stub_dir: a PATH directory holding the commands getcert runs. openssl is absent when the +# openssl option is undef. +#--- +sub stub_dir { + my (%opt) = @_; + my $dir = tempdir(DIR => $tmpdir, CLEANUP => 1); + write_stub($dir, 'allowcred.awk', "exec sleep 3\n"); + write_stub($dir, 'hostname', "echo node1\n"); + write_stub($dir, 'logger', "echo \"\$@\" >&2\n"); + write_stub($dir, 'sleep', "exec /bin/sleep \"\$@\"\n"); + if (defined $opt{openssl}) { + my $count = $opt{counter} ? "echo req >> '$opt{counter}'\n" : ''; + write_stub($dir, 'openssl', "[ \"\$1\" = req ] && { $count exit 1; }\nexit 0\n"); + } + return $dir; +} + +sub write_stub { + my ($dir, $name, $body) = @_; + write_text("$dir/$name", "#!/bin/sh\n$body"); + chmod 0755, "$dir/$name"; + return; +} + +#--- +# run_getcert: run getcert with only the stub directory on PATH. The timeout is the harness +# guard: a status of 124 means getcert never stopped. +#--- +sub run_getcert { + my ($bin, $limit, $csr_timeout) = @_; + my $outfile = "$tmpdir/out.$$"; + my $cmd = sprintf( + "timeout -k 2 %d env PATH=%s GETCERT_CSR_TIMEOUT=%d /bin/bash %s 192.0.2.1:3001 >%s 2>&1 > 8; + my $out = -f $outfile ? read_text($outfile) : ''; + unlink $outfile; + return ($status, $out); +} diff --git a/xCAT-test/unit/genesis_payload_verification.t b/xCAT-test/unit/genesis_payload_verification.t index 3e1d7bffa..c845b3cd9 100644 --- a/xCAT-test/unit/genesis_payload_verification.t +++ b/xCAT-test/unit/genesis_payload_verification.t @@ -15,9 +15,10 @@ use XCAT::Test::File qw(repo_path); my $verifier = repo_path('xCAT-genesis-builder/verify-genesis-payload'); plan skip_all => 'verify-genesis-payload not found' unless -f $verifier; -plan tests => 11; +plan tests => 18; my $tmpdir = tempdir(CLEANUP => 1); +my $module_seq = 0; # A complete payload: OpenSSH 9.9 sshd plus its session helper, tmux plus a UTF-8 locale. my $good = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 1, mktemp => 1); @@ -55,6 +56,39 @@ my $nomktemp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux isnt($rc, 0, 'a payload without mktemp fails'); like($err, qr{usr/bin/mktemp}, 'the missing mktemp is named'); +# dracut_install reports a missing binary and returns, so every name the dracut module +# installs has to be checked against the payload. The el10 image shipped with no openssl and +# getcert waited on it for the life of the node. +my $module = write_module_setup([qw(openssl wget tar)]); +my $full = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, + dhclient => 1, mktemp => 1, commands => [qw(openssl wget tar)]); +($rc, $err) = run_with_commands($module, $full); +is($rc, 0, 'a payload carrying every command the module names passes') or diag($err); + +my $noopenssl = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, + dhclient => 1, mktemp => 1, commands => [qw(wget tar)]); +($rc, $err) = run_with_commands($module, $noopenssl); +isnt($rc, 0, 'a payload without openssl fails'); +like($err, qr/openssl/, 'the missing openssl is named'); + +# The DHCP client is release-dependent, so the module installs it inside a conditional. Those +# names are not the contract; the spec passes the one it wants as a required path. +my $conditional = write_module_setup(['wget'], ['dhclient']); +my $nodhclient = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, + dhclient => 0, mktemp => 1, commands => ['wget']); +($rc, $err) = run_with_commands($conditional, $nodhclient); +is($rc, 0, 'a name installed under a condition is not required') or diag($err); + +# A module the verifier cannot read names for covers nothing, so say so instead of passing. +my $unparsable = "$tmpdir/module-setup-unparsable.sh"; +write_text($unparsable, "#!/bin/bash\nsetup() {\n dracut_install wget\n}\n"); +($rc, $err) = run_with_commands($unparsable, $full); +is($rc, 2, 'a module the verifier finds no command names in is a usage error'); +like($err, qr/command name/, 'the empty command list is named'); + +($rc, $err) = run_with_commands("$tmpdir/no-such-module", $full); +is($rc, 2, 'a module file that cannot be read is a usage error'); + ($rc, $err) = run("$tmpdir/does-not-exist"); is($rc >> 0, 2, 'a missing payload directory is a usage error'); @@ -77,6 +111,7 @@ sub build_payload { } write_text("$root/usr/sbin/dhclient", "dhclient\n") if $opt{dhclient}; write_text("$root/usr/bin/mktemp", "mktemp\n") if $opt{mktemp}; + write_text("$root/usr/bin/$_", "$_\n") for @{ $opt{commands} || [] }; return $root; } @@ -93,3 +128,37 @@ sub run { unlink $errfile; return ($status, $err); } + +#--- +# write_module_setup: a dracut module whose install() names commands at the top level, and +# optionally more inside a conditional. +#--- +sub write_module_setup { + my ($top, $conditional) = @_; + my $path = "$tmpdir/module-setup." . ++$module_seq . ".sh"; + my $text = "#!/bin/bash\n\ninstall() {\n"; + $text .= " dracut_install " . join(' ', @$top) . " # a trailing comment\n"; + $text .= " dracut_install /usr/bin/awk /etc/services\n"; + if ($conditional) { + $text .= " if command -v " . $conditional->[0] . " >/dev/null 2>&1; then\n"; + $text .= " dracut_install " . join(' ', @$conditional) . "\n"; + $text .= " fi\n"; + } + $text .= "}\n"; + write_text($path, $text); + return $path; +} + +#--- +# run_with_commands: run the verifier with the command list read back from a dracut module. +#--- +sub run_with_commands { + my ($module, $root) = @_; + my $errfile = "$tmpdir/err.commands.$$"; + my $cmd = join ' ', map { "'$_'" } ($verifier, '--commands-from', $module, $root); + system("/bin/bash $cmd >/dev/null 2>$errfile"); + my $status = $? >> 8; + my $err = -f $errfile ? read_text($errfile) : ''; + unlink $errfile; + return ($status, $err); +}