From 3992a3c9c4057ffb0273e8ed8ca33d02112301c8 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:41:00 -0300 Subject: [PATCH 01/37] test(xcat-core): capture the genesis specs leaving %{tarch} unexpanded A riscv64 build of xcat-core fails at xCAT-genesis-scripts: ERROR: Cannot find/open srpm: dist/rocky-10-riscv64-xcat/rpms/SRPMS/ xCAT-genesis-scripts-riscv64-2.19.0-snap202609020458.src.rpm The srpm on disk is named xCAT-genesis-scripts-%{tarch}-2.19.0-....src.rpm. xCAT-genesis-scripts.spec and xCAT-genesis-base.spec take the package name from %{tarch}, which an %ifarch ladder sets. That ladder has no riscv64 branch, so %{tarch} stays literal and rpm builds a package with a macro in its name. The test expands both specs with rpmspec for every arch xCAT supports and asserts the Name carries that arch. It fails on riscv64 for both specs. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 07a0e79e42ec39eaf1ed6e9291ab6b3f6f869e97) --- xCAT-test/unit/genesis_spec_target_arch.t | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 xCAT-test/unit/genesis_spec_target_arch.t diff --git a/xCAT-test/unit/genesis_spec_target_arch.t b/xCAT-test/unit/genesis_spec_target_arch.t new file mode 100644 index 000000000..8f9ea785a --- /dev/null +++ b/xCAT-test/unit/genesis_spec_target_arch.t @@ -0,0 +1,47 @@ +#!/usr/bin/env perl +# The genesis specs name their package after the target arch: xCAT-genesis-scripts- and +# xCAT-genesis-base-. %{tarch} comes from an %ifarch ladder, and an arch missing from that +# ladder leaves the macro UNEXPANDED instead of failing: rpm then builds a package literally named +# "xCAT-genesis-scripts-%{tarch}", buildrpms.pl cannot find the srpm it asked for, and the whole +# target build dies with a "Cannot find/open srpm" that names the right file. +# +# Expand each spec with rpmspec for every arch xCAT supports and assert the Name carries that arch. +use strict; +use warnings; + +use FindBin; +use Test::More; + +my $root = "$FindBin::Bin/../.."; + +sub command_exists { my ($c) = @_; return system("command -v $c >/dev/null 2>&1") == 0 } + +plan skip_all => 'rpmspec is not installed' unless command_exists('rpmspec'); + +# arch under test => the tarch the spec must resolve it to (x86 and ppc64 are historical names +# genesis keeps; see genesis_tarch_from_targetarch in buildrpms.pl). +my %tarch = ( + x86_64 => 'x86_64', + i686 => 'x86', + ppc64le => 'ppc64', + aarch64 => 'aarch64', + riscv64 => 'riscv64', +); + +my %spec = ( + 'xCAT-genesis-scripts' => "$root/xCAT-genesis-scripts/xCAT-genesis-scripts.spec", + 'xCAT-genesis-base' => "$root/xCAT-genesis-builder/xCAT-genesis-base.spec", +); + +for my $pkg (sort keys %spec) { + my $spec = $spec{$pkg}; + ok(-f $spec, "$pkg spec is present") or next; + for my $arch (sort keys %tarch) { + my $name = `rpmspec --target $arch -q --qf '%{NAME}' --define 'version 2.19.0' --define 'release snap0' @{[quotemeta $spec]} 2>/dev/null`; + chomp $name; + is($name, "$pkg-$tarch{$arch}", "$pkg on $arch is named $pkg-$tarch{$arch}"); + unlike($name, qr/%\{/, "$pkg on $arch leaves no unexpanded macro in its name"); + } +} + +done_testing(); From 11da82dbbae4c8a7db65aaff704715ade7f15228 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:41:46 -0300 Subject: [PATCH 02/37] fix(xcat-core): the genesis specs have no riscv64 branch, so %{tarch} stays literal A riscv64 build of xcat-core dies at xCAT-genesis-scripts with "Cannot find/open srpm: ...xCAT-genesis-scripts-riscv64-2.19.0-.src.rpm", because the srpm rpm produced is named xCAT-genesis-scripts-%{tarch}-2.19.0-.src.rpm. xCAT-genesis-scripts.spec and xCAT-genesis-base.spec take their package name from %{tarch}, which an %ifarch ladder sets for x86, x86_64, ppc64 and aarch64. riscv64 is absent, so rpm leaves the macro unexpanded and builds a package whose NAME contains it. buildrpms.pl then looks for the name it asked for and cannot find it. Add the riscv64 branch to both specs. An arch that is still missing from the ladder now stops the build with %{error:} instead of naming a package after a macro. xCAT-test/unit/genesis_spec_target_arch.t covers this: it fails on riscv64 without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit c9de89ccceaaa618f9e91568d74812b349c513ac) --- xCAT-genesis-builder/xCAT-genesis-base.spec | 8 ++++++++ xCAT-genesis-scripts/xCAT-genesis-scripts.spec | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/xCAT-genesis-builder/xCAT-genesis-base.spec b/xCAT-genesis-builder/xCAT-genesis-base.spec index 1a6f0f212..f2b790e51 100644 --- a/xCAT-genesis-builder/xCAT-genesis-base.spec +++ b/xCAT-genesis-builder/xCAT-genesis-base.spec @@ -12,6 +12,14 @@ Release: %{?release:%{release}}%{!?release:%(cat Release)} %ifarch aarch64 %define tarch aarch64 %endif +%ifarch riscv64 +%define tarch riscv64 +%endif +# An arch missing from the ladder above leaves %{tarch} unexpanded, and rpm then builds a package +# with a macro in its NAME instead of failing. Stop the build here instead. +%if ! %{defined tarch} +%{error:no genesis tarch for %{_target_cpu} -- add an %%ifarch branch above} +%endif BuildArch: noarch %define name xCAT-genesis-base-%{tarch} %define __spec_install_post : diff --git a/xCAT-genesis-scripts/xCAT-genesis-scripts.spec b/xCAT-genesis-scripts/xCAT-genesis-scripts.spec index f42ffd477..1760f86d7 100644 --- a/xCAT-genesis-scripts/xCAT-genesis-scripts.spec +++ b/xCAT-genesis-scripts/xCAT-genesis-scripts.spec @@ -10,6 +10,14 @@ %ifarch aarch64 %define tarch aarch64 %endif +%ifarch riscv64 +%define tarch riscv64 +%endif +# An arch missing from the ladder above leaves %{tarch} unexpanded, and rpm then builds a package +# with a macro in its NAME instead of failing. Stop the build here instead. +%if ! %{defined tarch} +%{error:no genesis tarch for %{_target_cpu} -- add an %%ifarch branch above} +%endif %define rpminstallroot /opt/xcat/share/xcat/netboot/genesis/%{tarch}/fs BuildArch: noarch %define name xCAT-genesis-scripts-%{tarch} From f7d52f94ecf9ac1244a2095be682af093c5e118f Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:38:14 -0300 Subject: [PATCH 03/37] test(xcat-core): capture the Debian control files excluding riscv64 Installing xCAT on a riscv64 Ubuntu management node fails before it starts: E: Unable to locate package xcat E: Unable to locate package xcat-test xCAT/debian/control and xCATsn/debian/control name their architectures explicitly, as "amd64 ppc64el". riscv64 is absent, so no riscv64 deb is ever produced and apt has nothing to install -- while the rest of the tree already carries riscv64 install templates, DHCP boot policy, mknb support and a Genesis machine configuration. The test reads both control files and asserts the explicit list covers every Debian architecture xCAT ships. It fails on both files today. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 059c23486d8f15e8a0a224ef7dfe05cd7fe123a4) --- xCAT-test/unit/debian_control_arch_coverage.t | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 xCAT-test/unit/debian_control_arch_coverage.t diff --git a/xCAT-test/unit/debian_control_arch_coverage.t b/xCAT-test/unit/debian_control_arch_coverage.t new file mode 100644 index 000000000..73bf2c7a1 --- /dev/null +++ b/xCAT-test/unit/debian_control_arch_coverage.t @@ -0,0 +1,40 @@ +#!/usr/bin/env perl +# xCAT and xCATsn name their Debian architectures explicitly. An architecture missing from that +# list is not a build failure -- it is a package that never exists: apt on that architecture says +# +# E: Unable to locate package xcat +# +# and the management node cannot be installed at all. riscv64 was missing while the rest of the +# tree already carried riscv64 install templates, DHCP boot policy and a Genesis machine config. +# +# The list is compared against the architectures the DEB build itself supports, taken from +# build-utils/lib/XCAT/BuildUtils or, failing that, the documented set. +use strict; +use warnings; + +use FindBin; +use Test::More; + +my $root = "$FindBin::Bin/../.."; + +# The Debian architectures xCAT ships. dpkg names, not rpm ones. +my @arches = qw(amd64 ppc64el riscv64); + +my @controls = grep { -f } ("$root/xCAT/debian/control", "$root/xCATsn/debian/control"); +plan skip_all => 'no Debian control files in this tree' unless @controls; + +for my $ctl (@controls) { + open my $fh, '<', $ctl or die "read $ctl: $!"; + local $/; my $text = <$fh>; close $fh; + (my $short = $ctl) =~ s{^\Q$root\E/}{}; + my @lines = ($text =~ /^Architecture:\s*(.+)$/mg); + my @explicit = grep { !/^(?:any|all)$/ } map { s/^\s+|\s+$//gr } @lines; + ok(scalar(@explicit), "$short names architectures explicitly") or next; + for my $line (@explicit) { + my %have = map { $_ => 1 } split /\s+/, $line; + my @missing = grep { !$have{$_} } @arches; + is_deeply(\@missing, [], "$short covers @arches"); + } +} + +done_testing(); From 1cffcec1cfed500cf8ab0a0b5b66e83f253bdd18 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:38:24 -0300 Subject: [PATCH 04/37] fix(xcat-core): build the xCAT debs for riscv64 apt on a riscv64 Ubuntu management node cannot find xCAT at all: E: Unable to locate package xcat xCAT/debian/control and xCATsn/debian/control list "Architecture: amd64 ppc64el", so the build produces no riscv64 deb and the published apt repository serves only those two architectures. Everything else riscv64 needs is already in the tree -- the rocky10/rhels10 riscv64 install templates, the grub2 boot policy, mknb, the OpenEmbedded Genesis machine -- and the xcat-dep riscv64 repository is built, signed and complete; only the core packages are missing. Add riscv64 to both lists. xCAT-test/unit/debian_control_arch_coverage.t covers this: it fails on both files without the change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 13492f55c32b34597b24934079d35789b4cc57c0) --- xCAT/debian/control | 2 +- xCATsn/debian/control | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/xCAT/debian/control b/xCAT/debian/control index 74ff70c64..d7077a5fc 100644 --- a/xCAT/debian/control +++ b/xCAT/debian/control @@ -8,7 +8,7 @@ Vcs-browser: https://github.com/xcat2/xcat-core.git Homepage: https://xcat.org/ Package: xcat -Architecture: amd64 ppc64el +Architecture: amd64 ppc64el riscv64 Depends: ${perl:Depends}, goconserver(>= 0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, isc-dhcp-server | kea, bind9, apache2, nfs-kernel-server, libxml-parser-perl, rsync, tftpd-hpa, libnet-telnet-perl, chrony | ntp, xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) Recommends: net-tools, nmap, kea, tftp-hpa, ipmitool-xcat (>= 1.8.17-1), syslinux[any-amd64], libsys-virt-perl, syslinux-xcat, xnba-undi, elilo-xcat, util-linux-extra, xcat-buildkit (>= 2.13-snap000000000000), xcat-probe (>= 2.13-snap000000000000), xcat-genesis-openembedded-x86-64, xcat-genesis-openembedded-ppc64le, xcat-genesis-openembedded-riscv64 Suggests: yaboot-xcat diff --git a/xCATsn/debian/control b/xCATsn/debian/control index 281c92066..73b096133 100644 --- a/xCATsn/debian/control +++ b/xCATsn/debian/control @@ -7,7 +7,7 @@ Standards-Version: 3.9.4 Homepage: https://xcat.org/ Package: xcatsn -Architecture: amd64 ppc64el +Architecture: amd64 ppc64el riscv64 Depends: ${perl:Depends}, goconserver (>=0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, libxml-parser-perl, tftpd-hpa, libnet-telnet-perl, isc-dhcp-server | kea, bind9, apache2, nfs-kernel-server, xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) Recommends: net-tools, nmap, kea, tftp-hpa, ipmitool-xcat (>= 1.8.17-1), syslinux[any-amd64], libsys-virt-perl, syslinux-xcat, xnba-undi, elilo-xcat, xcat-buildkit (>= 2.13-snap000000000000), xcat-probe (>= 2.13-snap000000000000), xcat-genesis-openembedded-x86-64, xcat-genesis-openembedded-ppc64le, xcat-genesis-openembedded-riscv64 Suggests: yaboot-xcat From 97f481a5706a2046cb9e3e27b7d60269a2629b92 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:52:05 -0300 Subject: [PATCH 05/37] fix(build): builddebs.pl drops riscv64, so apt serves no riscv64 xCAT builddebs.pl replaced build-ubunturepo as the Ubuntu builder, and the arch support riscv64 has in build-ubunturepo did not come with it. The pipeline prefers builddebs.pl whenever the ref carries it, so on this branch the switch silently stops producing riscv64 debs: no xcat_*_riscv64.deb in the pool, and a published Release that says 'Architectures: amd64 ppc64el'. apt on a riscv64 management node then reports 'Unable to locate package xcat', which is the same failure build-ubunturepo was fixed for. builddebs.pl reads its architectures from BuildUtils, so unlike build-ubunturepo -- which hardcoded the pair in three places -- riscv64 goes in one: @DEB_ARCHES. xcat-genesis-scripts is the exception and needs its own rule. Its per-arch deb Depends on xcat-genesis-base-, and no riscv64 genesis-base deb exists, because riscv64 takes the OpenEmbedded Genesis image from the shared xcat-dep pool. Built for riscv64 it would be uninstallable, so deb_package_arches excludes it. build_utils.t covers both: removing riscv64 from @DEB_ARCHES fails four assertions, including the reprepro Architectures line, and removing the genesis-scripts exclusion fails its own. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 90f9156411b11b33496a470574f37fcdc2a7330f) --- build-utils/lib/XCAT/BuildUtils.pm | 14 +++++++++++--- xCAT-test/unit/build_utils.t | 16 ++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/build-utils/lib/XCAT/BuildUtils.pm b/build-utils/lib/XCAT/BuildUtils.pm index e0deac209..1e7ecdff2 100644 --- a/build-utils/lib/XCAT/BuildUtils.pm +++ b/build-utils/lib/XCAT/BuildUtils.pm @@ -156,7 +156,14 @@ my %ARCH_PACKAGES = map { $_ => 1 } qw(xCAT xCATsn xCAT-genesis-scripts); # the repo-assembly and the package-selection paths cannot disagree about it. my %NO_PPC64EL = map { $_ => 1 } qw(saucy); -my @DEB_ARCHES = qw(amd64 ppc64el); +my @DEB_ARCHES = qw(amd64 ppc64el riscv64); + +# Packages that are NOT built for riscv64. xcat-genesis-scripts- Depends on +# xcat-genesis-base-, and no riscv64 genesis-base deb exists: riscv64 takes the +# OpenEmbedded Genesis image from the shared xcat-dep pool instead. Building it here would +# publish a package nothing can install, which is what happens when the arch list is one +# global constant. +my %NO_RISCV64 = map { $_ => 1 } qw(xCAT-genesis-scripts); # The Ubuntu releases the apt repository serves by default. Single source of truth: # the builder, the repo assembly and the tests all read it here, so they cannot drift. @@ -323,8 +330,9 @@ sub stage_probe_helpers { # 'all' is a single arch-independent build; the three arch packages get one per arch. sub deb_package_arches { my ($package) = @_; - return @DEB_ARCHES if $ARCH_PACKAGES{$package // ''}; - return ('all'); + return ('all') unless $ARCH_PACKAGES{$package // ''}; + return grep { $_ ne 'riscv64' } @DEB_ARCHES if $NO_RISCV64{$package}; + return @DEB_ARCHES; } # dist_arches: the architectures a release's apt repo declares. diff --git a/xCAT-test/unit/build_utils.t b/xCAT-test/unit/build_utils.t index 8a44fbe39..b750ad358 100644 --- a/xCAT-test/unit/build_utils.t +++ b/xCAT-test/unit/build_utils.t @@ -60,15 +60,19 @@ is_deeply( [deb_package_arches('perl-xCAT')], ['all'], 'a Perl package is built once, arch-independent' ); is_deeply( [deb_package_arches('xCAT-probe')], ['all'], 'xCAT-probe is arch-independent too' ); -for my $pkg (qw(xCAT xCATsn xCAT-genesis-scripts)) { - is_deeply( [deb_package_arches($pkg)], ['amd64', 'ppc64el'], +for my $pkg (qw(xCAT xCATsn)) { + is_deeply( [deb_package_arches($pkg)], ['amd64', 'ppc64el', 'riscv64'], "$pkg is built per architecture" ); } +# xcat-genesis-scripts- Depends on xcat-genesis-base- and there is no riscv64 +# genesis-base deb, so a riscv64 build of it would be uninstallable. +is_deeply( [deb_package_arches('xCAT-genesis-scripts')], ['amd64', 'ppc64el'], + 'xcat-genesis-scripts is built per architecture, but never for riscv64' ); is_deeply( [deb_package_arches(undef)], ['all'], 'an undefined package name does not blow up the arch lookup' ); -is_deeply( [dist_arches('noble')], ['amd64', 'ppc64el'], - 'a current release serves both architectures' ); +is_deeply( [dist_arches('noble')], ['amd64', 'ppc64el', 'riscv64'], + 'a current release serves every architecture xCAT builds' ); is_deeply( [dist_arches('saucy')], ['amd64'], 'saucy predates ppc64el and serves only amd64' ); @@ -151,8 +155,8 @@ is( scalar( () = $rewritten =~ /^ -- xCAT Build /mg ), 1, my $dists = reprepro_distributions([qw(focal noble)], 'DEADBEEF'); is( scalar(() = $dists =~ /^Codename:/mg), 2, 'one stanza per release' ); -like( $dists, qr/^Codename: focal\nArchitectures: amd64 ppc64el$/m, - 'a release declares both architectures, on the line after its codename' ); +like( $dists, qr/^Codename: focal\nArchitectures: amd64 ppc64el riscv64$/m, + 'a release declares every architecture, on the line after its codename' ); is( scalar(() = $dists =~ /^SignWith: DEADBEEF$/mg), 2, 'every stanza is signed when a key is given' ); From 445b755d8ef74f53c8fe4c43d40b4da195ceed39 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:15:57 -0300 Subject: [PATCH 06/37] test(mknb): capture a Genesis copy failing without failing mknb mknb stages the Genesis payload before building a netboot image, and those copies are the only point at which it learns that an installed Genesis image is unusable. The legacy branch runs two of them and keeps only the second exit status, so an unreadable root tree is invisible: mknb exits 0 having built an initramfs from nothing, and the node never boots with no error naming the cause. When the kernel copy is the one that fails, the message blames the root tree instead. Extract the staging decision as stage_genesis_payload, preserving today's behaviour exactly, so the outcome can be driven with an injected runner instead of a real Genesis tree. The test fails on this commit, 2 of 10: 'an unreadable root tree fails the step' and 'the failure names the kernel, not the root tree'. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 989deaa31eb1f3979a8db030d3ae8c404abda2fb) --- xCAT-server/lib/xcat/plugins/mknb.pm | 53 ++++++++++++++++------ xCAT-test/unit/mknb_genesis_staging.t | 64 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 xCAT-test/unit/mknb_genesis_staging.t diff --git a/xCAT-server/lib/xcat/plugins/mknb.pm b/xCAT-server/lib/xcat/plugins/mknb.pm index 891310e2d..be6ab48fc 100644 --- a/xCAT-server/lib/xcat/plugins/mknb.pm +++ b/xCAT-server/lib/xcat/plugins/mknb.pm @@ -327,6 +327,41 @@ sub genesis_lzma_command { return; } +#------------------------------------------------------------------------------- + +=head3 stage_genesis_payload + +Descriptions: + Copy the Genesis payload into place for mknb: for a legacy image the unpacked + root tree and then the kernel, for an exported image the nbroot tree. + + Extracted so the outcome can be driven directly. The copies are the only + place mknb learns that an installed Genesis image is unusable, and a caller + cannot tell WHICH copy failed from a single exit status. + +Arguments: + genesis_type, genesis_dir, tftpdir, arch, tempdir, and an optional run + coderef used in place of system() by the tests. +Returns: + (rc, source) -- rc is the exit status of the copy that failed, and source + names it, so the caller reports the file it could not read. + +=cut + +#------------------------------------------------------------------------------- +sub stage_genesis_payload { + my (%a) = @_; + my $run = $a{run} || sub { return system($_[0]); }; + my $rc; + if (($a{genesis_type} // '') eq 'legacy') { + $rc = $run->("shopt -s dotglob; GLOBIGNORE=\".:..\" cp -a $a{genesis_dir}/fs/* $a{tempdir}"); + $rc = $run->("cp -a $a{genesis_dir}/kernel $a{tftpdir}/xcat/genesis.kernel.$a{arch}"); + return ($rc, "$a{genesis_dir}/fs"); + } + $rc = $run->("cp -a $a{genesis_dir}/nbroot/* $a{tempdir}"); + return ($rc, "$a{genesis_dir}/nbroot"); +} + sub process_request { my $request = shift; my $callback = shift; @@ -555,21 +590,13 @@ sub process_request { unless (-e "$tftpdir/xcat") { mkpath("$tftpdir/xcat"); } - my $rc; - if ($genesis_type eq 'legacy') { - $rc = system("shopt -s dotglob; GLOBIGNORE=\".:..\" cp -a $genesis_dir/fs/* $tempdir"); - $rc = system("cp -a $genesis_dir/kernel $tftpdir/xcat/genesis.kernel.$arch"); - $invisibletouch = 1; - } else { - $rc = system("cp -a $genesis_dir/nbroot/* $tempdir"); - } + $invisibletouch = 1 if $genesis_type eq 'legacy'; + my ($rc, $failed_src) = stage_genesis_payload( + genesis_type => $genesis_type, genesis_dir => $genesis_dir, + tftpdir => $tftpdir, arch => $arch, tempdir => $tempdir); if ($rc) { system("rm -rf $tempdir"); - if ($invisibletouch) { - $callback->({ error => ["Failed to copy $genesis_dir/fs contents"], errorcode => [1] }); - } else { - $callback->({ error => ["Failed to copy $genesis_dir/nbroot contents"], errorcode => [1] }); - } + $callback->({ error => ["Failed to copy $failed_src contents"], errorcode => [1] }); return; } my $sshdir; diff --git a/xCAT-test/unit/mknb_genesis_staging.t b/xCAT-test/unit/mknb_genesis_staging.t new file mode 100644 index 000000000..262401f41 --- /dev/null +++ b/xCAT-test/unit/mknb_genesis_staging.t @@ -0,0 +1,64 @@ +#!/usr/bin/env perl +# mknb stages the Genesis payload before it can build a netboot image. Those copies are the +# only point at which mknb learns that an installed Genesis image is unusable, so a copy that +# fails silently produces an initramfs built from nothing and an exit status of 0 -- the node +# then never boots, with no error anywhere naming the cause. +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use Test::More; + +BEGIN { $INC{'xCAT/Utils.pm'} = 1; $INC{'xCAT/MsgUtils.pm'} = 1; + $INC{'xCAT/Table.pm'} = 1; $INC{'xCAT/NetworkUtils.pm'} = 1; + $INC{'xCAT/TableUtils.pm'} = 1; $INC{'xCAT_monitoring/monitorctrl.pm'} = 1; } + +require "$FindBin::Bin/../../xCAT-server/lib/xcat/plugins/mknb.pm"; + +can_ok('xCAT_plugin::mknb', 'stage_genesis_payload') + or BAIL_OUT('mknb has no stage_genesis_payload to drive'); + +# Drive the routine with a runner that fails exactly one copy, so each assertion names the +# copy it is about rather than the pair. +sub stage { + my (%opt) = @_; + my @ran; + my ($rc, $src) = xCAT_plugin::mknb::stage_genesis_payload( + genesis_type => $opt{type} // 'legacy', + genesis_dir => '/opt/xcat/share/xcat/netboot/genesis/x86_64', + tftpdir => '/tftpboot', + arch => 'x86_64', + tempdir => '/tmp/scratch', + run => sub { + my ($cmd) = @_; + push @ran, $cmd; + return ($opt{fail} && $cmd =~ /$opt{fail}/) ? 256 : 0; + }, + ); + return { rc => $rc, src => $src, ran => \@ran }; +} + +# --- legacy: both copies must be able to fail the step ----------------------- +my $ok = stage(); +is($ok->{rc}, 0, 'a legacy image whose copies both succeed stages cleanly'); +is(scalar @{ $ok->{ran} }, 2, 'the legacy path copies the root tree and the kernel'); + +my $nofs = stage(fail => qr{/fs/\*}); +isnt($nofs->{rc}, 0, 'an unreadable root tree fails the step'); +like($nofs->{src}, qr{/fs$}, 'and the failure names the root tree'); + +my $nokernel = stage(fail => qr{/kernel }); +isnt($nokernel->{rc}, 0, 'a missing kernel fails the step'); +like($nokernel->{src}, qr{/kernel$}, 'and the failure names the kernel, not the root tree'); + +# --- exported (OpenEmbedded) path ------------------------------------------- +my $nonb = stage(type => 'exported', fail => qr{/nbroot/\*}); +isnt($nonb->{rc}, 0, 'an unreadable nbroot fails the step'); +like($nonb->{src}, qr{/nbroot$}, 'and the failure names nbroot'); + +my $oknb = stage(type => 'exported'); +is($oknb->{rc}, 0, 'an exported image whose copy succeeds stages cleanly'); + +done_testing(); From 0136c51a18a258ed96318285b7bf8688e49cbda0 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:16:16 -0300 Subject: [PATCH 07/37] fix(mknb): a Genesis copy that fails must fail mknb The legacy branch ran both copies and kept only the second exit status, so an unreadable Genesis root tree left mknb exiting 0 with an initramfs built from nothing -- the node then never boots and nothing names the cause. A failing kernel copy was reported as a failure of the root tree, because the message was chosen from a flag set before either copy ran. Return on the first failing copy, carrying the name of the file that could not be read. mknb_genesis_staging.t goes from 2 failures to green on this commit. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit d46ca3e09bf52827bf0b8fa9cb94279a9da2c97b) --- xCAT-server/lib/xcat/plugins/mknb.pm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/xCAT-server/lib/xcat/plugins/mknb.pm b/xCAT-server/lib/xcat/plugins/mknb.pm index be6ab48fc..f84c099c1 100644 --- a/xCAT-server/lib/xcat/plugins/mknb.pm +++ b/xCAT-server/lib/xcat/plugins/mknb.pm @@ -354,9 +354,13 @@ sub stage_genesis_payload { my $run = $a{run} || sub { return system($_[0]); }; my $rc; if (($a{genesis_type} // '') eq 'legacy') { + # Two copies, each able to fail on its own. Return on the first, so neither the exit + # status nor the name of the unreadable file is lost to the one that follows it. $rc = $run->("shopt -s dotglob; GLOBIGNORE=\".:..\" cp -a $a{genesis_dir}/fs/* $a{tempdir}"); + return ($rc, "$a{genesis_dir}/fs") if $rc; $rc = $run->("cp -a $a{genesis_dir}/kernel $a{tftpdir}/xcat/genesis.kernel.$a{arch}"); - return ($rc, "$a{genesis_dir}/fs"); + return ($rc, "$a{genesis_dir}/kernel") if $rc; + return (0, undef); } $rc = $run->("cp -a $a{genesis_dir}/nbroot/* $a{tempdir}"); return ($rc, "$a{genesis_dir}/nbroot"); From 2bed8fe9a145d13a7ca26a7b10e033f0bbcd0cd9 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:29:48 -0300 Subject: [PATCH 08/37] fix(xcat-core): bound the pings that assert a purged node stopped resolving nodepurge/cases0 asserted that testnode1 and testnode2 no longer resolve by running 'ping' with no count and expecting a non-zero exit. When the name does not resolve the ping fails immediately, which is the passing path -- but when it DOES resolve, which is the regression the case exists to catch, the ping never returns. The cell stops there and is killed by the pipeline timeout, taking the whole run's JUnit with it, so the one case that finds a real defect is also the one that hides every other result. Bound both with -c 1 -w 2. The assertion is unchanged: a name that does not resolve, or resolves to something that does not answer, still exits non-zero. autotest_ping_bounded.t goes green on this commit. Closes #59. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 187daabe3601dfe0448656c446994d91d8f794aa) --- xCAT-test/autotest/testcase/nodepurge/cases0 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xCAT-test/autotest/testcase/nodepurge/cases0 b/xCAT-test/autotest/testcase/nodepurge/cases0 index 451fdc46b..94a744e0a 100644 --- a/xCAT-test/autotest/testcase/nodepurge/cases0 +++ b/xCAT-test/autotest/testcase/nodepurge/cases0 @@ -21,9 +21,9 @@ cmd:ls /install/autoinst/testnode1* check:output=~No such file or directory cmd:ls /install/autoinst/testnode2* check:output=~No such file or directory -cmd:ping testnode1 +cmd:ping -c 1 -w 2 testnode1 check:rc!=0 -cmd:ping testnode2 +cmd:ping -c 1 -w 2 testnode2 check:rc!=0 end From 349658d3d70f6bef0cde760c5edb7d9a1b43e8e2 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:09:17 -0300 Subject: [PATCH 09/37] fix(xcat-core): mkvm builds a riscv64 node as an x86_64 domain A node with arch=riscv64 got an x86_64 libvirt domain from mkvm. The node took a DHCP lease, received the riscv64 GRUB binary that nodeset staged, and could not run it. The firmware fell through to the empty disk and stopped, so both flat provisioning cases of the riscv64 cell failed with a node that never installed. build_xmldesc and build_diskstruct in xCAT-server/lib/xcat/plugins/kvm.pm read the architecture from the hypervisor cpumodel. The arch of the node was never read while the domain XML was built, so on an x86_64 hypervisor every guest was an x86_64 guest, whatever the node said. guest_arch_profile now takes the arch of the node as well, and returns the domain type, the arch and machine, the firmware and the device settings that follow from them. A riscv64 node becomes a qemu domain with the virt machine type and UEFI firmware. It drops the parts the riscv64 virt machine has no controller for, or that libvirt refuses there: the pae, acpi and apic features, the SeaBIOS serial option, the ich6 sound card, the USB tablet, and the ide disk and hd* optical drive. libvirt resolves the emulator and the UEFI firmware files itself. POWER and x86_64 domains do not change. kvm_guest_arch.t drives build_xmldesc and build_diskstruct in a scratch package, stubbing only the routines that reach libvirt or the xCAT database, and asserts the domain and the disks of each architecture. Ten of its twenty assertions fail without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 8d149c856302c8016fb0ead31e9859fd5a1e9dff) --- xCAT-server/lib/xcat/plugins/kvm.pm | 92 ++++++++++++++++----- xCAT-test/unit/kvm_guest_arch.t | 122 ++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 xCAT-test/unit/kvm_guest_arch.t diff --git a/xCAT-server/lib/xcat/plugins/kvm.pm b/xCAT-server/lib/xcat/plugins/kvm.pm index 1e81317ae..0bae3a15e 100644 --- a/xCAT-server/lib/xcat/plugins/kvm.pm +++ b/xCAT-server/lib/xcat/plugins/kvm.pm @@ -498,6 +498,8 @@ sub build_diskstruct { my @suffixes = ('a', 'b', 'd' .. 'zzz'); my $suffidx = 0; my $storagemodel = $confdata->{vm}->{$node}->[0]->{storagemodel}; + my $profile = guest_arch_profile($confdata->{nodetype}->{$node}->[0]->{arch}, + $confdata->{ $confdata->{vm}->{$node}->[0]->{host} }->{cpumodel}); my $cachemethod = "none"; if ($confdata->{vm}->{$node}->[0]->{storagecache}) { $cachemethod = $confdata->{vm}->{$node}->[0]->{storagecache}; @@ -517,7 +519,7 @@ sub build_diskstruct { #if not defined, model will stay undefined like above $model = $storagemodel; - unless ($model) { $model = 'ide'; } #if still not defined, ide + unless ($model) { $model = $profile->{disk_model}; } } my $prefix = 'hd'; if ($model eq 'virtio') { @@ -586,7 +588,8 @@ sub build_diskstruct { push @returns, $diskhash; } } - my $cdprefix = 'hd'; + # The riscv64 virt machine has no IDE controller, so the optical drive is scsi there. + my $cdprefix = $profile->{cd_prefix}; # Normally for vmstoragemodel=virtio, we would set prefix of "vd", but device name vd* # doesn't work for CDROM, so for now use the same prefix "sd" as for vmstoragemodel=scsi. @@ -704,6 +707,54 @@ sub getUnits { } } +# guest_arch_profile: the libvirt domain type and settings for one guest. +# +# The architecture of the guest comes from the node, not from the hypervisor. A node whose +# arch is not the arch of the hypervisor runs under emulation, which libvirt expresses as +# domain type "qemu". riscv64 has no BIOS: the virt machine boots UEFI, and pae/acpi/apic +# are x86 features that libvirt rejects there. +# +# POWER keeps reading the hypervisor cpumodel. ppc64le hypervisors report "ppc64le" (not +# "ppc64"); both are pseries guests whose libvirt arch is "ppc64". +# +# arch and machine stay undef when libvirt is to use its own default for the hypervisor. +sub guest_arch_profile { + my ($guest_arch, $hyp_cpumodel) = @_; + my %profile = ( + domtype => 'kvm', + arch => undef, + machine => undef, + firmware => undef, + x86_features => 1, + bios => 1, + sound => 1, + video => 'vga', + usb_input => 1, + disk_model => 'ide', + cd_prefix => 'hd', + ); + if (defined($guest_arch) and $guest_arch eq 'riscv64') { + $profile{domtype} = 'qemu'; + $profile{arch} = 'riscv64'; + $profile{machine} = 'virt'; + $profile{firmware} = 'efi'; + $profile{x86_features} = 0; + $profile{bios} = 0; + $profile{sound} = 0; + $profile{video} = 'virtio'; + $profile{usb_input} = 0; + $profile{disk_model} = 'scsi'; + $profile{cd_prefix} = 'sd'; + } elsif (defined($hyp_cpumodel) and ($hyp_cpumodel eq "ppc64" or $hyp_cpumodel eq "ppc64le")) { + $profile{arch} = 'ppc64'; + $profile{machine} = 'pseries'; + $profile{x86_features} = 0; + $profile{bios} = 0; + $profile{sound} = 0; + } + return \%profile; +} + sub build_xmldesc { my $node = shift; my %args = @_; @@ -716,19 +767,16 @@ sub build_xmldesc { $hypcputhreads = "1"; } - $xtree{type} = 'kvm'; + my $profile = guest_arch_profile($confdata->{nodetype}->{$node}->[0]->{arch}, $hypcpumodel); + + $xtree{type} = $profile->{domtype}; $xtree{name}->{content} = $node; $xtree{uuid}->{content} = getNodeUUID($node); $xtree{os} = build_oshash(); - # ppc64le hypervisors report cpumodel "ppc64le" (not "ppc64"); both are pseries - # guests whose libvirt arch is "ppc64". Without this the guest is emitted - # as an x86-style domain (no machine, plus the pae/acpi/apic below) which libvirt - # rejects on ppc64le hosts: "machine type 'pseries-*' does not support ACPI". - if (defined($hypcpumodel) and ($hypcpumodel eq "ppc64" or $hypcpumodel eq "ppc64le")) { - $xtree{os}->{type}->{arch} = "ppc64"; - $xtree{os}->{type}->{machine} = "pseries"; - delete $xtree{os}->{bios}; - } + $xtree{os}->{type}->{arch} = $profile->{arch} if defined $profile->{arch}; + $xtree{os}->{type}->{machine} = $profile->{machine} if defined $profile->{machine}; + $xtree{os}->{firmware} = $profile->{firmware} if defined $profile->{firmware}; + delete $xtree{os}->{bios} unless $profile->{bios}; if ($args{memory}) { $xtree{memory}->{content} = getUnits($args{memory}, "M", 1024); if ($confdata->{vm}->{$node}->[0]->{memory}) { @@ -940,9 +988,7 @@ sub build_xmldesc { } } - # pae/acpi/apic are x86 features; pseries (ppc64/ppc64le) guests do not support - # them and libvirt rejects the domain if they are present. - unless (defined($hypcpumodel) and ($hypcpumodel eq "ppc64" or $hypcpumodel eq "ppc64le")) { + if ($profile->{x86_features}) { $xtree{features}->{pae} = {}; $xtree{features}->{acpi} = {}; $xtree{features}->{apic} = {}; @@ -965,10 +1011,13 @@ sub build_xmldesc { $vram = 65536; } #surprise, spice blows up with less vram than this after version 0.6 and up $xtree{devices}->{video} = [ { 'content' => '', 'model' => { type => $model, vram => $vram } } ]; } else { - $xtree{devices}->{video} = [ { 'content' => '', 'model' => { type => 'vga', vram => 8192 } } ]; + $xtree{devices}->{video} = [ { 'content' => '', 'model' => { type => $profile->{video}, vram => 8192 } } ]; + } + # The riscv64 virt machine has no USB controller, and libvirt refuses a USB device there. + if ($profile->{usb_input}) { + $xtree{devices}->{input}->{type} = 'tablet'; + $xtree{devices}->{input}->{bus} = 'usb'; } - $xtree{devices}->{input}->{type} = 'tablet'; - $xtree{devices}->{input}->{bus} = 'usb'; if (defined($confdata->{vm}->{$node}->[0]->{vidproto})) { $xtree{devices}->{graphics}->{type} = $confdata->{vm}->{$node}->[0]->{vidproto}; } else { @@ -983,10 +1032,9 @@ sub build_xmldesc { } if (defined($hypcpumodel) and $hypcpumodel eq 'ppc64') { $xtree{devices}->{emulator}->{content} = "/usr/bin/qemu-system-ppc64"; - } elsif (defined($hypcpumodel) and $hypcpumodel eq 'ppc64le') { - # do nothing for ppc64le, do not support sound at this time - ; - } else { + } + # libvirt resolves the emulator for every other architecture from its own capabilities. + if ($profile->{sound}) { $xtree{devices}->{sound}->{model} = 'ich6'; } diff --git a/xCAT-test/unit/kvm_guest_arch.t b/xCAT-test/unit/kvm_guest_arch.t new file mode 100644 index 000000000..763722ef7 --- /dev/null +++ b/xCAT-test/unit/kvm_guest_arch.t @@ -0,0 +1,122 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin; +use Test::More; + +# The scratch package below declares these; the test names them once each. +no warnings 'once'; + +my $source = "$FindBin::Bin/../../xCAT-server/lib/xcat/plugins/kvm.pm"; +open(my $source_fh, '<', $source) or die "open $source: $!"; +my $content = do { local $/; <$source_fh> }; +close($source_fh) or die "close $source: $!"; + +my @routines; +for my $name (qw(build_xmldesc guest_arch_profile build_oshash build_diskstruct getUnits)) { + my ($routine) = $content =~ /^(sub \Q$name\E\s*\{.*?^\})/ms; + BAIL_OUT("could not extract $name from kvm.pm") unless $routine; + push(@routines, $routine); +} + +# kvm.pm needs a management node to load, so the domain builder runs in a scratch package. +# Only the routines that reach libvirt or the xCAT database are replaced; the domain builder +# itself is the code under test. +my $harness = <<'PERL'; +package KVMArch; +use XML::Simple qw(XMLout); +our ($node, $confdata, $updatetable, $hypconn); +sub getNodeUUID { return '00000000-0000-0000-0000-000000000001'; } +sub get_multiple_paths_by_url { return {}; } +sub build_nicstruct { return []; } +sub genpassword { return 'password'; } +PERL + +eval $harness . join("\n", @routines) . "\n1;\n"; ## no critic (BuiltinFunctions::ProhibitStringyEval) +BAIL_OUT("could not load the kvm domain builder: $@") if $@; + +# Build one domain for a node of $guest_arch on a hypervisor that reports $hyp_cpumodel. +sub domain_xml { + my ($guest_arch, $hyp_cpumodel) = @_; + local $KVMArch::node = 'cn1'; + local $KVMArch::confdata = { + vm => { cn1 => [ { host => 'hyp1', memory => 8192, cpus => 4 } ] }, + nodetype => { cn1 => [ { arch => $guest_arch, os => 'rocky10.2' } ] }, + hyp1 => { cpumodel => $hyp_cpumodel }, + }; + local $KVMArch::updatetable = {}; + my $xml = KVMArch::build_xmldesc('cn1'); + BAIL_OUT("build_xmldesc returned no XML for $guest_arch on $hyp_cpumodel") + unless defined $xml and !ref $xml; + return $xml; +} + +sub os_type_element { + my ($xml) = @_; + my ($attrs) = $xml =~ m{]*)>hvm}s; + return defined $attrs ? $attrs : ''; +} + +# A riscv64 node on an x86_64 hypervisor. The guest architecture is not the host +# architecture, so the domain runs under emulation and states its own machine type. +my $riscv = domain_xml('riscv64', 'x86_64'); +like($riscv, qr/]*\btype="qemu"/, + 'a riscv64 guest on an x86_64 hypervisor is a qemu domain, not kvm'); +like(os_type_element($riscv), qr/\barch="riscv64"/, + 'the domain arch is the arch of the node'); +like(os_type_element($riscv), qr/\bmachine="virt"/, + 'a riscv64 guest uses the virt machine type'); +like($riscv, qr/]*\bfirmware="efi"/, + 'a riscv64 virt guest boots UEFI'); +unlike($riscv, qr/<(?:pae|acpi|apic)\b/, + 'pae, acpi and apic are x86 features and are left out of a riscv64 guest'); +unlike($riscv, qr/]*\btype="kvm"/, 'a POWER guest stays a kvm domain'); +like(os_type_element($power), qr/\barch="ppc64"/, 'ppc64le hypervisors keep arch ppc64'); +like(os_type_element($power), qr/\bmachine="pseries"/, 'ppc64le hypervisors keep machine pseries'); + +# x86_64 on x86_64 is unchanged: libvirt picks the arch and the machine type. +my $x86 = domain_xml('x86_64', 'x86_64'); +like($x86, qr/]*\btype="kvm"/, 'an x86_64 guest stays a kvm domain'); +unlike(os_type_element($x86), qr/\barch=/, 'an x86_64 guest states no arch'); +unlike(os_type_element($x86), qr/\bmachine=/, 'an x86_64 guest states no machine type'); +like($x86, qr/]*\bbus="usb"/, 'an x86_64 guest keeps the USB tablet'); + +# The disks of a riscv64 guest. The virt machine has no IDE controller, so an ide disk or an +# hd* optical drive makes libvirt refuse the domain. +sub disk_struct { + my ($guest_arch) = @_; + local $KVMArch::node = 'cn1'; + local $KVMArch::confdata = { + vm => { cn1 => [ { host => 'hyp1', storage => '/var/lib/libvirt/images/cn1.img' } ] }, + nodetype => { cn1 => [ { arch => $guest_arch } ] }, + hyp1 => { cpumodel => 'x86_64' }, + }; + my $chatter = ''; + my $disks; + { + open(my $capture, '>', \\$chatter) or die "capture stdout: $!"; + local *STDOUT = $capture; + ($disks) = KVMArch::build_diskstruct(undef); + } + return $disks; +} + +my $riscv_disks = disk_struct('riscv64'); +is($riscv_disks->[0]->{target}->{bus}, 'scsi', 'a riscv64 disk is scsi, not ide'); +like($riscv_disks->[0]->{target}->{dev}, qr/^sd/, 'a riscv64 disk is named sd*'); +is($riscv_disks->[1]->{device}, 'cdrom', 'the guest still gets an optical drive'); +like($riscv_disks->[1]->{target}->{dev}, qr/^sd/, 'a riscv64 optical drive is named sd*, not hd*'); + +my $x86_disks = disk_struct('x86_64'); +is($x86_disks->[0]->{target}->{bus}, 'ide', 'an x86_64 disk keeps the ide default'); +like($x86_disks->[1]->{target}->{dev}, qr/^hd/, 'an x86_64 optical drive keeps the hd* name'); + +done_testing(); From 1a5192d0534b960e181aab72cb3925eced240d1c Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:24:48 -0300 Subject: [PATCH 10/37] fix(xcat-core): build-ubunturepo is dead code that still looks buildable builddebs.pl replaced build-ubunturepo, and both CD pipelines prefer it: the fallback to build-ubunturepo fires only for refs that predate builddebs.pl, and such a ref carries its own copy. Nothing on this branch runs the script, so its presence only invites edits that never reach a build. The developer guide said it was kept as a differential oracle until the CD pipelines moved over. They have. Remove the script, and record the removal in the build guide beside the buildcore.sh, makerpm and buildlocal.sh entries. xcat_probe_package_payload.t asserted the Debian staging by matching a `cp -f` line in build-ubunturepo. builddebs.pl stages the helpers through XCAT::BuildUtils::stage_probe_helpers, so the test now calls that function and checks the files it produced. Verified by making stage_probe_helpers skip a helper: the assertion fails. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- build-ubunturepo | 711 ------------------ builddebs.pl | 4 +- docs/source/developers/guides/code/builds.rst | 6 +- github_action_xcat_test.pl | 11 +- xCAT-test/unit/builddebs_lock.t | 4 +- xCAT-test/unit/xcat_probe_package_payload.t | 14 +- 6 files changed, 18 insertions(+), 732 deletions(-) delete mode 100755 build-ubunturepo diff --git a/build-ubunturepo b/build-ubunturepo deleted file mode 100755 index 8a36a17e4..000000000 --- a/build-ubunturepo +++ /dev/null @@ -1,711 +0,0 @@ -#!/bin/bash -# Update GSA Ubuntu Repositories or create a local repository -# -# Author: Leonardo Tonetto (tonetto@linux.vnet.ibm.com) -# Revisor: Arif Ali (aali@ocf.co.uk) -# -# -# Getting Started: -# - Clone the xcat-core git repository under a directory named "xcat-core/src" -# - make sure reprepro is installed on the build machine -# - Run this script from the local git repository you just created. -# ./build-ubunturepo -c BUILDALL=1 - -# Usage: attr=value attr=value ... ./build-ubunturepo { -c | -d } -# PROMOTE=1 - if the attribute "PROMOTE" is specified, means an official dot release. This does not -# actually build xcat, just uploads the most recent snap build to http://xcat.org/files/xcat/ . -# If not specified, a snap build is assumed, which uploads to https://xcat.org/files/xcat/ -# PREGA=1 - use this option with PROMOTE=1 on a branch that already has a released dot release, but this -# build is a GA candidate build, not to be released yet. This will result in the tarball -# being uploaded to http://xcat.org/files/xcat/repos/apt -# (but the tarball file name will be like a released tarball, not a snap build). -# When you are ready to release this build, use PROMOTE=1 without PREGA -# BUILDALL=1 - build all rpms, whether they changed or not. Should be used for snap builds that are in -# prep for a release. -# GPGSIGN=0 - Do not sign the repo in the end of the build. The repo will be signed by default -# -# LOCAL_KEY=1 Use local keys to sign repo instead of WGET from GSA. By default use GSA. -# -# GPG_HOME= - Use the specified directory as GNUPGHOME for signing (no passphrase assumed). -# Bypasses GSA download and LOCAL_KEY. -# -# SETUP=1 Setup environment for build. By default do not setup environment. -# -# LOG= - provide an LOG file option to redirect some output into log file -# -# DEST= - provide a directory to contains the build result -# -# Running builds in parallel on one host: the build lock is scoped to the source checkout (see -# the "build-lock" block below), so two builds from DIFFERENT -# checkouts -- e.g. the devel and stable Ubuntu CD lanes -- run -# concurrently, while two builds of the SAME checkout still fail-fast -# (they build in-place and would corrupt each other). For parallel -# builds give each a separate checkout and a separate output tree -# (a distinct DEST). GPG_HOME may be SHARED between parallel builds -# -- it is used read-only for signing. -# -# For the dependency packages 1. All the xcat dependency deb packages should be uploaded to -# "pokgsa/projects/x/xcat/build/ubuntu/xcat-dep/debs/" on GSA -# 2. run ./build-ubunturepo -d -# -# 3. the built xcat-dep deb packages tarball can be found in "../../xcat-dep" -# related to the path of this script -############################ -printusage() -{ - printf "Usage: %s {-c | -d} \n" $(basename $0) >&2 - echo " -c : Build the xcat-core packages and create the repo" - echo " -d : Create the xcat-dep repo." -} -# For the purpose of getting the distribution name -if [[ ! -f /etc/lsb-release ]]; then - echo "ERROR: Could not find /etc/lsb-release, is this script executed on a Ubuntu machine?" - exit 1 -fi -. /etc/lsb-release - -export HOME=/root - - -# Process cmd line variable assignments, assigning each attr=val pair to a variable of same name -for i in $*; do - echo $i | grep '=' -q - if [ $? != 0 ];then - continue - fi - # upper case the variable name - varstring=`echo "$i"|cut -d '=' -f 1|tr '[a-z]' '[A-Z]'`=`echo "$i"|cut -d '=' -f 2` - export $varstring -done - -#Setup environment so the xcat-deps can be built on a FVT test machine -if [ "$SETUP" = "1" ];then - #Mount GSA - POKGSA="/gsa/pokgsa" - POKGSA2="/gsa/pokgsa-p2" - POKGSAIBM="pokgsa.ibm.com" - if [ ! -d $POKGSA ];then - mkdir -p $POKGSA - mount ${POKGSAIBM}:${POKGSA} ${POKGSA} - fi - if [ ! -d $POKGSA2 ];then - mkdir -p $POKGSA2 - mount ${POKGSAIBM}:${POKGSA2} ${POKGSA2} - fi - - # Verify needed packages installed - REPREPO="reprepro" - DEVSCRIPTS="devscripts" - DEBHELPER="debhelper" - QUILT="quilt" - - apt-get -y install $REPREPO $DEVSCRIPTS $DEBHELPER $QUILT - - echo "Finished setup for xcat-dep build. Rerun this script with SETUP=0 LOCAL_KEY=1 flags" - exit 1 -fi - -# Check the necessary packages before starting the build -declare -a packages=( "reprepro" "devscripts" "debhelper" "libsoap-lite-perl" "libdbi-perl" "quilt" "git") - -for package in ${packages[@]}; do - RC=`dpkg -l | grep $package >> /dev/null 2>&1; echo $?` - if [[ ${RC} != 0 ]]; then - echo "ERROR: Could not find $package, install using 'apt-get install $package' to continue" - exit 1 - fi -done - -# Supported distributions. Set DISTS="jammy noble resolute" to limit local validation builds. -dists="${DISTS:-saucy trusty utopic xenial bionic focal jammy noble resolute}" - -# GPG key used to sign the apt repo (reprepro SignWith). Defaults to the historic -# name. Override with GPG_KEY_ID= (space-free, since it is passed via -# the attr=value parser above), e.g. GPG_KEY_ID=xcat-build@xcat.org -GPG_KEY_ID="${GPG_KEY_ID:-xCAT Automatic Signing Key}" - -c_flag= # xcat-core (trunk-delvel) path -d_flag= # xcat-dep (trunk) path -r_flag= #genesis base rpm package path - -while getopts 'cdr:' OPTION -do - case $OPTION in - c) c_flag=1 - ;; - d) d_flag=1 - ;; - r) r_flag=1 - genesis_rpm_path="$OPTARG" - ;; - ?) printusage - exit 2 - ;; - esac -done -shift $(($OPTIND - 1)) - -if [ -z "$c_flag" -a -z "$d_flag" ];then - printusage - exit 2 -fi - -if [ "$c_flag" -a "$d_flag" ];then - printusage - exit 2 -fi - -if [ -z "$BUILDALL" ]; then - BUILDALL=1 -fi - -# Find where this script is located to set some build variables -old_pwd=`pwd` -cd `dirname $0` -curdir=`pwd` - -# Scope the build lock to THIS checkout. build-ubunturepo builds the packages in-place -# in its own source tree (it rewrites debian/changelog and debian/control, drops -# *.orig.tar.gz at the checkout root and runs dpkg-buildpackage inside the package -# dirs), so the resource two builds actually contend for is the checkout -- not the -# host. The historic single /var/lock/xcatbld.lock was host-global and fail-fast, so -# two builds from *different* checkouts (e.g. the devel and stable Ubuntu CD lanes on -# one build host) collided and the loser failed the pipeline even though they share -# nothing. Key the lock on the checkout path instead: builds of the SAME checkout -# still fail-fast (they would corrupt each other in-place), while builds of DISTINCT -# checkouts get distinct locks and run in parallel. The lock file stays on the local -# /var/lock (reliable flock; the checkout may live on NFS/virtiofs where flock is not) -# and the source tree is left byte-pristine. -# -# NOTE: the two marked regions below are extracted verbatim and exercised by the unit -# test xCAT-test/unit/build_ubunturepo_lock.t (which runs them with a chosen $curdir) -# -- keep the markers, and keep each region self-contained. -# BEGIN build-lock-id -lock_id_for() { printf '%s' "$1" | md5sum | cut -c1-12; } -LOCKFILE="/var/lock/xcatbld-$(lock_id_for "$curdir").lock" -# END build-lock-id -# BEGIN build-lock-acquire -exec 8>"$LOCKFILE" -if ! flock -n 8; then - echo "ERROR: Can't get lock $LOCKFILE for checkout $curdir. Another build is already using this checkout. Exiting...." - exit 1 -fi -# END build-lock-acquire - -# for the git case, query the current branch and set REL (changing master to devel if necessary) -function setbranch { - # Get the current branch name. safe.directory='*' so this still works when the - # build runs as root against a repo owned by another user (otherwise git errors - # with "dubious ownership", returns empty, and REL collapses to an unstable value). - branch=`git -c safe.directory='*' rev-parse --abbrev-ref HEAD 2>/dev/null` - if [ "$branch" = "master" ]; then - REL="devel" - elif [ "$branch" = "HEAD" ] || [ -z "$branch" ]; then - # Special handling when in a 'detached HEAD' state - branch=`git -c safe.directory='*' describe --abbrev=0 HEAD 2>/dev/null` - [[ -n "$branch" ]] && REL=`echo $branch|cut -d. -f 1,2` - else - REL=$branch - fi -} - -WGET_CMD="wget" -if [ ! -z ${LOG} ]; then - WGET_CMD="wget -o ${LOG}" -fi - -if [ "$GPGSIGN" = "0" ];then - echo "GPGSIGN=$GPGSIGN specified, skip gnupg key downloading" -elif [ -n "$GPG_HOME" ];then - echo "GPG_HOME=$GPG_HOME specified, using provided GNUPGHOME" - export GNUPGHOME="$GPG_HOME" -else - #sync the gpg key to the build machine local - gsa_url=http://pokgsa.ibm.com/projects/x/xcat/build/linux - mkdir -p $HOME/.gnupg - for key_name in pubring.gpg secring.gpg trustdb.gpg; do - if [ "$LOCAL_KEY" = "1" ];then - # Keys are already in the local $HOME/.gnupg directory - chmod 600 $HOME/.gnupg/$key_name - else - # Need to download keys from GSA - if [ ! -f $HOME/.gnupg/$key_name ] || [ `wc -c $HOME/.gnupg/$key_name|cut -f 1 -d' '` == 0 ]; then - rm -f $HOME/.gnupg/$key_name - ${WGET_CMD} -P $HOME/.gnupg $gsa_url/keys/$key_name - chmod 600 $HOME/.gnupg/$key_name - fi - fi - done -fi - -REL=xcat-core -if [ "$c_flag" ] -then - setbranch - # Sanitize REL into a stable, filesystem-safe token: replace any character that - # isn't [A-Za-z0-9._-] (e.g. the '/' in a branch like feat/ubuntu-e2e, which would - # otherwise create nested dirs) with '-', and never let it be empty. - REL=${REL//[^A-Za-z0-9._-]/-} - [ -z "$REL" ] && REL="local" - package_dir_name=debs$REL - - #define the dep source code path, core build target path and dep build target path - if [ -z "$DEST" ]; then - local_core_repo_path="$curdir/../../xcat-core" - PKGDIR="../../$package_dir_name" - else - local_core_repo_path="$DEST/$package_dir_name/xcat-core" - PKGDIR="$DEST/$package_dir_name/$package_dir_name" - fi - if [ ! -d "$PKGDIR" ];then - mkdir -p "$PKGDIR" - fi - - echo "#############################################################" - echo "Building xcat-core on branch ($REL) to $local_core_repo_path" - echo "#############################################################" - if [ "$PROMOTE" != 1 ]; then - code_change=0 - update_log='' - - if [ -z "$GITUP" ];then - update_log=../coregitup - echo "git pull > $update_log" - git pull > $update_log - else - update_log=$GITUP - fi - - if ! grep -q 'Already up-to-date' $update_log; then - code_change=1 - fi - ver=`cat Version` - short_ver=`cat Version|cut -d. -f 1,2` - short_short_ver=`cat Version|cut -d. -f 1` - commit_id_long=`git rev-parse HEAD` - commit_id="${commit_id_long:0:7}" - if [ -f Gitepoch ]; then - source_date_epoch=$(cat Gitepoch) - else - source_date_epoch=$(git log -1 --format=%ct HEAD 2>/dev/null || date +%s) - fi - export SOURCE_DATE_EPOCH="$source_date_epoch" - export DEBEMAIL="xcat-build@xcat.org" - export DEBFULLNAME="xCAT Build" - build_time=$(date -d "@$source_date_epoch" --utc '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u) - build_machine=`hostname` - - if [ $code_change == 0 -a "$UP" != 1 -a "$BUILDALL" != 1 ]; then - echo "Nothing new detected. Exiting...." - exit 0 - fi - - echo "###############################" - echo "# Building xcat-core packages #" - echo "###############################" - - #the package type: local | snap | alpha - #the build introduce string - build_string="Snap_Build" - if [ -f Release ]; then - xcat_release=$(cat Release) - else - xcat_release="snap$(date -d "@$source_date_epoch" --utc '+%Y%m%d%H%M')" - fi - pkg_version="${ver}-${xcat_release}" - - packages="xCAT-client xCAT-genesis-scripts perl-xCAT xCAT-server xCAT xCATsn xCAT-test xCAT-buildkit xCAT-vlan xCAT-confluent xCAT-probe" - if [ -n "$PACKAGE" ]; then - match="" - for p in $packages; do - p_low=$(echo "$p" | tr '[A-Z]' '[a-z]') - pkg_low=$(echo "$PACKAGE" | tr '[A-Z]' '[a-z]') - if [ "$p_low" = "$pkg_low" ]; then - match="$p" - break - fi - done - if [ -z "$match" ]; then - echo "ERROR: Package '$PACKAGE' not found. Valid packages: $packages" - exit 1 - fi - packages="$match" - fi - target_archs=(amd64 ppc64el) - for file in $packages - do - file_low=`echo $file | tr '[A-Z]' '[a-z]'` - if [ "$file" = "xCAT" -o "$file" = "xCAT-genesis-scripts" -o "$file" = "xCATsn" ]; then - target_archs="amd64 ppc64el" - else - target_archs="all" - fi - for target_arch in $target_archs - do - tar_orig="${file_low}_${ver}.orig.tar.gz" - if grep -q "3.0 (quilt)" "${file}/debian/source/format" && [ ! -f "$tar_orig" ]; then - tar czf "$tar_orig" --exclude debian -C "$file" . - fi - - if grep -q $file $update_log || [ "$BUILDALL" == 1 -o "$file" = "perl-xCAT" ]; then - rm -f $PKGDIR/${file_low}_*.$target_arch.deb - cd $file - CURDIR=$(pwd) - - find . -name '*.dch' -delete - deterministic_date=$(date -R -d "@$SOURCE_DATE_EPOCH" --utc 2>/dev/null || date -R --utc) - sed -i "1s/(.*)/(${pkg_version})/" debian/changelog - sed -i "s/^ -- .*/ -- $DEBFULLNAME <$DEBEMAIL> $deterministic_date/" debian/changelog - if [ "$target_arch" = "all" ]; then - #xcat probe use some functions shipped by xCAT, for below reasons we need to copy files to xCAT-probe directory - #1 make xcat probe code to be self-contained - #2 don't maintain two files for each script - #3 symbolic link can't work during package - if [ $file_low = "xcat-probe" ]; then - mkdir -p ${CURDIR}/lib/perl/xCAT/ - cp -f ${CURDIR}/../perl-xCAT/xCAT/CommandUtils.pm ${CURDIR}/lib/perl/xCAT/ - cp -f ${CURDIR}/../perl-xCAT/xCAT/NetworkUtils.pm ${CURDIR}/lib/perl/xCAT/ - cp -f ${CURDIR}/../perl-xCAT/xCAT/GlobalDef.pm ${CURDIR}/lib/perl/xCAT/ - cp -f ${CURDIR}/../perl-xCAT/xCAT/ServiceNodeUtils.pm ${CURDIR}/lib/perl/xCAT/ - fi - CURDIR=$(pwd) - cp ${CURDIR}/debian/control ${CURDIR}/debian/control.save.998 - # Magic string used here - sed -i -e "s#>= 2.13-snap000000000000#= ${pkg_version}#g" ${CURDIR}/debian/control - dpkg-buildpackage -rfakeroot -uc -us - mv ${CURDIR}/debian/control.save.998 ${CURDIR}/debian/control - else - if [ "$file" = "xCAT-genesis-scripts" ]; then - echo "Rename control file to build pkg: mv ${CURDIR}/debian/control-${target_arch} ${CURDIR}/debian/control" - cp ${CURDIR}/debian/control-${target_arch} ${CURDIR}/debian/control - elif [ "$file" = "xCAT" ]; then - # shipping bmcsetup and getipmi scripts as part of postscripts - files=("bmcsetup" "getipmi") - for f in "${files[@]}"; do - cp ${CURDIR}/../xCAT-genesis-scripts/usr/bin/$f ${CURDIR}/postscripts/$f - sed -i "s/xcat.genesis.$f/$f/g" ${CURDIR}/postscripts/$f - done - fi - CURDIR=$(pwd) - cp ${CURDIR}/debian/control ${CURDIR}/debian/control.save.998 - # Magic string used here - sed -i -e "s#>= 2.13-snap000000000000#= ${pkg_version}#g" ${CURDIR}/debian/control - dpkg-buildpackage -rfakeroot -uc -us -a$target_arch - mv ${CURDIR}/debian/control.save.998 ${CURDIR}/debian/control - if [ "$file" = "xCAT-genesis-scripts" ]; then - echo "Move control file back: mv ${CURDIR}/debian/control ${CURDIR}/debian/control-${target_arch}" - rm ${CURDIR}/debian/control - elif [ "$file" = "xCAT" ]; then - files=("bmcsetup" "getipmi") - for f in "${files[@]}"; do - rm -f ${CURDIR}/postscripts/$f - done - fi - fi - rc=$? - if [ $rc -gt 0 ]; then - echo "Error: $file build package failed exit code $rc" - exit $rc - fi - cd - - find $file -maxdepth 3 -type d -name "${file_low}*" | grep debian | xargs rm -rf - find $file -maxdepth 3 -type f -name "files" | grep debian | xargs rm -rf - mv ${file_low}* $PKGDIR/ - fi - done - done - - find $PKGDIR/* ! -name '*.deb' | xargs rm -f - fi - - if [ "$PROMOTE" = 1 ]; then - upload_dir="xcat-core" - tar_name="xcat-core-$ver.tar.bz2" - else - upload_dir="core-snap" - tar_name="core-debs-snap.tar.bz2" - fi - - echo "#################################" - echo "# Creating xcat-core repository #" - echo "#################################" - - #clean the repo directory - if [ -e $local_core_repo_path ]; then - rm -rf $local_core_repo_path - fi - mkdir -p $local_core_repo_path - cd $local_core_repo_path - mkdir conf - - for dist in $dists; do - # for all releases moving forward, support amd64 and ppc64el - tmp_out_arch="amd64 ppc64el" - if [ "$dist" = "saucy" ]; then - # for older releases of Ubuntu that does not support ppc64el - tmp_out_arch="amd64" - fi - cat << __EOF__ >> conf/distributions -Origin: xCAT internal repository -Label: xcat-core bazaar repository -Codename: $dist -Architectures: $tmp_out_arch -Components: main -Description: Repository automatically genereted conf -__EOF__ - - if [ "$GPGSIGN" = "0" ];then - #echo "GPGSIGN=$GPGSIGN specified, the repo will not be signed" - echo "" >> conf/distributions - else - keyid=$(gpg --list-keys --keyid-format long "$GPG_KEY_ID" | grep '^pub' | sed -e 's/.*\///' -e 's/ .*//') - echo "SignWith: $keyid" >> conf/distributions - echo "" >> conf/distributions - fi - done - - if [ -n "$GPG_HOME" ]; then - cat << __EOF__ > conf/options -verbose -basedir . -__EOF__ - else - cat << __EOF__ > conf/options -verbose -ask-passphrase -basedir . -__EOF__ - fi - - #import the deb packages into the repo - amd_files=`ls ../$package_dir_name/*.deb | grep -v "ppc64el"` - all_files=`ls ../$package_dir_name/*.deb` - for dist in $dists; do - deb_files=$all_files - if [ "$dist" = "saucy" ]; then - # for older releases of Ubuntu that does not support ppc64el - deb_files=$amd_files - fi - for file in $deb_files; do - reprepro -b ./ includedeb $dist $file; - done - done - #create the mklocalrepo script - cat << '__EOF__' > mklocalrepo.sh -. /etc/lsb-release -cd `dirname $0` -host_arch=`uname -m` -if [ "$host_arch" != "ppc64le" ];then - host_arch="amd64" -else - host_arch="ppc64el" -fi -echo deb [arch=$host_arch] file://"`pwd`" $DISTRIB_CODENAME main > /etc/apt/sources.list.d/xcat-core.list -__EOF__ - - chmod 775 mklocalrepo.sh - - # - # Add a buildinfo file into the tar.bz2 file to track information about the build - # - BUILDINFO=$local_core_repo_path/buildinfo - echo "VERSION=$ver" > $BUILDINFO - echo "RELEASE=$xcat_release" >> $BUILDINFO - echo "BUILD_TIME=$build_time" >> $BUILDINFO - echo "BUILD_MACHINE=$build_machine" >> $BUILDINFO - echo "COMMIT_ID=$commit_id" >> $BUILDINFO - echo "COMMIT_ID_LONG=$commit_id_long" >> $BUILDINFO - - #create the xcat-core.list file - - cd ../ - if ! grep xcat /etc/group ; then - groupadd xcat - fi - - chgrp -R root xcat-core - chmod -R g+w xcat-core - - #build the tar ball - echo "Creating `pwd`/$tar_name ..." - tar -hjcf $tar_name xcat-core - chgrp root $tar_name - chmod g+w $tar_name - - if [ -n "$DEST" ]; then - ln -sf $(basename `pwd`)/$tar_name ../$tar_name - if [ $? != 0 ]; then - echo "ERROR: Failed to make symbol link $DEST/$tar_name" - fi - fi - - if [ ! -e core-snap ]; then - ln -s xcat-core core-snap - fi - - cd $old_pwd - exit 0 -fi - -if [ "$d_flag" ] -then - echo "################################" - echo "# Creating xcat-dep repository #" - echo "################################" - - #the path of ubuntu xcat-dep deb packages on GSA - GSA="/gsa/pokgsa/projects/x/xcat/build/ubuntu/xcat-dep" - if [ ! -d $GSA ]; then - echo "build-ubunturepo: It appears that you do not have GSA to access the xcat-dep pkgs." - exit 1; - fi - - #define the dep source code path, core build target path and dep build target path - if [ -z "$DEST" ]; then - local_dep_repo_path="$curdir/../../xcat-dep/xcat-dep" - else - local_dep_repo_path="$DEST/xcat-dep/xcat-dep" - fi - - # Sync from the GSA master copy of the dep rpms - echo "Creating directory $local_dep_repo_path" - mkdir -p $local_dep_repo_path/ - - echo "Syncing RPMs from $GSA/ to $local_dep_repo_path/../ ..." - rsync -ilrtpu --delete $GSA/ $local_dep_repo_path/../ - if [ $? -ne 0 ]; then - echo "Error from rsync, cannot continue!" - exit 1 - fi - - #clean all old files - if [ -e $local_dep_repo_path ];then - rm -rf $local_dep_repo_path - fi - mkdir -p $local_dep_repo_path - cd $local_dep_repo_path - mkdir conf - - - #create the conf/distributions file - for dist in $dists; do - tmp_out_arch="amd64 ppc64el" - if [ "$dist" = "saucy" ]; then - # for older releases of Ubuntu that does not support ppc64el - tmp_out_arch="amd64" - fi - cat << __EOF__ >> conf/distributions -Origin: xCAT internal repository -Label: xcat-dep bazaar repository -Codename: $dist -Architectures: $tmp_out_arch -Components: main -Description: Repository automatically genereted conf -__EOF__ - - if [ "$GPGSIGN" = "0" ];then - echo "GPGSIGN=$GPGSIGN specified, the repo will not be signed" - echo "" >> conf/distributions - else - keyid=$(gpg --list-keys --keyid-format long "$GPG_KEY_ID" | grep '^pub' | sed -e 's/.*\///' -e 's/ .*//') - echo "SignWith: $keyid" >> conf/distributions - echo "" >> conf/distributions - fi - - done - - - - if [ -n "$GPG_HOME" ]; then - cat << __EOF__ > conf/options -verbose -basedir . -__EOF__ - else - cat << __EOF__ > conf/options -verbose -ask-passphrase -basedir . -__EOF__ - fi - - #import the deb packages into the repo - amd_files=`ls ../debs/*.deb | grep -v "ppc64el"` - all_files=`ls ../debs/*.deb` - for dist in $dists; do - deb_files=$all_files - if [ "$dist" = "saucy" ]; then - # for older releases of Ubuntu that does not support ppc64el - deb_files=$amd_files - fi - for file in $deb_files; do - reprepro -b ./ includedeb $dist $file; - done - done - - cat << '__EOF__' > mklocalrepo.sh -. /etc/lsb-release -cd `dirname $0` -host_arch=`uname -m` -if [ "$host_arch" != "ppc64le" ];then - host_arch="amd64" -else - host_arch="ppc64el" -fi -echo deb [arch=$host_arch] file://"`pwd`" $DISTRIB_CODENAME main > /etc/apt/sources.list.d/xcat-dep.list -__EOF__ - - chmod 775 mklocalrepo.sh - - cd .. - if ! grep xcat /etc/group ; then - groupadd xcat - fi - - chgrp -R root xcat-dep - chmod -R g+w xcat-dep - - #create the tar ball - dep_tar_name=xcat-dep-ubuntu-`date +%Y%m%d%H%M`.tar.bz2 - tar -hjcf $dep_tar_name xcat-dep - chgrp root $dep_tar_name - chmod g+w $dep_tar_name - - - USER="xcat" - SERVER="xcat.org" - FILES_PATH="files" - FRS="/var/www/${SERVER}/${FILES_PATH}" - APT_DIR="${FRS}/xcat" - APT_REPO_DIR="${APT_DIR}/repos/apt/devel" - - # Decide whether to upload the xcat-dep package or NOT (default is to NOT upload xcat-dep - if [ "$UP" != "1" ]; then - echo "Upload not specified, Done! (rerun with UP=1, to upload)" - cd $old_pwd - exit 0 - fi - - #upload the dep packages - i=0 - echo "Uploading debs from xcat-dep to ${APT_REPO_DIR}/xcat-dep/ ..." - while [ $((i+=1)) -le 5 ] && ! rsync -urLv --delete xcat-dep $USER@${SERVER}:${APT_REPO_DIR}/ - do : ; done - - #upload the tarball - i=0 - echo "Uploading $dep_tar_name to ${APT_DIR}/xcat-dep/2.x_Ubuntu/ ..." - while [ $((i+=1)) -le 5 ] && ! rsync -v --force $dep_tar_name $USER@${SERVER}:${APT_DIR}/xcat-dep/2.x_Ubuntu/ - do : ; done - - #upload the README file - cd debs - i=0 - echo "Uploading README to ${APT_DIR}/xcat-dep/2.x_Ubuntu/ ..." - while [ $((i+=1)) -le 5 ] && ! rsync -v --force README $USER@${SERVER}:${APT_DIR}/xcat-dep/2.x_Ubuntu/ - do : ; done - -fi - -cd $old_pwd -exit 0 diff --git a/builddebs.pl b/builddebs.pl index 7e148223e..ea561a367 100755 --- a/builddebs.pl +++ b/builddebs.pl @@ -1,7 +1,7 @@ #!/usr/bin/perl # Build the xcat-core Debian packages and assemble a signed apt repository. # -# Replaces build-ubunturepo. The shape mirrors buildrpms.pl -- Getopt::Long options, +# Builds every xCAT deb and the apt repository. The shape mirrors buildrpms.pl -- Getopt::Long options, # one package list, build then index then sign -- so the two builders read the same way # and share XCAT::BuildUtils. # @@ -385,7 +385,7 @@ carry an architecture, and there the difference is packaging metadata rather tha compiled output. Consequently this builder needs no C and no per-codename chroot. (xcat-dep is different: its packages are compiled, so it builds per codename.) -Replaces C. The GSA upload paths, the C/C release +Replaced C, removed in 2.19. The GSA upload paths, the C/C release flows and the C<-d> xcat-dep repository mode were not carried over: publishing is done by the CD pipeline's own deploy step, and xcat-dep is built from its own repository. diff --git a/docs/source/developers/guides/code/builds.rst b/docs/source/developers/guides/code/builds.rst index 607075ce5..e870a39f0 100644 --- a/docs/source/developers/guides/code/builds.rst +++ b/docs/source/developers/guides/code/builds.rst @@ -36,10 +36,8 @@ emitted, because a source-only run has no binary packages to advertise. ``buildrpms.pl`` replaces all three, and its ``--source-only`` replaces the old ``SRCONLY=1``. - ``build-ubunturepo`` is superseded by ``builddebs.pl`` but is **still in the - tree for now**, as a differential oracle: it is the reference the new builder - is checked against, and it is removed once the CD pipelines have been moved - over. Do not add features to it. + ``build-ubunturepo`` was removed in 2.19. ``builddebs.pl`` replaces it, and the + CD pipelines build every Ubuntu target with it. Debian and Ubuntu packages -------------------------- diff --git a/github_action_xcat_test.pl b/github_action_xcat_test.pl index f4480771b..c345e38c3 100644 --- a/github_action_xcat_test.pl +++ b/github_action_xcat_test.pl @@ -32,13 +32,10 @@ my $GITHUB_API = "https://api.github.com"; # through FindBin, so they can only be run from a source tree. Take a copy # before building and run the unit tests out of the copy. # -# This used to be mandatory rather than tidy: build-ubunturepo set -# local_core_repo_path="$curdir/../../xcat-core" -# which, under the work// layout GitHub checks out into, resolved to -# the checkout's own parent, and it rm -rf'd that path to make room for the apt -# repository -- destroying the tree the tests need. builddebs.pl writes under -# dist/debs INSIDE the checkout and restores every file it edits, so the copy is -# now only isolating the tests from build residue. +# The copy is tidiness, not a requirement: builddebs.pl writes under dist/debs +# inside the checkout and restores every file it edits, so it isolates the tests +# from build residue and nothing more. Its predecessor deleted the checkout's +# parent directory, which is why the copy was added. my $srcdir = getcwd(); my $unitsrc = ($ENV{'RUNNER_TEMP'} ? $ENV{'RUNNER_TEMP'} : "/tmp") . "/xcat-core-unitsrc"; diff --git a/xCAT-test/unit/builddebs_lock.t b/xCAT-test/unit/builddebs_lock.t index 5166dcba9..cba190133 100644 --- a/xCAT-test/unit/builddebs_lock.t +++ b/xCAT-test/unit/builddebs_lock.t @@ -7,9 +7,7 @@ # builds of DIFFERENT checkouts share nothing and must run concurrently. The historic # host-global lock got that backwards and made the devel and stable CD lanes collide. # -# This drives the real lock. The predecessor extracted a marked region out of -# build-ubunturepo with a regex and ran that; now the lock is a function, so it is -# called directly. +# This drives the real lock. The lock is a function, so it is called directly. use strict; use warnings; diff --git a/xCAT-test/unit/xcat_probe_package_payload.t b/xCAT-test/unit/xcat_probe_package_payload.t index 26659bcdf..58ff90990 100644 --- a/xCAT-test/unit/xcat_probe_package_payload.t +++ b/xCAT-test/unit/xcat_probe_package_payload.t @@ -12,7 +12,7 @@ use lib "$FindBin::Bin/../lib"; use lib "$FindBin::Bin/../../build-utils/lib"; use Test::More; -use XCAT::BuildUtils qw(XCAT_PROBE_HELPERS); +use XCAT::BuildUtils qw(XCAT_PROBE_HELPERS stage_probe_helpers); use XCAT::Test::File qw(repo_path slurp_repo_file); my @helpers = qw( @@ -29,7 +29,6 @@ my @affected_subcommands = qw( ); my $builder = slurp_repo_file('buildrpms.pl'); -my $debian_builder = slurp_repo_file('build-ubunturepo'); my $installed_probe_test = slurp_repo_file('xCAT-test/autotest/testcase/probe/xcatproble_list'); my $rpm_spec = slurp_repo_file('xCAT-probe/xCAT-probe.spec'); @@ -67,6 +66,12 @@ like( 'Debian package requires ss or the legacy netstat provider' ); +# The Debian builder stages the helpers by calling stage_probe_helpers, so run it and +# look at what it produced. The predecessor matched a `cp -f` line in build-ubunturepo, +# which passed whenever that text was reformatted and failed whenever it moved. +my $staged_probe_dir = File::Spec->catdir(tempdir(CLEANUP => 1), 'lib', 'perl', 'xCAT'); +stage_probe_helpers(repo_path(File::Spec->catdir('perl-xCAT', 'xCAT')), $staged_probe_dir); + for my $helper (@helpers) { my $source = repo_path(File::Spec->catfile('perl-xCAT', 'xCAT', $helper)); ok(-f $source, "$helper source exists"); @@ -75,9 +80,8 @@ for my $helper (@helpers) { scalar(grep { $_ eq $helper } XCAT_PROBE_HELPERS), "the shared builder helper list carries $helper" ); - like( - $debian_builder, - qr{cp -f [^\n]*/perl-xCAT/xCAT/\Q$helper\E\s+[^\n]*/lib/perl/xCAT/}, + ok( + -f File::Spec->catfile($staged_probe_dir, $helper), "Debian builder stages $helper" ); like( From 0a858b60f4c207640699d5a194e191ed6b0617dd Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:20:47 -0300 Subject: [PATCH 11/37] fix(xcat-core): a pool-backed KVM disk reaches libvirt with no bus A node whose vmstorage is a libvirt storage pool (dir://, nfs:// or lvm://) got a element with no bus attribute. libvirt then chose the controller from the name of the device alone, so the disk of a riscv64 node worked only while its volume was named sd*. build_diskstruct in xCAT-server/lib/xcat/plugins/kvm.pm matched the pool entry, a hash reference, against /^vd/, /^hd/ and /^sd/. A reference in a match is its address as a string, so no branch ran and the bus was never set. The name of the device is in the device field of that entry. The three tests now read that field. The bus each one sets is the bus libvirt gives an hd*, sd* or vd* name, so no domain changes: a riscv64 node keeps the sd* name its volume has, and keeps the scsi controller the riscv64 virt machine provides. libvirt stores the domain built before this change with bus="scsi" on that disk, which is what the domain built after it states. kvm_diskstruct_bus.t drives build_diskstruct in a scratch package, with a stub storage pool in place of the one routine that reaches libvirt, and asserts the bus of an hd*, an sd* and a vd* volume. It also asserts that a riscv64 node keeps the sd* name of its volume. Four of its seven assertions fail without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-server/lib/xcat/plugins/kvm.pm | 11 ++-- xCAT-test/unit/kvm_diskstruct_bus.t | 90 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 xCAT-test/unit/kvm_diskstruct_bus.t diff --git a/xCAT-server/lib/xcat/plugins/kvm.pm b/xCAT-server/lib/xcat/plugins/kvm.pm index 0bae3a15e..27ba53551 100644 --- a/xCAT-server/lib/xcat/plugins/kvm.pm +++ b/xCAT-server/lib/xcat/plugins/kvm.pm @@ -551,13 +551,16 @@ sub build_diskstruct { $tdiskhash->{driver}->{type} = $disks{$_}->{format}; $tdiskhash->{driver}->{cache} = $cachemethod; $tdiskhash->{source}->{file} = $_; - $tdiskhash->{target}->{dev} = $disks{$_}->{device}; + my $device = $disks{$_}->{device}; + $tdiskhash->{target}->{dev} = $device; - if ($disks{$_} =~ /^vd/) { + # libvirt reads the bus out of the device name when the disk states + # none: hd* is ide, sd* is scsi, vd* is virtio. State the same bus. + if ($device =~ /^vd/) { $tdiskhash->{target}->{bus} = 'virtio'; - } elsif ($disks{$_} =~ /^hd/) { + } elsif ($device =~ /^hd/) { $tdiskhash->{target}->{bus} = 'ide'; - } elsif ($disks{$_} =~ /^sd/) { + } elsif ($device =~ /^sd/) { $tdiskhash->{target}->{bus} = 'scsi'; } push @returns, $tdiskhash; diff --git a/xCAT-test/unit/kvm_diskstruct_bus.t b/xCAT-test/unit/kvm_diskstruct_bus.t new file mode 100644 index 000000000..2087c9975 --- /dev/null +++ b/xCAT-test/unit/kvm_diskstruct_bus.t @@ -0,0 +1,90 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin; +use Test::More; + +# The scratch package below declares these; the test names them once each. +no warnings 'once'; + +my $source = "$FindBin::Bin/../../xCAT-server/lib/xcat/plugins/kvm.pm"; +open(my $source_fh, '<', $source) or die "open $source: $!"; +my $content = do { local $/; <$source_fh> }; +close($source_fh) or die "close $source: $!"; + +my @routines; +for my $name (qw(build_diskstruct guest_arch_profile getUnits)) { + my ($routine) = $content =~ /^(sub \Q$name\E\s*\{.*?^\})/ms; + BAIL_OUT("could not extract $name from kvm.pm") unless $routine; + push(@routines, $routine); +} + +# kvm.pm needs a management node to load, so the disk builder runs in a scratch package. +# get_multiple_paths_by_url is the only routine it calls that reaches libvirt; it answers +# from $pool, which holds what a storage pool reports for one node. +my $harness = <<'PERL'; +package KVMDisk; +our ($node, $confdata, $pool); +sub get_multiple_paths_by_url { return $pool; } +PERL + +eval $harness . join("\n", @routines) . "\n1;\n"; ## no critic (BuiltinFunctions::ProhibitStringyEval) +BAIL_OUT("could not load the kvm disk builder: $@") if $@; + +# Build the disks of a node of $arch whose vmstorage is a libvirt pool holding the volumes +# in $pool: a path => { device, format } map, the shape get_multiple_paths_by_url returns. +sub pool_disks { + my ($arch, $pool) = @_; + local $KVMDisk::node = 'cn1'; + local $KVMDisk::confdata = { + vm => { cn1 => [ { + host => 'hyp1', + storage => 'dir:///var/lib/libvirt/images/', + storagecache => 'writeback', + } ] }, + nodetype => { cn1 => [ { arch => $arch } ] }, + hyp1 => { cpumodel => 'x86_64' }, + }; + local $KVMDisk::pool = $pool; + my $chatter = ''; + my $disks; + { + open(my $capture, '>', \$chatter) or die "capture stdout: $!"; + local *STDOUT = $capture; + ($disks) = KVMDisk::build_diskstruct(undef); + } + BAIL_OUT('build_diskstruct returned no disks') unless ref $disks eq 'ARRAY'; + return $disks; +} + +# One volume in the pool, named ... The disk is the first element; +# the optical drive build_diskstruct always appends is the second. +sub pool_disk { + my ($arch, $device) = @_; + my $path = "/var/lib/libvirt/images/cn1.$device.qcow2"; + return pool_disks($arch, { $path => { device => $device, format => 'qcow2' } })->[0]; +} + +# A disk on a libvirt storage pool states the bus of the device name it is given. libvirt +# reads the same names the same way: hd* is ide, sd* is scsi, vd* is virtio. +is(pool_disk('x86_64', 'hda')->{target}->{bus}, 'ide', + 'an hd* disk on a storage pool is ide'); +is(pool_disk('x86_64', 'sda')->{target}->{bus}, 'scsi', + 'an sd* disk on a storage pool is scsi'); +is(pool_disk('x86_64', 'vda')->{target}->{bus}, 'virtio', + 'a vd* disk on a storage pool is virtio'); + +# The device name is the name of the volume in the pool, and stays it. The riscv64 virt +# machine has no IDE controller, so a riscv64 node depends on that name being sd*. +my $riscv = pool_disk('riscv64', 'sda'); +is($riscv->{target}->{dev}, 'sda', 'a riscv64 pool disk keeps the sd* name of its volume'); +is($riscv->{target}->{bus}, 'scsi', 'a riscv64 pool disk is scsi, not ide'); + +my $riscv_all = pool_disks('riscv64', + { '/var/lib/libvirt/images/cn1.sda.qcow2' => { device => 'sda', format => 'qcow2' } }); +is($riscv_all->[1]->{device}, 'cdrom', 'the riscv64 guest still gets an optical drive'); +like($riscv_all->[1]->{target}->{dev}, qr/^sd/, + 'the riscv64 optical drive is named sd*, not hd*'); + +done_testing(); From 506069061b1e94cb3255920348bd08517dea9207 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:20:57 -0300 Subject: [PATCH 12/37] fix(xcat-core): a match made elsewhere can name a KVM volume The name of the volume of a node, and the bus of a file-backed disk, could come from a match made by a routine on the call path. A riscv64 node breaks on it: a leaked value that is neither scsi nor virtio gives the node an hd* volume, and the riscv64 virt machine has no IDE controller for that disk. createstorage and build_diskstruct in xCAT-server/lib/xcat/plugins/kvm.pm read the model of the disk out of the vmstorage value with s/=(.*)//, then read $1. The substitution is allowed to fail, because most vmstorage values state no model, and a failed match leaves $1 as the last successful capture. dohyp gives every node the storage model scsi before mkvm runs, and a captured value takes priority over it, so a leaked value can only replace the default that keeps a riscv64 node on sd*. The leak follows the call path, not the history of the process. Perl restores $1 when the block that set it ends, so a match made in a routine that has returned cannot reach createstorage; only a match still live in an enclosing block can, and a later successful match without a group empties $1 again. A long-running xcatd is not what makes this happen, and looking for one is a wrong turn. Both routines now read $1 only when their own substitution matches. A vmstorage value that states a model, and vmstoragemodel, name the volume as before. The default itself moves into default_storagemodel, which dohyp calls, so a test can hold it. It sat inline with a comment, and changing it to ide left every assertion passing. kvm_createstorage_model.t runs each node twice, once with a capture left live in the calling block, because a case that leaves $1 empty passes against the defect. Five of its eleven assertions fail without this change, and a sixth fails if the default changes. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-server/lib/xcat/plugins/kvm.pm | 28 +++-- xCAT-test/unit/kvm_createstorage_model.t | 124 +++++++++++++++++++++++ 2 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 xCAT-test/unit/kvm_createstorage_model.t diff --git a/xCAT-server/lib/xcat/plugins/kvm.pm b/xCAT-server/lib/xcat/plugins/kvm.pm index 27ba53551..a58c0d825 100644 --- a/xCAT-server/lib/xcat/plugins/kvm.pm +++ b/xCAT-server/lib/xcat/plugins/kvm.pm @@ -513,8 +513,12 @@ sub build_diskstruct { #Setting default values of a virtual disk backed by a file at hd*. my $diskhash; - $disk =~ s/=(.*)//; - my $model = $1; + # A failed substitution leaves $1 as the last successful capture, which can come + # from a match made by a caller. Read $1 only when this substitution matches. + my $model; + if ($disk =~ s/=(.*)//) { + $model = $1; + } unless ($model) { #if not defined, model will stay undefined like above @@ -710,6 +714,15 @@ sub getUnits { } } +# default_storagemodel: the storage model of a node whose vmstoragemodel is empty. +# +# The model names the volume of the node, createstorage builds that name, and libvirt reads +# the bus of the disk out of it. scsi keeps every architecture on sd*, which is the only disk +# controller the riscv64 virt machine has. +sub default_storagemodel { + return 'scsi'; +} + # guest_arch_profile: the libvirt domain type and settings for one guest. # # The architecture of the guest comes from the node, not from the hypervisor. A node whose @@ -1582,8 +1595,12 @@ sub createstorage { if ($mastername and $size) { return 1, "Can not specify both a master to clone and size(s)"; } - $filename =~ s/=(.*)//; - my $model = $1; + # A failed substitution leaves $1 as the last successful capture, which can come from a + # match made by a caller. Read $1 only when this substitution matches. + my $model; + if ($filename =~ s/=(.*)//) { + $model = $1; + } unless ($model) { #if not defined, model will stay undefined like above @@ -4302,8 +4319,7 @@ sub dohyp { foreach $node (sort (keys %{ $hyphash{$hyp}->{nodes} })) { unless ($confdata->{vm}->{$node}->[0]->{storagemodel}) { - # Storage model is not set, default to scsi for all architectures - $confdata->{vm}->{$node}->[0]->{storagemodel} = "scsi"; + $confdata->{vm}->{$node}->[0]->{storagemodel} = default_storagemodel(); } if ($confdata->{$hyp}->{cpu_thread}) { $confdata->{vm}->{$node}->[0]->{cpu_thread} = $confdata->{$hyp}->{cpu_thread}; diff --git a/xCAT-test/unit/kvm_createstorage_model.t b/xCAT-test/unit/kvm_createstorage_model.t new file mode 100644 index 000000000..0ea406132 --- /dev/null +++ b/xCAT-test/unit/kvm_createstorage_model.t @@ -0,0 +1,124 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin; +use Test::More; + +# The scratch package below declares these; the test names them once each. +no warnings 'once'; + +my $source = "$FindBin::Bin/../../xCAT-server/lib/xcat/plugins/kvm.pm"; +open(my $source_fh, '<', $source) or die "open $source: $!"; +my $content = do { local $/; <$source_fh> }; +close($source_fh) or die "close $source: $!"; + +my @routines; +for my $name (qw(createstorage build_diskstruct guest_arch_profile getUnits + default_storagemodel)) { + my ($routine) = $content =~ /^(sub \Q$name\E\s*\{.*?^\})/ms; + BAIL_OUT("could not extract $name from kvm.pm") unless $routine; + push(@routines, $routine); +} + +# kvm.pm needs a management node to load, so createstorage runs in a scratch package. +# get_filepath_by_url is the routine that reaches libvirt; it records the device name it is +# asked for, which is the name createstorage gives the volume of the node. +my $harness = <<'PERL'; +package KVMStore; +our ($node, $confdata, $clonemethod, @asked); +sub getstorageformat { my ($cfginfo) = @_; return $cfginfo->{storageformat}; } +sub get_filepath_by_url { my %args = @_; push(@asked, $args{dev}); return $args{dev}; } +sub oldCreateStorage { push(@asked, 'oldCreateStorage'); } +sub get_multiple_paths_by_url { return {}; } +PERL + +eval $harness . join("\n", @routines) . "\n1;\n"; ## no critic (BuiltinFunctions::ProhibitStringyEval) +BAIL_OUT("could not load the kvm storage routines: $@") if $@; + +# The name createstorage gives the volume of one node. $stale is a capture left live in this +# block by an earlier successful match, which is the state createstorage runs in when a +# routine on the call path matched a pattern that has a group. +sub volume_dev { + my (%args) = @_; + my $storage = $args{storage} // 'dir:///var/lib/libvirt/images/'; + my $cfginfo = { + node => 'cn1', + host => 'hyp1', + storage => $storage, + storagemodel => $args{storagemodel}, + }; + @KVMStore::asked = (); + # The match must run in this block, and nothing may match after it: perl restores $1 when + # the block that set it ends, and any later successful match replaces what it holds. + my $subject = 'left by an earlier match: ' . ($args{stale} // ''); + $subject =~ /match: (.*)/ if defined $args{stale}; + # A match without a group empties $1, which is the clean state the other cases need. + $subject =~ /^left/ unless defined $args{stale}; + KVMStore::createstorage($storage, undef, '30G', $cfginfo, 1); + return $KVMStore::asked[0]; +} + +# dohyp sets storagemodel to scsi for every node it dispatches, whatever the architecture, +# before mkvm reaches createstorage. That default is what names the volume of a node whose +# vmstoragemodel is empty, and a riscv64 node depends on it: the riscv64 virt machine has no +# IDE controller, so its volume must be sd*. +is(volume_dev(storagemodel => 'scsi'), 'sda', + 'the scsi storage model names an sd* volume'); + +# A capture from a match made elsewhere must not name the volume. These are the values a +# routine on the mkvm call path can leave in $1. +is(volume_dev(storagemodel => 'scsi', stale => '/var/lib/libvirt/images/'), 'sda', + 'a path left by an earlier match does not name the volume'); +is(volume_dev(storagemodel => 'scsi', stale => 'virtio'), 'sda', + 'a model name left by an earlier match does not name the volume'); +is(volume_dev(storagemodel => 'virtio', stale => 'scsi'), 'vda', + 'an earlier match does not override vmstoragemodel either'); + +# The model stated on the vmstorage value, and vmstoragemodel, still name the volume. +is(volume_dev(storage => 'dir:///var/lib/libvirt/images/=scsi'), 'sda', + 'a model on the vmstorage value names an sd* volume'); +is(volume_dev(storagemodel => 'virtio'), 'vda', + 'vmstoragemodel=virtio names a vd* volume'); + +# createstorage on its own defaults to ide. Nothing in the product reaches this today, because +# dohyp gives every node the default storage model first. +is(volume_dev(), 'hda', 'createstorage alone defaults to an hd* volume'); + +# So the sd* name of a node with no vmstoragemodel rests on that default, and a riscv64 node +# rests on the sd* name. Drive the two together, so a change to the default fails here rather +# than on a riscv64 node that stops booting. +is(volume_dev(storagemodel => KVMStore::default_storagemodel()), 'sda', + 'the default storage model names an sd* volume'); + +# build_diskstruct reads $1 the same way, for a disk backed by a plain file. The device name +# and the bus of that disk must come from the node, not from a match made elsewhere. +sub file_disk { + my (%args) = @_; + local $KVMStore::node = 'cn1'; + local $KVMStore::confdata = { + vm => { cn1 => [ { host => 'hyp1', storage => '/var/lib/libvirt/images/cn1.img' } ] }, + nodetype => { cn1 => [ { arch => $args{arch} } ] }, + hyp1 => { cpumodel => 'x86_64' }, + }; + my $chatter = ''; + my $disks; + my $subject = 'left by an earlier match: ' . ($args{stale} // ''); + $subject =~ /match: (.*)/ if defined $args{stale}; + $subject =~ /^left/ unless defined $args{stale}; + { + open(my $capture, '>', \$chatter) or die "capture stdout: $!"; + local *STDOUT = $capture; + ($disks) = KVMStore::build_diskstruct(undef); + } + return $disks->[0]; +} + +is(file_disk(arch => 'x86_64')->{target}->{bus}, 'ide', + 'a file-backed disk of an x86_64 node is ide'); +is(file_disk(arch => 'x86_64', stale => 'virtio')->{target}->{bus}, 'ide', + 'a model name left by an earlier match does not choose the bus of a file-backed disk'); +is(file_disk(arch => 'riscv64', stale => 'ide')->{target}->{dev}, 'sda', + 'a riscv64 file-backed disk keeps its sd* name whatever an earlier match left behind'); + +done_testing(); From f7462389b6b3d89329468ff104419e80e91a15ae Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:39:33 -0300 Subject: [PATCH 13/37] fix(xcat-core): xcattest reports only the check where a case stopped A failing case named one failed check and no result at all for the checks after it, although the commands of the case kept running. The first [Failed] line was read as the cause of the failure three times this week, and each time the real fault was a later check: a riscv64 cell reported a makedns check 160 lines before rpower could not start the domain. run_case in xCAT-test/xcattest used one variable, $failflag, for two facts: the result of the case, and the result of the check being reported. Every branch read $failflag to decide whether to print [Pass] or [Failed], so a check that ran after a failed one always read as failed. The guard "last if ($failflag)" at the top of the check loop hid that, and hid every later check with it. The result of a check is now $checkfail, set and read inside one iteration. A continue block carries it into $failflag, which keeps the result of the case. The guard and the per-branch "last" statements are gone, so each check reports what it found. The output ~~ branch no longer clears $failflag on a match, which without the guard would have turned a failed case into a passing one. xCAT-test/unit/xcattest_report_every_check.t runs the harness over a fixture case and asserts on the CHECK lines it writes. Without this change it reports two of four checks, and one of two failed checks. A case whose checks all pass logs the same text before and after: no truncation could happen while $failflag stayed 0. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/xcattest_report_every_check.t | 142 +++++++++++++++++++ xCAT-test/xcattest | 31 ++-- 2 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 xCAT-test/unit/xcattest_report_every_check.t diff --git a/xCAT-test/unit/xcattest_report_every_check.t b/xCAT-test/unit/xcattest_report_every_check.t new file mode 100644 index 000000000..4479de6ab --- /dev/null +++ b/xCAT-test/unit/xcattest_report_every_check.t @@ -0,0 +1,142 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin; +use File::Copy qw(copy); +use File::Path qw(make_path); +use File::Temp qw(tempdir); +use Test::More; + +my $program = "$FindBin::Bin/../xcattest"; +BAIL_OUT("xcattest is not at $program") unless -f $program; + +#--- +=head3 run_harness + + Descriptions: Run xcattest over one fixture case file and return its log lines. + Arguments: + $case_text - the content of the fixture case file + @names - the case names to run + Returns: a reference to the array of log lines, and the failed-cases report lines +=cut + +#--- +sub run_harness { + my ($case_text, @names) = @_; + + # xcattest derives its result directory from the location of the program, so the copy + # under the scratch tree keeps every file the run writes inside that tree. + my $root = tempdir(CLEANUP => 1); + make_path("$root/bin", "$root/cases"); + copy($program, "$root/bin/xcattest") or BAIL_OUT("copy xcattest: $!"); + chmod 0755, "$root/bin/xcattest"; + + open(my $case_fh, '>', "$root/cases/fixture") or BAIL_OUT("write the fixture case: $!"); + print $case_fh $case_text; + close($case_fh) or BAIL_OUT("close the fixture case: $!"); + + local $ENV{XCATTEST_CASEDIR} = "$root/cases"; + system($^X, "$root/bin/xcattest", '-q', '-t', join(',', @names)); + + my $slurp = sub { + my ($path) = @_; + open(my $fh, '<', $path) or BAIL_OUT("open $path: $!"); + my @lines = <$fh>; + close($fh) or BAIL_OUT("close $path: $!"); + chomp(@lines); + return @lines; + }; + + my ($log) = glob("$root/share/xcat/tools/autotest/result/xcattest.log.*"); + BAIL_OUT("the harness wrote no running log under $root") unless $log; + my ($failed) = glob("$root/share/xcat/tools/autotest/result/failedcases.*"); + BAIL_OUT("the harness wrote no failed-cases report under $root") unless $failed; + + return ([ $slurp->($log) ], [ $slurp->($failed) ]); +} + +#--- +=head3 reported_checks + + Descriptions: Select the check results the harness reported. + Arguments: + $lines - a reference to the array of log lines + Returns: a reference to the array of CHECK lines, in the order they were reported +=cut + +#--- +sub reported_checks { + my ($lines) = @_; + return [ grep { /^CHECK:/ } @{$lines} ]; +} + +# The second command fails its first check. The check after it on the same command, and the +# checks of every command after it, describe the same run and must report their own result. +my $mixed = <<'CASE'; +start:mixedchecks +description:a failed check between checks that pass +cmd:echo alpha +check:rc==0 +cmd:echo beta +check:rc!=0 +check:output=~beta +cmd:echo gamma +check:output=~gamma +end +CASE + +my ($log, $failed) = run_harness($mixed, 'mixedchecks'); + +is_deeply(reported_checks($log), + [ "CHECK:rc == 0\t[Pass]", + "CHECK:rc != 0\t[Failed]", + "CHECK:output =~ beta\t[Pass]", + "CHECK:output =~ gamma\t[Pass]" ], + 'every check reports its own result, and a failed check does not silence the checks after it'); + +is_deeply(reported_checks($failed), reported_checks($log), + 'the failed-cases report carries the same check results as the running log'); + +ok(scalar(grep { /^------END::mixedchecks::Failed::/ } @{$log}), + 'a check that passes after a failed check does not make the case pass'); + +# A case that fails more than one check names every one of them. +my $twofails = <<'CASE'; +start:twofailedchecks +description:two commands, each with a check that fails +cmd:echo one +check:rc!=0 +cmd:echo two +check:rc!=0 +end +CASE + +($log, $failed) = run_harness($twofails, 'twofailedchecks'); + +is_deeply(reported_checks($log), + [ "CHECK:rc != 0\t[Failed]", "CHECK:rc != 0\t[Failed]" ], + 'both failed checks are reported, not just the first'); + +# A case where every check passes is unchanged. +my $allpass = <<'CASE'; +start:allcheckspass +description:every check passes +cmd:echo alpha +check:rc==0 +check:output=~alpha +cmd:echo beta +check:output=~beta +end +CASE + +($log, $failed) = run_harness($allpass, 'allcheckspass'); + +is_deeply(reported_checks($log), + [ "CHECK:rc == 0\t[Pass]", "CHECK:output =~ alpha\t[Pass]", "CHECK:output =~ beta\t[Pass]" ], + 'a case whose checks all pass reports every check'); + +ok(scalar(grep { /^------END::allcheckspass::Passed::/ } @{$log}), + 'a case whose checks all pass still reports Passed'); + +done_testing(); diff --git a/xCAT-test/xcattest b/xCAT-test/xcattest index bc89b4532..835788c03 100755 --- a/xCAT-test/xcattest +++ b/xCAT-test/xcattest @@ -1417,8 +1417,12 @@ sub run_case { log_this($running_log_fd, ("ElapsedTime:$diffduration sec", "RETURN rc = $rc", "OUTPUT:", @output)); push(@caselog, ("ElapsedTime:$diffduration sec", "RETURN rc = $rc", "OUTPUT:", @output)); + # $checkfail is the result of this check, $failflag the result of the case. They + # were one variable, so a failed check made every later check read as failed, and + # the guard that hid that also hid the checks (issue #76). + my $checkfail = 0; foreach my $check (@{ $cases_ref->[ $case_name_index_map_ref->{$case} ]->{check}->[$j] }) { - last if ($failflag); + $checkfail = 0; if ($check =~ /rc\s*([=!]+)\s*(\d+)/) { my $lvalue = $rc; @@ -1426,12 +1430,11 @@ sub run_case { my $rvalue = $2; if ((($op eq '!=') && ($lvalue == $rvalue)) || (($op eq '==') && ($lvalue != $rvalue))) { - $failflag = 1; + $checkfail = 1; } - if ($failflag) { + if ($checkfail) { log_this($running_log_fd, "CHECK:rc $op $rvalue\t[Failed]"); push(@caselog, "CHECK:rc $op $rvalue\t[Failed]"); - last; } else { log_this($running_log_fd, "CHECK:rc $op $rvalue\t[Pass]"); push(@caselog, "CHECK:rc $op $rvalue\t[Pass]"); @@ -1446,17 +1449,16 @@ sub run_case { || (($op eq '!~') && ($lvalue =~ /$rvalue/)) || (($op eq '==') && ($lvalue ne $rvalue)) || (($op eq '!=') && ($lvalue eq $rvalue))) { - $failflag = 1; + $checkfail = 1; } elsif (($op ne '=~') && ($op ne '!~') && ($op ne '==') && ($op ne '!=')) { - $failflag = 1; + $checkfail = 1; log_this($running_log_fd, "CHECK:output unrecognized operator: $op\t[Failed]"); push(@caselog, "CHECK:output unrecognized operator: $op\t[Failed]"); - last; + next; } - if ($failflag) { + if ($checkfail) { log_this($running_log_fd, "CHECK:output $op $rvalue\t[Failed]"); push(@caselog, "CHECK:output $op $rvalue\t[Failed]"); - last; } else { log_this($running_log_fd, "CHECK:output $op $rvalue\t[Pass]"); push(@caselog, "CHECK:output $op $rvalue\t[Pass]"); @@ -1464,7 +1466,7 @@ sub run_case { } elsif ($check =~ /output\s*~~\s*(\S.*)/) { my $op = "~~"; - #my $failflag = 1; + # This operator only sets $checkfail to 0, so the check always reports Pass. my $rvalue = $1; $rvalue = getfunc($rvalue); @@ -1481,7 +1483,7 @@ sub run_case { my $min = $num * 0.9; $line =~ /.*:.*: (\d+) /; if ($1 < $max && $1 > $min) { - $failflag = 0; + $checkfail = 0; last; } } else { @@ -1489,19 +1491,20 @@ sub run_case { } } } - if ($failflag) { + if ($checkfail) { log_this($running_log_fd, "CHECK:output $op $rvalue\t[Failed]"); push(@caselog, "CHECK:output $op $rvalue\t[Failed]"); - last; } else { log_this($running_log_fd, "CHECK:output $op $rvalue\t[Pass]"); push(@caselog, "CHECK:output $op $rvalue\t[Pass]"); } } else { - $failflag = 1; + $checkfail = 1; log_this($running_log_fd, "Unrecognized testcase syntax: CHECK:$check\t[Failed]"); push(@caselog, "Unrecognized testcase syntax: CHECK:$check\t[Failed]"); } + } continue { + $failflag = 1 if ($checkfail); } foreach my $cmdcheck (@{ $cases_ref->[ $case_name_index_map_ref->{$case} ]->{cmdcheck}->[$j] }) { if ($cmdcheck) { From ea1cb82a6d79e93a9b4ed6e23158160ffc4eeb91 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:08:41 -0300 Subject: [PATCH 14/37] fix(xcat-core): five check lines the xcattest harness never evaluates Three case files ship a check line the harness cannot use. pscp/cases0 asks for "$$CN: done" with no operator, and load_case drops a check whose content does not start with a word character, so the two lines go without a message and the case asserts less than it reads. rscan/cases0 writes a command as a check, and ngpfb/cases0 compares rc against a pattern, which no operator accepts, so both cases report "Unrecognized testcase syntax" and fail on every run. Each line is repaired to what the case around it says it means. pscp prints ": done" for each node it copied, in xCAT-client/bin/pscp, so the two lines become "output=~$$CN: done". rscan runs its check against the definitions that "rscan -z -w" wrote, so "check:lsdef -l $$CN" becomes a cmd and keeps the two checks that follow it. rmhwconn is asked for output without "state=LINE UP", which is what the lshwconn checks in the same case assert the other way round. xCAT-test/unit/autotest_check_lines_are_understood.t reads every check line under xCAT-test/autotest/testcase and asserts the harness reports one result for each of them, and that none uses an operator it does not know. Without this change it names all five: two lines from ngpfb, one from rscan, and pscp reporting 37 results for the 39 check lines it ships. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/autotest/testcase/ngpfb/cases0 | 4 +- xCAT-test/autotest/testcase/pscp/cases0 | 4 +- xCAT-test/autotest/testcase/rscan/cases0 | 2 +- .../autotest_check_lines_are_understood.t | 105 ++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 xCAT-test/unit/autotest_check_lines_are_understood.t diff --git a/xCAT-test/autotest/testcase/ngpfb/cases0 b/xCAT-test/autotest/testcase/ngpfb/cases0 index e3604488d..4d3c1b8c3 100644 --- a/xCAT-test/autotest/testcase/ngpfb/cases0 +++ b/xCAT-test/autotest/testcase/ngpfb/cases0 @@ -78,7 +78,7 @@ description:for hwconn label:others,hctrl_fsp cmd:rmhwconn $$CN check:rc==0 -check:rc!~(state=LINE UP) +check:output!~(state=LINE UP) cmd:mkhwconn $$CN -t check:rc==0 cmd:sleep 40 @@ -87,7 +87,7 @@ check:rc==0 check:output=~(LINE UP) cmd:rmhwconn blade check:rc==0 -check:rc!~(state=LINE UP) +check:output!~(state=LINE UP) cmd:mkhwconn blade -t check:rc==0 cmd:sleep 50 diff --git a/xCAT-test/autotest/testcase/pscp/cases0 b/xCAT-test/autotest/testcase/pscp/cases0 index e7050691e..ff061794c 100644 --- a/xCAT-test/autotest/testcase/pscp/cases0 +++ b/xCAT-test/autotest/testcase/pscp/cases0 @@ -19,7 +19,7 @@ cmd:echo "test" > /tmp/pscp.tmp check:rc==0 cmd:pscp /tmp/pscp.tmp $$CN:/tmp/ check:rc==0 -check:$$CN: done +check:output=~$$CN: done cmd:xdsh $$CN "ls -l /tmp |grep pscp.tmp" check:rc==0 check:output=~pscp.tmp @@ -41,7 +41,7 @@ cmd:echo "test" > /tmp/pscp/pscp.tmp check:rc==0 cmd:pscp -r /tmp/pscp $$CN:/tmp/ check:rc==0 -check:$$CN: done +check:output=~$$CN: done cmd:xdsh $$CN "ls -l /tmp |grep pscp" check:rc==0 check:output=~pscp diff --git a/xCAT-test/autotest/testcase/rscan/cases0 b/xCAT-test/autotest/testcase/rscan/cases0 index 98f09ae30..aaf6752e2 100644 --- a/xCAT-test/autotest/testcase/rscan/cases0 +++ b/xCAT-test/autotest/testcase/rscan/cases0 @@ -77,7 +77,7 @@ cmd:rmdef $$CN cmd:rscan __GETNODEATTR(testnode,hcp)__ -z -w check:rc==0 check:output=~parent=[\w-]+ -check:lsdef -l $$CN +cmd:lsdef -l $$CN check:rc==0 check:output=~parent=[\w-]+ cmd:rmdef all diff --git a/xCAT-test/unit/autotest_check_lines_are_understood.t b/xCAT-test/unit/autotest_check_lines_are_understood.t new file mode 100644 index 000000000..b589be314 --- /dev/null +++ b/xCAT-test/unit/autotest_check_lines_are_understood.t @@ -0,0 +1,105 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use File::Copy qw(copy); +use File::Find qw(find); +use File::Path qw(make_path); +use File::Temp qw(tempdir); +use FindBin; +use Test::More; + +my $program = "$FindBin::Bin/../xcattest"; +my $casedir = "$FindBin::Bin/../autotest/testcase"; +BAIL_OUT("xcattest is not at $program") unless -f $program; +BAIL_OUT("no test cases under $casedir") unless -d $casedir; + +# A check line xcattest does not understand costs the case the assertion it describes, and the +# case says nothing about it: an unknown operator reports "Unrecognized testcase syntax", and a +# line whose content does not start with a word character is dropped while the case is loaded. +# Read the shipped check lines and let the harness report on them. +my @files; +find({ wanted => sub { push(@files, $File::Find::name) if -f $File::Find::name }, no_chdir => 1 }, $casedir); +BAIL_OUT("no case files under $casedir") unless @files; + +my (%checks, %vars); +for my $file (sort @files) { + open(my $fh, '<', $file) or BAIL_OUT("open $file: $!"); + while (my $line = <$fh>) { + chomp($line); + next unless $line =~ /^check\s*:\s*(\S.*)$/; + my $check = $1; + + # __GETNODEATTR(...)__ and its siblings read the xCAT database, one lsdef for each + # check. The shape of the line is what this test reads, so a fixed value stands in. + $check =~ s/__\w+\([^)]*\)__/placeholder/g; + $vars{$1} = 1 while ($check =~ /\$\$(\w+)/g); + push(@{ $checks{$file} }, $check); + } + close($fh) or BAIL_OUT("close $file: $!"); +} +BAIL_OUT("no check lines under $casedir") unless keys %checks; + +# One case per shipped file, so a check that reports nothing is attributed to its own file. +my %case_of_file = map { $_ => 'syntax_' . do { my $n = $_; $n =~ s{^\Q$casedir\E/?}{}; $n =~ s/[^A-Za-z0-9_-]/_/g; $n } } keys %checks; + +my $fixture = ''; +for my $file (sort keys %checks) { + $fixture .= "start:$case_of_file{$file}\n"; + $fixture .= "cmd:true\n"; + $fixture .= "check:$_\n" for @{ $checks{$file} }; + $fixture .= "end\n"; +} + +# xcattest derives its result directory from the location of the program, so the copy under the +# scratch tree keeps every file the run writes inside that tree. +my $root = tempdir(CLEANUP => 1); +make_path("$root/bin", "$root/cases"); +copy($program, "$root/bin/xcattest") or BAIL_OUT("copy xcattest: $!"); +chmod 0755, "$root/bin/xcattest"; +open(my $fixture_fh, '>', "$root/cases/fixture") or BAIL_OUT("write the fixture case: $!"); +print $fixture_fh $fixture; +close($fixture_fh) or BAIL_OUT("close the fixture case: $!"); + +# Every variable a check line names has to resolve, or xcattest drops the whole case. +# A "local" here would be undone at the end of its own statement, before the run. +$ENV{"XCATTEST_$_"} = 'placeholder' for keys %vars; +$ENV{XCATTEST_CASEDIR} = "$root/cases"; +# Some shipped patterns warn when perl compiles them, and the warnings say nothing about the +# operator. The log file carries what this test reads, so the warnings go to the scratch tree. +open(my $stderr_save, '>&', \*STDERR) or BAIL_OUT("save STDERR: $!"); +open(STDERR, '>', "$root/stderr") or BAIL_OUT("redirect STDERR: $!"); +system($^X, "$root/bin/xcattest", '-q', '-t', join(',', sort values %case_of_file)); +open(STDERR, '>&', $stderr_save) or BAIL_OUT("restore STDERR: $!"); + +my ($logname) = glob("$root/share/xcat/tools/autotest/result/xcattest.log.*"); +BAIL_OUT("the harness wrote no log under $root") unless $logname; +open(my $log_fh, '<', $logname) or BAIL_OUT("open $logname: $!"); +my @log = <$log_fh>; +close($log_fh) or BAIL_OUT("close $logname: $!"); +chomp(@log); + +# Count what the harness reported for each case, and keep the lines it did not understand. +my (%reported, @unrecognized, $current); +for my $line (@log) { + $current = $1 if ($line =~ /^------START::(\S+)::/); + next unless defined $current; + $reported{$current}++ if ($line =~ /^CHECK:/ or $line =~ /^Unrecognized testcase syntax:/); + push(@unrecognized, "$current: $line") if ($line =~ /^Unrecognized testcase syntax:/); + $current = undef if ($line =~ /^------END::/); +} + +is(join("\n", @unrecognized), '', + 'every check line in the shipped cases uses an operator xcattest understands'); + +my @silent; +for my $file (sort keys %checks) { + my $case = $case_of_file{$file}; + my $fed = scalar @{ $checks{$file} }; + my $got = $reported{$case} || 0; + push(@silent, "$file: $fed check lines, $got reported") if ($got != $fed); +} +is(join("\n", @silent), '', + 'every check line in the shipped cases reports a result, so none is dropped while the case loads'); + +done_testing(); From ff20388fed7a15d8a2e94c5c1ca2bf20a8fb48a1 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:33:45 -0300 Subject: [PATCH 15/37] fix(xcat-core): the legacy Genesis image never reaches doxcat, so no node boots a shell A compute node fetches the legacy Genesis kernel and initramfs, the kernel starts, and then nothing else happens: doxcat never runs, the node acquires no address, sshd refuses every connection and the node stays at status=powering-on. The five genesis test cases in xCAT-test/autotest/testcase/genesis have never passed. VersatusHPC/xcat-internal#78. Three holes in the image, each fatal on its own. dracut_105/el/xcat-cmdline.sh ends in `while :; do tmux attach-session -t doxcat || tmux new-session -s doxcat doxcat; done`, and the image carries no locale data, so tmux exits with "need UTF-8 locale" and the loop spins without ever reaching doxcat. module-setup.sh does not install /usr/libexec/openssh/sshd-session, which OpenSSH 9.8 and later exec for every connection and which EL9 now ships. xCAT-genesis-base.spec does not BuildRequire dhcp-client, so dhclient is absent from the build chroot; dracut_install reports the missing binary and returns, and the module install function keeps going, so the image ships without it. xcat-cmdline.sh now resolves xcat_console_mode() once and runs doxcat directly when the terminal multiplexer cannot start a session; the same shape replaces the screen loop on Ubuntu. module-setup.sh installs the OpenSSH session helpers and the C.utf8 locale where they exist. The spec BuildRequires dhcp-client on the releases that package it, and runs the new xCAT-genesis-builder/verify-genesis-payload over the extracted payload, which fails the build when sshd needs a helper the image lacks, when tmux has no UTF-8 locale, or when a binary the caller named is missing. The same runs exposed four defects in the test cases themselves. test.sh defined its synthetic node as ppc64le whatever the management node was, so nodeset could not find a genesis kernel on x86_64. genesistest.pl get_os() matched neither AlmaLinux nor Rocky and reported the OS as unsupported. The -g check read $? instead of check_genesis_file()'s return value, so it could never fail. And testxdsh() met "REMOTE HOST IDENTIFICATION HAS CHANGED" from the second boot on, because Genesis makes new host keys every boot and nothing dropped the stale known_hosts entry. test.sh now derives the node arch from uname and takes the tftp root from TFTPDIR, get_os() recognises the redhat family, report_genesis_files() carries the result to an exit status, and forget_host_keys() runs makeknownhosts -r before each probe. Tests: genesis_console_mode.t drives xcat_console_mode() with the multiplexer shadowed; genesis_payload_verification.t drives the verifier over payload trees carrying each hole; genesis_testcase_helpers.t drives get_os(), check_genesis_file(), report_genesis_files() and testxdsh(); genesis_incorrectmasterip_check.t runs test.sh against a scratch tftp root. Each fails on the parent commit. The verifier also reports all three holes against the released xCAT-genesis-base-x86_64-2.19.0-snap202609021858 payload. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit cb6021eb3cdb3abc75e4dd6704cb42b28074e140) Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- buildrpms.pl | 3 + .../dracut_105/el/module-setup.sh | 16 ++ .../dracut_105/el/xcat-cmdline.sh | 27 +++- .../dracut_105/ubuntu/module-setup.sh | 16 ++ .../dracut_105/ubuntu/xcat-cmdline.sh | 27 +++- xCAT-genesis-builder/verify-genesis-payload | 63 ++++++++ xCAT-genesis-builder/xCAT-genesis-base.spec | 14 ++ .../autotest/testcase/genesis/genesistest.pl | 41 +++++- xCAT-test/autotest/testcase/genesis/test.sh | 14 +- xCAT-test/unit/genesis_console_mode.t | 86 +++++++++++ .../unit/genesis_incorrectmasterip_check.t | 84 +++++++++++ xCAT-test/unit/genesis_payload_verification.t | 87 +++++++++++ xCAT-test/unit/genesis_testcase_helpers.t | 138 ++++++++++++++++++ 13 files changed, 598 insertions(+), 18 deletions(-) create mode 100755 xCAT-genesis-builder/verify-genesis-payload create mode 100644 xCAT-test/unit/genesis_console_mode.t create mode 100644 xCAT-test/unit/genesis_incorrectmasterip_check.t create mode 100644 xCAT-test/unit/genesis_payload_verification.t create mode 100644 xCAT-test/unit/genesis_testcase_helpers.t diff --git a/buildrpms.pl b/buildrpms.pl index 1c8b86990..e14bdd436 100755 --- a/buildrpms.pl +++ b/buildrpms.pl @@ -346,6 +346,9 @@ sub buildsources_genesis_base($) { "Error copying dracut_105 sources"); cp "xCAT-genesis-builder/80-net-name-slot.rules", "$staging_root/80-net-name-slot.rules"; + # %install runs this against the extracted payload before it becomes an rpm. + cp "xCAT-genesis-builder/verify-genesis-payload", + "$staging_root/verify-genesis-payload"; unlink $support_tarball if -f $support_tarball; sh_or_die(qq(tar --sort=name --owner=0 --group=0 --mtime="\@$SOURCE_DATE_EPOCH" -cjf "$support_tarball" -C "$staging_parent" xCAT-genesis-base-build-support), diff --git a/xCAT-genesis-builder/dracut_105/el/module-setup.sh b/xCAT-genesis-builder/dracut_105/el/module-setup.sh index e2bb98250..ca70275c6 100755 --- a/xCAT-genesis-builder/dracut_105/el/module-setup.sh +++ b/xCAT-genesis-builder/dracut_105/el/module-setup.sh @@ -48,6 +48,22 @@ install() { #dracut_install libvirtd /usr/share/libvirt/cpu_map.xml /usr/bin/qemu-img /usr/libexec/qemu-kvm dracut_install mkswap df ifenslave ssh-keygen scp clear dracut_install dhclient lldpad + + # OpenSSH 9.8 moved the per-connection work into sshd-session, which sshd execs by + # absolute path. Without it every connection to Genesis is refused. + for _sshd_helper in \ + /usr/libexec/openssh/sshd-session \ + /usr/libexec/openssh/sshd-auth \ + /usr/lib/openssh/sshd-session \ + /usr/lib/openssh/sshd-auth + do + _dracut_install_opt "$_sshd_helper" + done + + # tmux exits under the C locale, and the image carries no locale data of its own. + for _lc_file in /usr/lib/locale/C.utf8/LC_*; do + _dracut_install_opt "$_lc_file" + done dracut_install /lib64/libnss_dns.so.2 dracut_install poweroff hwclock date /usr/share/terminfo/x/xterm /usr/share/terminfo/s/screen /etc/nsswitch.conf /etc/services dracut_install /sbin/rsyslogd /etc/protocols umount /bin/rpm /usr/lib/rpm/rpmrc diff --git a/xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh b/xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh index d0e6f053f..db9bfa206 100755 --- a/xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh +++ b/xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh @@ -2,6 +2,20 @@ root=1 rootok=1 netroot=xcat + +# The image ships the C.UTF-8 locale only. tmux refuses to start under the C locale. +export LC_ALL=C.UTF-8 + +# tmux exits when the image carries no UTF-8 locale. doxcat is the whole of Genesis, so it +# must run whether or not the multiplexer starts. Prints tmux or direct. +xcat_console_mode() { + if tmux -f /dev/null new-session -d -s xcatprobe true >/dev/null 2>&1; then + tmux kill-session -t xcatprobe >/dev/null 2>&1 + echo tmux + else + echo direct + fi +} clear echo PS1="'"'[xCAT Genesis running on \H \w]\$ '"'" > /.bashrc echo PS1="'"'[xCAT Genesis running on \H \w]\$ '"'" > /.bash_profile @@ -39,10 +53,13 @@ mkdir -p /var/lib/dhclient/ mkdir -p /var/log ip link set lo up echo '127.0.0.1 localhost' >> /etc/hosts -if grep -q console=ttyS /proc/cmdline; then +XCAT_CONSOLE_MODE="$(xcat_console_mode)" +if [ "$XCAT_CONSOLE_MODE" = "tmux" ]; then + if grep -q console=ttyS /proc/cmdline; then while :; do sleep 1; tmux attach-session -t doxcat /dev/tty1; clear &>/dev/tty1 ; done & + fi + while :; do tmux new-session < /dev/tty2 &> /dev/tty2 ; done & fi -while :; do tmux new-session < /dev/tty2 &> /dev/tty2 ; done & # The section below is just for System P LE hardware discovery @@ -87,4 +104,8 @@ elif [[ ${ARCH} =~ x86_64 ]]; then done fi -while :; do tmux attach-session -t doxcat || tmux new-session -s doxcat doxcat; done +if [ "$XCAT_CONSOLE_MODE" = "tmux" ]; then + while :; do tmux attach-session -t doxcat || tmux new-session -s doxcat doxcat; done +else + while :; do doxcat; sleep 5; done +fi diff --git a/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh b/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh index e4b8b0e3d..f2668dd39 100755 --- a/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh +++ b/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh @@ -53,6 +53,22 @@ install() { #dracut_install libvirtd /usr/share/libvirt/cpu_map.xml /usr/bin/qemu-img /usr/libexec/qemu-kvm dracut_install mkswap df ifenslave ssh-keygen scp clear dracut_install dhclient lldpad + + # OpenSSH 9.8 moved the per-connection work into sshd-session, which sshd execs by + # absolute path. Without it every connection to Genesis is refused. + for _sshd_helper in \ + /usr/libexec/openssh/sshd-session \ + /usr/libexec/openssh/sshd-auth \ + /usr/lib/openssh/sshd-session \ + /usr/lib/openssh/sshd-auth + do + _dracut_install_opt "$_sshd_helper" + done + + # tmux exits under the C locale, and the image carries no locale data of its own. + for _lc_file in /usr/lib/locale/C.utf8/LC_*; do + _dracut_install_opt "$_lc_file" + done _dracut_install_opt "/lib/$TRIPLET/libnss_dns.so.2" dracut_install poweroff hwclock date /usr/share/terminfo/x/xterm /usr/share/terminfo/s/screen /etc/nsswitch.conf /etc/services dracut_install /usr/sbin/rsyslogd /etc/protocols umount /usr/bin/dpkg diff --git a/xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh b/xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh index b6e3a0ce5..ea7697b91 100755 --- a/xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh +++ b/xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh @@ -2,6 +2,20 @@ root=1 rootok=1 netroot=xcat + +# The image ships the C.UTF-8 locale only. tmux refuses to start under the C locale. +export LC_ALL=C.UTF-8 + +# screen exits when the image carries no usable terminal. doxcat is the whole of Genesis, so +# it must run whether or not the multiplexer starts. Prints screen or direct. +xcat_console_mode() { + if screen -ln -d -m -S xcatprobe true >/dev/null 2>&1; then + screen -S xcatprobe -X quit >/dev/null 2>&1 + echo screen + else + echo direct + fi +} clear echo PS1="'"'[xCAT Genesis running on \H \w]\$ '"'" > /.bashrc echo PS1="'"'[xCAT Genesis running on \H \w]\$ '"'" > /.bash_profile @@ -39,10 +53,13 @@ mkdir -p /var/lib/dhclient/ mkdir -p /var/log ip link set lo up echo '127.0.0.1 localhost' >> /etc/hosts -if grep -q console=ttyS /proc/cmdline; then +XCAT_CONSOLE_MODE="$(xcat_console_mode)" +if [ "$XCAT_CONSOLE_MODE" = "screen" ]; then + if grep -q console=ttyS /proc/cmdline; then while :; do sleep 1; screen -S console -ln screen -x doxcat /dev/tty1; clear &>/dev/tty1 ; done & + fi + while :; do screen -ln < /dev/tty2 &> /dev/tty2 ; done & fi -while :; do screen -ln < /dev/tty2 &> /dev/tty2 ; done & # The section below is just for System P LE hardware discovery @@ -87,4 +104,8 @@ elif [[ ${ARCH} =~ x86_64 ]]; then done fi -while :; do screen -dr doxcat || screen -S doxcat -L -ln doxcat; done +if [ "$XCAT_CONSOLE_MODE" = "screen" ]; then + while :; do screen -dr doxcat || screen -S doxcat -L -ln doxcat; done +else + while :; do doxcat; sleep 5; done +fi diff --git a/xCAT-genesis-builder/verify-genesis-payload b/xCAT-genesis-builder/verify-genesis-payload new file mode 100755 index 000000000..3a9e0094f --- /dev/null +++ b/xCAT-genesis-builder/verify-genesis-payload @@ -0,0 +1,63 @@ +#!/bin/bash +# +# verify-genesis-payload [required-path ...] +# +# dracut_install() reports a missing binary and returns, so the module install function keeps +# going and the image ships without it. Three such holes reached a release: no dhclient, no +# sshd-session and no UTF-8 locale. Check the extracted payload before it becomes an rpm. +# +# Paths are relative to . The caller adds what only it knows (dhclient is not +# packaged on every release); the rules below come from the payload itself. + +set -u + +payload=${1:-} +if [ -z "$payload" ] || [ ! -d "$payload" ]; then + echo "verify-genesis-payload: not a payload directory: ${payload:-}" >&2 + exit 2 +fi +shift + +missing="" + +# have PATH: true when the payload carries PATH as a file, following the usr-merge symlinks +# the image ships (/sbin -> usr/sbin). +have() { + [ -e "$payload/$1" ] +} + +require() { + local path=$1 why=$2 + have "$path" || missing="$missing + $path ($why)" +} + +for path in "$@"; do + require "$path" "required by the build" +done + +require usr/sbin/sshd "Genesis is reached over ssh" + +# OpenSSH 9.8 split the per-connection work into sshd-session, which sshd execs by absolute +# path. EL9 carries OpenSSH 9.9, so an image with sshd alone refuses every connection. +if have usr/sbin/sshd && grep -qa 'sshd-session' "$payload/usr/sbin/sshd" 2>/dev/null; then + if ! have usr/libexec/openssh/sshd-session && ! have usr/lib/openssh/sshd-session; then + missing="$missing + usr/libexec/openssh/sshd-session (this sshd execs it for every connection)" + fi +fi + +# tmux exits under the C locale. The hook falls back to running doxcat directly, so this is +# not fatal to booting, but a Genesis shell without tmux loses the console attach. +if have usr/bin/tmux && ! have usr/lib/locale/C.utf8/LC_CTYPE; then + missing="$missing + usr/lib/locale/C.utf8/LC_CTYPE (tmux refuses to start without a UTF-8 locale)" +fi + +if [ -n "$missing" ]; then + echo "verify-genesis-payload: $payload is incomplete:$missing" >&2 + exit 1 +fi + +echo "verify-genesis-payload: $payload is complete" +exit 0 diff --git a/xCAT-genesis-builder/xCAT-genesis-base.spec b/xCAT-genesis-builder/xCAT-genesis-base.spec index f2b790e51..a445383a9 100644 --- a/xCAT-genesis-builder/xCAT-genesis-base.spec +++ b/xCAT-genesis-builder/xCAT-genesis-base.spec @@ -53,6 +53,11 @@ BuildRequires: efibootmgr BuildRequires: dosfstools BuildRequires: dracut BuildRequires: dracut-network +# doxcat drives the ISC client with -cf/-pf/-lf. RHEL 10 dropped dhcp-client, so el10 +# genesis has no DHCP client yet. +%if 0%{?rhel} && 0%{?rhel} < 10 +BuildRequires: dhcp-client +%endif BuildRequires: ethtool BuildRequires: gawk BuildRequires: ipmitool @@ -224,6 +229,15 @@ test -n "$KERNEL_IMAGE" test -e "$KERNEL_IMAGE" cp "$KERNEL_IMAGE" "$GENESIS_ROOT/kernel" +# dracut_install reports a missing binary and returns, so a hole in the image reaches the +# rpm silently. Three of them did. +GENESIS_REQUIRED="" +%if 0%{?rhel} && 0%{?rhel} < 10 +GENESIS_REQUIRED="usr/sbin/dhclient" +%endif +bash "%{_builddir}/xCAT-genesis-base-build-support/verify-genesis-payload" \ + "$GENESIS_FS" $GENESIS_REQUIRED + find "$GENESIS_TMPDIR" -type c -delete cp -a "$GENESIS_TMPDIR/%{prefix}/." "$RPM_BUILD_ROOT/%{prefix}/" diff --git a/xCAT-test/autotest/testcase/genesis/genesistest.pl b/xCAT-test/autotest/testcase/genesis/genesistest.pl index 19beef853..f78392161 100755 --- a/xCAT-test/autotest/testcase/genesis/genesistest.pl +++ b/xCAT-test/autotest/testcase/genesis/genesistest.pl @@ -68,13 +68,7 @@ if (!defined($noderange)) { } my $os = &get_os; if ($check_genesis_file) { - send_msg(2, "[$$]:Check if genesis packages are installed on mn..............."); - &check_genesis_file(&get_arch); - if ($?) { - send_msg(0, "genesis packages are not installed"); - } else { - send_msg(2, "genesis packages are installed"); - } + exit 1 if &report_genesis_files(&get_arch); } my $master=`lsdef -t site -i master -c 2>&1 | awk -F'=' '{print \$2}'`; if (!$master) { $master=hostname(); } @@ -148,6 +142,21 @@ if ($clear_env) { send_msg(2, "[$$]:Clear genesis test enviroment success..............."); } ################################## +#report_genesis_files +################################# +sub report_genesis_files { + my ($arch) = @_; + send_msg(2, "[$$]:Check if genesis packages are installed on mn..............."); + # The caller used to test $?, which holds the exit status of the last child process, not + # this return value. A node with no genesis packages therefore reported success. + if (&check_genesis_file($arch)) { + send_msg(0, "genesis packages are not installed"); + return 1; + } + send_msg(2, "genesis packages are installed"); + return 0; +} +################################## #check_genesis_file ################################# sub check_genesis_file { @@ -264,6 +273,17 @@ sub rungenesisimg { ######################################## ####sleep while for xdsh $$CN could work ######################################### +########################################## +####forget the node ssh host keys +########################################## +sub forget_host_keys { + my ($noderange) = @_; + # Genesis makes new host keys on every boot, and each case boots the node several times. + # The stale known_hosts entry then makes ssh refuse the changed key, and xdsh cannot reach + # the Genesis shell. + system("makeknownhosts $noderange -r >/dev/null 2>&1"); + return 0; +} sub testxdsh { my $value = shift; my $checkstring; @@ -285,6 +305,8 @@ sub testxdsh { return 1; } + &forget_host_keys($noderange); + # Check shell prompt on the node to verify it is running Genesis `xdsh $noderange -t 2 "echo \\\$PS1" | grep "Genesis"`; if ($?) { @@ -365,7 +387,10 @@ sub get_os { my $output = `cat /etc/*release* 2>&1`; if ($output =~ /suse/i) { $os = "sles"; - } elsif ($output =~ /Red Hat/i) { + } elsif ($output =~ /Red Hat/i + or $output =~ /\b(?:almalinux|rocky|centos|fedora|oracle\s+linux)\b/i + or $output =~ /^ID_LIKE=.*\brhel\b/mi) { + # AlmaLinux and Rocky release files name neither Red Hat nor themselves as one. $os = "redhat"; } elsif ($output =~ /ubuntu/i) { $os = "ubuntu"; diff --git a/xCAT-test/autotest/testcase/genesis/test.sh b/xCAT-test/autotest/testcase/genesis/test.sh index 4b46ea617..07694ed83 100755 --- a/xCAT-test/autotest/testcase/genesis/test.sh +++ b/xCAT-test/autotest/testcase/genesis/test.sh @@ -16,6 +16,12 @@ function runcmd(){ # We should be using private networks TESTNODE=testnode TESTNODE_IP="192.168.3.1" +# nodeset resolves the genesis kernel by the node arch. A hardcoded ppc64le node fails on +# every other management node with "Could not find genesis.kernel.ppc64". +TESTNODE_ARCH="$(uname -m)" +# The boot-loader configuration lives under the tftp root. Overridable so the check can run +# against a scratch tree. +TFTPDIR="${TFTPDIR:-/tftpboot}" MASTER_PRIVATE_IP="192.168.1.1" MASTER_PRIVATE_NETMASK="255.255.0.0" @@ -23,7 +29,7 @@ MASTER_PRIVATE_NETWORK="192_168_0_0-255_255_0_0" function check_destiny() { - cmd="chdef ${TESTNODE} arch=ppc64le cons=ipmi groups=all ip=${TESTNODE_IP} mac=4e:ee:ee:ee:ee:0e netboot=$NETBOOT tftpserver=$MASTER_PRIVATE_IP xcatmaster=$MASTER_PRIVATE_IP"; + cmd="chdef ${TESTNODE} arch=${TESTNODE_ARCH} cons=ipmi groups=all ip=${TESTNODE_IP} mac=4e:ee:ee:ee:ee:0e netboot=$NETBOOT tftpserver=$MASTER_PRIVATE_IP xcatmaster=$MASTER_PRIVATE_IP"; runcmd $cmd; lsdef ${TESTNODE} @@ -86,11 +92,11 @@ while [ "$#" -ge "0" ]; do "--check" ) NETBOOT=$2; if [[ $NETBOOT =~ petitboot ]];then - SHELLFOLDER="/tftpboot/petitboot"; + SHELLFOLDER="$TFTPDIR/petitboot"; elif [[ $NETBOOT =~ xnba ]];then - SHELLFOLDER="/tftpboot/xcat/xnba/nodes" + SHELLFOLDER="$TFTPDIR/xcat/xnba/nodes" else - SHELLFOLDER="/tftpboot/boot/grub2"; + SHELLFOLDER="$TFTPDIR/boot/grub2"; fi check_destiny ; if [[ $? -eq 1 ]];then diff --git a/xCAT-test/unit/genesis_console_mode.t b/xCAT-test/unit/genesis_console_mode.t new file mode 100644 index 000000000..f43e33b5d --- /dev/null +++ b/xCAT-test/unit/genesis_console_mode.t @@ -0,0 +1,86 @@ +#!/usr/bin/env perl +# Drive xcat_console_mode() out of the Genesis dracut cmdline hook. +# +# The hook cannot be sourced: it mounts filesystems, starts udev and ends in an endless +# loop. Extract the one function and run it with the terminal multiplexer shadowed. +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 %HOOK = ( + el => { path => 'xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh', mux => 'tmux' }, + ubuntu => { path => 'xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh', mux => 'screen' }, +); + +plan tests => 5 * scalar(keys %HOOK) + 2; + +my $tmpdir = tempdir(CLEANUP => 1); + +# The failure this captures: with no UTF-8 locale in the image, tmux exits and the old +# unconditional `while :; do tmux ...; done` never reached doxcat. +my $el = read_text(repo_path($HOOK{el}{path})); +ok($el !~ qr/^while :; do tmux attach-session/m, + 'el: no unguarded tmux loop is left at column 0'); +ok($el =~ qr/^export LC_ALL=C\.UTF-8$/m, + 'el: the hook exports a UTF-8 locale so tmux can start'); + +foreach my $family (sort keys %HOOK) { + my $hook = repo_path($HOOK{$family}{path}); + my $mux = $HOOK{$family}{mux}; + + my $body = extract_function($hook, 'xcat_console_mode', $family); + + is(run_mode($body, $mux, 0), 'direct', + "$family: xcat_console_mode reports direct when $mux cannot start a session"); + is(run_mode($body, $mux, 1), $mux, + "$family: xcat_console_mode reports $mux when $mux can start a session"); + + my $text = read_text($hook); + ok($text =~ qr/^XCAT_CONSOLE_MODE="\$\(xcat_console_mode\)"$/m, + "$family: the hook resolves the console mode once"); + my $guard = qq{if [ "\$XCAT_CONSOLE_MODE" = "$mux" ]; then}; + ok(index($text, $guard) >= 0, + "$family: the doxcat loop is guarded by the console mode"); + ok($text =~ qr/\Qelse\E\n\s+while :; do doxcat; sleep 5; done\n\Qfi\E/, + "$family: doxcat runs directly when $mux is not usable"); +} + +#--- +# extract_function: lift one shell function out of a script that cannot be sourced. +# Bails out when the function stops being extractable, so a rename fails loudly instead of +# leaving the test asserting nothing. +#--- +sub extract_function { + my ($path, $name, $label) = @_; + my $text = read_text($path); + my ($body) = $text =~ /^($name\(\)\s*\{.*?^\})$/ms; + BAIL_OUT("$label: $name() not found in $path") unless defined $body; + return $body; +} + +#--- +# run_mode: run the extracted function with the multiplexer shadowed by a stub that either +# starts a session or refuses, the way tmux refuses without a UTF-8 locale. +#--- +sub run_mode { + my ($body, $mux, $mux_works) = @_; + my $dir = tempdir(DIR => $tmpdir, CLEANUP => 1); + my $bin = "$dir/bin"; + make_path($bin); + write_text("$bin/$mux", $mux_works + ? "#!/bin/sh\nexit 0\n" + : "#!/bin/sh\necho '$mux: need UTF-8 locale (LC_CTYPE) but have ANSI_X3.4-1968' >&2\nexit 1\n"); + chmod 0755, "$bin/$mux"; + write_text("$dir/probe.sh", "$body\nxcat_console_mode\n"); + my $out = `PATH="$bin:\$PATH" /bin/bash "$dir/probe.sh" 2>/dev/null`; + chomp $out; + return $out; +} diff --git a/xCAT-test/unit/genesis_incorrectmasterip_check.t b/xCAT-test/unit/genesis_incorrectmasterip_check.t new file mode 100644 index 000000000..2b80dde47 --- /dev/null +++ b/xCAT-test/unit/genesis_incorrectmasterip_check.t @@ -0,0 +1,84 @@ +#!/usr/bin/env perl +# Run the nodeset_shell_incorrectmasterip check against a scratch tftp root, with the xCAT +# commands and the net tools shadowed. +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 $script = repo_path('xCAT-test/autotest/testcase/genesis/test.sh'); +plan skip_all => 'genesis test.sh not found' unless -f $script; +plan tests => 5; + +my $host_arch = `uname -m`; +chomp $host_arch; + +# The case defined its node as ppc64le whatever the management node was, so nodeset could not +# find a genesis kernel for it on x86_64 and the case could never pass there. +my $run = run_check('xnba', write_boot_file => 1); +is($run->{status}, 0, 'the xnba check passes when nodeset writes the boot file') + or diag($run->{output}); +like($run->{chdef}, qr/\barch=\Q$host_arch\E\b/, + 'the test node is defined with the management node arch'); +ok($host_arch eq 'ppc64le' || $run->{chdef} !~ /\barch=ppc64le\b/, + 'the test node arch is not pinned to ppc64le'); + +# A nodeset that writes nothing must fail the check, not pass it. +my $empty = run_check('xnba', write_boot_file => 0); +isnt($empty->{status}, 0, 'the check fails when nodeset writes no boot file'); + +# grub2 and petitboot read their configuration from other directories under the tftp root. +my $grub = run_check('grub2', write_boot_file => 1); +is($grub->{status}, 0, 'the grub2 check reads the grub2 directory') + or diag($grub->{output}); + +#--- +# run_check: run `test.sh --check ` against a scratch tftp root. test.sh resets PATH, +# so the xCAT commands are shadowed with shell functions, which bash resolves first. The fake +# nodeset writes the boot file the check greps, so the assertion is on the check, not on xCAT. +#--- +sub run_check { + my ($loader, %opt) = @_; + my $root = tempdir(CLEANUP => 1); + my $tftp = "$root/tftpboot"; + make_path("$tftp/xcat/xnba/nodes", "$tftp/boot/grub2", "$tftp/petitboot"); + + my $folder = $loader eq 'xnba' ? "$tftp/xcat/xnba/nodes" + : $loader eq 'petitboot' ? "$tftp/petitboot" + : "$tftp/boot/grub2"; + my $write = $opt{write_boot_file} + ? "printf 'xcatd=192.168.1.1:3001 destiny=shell\\n' > '$folder/testnode'" + : ":"; + + my $driver = "$root/driver.sh"; + write_text($driver, <<"DRIVER"); +chdef() { echo "\$@" >> '$root/chdef.log'; } +lsdef() { + if [ "\$1" = "-t" ] && [ "\$2" = "site" ]; then echo "clustersite: master=192.168.9.9"; return 0; fi + echo "Object name: testnode" +} +ifconfig() { printf 'eth0: flags\\n inet 192.168.9.9\\n\\n'; } +netstat() { printf 'Kernel\\nIface\\neth0\\neth1\\nlo\\n'; } +ip() { return 0; } +makenetworks() { return 0; } +tabdump() { return 0; } +makehosts() { return 0; } +rmdef() { return 0; } +nodeset() { $write; return 0; } +export TFTPDIR='$tftp' +. '$script' --check $loader +DRIVER + + my $out = `/bin/bash "$driver" 2>&1`; + my $status = $? >> 8; + my $chdef = -f "$root/chdef.log" ? read_text("$root/chdef.log") : ''; + return { status => $status, output => $out, chdef => $chdef }; +} + diff --git a/xCAT-test/unit/genesis_payload_verification.t b/xCAT-test/unit/genesis_payload_verification.t new file mode 100644 index 000000000..3ecf80e98 --- /dev/null +++ b/xCAT-test/unit/genesis_payload_verification.t @@ -0,0 +1,87 @@ +#!/usr/bin/env perl +# Drive verify-genesis-payload against payload trees that reproduce the three holes the +# released legacy Genesis image shipped with. +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 $verifier = repo_path('xCAT-genesis-builder/verify-genesis-payload'); +plan skip_all => 'verify-genesis-payload not found' unless -f $verifier; +plan tests => 9; + +my $tmpdir = tempdir(CLEANUP => 1); + +# 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); +my ($rc, $err) = run($good, 'usr/sbin/dhclient'); +is($rc, 0, 'a complete payload passes') or diag($err); + +# doxcat calls dhclient with ISC flags. The released el9 image carried dhclient.conf and +# dhclient-script but no dhclient, so Genesis never acquired an address. +my $nodhcp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 0); +($rc, $err) = run($nodhcp, 'usr/sbin/dhclient'); +isnt($rc, 0, 'a payload without dhclient fails'); +like($err, qr{usr/sbin/dhclient}, 'the missing dhclient is named'); + +# sshd 9.9 execs /usr/libexec/openssh/sshd-session for every connection. +my $nohelper = build_payload(sshd_execs_session => 1, session_helper => 0, tmux => 1, locale => 1, dhclient => 1); +($rc, $err) = run($nohelper, 'usr/sbin/dhclient'); +isnt($rc, 0, 'a payload whose sshd execs sshd-session but does not ship it fails'); +like($err, qr{sshd-session}, 'the missing sshd-session is named'); + +# OpenSSH 8 does not use the helper, so el8 must still pass without it. +my $openssh8 = build_payload(sshd_execs_session => 0, session_helper => 0, tmux => 1, locale => 1, dhclient => 1); +($rc, $err) = run($openssh8, 'usr/sbin/dhclient'); +is($rc, 0, 'an OpenSSH 8 payload passes without sshd-session') or diag($err); + +# tmux without a UTF-8 locale is what stopped doxcat from ever running. +my $nolocale = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 0, dhclient => 1); +($rc, $err) = run($nolocale, 'usr/sbin/dhclient'); +isnt($rc, 0, 'a payload with tmux and no UTF-8 locale fails'); +like($err, qr{C\.utf8}, 'the missing locale is named'); + +($rc, $err) = run("$tmpdir/does-not-exist"); +is($rc >> 0, 2, 'a missing payload directory is a usage error'); + +#--- +# build_payload: make a payload tree with the pieces the verifier reasons about. +#--- +sub build_payload { + my (%opt) = @_; + my $root = tempdir(DIR => $tmpdir, CLEANUP => 1); + make_path("$root/usr/sbin", "$root/usr/bin", "$root/usr/libexec/openssh"); + write_text("$root/usr/sbin/sshd", + $opt{sshd_execs_session} + ? "OpenSSH_9.9p1\n/usr/libexec/openssh/sshd-session\n" + : "OpenSSH_8.0p1\n"); + write_text("$root/usr/libexec/openssh/sshd-session", "helper\n") if $opt{session_helper}; + write_text("$root/usr/bin/tmux", "tmux\n") if $opt{tmux}; + if ($opt{locale}) { + make_path("$root/usr/lib/locale/C.utf8"); + write_text("$root/usr/lib/locale/C.utf8/LC_CTYPE", "ctype\n"); + } + write_text("$root/usr/sbin/dhclient", "dhclient\n") if $opt{dhclient}; + return $root; +} + +#--- +# run: run the verifier and return its exit status and stderr. +#--- +sub run { + my ($root, @required) = @_; + my $errfile = "$tmpdir/err.$$"; + my $cmd = join ' ', map { "'$_'" } ($verifier, $root, @required); + system("/bin/bash $cmd >/dev/null 2>$errfile"); + my $status = $? >> 8; + my $err = -f $errfile ? read_text($errfile) : ''; + unlink $errfile; + return ($status, $err); +} diff --git a/xCAT-test/unit/genesis_testcase_helpers.t b/xCAT-test/unit/genesis_testcase_helpers.t new file mode 100644 index 000000000..de035f331 --- /dev/null +++ b/xCAT-test/unit/genesis_testcase_helpers.t @@ -0,0 +1,138 @@ +#!/usr/bin/env perl +# Drive the genesis test case helpers. genesistest.pl needs a management node, so lift the +# routines out and run them with rpm, dpkg and cat shadowed. +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 $helper = repo_path('xCAT-test/autotest/testcase/genesis/genesistest.pl'); +my $shell = repo_path('xCAT-test/autotest/testcase/genesis/test.sh'); +plan skip_all => 'genesis testcase helpers not found' unless -f $helper && -f $shell; +plan tests => 11; + +my $tmpdir = tempdir(CLEANUP => 1); +my $source = read_text($helper); + +eval_subs($source, qw(get_os get_arch check_genesis_file)); + +# get_os drives every later branch. AlmaLinux and Rocky release files say neither "Red Hat" +# nor "suse" nor "ubuntu", so the management node read as unknown and the check was skipped. +is(os_for("AlmaLinux release 9.8 (Olive Jaguar)\n"), 'redhat', 'AlmaLinux is a redhat family node'); +is(os_for("Rocky Linux release 9.5 (Blue Onyx)\n"), 'redhat', 'Rocky is a redhat family node'); +is(os_for("Red Hat Enterprise Linux release 9.5\n"), 'redhat', 'RHEL is still a redhat family node'); +is(os_for("SUSE Linux Enterprise Server 15 SP6\n"), 'sles', 'SLES is still detected'); +is(os_for("NAME=\"Ubuntu\"\nID=ubuntu\n"), 'ubuntu', 'Ubuntu is still detected'); + +# check_genesis_file answers with a return value. The caller used to read $? instead, so a +# management node with no genesis packages reported success. +{ + no warnings 'once'; + local $GenesisTest::os = 'redhat'; + is(rpm_check("xCAT-genesis-base-x86_64-2.19.0-snap1.noarch\nxCAT-genesis-scripts-x86_64-2.19.0-snap1.noarch\n"), + 0, 'both genesis packages installed reports success'); + is(rpm_check("xCAT-genesis-scripts-x86_64-2.19.0-snap1.noarch\n"), + 1, 'a missing genesis-base reports failure'); + eval_subs($source, qw(report_genesis_files)); + is(report_files("xCAT-genesis-scripts-x86_64-2.19.0-snap1.noarch\n"), + 1, 'report_genesis_files propagates the failure to its caller'); +} + +# Genesis generates new host keys at every boot and each case boots the node several times, so +# the second boot met "REMOTE HOST IDENTIFICATION HAS CHANGED" and xdsh could not reach it. +{ + no warnings 'once'; + eval_subs($source, qw(forget_host_keys testxdsh)); + local $GenesisTest::noderange = 'xcat71-cn'; + my $run = run_testxdsh(3, genesis_prompt => 1, cmdline => 'destiny=shell'); + is($run->{status}, 0, 'testxdsh succeeds when the node answers in the Genesis shell'); + like($run->{makeknownhosts}, qr/\bxcat71-cn\b/, 'the node host keys are forgotten first'); + like($run->{makeknownhosts}, qr/-r/, 'makeknownhosts is asked to remove them'); +} + +#--- +# run_testxdsh: drive testxdsh with makeknownhosts and xdsh shadowed. xdsh is asked twice -- +# once for the prompt, once for the file -- and the stub answers both from its arguments. +#--- +sub run_testxdsh { + my ($value, %opt) = @_; + my $dir = tempdir(DIR => $tmpdir, CLEANUP => 1); + my $log = "$dir/makeknownhosts.log"; + write_text("$dir/makeknownhosts", "#!/bin/sh\necho \"\$@\" >> '$log'\n"); + my $prompt = $opt{genesis_prompt} ? '[xCAT Genesis running on node]' : 'sh-5.1'; + write_text("$dir/xdsh", "#!/bin/sh\nfor a in \"\$@\"; do\n case \"\$a\" in\n */cmdline|/proc/cmdline) printf '%s\\n' '$opt{cmdline}'; exit 0;;\n esac\ndone\nprintf '%s\\n' '$prompt'\n"); + chmod 0755, "$dir/makeknownhosts", "$dir/xdsh"; + local $ENV{PATH} = "$dir:$ENV{PATH}"; + my $status = GenesisTest::testxdsh($value); + return { status => $status, makeknownhosts => (-f $log ? read_text($log) : '') }; +} + +#--- +# eval_subs: lift named subs out of the script and compile them into a scratch package, so +# they can be run without a management node. Bails out when a sub stops being extractable. +#--- +sub eval_subs { + my ($text, @names) = @_; + my $code = "package GenesisTest;\nno strict;\nno warnings;\nour \$os;\nour \$check_genesis_file;\nour \$noderange;\n"; + $code .= "sub send_msg { push \@GenesisTest::MSG, \$_[1]; return 0; }\n"; + foreach my $name (@names) { + my ($body) = $text =~ /^(sub \Q$name\E \{.*?^\})$/ms; + BAIL_OUT("sub $name() not found in $helper") unless defined $body; + $code .= "$body\n"; + } + $code .= "1;\n"; + eval $code or BAIL_OUT("cannot compile the extracted helpers: $@"); +} + +#--- +# os_for: run get_os with `cat` shadowed so it reads the release text under test. +#--- +sub os_for { + my ($release) = @_; + local $ENV{PATH} = stub_bin(cat => "#!/bin/sh\nprintf '%s' " . shell_quote($release)) . ":$ENV{PATH}"; + return GenesisTest::get_os(); +} + +#--- +# rpm_check: run check_genesis_file with `rpm` shadowed so `rpm -qa` lists the given packages. +#--- +sub rpm_check { + my ($installed) = @_; + local $ENV{PATH} = stub_bin(rpm => "#!/bin/sh\nprintf '%s' " . shell_quote($installed)) . ":$ENV{PATH}"; + return GenesisTest::check_genesis_file('x86_64'); +} + +sub report_files { + my ($installed) = @_; + local $ENV{PATH} = stub_bin(rpm => "#!/bin/sh\nprintf '%s' " . shell_quote($installed)) . ":$ENV{PATH}"; + return GenesisTest::report_genesis_files('x86_64'); +} + +#--- +# shell_quote: single-quote a string for /bin/sh. +#--- +sub shell_quote { + my ($v) = @_; + $v =~ s/'/'\\''/g; + return "'$v'"; +} + +#--- +# stub_bin: a directory holding one shadow command, ahead of the real one on PATH. +#--- +sub stub_bin { + my (%cmd) = @_; + my $dir = tempdir(DIR => $tmpdir, CLEANUP => 1); + while (my ($name, $body) = each %cmd) { + write_text("$dir/$name", $body); + chmod 0755, "$dir/$name"; + } + return $dir; +} From afa2d85313974d6edfa2ffd1dfbbb1eebc315ab6 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:15:09 -0300 Subject: [PATCH 16/37] test(xcat-core): capture doxcat naming dhclient on a release that drops it doxcat names dhclient at six call sites. AlmaLinux 10 and EPEL 10 package no ISC dhcp-client, so the legacy Genesis image for el10 carries no dhclient binary. A node that boots that image reports "dhclient: command not found" on its console and never acquires an address. The test lifts the client selection out of doxcat and runs it with the clients shadowed by stubs that record their own argv. doxcat cannot be sourced, so the routines are extracted and driven on their own. It also reads the spec and the dracut module, which decide what client reaches the image. It fails on the current source for nine reasons: doxcat carries no selection routine and no runner, it still starts command lines with dhclient and still chains into dhclient from the secondary NIC loop, the spec build-requires no client on the releases that drop the ISC one and does not check the payload for one, and the dracut module installs dhclient alone. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 5d2801f400978c07f9073c7b920f327e990be25b) --- xCAT-test/unit/genesis_dhcp_client.t | 163 +++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 xCAT-test/unit/genesis_dhcp_client.t diff --git a/xCAT-test/unit/genesis_dhcp_client.t b/xCAT-test/unit/genesis_dhcp_client.t new file mode 100644 index 000000000..e50ccae40 --- /dev/null +++ b/xCAT-test/unit/genesis_dhcp_client.t @@ -0,0 +1,163 @@ +#!/usr/bin/env perl +# Drive the DHCP client selection out of doxcat. +# +# doxcat cannot be sourced: it restarts rsyslogd, reads /proc/cmdline and ends in a loop that +# waits for an address. Extract the two routines and run them with the clients shadowed by +# stubs that record their own argv. +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 $DOXCAT = 'xCAT-genesis-scripts/usr/bin/doxcat'; +my $ISC4 = 'dhclient -cf /etc/dhclient.conf -pf /var/run/dhclient.eth0.pid eth0'; +my $ISC6 = 'dhclient -6 -pf /var/run/dhclient6.eth0.pid eth0 -lf /var/lib/dhclient/dhclient6.leases'; + +my $source = read_text( repo_path($DOXCAT) ); +my $tmpdir = tempdir( CLEANUP => 1 ); + +# The failure this captures: doxcat named dhclient at six call sites, so on a release that +# packages no ISC client Genesis reported "dhclient: command not found" and no node ever got +# an address. +ok( $source !~ qr/^\s*dhclient\s/m, + 'doxcat starts no command line with dhclient' ); +ok( $source !~ qr/;\s*dhclient\s/, + 'doxcat chains no command line into dhclient' ); + +# The build root has to carry a client, or the image installs none. EL8 and EL9 package the +# ISC client; AlmaLinux 10 baseos packages dhcpcd. +my $spec = read_text( repo_path('xCAT-genesis-builder/xCAT-genesis-base.spec') ); +like( $spec, qr/^%if 0%\{\?rhel\} >= 10\nBuildRequires: dhcpcd$/m, + 'the spec build-requires dhcpcd on the releases that drop the ISC client' ); + +# The payload check has to name the client the release ships, or the build passes with no +# client in the image again. +like( $spec, qr{^%if 0%\{\?rhel\} >= 10\nGENESIS_REQUIRED="usr/sbin/dhcpcd"$}m, + 'the payload check requires dhcpcd on the releases that drop the ISC client' ); + +# dracut_install reports a missing binary and returns, so naming dhclient alone shipped an +# image with no client at all. +my $module = read_text( repo_path('xCAT-genesis-builder/dracut_105/el/module-setup.sh') ); +ok( $module !~ qr/^\s*dracut_install dhclient lldpad$/m, + 'the dracut module no longer installs dhclient unconditionally' ); +like( $module, qr/^\s*dracut_install dhcpcd$/m, + 'the dracut module installs dhcpcd when the build root carries it' ); +like( $module, qr{^\s*dracut_install /usr/libexec/dhcpcd-run-hooks$}m, + 'the dracut module installs the hooks dhcpcd runs on every lease' ); + +my $selector = extract_function( $source, 'genesis_dhcp_command' ); +my $runner = extract_function( $source, 'genesis_start_dhcp' ); + +if ( !defined $selector || !defined $runner ) { + fail('doxcat carries genesis_dhcp_command() to choose the client'); + fail('doxcat carries genesis_start_dhcp() to run the chosen client'); + done_testing(); + exit 0; +} + +# EL8 and EL9 package the ISC client, and it stays the one Genesis uses there. +is( selected( 4, ['dhclient'] ), $ISC4, 'the ISC client keeps its IPv4 command line' ); +is( selected( 6, ['dhclient'] ), $ISC6, 'the ISC client keeps its IPv6 command line' ); +is( selected( 4, [ 'dhclient', 'dhcpcd' ] ), $ISC4, + 'the ISC client is preferred when the image carries both' ); + +# RHEL 10 packages no ISC client. AlmaLinux 10 baseos packages dhcpcd, which carries its own +# resolv.conf, hostname and ntp hooks, so it needs no dhclient-script. +is( selected( 4, ['dhcpcd'] ), 'dhcpcd -4 -b -p -t 0 eth0', + 'dhcpcd stands in for dhclient on IPv4' ); +is( selected( 6, ['dhcpcd'] ), 'dhcpcd -6 -b -p -t 0 eth0', + 'dhcpcd stands in for dhclient on IPv6' ); + +# dhcpcd on a single interface exits when its timeout expires, and the default is 30 seconds. +# doxcat waits for the lease for as long as it takes, so the client must not give up first. +like( selected( 4, ['dhcpcd'] ), qr/(?:^|\s)-t 0(?:\s|$)/, + 'dhcpcd is asked to wait for a lease instead of timing out' ); + +# dhcpcd de-configures the interface when it exits unless it is persistent. Genesis keeps the +# address it was given. +like( selected( 4, ['dhcpcd'] ), qr/(?:^|\s)-p(?:\s|$)/, + 'dhcpcd is asked to leave the address in place' ); + +# An image with no client at all has to say so rather than run an empty command line. +is( selected( 4, [] ), '', 'nothing is chosen when the image carries no client' ); + +# The runner is what the call sites use, so it has to actually execute the chosen client. +is( started( 4, ['dhcpcd'] ), 'dhcpcd -4 -b -p -t 0 eth0', + 'genesis_start_dhcp runs dhcpcd when it is the only client' ); +is( started( 4, ['dhclient'] ), $ISC4, + 'genesis_start_dhcp runs the ISC client when it is there' ); +is( started( 4, [] ), '', + 'genesis_start_dhcp runs no client when the image carries none' ); +isnt( start_status( 4, [] ), 0, + 'genesis_start_dhcp reports failure when the image carries no client' ); + +done_testing(); + +#--- +# extract_function: lift one shell function out of a script that cannot be sourced. +# Returns undef when the function is absent, so the caller fails the assertion instead of +# bailing out of a suite that has already found the defect. +#--- +sub extract_function { + my ( $text, $name ) = @_; + my ($body) = $text =~ /^($name\(\)\s*\{.*?^\})$/ms; + return $body; +} + +#--- +# probe: run the extracted routines with only the named clients on PATH. +# Returns the standard output, the recorded argv of whatever ran, and the exit status. +#--- +sub probe { + my ( $call, $clients ) = @_; + my $dir = tempdir( DIR => $tmpdir, CLEANUP => 1 ); + my $bin = "$dir/bin"; + make_path($bin); + + # PATH holds the stubs alone, so each one names itself rather than calling basename. + my $record = "$dir/record"; + foreach my $client ( @{$clients} ) { + write_text( "$bin/$client", + qq{#!/bin/sh\necho "$client \$*" >> "$record"\nexit 0\n} ); + chmod 0755, "$bin/$client"; + } + + # logger writes to the console in the image and is not what these assertions measure. + write_text( "$bin/logger", "#!/bin/sh\nexit 0\n" ); + chmod 0755, "$bin/logger"; + + write_text( "$dir/probe.sh", "log_label=test\n$selector\n$runner\n$call\n" ); + my $out = `PATH="$bin" /bin/bash "$dir/probe.sh" 2>/dev/null`; + my $status = $? >> 8; + chomp $out; + + my $ran = -e $record ? read_text($record) : ''; + chomp $ran; + + return ( $out, $ran, $status ); +} + +sub selected { + my ( $family, $clients ) = @_; + my ( $out, undef, undef ) = probe( qq{genesis_dhcp_command $family eth0}, $clients ); + return $out; +} + +sub started { + my ( $family, $clients ) = @_; + my ( undef, $ran, undef ) = probe( qq{genesis_start_dhcp $family eth0}, $clients ); + return $ran; +} + +sub start_status { + my ( $family, $clients ) = @_; + my ( undef, undef, $status ) = probe( qq{genesis_start_dhcp $family eth0}, $clients ); + return $status; +} From 279207cf0ade1fd999273d6fd7e6d17ab50f40e0 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:17:50 -0300 Subject: [PATCH 17/37] fix(xcat-core): the legacy Genesis image for el10 carries no DHCP client A compute node that boots the legacy Genesis image on el10 x86_64 never acquires an address. Its serial console reports "/usr/bin/doxcat: line 293: dhclient: command not found" and then "It seems to be taking a while to acquire an IPv4 address". The DHCP server side is sound: Kea leases the address and the node never asks for it. AlmaLinux 10 and EPEL 10 package no ISC dhcp-client. dracut_install reports a missing binary and returns, so module-setup.sh named dhclient, the build kept going and the image shipped without a client. doxcat then named dhclient at six call sites with no alternative. doxcat now chooses its client at run time. genesis_dhcp_command() returns the command line for one interface and one address family, and genesis_start_dhcp() runs it and reports when the image carries none. The ISC client keeps its command line where a release packages it, so el8 and el9 are unchanged. Where it is absent, dhcpcd stands in: AlmaLinux 10 baseos packages it at 236 KB, and it carries its own resolv.conf, hostname and ntp hooks, so it needs no dhclient-script. dhcpcd on a single interface exits when its 30 second timeout expires and de-configures the interface as it goes, so it is asked for -t 0 and -p. The dracut module installs whichever client the build root carries, together with the hooks dhcpcd runs on every lease. The spec build-requires dhcpcd from rhel 10 on, and verify-genesis-payload now requires usr/sbin/dhcpcd there, so an image that ships with no client fails the build instead of reaching a node. genesis_dhcp_client.t drives both routines with the clients shadowed by recording stubs. It fails on the parent commit, and deleting the dhcpcd branch turns five of its assertions red. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit b9329998c80383bb0397d16a558f66a6bb9db399) --- .../dracut_105/el/module-setup.sh | 17 +++++- xCAT-genesis-builder/xCAT-genesis-base.spec | 11 +++- xCAT-genesis-scripts/usr/bin/doxcat | 53 ++++++++++++++++--- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/xCAT-genesis-builder/dracut_105/el/module-setup.sh b/xCAT-genesis-builder/dracut_105/el/module-setup.sh index ca70275c6..d410d50f8 100755 --- a/xCAT-genesis-builder/dracut_105/el/module-setup.sh +++ b/xCAT-genesis-builder/dracut_105/el/module-setup.sh @@ -47,7 +47,22 @@ install() { dracut_install mount.nfs sshd vi reboot lspci parted tmux mkfs mkfs.ext4 mkfs.xfs xfs_db #dracut_install libvirtd /usr/share/libvirt/cpu_map.xml /usr/bin/qemu-img /usr/libexec/qemu-kvm dracut_install mkswap df ifenslave ssh-keygen scp clear - dracut_install dhclient lldpad + dracut_install lldpad + + # RHEL 10 packages no ISC dhcp-client. Install whichever client the build root carries; + # doxcat chooses between them at run time. + if command -v dhclient >/dev/null 2>&1; then + dracut_install dhclient + elif command -v dhcpcd >/dev/null 2>&1; then + dracut_install dhcpcd + # dhcpcd runs these on every lease. They write resolv.conf, the hostname and + # ntp.conf, which is the work dhclient-script does for the ISC client. + dracut_install /usr/libexec/dhcpcd-run-hooks + for _dhcpcd_hook in /usr/libexec/dhcpcd-hooks/*; do + _dracut_install_opt "$_dhcpcd_hook" + done + _dracut_install_opt /etc/dhcpcd.conf + fi # OpenSSH 9.8 moved the per-connection work into sshd-session, which sshd execs by # absolute path. Without it every connection to Genesis is refused. diff --git a/xCAT-genesis-builder/xCAT-genesis-base.spec b/xCAT-genesis-builder/xCAT-genesis-base.spec index a445383a9..cd3e737bb 100644 --- a/xCAT-genesis-builder/xCAT-genesis-base.spec +++ b/xCAT-genesis-builder/xCAT-genesis-base.spec @@ -53,11 +53,15 @@ BuildRequires: efibootmgr BuildRequires: dosfstools BuildRequires: dracut BuildRequires: dracut-network -# doxcat drives the ISC client with -cf/-pf/-lf. RHEL 10 dropped dhcp-client, so el10 -# genesis has no DHCP client yet. +# doxcat chooses its DHCP client at run time. RHEL 10 packages no ISC dhcp-client; its +# baseos packages dhcpcd, which carries its own resolv.conf, hostname and ntp hooks and so +# needs no dhclient-script. %if 0%{?rhel} && 0%{?rhel} < 10 BuildRequires: dhcp-client %endif +%if 0%{?rhel} >= 10 +BuildRequires: dhcpcd +%endif BuildRequires: ethtool BuildRequires: gawk BuildRequires: ipmitool @@ -235,6 +239,9 @@ GENESIS_REQUIRED="" %if 0%{?rhel} && 0%{?rhel} < 10 GENESIS_REQUIRED="usr/sbin/dhclient" %endif +%if 0%{?rhel} >= 10 +GENESIS_REQUIRED="usr/sbin/dhcpcd" +%endif bash "%{_builddir}/xCAT-genesis-base-build-support/verify-genesis-payload" \ "$GENESIS_FS" $GENESIS_REQUIRED diff --git a/xCAT-genesis-scripts/usr/bin/doxcat b/xCAT-genesis-scripts/usr/bin/doxcat index 53e83fea3..d0ce643b3 100755 --- a/xCAT-genesis-scripts/usr/bin/doxcat +++ b/xCAT-genesis-scripts/usr/bin/doxcat @@ -205,6 +205,47 @@ secondary_nic_needs_dhcp() { return 0 } +# RHEL 10 packages no ISC dhcp-client, so the image carries whichever client its release +# ships. Print the command line for one interface and one address family, or nothing when +# the image carries no client at all. +genesis_dhcp_command() { + local family=$1 + local nic=$2 + + if command -v dhclient >/dev/null 2>&1; then + if [ "$family" = 6 ]; then + echo "dhclient -6 -pf /var/run/dhclient6.$nic.pid $nic -lf /var/lib/dhclient/dhclient6.leases" + else + echo "dhclient -cf /etc/dhclient.conf -pf /var/run/dhclient.$nic.pid $nic" + fi + return 0 + fi + + if command -v dhcpcd >/dev/null 2>&1; then + # dhcpcd carries its own resolv.conf, hostname and ntp hooks, so it does not need + # dhclient-script. On a single interface it exits when its timeout expires, and it + # de-configures the interface as it goes; -t 0 and -p turn both off. + echo "dhcpcd -$family -b -p -t 0 $nic" + return 0 + fi + + return 0 +} + +# Run the client genesis_dhcp_command chose. The caller backgrounds this. +genesis_start_dhcp() { + local family=$1 + local nic=$2 + local command + + command=$(genesis_dhcp_command "$family" "$nic") + if [ -z "$command" ]; then + logger -s -t $log_label -p local4.err "The image carries no DHCP client, so $nic gets no IPv$family address." + return 1 + fi + $command +} + # see if they specified static ip info, otherwise use dhcp XCATPORT=3001 for parm in `cat /proc/cmdline`; do @@ -253,8 +294,8 @@ else while [ $tries -lt 100 ]; do ALLUP_NICS=`ip link show | grep -v "^ " | grep "state UP" | awk '{print $2}' | sed -e 's/:$//'|grep -v lo | sort -n -r` for tmp1 in $ALLUP_NICS; do - dhclient -cf /etc/dhclient.conf -pf /var/run/dhclient.$tmp1.pid $tmp1 & - dhclient -6 -pf /var/run/dhclient6.$tmp1.pid $tmp1 -lf /var/lib/dhclient/dhclient6.leases & + genesis_start_dhcp 4 "$tmp1" & + genesis_start_dhcp 6 "$tmp1" & #bootnic=$tmp1 #break done @@ -290,11 +331,11 @@ else /bin/bash fi else - dhclient -cf /etc/dhclient.conf -pf /var/run/dhclient.$bootnic.pid $bootnic & + genesis_start_dhcp 4 "$bootnic" & #we'll kick of IPv6 and IPv4 on all nics, but not wait for them to come up unless doing discovery, to reduce #chances that we'll perform a partial discovery #in other scenarios where downed non-bootnics cause issues, will rely on retries to fix things up - dhclient -6 -pf /var/run/dhclient6.$bootnic.pid $bootnic -lf /var/lib/dhclient/dhclient6.leases & + genesis_start_dhcp 6 "$bootnic" & NICCANDIDATES=`ip link|grep mtu|grep -v LOOPBACK|grep -v $bootnic|grep -v usb|awk -F: '{print $2}'` TSMNIC=$(cat /tmp/tsmhostnic 2>/dev/null) NICSTOBRINGUP= @@ -305,8 +346,8 @@ else done export NICSTOBRINGUP for nic in $NICSTOBRINGUP; do - (while ! ethtool $nic | grep Link\ detected|grep yes > /dev/null && [ ! -f /tmp/netinitted ]; do sleep 5; done; dhclient -cf /etc/dhclient.conf -pf /var/run/dhclient.$nic.pid $nic ) & - (while ! ethtool $nic | grep Link\ detected|grep yes > /dev/null && [ ! -f /tmp/netinitted ]; do sleep 5; done; dhclient -cf /etc/dhclient.conf -6 -pf /var/run/dhclient6.$nic.pid -lf /var/lib/dhclient/dhclient6.leases $nic ) & + (while ! ethtool $nic | grep Link\ detected|grep yes > /dev/null && [ ! -f /tmp/netinitted ]; do sleep 5; done; genesis_start_dhcp 4 "$nic" ) & + (while ! ethtool $nic | grep Link\ detected|grep yes > /dev/null && [ ! -f /tmp/netinitted ]; do sleep 5; done; genesis_start_dhcp 6 "$nic" ) & done gripeiter=101 From 4dd16eb5f19f079e36aa3f704108d2787a4b52ba Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:07:12 -0300 Subject: [PATCH 18/37] test(xcat-core): capture the genesis harness passing on a failed nodeset The nodeset_shell_incorrectmasterip case ran "nodeset testnode shell", the command failed with "/tftpboot/boot/grub2/grub2.x86_64 does not exits", and the case still passed. check_destiny in xCAT-test/autotest/testcase/genesis/test.sh discards the return value of runcmd and greps the boot configuration file, which grub2.pm writes before it stops on the missing boot loader. The sub-case asserts nothing. wait_for_boot in xCAT-test/autotest/testcase/genesis/genesistest.pl waits for nodelist.status "booted". A Genesis node reports its destiny with getdestiny and xcatd writes "shell", "configuring" or "booting" from it, never "booted". Every caller discards the return value, so each case rests on its xdsh probes alone. genesis_incorrectmasterip_check.t now runs the check with a nodeset that fails, and reads whether the grub2 boot loader for the node arch is present when nodeset runs. genesis_testcase_helpers.t drives the status wait with lsdef shadowed, and drives the shell case with every command it runs shadowed. genesis_payload_verification.t reads a payload without mktemp, which getdestiny needs to make its request file. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 3597760645ddf38f110457934982e2cc9a113f84) --- .../unit/genesis_incorrectmasterip_check.t | 34 ++++++++- xCAT-test/unit/genesis_payload_verification.t | 20 +++-- xCAT-test/unit/genesis_testcase_helpers.t | 76 ++++++++++++++++++- 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/xCAT-test/unit/genesis_incorrectmasterip_check.t b/xCAT-test/unit/genesis_incorrectmasterip_check.t index 2b80dde47..a32b1a23f 100644 --- a/xCAT-test/unit/genesis_incorrectmasterip_check.t +++ b/xCAT-test/unit/genesis_incorrectmasterip_check.t @@ -15,11 +15,14 @@ use XCAT::Test::File qw(repo_path); my $script = repo_path('xCAT-test/autotest/testcase/genesis/test.sh'); plan skip_all => 'genesis test.sh not found' unless -f $script; -plan tests => 5; +plan tests => 9; my $host_arch = `uname -m`; chomp $host_arch; +# grub2.pm names the boot loader grub2., with every ppc64 flavour written as "ppc". +my $loader_name = $host_arch =~ /^ppc64/ ? 'ppc' : $host_arch; + # The case defined its node as ppc64le whatever the management node was, so nodeset could not # find a genesis kernel for it on x86_64 and the case could never pass there. my $run = run_check('xnba', write_boot_file => 1); @@ -39,6 +42,20 @@ my $grub = run_check('grub2', write_boot_file => 1); is($grub->{status}, 0, 'the grub2 check reads the grub2 directory') or diag($grub->{output}); +# grub2.pm writes the boot configuration and only then stops on a missing boot loader. The +# check read the file that failed nodeset had already written, so it passed on the debris. +my $refused = run_check('grub2', write_boot_file => 1, nodeset_status => 1); +isnt($refused->{status}, 0, 'a nodeset that fails makes the check fail'); + +my $refused_xnba = run_check('xnba', write_boot_file => 1, nodeset_status => 1); +isnt($refused_xnba->{status}, 0, 'a nodeset that fails makes the xnba check fail too'); + +# xCAT builds no x86_64 or aarch64 grub2 network boot loader, so grub2.pm stops before it +# configures anything. The check stages one for the node arch and removes it after. +is($grub->{loader_at_nodeset}, "yes\n", + 'the grub2 boot loader for the node arch is in place when nodeset runs'); +ok(!$grub->{loader_left}, 'the staged boot loader is removed again'); + #--- # run_check: run `test.sh --check ` against a scratch tftp root. test.sh resets PATH, # so the xCAT commands are shadowed with shell functions, which bash resolves first. The fake @@ -49,6 +66,7 @@ sub run_check { my $root = tempdir(CLEANUP => 1); my $tftp = "$root/tftpboot"; make_path("$tftp/xcat/xnba/nodes", "$tftp/boot/grub2", "$tftp/petitboot"); + my $boot_loader = "$tftp/boot/grub2/grub2.$loader_name"; my $folder = $loader eq 'xnba' ? "$tftp/xcat/xnba/nodes" : $loader eq 'petitboot' ? "$tftp/petitboot" @@ -71,7 +89,11 @@ makenetworks() { return 0; } tabdump() { return 0; } makehosts() { return 0; } rmdef() { return 0; } -nodeset() { $write; return 0; } +nodeset() { + if [ -e '$boot_loader' ]; then echo yes > '$root/loader.at.nodeset'; else echo no > '$root/loader.at.nodeset'; fi + $write + return @{[ $opt{nodeset_status} || 0 ]}; +} export TFTPDIR='$tftp' . '$script' --check $loader DRIVER @@ -79,6 +101,12 @@ DRIVER my $out = `/bin/bash "$driver" 2>&1`; my $status = $? >> 8; my $chdef = -f "$root/chdef.log" ? read_text("$root/chdef.log") : ''; - return { status => $status, output => $out, chdef => $chdef }; + return { + status => $status, + output => $out, + chdef => $chdef, + loader_at_nodeset => (-f "$root/loader.at.nodeset" ? read_text("$root/loader.at.nodeset") : ''), + loader_left => (-e $boot_loader ? 1 : 0), + }; } diff --git a/xCAT-test/unit/genesis_payload_verification.t b/xCAT-test/unit/genesis_payload_verification.t index 3ecf80e98..3e1d7bffa 100644 --- a/xCAT-test/unit/genesis_payload_verification.t +++ b/xCAT-test/unit/genesis_payload_verification.t @@ -15,39 +15,46 @@ 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 => 9; +plan tests => 11; my $tmpdir = tempdir(CLEANUP => 1); # 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); +my $good = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 1, mktemp => 1); my ($rc, $err) = run($good, 'usr/sbin/dhclient'); is($rc, 0, 'a complete payload passes') or diag($err); # doxcat calls dhclient with ISC flags. The released el9 image carried dhclient.conf and # dhclient-script but no dhclient, so Genesis never acquired an address. -my $nodhcp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 0); +my $nodhcp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 0, mktemp => 1); ($rc, $err) = run($nodhcp, 'usr/sbin/dhclient'); isnt($rc, 0, 'a payload without dhclient fails'); like($err, qr{usr/sbin/dhclient}, 'the missing dhclient is named'); # sshd 9.9 execs /usr/libexec/openssh/sshd-session for every connection. -my $nohelper = build_payload(sshd_execs_session => 1, session_helper => 0, tmux => 1, locale => 1, dhclient => 1); +my $nohelper = build_payload(sshd_execs_session => 1, session_helper => 0, tmux => 1, locale => 1, dhclient => 1, mktemp => 1); ($rc, $err) = run($nohelper, 'usr/sbin/dhclient'); isnt($rc, 0, 'a payload whose sshd execs sshd-session but does not ship it fails'); like($err, qr{sshd-session}, 'the missing sshd-session is named'); # OpenSSH 8 does not use the helper, so el8 must still pass without it. -my $openssh8 = build_payload(sshd_execs_session => 0, session_helper => 0, tmux => 1, locale => 1, dhclient => 1); +my $openssh8 = build_payload(sshd_execs_session => 0, session_helper => 0, tmux => 1, locale => 1, dhclient => 1, mktemp => 1); ($rc, $err) = run($openssh8, 'usr/sbin/dhclient'); is($rc, 0, 'an OpenSSH 8 payload passes without sshd-session') or diag($err); # tmux without a UTF-8 locale is what stopped doxcat from ever running. -my $nolocale = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 0, dhclient => 1); +my $nolocale = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 0, dhclient => 1, mktemp => 1); ($rc, $err) = run($nolocale, 'usr/sbin/dhclient'); isnt($rc, 0, 'a payload with tmux and no UTF-8 locale fails'); like($err, qr{C\.utf8}, 'the missing locale is named'); +# getdestiny makes its request file with mktemp. Without it the node never reports its destiny, +# so xcatd never sets nodelist.status and the node stays at powering-on. +my $nomktemp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 1, mktemp => 0); +($rc, $err) = run($nomktemp, 'usr/sbin/dhclient'); +isnt($rc, 0, 'a payload without mktemp fails'); +like($err, qr{usr/bin/mktemp}, 'the missing mktemp is named'); + ($rc, $err) = run("$tmpdir/does-not-exist"); is($rc >> 0, 2, 'a missing payload directory is a usage error'); @@ -69,6 +76,7 @@ sub build_payload { write_text("$root/usr/lib/locale/C.utf8/LC_CTYPE", "ctype\n"); } write_text("$root/usr/sbin/dhclient", "dhclient\n") if $opt{dhclient}; + write_text("$root/usr/bin/mktemp", "mktemp\n") if $opt{mktemp}; return $root; } diff --git a/xCAT-test/unit/genesis_testcase_helpers.t b/xCAT-test/unit/genesis_testcase_helpers.t index de035f331..b07c51594 100644 --- a/xCAT-test/unit/genesis_testcase_helpers.t +++ b/xCAT-test/unit/genesis_testcase_helpers.t @@ -16,13 +16,20 @@ use XCAT::Test::File qw(repo_path); my $helper = repo_path('xCAT-test/autotest/testcase/genesis/genesistest.pl'); my $shell = repo_path('xCAT-test/autotest/testcase/genesis/test.sh'); plan skip_all => 'genesis testcase helpers not found' unless -f $helper && -f $shell; -plan tests => 11; +plan tests => 16; my $tmpdir = tempdir(CLEANUP => 1); my $source = read_text($helper); eval_subs($source, qw(get_os get_arch check_genesis_file)); +# The destiny status check used to be wait_for_boot(), which waited for "booted" and ignored +# its argument. Take whichever name the script carries, so this test fails on the status the +# check waits for and not on a missing subroutine. +my $waiter_name = waiter_name($source); +eval_subs($source, $waiter_name); +my $waiter = \&{"GenesisTest::$waiter_name"}; + # get_os drives every later branch. AlmaLinux and Rocky release files say neither "Red Hat" # nor "suse" nor "ubuntu", so the management node read as unknown and the check was skipped. is(os_for("AlmaLinux release 9.8 (Olive Jaguar)\n"), 'redhat', 'AlmaLinux is a redhat family node'); @@ -57,6 +64,60 @@ is(os_for("NAME=\"Ubuntu\"\nID=ubuntu\n"), 'ubuntu', 'Ubuntu is still like($run->{makeknownhosts}, qr/-r/, 'makeknownhosts is asked to remove them'); } +# xCAT sets nodelist.status from the destiny the node reports with getdestiny: "shell" for the +# shell destiny, "configuring" for runcmd. A Genesis node never reaches "booted" -- that status +# belongs to an operating system install reporting through updateflag. +{ + no warnings 'once'; + local $GenesisTest::noderange = 'xcat71-cn'; + is(wait_status('shell', 'shell'), 0, + 'a node that reports the shell destiny ends the wait'); + is(wait_status('configuring', 'configuring'), 0, + 'a node that reports the runcmd destiny ends the wait'); + isnt(wait_status('powering-on', 'shell'), 0, + 'a node that never reports its destiny fails the wait'); +} + +# The shell case ignored the result of the wait, so it went on to xdsh whatever the node had +# reported and rested entirely on the xdsh probes. +{ + no warnings 'once'; + eval_subs($source, qw(run_nodeset_shell_test)); + local $GenesisTest::noderange = 'xcat71-cn'; + is(run_shell_test(status => 'shell'), 0, + 'the shell case passes when the node reports the shell destiny'); + isnt(run_shell_test(status => 'powering-on'), 0, + 'the shell case fails when the node never reports the shell destiny'); +} + +#--- +# wait_status: drive the destiny status check with lsdef shadowed to report one status. The +# extracted package neuters sleep, so the failure path does not wait five minutes. +#--- +sub wait_status { + my ($reported, $expected) = @_; + local $ENV{PATH} = stub_bin(lsdef => + "#!/bin/sh\nprintf 'xcat71-cn: status=%s\\n' " . shell_quote($reported)) . ":$ENV{PATH}"; + return $waiter->($expected); +} + +#--- +# run_shell_test: drive the shell case with every command it runs shadowed. xdsh always answers +# as a Genesis node, so the only thing under test is what the case does with the node status. +#--- +sub run_shell_test { + my (%opt) = @_; + my $dir = tempdir(DIR => $tmpdir, CLEANUP => 1); + write_text("$dir/nodeset", "#!/bin/sh\nexit 0\n"); + write_text("$dir/rpower", "#!/bin/sh\nexit 0\n"); + write_text("$dir/makeknownhosts", "#!/bin/sh\nexit 0\n"); + write_text("$dir/lsdef", "#!/bin/sh\nprintf 'xcat71-cn: status=%s\\n' " . shell_quote($opt{status}) . "\n"); + write_text("$dir/xdsh", "#!/bin/sh\nfor a in \"\$@\"; do\n case \"\$a\" in\n */cmdline|/proc/cmdline) printf '%s\\n' 'destiny=shell'; exit 0;;\n esac\ndone\nprintf '%s\\n' '[xCAT Genesis running on node]'\n"); + chmod 0755, map { "$dir/$_" } qw(nodeset rpower makeknownhosts lsdef xdsh); + local $ENV{PATH} = "$dir:$ENV{PATH}"; + return GenesisTest::run_nodeset_shell_test(); +} + #--- # run_testxdsh: drive testxdsh with makeknownhosts and xdsh shadowed. xdsh is asked twice -- # once for the prompt, once for the file -- and the stub answers both from its arguments. @@ -81,6 +142,8 @@ sub run_testxdsh { sub eval_subs { my ($text, @names) = @_; my $code = "package GenesisTest;\nno strict;\nno warnings;\nour \$os;\nour \$check_genesis_file;\nour \$noderange;\n"; + # The waits are minutes long. Neuter sleep so the extracted routines run at test speed. + $code .= "use subs qw(sleep);\nsub sleep { \$GenesisTest::SLEPT += (\$_[0] || 0); return 1; }\n"; $code .= "sub send_msg { push \@GenesisTest::MSG, \$_[1]; return 0; }\n"; foreach my $name (@names) { my ($body) = $text =~ /^(sub \Q$name\E \{.*?^\})$/ms; @@ -91,6 +154,17 @@ sub eval_subs { eval $code or BAIL_OUT("cannot compile the extracted helpers: $@"); } +#--- +# waiter_name: the name the script gives its destiny status check. +#--- +sub waiter_name { + my ($text) = @_; + foreach my $name (qw(wait_for_node_status wait_for_boot)) { + return $name if $text =~ /^sub \Q$name\E \{/m; + } + BAIL_OUT("no destiny status check found in $helper"); +} + #--- # os_for: run get_os with `cat` shadowed so it reads the release text under test. #--- From 1db2c7cde0ad236241829e5334373d2f100583d0 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:12:25 -0300 Subject: [PATCH 19/37] fix(xcat-core): the genesis harness passes on a nodeset that failed and a node that never booted The nodeset_shell_incorrectmasterip case passed while "nodeset testnode shell" failed with "/tftpboot/boot/grub2/grub2.x86_64 does not exits". The grub2 sub-case asserted nothing. Every genesis case reported "After 30 iterations node status: powering-on" and passed anyway. check_destiny in xCAT-test/autotest/testcase/genesis/test.sh discarded the return value of runcmd and read the boot configuration file, which grub2.pm writes before it stops on the missing boot loader. xCAT builds no grub2 boot loader for x86_64, so the file is absent on a correctly built management node. wait_for_boot in genesistest.pl waited for nodelist.status "booted"; a Genesis node reports its destiny with getdestiny and xcatd writes "shell", "configuring" or "booting" from it. Every caller discarded the return value. The node did not reach even those statuses, because getdestiny makes its request file with mktemp and the dracut module never installed it. check_destiny now returns the status of nodeset, and the grub2 check stages an empty grub2. when the management node has none and removes it after. wait_for_node_status takes the status the destiny implies and each caller fails when the node does not reach it; the shell case moved into run_nodeset_shell_test so its result can be read. clearenv no longer waits, because "rinstall boot" boots a disk with no operating system and reports nothing. The dracut modules install mktemp and verify-genesis-payload requires it. Tests: genesis_incorrectmasterip_check.t runs the check with a failing nodeset and reads whether the boot loader is present when nodeset runs; genesis_testcase_helpers.t drives the status wait and the shell case; genesis_payload_verification.t reads a payload without mktemp. Each fails on the parent commit. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 8ecf0a806785023aca0ea6d1b0e41810630816b2) The Release hunk of the original commit is dropped. buildrpms.pl writes Release from SOURCE_DATE_EPOCH at build time, so the committed snap stamp is build debris. --- .../dracut_105/el/module-setup.sh | 3 + .../dracut_105/ubuntu/module-setup.sh | 3 + xCAT-genesis-builder/verify-genesis-payload | 1 + .../autotest/testcase/genesis/genesistest.pl | 70 +++++++++++-------- xCAT-test/autotest/testcase/genesis/test.sh | 42 ++++++++++- 5 files changed, 88 insertions(+), 31 deletions(-) diff --git a/xCAT-genesis-builder/dracut_105/el/module-setup.sh b/xCAT-genesis-builder/dracut_105/el/module-setup.sh index d410d50f8..32f0aadda 100755 --- a/xCAT-genesis-builder/dracut_105/el/module-setup.sh +++ b/xCAT-genesis-builder/dracut_105/el/module-setup.sh @@ -47,6 +47,9 @@ install() { dracut_install mount.nfs sshd vi reboot lspci parted tmux mkfs mkfs.ext4 mkfs.xfs xfs_db #dracut_install libvirtd /usr/share/libvirt/cpu_map.xml /usr/bin/qemu-img /usr/libexec/qemu-kvm dracut_install mkswap df ifenslave ssh-keygen scp clear + # getdestiny makes its request file with mktemp. Without it the node reports no + # destiny, so xcatd never moves nodelist.status past powering-on. + dracut_install mktemp dracut_install lldpad # RHEL 10 packages no ISC dhcp-client. Install whichever client the build root carries; diff --git a/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh b/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh index f2668dd39..6d89aa046 100755 --- a/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh +++ b/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh @@ -52,6 +52,9 @@ install() { dracut_install mount.nfs sshd vi reboot lspci parted screen mkfs mkfs.ext4 mkfs.btrfs #dracut_install libvirtd /usr/share/libvirt/cpu_map.xml /usr/bin/qemu-img /usr/libexec/qemu-kvm dracut_install mkswap df ifenslave ssh-keygen scp clear + # getdestiny makes its request file with mktemp. Without it the node reports no + # destiny, so xcatd never moves nodelist.status past powering-on. + dracut_install mktemp dracut_install dhclient lldpad # OpenSSH 9.8 moved the per-connection work into sshd-session, which sshd execs by diff --git a/xCAT-genesis-builder/verify-genesis-payload b/xCAT-genesis-builder/verify-genesis-payload index 3a9e0094f..7addc544f 100755 --- a/xCAT-genesis-builder/verify-genesis-payload +++ b/xCAT-genesis-builder/verify-genesis-payload @@ -37,6 +37,7 @@ for path in "$@"; do done require usr/sbin/sshd "Genesis is reached over ssh" +require usr/bin/mktemp "getdestiny makes its request file with it" # OpenSSH 9.8 split the per-connection work into sshd-session, which sshd execs by absolute # path. EL9 carries OpenSSH 9.9, so an image with sshd alone refuses every connection. diff --git a/xCAT-test/autotest/testcase/genesis/genesistest.pl b/xCAT-test/autotest/testcase/genesis/genesistest.pl index f78392161..6b520d744 100755 --- a/xCAT-test/autotest/testcase/genesis/genesistest.pl +++ b/xCAT-test/autotest/testcase/genesis/genesistest.pl @@ -83,29 +83,7 @@ if (!(-e $nodestanza)) { ####nodesetshell test for genesis #################################### if ($genesis_nodesetshell_test) { - send_msg(2, "[$$]:Running nodeset NODE shell test..............."); - `nodeset $noderange shell`; - if ($?) { - send_msg(0, "[$$]:nodeset $noderange shell failed..............."); - exit 1; - } - `rpower $noderange boot`; - if ($?) { - send_msg(0, "[$$]:rpower $noderange failed..............."); - exit 1; - } - else { - send_msg(2, "Installing with \"nodeset $noderange shell\" for shell test"); - sleep 120; # wait 2 min for install to finish - wait_for_boot(); - } - #run nodeshell test - send_msg(2, "prepare for nodeshell script."); - if ( &testxdsh(3)) { - send_msg(0, "[$$]:Could not verify test results using xdsh..............."); - exit 1; - } - send_msg(2, "[$$]:Running nodesetshell test success..............."); + exit 1 if &run_nodeset_shell_test(); } #################################### ####runcmd test for genesis @@ -142,6 +120,36 @@ if ($clear_env) { send_msg(2, "[$$]:Clear genesis test enviroment success..............."); } ################################## +#run_nodeset_shell_test +################################# +sub run_nodeset_shell_test { + send_msg(2, "[$$]:Running nodeset NODE shell test..............."); + `nodeset $noderange shell`; + if ($?) { + send_msg(0, "[$$]:nodeset $noderange shell failed..............."); + return 1; + } + `rpower $noderange boot`; + if ($?) { + send_msg(0, "[$$]:rpower $noderange failed..............."); + return 1; + } + send_msg(2, "Installing with \"nodeset $noderange shell\" for shell test"); + sleep 120; # wait 2 min for install to finish + if (&wait_for_node_status("shell")) { + send_msg(0, "[$$]:$noderange did not report the shell destiny..............."); + return 1; + } + #run nodeshell test + send_msg(2, "prepare for nodeshell script."); + if (&testxdsh(3)) { + send_msg(0, "[$$]:Could not verify test results using xdsh..............."); + return 1; + } + send_msg(2, "[$$]:Running nodesetshell test success..............."); + return 0; +} +################################## #report_genesis_files ################################# sub report_genesis_files { @@ -223,7 +231,7 @@ sub rungenesiscmd { else { send_msg(2, "Installing with \"$rinstall_cmd\" for runcmd test"); sleep 120; # wait 2 min for install to finish - wait_for_boot(); + $value = -1 if &wait_for_node_status("configuring"); } return $value; } @@ -266,7 +274,7 @@ sub rungenesisimg { } else { send_msg(2, "Installing with \"$rinstall_cmd\" for runimage test\n"); sleep 120; # wait 2 min for install to finish - wait_for_boot(); + $value = -1 if &wait_for_node_status("booting"); } return $value; } @@ -375,8 +383,9 @@ sub clearenv { `cat $nodestanza | chdef -z`; unlink("$nodestanza"); } + # "rinstall boot" boots the node from its disk, which carries no operating system, + # so the node reports no destiny and nodelist.status stays at powering-on. Only wait. sleep 120; # wait 2 min for reboot to finish - wait_for_boot(); return 0; } #################################### @@ -448,9 +457,10 @@ sub send_msg { } ######################################### -### Wait for node to be in "booted" state +### Wait for the node to report the status its destiny implies ########################################## -sub wait_for_boot { +sub wait_for_node_status { + my ($expected) = @_; my $iterations = 30; # Max wait 30x10 = 5 min my $sleep_interval = 10; my $boot_status; @@ -458,11 +468,11 @@ sub wait_for_boot { foreach my $i (1..$iterations) { $boot_status = `lsdef $noderange -i status -c | cut -d'=' -f2`; chop($boot_status); - if ($boot_status eq "booted") { + if ($boot_status eq $expected) { return 0; } sleep $sleep_interval; } - print "After $iterations iterations node status: $boot_status \n"; + print "After $iterations iterations node status: $boot_status, expected $expected \n"; return 1; } diff --git a/xCAT-test/autotest/testcase/genesis/test.sh b/xCAT-test/autotest/testcase/genesis/test.sh index 07694ed83..542d065d2 100755 --- a/xCAT-test/autotest/testcase/genesis/test.sh +++ b/xCAT-test/autotest/testcase/genesis/test.sh @@ -23,11 +23,41 @@ TESTNODE_ARCH="$(uname -m)" # against a scratch tree. TFTPDIR="${TFTPDIR:-/tftpboot}" +# grub2.pm names the boot loader grub2., with every ppc64 flavour written as "ppc". +TESTNODE_LOADER_ARCH="$TESTNODE_ARCH" +[[ $TESTNODE_LOADER_ARCH =~ ^ppc64 ]] && TESTNODE_LOADER_ARCH="ppc" +STAGED_BOOT_LOADER="" + MASTER_PRIVATE_IP="192.168.1.1" MASTER_PRIVATE_NETMASK="255.255.0.0" MASTER_PRIVATE_NETWORK="192_168_0_0-255_255_0_0" +# xCAT builds no grub2 network boot loader for x86_64 or aarch64. The administrator installs +# grub2. by hand -- docs/source/guides/install-guides/yum/grub2.rst. grub2.pm stops the +# configuration when the file is absent, and this case reads the configuration only. +function stage_boot_loader() { + local loader="$TFTPDIR/boot/grub2/grub2.$TESTNODE_LOADER_ARCH"; + if [[ -e $loader ]];then + return 0; + fi + mkdir -p "$TFTPDIR/boot/grub2" || return 1; + : > "$loader" || return 1; + STAGED_BOOT_LOADER="$loader"; + echo "Staged an empty boot loader at $loader for the check"; + return 0; +} + +function unstage_boot_loader() { + if [[ -z $STAGED_BOOT_LOADER ]];then + return 0; + fi + # grub2.pm links grub2- to the loader. Remove the link with the file it points at. + rm -f "$STAGED_BOOT_LOADER" "$TFTPDIR/boot/grub2/grub2-${TESTNODE}"; + STAGED_BOOT_LOADER=""; + return 0; +} + function check_destiny() { cmd="chdef ${TESTNODE} arch=${TESTNODE_ARCH} cons=ipmi groups=all ip=${TESTNODE_IP} mac=4e:ee:ee:ee:ee:0e netboot=$NETBOOT tftpserver=$MASTER_PRIVATE_IP xcatmaster=$MASTER_PRIVATE_IP"; runcmd $cmd; @@ -58,8 +88,15 @@ function check_destiny() { grep ${TESTNODE} /etc/hosts cmd="nodeset ${TESTNODE} shell"; runcmd $cmd; + # grub2.pm writes the boot configuration and only then stops on a missing boot loader, + # so the file the check reads below exists even when nodeset failed. + nodeset_rc=$?; cmd="ip addr del $MASTER_PRIVATE_IP/$MASTER_PRIVATE_NETMASK dev $NET2"; runcmd $cmd; + if [[ $nodeset_rc -ne 0 ]];then + echo "'nodeset ${TESTNODE} shell' FAILED"; + return 1; + fi echo "Check if 'nodeset ${TESTNODE} shell' is added to ${SHELLFOLDER}/${TESTNODE}" echo "===============================================" cat "${SHELLFOLDER}/${TESTNODE}" @@ -97,9 +134,12 @@ while [ "$#" -ge "0" ]; do SHELLFOLDER="$TFTPDIR/xcat/xnba/nodes" else SHELLFOLDER="$TFTPDIR/boot/grub2"; + stage_boot_loader || exit 1; fi check_destiny ; - if [[ $? -eq 1 ]];then + rc=$?; + unstage_boot_loader; + if [[ $rc -eq 1 ]];then exit 1 else exit 0 From 76d6967ed70e5a00ef64069a6b08c91a16d00007 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:33:56 -0300 Subject: [PATCH 20/37] test(xcat-core): capture the Debian genesis dependency ignoring the architecture The ppc64el and riscv64 xcat debs depend on xcat-genesis-scripts-amd64, and xcat-genesis-scripts-ppc64 depends on xcat-genesis-base-ppc64, a package no repository publishes. Nothing reports either one: the amd64 scripts package is Architecture: all, so it installs on any architecture, and the broken ppc64 dependency is never reached because nothing pulls that package. Extend debian_control_arch_coverage.t. It now reads the Depends field of xCAT/debian/control and xCATsn/debian/control, applies each architecture restriction the way dpkg-gencontrol does, and asserts that the genesis scripts a given architecture receives are that architecture's own. It also asserts that xCAT-genesis-scripts/debian/control- builds xcat-genesis-scripts- and depends on xcat-genesis-base-. Eight of the eighteen assertions fail on this tree. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/debian_control_arch_coverage.t | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/xCAT-test/unit/debian_control_arch_coverage.t b/xCAT-test/unit/debian_control_arch_coverage.t index 73bf2c7a1..dc545f0bc 100644 --- a/xCAT-test/unit/debian_control_arch_coverage.t +++ b/xCAT-test/unit/debian_control_arch_coverage.t @@ -37,4 +37,102 @@ for my $ctl (@controls) { } } +# The genesis dependency must follow the architecture. xCAT and xCATsn are built once per +# architecture from one control file, so an unrestricted "Depends: xcat-genesis-scripts-amd64" +# reaches the ppc64el and riscv64 debs too. That package is Architecture: all, so it installs and +# apt reports no error -- it lays down the x86_64 Genesis tree and pulls the 128 MB amd64 +# genesis-base, and the management node gets no Genesis for its own architecture. The rpm side +# already selects per architecture through %{?genesistarch:Requires: xCAT-genesis-scripts-...}. +# +# xCAT-genesis-scripts keeps one control file per Debian architecture, and the file name is the +# Debian architecture. Its package name and its genesis-base dependency must carry that same +# architecture: xcat-genesis-base-ppc64 is a name no repository publishes, while the base deb +# that builddeb-genesis-base builds for ppc64el is xcat-genesis-base-ppc64el. + +# Return the folded value of a control field, or undef. +sub control_field { + my ($text, $name) = @_; + return $1 if $text =~ /^\Q$name\E:[ \t]*(.*(?:\n[ \t]+.*)*)/m; + return; +} + +# Split a dependency field into [package name, architecture restriction] pairs. Alternatives +# separated by "|" are returned one by one, because a restriction binds to one alternative. +sub dependency_terms { + my ($field) = @_; + my @terms; + return @terms unless defined $field; + $field =~ s/\n/ /g; + for my $dep (split /,/, $field) { + for my $alt (split /\|/, $dep) { + next unless $alt =~ /^\s*([A-Za-z0-9][A-Za-z0-9+.-]*)\s*(?:\([^)]*\))?\s*(?:\[([^\]]*)\])?/; + push @terms, [ $1, $2 ]; + } + } + return @terms; +} + +# dpkg-gencontrol drops a dependency whose architecture restriction excludes the build +# architecture. No restriction means the dependency reaches every architecture. +sub term_applies { + my ($restriction, $arch) = @_; + return 1 unless defined $restriction; + my @tokens = grep { length } split /\s+/, $restriction; + return 1 unless @tokens; + my $negated = ($tokens[0] =~ /^!/) ? 1 : 0; + my %named = map { my $t = $_; $t =~ s/^!//; $t =~ s/^any-//; ($t => 1) } @tokens; + return $negated ? (exists $named{$arch} ? 0 : 1) : (exists $named{$arch} ? 1 : 0); +} + +# The architectures xCAT-genesis-scripts is packaged for, taken from its per-architecture control +# files. riscv64 has none on purpose: its Genesis is the OpenEmbedded image. +my $scripts_debian = "$root/xCAT-genesis-scripts/debian"; +my @scripts_arches = sort map { m{/control-(.+)$} ? $1 : () } glob("$scripts_debian/control-*"); + +SKIP: { + skip 'xCAT-genesis-scripts has no per-architecture control files', 1 unless @scripts_arches; + + for my $arch (@scripts_arches) { + my $ctl = "$scripts_debian/control-$arch"; + open my $fh, '<', $ctl or die "read $ctl: $!"; + local $/; my $text = <$fh>; close $fh; + + my ($package) = ($text =~ /^Package:\s*(\S+)/m); + is($package, "xcat-genesis-scripts-$arch", + "control-$arch builds xcat-genesis-scripts-$arch"); + + my @bases = grep { /^xcat-genesis-base-/ } + map { $_->[0] } dependency_terms(control_field($text, 'Depends')); + is_deeply(\@bases, ["xcat-genesis-base-$arch"], + "xcat-genesis-scripts-$arch depends on xcat-genesis-base-$arch"); + } + + for my $ctl (@controls) { + open my $fh, '<', $ctl or die "read $ctl: $!"; + local $/; my $text = <$fh>; close $fh; + (my $short = $ctl) =~ s{^\Q$root\E/}{}; + + my ($arch_line) = ($text =~ /^Architecture:\s*(.+)$/m); + next unless defined $arch_line; + my @built = grep { !/^(?:any|all)$/ } split /\s+/, $arch_line; + + my @genesis = grep { $_->[0] =~ /^xcat-genesis-scripts-/ } + dependency_terms(control_field($text, 'Depends')); + + for my $arch (@built) { + my @reaching = map { $_->[0] } + grep { term_applies($_->[1], $arch) } @genesis; + my @foreign = grep { $_ ne "xcat-genesis-scripts-$arch" } @reaching; + is_deeply(\@foreign, [], + "$short on $arch depends on no other architecture's genesis scripts"); + + my %packaged = map { $_ => 1 } @scripts_arches; + next unless $packaged{$arch}; + ok(scalar(grep { $_ eq "xcat-genesis-scripts-$arch" } @reaching), + "$short on $arch depends on xcat-genesis-scripts-$arch"); + } + } +} + + done_testing(); From 6798db5fb717031ad6376e30deae9b113d6b1a52 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:38:18 -0300 Subject: [PATCH 21/37] fix(xcat-core): the Debian genesis dependency ignores the node architecture A ppc64el or riscv64 management node installs the amd64 Genesis. xCAT and xCATsn declare Architecture: amd64 ppc64el riscv64 and one unrestricted Depends: xcat-genesis-scripts-amd64, so every architecture gets it. That package is Architecture: all, so apt reports no error. It lays down /opt/xcat/share/xcat/netboot/genesis/x86_64 and pulls the 128 MB xcat-genesis-base-amd64, and the node receives no Genesis for its own architecture. xcat-genesis-scripts-ppc64, the package that would carry it, is uninstallable: it depends on xcat-genesis-base-ppc64, and builddeb-genesis-base names the ppc64el base deb xcat-genesis-base-ppc64el. xCAT/debian/control and xCATsn/debian/control now restrict the dependency by architecture, the way xCAT.spec does with %{?genesistarch:Requires: xCAT-genesis-scripts-%{genesistarch}}. amd64 gets xcat-genesis-scripts-amd64, ppc64el gets xcat-genesis-scripts-ppc64el, and riscv64 gets neither, because its Genesis is the OpenEmbedded image. xCAT-genesis-scripts/debian/control-ppc64el builds xcat-genesis-scripts-ppc64el and depends on xcat-genesis-base-ppc64el. It conflicts with and replaces the old name, which shares the same files. debian_control_arch_coverage.t asserts the genesis scripts an architecture receives are that architecture's own, and that control- builds xcat-genesis-scripts- against xcat-genesis-base-. Eight of its assertions fail without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .../guides/install-guides/maintenance/uninstall_xcat.rst | 2 +- xCAT-genesis-scripts/debian/control-ppc64el | 8 ++++---- xCAT/debian/control | 2 +- xCATsn/debian/control | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/guides/install-guides/maintenance/uninstall_xcat.rst b/docs/source/guides/install-guides/maintenance/uninstall_xcat.rst index b31b1a79d..c5aa4f497 100644 --- a/docs/source/guides/install-guides/maintenance/uninstall_xcat.rst +++ b/docs/source/guides/install-guides/maintenance/uninstall_xcat.rst @@ -64,7 +64,7 @@ Remove xCAT Files [Ubuntu] :: - apt-get remove conserver-xcat elilo-xcat goconserver grub2-xcat ipmitool-xcat perl-xcat syslinux-xcat xcat xcat-buildkit xcat-client xcat-confluent xcat-genesis-base-amd64 xcat-genesis-base-ppc64 xcat-genesis-scripts-amd64 xcat-genesis-scripts-ppc64 xcat-probe xcat-server xcat-test xcat-vlan xcatsn xnba-undi + apt-get remove conserver-xcat elilo-xcat goconserver grub2-xcat ipmitool-xcat perl-xcat syslinux-xcat xcat xcat-buildkit xcat-client xcat-confluent xcat-genesis-base-amd64 xcat-genesis-base-ppc64el xcat-genesis-scripts-amd64 xcat-genesis-scripts-ppc64el xcat-probe xcat-server xcat-test xcat-vlan xcatsn xnba-undi To do an even more thorough cleanup, use links below to get a list of RPMs installed by xCAT. Some RPMs may not to be installed in a specific environment. diff --git a/xCAT-genesis-scripts/debian/control-ppc64el b/xCAT-genesis-scripts/debian/control-ppc64el index 53200fc05..75853f231 100644 --- a/xCAT-genesis-scripts/debian/control-ppc64el +++ b/xCAT-genesis-scripts/debian/control-ppc64el @@ -5,11 +5,11 @@ Maintainer: xCAT Build-Depends: debhelper (>= 9) Standards-Version: 3.9.4 -Package: xcat-genesis-scripts-ppc64 +Package: xcat-genesis-scripts-ppc64el Architecture: all -Depends: xcat-genesis-base-ppc64 (>= 2.13.10) -Conflicts: xcat-genesis-scripts -Replaces: xcat-genesis-scripts +Depends: xcat-genesis-base-ppc64el (>= 2.13.10) +Conflicts: xcat-genesis-scripts, xcat-genesis-scripts-ppc64 +Replaces: xcat-genesis-scripts, xcat-genesis-scripts-ppc64 Description: xCAT genesis (Genesis Enhanced Netboot Environment for System Information and Servicing) is a small, embedded-like environment for xCAT's use in discovery and diff --git a/xCAT/debian/control b/xCAT/debian/control index d7077a5fc..52b45cabd 100644 --- a/xCAT/debian/control +++ b/xCAT/debian/control @@ -9,7 +9,7 @@ Homepage: https://xcat.org/ Package: xcat Architecture: amd64 ppc64el riscv64 -Depends: ${perl:Depends}, goconserver(>= 0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, isc-dhcp-server | kea, bind9, apache2, nfs-kernel-server, libxml-parser-perl, rsync, tftpd-hpa, libnet-telnet-perl, chrony | ntp, xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) +Depends: ${perl:Depends}, goconserver(>= 0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, isc-dhcp-server | kea, bind9, apache2, nfs-kernel-server, libxml-parser-perl, rsync, tftpd-hpa, libnet-telnet-perl, chrony | ntp, xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) [amd64], xcat-genesis-scripts-ppc64el (>= 2.13-snap000000000000) [ppc64el] Recommends: net-tools, nmap, kea, tftp-hpa, ipmitool-xcat (>= 1.8.17-1), syslinux[any-amd64], libsys-virt-perl, syslinux-xcat, xnba-undi, elilo-xcat, util-linux-extra, xcat-buildkit (>= 2.13-snap000000000000), xcat-probe (>= 2.13-snap000000000000), xcat-genesis-openembedded-x86-64, xcat-genesis-openembedded-ppc64le, xcat-genesis-openembedded-riscv64 Suggests: yaboot-xcat Description: Metapackage for a common, default xCAT setup diff --git a/xCATsn/debian/control b/xCATsn/debian/control index 73b096133..9eb802883 100644 --- a/xCATsn/debian/control +++ b/xCATsn/debian/control @@ -8,7 +8,7 @@ Homepage: https://xcat.org/ Package: xcatsn Architecture: amd64 ppc64el riscv64 -Depends: ${perl:Depends}, goconserver (>=0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, libxml-parser-perl, tftpd-hpa, libnet-telnet-perl, isc-dhcp-server | kea, bind9, apache2, nfs-kernel-server, xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) +Depends: ${perl:Depends}, goconserver (>=0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, libxml-parser-perl, tftpd-hpa, libnet-telnet-perl, isc-dhcp-server | kea, bind9, apache2, nfs-kernel-server, xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) [amd64], xcat-genesis-scripts-ppc64el (>= 2.13-snap000000000000) [ppc64el] Recommends: net-tools, nmap, kea, tftp-hpa, ipmitool-xcat (>= 1.8.17-1), syslinux[any-amd64], libsys-virt-perl, syslinux-xcat, xnba-undi, elilo-xcat, xcat-buildkit (>= 2.13-snap000000000000), xcat-probe (>= 2.13-snap000000000000), xcat-genesis-openembedded-x86-64, xcat-genesis-openembedded-ppc64le, xcat-genesis-openembedded-riscv64 Suggests: yaboot-xcat Description: Metapackage for a common, default xCAT service node setup From 8c87ecf8e751df85d0ba9a5a59f3c03464042aa9 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:17:53 -0300 Subject: [PATCH 22/37] test(xcat-core): capture go-xcat naming a genesis package no repository has go-xcat keeps one package list per packaging format. Its dpkg list names xcat-genesis-scripts-ppc64 and xcat-genesis-base-ppc64. The Debian architecture is ppc64el, so apt cannot find either package and both "go-xcat install" and "go-xcat uninstall" stop on ppc64el. The test evaluates the two arrays of go-xcat, one run per branch, and compares them with the packaging: the deb names against the Package and Depends fields of the xCAT-genesis-scripts control files, the rpm names against the Genesis target architectures of xCAT-genesis-base.spec. It fails on the four deb assertions today. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .../unit/go_xcat_genesis_package_names.t | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 xCAT-test/unit/go_xcat_genesis_package_names.t diff --git a/xCAT-test/unit/go_xcat_genesis_package_names.t b/xCAT-test/unit/go_xcat_genesis_package_names.t new file mode 100644 index 000000000..844bc275f --- /dev/null +++ b/xCAT-test/unit/go_xcat_genesis_package_names.t @@ -0,0 +1,126 @@ +#!/usr/bin/env perl +# go-xcat installs and uninstalls a fixed list of package names, and it keeps one list per +# packaging format. The Genesis packages are named after the architecture, and the two formats +# spell that architecture differently: the rpm is xCAT-genesis-scripts-ppc64, the deb is +# xcat-genesis-scripts-ppc64el. A name that no repository publishes makes apt fail the whole +# transaction, so one stale entry stops "go-xcat install" and "go-xcat uninstall" on that +# architecture. +# +# The lists are built by go-xcat itself here, not read as text: the deb list exists only when +# "type dpkg" succeeds, so a shell function decides which branch each run takes. +use strict; +use warnings; + +use File::Temp qw(tempdir); +use FindBin; +use Test::More; + +my $root = "$FindBin::Bin/../.."; +my $go_xcat = "$root/xCAT-server/share/xcat/tools/go-xcat"; +BAIL_OUT("go-xcat not found at $go_xcat") unless -f $go_xcat; + +my $tmpdir = tempdir(CLEANUP => 1); +my $driver = "$tmpdir/driver.sh"; +open(my $driver_fh, '>', $driver) or die "open $driver: $!"; +print {$driver_fh} <<'DRIVER'; +#!/bin/bash + +if [[ ${WANT_DPKG:-0} == 1 ]] +then + dpkg() { :; } +fi + +list_body=$( + awk ' + /^GO_XCAT_INSTALL_LIST=\(/ { copy = 1 } + /^PATH=/ { exit } + copy { print } + ' "$GO_XCAT_SOURCE" +) +[[ -n "${list_body}" ]] || { echo "go-xcat package arrays not found" >&2 ; exit 3 ; } + +# A real dpkg on the build host would select the deb branch on every run. +PATH="" +eval "${list_body}" + +printf 'install %s\n' "${GO_XCAT_INSTALL_LIST[*]}" +printf 'uninstall %s\n' "${GO_XCAT_UNINSTALL_LIST[*]}" +DRIVER +close($driver_fh) or die "close $driver: $!"; + +# Run go-xcat's array definitions and return the two lists it built. +sub package_lists { + my ($want_dpkg) = @_; + local %ENV = (%ENV, GO_XCAT_SOURCE => $go_xcat, WANT_DPKG => $want_dpkg); + open(my $out, '-|', 'bash', $driver) or die "run $driver: $!"; + my %list; + while (my $line = <$out>) { + chomp $line; + my ($which, $packages) = split /\s+/, $line, 2; + $list{$which} = [ split /\s+/, ($packages // '') ]; + } + close($out); + BAIL_OUT('go-xcat package arrays could not be evaluated') + unless $list{install} && $list{uninstall}; + return \%list; +} + +# Read the whole of a file. +sub slurp { + my ($path) = @_; + open(my $fh, '<', $path) or die "read $path: $!"; + local $/; my $text = <$fh>; close $fh; + return $text; +} + +# The package names, sorted, that match a prefix. +sub named { + my ($packages, $prefix) = @_; + my @found = sort grep { index($_, $prefix) == 0 } @{$packages}; + return \@found; +} + +my $rpm = package_lists(0); +my $deb = package_lists(1); +BAIL_OUT('the dpkg branch of go-xcat was not taken') + unless grep { $_ eq 'xcat-client' } @{ $deb->{install} }; +BAIL_OUT('the rpm branch of go-xcat was not taken') + unless grep { $_ eq 'xCAT-client' } @{ $rpm->{install} }; + +# The deb names come from the packaging: one control file per Debian architecture names the +# genesis-scripts package, and its Depends names the genesis-base package that carries the +# Genesis tree for that same architecture. +my @control = sort glob("$root/xCAT-genesis-scripts/debian/control-*"); +BAIL_OUT('no xCAT-genesis-scripts Debian control files') unless @control; +my (@deb_scripts, @deb_base); +for my $control (@control) { + my $text = slurp($control); + push @deb_scripts, ($text =~ /^Package:\s*(\S+)/mg); + push @deb_base, ($text =~ /^Depends:.*?(xcat-genesis-base-[a-z0-9]+)/mg); +} +@deb_scripts = sort @deb_scripts; +@deb_base = sort @deb_base; + +for my $which (qw(install uninstall)) { + is_deeply(named($deb->{$which}, 'xcat-genesis-scripts-'), \@deb_scripts, + "the deb $which list names the genesis scripts packages xCAT-genesis-scripts builds"); + is_deeply(named($deb->{$which}, 'xcat-genesis-base-'), \@deb_base, + "the deb $which list names the genesis base packages those scripts depend on"); +} + +# The rpm names use the Genesis target architecture of the spec, which is not a Debian +# architecture name. +my %tarch = map { $_ => 1 } (slurp("$root/xCAT-genesis-builder/xCAT-genesis-base.spec") + =~ /^%define\s+tarch\s+(\S+)/mg); +BAIL_OUT('no Genesis target architectures in xCAT-genesis-base.spec') unless %tarch; + +for my $which (qw(install uninstall)) { + for my $prefix (qw(xCAT-genesis-scripts- xCAT-genesis-base-)) { + my @wrong = grep { my $arch = substr($_, length $prefix); !$tarch{$arch} } + @{ named($rpm->{$which}, $prefix) }; + is_deeply(\@wrong, [], + "the rpm $which list names only Genesis target architectures for $prefix*"); + } +} + +done_testing(); From b83d3379e6c7cfd0b6ec218ad7d912ab7e78cac6 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:18:04 -0300 Subject: [PATCH 23/37] fix(xcat-core): go-xcat names a genesis package no apt repository has On ppc64el "go-xcat install" and "go-xcat uninstall" stop with E: Unable to locate package xcat-genesis-scripts-ppc64 apt refuses the whole transaction, so no package of the list is installed or removed. The dpkg branch of GO_XCAT_INSTALL_LIST in xCAT-server/share/xcat/tools/go-xcat names xcat-genesis-scripts-ppc64 and xcat-genesis-base-ppc64. Those are rpm architecture names. GO_XCAT_UNINSTALL_LIST is derived from the same array, so both actions carry the wrong names. The Debian architecture is ppc64el. This change renames the two entries of the dpkg branch. The rpm branch keeps ppc64, which is the Genesis target architecture of the spec. go_xcat_genesis_package_names.t fails the four deb assertions without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-server/share/xcat/tools/go-xcat | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xCAT-server/share/xcat/tools/go-xcat b/xCAT-server/share/xcat/tools/go-xcat index 09aee446d..41c027da7 100755 --- a/xCAT-server/share/xcat/tools/go-xcat +++ b/xCAT-server/share/xcat/tools/go-xcat @@ -199,9 +199,9 @@ GO_XCAT_INSTALL_LIST=(perl-xCAT xCAT-client xCAT xCAT-buildkit # For Debian/Ubuntu, it will need a slightly different package list type dpkg >/dev/null 2>&1 && GO_XCAT_INSTALL_LIST=(perl-xcat xcat-client xcat xcat-buildkit - xcat-genesis-scripts-amd64 xcat-genesis-scripts-ppc64 xcat-server + xcat-genesis-scripts-amd64 xcat-genesis-scripts-ppc64el xcat-server elilo-xcat grub2-xcat ipmitool-xcat syslinux-xcat - xcat-genesis-base-amd64 xcat-genesis-base-ppc64 xnba-undi) + xcat-genesis-base-amd64 xcat-genesis-base-ppc64el xnba-undi) # The package list of all the packages should be uninstalled GO_XCAT_UNINSTALL_LIST=("${GO_XCAT_INSTALL_LIST[@]}" goconserver xCAT-SoftLayer xCAT-confluent xCAT-csm xCAT-genesis-builder From 4c8162824d579a6a98a5994ae41798dea25d7995 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:19:17 -0300 Subject: [PATCH 24/37] fix(xcat-core): the alien Genesis base deb keeps the rpm architecture debuild-xcat-genesis-base maps only x86_64 to amd64. For the ppc64 rpm it leaves every name at ppc64, so it builds xcat-genesis-base-ppc64 and writes "Breaks: xcat-genesis-scripts-ppc64". The Debian architecture is ppc64el, and the packages xCAT-genesis-scripts builds for it are named ppc64el, so the Breaks names a package no repository publishes. This change replaces the single x86_64 test with an architecture map: x86_64 to amd64, ppc64 and ppc64le to ppc64el. The map now drives the source tree rename, the control and changelog rewrite, and the Breaks field. PACKAGE_ARCH keeps the rpm value, because the preinst removes the Genesis tree under that name. genesis_base_deb_arch.t drives the script with alien shadowed. It fails the three ppc64el assertions without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .../debuild-xcat-genesis-base | 27 ++++-- xCAT-test/unit/genesis_base_deb_arch.t | 91 +++++++++++++++++++ 2 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 xCAT-test/unit/genesis_base_deb_arch.t diff --git a/xCAT-genesis-builder/debuild-xcat-genesis-base b/xCAT-genesis-builder/debuild-xcat-genesis-base index 39ed5d4e8..665d10f70 100755 --- a/xCAT-genesis-builder/debuild-xcat-genesis-base +++ b/xCAT-genesis-builder/debuild-xcat-genesis-base @@ -30,17 +30,28 @@ alien -d -g -c -k "${RPM_PACKAGE}" || exit 1 PACKAGE_ARCH="${EXTRACT_DIR%-*}" PACKAGE_ARCH="${PACKAGE_ARCH##*-}" -if [[ ${EXTRACT_DIR} =~ -x86_64- ]] -then - rm -rf "${EXTRACT_DIR//x86_64/amd64}" - mv "${EXTRACT_DIR}" "${EXTRACT_DIR//x86_64/amd64}" - EXTRACT_DIR="${EXTRACT_DIR//x86_64/amd64}" +# The rpm carries the Genesis target architecture, the deb must carry the Debian architecture. +# alien copies the rpm name into the deb and writes "_" as "-", so x86_64 arrives as x86-64. +case "${PACKAGE_ARCH}" in +x86_64) + ALIEN_ARCH="x86-64" ; DEB_ARCH="amd64" ;; +ppc64|ppc64le) + ALIEN_ARCH="${PACKAGE_ARCH}" ; DEB_ARCH="ppc64el" ;; +*) + ALIEN_ARCH="${PACKAGE_ARCH}" ; DEB_ARCH="${PACKAGE_ARCH}" ;; +esac - sed -i -e 's/x86-64/amd64/g' "${EXTRACT_DIR}/debian/control" - sed -i -e 's/x86-64/amd64/g' "${EXTRACT_DIR}/debian/changelog" +if [[ "${DEB_ARCH}" != "${PACKAGE_ARCH}" ]] +then + rm -rf "${EXTRACT_DIR//${PACKAGE_ARCH}/${DEB_ARCH}}" + mv "${EXTRACT_DIR}" "${EXTRACT_DIR//${PACKAGE_ARCH}/${DEB_ARCH}}" + EXTRACT_DIR="${EXTRACT_DIR//${PACKAGE_ARCH}/${DEB_ARCH}}" + + sed -i -e "s/${ALIEN_ARCH}/${DEB_ARCH}/g" "${EXTRACT_DIR}/debian/control" + sed -i -e "s/${ALIEN_ARCH}/${DEB_ARCH}/g" "${EXTRACT_DIR}/debian/changelog" fi -sed -i -e "/^Description:/i Breaks: xcat-genesis-scripts-${PACKAGE_ARCH//x86_64/amd64} (<< 2.13.10)" "${EXTRACT_DIR}/debian/control" +sed -i -e "/^Description:/i Breaks: xcat-genesis-scripts-${DEB_ARCH} (<< 2.13.10)" "${EXTRACT_DIR}/debian/control" cat >"${EXTRACT_DIR}/debian/preinst" < 1); +my $driver = "$tmpdir/driver.sh"; +open(my $driver_fh, '>', $driver) or die "open $driver: $!"; +print {$driver_fh} <<'DRIVER'; +#!/bin/bash + +# alien names the deb after the rpm: lower case, and "_" written as "-". +alien() { + local rpm="${!#}" + local name="${rpm##*/}" + name="${name%.rpm}" + local dir="${name%%-snap*}" + local package="${dir%-*}" + package="${package,,}" + package="${package//_/-}" + + mkdir -p "${dir}/debian" + cat >"${dir}/debian/control" < + +Package: ${package} +Architecture: all +Description: xCAT genesis base +CONTROL + printf '%s (%s) unstable; urgency=low\n' "${package}" "1.0" \ + >"${dir}/debian/changelog" + printf '#!/usr/bin/make -f\nbinary:\n\t@true\n' >"${dir}/debian/rules" + chmod 0755 "${dir}/debian/rules" +} + +cd "${WORK_DIR}" || exit 1 +: >"${RPM_NAME}" +source "${SCRIPT}" "${RPM_NAME}" >/dev/null 2>&1 +DRIVER +close($driver_fh) or die "close $driver: $!"; + +# Convert one rpm name and return the produced source directory and its control file. +sub convert { + my ($name, $rpm) = @_; + my $work = "$tmpdir/$name"; + mkdir $work or die "mkdir $work: $!"; + local %ENV = (%ENV, SCRIPT => $script, WORK_DIR => $work, RPM_NAME => $rpm); + system('bash', $driver) == 0 or return (undef, ''); + my ($dir) = grep { -d $_ } glob("$work/*"); + return (undef, '') unless defined $dir && -f "$dir/debian/control"; + open(my $fh, '<', "$dir/debian/control") or die "read $dir/debian/control: $!"; + local $/; my $control = <$fh>; close $fh; + $dir =~ s{^\Q$work\E/}{}; + return ($dir, $control); +} + +my %expected = ( + 'xCAT-genesis-base-x86_64-2.13.10-snap202601010000.noarch.rpm' => 'amd64', + 'xCAT-genesis-base-ppc64-2.13.10-snap202601010000.noarch.rpm' => 'ppc64el', +); + +for my $rpm (sort keys %expected) { + my $arch = $expected{$rpm}; + my ($dir, $control) = convert($arch, $rpm); + BAIL_OUT("debuild-xcat-genesis-base produced no source tree for $rpm") + unless defined $dir; + + like($dir, qr/\Q-$arch-\E/, "$rpm builds in a $arch source tree"); + like($control, qr/^Package:\s*xcat-genesis-base-\Q$arch\E$/m, + "$rpm builds the package xcat-genesis-base-$arch"); + like($control, qr/^Breaks:\s*xcat-genesis-scripts-\Q$arch\E\b/m, + "xcat-genesis-base-$arch breaks the genesis scripts of its own architecture"); +} + +done_testing(); 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 25/37] 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); +} From 772f11c257757662238cc83c3d02d8298589fd75 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:15:15 -0300 Subject: [PATCH 26/37] fix(xcat-core): the el10 Genesis image carries no openssl, so getcert never returns A compute node that boots the legacy Genesis image built on el10 stops after "Getting initial certificate --> :3001". /etc/xcat is empty, getdestiny never runs and the node reports no destiny, so xcatd leaves nodelist.status at powering-on. On the node, openssl reports "command not found" and the getcert process stays alive. xCAT-genesis-base.spec never build-requires openssl. el8 and el9 hold /usr/bin/openssl in the build root as a dependency of another package, and el10 does not, so dracut_install in dracut_105/el/module-setup.sh installed nothing and reported nothing. getcert line 8 waits for openssl with no bound, which turns the missing command into a wait instead of an error. The spec now build-requires openssl on every release. verify-genesis-payload reads the command names back from the dracut module with --commands-from and requires each one in the payload, so the next name the build root does not supply fails the build. getcert reports a missing openssl and stops, and bounds the wait for /etc/xcat/certkey.pem at 600 seconds, which is far longer than the background 4096 bit key needs. The spec read %{_target_cpu} after BuildArch: noarch, where rpm has already set it to noarch, so the dmidecode and efibootmgr build-requires never applied and a sed deleted their dracut_install line on every architecture. Every shipped image lacks both. The spec now reads %{tarch}, and the dracut module installs whichever of the two the build root carries, because ppc64le packages neither. xCAT-test/unit/genesis_getcert_missing_openssl.t, genesis_payload_verification.t and genesis_base_spec_buildrequires.t fail on the test commit before this one. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .../dracut_105/el/module-setup.sh | 6 +- xCAT-genesis-builder/verify-genesis-payload | 57 +++++++++++++++++-- xCAT-genesis-builder/xCAT-genesis-base.spec | 9 +-- xCAT-genesis-scripts/usr/bin/getcert | 19 +++++++ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/xCAT-genesis-builder/dracut_105/el/module-setup.sh b/xCAT-genesis-builder/dracut_105/el/module-setup.sh index 32f0aadda..95d52c395 100755 --- a/xCAT-genesis-builder/dracut_105/el/module-setup.sh +++ b/xCAT-genesis-builder/dracut_105/el/module-setup.sh @@ -87,7 +87,11 @@ install() { dracut_install /sbin/rsyslogd /etc/protocols umount /bin/rpm /usr/lib/rpm/rpmrc #dracut_install chmod /sbin/route /sbin/ifconfig /usr/bin/whoami /usr/bin/head /usr/bin/tail basename /etc/redhat-release ping tr lsusb /usr/share/hwdata/usb.ids #ibm fw wrapper requirements dracut_install chmod ip /usr/bin/whoami /usr/bin/head /usr/bin/tail basename /etc/redhat-release ping tr lsusb /usr/share/hwdata/usb.ids #ibm fw wrapper requirements - dracut_install efibootmgr dmidecode #uxspi prereqs, but will use dmidecode to improve decision on loading ipmi_si + # uxspi prereqs. dmidecode also improves the decision on loading ipmi_si. Neither is + # packaged for ppc64le, so install whichever the build root carries. + for _fw_tool in efibootmgr dmidecode; do + command -v "$_fw_tool" >/dev/null 2>&1 && dracut_install "$_fw_tool" + done dracut_install lldptool dracut_install /usr/share/zoneinfo/posix/Zulu dracut_install /usr/share/zoneinfo/posix/GMT-0 diff --git a/xCAT-genesis-builder/verify-genesis-payload b/xCAT-genesis-builder/verify-genesis-payload index 7addc544f..11fdc4a33 100755 --- a/xCAT-genesis-builder/verify-genesis-payload +++ b/xCAT-genesis-builder/verify-genesis-payload @@ -1,16 +1,35 @@ #!/bin/bash # -# verify-genesis-payload [required-path ...] +# verify-genesis-payload [--commands-from ] [required-path ...] # # dracut_install() reports a missing binary and returns, so the module install function keeps -# going and the image ships without it. Three such holes reached a release: no dhclient, no -# sshd-session and no UTF-8 locale. Check the extracted payload before it becomes an rpm. +# going and the image ships without it. Four such holes reached a release: no dhclient, no +# openssl, no sshd-session and no UTF-8 locale. Check the extracted payload before it becomes +# an rpm. # -# Paths are relative to . The caller adds what only it knows (dhclient is not -# packaged on every release); the rules below come from the payload itself. +# Paths are relative to . --commands-from reads back the command names the +# dracut module installs. The caller adds what only it knows (the DHCP client is not the same +# package on every release); the rules below come from the payload itself. set -u +commands_from="" +while [ $# -gt 0 ]; do + case "$1" in + --commands-from) + commands_from=${2:-} + shift 2 || true + ;; + --commands-from=*) + commands_from=${1#*=} + shift + ;; + *) + break + ;; + esac +done + payload=${1:-} if [ -z "$payload" ] || [ ! -d "$payload" ]; then echo "verify-genesis-payload: not a payload directory: ${payload:-}" >&2 @@ -36,6 +55,34 @@ for path in "$@"; do require "$path" "required by the build" done +# The dracut module names every command Genesis runs. A name that the build root does not +# supply installs nothing and says nothing, so read the names back and check each one. +# Names under a condition are release-dependent, so only the top level of install() counts. +if [ -n "$commands_from" ]; then + if [ ! -r "$commands_from" ]; then + echo "verify-genesis-payload: cannot read $commands_from" >&2 + exit 2 + fi + commands=$(awk ' + /^install\(\)/ { in_install = 1; next } + in_install && /^}/ { in_install = 0 } + in_install && /^ dracut_install / { + sub(/#.*/, "") + sub(/^ dracut_install /, "") + print + }' "$commands_from" | tr ' \t' '\n\n' | grep -v '^$' | grep -v '^[/-]' | sort -u) + if [ -z "$commands" ]; then + echo "verify-genesis-payload: no command name read from $commands_from" >&2 + exit 2 + fi + for command in $commands; do + have "bin/$command" || have "sbin/$command" \ + || have "usr/bin/$command" || have "usr/sbin/$command" \ + || missing="$missing + $command (installed by $commands_from)" + done +fi + require usr/sbin/sshd "Genesis is reached over ssh" require usr/bin/mktemp "getdestiny makes its request file with it" diff --git a/xCAT-genesis-builder/xCAT-genesis-base.spec b/xCAT-genesis-builder/xCAT-genesis-base.spec index cd3e737bb..7fa11dcce 100644 --- a/xCAT-genesis-builder/xCAT-genesis-base.spec +++ b/xCAT-genesis-builder/xCAT-genesis-base.spec @@ -46,7 +46,7 @@ BuildRequires: chrony BuildRequires: cpio BuildRequires: e2fsprogs BuildRequires: hostname -%if "%{_target_cpu}" == "x86_64" +%if "%{tarch}" == "x86_64" BuildRequires: dmidecode BuildRequires: efibootmgr %endif @@ -79,6 +79,9 @@ BuildRequires: nfs-utils BuildRequires: nmap-ncat BuildRequires: openssh-clients BuildRequires: openssh-server +# getcert, getdestiny, getipmi and getadapter run the openssl command. el8 and el9 hold it in +# the build root as a dependency of another package; el10 does not. +BuildRequires: openssl BuildRequires: parted BuildRequires: pciutils BuildRequires: perl @@ -134,9 +137,6 @@ rm -rf "$DRACUTMODDIR" mkdir -p "$DRACUTMODDIR" cp -a "%{_builddir}/xCAT-genesis-base-build-support/dracut_105/el/." "$DRACUTMODDIR/" chmod 0755 "$DRACUTMODDIR/module-setup.sh" "$DRACUTMODDIR/xcatroot" "$DRACUTMODDIR/dhclient-script" -if [ "%{_target_cpu}" != "x86_64" ]; then - sed -i '/efibootmgr dmidecode/d' "$DRACUTMODDIR/module-setup.sh" -fi KERNELVERSION=$(ls -1 /lib/modules | sort -V | tail -n 1) test -n "$KERNELVERSION" @@ -243,6 +243,7 @@ GENESIS_REQUIRED="usr/sbin/dhclient" GENESIS_REQUIRED="usr/sbin/dhcpcd" %endif bash "%{_builddir}/xCAT-genesis-base-build-support/verify-genesis-payload" \ + --commands-from "$DRACUTMODDIR/module-setup.sh" \ "$GENESIS_FS" $GENESIS_REQUIRED find "$GENESIS_TMPDIR" -type c -delete diff --git a/xCAT-genesis-scripts/usr/bin/getcert b/xCAT-genesis-scripts/usr/bin/getcert index cf4cf1b07..7bad27285 100755 --- a/xCAT-genesis-scripts/usr/bin/getcert +++ b/xCAT-genesis-scripts/usr/bin/getcert @@ -4,8 +4,27 @@ CREDPID=$! if [ -z "$XCATDEST" ]; then XCATDEST=$1 fi +# doxcat runs getcert in the foreground and ignores its status, so a wait with no bound stops +# the boot and prints nothing. +give_up() { + logger -s -t xcat -p local4.err "getcert: $1" + kill $CREDPID + exit 1 +} + +if ! command -v openssl > /dev/null 2>&1; then + give_up "this Genesis image carries no openssl, so no certificate is requested" +fi + #retry in case certkey.pem is not right, yet +# doxcat writes /etc/xcat/certkey.pem in the background with a 4096 bit key, so the first +# requests fail. An emulated node needs minutes for that key. +CSR_TIMEOUT=${GETCERT_CSR_TIMEOUT:-600} +CSR_DEADLINE=$((SECONDS + CSR_TIMEOUT)) while ! openssl req -new -key /etc/xcat/certkey.pem -out /tmp/tls.csr -subj "/CN=$(hostname)" >& /dev/null; do + if [ "$SECONDS" -ge "$CSR_DEADLINE" ]; then + give_up "no certificate request after ${CSR_TIMEOUT}s; /etc/xcat/certkey.pem is not usable" + fi sleep 1 done echo " From 100d537a7d9c0257012cca2fe1def472f2e0ee90 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:36:25 -0300 Subject: [PATCH 27/37] test(xcat-core): capture the Genesis root home directory left at /root mknb writes the management node key to /.ssh/authorized_keys for the legacy Genesis image. sshd reads that file only while the home directory of root is /. The dracut cmdline hook makes it / by deleting the root entry the image ships and appending its own. The test runs that rewrite against both root entry shapes dracut writes, for all three hooks, and reads back /etc/passwd. It also keeps a user name that starts with root in the file, so a wider delete cannot pass unnoticed. Six of the eighteen assertions fail today. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/genesis_root_home.t | 90 ++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 xCAT-test/unit/genesis_root_home.t diff --git a/xCAT-test/unit/genesis_root_home.t b/xCAT-test/unit/genesis_root_home.t new file mode 100644 index 000000000..7ce1ca5fe --- /dev/null +++ b/xCAT-test/unit/genesis_root_home.t @@ -0,0 +1,90 @@ +#!/usr/bin/env perl +# Drive the /etc/passwd rewrite out of the Genesis dracut cmdline hooks. +# +# mknb writes the management node key to /.ssh/authorized_keys for the legacy Genesis +# image, so sshd finds it only while the home directory of root is /. The hook makes it / +# by deleting the root entry the image ships and appending its own. Run that rewrite +# against every root entry shape dracut writes and read back the result. +use strict; +use warnings; + +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 @HOOKS = ( + 'xCAT-genesis-builder/xcat-cmdline.sh', + 'xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh', + 'xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh', +); + +# dracut 99base writes the root entry itself. Up to dracut 057 the password field is +# always x. From dracut 060 the x arrives only with --hostonly, and the Genesis image is +# built with -N, so el10 (dracut 107) ships an empty password field. +my %SHIPPED = ( + 'dracut 049/057 (el8, el9)' => "root:x:0:0::/root:/bin/sh\n", + 'dracut 107 (el10)' => "root::0:0::/root:/bin/sh\n", +); + +# A user name that starts with root but is not root. The delete must keep this line. +my $DECOY = "rootfsadm:x:501:501::/home/rootfsadm:/sbin/nologin\n"; + +plan tests => 3 * @HOOKS * scalar(keys %SHIPPED); + +my $tmpdir = tempdir(CLEANUP => 1); + +foreach my $hook (@HOOKS) { + my $block = extract_passwd_block(repo_path($hook), $hook); + foreach my $shape (sort keys %SHIPPED) { + my $passwd = run_rewrite($block, $SHIPPED{$shape} . $DECOY, $hook); + my @root = grep { /^root:/ } split(/\n/, $passwd); + + is(scalar @root, 1, + "$hook / $shape: one root entry is left in /etc/passwd"); + is($root[0], 'root:x:0:0::/:/bin/bash', + "$hook / $shape: the home directory of root is /"); + like($passwd, qr/^\Qrootfsadm:x:501:501::\/home\/rootfsadm:\/sbin\/nologin\E$/m, + "$hook / $shape: a user name that starts with root is kept"); + } +} + +#--- +# extract_passwd_block: lift the /etc/passwd rewrite out of a hook that cannot be sourced. +# The hook mounts filesystems, starts udev and ends in an endless loop. +# Bails out when the block stops being extractable, so a rewrite fails loudly instead of +# leaving the test asserting nothing. +#--- +sub extract_passwd_block { + my ($path, $label) = @_; + my $text = read_text($path); + my ($block) = $text =~ m{^(sed [^\n]*/etc/passwd\ncat >>/etc/passwd <<"__ENDL"\n.*?^__ENDL)$}ms; + BAIL_OUT("$label: the /etc/passwd rewrite was not found") unless defined $block; + return $block; +} + +#--- +# run_rewrite: run the extracted block against a scratch passwd file and return it. +# The block names /etc/passwd literally, so the path is redirected into the scratch tree +# first. Bails out if any reference to the real file survives, because the block runs as +# root under CI. +#--- +sub run_rewrite { + my ($block, $shipped, $label) = @_; + my $dir = tempdir(DIR => $tmpdir, CLEANUP => 1); + my $passwd = "$dir/passwd"; + write_text($passwd, $shipped); + + my $script = $block; + my $hits = ($script =~ s{/etc/passwd}{$passwd}g); + BAIL_OUT("$label: expected 2 references to /etc/passwd, found $hits") unless $hits == 2; + BAIL_OUT("$label: a reference to the real /etc/passwd survived") if index($script, '/etc/passwd') >= 0; + + write_text("$dir/rewrite.sh", "set -e\n$script\n"); + system('/bin/bash', "$dir/rewrite.sh") == 0 + or BAIL_OUT("$label: the /etc/passwd rewrite failed to run"); + return read_text($passwd); +} From 0b31e2146e389f91748a6ef6603452a79d8a9082 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:36:55 -0300 Subject: [PATCH 28/37] fix(xcat-core): the el10 Genesis image gives root the home directory /root Every case that boots a node into the el10 legacy Genesis image fails with "root@: Permission denied (publickey,password,keyboard-interactive)". mknb writes the management node key to /.ssh/authorized_keys, and sshd looks for it under the home directory of root. The dracut cmdline hook deletes the root entry the image ships with "sed -i '/^root:x/d'" and appends its own entry with the home directory /. dracut 99base writes that entry, and from dracut 060 the password field holds x only when the image is built --hostonly. The Genesis image is built with -N, so el10 (dracut 107) ships "root::0:0::/root:/bin/sh". The delete does not match, two root entries survive, and getpwnam returns the first one. el8 (dracut 049) and el9 (dracut 057) always ship the x and are not affected. The delete now matches the user name only. All three cmdline hooks carry the same statement and all three change. genesis_root_home.t runs the rewrite against both entry shapes and fails on the empty password field without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh | 4 +++- xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh | 4 +++- xCAT-genesis-builder/xcat-cmdline.sh | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh b/xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh index db9bfa206..8039ee149 100755 --- a/xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh +++ b/xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh @@ -22,7 +22,9 @@ echo PS1="'"'[xCAT Genesis running on \H \w]\$ '"'" > /.bash_profile mkdir -p /etc/ssh mkdir -p /var/tmp/ mkdir -p /var/empty/sshd -sed -i '/^root:x/d' /etc/passwd +# dracut writes this entry itself, with an empty password field unless the image is +# built --hostonly. Match the user name only. +sed -i '/^root:/d' /etc/passwd cat >>/etc/passwd <<"__ENDL" root:x:0:0::/:/bin/bash sshd:x:30:30:SSH User:/var/empty/sshd:/sbin/nologin diff --git a/xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh b/xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh index ea7697b91..c54ec1df3 100755 --- a/xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh +++ b/xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh @@ -22,7 +22,9 @@ echo PS1="'"'[xCAT Genesis running on \H \w]\$ '"'" > /.bash_profile mkdir -p /etc/ssh mkdir -p /var/tmp/ mkdir -p /var/empty/sshd -sed -i '/^root:x/d' /etc/passwd +# dracut writes this entry itself, with an empty password field unless the image is +# built --hostonly. Match the user name only. +sed -i '/^root:/d' /etc/passwd cat >>/etc/passwd <<"__ENDL" root:x:0:0::/:/bin/bash sshd:x:30:30:SSH User:/var/empty/sshd:/sbin/nologin diff --git a/xCAT-genesis-builder/xcat-cmdline.sh b/xCAT-genesis-builder/xcat-cmdline.sh index 525ea19c9..3d0de2382 100755 --- a/xCAT-genesis-builder/xcat-cmdline.sh +++ b/xCAT-genesis-builder/xcat-cmdline.sh @@ -8,7 +8,9 @@ echo PS1="'"'[xCAT Genesis running on \H \w]\$ '"'" > /.bash_profile mkdir -p /etc/ssh mkdir -p /var/tmp/ mkdir -p /var/empty/sshd -sed -i '/^root:x/d' /etc/passwd +# dracut writes this entry itself, with an empty password field unless the image is +# built --hostonly. Match the user name only. +sed -i '/^root:/d' /etc/passwd cat >>/etc/passwd <<"__ENDL" root:x:0:0::/:/bin/bash sshd:x:30:30:SSH User:/var/empty/sshd:/sbin/nologin From c406d93bce33fc500041ea030659c78b3551c6c9 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:26:49 -0300 Subject: [PATCH 29/37] fix(xcat-core): the genesis lzma case passes when mknb writes no lzma image nodeset_shell_lzma asserts "output=~genesis" on "ls -l /tftpboot/xcat/genesis.fs.*.lzma". The path in the command holds the string the check looks for, so the error "ls: cannot access '/tftpboot/xcat/genesis.fs.*.lzma'" matches too. The check is green whether mknb wrote the lzma image or not. Removing the "xz --format=lzma" fallback from genesis_lzma_command in mknb.pm makes mknb write only the gz image, and this check still passes. The same case starts with a "yum install" of xz-lzma-compat from a rpmfind.net CentOS 8-Stream PowerTools URL. No check follows it. The URL returns 404 since CentOS 8-Stream went end of life, so the step has done nothing for a long time. The check is now "rc==0". ls returns 2 when the glob matches no file, so the check is red exactly when mknb wrote no lzma image. The dead install and its paired "yum remove" are deleted rather than gated, because genesis_lzma_command falls back to "xz --format=lzma" and xz is present on every EL management node: the lzma image the case tests is produced without the package. Verified with the runcmd and check-evaluation code lifted out of xCAT-test/xcattest. With the lzma image present both checks are green. With only the gz image present the old check stays green and "rc==0" is red. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 942729abecde9cad7c7ac7adcdbe270d97a79051) --- xCAT-test/autotest/testcase/genesis/cases0 | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/xCAT-test/autotest/testcase/genesis/cases0 b/xCAT-test/autotest/testcase/genesis/cases0 index e2e7e7ca8..066eac4e8 100644 --- a/xCAT-test/autotest/testcase/genesis/cases0 +++ b/xCAT-test/autotest/testcase/genesis/cases0 @@ -2,14 +2,13 @@ start:nodeset_shell_lzma os:rhels8 label:others,genesis description: verify could log in genesis shell lzma compression -cmd:if [[ "__GETNODEATTR($$CN,os)__" =~ "rhel" ]]; then yum install -y https://rpmfind.net/linux/centos/8-stream/PowerTools/__GETNODEATTR($$CN,arch)__/os/Packages/xz-lzma-compat-5.2.4-3.el8.__GETNODEATTR($$CN,arch)__.rpm; elif rpm -q xz; then yum download https://rpmfind.net/linux/centos/8-stream/PowerTools/__GETNODEATTR($$CN,arch)__/os/Packages/xz-lzma-compat-5.2.4-3.el8.__GETNODEATTR($$CN,arch)__.rpm; rpm -ivh --nodeps xz-lzma-compat-5.2.4-3.el8.__GETNODEATTR($$CN,arch)__.rpm; fi #Generate genesis network boot with lzma compression cmd:mknb __GETNODEATTR($$CN,arch)__ check:rc==0 cmd:nodeset $$CN shell check:rc==0 cmd:ls -l /tftpboot/xcat/genesis.fs.*.lzma -check:output=~genesis +check:rc==0 cmd:find /tftpboot -type f -name $$CN | xargs grep "lzma" check:output=~genesis cmd:perl /opt/xcat/share/xcat/tools/autotest/testcase/genesis/genesistest.pl -n $$CN -g @@ -19,8 +18,7 @@ check:rc==0 cmd:perl /opt/xcat/share/xcat/tools/autotest/testcase/genesis/genesistest.pl -n $$CN -c check:rc==0 cmd:cat /tmp/genesistestlog/* -#Remove lzma compression RPM, cleanup and generate default gz genesis network boot -cmd:yum remove -y xz-lzma-compat +#Cleanup and generate the default gz genesis network boot cmd:rm -f /tftpboot/xcat/genesis.fs.*.lzma cmd:mknb __GETNODEATTR($$CN,arch)__ end From 0eaed3119f06cb585cb41c3df5c01f99bac7acc2 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:27:02 -0300 Subject: [PATCH 30/37] fix(xcat-core): six xcattest checks pass on the failure they test for A sweep of xCAT-test/autotest/testcase for the shape found in nodeset_shell_lzma -- a check whose expected pattern also appears in its own command -- returns 274 matches. In six of them the self-matching check is the only proof of the property the case exists to test, so the case is green when the property is absent. xdcp_RP and xdcp_R write "test1" into /tmp/xdcp/test1/test1.txt, then read it back and assert "output=~test1". The path repeats the content, so "cat: /tmp/xdcp/test1/test1.txt: No such file or directory" matches. These are the only checks that read the copied bytes; the earlier ls checks prove the name arrived, not the content. updatenode_diskful_syncfiles_dir has the same shape for the files updatenode -F syncs. lsxcatd_null asserts "output=~lsxcatd" on "lsxcatd", which the shell's "lsxcatd: command not found" satisfies. export_import_multiple_osimages_by_dir asserts "output=~site" on "ls -R /opt/inventory/site", which "ls: cannot access '/opt/inventory/site'" satisfies. The copied and synced files now carry a content marker that the path does not repeat, so the check reads the bytes and not the file name. lsxcatd_null asserts a line of the usage text, and the two inventory listings assert the exported osimage name. Verified with the runcmd and check-evaluation code lifted out of xCAT-test/xcattest. Every replaced check is green both when the behaviour is present and when it is removed. Every new check is green with the behaviour present and red without it. Seven further self-matching checks in xdcp/cases0 ("ls -l /tmp/test1.txt" with "output=~test1") measure nothing but sit behind a sound "ls -l /tmp" check, so a real failure is already caught. They are left for a decision. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> (cherry picked from commit 194dd4ec6cbf3dc9d1e341b69445cbd3baa27b34) --- xCAT-test/autotest/testcase/lsxcatd/cases0 | 2 +- xCAT-test/autotest/testcase/updatenode/cases0 | 8 ++++---- .../testcase/xcat_inventory/cases.osimage | 8 ++++---- xCAT-test/autotest/testcase/xdcp/cases0 | 16 ++++++++-------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/xCAT-test/autotest/testcase/lsxcatd/cases0 b/xCAT-test/autotest/testcase/lsxcatd/cases0 index eb82fb0ff..b7003d219 100644 --- a/xCAT-test/autotest/testcase/lsxcatd/cases0 +++ b/xCAT-test/autotest/testcase/lsxcatd/cases0 @@ -2,7 +2,7 @@ start:lsxcatd_null description:lsxcatd without any flag label:mn_only,ci_test,xcatd cmd:lsxcatd -check:output=~lsxcatd +check:output=~\[-v\|--version\] end start:lsxcatd_h diff --git a/xCAT-test/autotest/testcase/updatenode/cases0 b/xCAT-test/autotest/testcase/updatenode/cases0 index d0d24e4c5..a8b94d8ed 100644 --- a/xCAT-test/autotest/testcase/updatenode/cases0 +++ b/xCAT-test/autotest/testcase/updatenode/cases0 @@ -83,9 +83,9 @@ start:updatenode_diskful_syncfiles_dir label:others,updatenode cmd:mkdir -p /tmp/sync/ check:rc==0 -cmd:echo "test1" > /tmp/sync/test1.txt +cmd:echo "syncdata1" > /tmp/sync/test1.txt check:rc==0 -cmd:echo "test2" > /tmp/sync/test2.txt +cmd:echo "syncdata2" > /tmp/sync/test2.txt check:rc==0 cmd:echo "/tmp/sync/* -> /tmp/" > /install/custom/install/__GETNODEATTR($$CN,os)__/compute.$$OS.synclist check:rc==0 @@ -97,9 +97,9 @@ cmd:xdsh $$CN "ls -l /tmp" check:output=~test1.txt check:output=~test2.txt cmd:xdsh $$CN "cat /tmp/test1.txt" -check:output=~test1 +check:output=~syncdata1 cmd:xdsh $$CN "cat /tmp/test2.txt" -check:output=~test2 +check:output=~syncdata2 cmd:xdsh $$CN "rm -rf /tmp/test1.txt /tmp/test2.txt" check:rc==0 cmd:chdef -t osimage -o __GETNODEATTR($$CN,os)__-__GETNODEATTR($$CN,arch)__-install-compute synclists= diff --git a/xCAT-test/autotest/testcase/xcat_inventory/cases.osimage b/xCAT-test/autotest/testcase/xcat_inventory/cases.osimage index cd6fb34ff..8b5e9fb3a 100644 --- a/xCAT-test/autotest/testcase/xcat_inventory/cases.osimage +++ b/xCAT-test/autotest/testcase/xcat_inventory/cases.osimage @@ -1040,8 +1040,8 @@ cmd:dir="/opt/inventory/site/osimage";if [ -e "${dir}" ];then mv ${dir} ${dir}". cmd:xcat-inventory export -t osimage -o test_myimage1,test_myimage2 --format json -d /opt/inventory/site/osimage check:rc==0 check:output=~The osimage objects has been exported to directory /opt/inventory/site/osimage -cmd: ls -R /opt/inventory/site/osimage -check: output =~ site +cmd:ls -R /opt/inventory/site/osimage +check:output=~test_myimage1 cmd:otherpkglist=`lsdef -t osimage -o test_myimage1 |grep otherpkglist|awk -F= '{print $2}'`;diff -y $otherpkglist /opt/inventory/site/osimage/test_myimage1$otherpkglist check:rc==0 cmd:synclists=`lsdef -t osimage -o test_myimage1 |grep synclists|awk -F= '{print $2}'`;diff -y $synclists /opt/inventory/site/osimage/test_myimage1$synclists @@ -1073,8 +1073,8 @@ check:rc==0 cmd: rmdef -t osimage -o test_myimage1,test_myimage2 check:rc==0 cmd:rm -rf /tmp/otherpkglist /tmp/synclists /tmp/postinstall /tmp/exlist /tmp/partitionfile /tmp/pkglist /tmp/template -cmd: ls -R /opt/inventory/site -check: output =~ site +cmd:ls -R /opt/inventory/site +check:output=~test_myimage1 cmd:xcat-inventory import -t osimage -o test_myimage1,test_myimage2 -d /opt/inventory/site/osimage check:rc==0 check:output=~The object test_myimage1 has been imported diff --git a/xCAT-test/autotest/testcase/xdcp/cases0 b/xCAT-test/autotest/testcase/xdcp/cases0 index 57fa39bf9..5d8c095a0 100644 --- a/xCAT-test/autotest/testcase/xdcp/cases0 +++ b/xCAT-test/autotest/testcase/xdcp/cases0 @@ -42,11 +42,11 @@ start:xdcp_RP label:cn_os_ready,parallel_cmds cmd:xdsh $$CN "mkdir -p /tmp/xdcp/test1" check:rc==0 -cmd:xdsh $$CN "echo "test1" > /tmp/xdcp/test1/test1.txt" +cmd:xdsh $$CN "echo "xdcpdata1" > /tmp/xdcp/test1/test1.txt" check:rc==0 cmd:xdsh $$CN "mkdir -p /tmp/xdcp/test2" check:rc==0 -cmd:xdsh $$CN "echo "test2" > /tmp/xdcp/test2/test2.txt" +cmd:xdsh $$CN "echo "xdcpdata2" > /tmp/xdcp/test2/test2.txt" check:rc==0 cmd:xdcp $$CN -RP /tmp/xdcp /tmp check:rc==0 @@ -58,9 +58,9 @@ check:output=~test1.txt cmd:ls -l /tmp/xdcp._$$CN/test2 check:output=~test2.txt cmd:cat /tmp/xdcp._$$CN/test1/test1.txt -check:output=~test1 +check:output=~xdcpdata1 cmd:cat /tmp/xdcp._$$CN/test2/test2.txt -check:output=~test2 +check:output=~xdcpdata2 cmd:xdsh $$CN "rm -rf /tmp/xdcp" check:rc==0 cmd:rm -rf /tmp/xdcp._$$CN @@ -71,11 +71,11 @@ start:xdcp_R label:cn_os_ready,parallel_cmds cmd:mkdir -p /tmp/xdcp/test1 check:rc==0 -cmd:echo "test1" > /tmp/xdcp/test1/test1.txt +cmd:echo "xdcpdata1" > /tmp/xdcp/test1/test1.txt check:rc==0 cmd:mkdir -p /tmp/xdcp/test2 check:rc==0 -cmd:echo "test2" > /tmp/xdcp/test2/test2.txt +cmd:echo "xdcpdata2" > /tmp/xdcp/test2/test2.txt check:rc==0 cmd:xdcp $$CN -R /tmp/xdcp /tmp check:rc==0 @@ -89,9 +89,9 @@ check:output=~test1.txt cmd:xdsh $$CN "ls -l /tmp/xdcp/test2" check:output=~test2.txt cmd:xdsh $$CN "cat /tmp/xdcp/test1/test1.txt" -check:output=~test1 +check:output=~xdcpdata1 cmd:xdsh $$CN "cat /tmp/xdcp/test2/test2.txt" -check:output=~test2 +check:output=~xdcpdata2 cmd:xdsh $$CN "rm -rf /tmp/xdcp" check:rc==0 cmd:rm -rf /tmp/xdcp From b3ed5e6d24ca35fcec1c1578a50f1246569f1e2f Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:00:32 -0300 Subject: [PATCH 31/37] test(xcat-core): a Genesis image with no /usr/bin/awk passes verification verify-genesis-payload reads the names the dracut module installs back out of module-setup.sh and checks each one against the extracted payload. It drops every name that starts with "/", so the 609 absolute paths the EL module names are checked by nothing. An image built without /usr/bin/awk, /etc/services or /lib64/libnss_dns.so.2 passes. The Genesis debs carry the architecture in the package name. 2.19 renames the ppc64 debs to ppc64el, and neither builddeb-genesis-base nor debuild-xcat-genesis-base names the deb the new package supersedes. dpkg keeps xcat-genesis-base-ppc64 installed beside xcat-genesis-base-ppc64el, and the old package owns the same files under /opt/xcat/share/xcat/netboot/genesis. genesis_payload_verification.t drives the verifier against a payload missing /usr/bin/awk and one missing /etc/services. genesis_base_deb_arch.t asserts the Replaces and Breaks the alien path writes. genesis_base_deb_control_rewrite.t lifts rewrite_control() out of builddeb-genesis-base and runs it over the control file in the tree. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/genesis_base_deb_arch.t | 16 ++++- .../unit/genesis_base_deb_control_rewrite.t | 64 +++++++++++++++++++ xCAT-test/unit/genesis_payload_verification.t | 28 +++++++- 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 xCAT-test/unit/genesis_base_deb_control_rewrite.t diff --git a/xCAT-test/unit/genesis_base_deb_arch.t b/xCAT-test/unit/genesis_base_deb_arch.t index 6f7e0b701..5bcb83227 100644 --- a/xCAT-test/unit/genesis_base_deb_arch.t +++ b/xCAT-test/unit/genesis_base_deb_arch.t @@ -2,7 +2,8 @@ # debuild-xcat-genesis-base converts the EL Genesis base rpm to a deb. The rpm name carries the # Genesis target architecture, and the deb must carry the Debian architecture: ppc64 becomes # ppc64el, x86_64 becomes amd64. An unmapped architecture leaves the deb named after the rpm and -# makes it break a genesis-scripts package that no repository publishes. +# makes it break a genesis-scripts package that no repository publishes. The rename also has to +# name the deb it supersedes, or an upgraded ppc node keeps xcat-genesis-base-ppc64 as well. # # The script is driven here with alien and dpkg-buildpackage shadowed by shell functions. use strict; @@ -75,6 +76,13 @@ my %expected = ( 'xCAT-genesis-base-ppc64-2.13.10-snap202601010000.noarch.rpm' => 'ppc64el', ); +# The package the new deb takes over from. On ppc that is the deb this rename leaves behind: +# without the relation dpkg keeps xcat-genesis-base-ppc64 and its copy of the same files. +my %superseded = ( + 'amd64' => 'xcat-genesis-amd64', + 'ppc64el' => 'xcat-genesis-ppc64, xcat-genesis-base-ppc64', +); + for my $rpm (sort keys %expected) { my $arch = $expected{$rpm}; my ($dir, $control) = convert($arch, $rpm); @@ -84,8 +92,12 @@ for my $rpm (sort keys %expected) { like($dir, qr/\Q-$arch-\E/, "$rpm builds in a $arch source tree"); like($control, qr/^Package:\s*xcat-genesis-base-\Q$arch\E$/m, "$rpm builds the package xcat-genesis-base-$arch"); - like($control, qr/^Breaks:\s*xcat-genesis-scripts-\Q$arch\E\b/m, + like($control, qr/^Breaks:.*\bxcat-genesis-scripts-\Q$arch\E\b/m, "xcat-genesis-base-$arch breaks the genesis scripts of its own architecture"); + like($control, qr/^Replaces:\s*\Q$superseded{$arch}\E\s*$/m, + "xcat-genesis-base-$arch replaces $superseded{$arch}"); + like($control, qr/^Breaks:\s*\Q$superseded{$arch}\E\b/m, + "xcat-genesis-base-$arch breaks $superseded{$arch}"); } done_testing(); diff --git a/xCAT-test/unit/genesis_base_deb_control_rewrite.t b/xCAT-test/unit/genesis_base_deb_control_rewrite.t new file mode 100644 index 000000000..44fb2979d --- /dev/null +++ b/xCAT-test/unit/genesis_base_deb_control_rewrite.t @@ -0,0 +1,64 @@ +#!/usr/bin/env perl +# builddeb-genesis-base builds the Genesis base deb natively on Ubuntu. It writes the target +# architecture into debian/control, which is held in the amd64 form in the tree. 2.19 renames +# the ppc64 debs to ppc64el, so the ppc control must also name the deb it supersedes: without +# the relation dpkg keeps xcat-genesis-base-ppc64 installed beside the new package, and that +# old package owns the same files under /opt/xcat/share/xcat/netboot/genesis. +# +# The script needs dracut and root, so rewrite_control() is lifted out of it and run alone +# against the control file the tree ships. +use strict; +use warnings; + +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 slurp_repo_file); + +my $script = repo_path('xCAT-genesis-builder/builddeb-genesis-base'); +my $control = repo_path('xCAT-genesis-builder/debian/control'); +plan skip_all => 'builddeb-genesis-base not found' unless -f $script; +plan tests => 8; + +my $text = slurp_repo_file('xCAT-genesis-builder/builddeb-genesis-base'); +my ($function) = $text =~ /^(rewrite_control\(\)\s*\{.*?^\})/ms; +BAIL_OUT('rewrite_control() no longer matches in builddeb-genesis-base') + unless defined $function; + +my $tmpdir = tempdir(CLEANUP => 1); + +# What the ppc64el package has to take over from, and what amd64 already took over from. +my %superseded = ( + 'amd64' => 'xcat-genesis-amd64', + 'ppc64el' => 'xcat-genesis-ppc64, xcat-genesis-base-ppc64', +); + +for my $arch (sort keys %superseded) { + my $out = rewrite($arch); + + like($out, qr/^Package:\s*xcat-genesis-base-\Q$arch\E$/m, + "$arch control names the package xcat-genesis-base-$arch"); + like($out, qr/^Replaces:\s*\Q$superseded{$arch}\E\s*$/m, + "$arch control replaces $superseded{$arch}"); + like($out, qr/^Breaks:\s*\Q$superseded{$arch}\E\b/m, + "$arch control breaks $superseded{$arch}"); + like($out, qr/^Breaks:.*\bxcat-genesis-scripts-\Q$arch\E \(<< 2\.13\.10\)/m, + "$arch control breaks the genesis scripts of its own architecture"); +} + +#--- +# rewrite: run the lifted rewrite_control() over a copy of the control file in the tree. +#--- +sub rewrite { + my ($arch) = @_; + my $copy = "$tmpdir/control.$arch"; + write_text($copy, read_text($control)); + my $driver = "$tmpdir/driver.$arch.sh"; + write_text($driver, "#!/bin/bash\nset -eu\n$function\nrewrite_control \"\$1\" \"\$2\"\n"); + system('bash', $driver, $copy, $arch) == 0 + or BAIL_OUT("rewrite_control failed for $arch"); + return read_text($copy); +} diff --git a/xCAT-test/unit/genesis_payload_verification.t b/xCAT-test/unit/genesis_payload_verification.t index c845b3cd9..7e4be6ba6 100644 --- a/xCAT-test/unit/genesis_payload_verification.t +++ b/xCAT-test/unit/genesis_payload_verification.t @@ -15,7 +15,7 @@ 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 => 18; +plan tests => 22; my $tmpdir = tempdir(CLEANUP => 1); my $module_seq = 0; @@ -71,6 +71,23 @@ my $noopenssl = build_payload(sshd_execs_session => 1, session_helper => 1, tmux isnt($rc, 0, 'a payload without openssl fails'); like($err, qr/openssl/, 'the missing openssl is named'); +# dracut_install installs an absolute path at that same path. The verifier used to drop every +# name that started with "/", so an image with no /usr/bin/awk passed. doxcat, getdestiny and +# the firmware wrappers all run awk. +my $noawk = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, + dhclient => 1, mktemp => 1, commands => [qw(openssl wget tar)], absent => ['usr/bin/awk']); +($rc, $err) = run_with_commands($module, $noawk); +isnt($rc, 0, 'a payload without the absolute path /usr/bin/awk fails'); +like($err, qr{/usr/bin/awk}, 'the missing /usr/bin/awk is named'); + +# The module names data files by absolute path too. Genesis resolves service names with +# /etc/services. +my $noservices = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, + dhclient => 1, mktemp => 1, commands => [qw(openssl wget tar)], absent => ['etc/services']); +($rc, $err) = run_with_commands($module, $noservices); +isnt($rc, 0, 'a payload without the absolute path /etc/services fails'); +like($err, qr{/etc/services}, 'the missing /etc/services 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']); @@ -112,6 +129,15 @@ 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} || [] }; + + # The module written by write_module_setup names these two by absolute path. + my %absent = map { $_ => 1 } @{ $opt{absent} || [] }; + for my $path (qw(usr/bin/awk etc/services)) { + next if $absent{$path}; + my ($dir) = $path =~ m{^(.*)/}; + make_path("$root/$dir"); + write_text("$root/$path", "$path\n"); + } return $root; } From da9e17fb538278e9d118253d12c6603dbbbe1dcd Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:00:57 -0300 Subject: [PATCH 32/37] fix(xcat-core): check the absolute paths the Genesis module installs, and name the deb the rename supersedes verify-genesis-payload dropped every name that started with "/" before checking the payload, so the 609 absolute paths the EL dracut module installs were checked by nothing. An image with no /usr/bin/awk passed. The filter now drops only option words, and an absolute name is read back under the payload root. The ppc64 to ppc64el rename left the new Genesis base deb without a relation to the deb it replaces, so dpkg kept xcat-genesis-base-ppc64 installed beside it with its own copy of the files under /opt/xcat/share/xcat/netboot/genesis. builddeb-genesis-base and debuild-xcat-genesis-base now write Replaces and Breaks for the superseded package, as the genesis-scripts control file already does. The native path takes the architecture rewrite into rewrite_control() so the superseded name is derived in one place. genesis_payload_verification.t, genesis_base_deb_arch.t and genesis_base_deb_control_rewrite.t fail without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-genesis-builder/builddeb-genesis-base | 17 +++++++-- .../debuild-xcat-genesis-base | 14 +++++++- xCAT-genesis-builder/verify-genesis-payload | 35 ++++++++++++------- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/xCAT-genesis-builder/builddeb-genesis-base b/xCAT-genesis-builder/builddeb-genesis-base index 8a687a60a..e78c12c58 100755 --- a/xCAT-genesis-builder/builddeb-genesis-base +++ b/xCAT-genesis-builder/builddeb-genesis-base @@ -15,6 +15,20 @@ case "$BUILDARCH" in *) echo "ERROR: unsupported architecture: $BUILDARCH" >&2; exit 1 ;; esac +# The Genesis debs carry the architecture in the package name, so the packages an upgrade has +# to displace carry it too. 2.19 renames the ppc64 debs to ppc64el; dpkg keeps the old package, +# and its copy of the same files, unless the new one replaces it by name. +rewrite_control() { + local control=$1 arch=$2 superseded + case "$arch" in + ppc64el) superseded="xcat-genesis-ppc64, xcat-genesis-base-ppc64" ;; + *) superseded="xcat-genesis-$arch" ;; + esac + sed -i -e "s/xcat-genesis-base-amd64/xcat-genesis-base-$arch/g" \ + -e "s/xcat-genesis-scripts-amd64/xcat-genesis-scripts-$arch/g" \ + -e "s/xcat-genesis-amd64/$superseded/g" "$control" +} + VERSION=$(cat "$DIR/../Version" 2>/dev/null || echo "2.18.0") RELEASE=$(cat "$DIR/../Release" 2>/dev/null || echo "snap$(date +%Y%m%d%H%M)") CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME") @@ -148,8 +162,7 @@ rm -rf "$DIR/opt" cp -a "$GENESIS_TMPDIR/opt" "$DIR/" # Adjust control file for target arch -sed -i "s/xcat-genesis-base-amd64/xcat-genesis-base-$BUILDARCH/g" "$DIR/debian/control" -sed -i "s/xcat-genesis-scripts-amd64/xcat-genesis-scripts-$BUILDARCH/g" "$DIR/debian/control" +rewrite_control "$DIR/debian/control" "$BUILDARCH" PKG_VERSION="${VERSION}-${RELEASE}~${CODENAME}" rm -f "$DIR/debian/changelog" diff --git a/xCAT-genesis-builder/debuild-xcat-genesis-base b/xCAT-genesis-builder/debuild-xcat-genesis-base index 665d10f70..ffa0dc2d9 100755 --- a/xCAT-genesis-builder/debuild-xcat-genesis-base +++ b/xCAT-genesis-builder/debuild-xcat-genesis-base @@ -51,7 +51,19 @@ then sed -i -e "s/${ALIEN_ARCH}/${DEB_ARCH}/g" "${EXTRACT_DIR}/debian/changelog" fi -sed -i -e "/^Description:/i Breaks: xcat-genesis-scripts-${DEB_ARCH} (<< 2.13.10)" "${EXTRACT_DIR}/debian/control" +# The Genesis debs carry the architecture in the package name, so the packages an upgrade has +# to displace carry it too. 2.19 renames the ppc64 debs to ppc64el; dpkg keeps the old package, +# and its copy of the same files, unless the new one replaces it by name. +case "${DEB_ARCH}" in +ppc64el) + SUPERSEDED="xcat-genesis-ppc64, xcat-genesis-base-ppc64" ;; +*) + SUPERSEDED="xcat-genesis-${DEB_ARCH}" ;; +esac + +sed -i -e "/^Description:/i Replaces: ${SUPERSEDED}" \ + -e "/^Description:/i Breaks: ${SUPERSEDED}, xcat-genesis-scripts-${DEB_ARCH} (<< 2.13.10)" \ + "${EXTRACT_DIR}/debian/control" cat >"${EXTRACT_DIR}/debian/preinst" <. --commands-from reads back the command names the -# dracut module installs. The caller adds what only it knows (the DHCP client is not the same -# package on every release); the rules below come from the payload itself. +# Paths given on the command line are relative to . --commands-from reads back +# what the dracut module installs: a bare command name is looked for in the four binary +# directories, an absolute path under itself. The caller adds what only it +# knows (the DHCP client is not the same package on every release); the rules below come from +# the payload itself. set -u @@ -55,9 +57,10 @@ for path in "$@"; do require "$path" "required by the build" done -# The dracut module names every command Genesis runs. A name that the build root does not -# supply installs nothing and says nothing, so read the names back and check each one. -# Names under a condition are release-dependent, so only the top level of install() counts. +# The dracut module names every command and every data file Genesis needs. A name the build +# root does not supply installs nothing and says nothing, so read the names back and check +# each one. Names under a condition are release-dependent, so only the top level of install() +# counts. if [ -n "$commands_from" ]; then if [ ! -r "$commands_from" ]; then echo "verify-genesis-payload: cannot read $commands_from" >&2 @@ -70,16 +73,24 @@ if [ -n "$commands_from" ]; then sub(/#.*/, "") sub(/^ dracut_install /, "") print - }' "$commands_from" | tr ' \t' '\n\n' | grep -v '^$' | grep -v '^[/-]' | sort -u) + }' "$commands_from" | tr ' \t' '\n\n' | grep -v '^$' | grep -v '^-' | sort -u) if [ -z "$commands" ]; then echo "verify-genesis-payload: no command name read from $commands_from" >&2 exit 2 fi - for command in $commands; do - have "bin/$command" || have "sbin/$command" \ - || have "usr/bin/$command" || have "usr/sbin/$command" \ - || missing="$missing - $command (installed by $commands_from)" + for want in $commands; do + case "$want" in + # dracut_install installs an absolute path at that same path, so read it back + # under the payload root. Dropping these let an image with no /usr/bin/awk pass. + /*) have "${want#/}" || missing="$missing + $want (installed by $commands_from)" + ;; + *) have "bin/$want" || have "sbin/$want" \ + || have "usr/bin/$want" || have "usr/sbin/$want" \ + || missing="$missing + $want (installed by $commands_from)" + ;; + esac done fi From 57dddd4efd11e85abafcece29c5034f13fa88171 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:25:47 -0300 Subject: [PATCH 33/37] test(xcat-core): follow the per-architecture Genesis scripts dependency xcat_riscv64_genesis_dependency.t pins the deb dependency on the legacy Genesis scripts as a single xcat-genesis-scripts-amd64 entry qualified [!riscv64]. That entry gives a ppc64el management node the amd64 scripts package, which pulls the amd64 Genesis base with it, and this branch replaces it with one entry per architecture. The test now asserts that every xcat-genesis-scripts entry names an architecture that has a legacy Genesis, and that the ppc64el reduction asks for xcat-genesis-scripts-ppc64el. The riscv64 assertions are unchanged: no entry applies there. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .../unit/xcat_riscv64_genesis_dependency.t | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/xCAT-test/unit/xcat_riscv64_genesis_dependency.t b/xCAT-test/unit/xcat_riscv64_genesis_dependency.t index 4a1930c4a..67bd0c774 100644 --- a/xCAT-test/unit/xcat_riscv64_genesis_dependency.t +++ b/xCAT-test/unit/xcat_riscv64_genesis_dependency.t @@ -12,8 +12,8 @@ use Test::More; # # The deb side named xcat-genesis-scripts-amd64 in a plain Depends, and that package is # Architecture: all, so apt installed the x86 Genesis scripts (and, through them, the x86 Genesis -# base) on a riscv64 management node. Restrict the dependency to the architectures that have a -# legacy Genesis, and leave amd64 and ppc64el untouched. +# base) on every management node that is not amd64. Name one scripts package per architecture that +# has a legacy Genesis, so riscv64 gets none and ppc64el gets its own. my $repo_root = File::Spec->rel2abs( File::Spec->catdir( $FindBin::Bin, '..', '..' ) @@ -37,10 +37,12 @@ foreach my $pkg ( [ 'xCAT', 'xcat' ], [ 'xCATsn', 'xcatsn' ] ) { my ($recommends) = $control =~ /^Recommends:\s*(.*)$/m; ok( defined $recommends, "$name debian/control has a Recommends line" ); - my ($entry) = grep { /xcat-genesis-scripts/ } split( /\s*,\s*/, $depends ); - ok( defined $entry, "$name depends on a legacy Genesis scripts package" ); - like( $entry, qr/\[!riscv64\]/, - "$name excludes riscv64 from the legacy Genesis scripts dependency" ); + my @entries = grep { /xcat-genesis-scripts/ } split( /\s*,\s*/, $depends ); + ok( scalar(@entries), "$name depends on a legacy Genesis scripts package" ); + my @unqualified = grep { !/\[(?:amd64|ppc64el)\]\s*$/ } @entries; + is_deeply( \@unqualified, [], + "$name asks for the legacy Genesis scripts of an architecture that has them" ) + or diag( "unqualified: @unqualified" ); SKIP: { skip( "Dpkg::Deps is not available", 7 ) unless $have_dpkg_deps; @@ -56,8 +58,8 @@ foreach my $pkg ( [ 'xCAT', 'xcat' ], [ 'xCATsn', 'xcatsn' ] ) { "$name on riscv64 does not pull the legacy Genesis scripts" ); like( $reduced{amd64}, qr/xcat-genesis-scripts-amd64/, "$name on amd64 still pulls them" ); - like( $reduced{ppc64el}, qr/xcat-genesis-scripts-amd64/, - "$name on ppc64el still pulls them" ); + like( $reduced{ppc64el}, qr/xcat-genesis-scripts-ppc64el/, + "$name on ppc64el pulls the ppc64el ones" ); # The restriction must not take anything else with it: every other dependency of the # amd64 package must survive on riscv64. From b91795a8f42d445acfca2544f0262cfd1458b93f Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:46:57 -0300 Subject: [PATCH 34/37] test(xcat-core): a failed extraction in thirteen test files stops the whole suite Thirteen test files this branch adds call BAIL_OUT at fifty-one places: an extraction that stopped matching, a fixture that is not there, a harness that wrote no log. prove stops every remaining file on a bail-out, not only the file that called it, so one of them hides the results of every test that would have run after it. die is just as loud and costs only its own file. Fifteen comments the branch added also carried the incident rather than the constraint. Three pasted an error transcript, five traced a failure from a macro or a missing file out to a node that never boots, and the rest counted call sites, package sizes or dracut build numbers. Each now states the one fact the reader cannot re-derive from the code. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- github_action_xcat_test.pl | 5 ++-- .../dracut_105/el/module-setup.sh | 5 ++-- .../dracut_105/ubuntu/module-setup.sh | 5 ++-- xCAT-genesis-builder/verify-genesis-payload | 4 +-- xCAT-test/autotest/testcase/genesis/test.sh | 3 +- .../autotest_check_lines_are_understood.t | 30 +++++++++---------- xCAT-test/unit/debian_control_arch_coverage.t | 21 ++++--------- xCAT-test/unit/genesis_base_deb_arch.t | 4 +-- .../unit/genesis_base_deb_control_rewrite.t | 4 +-- xCAT-test/unit/genesis_console_mode.t | 5 ++-- xCAT-test/unit/genesis_dhcp_client.t | 4 +-- xCAT-test/unit/genesis_payload_verification.t | 13 ++++---- xCAT-test/unit/genesis_root_home.t | 13 ++++---- xCAT-test/unit/genesis_spec_target_arch.t | 5 ++-- xCAT-test/unit/genesis_testcase_helpers.t | 12 ++++---- .../unit/go_xcat_genesis_package_names.t | 18 +++++------ xCAT-test/unit/kvm_createstorage_model.t | 13 ++++---- xCAT-test/unit/kvm_diskstruct_bus.t | 6 ++-- xCAT-test/unit/kvm_guest_arch.t | 6 ++-- xCAT-test/unit/mknb_genesis_staging.t | 8 ++--- xCAT-test/unit/xcat_probe_package_payload.t | 2 +- xCAT-test/unit/xcattest_report_every_check.t | 16 +++++----- xCAT-test/xcattest | 5 ++-- 23 files changed, 88 insertions(+), 119 deletions(-) diff --git a/github_action_xcat_test.pl b/github_action_xcat_test.pl index 23e18154b..34fc6b675 100644 --- a/github_action_xcat_test.pl +++ b/github_action_xcat_test.pl @@ -33,9 +33,8 @@ my $GITHUB_API = "https://api.github.com"; # before building and run the unit tests out of the copy. # # The copy is tidiness, not a requirement: builddebs.pl writes under dist/debs -# inside the checkout and restores every file it edits, so it isolates the tests -# from build residue and nothing more. Its predecessor deleted the checkout's -# parent directory, which is why the copy was added. +# inside the checkout and restores every file it edits, so the copy only keeps +# build residue away from the tests. my $srcdir = getcwd(); my $unitsrc = ($ENV{'RUNNER_TEMP'} ? $ENV{'RUNNER_TEMP'} : "/tmp") . "/xcat-core-unitsrc"; diff --git a/xCAT-genesis-builder/dracut_105/el/module-setup.sh b/xCAT-genesis-builder/dracut_105/el/module-setup.sh index 541e19c55..f4c22c318 100755 --- a/xCAT-genesis-builder/dracut_105/el/module-setup.sh +++ b/xCAT-genesis-builder/dracut_105/el/module-setup.sh @@ -48,8 +48,7 @@ install() { dracut_install mount.nfs sshd vi reboot lspci parted tmux mkfs mkfs.ext4 mkfs.xfs xfs_db #dracut_install libvirtd /usr/share/libvirt/cpu_map.xml /usr/bin/qemu-img /usr/libexec/qemu-kvm dracut_install mkswap df ifenslave ssh-keygen scp clear - # getdestiny makes its request file with mktemp. Without it the node reports no - # destiny, so xcatd never moves nodelist.status past powering-on. + # getdestiny makes its request file with mktemp. dracut_install mktemp dracut_install lldpad @@ -69,7 +68,7 @@ install() { fi # OpenSSH 9.8 moved the per-connection work into sshd-session, which sshd execs by - # absolute path. Without it every connection to Genesis is refused. + # absolute path. for _sshd_helper in \ /usr/libexec/openssh/sshd-session \ /usr/libexec/openssh/sshd-auth \ diff --git a/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh b/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh index 96c608dc9..367439ce4 100755 --- a/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh +++ b/xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh @@ -53,13 +53,12 @@ install() { dracut_install mount.nfs sshd vi reboot lspci parted screen mkfs mkfs.ext4 mkfs.btrfs #dracut_install libvirtd /usr/share/libvirt/cpu_map.xml /usr/bin/qemu-img /usr/libexec/qemu-kvm dracut_install mkswap df ifenslave ssh-keygen scp clear - # getdestiny makes its request file with mktemp. Without it the node reports no - # destiny, so xcatd never moves nodelist.status past powering-on. + # getdestiny makes its request file with mktemp. dracut_install mktemp dracut_install dhclient lldpad # OpenSSH 9.8 moved the per-connection work into sshd-session, which sshd execs by - # absolute path. Without it every connection to Genesis is refused. + # absolute path. for _sshd_helper in \ /usr/libexec/openssh/sshd-session \ /usr/libexec/openssh/sshd-auth \ diff --git a/xCAT-genesis-builder/verify-genesis-payload b/xCAT-genesis-builder/verify-genesis-payload index 3765e27e2..1f44898c1 100755 --- a/xCAT-genesis-builder/verify-genesis-payload +++ b/xCAT-genesis-builder/verify-genesis-payload @@ -3,9 +3,7 @@ # verify-genesis-payload [--commands-from ] [required-path ...] # # dracut_install() reports a missing binary and returns, so the module install function keeps -# going and the image ships without it. Four such holes reached a release: no dhclient, no -# openssl, no sshd-session and no UTF-8 locale. Check the extracted payload before it becomes -# an rpm. +# going and the image ships without it. Check the extracted payload before it is packaged. # # Paths given on the command line are relative to . --commands-from reads back # what the dracut module installs: a bare command name is looked for in the four binary diff --git a/xCAT-test/autotest/testcase/genesis/test.sh b/xCAT-test/autotest/testcase/genesis/test.sh index 542d065d2..904eea1d4 100755 --- a/xCAT-test/autotest/testcase/genesis/test.sh +++ b/xCAT-test/autotest/testcase/genesis/test.sh @@ -16,8 +16,7 @@ function runcmd(){ # We should be using private networks TESTNODE=testnode TESTNODE_IP="192.168.3.1" -# nodeset resolves the genesis kernel by the node arch. A hardcoded ppc64le node fails on -# every other management node with "Could not find genesis.kernel.ppc64". +# nodeset resolves the genesis kernel by the node arch, so the node takes this machine's. TESTNODE_ARCH="$(uname -m)" # The boot-loader configuration lives under the tftp root. Overridable so the check can run # against a scratch tree. diff --git a/xCAT-test/unit/autotest_check_lines_are_understood.t b/xCAT-test/unit/autotest_check_lines_are_understood.t index b589be314..1f4e54861 100644 --- a/xCAT-test/unit/autotest_check_lines_are_understood.t +++ b/xCAT-test/unit/autotest_check_lines_are_understood.t @@ -11,8 +11,8 @@ use Test::More; my $program = "$FindBin::Bin/../xcattest"; my $casedir = "$FindBin::Bin/../autotest/testcase"; -BAIL_OUT("xcattest is not at $program") unless -f $program; -BAIL_OUT("no test cases under $casedir") unless -d $casedir; +die("xcattest is not at $program") unless -f $program; +die("no test cases under $casedir") unless -d $casedir; # A check line xcattest does not understand costs the case the assertion it describes, and the # case says nothing about it: an unknown operator reports "Unrecognized testcase syntax", and a @@ -20,11 +20,11 @@ BAIL_OUT("no test cases under $casedir") unless -d $casedir; # Read the shipped check lines and let the harness report on them. my @files; find({ wanted => sub { push(@files, $File::Find::name) if -f $File::Find::name }, no_chdir => 1 }, $casedir); -BAIL_OUT("no case files under $casedir") unless @files; +die("no case files under $casedir") unless @files; my (%checks, %vars); for my $file (sort @files) { - open(my $fh, '<', $file) or BAIL_OUT("open $file: $!"); + open(my $fh, '<', $file) or die("open $file: $!"); while (my $line = <$fh>) { chomp($line); next unless $line =~ /^check\s*:\s*(\S.*)$/; @@ -36,9 +36,9 @@ for my $file (sort @files) { $vars{$1} = 1 while ($check =~ /\$\$(\w+)/g); push(@{ $checks{$file} }, $check); } - close($fh) or BAIL_OUT("close $file: $!"); + close($fh) or die("close $file: $!"); } -BAIL_OUT("no check lines under $casedir") unless keys %checks; +die("no check lines under $casedir") unless keys %checks; # One case per shipped file, so a check that reports nothing is attributed to its own file. my %case_of_file = map { $_ => 'syntax_' . do { my $n = $_; $n =~ s{^\Q$casedir\E/?}{}; $n =~ s/[^A-Za-z0-9_-]/_/g; $n } } keys %checks; @@ -55,11 +55,11 @@ for my $file (sort keys %checks) { # scratch tree keeps every file the run writes inside that tree. my $root = tempdir(CLEANUP => 1); make_path("$root/bin", "$root/cases"); -copy($program, "$root/bin/xcattest") or BAIL_OUT("copy xcattest: $!"); +copy($program, "$root/bin/xcattest") or die("copy xcattest: $!"); chmod 0755, "$root/bin/xcattest"; -open(my $fixture_fh, '>', "$root/cases/fixture") or BAIL_OUT("write the fixture case: $!"); +open(my $fixture_fh, '>', "$root/cases/fixture") or die("write the fixture case: $!"); print $fixture_fh $fixture; -close($fixture_fh) or BAIL_OUT("close the fixture case: $!"); +close($fixture_fh) or die("close the fixture case: $!"); # Every variable a check line names has to resolve, or xcattest drops the whole case. # A "local" here would be undone at the end of its own statement, before the run. @@ -67,16 +67,16 @@ $ENV{"XCATTEST_$_"} = 'placeholder' for keys %vars; $ENV{XCATTEST_CASEDIR} = "$root/cases"; # Some shipped patterns warn when perl compiles them, and the warnings say nothing about the # operator. The log file carries what this test reads, so the warnings go to the scratch tree. -open(my $stderr_save, '>&', \*STDERR) or BAIL_OUT("save STDERR: $!"); -open(STDERR, '>', "$root/stderr") or BAIL_OUT("redirect STDERR: $!"); +open(my $stderr_save, '>&', \*STDERR) or die("save STDERR: $!"); +open(STDERR, '>', "$root/stderr") or die("redirect STDERR: $!"); system($^X, "$root/bin/xcattest", '-q', '-t', join(',', sort values %case_of_file)); -open(STDERR, '>&', $stderr_save) or BAIL_OUT("restore STDERR: $!"); +open(STDERR, '>&', $stderr_save) or die("restore STDERR: $!"); my ($logname) = glob("$root/share/xcat/tools/autotest/result/xcattest.log.*"); -BAIL_OUT("the harness wrote no log under $root") unless $logname; -open(my $log_fh, '<', $logname) or BAIL_OUT("open $logname: $!"); +die("the harness wrote no log under $root") unless $logname; +open(my $log_fh, '<', $logname) or die("open $logname: $!"); my @log = <$log_fh>; -close($log_fh) or BAIL_OUT("close $logname: $!"); +close($log_fh) or die("close $logname: $!"); chomp(@log); # Count what the harness reported for each case, and keep the lines it did not understand. diff --git a/xCAT-test/unit/debian_control_arch_coverage.t b/xCAT-test/unit/debian_control_arch_coverage.t index dc545f0bc..824df71cb 100644 --- a/xCAT-test/unit/debian_control_arch_coverage.t +++ b/xCAT-test/unit/debian_control_arch_coverage.t @@ -1,11 +1,6 @@ #!/usr/bin/env perl # xCAT and xCATsn name their Debian architectures explicitly. An architecture missing from that -# list is not a build failure -- it is a package that never exists: apt on that architecture says -# -# E: Unable to locate package xcat -# -# and the management node cannot be installed at all. riscv64 was missing while the rest of the -# tree already carried riscv64 install templates, DHCP boot policy and a Genesis machine config. +# list is not a build failure: it is a package apt cannot find at all. # # The list is compared against the architectures the DEB build itself supports, taken from # build-utils/lib/XCAT/BuildUtils or, failing that, the documented set. @@ -38,16 +33,12 @@ for my $ctl (@controls) { } # The genesis dependency must follow the architecture. xCAT and xCATsn are built once per -# architecture from one control file, so an unrestricted "Depends: xcat-genesis-scripts-amd64" -# reaches the ppc64el and riscv64 debs too. That package is Architecture: all, so it installs and -# apt reports no error -- it lays down the x86_64 Genesis tree and pulls the 128 MB amd64 -# genesis-base, and the management node gets no Genesis for its own architecture. The rpm side -# already selects per architecture through %{?genesistarch:Requires: xCAT-genesis-scripts-...}. +# architecture from one control file, and xcat-genesis-scripts-amd64 is Architecture: all, so an +# unrestricted Depends on it installs the x86_64 Genesis tree on every architecture. # -# xCAT-genesis-scripts keeps one control file per Debian architecture, and the file name is the -# Debian architecture. Its package name and its genesis-base dependency must carry that same -# architecture: xcat-genesis-base-ppc64 is a name no repository publishes, while the base deb -# that builddeb-genesis-base builds for ppc64el is xcat-genesis-base-ppc64el. +# xCAT-genesis-scripts keeps one control file per Debian architecture, named for it. Its package +# name and its genesis-base dependency must carry that same architecture: the base deb +# builddeb-genesis-base builds for ppc64el is xcat-genesis-base-ppc64el, not -ppc64. # Return the folded value of a control field, or undef. sub control_field { diff --git a/xCAT-test/unit/genesis_base_deb_arch.t b/xCAT-test/unit/genesis_base_deb_arch.t index 5bcb83227..238d80c52 100644 --- a/xCAT-test/unit/genesis_base_deb_arch.t +++ b/xCAT-test/unit/genesis_base_deb_arch.t @@ -15,7 +15,7 @@ use Test::More; my $root = "$FindBin::Bin/../.."; my $script = "$root/xCAT-genesis-builder/debuild-xcat-genesis-base"; -BAIL_OUT("debuild-xcat-genesis-base not found at $script") unless -f $script; +die("debuild-xcat-genesis-base not found at $script") unless -f $script; my $tmpdir = tempdir(CLEANUP => 1); my $driver = "$tmpdir/driver.sh"; @@ -86,7 +86,7 @@ my %superseded = ( for my $rpm (sort keys %expected) { my $arch = $expected{$rpm}; my ($dir, $control) = convert($arch, $rpm); - BAIL_OUT("debuild-xcat-genesis-base produced no source tree for $rpm") + die("debuild-xcat-genesis-base produced no source tree for $rpm") unless defined $dir; like($dir, qr/\Q-$arch-\E/, "$rpm builds in a $arch source tree"); diff --git a/xCAT-test/unit/genesis_base_deb_control_rewrite.t b/xCAT-test/unit/genesis_base_deb_control_rewrite.t index 44fb2979d..9f74ebff5 100644 --- a/xCAT-test/unit/genesis_base_deb_control_rewrite.t +++ b/xCAT-test/unit/genesis_base_deb_control_rewrite.t @@ -25,7 +25,7 @@ plan tests => 8; my $text = slurp_repo_file('xCAT-genesis-builder/builddeb-genesis-base'); my ($function) = $text =~ /^(rewrite_control\(\)\s*\{.*?^\})/ms; -BAIL_OUT('rewrite_control() no longer matches in builddeb-genesis-base') +die('rewrite_control() no longer matches in builddeb-genesis-base') unless defined $function; my $tmpdir = tempdir(CLEANUP => 1); @@ -59,6 +59,6 @@ sub rewrite { my $driver = "$tmpdir/driver.$arch.sh"; write_text($driver, "#!/bin/bash\nset -eu\n$function\nrewrite_control \"\$1\" \"\$2\"\n"); system('bash', $driver, $copy, $arch) == 0 - or BAIL_OUT("rewrite_control failed for $arch"); + or die("rewrite_control failed for $arch"); return read_text($copy); } diff --git a/xCAT-test/unit/genesis_console_mode.t b/xCAT-test/unit/genesis_console_mode.t index f43e33b5d..c17ba3ac0 100644 --- a/xCAT-test/unit/genesis_console_mode.t +++ b/xCAT-test/unit/genesis_console_mode.t @@ -24,8 +24,7 @@ plan tests => 5 * scalar(keys %HOOK) + 2; my $tmpdir = tempdir(CLEANUP => 1); -# The failure this captures: with no UTF-8 locale in the image, tmux exits and the old -# unconditional `while :; do tmux ...; done` never reached doxcat. +# tmux exits under the C locale, so an unguarded tmux loop never reaches doxcat. my $el = read_text(repo_path($HOOK{el}{path})); ok($el !~ qr/^while :; do tmux attach-session/m, 'el: no unguarded tmux loop is left at column 0'); @@ -62,7 +61,7 @@ sub extract_function { my ($path, $name, $label) = @_; my $text = read_text($path); my ($body) = $text =~ /^($name\(\)\s*\{.*?^\})$/ms; - BAIL_OUT("$label: $name() not found in $path") unless defined $body; + die("$label: $name() not found in $path") unless defined $body; return $body; } diff --git a/xCAT-test/unit/genesis_dhcp_client.t b/xCAT-test/unit/genesis_dhcp_client.t index e50ccae40..8ddd03e00 100644 --- a/xCAT-test/unit/genesis_dhcp_client.t +++ b/xCAT-test/unit/genesis_dhcp_client.t @@ -23,9 +23,7 @@ my $ISC6 = 'dhclient -6 -pf /var/run/dhclient6.eth0.pid eth0 -lf /var/lib/dhcl my $source = read_text( repo_path($DOXCAT) ); my $tmpdir = tempdir( CLEANUP => 1 ); -# The failure this captures: doxcat named dhclient at six call sites, so on a release that -# packages no ISC client Genesis reported "dhclient: command not found" and no node ever got -# an address. +# A release that packages no ISC client has no dhclient, so doxcat must not name one directly. ok( $source !~ qr/^\s*dhclient\s/m, 'doxcat starts no command line with dhclient' ); ok( $source !~ qr/;\s*dhclient\s/, diff --git a/xCAT-test/unit/genesis_payload_verification.t b/xCAT-test/unit/genesis_payload_verification.t index 7e4be6ba6..c14f5f045 100644 --- a/xCAT-test/unit/genesis_payload_verification.t +++ b/xCAT-test/unit/genesis_payload_verification.t @@ -1,6 +1,6 @@ #!/usr/bin/env perl -# Drive verify-genesis-payload against payload trees that reproduce the three holes the -# released legacy Genesis image shipped with. +# Drive verify-genesis-payload against payload trees that each leave out one thing the image +# needs. use strict; use warnings; @@ -25,8 +25,7 @@ my $good = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1 my ($rc, $err) = run($good, 'usr/sbin/dhclient'); is($rc, 0, 'a complete payload passes') or diag($err); -# doxcat calls dhclient with ISC flags. The released el9 image carried dhclient.conf and -# dhclient-script but no dhclient, so Genesis never acquired an address. +# doxcat calls dhclient with ISC flags. dhclient.conf and dhclient-script are not enough. my $nodhcp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 0, mktemp => 1); ($rc, $err) = run($nodhcp, 'usr/sbin/dhclient'); isnt($rc, 0, 'a payload without dhclient fails'); @@ -49,16 +48,14 @@ my $nolocale = build_payload(sshd_execs_session => 1, session_helper => 1, tmux isnt($rc, 0, 'a payload with tmux and no UTF-8 locale fails'); like($err, qr{C\.utf8}, 'the missing locale is named'); -# getdestiny makes its request file with mktemp. Without it the node never reports its destiny, -# so xcatd never sets nodelist.status and the node stays at powering-on. +# getdestiny makes its request file with mktemp. my $nomktemp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 1, mktemp => 0); ($rc, $err) = run($nomktemp, 'usr/sbin/dhclient'); 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. +# installs has to be checked against the payload. 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)]); diff --git a/xCAT-test/unit/genesis_root_home.t b/xCAT-test/unit/genesis_root_home.t index 7ce1ca5fe..a7180b2aa 100644 --- a/xCAT-test/unit/genesis_root_home.t +++ b/xCAT-test/unit/genesis_root_home.t @@ -22,9 +22,8 @@ my @HOOKS = ( 'xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh', ); -# dracut 99base writes the root entry itself. Up to dracut 057 the password field is -# always x. From dracut 060 the x arrives only with --hostonly, and the Genesis image is -# built with -N, so el10 (dracut 107) ships an empty password field. +# dracut 99base writes the root entry itself. Up to dracut 057 the password field is always +# x; from dracut 060 the x arrives only with --hostonly, and the Genesis image is built -N. my %SHIPPED = ( 'dracut 049/057 (el8, el9)' => "root:x:0:0::/root:/bin/sh\n", 'dracut 107 (el10)' => "root::0:0::/root:/bin/sh\n", @@ -62,7 +61,7 @@ sub extract_passwd_block { my ($path, $label) = @_; my $text = read_text($path); my ($block) = $text =~ m{^(sed [^\n]*/etc/passwd\ncat >>/etc/passwd <<"__ENDL"\n.*?^__ENDL)$}ms; - BAIL_OUT("$label: the /etc/passwd rewrite was not found") unless defined $block; + die("$label: the /etc/passwd rewrite was not found") unless defined $block; return $block; } @@ -80,11 +79,11 @@ sub run_rewrite { my $script = $block; my $hits = ($script =~ s{/etc/passwd}{$passwd}g); - BAIL_OUT("$label: expected 2 references to /etc/passwd, found $hits") unless $hits == 2; - BAIL_OUT("$label: a reference to the real /etc/passwd survived") if index($script, '/etc/passwd') >= 0; + die("$label: expected 2 references to /etc/passwd, found $hits") unless $hits == 2; + die("$label: a reference to the real /etc/passwd survived") if index($script, '/etc/passwd') >= 0; write_text("$dir/rewrite.sh", "set -e\n$script\n"); system('/bin/bash', "$dir/rewrite.sh") == 0 - or BAIL_OUT("$label: the /etc/passwd rewrite failed to run"); + or die("$label: the /etc/passwd rewrite failed to run"); return read_text($passwd); } diff --git a/xCAT-test/unit/genesis_spec_target_arch.t b/xCAT-test/unit/genesis_spec_target_arch.t index 8f9ea785a..fa69c4c19 100644 --- a/xCAT-test/unit/genesis_spec_target_arch.t +++ b/xCAT-test/unit/genesis_spec_target_arch.t @@ -1,9 +1,8 @@ #!/usr/bin/env perl # The genesis specs name their package after the target arch: xCAT-genesis-scripts- and # xCAT-genesis-base-. %{tarch} comes from an %ifarch ladder, and an arch missing from that -# ladder leaves the macro UNEXPANDED instead of failing: rpm then builds a package literally named -# "xCAT-genesis-scripts-%{tarch}", buildrpms.pl cannot find the srpm it asked for, and the whole -# target build dies with a "Cannot find/open srpm" that names the right file. +# ladder leaves the macro unexpanded instead of failing, so rpm builds a package whose Name +# carries the macro. # # Expand each spec with rpmspec for every arch xCAT supports and assert the Name carries that arch. use strict; diff --git a/xCAT-test/unit/genesis_testcase_helpers.t b/xCAT-test/unit/genesis_testcase_helpers.t index b07c51594..2747bf314 100644 --- a/xCAT-test/unit/genesis_testcase_helpers.t +++ b/xCAT-test/unit/genesis_testcase_helpers.t @@ -52,8 +52,7 @@ is(os_for("NAME=\"Ubuntu\"\nID=ubuntu\n"), 'ubuntu', 'Ubuntu is still 1, 'report_genesis_files propagates the failure to its caller'); } -# Genesis generates new host keys at every boot and each case boots the node several times, so -# the second boot met "REMOTE HOST IDENTIFICATION HAS CHANGED" and xdsh could not reach it. +# Genesis generates new host keys at every boot, and each case boots the node several times. { no warnings 'once'; eval_subs($source, qw(forget_host_keys testxdsh)); @@ -65,8 +64,7 @@ is(os_for("NAME=\"Ubuntu\"\nID=ubuntu\n"), 'ubuntu', 'Ubuntu is still } # xCAT sets nodelist.status from the destiny the node reports with getdestiny: "shell" for the -# shell destiny, "configuring" for runcmd. A Genesis node never reaches "booted" -- that status -# belongs to an operating system install reporting through updateflag. +# shell destiny, "configuring" for runcmd. "booted" belongs to an operating system install. { no warnings 'once'; local $GenesisTest::noderange = 'xcat71-cn'; @@ -147,11 +145,11 @@ sub eval_subs { $code .= "sub send_msg { push \@GenesisTest::MSG, \$_[1]; return 0; }\n"; foreach my $name (@names) { my ($body) = $text =~ /^(sub \Q$name\E \{.*?^\})$/ms; - BAIL_OUT("sub $name() not found in $helper") unless defined $body; + die("sub $name() not found in $helper") unless defined $body; $code .= "$body\n"; } $code .= "1;\n"; - eval $code or BAIL_OUT("cannot compile the extracted helpers: $@"); + eval $code or die("cannot compile the extracted helpers: $@"); } #--- @@ -162,7 +160,7 @@ sub waiter_name { foreach my $name (qw(wait_for_node_status wait_for_boot)) { return $name if $text =~ /^sub \Q$name\E \{/m; } - BAIL_OUT("no destiny status check found in $helper"); + die("no destiny status check found in $helper"); } #--- diff --git a/xCAT-test/unit/go_xcat_genesis_package_names.t b/xCAT-test/unit/go_xcat_genesis_package_names.t index 844bc275f..29e37ae8d 100644 --- a/xCAT-test/unit/go_xcat_genesis_package_names.t +++ b/xCAT-test/unit/go_xcat_genesis_package_names.t @@ -1,10 +1,8 @@ #!/usr/bin/env perl # go-xcat installs and uninstalls a fixed list of package names, and it keeps one list per # packaging format. The Genesis packages are named after the architecture, and the two formats -# spell that architecture differently: the rpm is xCAT-genesis-scripts-ppc64, the deb is -# xcat-genesis-scripts-ppc64el. A name that no repository publishes makes apt fail the whole -# transaction, so one stale entry stops "go-xcat install" and "go-xcat uninstall" on that -# architecture. +# spell it differently: the rpm is xCAT-genesis-scripts-ppc64, the deb is +# xcat-genesis-scripts-ppc64el. # # The lists are built by go-xcat itself here, not read as text: the deb list exists only when # "type dpkg" succeeds, so a shell function decides which branch each run takes. @@ -17,7 +15,7 @@ use Test::More; my $root = "$FindBin::Bin/../.."; my $go_xcat = "$root/xCAT-server/share/xcat/tools/go-xcat"; -BAIL_OUT("go-xcat not found at $go_xcat") unless -f $go_xcat; +die("go-xcat not found at $go_xcat") unless -f $go_xcat; my $tmpdir = tempdir(CLEANUP => 1); my $driver = "$tmpdir/driver.sh"; @@ -60,7 +58,7 @@ sub package_lists { $list{$which} = [ split /\s+/, ($packages // '') ]; } close($out); - BAIL_OUT('go-xcat package arrays could not be evaluated') + die('go-xcat package arrays could not be evaluated') unless $list{install} && $list{uninstall}; return \%list; } @@ -82,16 +80,16 @@ sub named { my $rpm = package_lists(0); my $deb = package_lists(1); -BAIL_OUT('the dpkg branch of go-xcat was not taken') +die('the dpkg branch of go-xcat was not taken') unless grep { $_ eq 'xcat-client' } @{ $deb->{install} }; -BAIL_OUT('the rpm branch of go-xcat was not taken') +die('the rpm branch of go-xcat was not taken') unless grep { $_ eq 'xCAT-client' } @{ $rpm->{install} }; # The deb names come from the packaging: one control file per Debian architecture names the # genesis-scripts package, and its Depends names the genesis-base package that carries the # Genesis tree for that same architecture. my @control = sort glob("$root/xCAT-genesis-scripts/debian/control-*"); -BAIL_OUT('no xCAT-genesis-scripts Debian control files') unless @control; +die('no xCAT-genesis-scripts Debian control files') unless @control; my (@deb_scripts, @deb_base); for my $control (@control) { my $text = slurp($control); @@ -112,7 +110,7 @@ for my $which (qw(install uninstall)) { # architecture name. my %tarch = map { $_ => 1 } (slurp("$root/xCAT-genesis-builder/xCAT-genesis-base.spec") =~ /^%define\s+tarch\s+(\S+)/mg); -BAIL_OUT('no Genesis target architectures in xCAT-genesis-base.spec') unless %tarch; +die('no Genesis target architectures in xCAT-genesis-base.spec') unless %tarch; for my $which (qw(install uninstall)) { for my $prefix (qw(xCAT-genesis-scripts- xCAT-genesis-base-)) { diff --git a/xCAT-test/unit/kvm_createstorage_model.t b/xCAT-test/unit/kvm_createstorage_model.t index 0ea406132..7128f151e 100644 --- a/xCAT-test/unit/kvm_createstorage_model.t +++ b/xCAT-test/unit/kvm_createstorage_model.t @@ -17,7 +17,7 @@ my @routines; for my $name (qw(createstorage build_diskstruct guest_arch_profile getUnits default_storagemodel)) { my ($routine) = $content =~ /^(sub \Q$name\E\s*\{.*?^\})/ms; - BAIL_OUT("could not extract $name from kvm.pm") unless $routine; + die("could not extract $name from kvm.pm") unless $routine; push(@routines, $routine); } @@ -34,7 +34,7 @@ sub get_multiple_paths_by_url { return {}; } PERL eval $harness . join("\n", @routines) . "\n1;\n"; ## no critic (BuiltinFunctions::ProhibitStringyEval) -BAIL_OUT("could not load the kvm storage routines: $@") if $@; +die("could not load the kvm storage routines: $@") if $@; # The name createstorage gives the volume of one node. $stale is a capture left live in this # block by an earlier successful match, which is the state createstorage runs in when a @@ -81,13 +81,12 @@ is(volume_dev(storage => 'dir:///var/lib/libvirt/images/=scsi'), 'sda', is(volume_dev(storagemodel => 'virtio'), 'vda', 'vmstoragemodel=virtio names a vd* volume'); -# createstorage on its own defaults to ide. Nothing in the product reaches this today, because -# dohyp gives every node the default storage model first. +# createstorage on its own defaults to ide. Nothing in the product reaches this today: dohyp +# gives every node the default storage model first. is(volume_dev(), 'hda', 'createstorage alone defaults to an hd* volume'); -# So the sd* name of a node with no vmstoragemodel rests on that default, and a riscv64 node -# rests on the sd* name. Drive the two together, so a change to the default fails here rather -# than on a riscv64 node that stops booting. +# A node with no vmstoragemodel takes its sd* name from that default, so the two are driven +# together. is(volume_dev(storagemodel => KVMStore::default_storagemodel()), 'sda', 'the default storage model names an sd* volume'); diff --git a/xCAT-test/unit/kvm_diskstruct_bus.t b/xCAT-test/unit/kvm_diskstruct_bus.t index 2087c9975..a02c1c2b3 100644 --- a/xCAT-test/unit/kvm_diskstruct_bus.t +++ b/xCAT-test/unit/kvm_diskstruct_bus.t @@ -16,7 +16,7 @@ close($source_fh) or die "close $source: $!"; my @routines; for my $name (qw(build_diskstruct guest_arch_profile getUnits)) { my ($routine) = $content =~ /^(sub \Q$name\E\s*\{.*?^\})/ms; - BAIL_OUT("could not extract $name from kvm.pm") unless $routine; + die("could not extract $name from kvm.pm") unless $routine; push(@routines, $routine); } @@ -30,7 +30,7 @@ sub get_multiple_paths_by_url { return $pool; } PERL eval $harness . join("\n", @routines) . "\n1;\n"; ## no critic (BuiltinFunctions::ProhibitStringyEval) -BAIL_OUT("could not load the kvm disk builder: $@") if $@; +die("could not load the kvm disk builder: $@") if $@; # Build the disks of a node of $arch whose vmstorage is a libvirt pool holding the volumes # in $pool: a path => { device, format } map, the shape get_multiple_paths_by_url returns. @@ -54,7 +54,7 @@ sub pool_disks { local *STDOUT = $capture; ($disks) = KVMDisk::build_diskstruct(undef); } - BAIL_OUT('build_diskstruct returned no disks') unless ref $disks eq 'ARRAY'; + die('build_diskstruct returned no disks') unless ref $disks eq 'ARRAY'; return $disks; } diff --git a/xCAT-test/unit/kvm_guest_arch.t b/xCAT-test/unit/kvm_guest_arch.t index 763722ef7..34ba7649a 100644 --- a/xCAT-test/unit/kvm_guest_arch.t +++ b/xCAT-test/unit/kvm_guest_arch.t @@ -16,7 +16,7 @@ close($source_fh) or die "close $source: $!"; my @routines; for my $name (qw(build_xmldesc guest_arch_profile build_oshash build_diskstruct getUnits)) { my ($routine) = $content =~ /^(sub \Q$name\E\s*\{.*?^\})/ms; - BAIL_OUT("could not extract $name from kvm.pm") unless $routine; + die("could not extract $name from kvm.pm") unless $routine; push(@routines, $routine); } @@ -34,7 +34,7 @@ sub genpassword { return 'password'; } PERL eval $harness . join("\n", @routines) . "\n1;\n"; ## no critic (BuiltinFunctions::ProhibitStringyEval) -BAIL_OUT("could not load the kvm domain builder: $@") if $@; +die("could not load the kvm domain builder: $@") if $@; # Build one domain for a node of $guest_arch on a hypervisor that reports $hyp_cpumodel. sub domain_xml { @@ -47,7 +47,7 @@ sub domain_xml { }; local $KVMArch::updatetable = {}; my $xml = KVMArch::build_xmldesc('cn1'); - BAIL_OUT("build_xmldesc returned no XML for $guest_arch on $hyp_cpumodel") + die("build_xmldesc returned no XML for $guest_arch on $hyp_cpumodel") unless defined $xml and !ref $xml; return $xml; } diff --git a/xCAT-test/unit/mknb_genesis_staging.t b/xCAT-test/unit/mknb_genesis_staging.t index 262401f41..81fb7ae77 100644 --- a/xCAT-test/unit/mknb_genesis_staging.t +++ b/xCAT-test/unit/mknb_genesis_staging.t @@ -1,8 +1,6 @@ #!/usr/bin/env perl -# mknb stages the Genesis payload before it can build a netboot image. Those copies are the -# only point at which mknb learns that an installed Genesis image is unusable, so a copy that -# fails silently produces an initramfs built from nothing and an exit status of 0 -- the node -# then never boots, with no error anywhere naming the cause. +# mknb stages the Genesis payload before it can build a netboot image. Those copies are the only +# point at which mknb learns that an installed Genesis image is unusable. use strict; use warnings; @@ -18,7 +16,7 @@ BEGIN { $INC{'xCAT/Utils.pm'} = 1; $INC{'xCAT/MsgUtils.pm'} = 1; require "$FindBin::Bin/../../xCAT-server/lib/xcat/plugins/mknb.pm"; can_ok('xCAT_plugin::mknb', 'stage_genesis_payload') - or BAIL_OUT('mknb has no stage_genesis_payload to drive'); + or die('mknb has no stage_genesis_payload to drive'); # Drive the routine with a runner that fails exactly one copy, so each assertion names the # copy it is about rather than the pair. diff --git a/xCAT-test/unit/xcat_probe_package_payload.t b/xCAT-test/unit/xcat_probe_package_payload.t index 58ff90990..bbd363e6c 100644 --- a/xCAT-test/unit/xcat_probe_package_payload.t +++ b/xCAT-test/unit/xcat_probe_package_payload.t @@ -148,7 +148,7 @@ sub copy_tree { my ($source, $destination) = @_; my $rc = system('cp', '-R', $source, $destination); is($rc, 0, "copied $source into the package fixture") - or BAIL_OUT("unable to create package fixture from $source"); + or die("unable to create package fixture from $source"); } sub run_command { diff --git a/xCAT-test/unit/xcattest_report_every_check.t b/xCAT-test/unit/xcattest_report_every_check.t index 4479de6ab..2dd3e9824 100644 --- a/xCAT-test/unit/xcattest_report_every_check.t +++ b/xCAT-test/unit/xcattest_report_every_check.t @@ -9,7 +9,7 @@ use File::Temp qw(tempdir); use Test::More; my $program = "$FindBin::Bin/../xcattest"; -BAIL_OUT("xcattest is not at $program") unless -f $program; +die("xcattest is not at $program") unless -f $program; #--- =head3 run_harness @@ -29,29 +29,29 @@ sub run_harness { # under the scratch tree keeps every file the run writes inside that tree. my $root = tempdir(CLEANUP => 1); make_path("$root/bin", "$root/cases"); - copy($program, "$root/bin/xcattest") or BAIL_OUT("copy xcattest: $!"); + copy($program, "$root/bin/xcattest") or die("copy xcattest: $!"); chmod 0755, "$root/bin/xcattest"; - open(my $case_fh, '>', "$root/cases/fixture") or BAIL_OUT("write the fixture case: $!"); + open(my $case_fh, '>', "$root/cases/fixture") or die("write the fixture case: $!"); print $case_fh $case_text; - close($case_fh) or BAIL_OUT("close the fixture case: $!"); + close($case_fh) or die("close the fixture case: $!"); local $ENV{XCATTEST_CASEDIR} = "$root/cases"; system($^X, "$root/bin/xcattest", '-q', '-t', join(',', @names)); my $slurp = sub { my ($path) = @_; - open(my $fh, '<', $path) or BAIL_OUT("open $path: $!"); + open(my $fh, '<', $path) or die("open $path: $!"); my @lines = <$fh>; - close($fh) or BAIL_OUT("close $path: $!"); + close($fh) or die("close $path: $!"); chomp(@lines); return @lines; }; my ($log) = glob("$root/share/xcat/tools/autotest/result/xcattest.log.*"); - BAIL_OUT("the harness wrote no running log under $root") unless $log; + die("the harness wrote no running log under $root") unless $log; my ($failed) = glob("$root/share/xcat/tools/autotest/result/failedcases.*"); - BAIL_OUT("the harness wrote no failed-cases report under $root") unless $failed; + die("the harness wrote no failed-cases report under $root") unless $failed; return ([ $slurp->($log) ], [ $slurp->($failed) ]); } diff --git a/xCAT-test/xcattest b/xCAT-test/xcattest index 835788c03..25eaf9d6c 100755 --- a/xCAT-test/xcattest +++ b/xCAT-test/xcattest @@ -1417,9 +1417,8 @@ sub run_case { log_this($running_log_fd, ("ElapsedTime:$diffduration sec", "RETURN rc = $rc", "OUTPUT:", @output)); push(@caselog, ("ElapsedTime:$diffduration sec", "RETURN rc = $rc", "OUTPUT:", @output)); - # $checkfail is the result of this check, $failflag the result of the case. They - # were one variable, so a failed check made every later check read as failed, and - # the guard that hid that also hid the checks (issue #76). + # $checkfail is the result of this check, $failflag the result of the case. One + # variable for both makes a failed check read as failing every later check. my $checkfail = 0; foreach my $check (@{ $cases_ref->[ $case_name_index_map_ref->{$case} ]->{check}->[$j] }) { $checkfail = 0; From 79b2e444982e2f944acc2735a76abeeeb22b88ee Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:50:36 -0300 Subject: [PATCH 35/37] style(xcat-core): the payload test comment describes the verifier that was replaced The comment above the /usr/bin/awk case in genesis_payload_verification.t said what the verifier used to do with an absolute path. The rule the reader needs is what dracut_install does with one. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/genesis_payload_verification.t | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/xCAT-test/unit/genesis_payload_verification.t b/xCAT-test/unit/genesis_payload_verification.t index c14f5f045..d1334b24b 100644 --- a/xCAT-test/unit/genesis_payload_verification.t +++ b/xCAT-test/unit/genesis_payload_verification.t @@ -68,9 +68,8 @@ my $noopenssl = build_payload(sshd_execs_session => 1, session_helper => 1, tmux isnt($rc, 0, 'a payload without openssl fails'); like($err, qr/openssl/, 'the missing openssl is named'); -# dracut_install installs an absolute path at that same path. The verifier used to drop every -# name that started with "/", so an image with no /usr/bin/awk passed. doxcat, getdestiny and -# the firmware wrappers all run awk. +# dracut_install installs an absolute path at that same path, so a name starting with "/" is a +# command the payload must carry. doxcat, getdestiny and the firmware wrappers all run awk. my $noawk = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 1, mktemp => 1, commands => [qw(openssl wget tar)], absent => ['usr/bin/awk']); ($rc, $err) = run_with_commands($module, $noawk); From 4b9572a0a329788808bc87c1034433b67cdf6d16 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:07:49 -0300 Subject: [PATCH 36/37] test(xcat-core): the Genesis shell tests are written as Perl programs Eight unit tests measure shell code: the Genesis dracut cmdline hooks, doxcat, getcert, the two Genesis deb builders, go-xcat and the genesis test case. The Perl in each one is scaffolding. It reads the script, lifts a block out with a regular expression, writes a wrapper, shells out and reads the files back. A reader follows two languages to reach one assertion, and the scaffolding is longer than the assertion. xCAT-test/bats already states this kind of assertion in the language of the thing under test, and the xcat_test workflow runs it. The eight files move there. Each one keeps what it proved: the rpm architecture becomes the Debian architecture and names the deb it supersedes, the dracut hook picks the console mode the multiplexer can provide, the hook gives root the home directory /, getcert stops when the image ships no openssl, the genesis case defines its node with the architecture of the management node and fails when nodeset fails, doxcat picks dhcpcd where the release drops the ISC client, and go-xcat names the Genesis packages the packaging builds. helpers/shell_source.bash gains refute_grep. bash ignores errexit for a command inverted with "!", so "! grep" anywhere but the last line of a test can never fail it. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/bats/genesis_base_deb_arch.bats | 91 ++++++++++ .../genesis_base_deb_control_rewrite.bats | 57 +++++++ xCAT-test/bats/genesis_console_mode.bats | 68 ++++++++ xCAT-test/bats/genesis_dhcp_client.bats | 122 +++++++++++++ .../bats/genesis_getcert_missing_openssl.bats | 72 ++++++++ .../bats/genesis_incorrectmasterip_check.bats | 111 ++++++++++++ xCAT-test/bats/genesis_root_home.bats | 89 ++++++++++ .../bats/go_xcat_genesis_package_names.bats | 129 ++++++++++++++ xCAT-test/bats/helpers/shell_source.bash | 10 ++ xCAT-test/unit/genesis_base_deb_arch.t | 103 ----------- .../unit/genesis_base_deb_control_rewrite.t | 64 ------- xCAT-test/unit/genesis_console_mode.t | 85 --------- xCAT-test/unit/genesis_dhcp_client.t | 161 ------------------ .../unit/genesis_getcert_missing_openssl.t | 81 --------- .../unit/genesis_incorrectmasterip_check.t | 112 ------------ xCAT-test/unit/genesis_root_home.t | 89 ---------- .../unit/go_xcat_genesis_package_names.t | 124 -------------- 17 files changed, 749 insertions(+), 819 deletions(-) create mode 100644 xCAT-test/bats/genesis_base_deb_arch.bats create mode 100644 xCAT-test/bats/genesis_base_deb_control_rewrite.bats create mode 100644 xCAT-test/bats/genesis_console_mode.bats create mode 100644 xCAT-test/bats/genesis_dhcp_client.bats create mode 100644 xCAT-test/bats/genesis_getcert_missing_openssl.bats create mode 100644 xCAT-test/bats/genesis_incorrectmasterip_check.bats create mode 100644 xCAT-test/bats/genesis_root_home.bats create mode 100644 xCAT-test/bats/go_xcat_genesis_package_names.bats delete mode 100644 xCAT-test/unit/genesis_base_deb_arch.t delete mode 100644 xCAT-test/unit/genesis_base_deb_control_rewrite.t delete mode 100644 xCAT-test/unit/genesis_console_mode.t delete mode 100644 xCAT-test/unit/genesis_dhcp_client.t delete mode 100644 xCAT-test/unit/genesis_getcert_missing_openssl.t delete mode 100644 xCAT-test/unit/genesis_incorrectmasterip_check.t delete mode 100644 xCAT-test/unit/genesis_root_home.t delete mode 100644 xCAT-test/unit/go_xcat_genesis_package_names.t diff --git a/xCAT-test/bats/genesis_base_deb_arch.bats b/xCAT-test/bats/genesis_base_deb_arch.bats new file mode 100644 index 000000000..5d618d3c8 --- /dev/null +++ b/xCAT-test/bats/genesis_base_deb_arch.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats +# +# debuild-xcat-genesis-base converts the EL Genesis base rpm to a deb. The rpm name carries the +# Genesis target architecture, and the deb must carry the Debian architecture: ppc64 becomes +# ppc64el, x86_64 becomes amd64. An unmapped architecture leaves the deb named after the rpm and +# makes it break a genesis-scripts package that no repository publishes. The rename also has to +# name the deb it supersedes, or an upgraded ppc node keeps xcat-genesis-base-ppc64 as well. +# +# The script is driven here with alien shadowed by a shell function. + +load 'helpers/shell_source' + +setup() +{ + SCRIPT="$(repo_path 'xCAT-genesis-builder/debuild-xcat-genesis-base')" + [ -r "$SCRIPT" ] || skip "$SCRIPT is required" + export SCRIPT +} + +# alien names the deb after the rpm: lower case, and "_" written as "-". +shadow_alien() +{ + alien() + { + local rpm="${!#}" + local name="${rpm##*/}" + name="${name%.rpm}" + local dir="${name%%-snap*}" + local package="${dir%-*}" + package="${package,,}" + package="${package//_/-}" + + mkdir -p "${dir}/debian" + cat >"${dir}/debian/control" < + +Package: ${package} +Architecture: all +Description: xCAT genesis base +CONTROL + printf '%s (%s) unstable; urgency=low\n' "${package}" "1.0" \ + >"${dir}/debian/changelog" + printf '#!/usr/bin/make -f\nbinary:\n\t@true\n' >"${dir}/debian/rules" + chmod 0755 "${dir}/debian/rules" + } +} + +# Convert one rpm name. Sets SOURCE_DIR to the produced source directory and CONTROL to its +# control file. +convert() +{ + local rpm="$1" + local work="${BATS_TEST_TMPDIR}/convert" + + rm -rf "$work" + mkdir -p "$work" + ( + shadow_alien + cd "$work" || exit 1 + : >"$rpm" + source "$SCRIPT" "$rpm" >/dev/null 2>&1 + ) + + SOURCE_DIR="$(find "$work" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | head -1)" + [ -n "$SOURCE_DIR" ] || return 1 + CONTROL="$work/$SOURCE_DIR/debian/control" + [ -f "$CONTROL" ] || return 1 +} + +@test "the x86_64 rpm becomes the amd64 deb, and replaces the package it supersedes" { + convert 'xCAT-genesis-base-x86_64-2.13.10-snap202601010000.noarch.rpm' + + [[ "$SOURCE_DIR" == *-amd64-* ]] + grep -qx 'Package: xcat-genesis-base-amd64' "$CONTROL" + grep -qE '^Breaks:.*\bxcat-genesis-scripts-amd64\b' "$CONTROL" + grep -qx 'Replaces: xcat-genesis-amd64' "$CONTROL" + grep -qE '^Breaks: xcat-genesis-amd64\b' "$CONTROL" +} + +@test "the ppc64 rpm becomes the ppc64el deb, and replaces the deb the rename leaves behind" { + convert 'xCAT-genesis-base-ppc64-2.13.10-snap202601010000.noarch.rpm' + + [[ "$SOURCE_DIR" == *-ppc64el-* ]] + grep -qx 'Package: xcat-genesis-base-ppc64el' "$CONTROL" + grep -qE '^Breaks:.*\bxcat-genesis-scripts-ppc64el\b' "$CONTROL" + grep -qx 'Replaces: xcat-genesis-ppc64, xcat-genesis-base-ppc64' "$CONTROL" + grep -qE '^Breaks: xcat-genesis-ppc64, xcat-genesis-base-ppc64\b' "$CONTROL" +} diff --git a/xCAT-test/bats/genesis_base_deb_control_rewrite.bats b/xCAT-test/bats/genesis_base_deb_control_rewrite.bats new file mode 100644 index 000000000..188671c67 --- /dev/null +++ b/xCAT-test/bats/genesis_base_deb_control_rewrite.bats @@ -0,0 +1,57 @@ +#!/usr/bin/env bats +# +# builddeb-genesis-base builds the Genesis base deb natively on Ubuntu. It writes the target +# architecture into debian/control, which is held in the amd64 form in the tree. 2.19 renames +# the ppc64 debs to ppc64el, so the ppc control must also name the deb it supersedes: without +# the relation dpkg keeps xcat-genesis-base-ppc64 installed beside the new package, and that +# old package owns the same files under /opt/xcat/share/xcat/netboot/genesis. +# +# The script needs dracut and root, so rewrite_control() is lifted out of it and run alone +# against the control file the tree ships. + +load 'helpers/shell_source' + +setup() +{ + SCRIPT="$(repo_path 'xCAT-genesis-builder/builddeb-genesis-base')" + CONTROL="$(repo_path 'xCAT-genesis-builder/debian/control')" + [ -r "$SCRIPT" ] || skip "$SCRIPT is required" + [ -r "$CONTROL" ] || skip "$CONTROL is required" + export SCRIPT CONTROL +} + +# Run the lifted rewrite_control() over a copy of the control file in the tree, and print it. +rewrite() +{ + local arch="$1" function copy="${BATS_TEST_TMPDIR}/control.$1" + + function="$(extract_shell_function "$SCRIPT" rewrite_control)" || + { echo 'rewrite_control() no longer matches in builddeb-genesis-base' >&2; return 99; } + cp "$CONTROL" "$copy" + ( + set -eu + eval "$function" + rewrite_control "$copy" "$arch" + ) || return 1 + cat "$copy" +} + +@test "the amd64 control names the package and the genesis deb it took over from" { + run rewrite amd64 + [ "$status" -eq 0 ] + + [[ "$output" =~ (^|$'\n')"Package: xcat-genesis-base-amd64"($'\n'|$) ]] + [[ "$output" =~ (^|$'\n')"Replaces: xcat-genesis-amd64"($'\n'|$) ]] + [[ "$output" =~ (^|$'\n')"Breaks: xcat-genesis-amd64, " ]] + [[ "$output" =~ "xcat-genesis-scripts-amd64 (<< 2.13.10)" ]] +} + +@test "the ppc64el control also takes over from the ppc64 deb the rename leaves behind" { + run rewrite ppc64el + [ "$status" -eq 0 ] + + [[ "$output" =~ (^|$'\n')"Package: xcat-genesis-base-ppc64el"($'\n'|$) ]] + [[ "$output" =~ (^|$'\n')"Replaces: xcat-genesis-ppc64, xcat-genesis-base-ppc64"($'\n'|$) ]] + [[ "$output" =~ (^|$'\n')"Breaks: xcat-genesis-ppc64, xcat-genesis-base-ppc64, " ]] + [[ "$output" =~ "xcat-genesis-scripts-ppc64el (<< 2.13.10)" ]] +} diff --git a/xCAT-test/bats/genesis_console_mode.bats b/xCAT-test/bats/genesis_console_mode.bats new file mode 100644 index 000000000..98d36f95d --- /dev/null +++ b/xCAT-test/bats/genesis_console_mode.bats @@ -0,0 +1,68 @@ +#!/usr/bin/env bats +# +# Drive xcat_console_mode() out of the Genesis dracut cmdline hook. +# +# The hook cannot be sourced: it mounts filesystems, starts udev and ends in an endless +# loop. Extract the one function and run it with the terminal multiplexer shadowed. + +load 'helpers/shell_source' + +setup() +{ + EL_HOOK="$(repo_path 'xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh')" + UBUNTU_HOOK="$(repo_path 'xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh')" + [ -r "$EL_HOOK" ] || skip "$EL_HOOK is required" + [ -r "$UBUNTU_HOOK" ] || skip "$UBUNTU_HOOK is required" + export EL_HOOK UBUNTU_HOOK +} + +# Run the extracted function with the multiplexer shadowed by a stub that either starts a +# session or refuses, the way tmux refuses without a UTF-8 locale. +run_mode() +{ + local hook="$1" mux="$2" mux_works="$3" body + body="$(extract_shell_function "$hook" xcat_console_mode)" || + { echo "xcat_console_mode() not found in $hook" >&2; return 99; } + ( + eval "$body" + eval "$mux() { + [ \"\$mux_works\" = 1 ] && return 0 + echo '$mux: need UTF-8 locale (LC_CTYPE) but have ANSI_X3.4-1968' >&2 + return 1 + }" + xcat_console_mode + ) 2>/dev/null +} + +# The hook reads the mode once and guards the doxcat loop with it. +assert_hook_guards_doxcat() +{ + local hook="$1" mux="$2" + grep -qx 'XCAT_CONSOLE_MODE="$(xcat_console_mode)"' "$hook" + grep -qFx "if [ \"\$XCAT_CONSOLE_MODE\" = \"$mux\" ]; then" "$hook" + grep -A1 '^else$' "$hook" | grep -qx ' while :; do doxcat; sleep 5; done' +} + +@test "the el hook leaves no unguarded tmux loop and exports a UTF-8 locale" { + # tmux exits under the C locale, so an unguarded tmux loop never reaches doxcat. + refute_grep -q '^while :; do tmux attach-session' "$EL_HOOK" + grep -qx 'export LC_ALL=C.UTF-8' "$EL_HOOK" +} + +@test "el: xcat_console_mode reports the mode tmux can actually provide" { + [ "$(run_mode "$EL_HOOK" tmux 0)" = direct ] + [ "$(run_mode "$EL_HOOK" tmux 1)" = tmux ] +} + +@test "el: the hook resolves the console mode once and runs doxcat directly without tmux" { + assert_hook_guards_doxcat "$EL_HOOK" tmux +} + +@test "ubuntu: xcat_console_mode reports the mode screen can actually provide" { + [ "$(run_mode "$UBUNTU_HOOK" screen 0)" = direct ] + [ "$(run_mode "$UBUNTU_HOOK" screen 1)" = screen ] +} + +@test "ubuntu: the hook resolves the console mode once and runs doxcat directly without screen" { + assert_hook_guards_doxcat "$UBUNTU_HOOK" screen +} diff --git a/xCAT-test/bats/genesis_dhcp_client.bats b/xCAT-test/bats/genesis_dhcp_client.bats new file mode 100644 index 000000000..f45ec2cca --- /dev/null +++ b/xCAT-test/bats/genesis_dhcp_client.bats @@ -0,0 +1,122 @@ +#!/usr/bin/env bats +# +# Drive the DHCP client selection out of doxcat. +# +# doxcat cannot be sourced: it restarts rsyslogd, reads /proc/cmdline and ends in a loop that +# waits for an address. Extract the two routines and run them with the clients shadowed by +# stubs that record their own argv. + +load 'helpers/shell_source' + +ISC4='dhclient -cf /etc/dhclient.conf -pf /var/run/dhclient.eth0.pid eth0' +ISC6='dhclient -6 -pf /var/run/dhclient6.eth0.pid eth0 -lf /var/lib/dhclient/dhclient6.leases' + +setup() +{ + DOXCAT="$(repo_path 'xCAT-genesis-scripts/usr/bin/doxcat')" + SPEC="$(repo_path 'xCAT-genesis-builder/xCAT-genesis-base.spec')" + MODULE="$(repo_path 'xCAT-genesis-builder/dracut_105/el/module-setup.sh')" + [ -r "$DOXCAT" ] || skip "$DOXCAT is required" + [ -r "$SPEC" ] || skip "$SPEC is required" + [ -r "$MODULE" ] || skip "$MODULE is required" + export DOXCAT SPEC MODULE +} + +# Run the extracted routines with only the named clients on PATH. Sets OUT to the standard +# output, RAN to the recorded argv of whatever ran, and STATUS to the exit status. +probe() +{ + local call="$1" + shift + local dir="${BATS_TEST_TMPDIR}/probe" + local bin="$dir/bin" record="$dir/record" client selector runner + + selector="$(extract_shell_function "$DOXCAT" genesis_dhcp_command)" || + { echo 'doxcat carries no genesis_dhcp_command() to choose the client' >&2; return 99; } + runner="$(extract_shell_function "$DOXCAT" genesis_start_dhcp)" || + { echo 'doxcat carries no genesis_start_dhcp() to run the chosen client' >&2; return 99; } + + rm -rf "$dir" + mkdir -p "$bin" + + # PATH holds the stubs alone, so each one names itself rather than calling basename. + for client in "$@"; do + printf '#!/bin/sh\necho "%s $*" >> "%s"\nexit 0\n' "$client" "$record" >"$bin/$client" + chmod 0755 "$bin/$client" + done + + # logger writes to the console in the image and is not what these assertions measure. + printf '#!/bin/sh\nexit 0\n' >"$bin/logger" + chmod 0755 "$bin/logger" + + printf 'log_label=test\n%s\n%s\n%s\n' "$selector" "$runner" "$call" >"$dir/probe.sh" + OUT="$(PATH="$bin" /bin/bash "$dir/probe.sh" 2>/dev/null)" && STATUS=0 || STATUS=$? + RAN="$(read_file_or_empty "$record")" + return 0 +} + +selected() +{ + local family="$1" + shift + probe "genesis_dhcp_command $family eth0" "$@" + printf '%s\n' "$OUT" +} + +started() +{ + local family="$1" + shift + probe "genesis_start_dhcp $family eth0" "$@" + printf '%s\n' "$RAN" +} + +@test "doxcat names no DHCP client directly" { + # A release that packages no ISC client has no dhclient. + refute_grep -qE '^[[:space:]]*dhclient[[:space:]]' "$DOXCAT" + refute_grep -qE ';[[:space:]]*dhclient[[:space:]]' "$DOXCAT" +} + +@test "the build root and the payload check name the client the release ships" { + # EL8 and EL9 package the ISC client; AlmaLinux 10 baseos packages dhcpcd. The payload + # check has to name the client too, or the build passes with no client in the image again. + grep -A1 '^%if 0%{?rhel} >= 10$' "$SPEC" | grep -qx 'BuildRequires: dhcpcd' + grep -A1 '^%if 0%{?rhel} >= 10$' "$SPEC" | grep -qx 'GENESIS_REQUIRED="usr/sbin/dhcpcd"' +} + +@test "the dracut module installs the client the build root carries" { + # dracut_install reports a missing binary and returns, so naming dhclient alone shipped an + # image with no client at all. + refute_grep -qE '^[[:space:]]*dracut_install dhclient lldpad$' "$MODULE" + grep -qE '^[[:space:]]*dracut_install dhcpcd$' "$MODULE" + grep -qE '^[[:space:]]*dracut_install /usr/libexec/dhcpcd-run-hooks$' "$MODULE" +} + +@test "the ISC client keeps its command lines and is preferred when both are present" { + [ "$(selected 4 dhclient)" = "$ISC4" ] + [ "$(selected 6 dhclient)" = "$ISC6" ] + [ "$(selected 4 dhclient dhcpcd)" = "$ISC4" ] +} + +@test "dhcpcd stands in for dhclient, waiting for a lease and keeping the address" { + # dhcpcd on a single interface exits when its timeout expires, and the default is 30 + # seconds; doxcat waits for the lease for as long as it takes. dhcpcd also de-configures + # the interface when it exits unless it is persistent. + [ "$(selected 4 dhcpcd)" = 'dhcpcd -4 -b -p -t 0 eth0' ] + [ "$(selected 6 dhcpcd)" = 'dhcpcd -6 -b -p -t 0 eth0' ] + [[ "$(selected 4 dhcpcd)" =~ (^|[[:space:]])-t\ 0([[:space:]]|$) ]] + [[ "$(selected 4 dhcpcd)" =~ (^|[[:space:]])-p([[:space:]]|$) ]] +} + +@test "an image with no client chooses nothing, runs nothing and reports a failure" { + [ "$(selected 4)" = '' ] + [ "$(started 4)" = '' ] + + probe 'genesis_start_dhcp 4 eth0' + [ "$STATUS" -ne 0 ] +} + +@test "genesis_start_dhcp runs the client it chose" { + [ "$(started 4 dhcpcd)" = 'dhcpcd -4 -b -p -t 0 eth0' ] + [ "$(started 4 dhclient)" = "$ISC4" ] +} diff --git a/xCAT-test/bats/genesis_getcert_missing_openssl.bats b/xCAT-test/bats/genesis_getcert_missing_openssl.bats new file mode 100644 index 000000000..f88914eda --- /dev/null +++ b/xCAT-test/bats/genesis_getcert_missing_openssl.bats @@ -0,0 +1,72 @@ +#!/usr/bin/env bats +# +# 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. + +load 'helpers/shell_source' + +setup() +{ + GETCERT="$(repo_path 'xCAT-genesis-scripts/usr/bin/getcert')" + [ -r "$GETCERT" ] || skip "$GETCERT is required" + COUNTER="${BATS_TEST_TMPDIR}/req-count" + export GETCERT COUNTER +} + +write_stub() +{ + local dir="$1" name="$2" body="$3" + printf '#!/bin/sh\n%s\n' "$body" >"$dir/$name" + chmod 0755 "$dir/$name" +} + +# A PATH directory holding the commands getcert runs. openssl is absent unless it is asked for. +stub_dir() +{ + local with_openssl="${1:-0}" count="" + local dir="${BATS_TEST_TMPDIR}/bin" + + rm -rf "$dir" + mkdir -p "$dir" + write_stub "$dir" allowcred.awk 'exec sleep 3' + write_stub "$dir" hostname 'echo node1' + write_stub "$dir" logger 'echo "$@" >&2' + write_stub "$dir" sleep 'exec /bin/sleep "$@"' + if [ "$with_openssl" = 1 ]; then + [ -n "${COUNT_REQUESTS:-}" ] && count="echo req >> '$COUNTER'" + write_stub "$dir" openssl "[ \"\$1\" = req ] && { $count ; exit 1; } +exit 0" + fi + printf '%s\n' "$dir" +} + +# Run getcert with only the stub directory on PATH. The timeout is the harness guard: a status +# of 124 means getcert never stopped. +run_getcert() +{ + local bin="$1" limit="$2" csr_timeout="$3" + timeout -k 2 "$limit" env PATH="$bin" GETCERT_CSR_TIMEOUT="$csr_timeout" \ + /bin/bash "$GETCERT" 192.0.2.1:3001 2>&1 , with every ppc64 flavour written as "ppc". + case "$HOST_ARCH" in + ppc64*) LOADER_NAME=ppc ;; + *) LOADER_NAME="$HOST_ARCH" ;; + esac + export SCRIPT HOST_ARCH LOADER_NAME +} + +# Run `test.sh --check ` against a scratch tftp root. test.sh resets PATH, so the xCAT +# commands are shadowed with shell functions, which bash resolves first. The fake nodeset writes +# the boot file the check greps, so the assertion is on the check, not on xCAT. +# +# Sets STATUS, OUTPUT, CHDEF, LOADER_AT_NODESET and LOADER_LEFT. +run_check() +{ + local loader="$1" write_boot_file="$2" nodeset_status="${3:-0}" + local root="${BATS_TEST_TMPDIR}/$loader-$write_boot_file-$nodeset_status" + local tftp="$root/tftpboot" + local boot_loader="$tftp/boot/grub2/grub2.$LOADER_NAME" + local folder write + + rm -rf "$root" + mkdir -p "$tftp/xcat/xnba/nodes" "$tftp/boot/grub2" "$tftp/petitboot" + + case "$loader" in + xnba) folder="$tftp/xcat/xnba/nodes" ;; + petitboot) folder="$tftp/petitboot" ;; + *) folder="$tftp/boot/grub2" ;; + esac + if [ "$write_boot_file" = 1 ]; then + write="printf 'xcatd=192.168.1.1:3001 destiny=shell\n' > '$folder/testnode'" + else + write=":" + fi + + cat >"$root/driver.sh" <> '$root/chdef.log'; } +lsdef() { + if [ "\$1" = "-t" ] && [ "\$2" = "site" ]; then echo "clustersite: master=192.168.9.9"; return 0; fi + echo "Object name: testnode" +} +ifconfig() { printf 'eth0: flags\n inet 192.168.9.9\n\n'; } +netstat() { printf 'Kernel\nIface\neth0\neth1\nlo\n'; } +ip() { return 0; } +makenetworks() { return 0; } +tabdump() { return 0; } +makehosts() { return 0; } +rmdef() { return 0; } +nodeset() { + if [ -e '$boot_loader' ]; then echo yes > '$root/loader.at.nodeset'; else echo no > '$root/loader.at.nodeset'; fi + $write + return $nodeset_status +} +export TFTPDIR='$tftp' +. '$SCRIPT' --check $loader +DRIVER + + OUTPUT="$(/bin/bash "$root/driver.sh" 2>&1)" && STATUS=0 || STATUS=$? + CHDEF="$(read_file_or_empty "$root/chdef.log")" + LOADER_AT_NODESET="$(read_file_or_empty "$root/loader.at.nodeset")" + LOADER_LEFT=0 + [ -e "$boot_loader" ] && LOADER_LEFT=1 + return 0 +} + +@test "the xnba check passes and defines the node with the management node architecture" { + # The case defined its node as ppc64le whatever the management node was, so nodeset could + # not find a genesis kernel for it on x86_64 and the case could never pass there. + run_check xnba 1 + [ "$STATUS" -eq 0 ] || { echo "$OUTPUT"; false; } + [[ "$CHDEF" =~ (^|[[:space:]])arch=$HOST_ARCH([[:space:]]|$) ]] + [ "$HOST_ARCH" = ppc64le ] || + [ "$(grep -cE '(^|[[:space:]])arch=ppc64le([[:space:]]|$)' <<<"$CHDEF")" -eq 0 ] +} + +@test "the check fails when nodeset writes no boot file" { + run_check xnba 0 + [ "$STATUS" -ne 0 ] +} + +@test "the grub2 check reads the grub2 directory, and stages then removes the boot loader" { + # grub2 and petitboot read their configuration from other directories under the tftp root. + # xCAT builds no x86_64 or aarch64 grub2 network boot loader, so grub2.pm stops before it + # configures anything. The check stages one for the node arch and removes it after. + run_check grub2 1 + [ "$STATUS" -eq 0 ] || { echo "$OUTPUT"; false; } + [ "$LOADER_AT_NODESET" = yes ] + [ "$LOADER_LEFT" -eq 0 ] +} + +@test "a nodeset that fails makes the check fail, whatever the boot file holds" { + # grub2.pm writes the boot configuration and only then stops on a missing boot loader. The + # check read the file that failed nodeset had already written, so it passed on the debris. + run_check grub2 1 1 + [ "$STATUS" -ne 0 ] + + run_check xnba 1 1 + [ "$STATUS" -ne 0 ] +} diff --git a/xCAT-test/bats/genesis_root_home.bats b/xCAT-test/bats/genesis_root_home.bats new file mode 100644 index 000000000..cab5e780d --- /dev/null +++ b/xCAT-test/bats/genesis_root_home.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# +# Drive the /etc/passwd rewrite out of the Genesis dracut cmdline hooks. +# +# mknb writes the management node key to /.ssh/authorized_keys for the legacy Genesis +# image, so sshd finds it only while the home directory of root is /. The hook makes it / +# by deleting the root entry the image ships and appending its own. Run that rewrite +# against every root entry shape dracut writes and read back the result. + +load 'helpers/shell_source' + +# dracut 99base writes the root entry itself. Up to dracut 057 the password field is always +# x; from dracut 060 the x arrives only with --hostonly, and the Genesis image is built -N. +DRACUT_049_057='root:x:0:0::/root:/bin/sh' +DRACUT_107='root::0:0::/root:/bin/sh' + +# A user name that starts with root but is not root. The delete must keep this line. +DECOY='rootfsadm:x:501:501::/home/rootfsadm:/sbin/nologin' + +# Lift the /etc/passwd rewrite out of a hook that cannot be sourced: the hook mounts +# filesystems, starts udev and ends in an endless loop. +extract_passwd_block() +{ + local hook="$1" + awk ' + /^sed .*\/etc\/passwd$/ { copy = 1 } + copy { print } + copy && /^__ENDL$/ { found = 1; exit } + END { if (!found) exit 1 } + ' "$hook" +} + +# Run the extracted block against a scratch passwd file and print the result. The block names +# /etc/passwd literally, so the path is redirected into the scratch tree first, and the run is +# refused if any reference to the real file survives: CI runs this as root. +run_rewrite() +{ + local hook="$1" shipped="$2" + local dir="${BATS_TEST_TMPDIR}/rewrite" + local passwd="$dir/passwd" block script + + rm -rf "$dir" + mkdir -p "$dir" + printf '%s\n%s\n' "$shipped" "$DECOY" >"$passwd" + + block="$(extract_passwd_block "$(repo_path "$hook")")" || + { echo "$hook: the /etc/passwd rewrite was not found" >&2; return 99; } + + [ "$(grep -o -F '/etc/passwd' <<<"$block" | wc -l)" -eq 2 ] || + { echo "$hook: expected 2 references to /etc/passwd" >&2; return 98; } + script="${block//\/etc\/passwd/$passwd}" + case "$script" in + */etc/passwd*) echo "$hook: a reference to the real /etc/passwd survived" >&2; return 97 ;; + esac + + bash -c "set -e +$script" || return 1 + cat "$passwd" +} + +assert_root_home_is_slash() +{ + local hook="$1" shipped="$2" passwd + passwd="$(run_rewrite "$hook" "$shipped")" + + [ "$(grep -c '^root:' <<<"$passwd")" -eq 1 ] + [ "$(grep '^root:' <<<"$passwd")" = 'root:x:0:0::/:/bin/bash' ] + grep -qxF "$DECOY" <<<"$passwd" +} + +assert_hook() +{ + local hook="$1" + [ -r "$(repo_path "$hook")" ] || skip "$hook is required" + assert_root_home_is_slash "$hook" "$DRACUT_049_057" + assert_root_home_is_slash "$hook" "$DRACUT_107" +} + +@test "the legacy hook gives root the home directory /" { + assert_hook 'xCAT-genesis-builder/xcat-cmdline.sh' +} + +@test "the el dracut 105 hook gives root the home directory /" { + assert_hook 'xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh' +} + +@test "the ubuntu dracut 105 hook gives root the home directory /" { + assert_hook 'xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh' +} diff --git a/xCAT-test/bats/go_xcat_genesis_package_names.bats b/xCAT-test/bats/go_xcat_genesis_package_names.bats new file mode 100644 index 000000000..871ae884b --- /dev/null +++ b/xCAT-test/bats/go_xcat_genesis_package_names.bats @@ -0,0 +1,129 @@ +#!/usr/bin/env bats +# +# go-xcat installs and uninstalls a fixed list of package names, and it keeps one list per +# packaging format. The Genesis packages are named after the architecture, and the two formats +# spell it differently: the rpm is xCAT-genesis-scripts-ppc64, the deb is +# xcat-genesis-scripts-ppc64el. +# +# The lists are built by go-xcat itself here, not read as text: the deb list exists only when +# "type dpkg" succeeds, so a shell function decides which branch each run takes. + +load 'helpers/go_xcat' +load 'helpers/shell_source' + +setup() +{ + go_xcat_require_source + SCRIPTS_DEBIAN="$(repo_path 'xCAT-genesis-scripts/debian')" + SPEC="$(repo_path 'xCAT-genesis-builder/xCAT-genesis-base.spec')" + [ -d "$SCRIPTS_DEBIAN" ] || skip "$SCRIPTS_DEBIAN is required" + [ -r "$SPEC" ] || skip "$SPEC is required" + export SCRIPTS_DEBIAN SPEC +} + +# Run the array definitions of go-xcat and print the two lists it built, one per line. +package_lists() +{ + local want_dpkg="$1" list_body + list_body="$(awk ' + /^GO_XCAT_INSTALL_LIST=\(/ { copy = 1 } + /^PATH=/ { exit } + copy { print } + ' "$GO_XCAT_SOURCE")" + [ -n "$list_body" ] || { echo 'go-xcat package arrays not found' >&2; return 3; } + ( + if [ "$want_dpkg" = 1 ]; then + dpkg() { :; } + fi + # A real dpkg on the build host would select the deb branch on every run. + PATH="" + eval "$list_body" + printf 'install %s\n' "${GO_XCAT_INSTALL_LIST[*]}" + printf 'uninstall %s\n' "${GO_XCAT_UNINSTALL_LIST[*]}" + ) +} + +package_list() +{ + package_lists "$1" | sed -n "s/^$2 //p" +} + +# The package names of a list, sorted, that start with a prefix. +named() +{ + local prefix="$1" word + for word in $(cat); do + case "$word" in + "$prefix"*) printf '%s\n' "$word" ;; + esac + done | sort +} + +# The deb names come from the packaging: one control file per Debian architecture names the +# genesis-scripts package, and its Depends names the genesis-base package that carries the +# Genesis tree for that same architecture. +control_scripts_packages() +{ + grep -h '^Package:' "$SCRIPTS_DEBIAN"/control-* | awk '{ print $2 }' | sort +} + +control_base_packages() +{ + grep -h '^Depends:' "$SCRIPTS_DEBIAN"/control-* | + grep -o 'xcat-genesis-base-[a-z0-9]\+' | sort +} + +# The Genesis target architectures of the spec, which are not Debian architecture names. +spec_target_arches() +{ + awk '$1 == "%define" && $2 == "tarch" { print $3 }' "$SPEC" | sort -u +} + +# The names of a list that carry an architecture the spec does not define. +unknown_target_arches() +{ + local prefix="$1" name arch + while read -r name; do + arch="${name#"$prefix"}" + spec_target_arches | grep -qx "$arch" || printf '%s\n' "$name" + done +} + +@test "the package lists of go-xcat can be built for both packaging formats" { + [ -n "$(control_scripts_packages)" ] + [ -n "$(spec_target_arches)" ] + + run package_list 1 install + [ "$status" -eq 0 ] + [[ " $output " == *' xcat-client '* ]] + + run package_list 0 install + [ "$status" -eq 0 ] + [[ " $output " == *' xCAT-client '* ]] +} + +@test "the deb install list names the genesis packages the Debian control files declare" { + list="$(package_list 1 install)" + [ "$(printf '%s' "$list" | named 'xcat-genesis-scripts-')" = "$(control_scripts_packages)" ] + [ "$(printf '%s' "$list" | named 'xcat-genesis-base-')" = "$(control_base_packages)" ] +} + +@test "the deb uninstall list names the genesis packages the Debian control files declare" { + list="$(package_list 1 uninstall)" + [ "$(printf '%s' "$list" | named 'xcat-genesis-scripts-')" = "$(control_scripts_packages)" ] + [ "$(printf '%s' "$list" | named 'xcat-genesis-base-')" = "$(control_base_packages)" ] +} + +@test "the rpm install list names only Genesis target architectures" { + list="$(package_list 0 install)" + for prefix in xCAT-genesis-scripts- xCAT-genesis-base-; do + [ -z "$(printf '%s' "$list" | named "$prefix" | unknown_target_arches "$prefix")" ] + done +} + +@test "the rpm uninstall list names only Genesis target architectures" { + list="$(package_list 0 uninstall)" + for prefix in xCAT-genesis-scripts- xCAT-genesis-base-; do + [ -z "$(printf '%s' "$list" | named "$prefix" | unknown_target_arches "$prefix")" ] + done +} diff --git a/xCAT-test/bats/helpers/shell_source.bash b/xCAT-test/bats/helpers/shell_source.bash index 61a0bea8d..4f4384fb2 100644 --- a/xCAT-test/bats/helpers/shell_source.bash +++ b/xCAT-test/bats/helpers/shell_source.bash @@ -130,3 +130,13 @@ extract_first_matching_line() } ' "$file" } + +# grep that fails when the pattern IS present. +# +# Do not write "! grep ..." for this. bash ignores errexit for a command inverted with "!", +# so such a line never fails a test unless it is the last line of one. +refute_grep() +{ + ! grep "$@" + return $? +} diff --git a/xCAT-test/unit/genesis_base_deb_arch.t b/xCAT-test/unit/genesis_base_deb_arch.t deleted file mode 100644 index 238d80c52..000000000 --- a/xCAT-test/unit/genesis_base_deb_arch.t +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env perl -# debuild-xcat-genesis-base converts the EL Genesis base rpm to a deb. The rpm name carries the -# Genesis target architecture, and the deb must carry the Debian architecture: ppc64 becomes -# ppc64el, x86_64 becomes amd64. An unmapped architecture leaves the deb named after the rpm and -# makes it break a genesis-scripts package that no repository publishes. The rename also has to -# name the deb it supersedes, or an upgraded ppc node keeps xcat-genesis-base-ppc64 as well. -# -# The script is driven here with alien and dpkg-buildpackage shadowed by shell functions. -use strict; -use warnings; - -use File::Temp qw(tempdir); -use FindBin; -use Test::More; - -my $root = "$FindBin::Bin/../.."; -my $script = "$root/xCAT-genesis-builder/debuild-xcat-genesis-base"; -die("debuild-xcat-genesis-base not found at $script") unless -f $script; - -my $tmpdir = tempdir(CLEANUP => 1); -my $driver = "$tmpdir/driver.sh"; -open(my $driver_fh, '>', $driver) or die "open $driver: $!"; -print {$driver_fh} <<'DRIVER'; -#!/bin/bash - -# alien names the deb after the rpm: lower case, and "_" written as "-". -alien() { - local rpm="${!#}" - local name="${rpm##*/}" - name="${name%.rpm}" - local dir="${name%%-snap*}" - local package="${dir%-*}" - package="${package,,}" - package="${package//_/-}" - - mkdir -p "${dir}/debian" - cat >"${dir}/debian/control" < - -Package: ${package} -Architecture: all -Description: xCAT genesis base -CONTROL - printf '%s (%s) unstable; urgency=low\n' "${package}" "1.0" \ - >"${dir}/debian/changelog" - printf '#!/usr/bin/make -f\nbinary:\n\t@true\n' >"${dir}/debian/rules" - chmod 0755 "${dir}/debian/rules" -} - -cd "${WORK_DIR}" || exit 1 -: >"${RPM_NAME}" -source "${SCRIPT}" "${RPM_NAME}" >/dev/null 2>&1 -DRIVER -close($driver_fh) or die "close $driver: $!"; - -# Convert one rpm name and return the produced source directory and its control file. -sub convert { - my ($name, $rpm) = @_; - my $work = "$tmpdir/$name"; - mkdir $work or die "mkdir $work: $!"; - local %ENV = (%ENV, SCRIPT => $script, WORK_DIR => $work, RPM_NAME => $rpm); - system('bash', $driver) == 0 or return (undef, ''); - my ($dir) = grep { -d $_ } glob("$work/*"); - return (undef, '') unless defined $dir && -f "$dir/debian/control"; - open(my $fh, '<', "$dir/debian/control") or die "read $dir/debian/control: $!"; - local $/; my $control = <$fh>; close $fh; - $dir =~ s{^\Q$work\E/}{}; - return ($dir, $control); -} - -my %expected = ( - 'xCAT-genesis-base-x86_64-2.13.10-snap202601010000.noarch.rpm' => 'amd64', - 'xCAT-genesis-base-ppc64-2.13.10-snap202601010000.noarch.rpm' => 'ppc64el', -); - -# The package the new deb takes over from. On ppc that is the deb this rename leaves behind: -# without the relation dpkg keeps xcat-genesis-base-ppc64 and its copy of the same files. -my %superseded = ( - 'amd64' => 'xcat-genesis-amd64', - 'ppc64el' => 'xcat-genesis-ppc64, xcat-genesis-base-ppc64', -); - -for my $rpm (sort keys %expected) { - my $arch = $expected{$rpm}; - my ($dir, $control) = convert($arch, $rpm); - die("debuild-xcat-genesis-base produced no source tree for $rpm") - unless defined $dir; - - like($dir, qr/\Q-$arch-\E/, "$rpm builds in a $arch source tree"); - like($control, qr/^Package:\s*xcat-genesis-base-\Q$arch\E$/m, - "$rpm builds the package xcat-genesis-base-$arch"); - like($control, qr/^Breaks:.*\bxcat-genesis-scripts-\Q$arch\E\b/m, - "xcat-genesis-base-$arch breaks the genesis scripts of its own architecture"); - like($control, qr/^Replaces:\s*\Q$superseded{$arch}\E\s*$/m, - "xcat-genesis-base-$arch replaces $superseded{$arch}"); - like($control, qr/^Breaks:\s*\Q$superseded{$arch}\E\b/m, - "xcat-genesis-base-$arch breaks $superseded{$arch}"); -} - -done_testing(); diff --git a/xCAT-test/unit/genesis_base_deb_control_rewrite.t b/xCAT-test/unit/genesis_base_deb_control_rewrite.t deleted file mode 100644 index 9f74ebff5..000000000 --- a/xCAT-test/unit/genesis_base_deb_control_rewrite.t +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env perl -# builddeb-genesis-base builds the Genesis base deb natively on Ubuntu. It writes the target -# architecture into debian/control, which is held in the amd64 form in the tree. 2.19 renames -# the ppc64 debs to ppc64el, so the ppc control must also name the deb it supersedes: without -# the relation dpkg keeps xcat-genesis-base-ppc64 installed beside the new package, and that -# old package owns the same files under /opt/xcat/share/xcat/netboot/genesis. -# -# The script needs dracut and root, so rewrite_control() is lifted out of it and run alone -# against the control file the tree ships. -use strict; -use warnings; - -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 slurp_repo_file); - -my $script = repo_path('xCAT-genesis-builder/builddeb-genesis-base'); -my $control = repo_path('xCAT-genesis-builder/debian/control'); -plan skip_all => 'builddeb-genesis-base not found' unless -f $script; -plan tests => 8; - -my $text = slurp_repo_file('xCAT-genesis-builder/builddeb-genesis-base'); -my ($function) = $text =~ /^(rewrite_control\(\)\s*\{.*?^\})/ms; -die('rewrite_control() no longer matches in builddeb-genesis-base') - unless defined $function; - -my $tmpdir = tempdir(CLEANUP => 1); - -# What the ppc64el package has to take over from, and what amd64 already took over from. -my %superseded = ( - 'amd64' => 'xcat-genesis-amd64', - 'ppc64el' => 'xcat-genesis-ppc64, xcat-genesis-base-ppc64', -); - -for my $arch (sort keys %superseded) { - my $out = rewrite($arch); - - like($out, qr/^Package:\s*xcat-genesis-base-\Q$arch\E$/m, - "$arch control names the package xcat-genesis-base-$arch"); - like($out, qr/^Replaces:\s*\Q$superseded{$arch}\E\s*$/m, - "$arch control replaces $superseded{$arch}"); - like($out, qr/^Breaks:\s*\Q$superseded{$arch}\E\b/m, - "$arch control breaks $superseded{$arch}"); - like($out, qr/^Breaks:.*\bxcat-genesis-scripts-\Q$arch\E \(<< 2\.13\.10\)/m, - "$arch control breaks the genesis scripts of its own architecture"); -} - -#--- -# rewrite: run the lifted rewrite_control() over a copy of the control file in the tree. -#--- -sub rewrite { - my ($arch) = @_; - my $copy = "$tmpdir/control.$arch"; - write_text($copy, read_text($control)); - my $driver = "$tmpdir/driver.$arch.sh"; - write_text($driver, "#!/bin/bash\nset -eu\n$function\nrewrite_control \"\$1\" \"\$2\"\n"); - system('bash', $driver, $copy, $arch) == 0 - or die("rewrite_control failed for $arch"); - return read_text($copy); -} diff --git a/xCAT-test/unit/genesis_console_mode.t b/xCAT-test/unit/genesis_console_mode.t deleted file mode 100644 index c17ba3ac0..000000000 --- a/xCAT-test/unit/genesis_console_mode.t +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env perl -# Drive xcat_console_mode() out of the Genesis dracut cmdline hook. -# -# The hook cannot be sourced: it mounts filesystems, starts udev and ends in an endless -# loop. Extract the one function and run it with the terminal multiplexer shadowed. -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 %HOOK = ( - el => { path => 'xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh', mux => 'tmux' }, - ubuntu => { path => 'xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh', mux => 'screen' }, -); - -plan tests => 5 * scalar(keys %HOOK) + 2; - -my $tmpdir = tempdir(CLEANUP => 1); - -# tmux exits under the C locale, so an unguarded tmux loop never reaches doxcat. -my $el = read_text(repo_path($HOOK{el}{path})); -ok($el !~ qr/^while :; do tmux attach-session/m, - 'el: no unguarded tmux loop is left at column 0'); -ok($el =~ qr/^export LC_ALL=C\.UTF-8$/m, - 'el: the hook exports a UTF-8 locale so tmux can start'); - -foreach my $family (sort keys %HOOK) { - my $hook = repo_path($HOOK{$family}{path}); - my $mux = $HOOK{$family}{mux}; - - my $body = extract_function($hook, 'xcat_console_mode', $family); - - is(run_mode($body, $mux, 0), 'direct', - "$family: xcat_console_mode reports direct when $mux cannot start a session"); - is(run_mode($body, $mux, 1), $mux, - "$family: xcat_console_mode reports $mux when $mux can start a session"); - - my $text = read_text($hook); - ok($text =~ qr/^XCAT_CONSOLE_MODE="\$\(xcat_console_mode\)"$/m, - "$family: the hook resolves the console mode once"); - my $guard = qq{if [ "\$XCAT_CONSOLE_MODE" = "$mux" ]; then}; - ok(index($text, $guard) >= 0, - "$family: the doxcat loop is guarded by the console mode"); - ok($text =~ qr/\Qelse\E\n\s+while :; do doxcat; sleep 5; done\n\Qfi\E/, - "$family: doxcat runs directly when $mux is not usable"); -} - -#--- -# extract_function: lift one shell function out of a script that cannot be sourced. -# Bails out when the function stops being extractable, so a rename fails loudly instead of -# leaving the test asserting nothing. -#--- -sub extract_function { - my ($path, $name, $label) = @_; - my $text = read_text($path); - my ($body) = $text =~ /^($name\(\)\s*\{.*?^\})$/ms; - die("$label: $name() not found in $path") unless defined $body; - return $body; -} - -#--- -# run_mode: run the extracted function with the multiplexer shadowed by a stub that either -# starts a session or refuses, the way tmux refuses without a UTF-8 locale. -#--- -sub run_mode { - my ($body, $mux, $mux_works) = @_; - my $dir = tempdir(DIR => $tmpdir, CLEANUP => 1); - my $bin = "$dir/bin"; - make_path($bin); - write_text("$bin/$mux", $mux_works - ? "#!/bin/sh\nexit 0\n" - : "#!/bin/sh\necho '$mux: need UTF-8 locale (LC_CTYPE) but have ANSI_X3.4-1968' >&2\nexit 1\n"); - chmod 0755, "$bin/$mux"; - write_text("$dir/probe.sh", "$body\nxcat_console_mode\n"); - my $out = `PATH="$bin:\$PATH" /bin/bash "$dir/probe.sh" 2>/dev/null`; - chomp $out; - return $out; -} diff --git a/xCAT-test/unit/genesis_dhcp_client.t b/xCAT-test/unit/genesis_dhcp_client.t deleted file mode 100644 index 8ddd03e00..000000000 --- a/xCAT-test/unit/genesis_dhcp_client.t +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env perl -# Drive the DHCP client selection out of doxcat. -# -# doxcat cannot be sourced: it restarts rsyslogd, reads /proc/cmdline and ends in a loop that -# waits for an address. Extract the two routines and run them with the clients shadowed by -# stubs that record their own argv. -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 $DOXCAT = 'xCAT-genesis-scripts/usr/bin/doxcat'; -my $ISC4 = 'dhclient -cf /etc/dhclient.conf -pf /var/run/dhclient.eth0.pid eth0'; -my $ISC6 = 'dhclient -6 -pf /var/run/dhclient6.eth0.pid eth0 -lf /var/lib/dhclient/dhclient6.leases'; - -my $source = read_text( repo_path($DOXCAT) ); -my $tmpdir = tempdir( CLEANUP => 1 ); - -# A release that packages no ISC client has no dhclient, so doxcat must not name one directly. -ok( $source !~ qr/^\s*dhclient\s/m, - 'doxcat starts no command line with dhclient' ); -ok( $source !~ qr/;\s*dhclient\s/, - 'doxcat chains no command line into dhclient' ); - -# The build root has to carry a client, or the image installs none. EL8 and EL9 package the -# ISC client; AlmaLinux 10 baseos packages dhcpcd. -my $spec = read_text( repo_path('xCAT-genesis-builder/xCAT-genesis-base.spec') ); -like( $spec, qr/^%if 0%\{\?rhel\} >= 10\nBuildRequires: dhcpcd$/m, - 'the spec build-requires dhcpcd on the releases that drop the ISC client' ); - -# The payload check has to name the client the release ships, or the build passes with no -# client in the image again. -like( $spec, qr{^%if 0%\{\?rhel\} >= 10\nGENESIS_REQUIRED="usr/sbin/dhcpcd"$}m, - 'the payload check requires dhcpcd on the releases that drop the ISC client' ); - -# dracut_install reports a missing binary and returns, so naming dhclient alone shipped an -# image with no client at all. -my $module = read_text( repo_path('xCAT-genesis-builder/dracut_105/el/module-setup.sh') ); -ok( $module !~ qr/^\s*dracut_install dhclient lldpad$/m, - 'the dracut module no longer installs dhclient unconditionally' ); -like( $module, qr/^\s*dracut_install dhcpcd$/m, - 'the dracut module installs dhcpcd when the build root carries it' ); -like( $module, qr{^\s*dracut_install /usr/libexec/dhcpcd-run-hooks$}m, - 'the dracut module installs the hooks dhcpcd runs on every lease' ); - -my $selector = extract_function( $source, 'genesis_dhcp_command' ); -my $runner = extract_function( $source, 'genesis_start_dhcp' ); - -if ( !defined $selector || !defined $runner ) { - fail('doxcat carries genesis_dhcp_command() to choose the client'); - fail('doxcat carries genesis_start_dhcp() to run the chosen client'); - done_testing(); - exit 0; -} - -# EL8 and EL9 package the ISC client, and it stays the one Genesis uses there. -is( selected( 4, ['dhclient'] ), $ISC4, 'the ISC client keeps its IPv4 command line' ); -is( selected( 6, ['dhclient'] ), $ISC6, 'the ISC client keeps its IPv6 command line' ); -is( selected( 4, [ 'dhclient', 'dhcpcd' ] ), $ISC4, - 'the ISC client is preferred when the image carries both' ); - -# RHEL 10 packages no ISC client. AlmaLinux 10 baseos packages dhcpcd, which carries its own -# resolv.conf, hostname and ntp hooks, so it needs no dhclient-script. -is( selected( 4, ['dhcpcd'] ), 'dhcpcd -4 -b -p -t 0 eth0', - 'dhcpcd stands in for dhclient on IPv4' ); -is( selected( 6, ['dhcpcd'] ), 'dhcpcd -6 -b -p -t 0 eth0', - 'dhcpcd stands in for dhclient on IPv6' ); - -# dhcpcd on a single interface exits when its timeout expires, and the default is 30 seconds. -# doxcat waits for the lease for as long as it takes, so the client must not give up first. -like( selected( 4, ['dhcpcd'] ), qr/(?:^|\s)-t 0(?:\s|$)/, - 'dhcpcd is asked to wait for a lease instead of timing out' ); - -# dhcpcd de-configures the interface when it exits unless it is persistent. Genesis keeps the -# address it was given. -like( selected( 4, ['dhcpcd'] ), qr/(?:^|\s)-p(?:\s|$)/, - 'dhcpcd is asked to leave the address in place' ); - -# An image with no client at all has to say so rather than run an empty command line. -is( selected( 4, [] ), '', 'nothing is chosen when the image carries no client' ); - -# The runner is what the call sites use, so it has to actually execute the chosen client. -is( started( 4, ['dhcpcd'] ), 'dhcpcd -4 -b -p -t 0 eth0', - 'genesis_start_dhcp runs dhcpcd when it is the only client' ); -is( started( 4, ['dhclient'] ), $ISC4, - 'genesis_start_dhcp runs the ISC client when it is there' ); -is( started( 4, [] ), '', - 'genesis_start_dhcp runs no client when the image carries none' ); -isnt( start_status( 4, [] ), 0, - 'genesis_start_dhcp reports failure when the image carries no client' ); - -done_testing(); - -#--- -# extract_function: lift one shell function out of a script that cannot be sourced. -# Returns undef when the function is absent, so the caller fails the assertion instead of -# bailing out of a suite that has already found the defect. -#--- -sub extract_function { - my ( $text, $name ) = @_; - my ($body) = $text =~ /^($name\(\)\s*\{.*?^\})$/ms; - return $body; -} - -#--- -# probe: run the extracted routines with only the named clients on PATH. -# Returns the standard output, the recorded argv of whatever ran, and the exit status. -#--- -sub probe { - my ( $call, $clients ) = @_; - my $dir = tempdir( DIR => $tmpdir, CLEANUP => 1 ); - my $bin = "$dir/bin"; - make_path($bin); - - # PATH holds the stubs alone, so each one names itself rather than calling basename. - my $record = "$dir/record"; - foreach my $client ( @{$clients} ) { - write_text( "$bin/$client", - qq{#!/bin/sh\necho "$client \$*" >> "$record"\nexit 0\n} ); - chmod 0755, "$bin/$client"; - } - - # logger writes to the console in the image and is not what these assertions measure. - write_text( "$bin/logger", "#!/bin/sh\nexit 0\n" ); - chmod 0755, "$bin/logger"; - - write_text( "$dir/probe.sh", "log_label=test\n$selector\n$runner\n$call\n" ); - my $out = `PATH="$bin" /bin/bash "$dir/probe.sh" 2>/dev/null`; - my $status = $? >> 8; - chomp $out; - - my $ran = -e $record ? read_text($record) : ''; - chomp $ran; - - return ( $out, $ran, $status ); -} - -sub selected { - my ( $family, $clients ) = @_; - my ( $out, undef, undef ) = probe( qq{genesis_dhcp_command $family eth0}, $clients ); - return $out; -} - -sub started { - my ( $family, $clients ) = @_; - my ( undef, $ran, undef ) = probe( qq{genesis_start_dhcp $family eth0}, $clients ); - return $ran; -} - -sub start_status { - my ( $family, $clients ) = @_; - my ( undef, undef, $status ) = probe( qq{genesis_start_dhcp $family eth0}, $clients ); - return $status; -} diff --git a/xCAT-test/unit/genesis_getcert_missing_openssl.t b/xCAT-test/unit/genesis_getcert_missing_openssl.t deleted file mode 100644 index 4e7398d9c..000000000 --- a/xCAT-test/unit/genesis_getcert_missing_openssl.t +++ /dev/null @@ -1,81 +0,0 @@ -#!/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_incorrectmasterip_check.t b/xCAT-test/unit/genesis_incorrectmasterip_check.t deleted file mode 100644 index a32b1a23f..000000000 --- a/xCAT-test/unit/genesis_incorrectmasterip_check.t +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env perl -# Run the nodeset_shell_incorrectmasterip check against a scratch tftp root, with the xCAT -# commands and the net tools shadowed. -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 $script = repo_path('xCAT-test/autotest/testcase/genesis/test.sh'); -plan skip_all => 'genesis test.sh not found' unless -f $script; -plan tests => 9; - -my $host_arch = `uname -m`; -chomp $host_arch; - -# grub2.pm names the boot loader grub2., with every ppc64 flavour written as "ppc". -my $loader_name = $host_arch =~ /^ppc64/ ? 'ppc' : $host_arch; - -# The case defined its node as ppc64le whatever the management node was, so nodeset could not -# find a genesis kernel for it on x86_64 and the case could never pass there. -my $run = run_check('xnba', write_boot_file => 1); -is($run->{status}, 0, 'the xnba check passes when nodeset writes the boot file') - or diag($run->{output}); -like($run->{chdef}, qr/\barch=\Q$host_arch\E\b/, - 'the test node is defined with the management node arch'); -ok($host_arch eq 'ppc64le' || $run->{chdef} !~ /\barch=ppc64le\b/, - 'the test node arch is not pinned to ppc64le'); - -# A nodeset that writes nothing must fail the check, not pass it. -my $empty = run_check('xnba', write_boot_file => 0); -isnt($empty->{status}, 0, 'the check fails when nodeset writes no boot file'); - -# grub2 and petitboot read their configuration from other directories under the tftp root. -my $grub = run_check('grub2', write_boot_file => 1); -is($grub->{status}, 0, 'the grub2 check reads the grub2 directory') - or diag($grub->{output}); - -# grub2.pm writes the boot configuration and only then stops on a missing boot loader. The -# check read the file that failed nodeset had already written, so it passed on the debris. -my $refused = run_check('grub2', write_boot_file => 1, nodeset_status => 1); -isnt($refused->{status}, 0, 'a nodeset that fails makes the check fail'); - -my $refused_xnba = run_check('xnba', write_boot_file => 1, nodeset_status => 1); -isnt($refused_xnba->{status}, 0, 'a nodeset that fails makes the xnba check fail too'); - -# xCAT builds no x86_64 or aarch64 grub2 network boot loader, so grub2.pm stops before it -# configures anything. The check stages one for the node arch and removes it after. -is($grub->{loader_at_nodeset}, "yes\n", - 'the grub2 boot loader for the node arch is in place when nodeset runs'); -ok(!$grub->{loader_left}, 'the staged boot loader is removed again'); - -#--- -# run_check: run `test.sh --check ` against a scratch tftp root. test.sh resets PATH, -# so the xCAT commands are shadowed with shell functions, which bash resolves first. The fake -# nodeset writes the boot file the check greps, so the assertion is on the check, not on xCAT. -#--- -sub run_check { - my ($loader, %opt) = @_; - my $root = tempdir(CLEANUP => 1); - my $tftp = "$root/tftpboot"; - make_path("$tftp/xcat/xnba/nodes", "$tftp/boot/grub2", "$tftp/petitboot"); - my $boot_loader = "$tftp/boot/grub2/grub2.$loader_name"; - - my $folder = $loader eq 'xnba' ? "$tftp/xcat/xnba/nodes" - : $loader eq 'petitboot' ? "$tftp/petitboot" - : "$tftp/boot/grub2"; - my $write = $opt{write_boot_file} - ? "printf 'xcatd=192.168.1.1:3001 destiny=shell\\n' > '$folder/testnode'" - : ":"; - - my $driver = "$root/driver.sh"; - write_text($driver, <<"DRIVER"); -chdef() { echo "\$@" >> '$root/chdef.log'; } -lsdef() { - if [ "\$1" = "-t" ] && [ "\$2" = "site" ]; then echo "clustersite: master=192.168.9.9"; return 0; fi - echo "Object name: testnode" -} -ifconfig() { printf 'eth0: flags\\n inet 192.168.9.9\\n\\n'; } -netstat() { printf 'Kernel\\nIface\\neth0\\neth1\\nlo\\n'; } -ip() { return 0; } -makenetworks() { return 0; } -tabdump() { return 0; } -makehosts() { return 0; } -rmdef() { return 0; } -nodeset() { - if [ -e '$boot_loader' ]; then echo yes > '$root/loader.at.nodeset'; else echo no > '$root/loader.at.nodeset'; fi - $write - return @{[ $opt{nodeset_status} || 0 ]}; -} -export TFTPDIR='$tftp' -. '$script' --check $loader -DRIVER - - my $out = `/bin/bash "$driver" 2>&1`; - my $status = $? >> 8; - my $chdef = -f "$root/chdef.log" ? read_text("$root/chdef.log") : ''; - return { - status => $status, - output => $out, - chdef => $chdef, - loader_at_nodeset => (-f "$root/loader.at.nodeset" ? read_text("$root/loader.at.nodeset") : ''), - loader_left => (-e $boot_loader ? 1 : 0), - }; -} - diff --git a/xCAT-test/unit/genesis_root_home.t b/xCAT-test/unit/genesis_root_home.t deleted file mode 100644 index a7180b2aa..000000000 --- a/xCAT-test/unit/genesis_root_home.t +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env perl -# Drive the /etc/passwd rewrite out of the Genesis dracut cmdline hooks. -# -# mknb writes the management node key to /.ssh/authorized_keys for the legacy Genesis -# image, so sshd finds it only while the home directory of root is /. The hook makes it / -# by deleting the root entry the image ships and appending its own. Run that rewrite -# against every root entry shape dracut writes and read back the result. -use strict; -use warnings; - -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 @HOOKS = ( - 'xCAT-genesis-builder/xcat-cmdline.sh', - 'xCAT-genesis-builder/dracut_105/el/xcat-cmdline.sh', - 'xCAT-genesis-builder/dracut_105/ubuntu/xcat-cmdline.sh', -); - -# dracut 99base writes the root entry itself. Up to dracut 057 the password field is always -# x; from dracut 060 the x arrives only with --hostonly, and the Genesis image is built -N. -my %SHIPPED = ( - 'dracut 049/057 (el8, el9)' => "root:x:0:0::/root:/bin/sh\n", - 'dracut 107 (el10)' => "root::0:0::/root:/bin/sh\n", -); - -# A user name that starts with root but is not root. The delete must keep this line. -my $DECOY = "rootfsadm:x:501:501::/home/rootfsadm:/sbin/nologin\n"; - -plan tests => 3 * @HOOKS * scalar(keys %SHIPPED); - -my $tmpdir = tempdir(CLEANUP => 1); - -foreach my $hook (@HOOKS) { - my $block = extract_passwd_block(repo_path($hook), $hook); - foreach my $shape (sort keys %SHIPPED) { - my $passwd = run_rewrite($block, $SHIPPED{$shape} . $DECOY, $hook); - my @root = grep { /^root:/ } split(/\n/, $passwd); - - is(scalar @root, 1, - "$hook / $shape: one root entry is left in /etc/passwd"); - is($root[0], 'root:x:0:0::/:/bin/bash', - "$hook / $shape: the home directory of root is /"); - like($passwd, qr/^\Qrootfsadm:x:501:501::\/home\/rootfsadm:\/sbin\/nologin\E$/m, - "$hook / $shape: a user name that starts with root is kept"); - } -} - -#--- -# extract_passwd_block: lift the /etc/passwd rewrite out of a hook that cannot be sourced. -# The hook mounts filesystems, starts udev and ends in an endless loop. -# Bails out when the block stops being extractable, so a rewrite fails loudly instead of -# leaving the test asserting nothing. -#--- -sub extract_passwd_block { - my ($path, $label) = @_; - my $text = read_text($path); - my ($block) = $text =~ m{^(sed [^\n]*/etc/passwd\ncat >>/etc/passwd <<"__ENDL"\n.*?^__ENDL)$}ms; - die("$label: the /etc/passwd rewrite was not found") unless defined $block; - return $block; -} - -#--- -# run_rewrite: run the extracted block against a scratch passwd file and return it. -# The block names /etc/passwd literally, so the path is redirected into the scratch tree -# first. Bails out if any reference to the real file survives, because the block runs as -# root under CI. -#--- -sub run_rewrite { - my ($block, $shipped, $label) = @_; - my $dir = tempdir(DIR => $tmpdir, CLEANUP => 1); - my $passwd = "$dir/passwd"; - write_text($passwd, $shipped); - - my $script = $block; - my $hits = ($script =~ s{/etc/passwd}{$passwd}g); - die("$label: expected 2 references to /etc/passwd, found $hits") unless $hits == 2; - die("$label: a reference to the real /etc/passwd survived") if index($script, '/etc/passwd') >= 0; - - write_text("$dir/rewrite.sh", "set -e\n$script\n"); - system('/bin/bash', "$dir/rewrite.sh") == 0 - or die("$label: the /etc/passwd rewrite failed to run"); - return read_text($passwd); -} diff --git a/xCAT-test/unit/go_xcat_genesis_package_names.t b/xCAT-test/unit/go_xcat_genesis_package_names.t deleted file mode 100644 index 29e37ae8d..000000000 --- a/xCAT-test/unit/go_xcat_genesis_package_names.t +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env perl -# go-xcat installs and uninstalls a fixed list of package names, and it keeps one list per -# packaging format. The Genesis packages are named after the architecture, and the two formats -# spell it differently: the rpm is xCAT-genesis-scripts-ppc64, the deb is -# xcat-genesis-scripts-ppc64el. -# -# The lists are built by go-xcat itself here, not read as text: the deb list exists only when -# "type dpkg" succeeds, so a shell function decides which branch each run takes. -use strict; -use warnings; - -use File::Temp qw(tempdir); -use FindBin; -use Test::More; - -my $root = "$FindBin::Bin/../.."; -my $go_xcat = "$root/xCAT-server/share/xcat/tools/go-xcat"; -die("go-xcat not found at $go_xcat") unless -f $go_xcat; - -my $tmpdir = tempdir(CLEANUP => 1); -my $driver = "$tmpdir/driver.sh"; -open(my $driver_fh, '>', $driver) or die "open $driver: $!"; -print {$driver_fh} <<'DRIVER'; -#!/bin/bash - -if [[ ${WANT_DPKG:-0} == 1 ]] -then - dpkg() { :; } -fi - -list_body=$( - awk ' - /^GO_XCAT_INSTALL_LIST=\(/ { copy = 1 } - /^PATH=/ { exit } - copy { print } - ' "$GO_XCAT_SOURCE" -) -[[ -n "${list_body}" ]] || { echo "go-xcat package arrays not found" >&2 ; exit 3 ; } - -# A real dpkg on the build host would select the deb branch on every run. -PATH="" -eval "${list_body}" - -printf 'install %s\n' "${GO_XCAT_INSTALL_LIST[*]}" -printf 'uninstall %s\n' "${GO_XCAT_UNINSTALL_LIST[*]}" -DRIVER -close($driver_fh) or die "close $driver: $!"; - -# Run go-xcat's array definitions and return the two lists it built. -sub package_lists { - my ($want_dpkg) = @_; - local %ENV = (%ENV, GO_XCAT_SOURCE => $go_xcat, WANT_DPKG => $want_dpkg); - open(my $out, '-|', 'bash', $driver) or die "run $driver: $!"; - my %list; - while (my $line = <$out>) { - chomp $line; - my ($which, $packages) = split /\s+/, $line, 2; - $list{$which} = [ split /\s+/, ($packages // '') ]; - } - close($out); - die('go-xcat package arrays could not be evaluated') - unless $list{install} && $list{uninstall}; - return \%list; -} - -# Read the whole of a file. -sub slurp { - my ($path) = @_; - open(my $fh, '<', $path) or die "read $path: $!"; - local $/; my $text = <$fh>; close $fh; - return $text; -} - -# The package names, sorted, that match a prefix. -sub named { - my ($packages, $prefix) = @_; - my @found = sort grep { index($_, $prefix) == 0 } @{$packages}; - return \@found; -} - -my $rpm = package_lists(0); -my $deb = package_lists(1); -die('the dpkg branch of go-xcat was not taken') - unless grep { $_ eq 'xcat-client' } @{ $deb->{install} }; -die('the rpm branch of go-xcat was not taken') - unless grep { $_ eq 'xCAT-client' } @{ $rpm->{install} }; - -# The deb names come from the packaging: one control file per Debian architecture names the -# genesis-scripts package, and its Depends names the genesis-base package that carries the -# Genesis tree for that same architecture. -my @control = sort glob("$root/xCAT-genesis-scripts/debian/control-*"); -die('no xCAT-genesis-scripts Debian control files') unless @control; -my (@deb_scripts, @deb_base); -for my $control (@control) { - my $text = slurp($control); - push @deb_scripts, ($text =~ /^Package:\s*(\S+)/mg); - push @deb_base, ($text =~ /^Depends:.*?(xcat-genesis-base-[a-z0-9]+)/mg); -} -@deb_scripts = sort @deb_scripts; -@deb_base = sort @deb_base; - -for my $which (qw(install uninstall)) { - is_deeply(named($deb->{$which}, 'xcat-genesis-scripts-'), \@deb_scripts, - "the deb $which list names the genesis scripts packages xCAT-genesis-scripts builds"); - is_deeply(named($deb->{$which}, 'xcat-genesis-base-'), \@deb_base, - "the deb $which list names the genesis base packages those scripts depend on"); -} - -# The rpm names use the Genesis target architecture of the spec, which is not a Debian -# architecture name. -my %tarch = map { $_ => 1 } (slurp("$root/xCAT-genesis-builder/xCAT-genesis-base.spec") - =~ /^%define\s+tarch\s+(\S+)/mg); -die('no Genesis target architectures in xCAT-genesis-base.spec') unless %tarch; - -for my $which (qw(install uninstall)) { - for my $prefix (qw(xCAT-genesis-scripts- xCAT-genesis-base-)) { - my @wrong = grep { my $arch = substr($_, length $prefix); !$tarch{$arch} } - @{ named($rpm->{$which}, $prefix) }; - is_deeply(\@wrong, [], - "the rpm $which list names only Genesis target architectures for $prefix*"); - } -} - -done_testing(); From 6f635541e4629d0a372e90d23e52f57024cad77f Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:26:13 -0300 Subject: [PATCH 37/37] test(xcat-core): a missing deb converter makes the arch test skip, not fail genesis_base_deb_arch.t stopped with a die when debuild-xcat-genesis-base was absent, because a checkout without the converter has no deb rename to measure. The BATS file skipped there instead, and a skip reads green. setup() now asserts the script is readable. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/bats/genesis_base_deb_arch.bats | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xCAT-test/bats/genesis_base_deb_arch.bats b/xCAT-test/bats/genesis_base_deb_arch.bats index 5d618d3c8..addf31146 100644 --- a/xCAT-test/bats/genesis_base_deb_arch.bats +++ b/xCAT-test/bats/genesis_base_deb_arch.bats @@ -13,7 +13,9 @@ load 'helpers/shell_source' setup() { SCRIPT="$(repo_path 'xCAT-genesis-builder/debuild-xcat-genesis-base')" - [ -r "$SCRIPT" ] || skip "$SCRIPT is required" + # Fail rather than skip: a checkout without the converter has no deb rename to measure, + # and a skip there covers nothing while reading green. + [ -r "$SCRIPT" ] export SCRIPT }