diff --git a/.github/workflows/genesis_openembedded.yml b/.github/workflows/genesis_openembedded.yml index b2ee14521..4d38f7131 100644 --- a/.github/workflows/genesis_openembedded.yml +++ b/.github/workflows/genesis_openembedded.yml @@ -2,7 +2,7 @@ name: genesis_openembedded on: pull_request: paths: - - 'xCAT-genesis-builder/oe/**' + - 'xCAT-genesis-base/oe/**' - 'xCAT-genesis-scripts/**' - '.github/workflows/genesis_openembedded.yml' workflow_dispatch: @@ -17,7 +17,7 @@ jobs: with: python-version: '3.12' cache: pip - cache-dependency-path: xCAT-genesis-builder/oe/requirements.txt + cache-dependency-path: xCAT-genesis-base/oe/requirements.txt - name: Install OpenEmbedded host dependencies run: >- sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y @@ -25,7 +25,7 @@ jobs: build-essential chrpath cpio diffstat file gawk git locales lz4 openssl socat texinfo unzip wget xz-utils zstd - name: Install KAS - run: python -m pip install -r xCAT-genesis-builder/oe/requirements.txt + run: python -m pip install -r xCAT-genesis-base/oe/requirements.txt - name: Enable BitBake user namespaces run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Validate every architecture configuration @@ -36,7 +36,7 @@ jobs: set -euo pipefail for architecture in x86 x86_64 ppc64 ppc64le armv7hf aarch64 riscv64 s390x; do export KAS_BUILD_DIR="${RUNNER_TEMP}/kas-build-${architecture}" - configuration="xCAT-genesis-builder/oe/kas/${architecture}.yml" + configuration="xCAT-genesis-base/oe/kas/${architecture}.yml" kas dump "${configuration}" >/dev/null kas shell "${configuration}" -c 'bitbake -p' done @@ -47,8 +47,8 @@ jobs: KAS_BUILD_DIR: ${{ runner.temp }}/kas-build-x86_64 run: | set -euo pipefail - kas shell xCAT-genesis-builder/oe/kas/x86_64.yml -c \ + kas shell xCAT-genesis-base/oe/kas/x86_64.yml -c \ 'bitbake -n xcat-genesis-image xcat-genesis-extension-smoke' export KAS_BUILD_DIR="${RUNNER_TEMP}/kas-build-s390x" - kas shell xCAT-genesis-builder/oe/kas/s390x.yml -c \ + kas shell xCAT-genesis-base/oe/kas/s390x.yml -c \ 'bitbake -n xcat-genesis-image xcat-genesis-extension-smoke' diff --git a/build-utils/lib/XCAT/BuildUtils.pm b/build-utils/lib/XCAT/BuildUtils.pm index 3e3bbcd52..db835ce5e 100644 --- a/build-utils/lib/XCAT/BuildUtils.pm +++ b/build-utils/lib/XCAT/BuildUtils.pm @@ -382,9 +382,9 @@ sub stage_probe_helpers { Descriptions: Write the build support tarball that xCAT-genesis-base.spec unpacks: - the dracut_105 modules, 80-net-name-slot.rules and - verify-genesis-payload, taken from the xCAT-genesis-base directory of - a checkout. + the dracut_105 modules, 80-net-name-slot.rules, and + verify-genesis-payload with its XCAT::GenesisPayload module, taken from + the xCAT-genesis-base directory of a checkout. Arguments: $checkout: the top of the xcat-core checkout $tarball: the path of the tarball to write @@ -414,6 +414,9 @@ sub stage_genesis_base_sources { # The spec runs the verifier against the extracted payload in %install. copy("$source/verify-genesis-payload", "$staging_root/verify-genesis-payload") or die "Unable to stage $source/verify-genesis-payload: $!\n"; + make_path("$staging_root/lib/XCAT"); + copy("$source/lib/XCAT/GenesisPayload.pm", "$staging_root/lib/XCAT/GenesisPayload.pm") + or die "Unable to stage $source/lib/XCAT/GenesisPayload.pm: $!\n"; unlink $tarball if -f $tarball; sh_or_die(qq(tar --sort=name --owner=0 --group=0 --mtime="\@$epoch" -cjf "$tarball" -C "$staging_parent" xCAT-genesis-base-build-support), diff --git a/xCAT-genesis-base/builddeb-genesis-base b/xCAT-genesis-base/builddeb-genesis-base index 5b9bc9141..8758aa7a1 100755 --- a/xCAT-genesis-base/builddeb-genesis-base +++ b/xCAT-genesis-base/builddeb-genesis-base @@ -37,36 +37,12 @@ echo "Building xcat-genesis-base for $BUILDARCH ($TARCH) on Ubuntu $CODENAME" export DEBIAN_FRONTEND=noninteractive -REQUIRED_PACKAGES=" - dracut linux-image-generic - ipmitool lldpad ethtool iproute2 kexec-tools screen - openssh-server openssh-client rsyslog chrony - nfs-common rpcbind pciutils usbutils parted - dosfstools e2fsprogs lvm2 mdadm net-tools - bc psmisc rsync wget cpio - isc-dhcp-client ifenslave - dpkg-dev debhelper fakeroot devscripts vim-tiny -" -if [ "$BUILDARCH" = "amd64" ]; then - REQUIRED_PACKAGES="$REQUIRED_PACKAGES dmidecode efibootmgr" -fi - -# Keep only the packages this release actually has. hwclock moved out of util-linux into -# util-linux-extra, so focal and jammy have no such package and naming it there fails the -# whole install. util-linux only Suggests it, and --no-install-recommends is passed, so the -# releases that did split it must name it. -optional_packages() { - local package - for package in "$@"; do - if apt-cache show "$package" >/dev/null 2>&1; then - echo "$package" - fi - done -} - echo "Installing build dependencies..." apt-get update -qq -REQUIRED_PACKAGES="$REQUIRED_PACKAGES $(optional_packages util-linux-extra)" + +# XCAT::GenesisBuildRoot holds the package list, so the unit tests can call it. +REQUIRED_PACKAGES=$(perl -I"$DIR/lib" -MXCAT::GenesisBuildRoot=required_packages \ + -e 'print "$_\n" for required_packages(@ARGV)' "$BUILDARCH" "$CODENAME") apt-get install -y --no-install-recommends $REQUIRED_PACKAGES # Set up dracut module diff --git a/xCAT-genesis-base/lib/XCAT/GenesisBuildRoot.pm b/xCAT-genesis-base/lib/XCAT/GenesisBuildRoot.pm new file mode 100644 index 000000000..afc7c9f62 --- /dev/null +++ b/xCAT-genesis-base/lib/XCAT/GenesisBuildRoot.pm @@ -0,0 +1,73 @@ +package XCAT::GenesisBuildRoot; + +# builddeb-genesis-base runs this module inside an Ubuntu build root, where only perl-base is +# installed. Use core modules only. +use strict; +use warnings; +use Exporter 'import'; + +our @EXPORT_OK = qw(required_packages apt_carries); + +my @BASE_PACKAGES = qw( + dracut linux-image-generic + ipmitool lldpad ethtool iproute2 kexec-tools screen + openssh-server openssh-client rsyslog chrony + nfs-common rpcbind pciutils usbutils parted + dosfstools e2fsprogs lvm2 mdadm net-tools + bc psmisc rsync wget cpio + isc-dhcp-client ifenslave + dpkg-dev debhelper fakeroot devscripts vim-tiny +); + +# hwclock moved out of util-linux into util-linux-extra. focal and jammy have no such package, +# and naming it there fails the whole install. util-linux only Suggests it, and the build +# passes --no-install-recommends, so the releases that split it must name it. +my @OPTIONAL_PACKAGES = qw(util-linux-extra); + +#------------------------------------------------------------------------------- + +=head3 apt_carries + + Descriptions: ask apt-cache whether the release carries a package. + Arguments: + $package: the package name + Returns: + 1 when apt-cache knows the package, 0 when it does not + +=cut + +#------------------------------------------------------------------------------- +sub apt_carries { + my ($package) = @_; + return system('sh', '-c', 'apt-cache show "$1" >/dev/null 2>&1', 'sh', $package) == 0 + ? 1 : 0; +} + +#------------------------------------------------------------------------------- + +=head3 required_packages + + Descriptions: list the packages the Genesis build root needs. + An optional package is listed only when the release carries it. + Arguments: + $arch: the dpkg architecture (amd64, ppc64el) + $codename: the release name + $carries: optional code ref. It takes a package name and returns true when the + release carries that package. The default is apt_carries. + Returns: + the package names, in install order. + +=cut + +#------------------------------------------------------------------------------- +sub required_packages { + my ($arch, $codename, $carries) = @_; + $carries ||= \&apt_carries; + + my @packages = @BASE_PACKAGES; + push @packages, qw(dmidecode efibootmgr) if $arch eq 'amd64'; + push @packages, grep { $carries->($_) } @OPTIONAL_PACKAGES; + return @packages; +} + +1; diff --git a/xCAT-genesis-base/lib/XCAT/GenesisPayload.pm b/xCAT-genesis-base/lib/XCAT/GenesisPayload.pm new file mode 100644 index 000000000..135bd925e --- /dev/null +++ b/xCAT-genesis-base/lib/XCAT/GenesisPayload.pm @@ -0,0 +1,196 @@ +package XCAT::GenesisPayload; + +# verify-genesis-payload runs this module in the rpm %install of xCAT-genesis-base and inside +# the Ubuntu build root, where only perl-base is installed. Use core modules only. +use strict; +use warnings; +use Exporter 'import'; + +our @EXPORT_OK = qw(module_commands missing_paths check_payload main); + +my $ME = 'verify-genesis-payload'; + +#------------------------------------------------------------------------------- + +=head3 module_commands + + Descriptions: read back the names a dracut module installs. + Only the top level of install() counts. A name under a condition is + release-dependent, so the caller names it as a required path instead. + An option to dracut_install (a word that starts with "-") is not a name. + Arguments: + $module_setup: the path of the module-setup.sh + Returns: + the names, sorted, each one once. A name that starts with "/" is an + absolute path; any other name is a command. + Dies with "verify-genesis-payload: cannot read " when the file + cannot be read, and with "verify-genesis-payload: no command name read + from " when install() names nothing. + +=cut + +#------------------------------------------------------------------------------- +sub module_commands { + my ($module_setup) = @_; + open(my $fh, '<', $module_setup) + or die "$ME: cannot read $module_setup\n"; + + my ($in_install, %names); + while (my $line = <$fh>) { + if ($line =~ /^install\(\)/) { $in_install = 1; next } + $in_install = 0 if $in_install && $line =~ /^}/; + next unless $in_install && $line =~ s/^ dracut_install //; + $line =~ s/#.*//s; + $names{$_} = 1 for grep { length && !/^-/ } split /\s+/, $line; + } + close($fh); + + die "$ME: no command name read from $module_setup\n" unless %names; + return sort keys %names; +} + +#------------------------------------------------------------------------------- + +=head3 missing_paths + + Descriptions: list what a Genesis payload lacks. + dracut_install reports a missing binary and returns, so the image can + ship without it. This check runs on the extracted payload before it is + packaged. + Arguments: + $have: code ref. It takes a path relative to the payload root and + returns true when the payload carries it. + %opt: + required: paths relative to the payload root that the caller needs + commands: names from module_commands. A bare command is looked + for in bin, sbin, usr/bin and usr/sbin; an absolute + path is looked for under the payload root. + source: the module the commands came from, for the message + sshd: the content of usr/sbin/sshd, or undef without one + Returns: + one " ()" string per missing item, in the order checked. + An empty list means the payload is complete. + +=cut + +#------------------------------------------------------------------------------- +sub missing_paths { + my ($have, %opt) = @_; + my @missing; + + for my $path (@{ $opt{required} || [] }) { + push @missing, "$path (required by the build)" unless $have->($path); + } + + for my $want (@{ $opt{commands} || [] }) { + my $found = + $want =~ m{^/(.*)} + ? $have->($1) + : scalar(grep { $have->("$_/$want") } qw(bin sbin usr/bin usr/sbin)); + push @missing, "$want (installed by $opt{source})" unless $found; + } + + push @missing, "usr/sbin/sshd (Genesis is reached over ssh)" + unless $have->('usr/sbin/sshd'); + push @missing, "usr/bin/mktemp (getdestiny makes its request file with it)" + unless $have->('usr/bin/mktemp'); + + # 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 (index($opt{sshd} // '', 'sshd-session') >= 0 + && !$have->('usr/libexec/openssh/sshd-session') + && !$have->('usr/lib/openssh/sshd-session')) + { + push @missing, + "usr/libexec/openssh/sshd-session (this sshd execs it for every connection)"; + } + + # tmux exits under the C locale. The hook then runs doxcat directly, but a Genesis + # shell without tmux loses the console attach. + if ($have->('usr/bin/tmux') && !$have->('usr/lib/locale/C.utf8/LC_CTYPE')) { + push @missing, + "usr/lib/locale/C.utf8/LC_CTYPE (tmux refuses to start without a UTF-8 locale)"; + } + return @missing; +} + +#------------------------------------------------------------------------------- + +=head3 check_payload + + Descriptions: the command line of verify-genesis-payload, without the output: + [--commands-from ] [required-path ...] + Arguments: + @args: the command line + Returns: + ($status, $message). $status is the exit status: 0 when the payload is + complete, 1 when it lacks something, 2 on a usage error. $message is + the text to print, one line or a list of missing items. + +=cut + +#------------------------------------------------------------------------------- +sub check_payload { + my @args = @_; + my $commands_from = ''; + while (@args) { + if ($args[0] eq '--commands-from') { + shift @args; + $commands_from = shift(@args) // ''; + } elsif ($args[0] =~ /^--commands-from=(.*)/s) { + $commands_from = $1; + shift @args; + } else { + last; + } + } + + my $payload = shift(@args) // ''; + if ($payload eq '' || !-d $payload) { + return (2, "$ME: not a payload directory: " . ($payload eq '' ? '' : $payload) . "\n"); + } + + my @commands; + if ($commands_from ne '') { + @commands = eval { module_commands($commands_from) }; + return (2, $@) if $@; + } + + my $sshd; + if (open(my $fh, '<:raw', "$payload/usr/sbin/sshd")) { + local $/; + $sshd = <$fh> // ''; + close($fh); + } + my @missing = missing_paths(sub { -e "$payload/$_[0]" }, + required => \@args, + commands => \@commands, + source => $commands_from, + sshd => $sshd); + return (1, "$ME: $payload is incomplete:" . join('', map { "\n $_" } @missing) . "\n") + if @missing; + return (0, "$ME: $payload is complete\n"); +} + +#------------------------------------------------------------------------------- + +=head3 main + + Descriptions: run check_payload and print its message: on STDOUT when the + payload is complete, on STDERR otherwise. + Arguments: + @args: the command line + Returns: + the exit status from check_payload + +=cut + +#------------------------------------------------------------------------------- +sub main { + my ($status, $message) = check_payload(@_); + print { $status ? *STDERR : *STDOUT } $message; + return $status; +} + +1; diff --git a/xCAT-genesis-base/verify-genesis-payload b/xCAT-genesis-base/verify-genesis-payload index 1f44898c1..d5d0369b0 100755 --- a/xCAT-genesis-base/verify-genesis-payload +++ b/xCAT-genesis-base/verify-genesis-payload @@ -6,115 +6,9 @@ # 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 -# 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. +# what the dracut module installs. XCAT::GenesisPayload, in lib/ beside this script, holds +# the rules, so the unit tests call the same code. +# +# Exit status: 0 complete, 1 something missing, 2 usage error. -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 - 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 - -# 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 - 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 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 - -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. -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 +exec perl -I"$(dirname "$0")/lib" -MXCAT::GenesisPayload=main -e 'exit main(@ARGV)' -- "$@" diff --git a/xCAT-test/bats/genesis_base_deb_arch.bats b/xCAT-test/bats/genesis_base_deb_arch.bats index addf31146..b681dec28 100644 --- a/xCAT-test/bats/genesis_base_deb_arch.bats +++ b/xCAT-test/bats/genesis_base_deb_arch.bats @@ -12,7 +12,7 @@ load 'helpers/shell_source' setup() { - SCRIPT="$(repo_path 'xCAT-genesis-builder/debuild-xcat-genesis-base')" + SCRIPT="$(repo_path 'xCAT-genesis-base/debuild-xcat-genesis-base')" # 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" ] diff --git a/xCAT-test/bats/genesis_base_deb_control_rewrite.bats b/xCAT-test/bats/genesis_base_deb_control_rewrite.bats index 188671c67..1d18359e5 100644 --- a/xCAT-test/bats/genesis_base_deb_control_rewrite.bats +++ b/xCAT-test/bats/genesis_base_deb_control_rewrite.bats @@ -13,8 +13,8 @@ load 'helpers/shell_source' setup() { - SCRIPT="$(repo_path 'xCAT-genesis-builder/builddeb-genesis-base')" - CONTROL="$(repo_path 'xCAT-genesis-builder/debian/control')" + SCRIPT="$(repo_path 'xCAT-genesis-base/builddeb-genesis-base')" + CONTROL="$(repo_path 'xCAT-genesis-base/debian/control')" [ -r "$SCRIPT" ] || skip "$SCRIPT is required" [ -r "$CONTROL" ] || skip "$CONTROL is required" export SCRIPT CONTROL diff --git a/xCAT-test/bats/genesis_console_mode.bats b/xCAT-test/bats/genesis_console_mode.bats index 98d36f95d..0473235b9 100644 --- a/xCAT-test/bats/genesis_console_mode.bats +++ b/xCAT-test/bats/genesis_console_mode.bats @@ -9,8 +9,8 @@ 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')" + EL_HOOK="$(repo_path 'xCAT-genesis-base/dracut_105/el/xcat-cmdline.sh')" + UBUNTU_HOOK="$(repo_path 'xCAT-genesis-base/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 diff --git a/xCAT-test/bats/genesis_dhcp_client.bats b/xCAT-test/bats/genesis_dhcp_client.bats index f45ec2cca..2b4233a93 100644 --- a/xCAT-test/bats/genesis_dhcp_client.bats +++ b/xCAT-test/bats/genesis_dhcp_client.bats @@ -14,8 +14,8 @@ ISC6='dhclient -6 -pf /var/run/dhclient6.eth0.pid eth0 -lf /var/lib/dhclient/dhc 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')" + SPEC="$(repo_path 'xCAT-genesis-base/xCAT-genesis-base.spec')" + MODULE="$(repo_path 'xCAT-genesis-base/dracut_105/el/module-setup.sh')" [ -r "$DOXCAT" ] || skip "$DOXCAT is required" [ -r "$SPEC" ] || skip "$SPEC is required" [ -r "$MODULE" ] || skip "$MODULE is required" diff --git a/xCAT-test/bats/genesis_root_home.bats b/xCAT-test/bats/genesis_root_home.bats index cab5e780d..71a266e3f 100644 --- a/xCAT-test/bats/genesis_root_home.bats +++ b/xCAT-test/bats/genesis_root_home.bats @@ -77,13 +77,13 @@ assert_hook() } @test "the legacy hook gives root the home directory /" { - assert_hook 'xCAT-genesis-builder/xcat-cmdline.sh' + assert_hook 'xCAT-genesis-base/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' + assert_hook 'xCAT-genesis-base/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' + assert_hook 'xCAT-genesis-base/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 index 871ae884b..68913d0c2 100644 --- a/xCAT-test/bats/go_xcat_genesis_package_names.bats +++ b/xCAT-test/bats/go_xcat_genesis_package_names.bats @@ -15,7 +15,7 @@ setup() { go_xcat_require_source SCRIPTS_DEBIAN="$(repo_path 'xCAT-genesis-scripts/debian')" - SPEC="$(repo_path 'xCAT-genesis-builder/xCAT-genesis-base.spec')" + SPEC="$(repo_path 'xCAT-genesis-base/xCAT-genesis-base.spec')" [ -d "$SCRIPTS_DEBIAN" ] || skip "$SCRIPTS_DEBIAN is required" [ -r "$SPEC" ] || skip "$SPEC is required" export SCRIPTS_DEBIAN SPEC diff --git a/xCAT-test/unit/genesis_base_sources_staged.t b/xCAT-test/unit/genesis_base_sources_staged.t index 96325c10f..f6f3d13a0 100644 --- a/xCAT-test/unit/genesis_base_sources_staged.t +++ b/xCAT-test/unit/genesis_base_sources_staged.t @@ -1,7 +1,7 @@ #!/usr/bin/env perl # xCAT-genesis-base.spec builds the Genesis image from a tarball that buildrpms.pl stages: -# the dracut_105 modules, 80-net-name-slot.rules and verify-genesis-payload. Renaming the -# directory they live in breaks that staging silently. +# the dracut_105 modules, 80-net-name-slot.rules, and verify-genesis-payload with its module. +# Renaming the directory they live in breaks that staging silently. use strict; use warnings; @@ -37,6 +37,8 @@ make_path("$checkout/xCAT-genesis-base/dracut_105/el", write_text("$checkout/xCAT-genesis-base/dracut_105/$_/$MODULE", "$_ module\n") for qw(el ubuntu); write_text("$checkout/xCAT-genesis-base/80-net-name-slot.rules", "rules\n"); write_text("$checkout/xCAT-genesis-base/verify-genesis-payload", "verifier\n"); +make_path("$checkout/xCAT-genesis-base/lib/XCAT"); +write_text("$checkout/xCAT-genesis-base/lib/XCAT/GenesisPayload.pm", "1;\n"); my $tarball = "$scratch/scratch.tar.bz2"; is(stage_genesis_base_sources($checkout, $tarball, $EPOCH), $tarball, @@ -51,9 +53,12 @@ is_deeply([ names(@listing) ], [ "xCAT-genesis-base-build-support/dracut_105/el/$MODULE", 'xCAT-genesis-base-build-support/dracut_105/ubuntu/', "xCAT-genesis-base-build-support/dracut_105/ubuntu/$MODULE", + 'xCAT-genesis-base-build-support/lib/', + 'xCAT-genesis-base-build-support/lib/XCAT/', + 'xCAT-genesis-base-build-support/lib/XCAT/GenesisPayload.pm', 'xCAT-genesis-base-build-support/verify-genesis-payload', ], - 'the tarball holds the dracut modules, the rules file and the payload verifier'); + 'the tarball holds the dracut modules, the rules file and the payload verifier with its module'); is_deeply([ grep { !m{ root/root .* 2023-11-14 22:13 } } @listing ], [], 'every member is owned by root and dated SOURCE_DATE_EPOCH'); @@ -62,7 +67,7 @@ my %shipped = map { $_ => 1 } names(members(stage_genesis_base_sources(repo_path('.'), "$scratch/shipped.tar.bz2", $EPOCH))); is_deeply([ grep { !$shipped{"xCAT-genesis-base-build-support/$_"} } "dracut_105/el/$MODULE", "dracut_105/ubuntu/$MODULE", '80-net-name-slot.rules', - 'verify-genesis-payload' ], [], + 'verify-genesis-payload', 'lib/XCAT/GenesisPayload.pm' ], [], 'xCAT-genesis-base in the checkout carries the dracut modules, the rules file and the verifier'); # A checkout that still uses the old name must stop the build, not produce an empty tarball. diff --git a/xCAT-test/unit/genesis_base_spec_buildrequires.t b/xCAT-test/unit/genesis_base_spec_buildrequires.t index 621f3a001..fa4fa66c0 100644 --- a/xCAT-test/unit/genesis_base_spec_buildrequires.t +++ b/xCAT-test/unit/genesis_base_spec_buildrequires.t @@ -10,7 +10,7 @@ use Test::More; use XCAT::Test::File qw(repo_path slurp_repo_file); -my $relative = 'xCAT-genesis-builder/xCAT-genesis-base.spec'; +my $relative = 'xCAT-genesis-base/xCAT-genesis-base.spec'; plan skip_all => "$relative not found" unless -f repo_path($relative); plan tests => 4; diff --git a/xCAT-test/unit/genesis_payload_verification.t b/xCAT-test/unit/genesis_payload_verification.t index d1334b24b..2d8ee7b6c 100644 --- a/xCAT-test/unit/genesis_payload_verification.t +++ b/xCAT-test/unit/genesis_payload_verification.t @@ -1,186 +1,185 @@ #!/usr/bin/env perl -# Drive verify-genesis-payload against payload trees that each leave out one thing the image -# needs. +# XCAT::GenesisPayload decides whether an extracted Genesis payload is complete. +# verify-genesis-payload calls it in the EL spec and in the Ubuntu builder. Each payload +# below leaves out one thing the image needs. use strict; use warnings; +use File::Basename qw(dirname); use File::Path qw(make_path); -use File::Slurper qw(read_text write_text); +use File::Slurper qw(write_text); use File::Temp qw(tempdir); use FindBin; use lib "$FindBin::Bin/../lib"; +use lib "$FindBin::Bin/../../xCAT-genesis-base/lib"; use Test::More; -use XCAT::Test::File qw(repo_path); +use XCAT::GenesisPayload qw(module_commands missing_paths check_payload); -my $verifier = repo_path('xCAT-genesis-builder/verify-genesis-payload'); -plan skip_all => 'verify-genesis-payload not found' unless -f $verifier; -plan tests => 22; - -my $tmpdir = tempdir(CLEANUP => 1); -my $module_seq = 0; +my $OPENSSH_99 = "OpenSSH_9.9p1\n/usr/libexec/openssh/sshd-session\n"; +my $OPENSSH_80 = "OpenSSH_8.0p1\n"; # 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); -my ($rc, $err) = run($good, 'usr/sbin/dhclient'); -is($rc, 0, 'a complete payload passes') or diag($err); +my @COMPLETE = qw( + usr/sbin/sshd usr/libexec/openssh/sshd-session usr/bin/tmux + usr/lib/locale/C.utf8/LC_CTYPE usr/sbin/dhclient usr/bin/mktemp + usr/bin/awk etc/services usr/bin/openssl usr/bin/wget usr/bin/tar +); + +# The payload carries exactly the paths it is given. +sub carries { + my %present = map { $_ => 1 } @_; + return sub { $present{ $_[0] } }; +} +sub all_but { my %gone = map { $_ => 1 } @_; return carries(grep { !$gone{$_} } @COMPLETE) } + +is_deeply([ missing_paths(carries(@COMPLETE), sshd => $OPENSSH_99, required => ['usr/sbin/dhclient']) ], + [], 'a complete payload passes'); # 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'); -like($err, qr{usr/sbin/dhclient}, 'the missing dhclient is named'); +is_deeply([ missing_paths(all_but('usr/sbin/dhclient'), sshd => $OPENSSH_99, required => ['usr/sbin/dhclient']) ], + ['usr/sbin/dhclient (required by the build)'], + 'a payload without dhclient fails and names it'); # 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, 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, mktemp => 1); -($rc, $err) = run($openssh8, 'usr/sbin/dhclient'); -is($rc, 0, 'an OpenSSH 8 payload passes without sshd-session') or diag($err); +is_deeply([ missing_paths(all_but('usr/libexec/openssh/sshd-session'), sshd => $OPENSSH_99) ], + ['usr/libexec/openssh/sshd-session (this sshd execs it for every connection)'], + 'a payload whose sshd execs sshd-session but does not ship it fails'); # 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, 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'); +is_deeply([ missing_paths(all_but('usr/lib/locale/C.utf8/LC_CTYPE'), sshd => $OPENSSH_99) ], + ['usr/lib/locale/C.utf8/LC_CTYPE (tmux refuses to start without a UTF-8 locale)'], + 'a payload with tmux and no UTF-8 locale fails'); # 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'); +is_deeply([ missing_paths(all_but('usr/bin/mktemp'), sshd => $OPENSSH_99) ], + ['usr/bin/mktemp (getdestiny makes its request file with it)'], + 'a payload without mktemp fails'); + +# Genesis is reached over ssh. +is_deeply([ missing_paths(all_but('usr/sbin/sshd')) ], + ['usr/sbin/sshd (Genesis is reached over ssh)'], + 'a payload without sshd fails'); + +is_deeply([ missing_paths(carries(grep({ $_ ne 'usr/libexec/openssh/sshd-session' } @COMPLETE), + 'usr/lib/openssh/sshd-session'), sshd => $OPENSSH_99) ], + [], 'the Debian path of sshd-session counts'); + +# OpenSSH 8 does not use the helper, so el8 must still pass without it. +is_deeply([ missing_paths(all_but('usr/libexec/openssh/sshd-session'), sshd => $OPENSSH_80) ], + [], 'an OpenSSH 8 payload passes without sshd-session'); + +is_deeply([ missing_paths(all_but('usr/lib/locale/C.utf8/LC_CTYPE', 'usr/bin/tmux'), sshd => $OPENSSH_99) ], + [], 'a payload without tmux needs no locale'); # dracut_install reports a missing binary and returns, so every name the dracut module -# 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)]); -($rc, $err) = run_with_commands($module, $full); -is($rc, 0, 'a payload carrying every command the module names passes') or diag($err); +# installs has to be checked against the payload. A name starting with "/" is installed at +# that same path; the rest are commands. +my $tmpdir = tempdir(CLEANUP => 1); +my $module = "$tmpdir/module-setup.sh"; +write_text($module, <<'SH'); +#!/bin/bash -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'); +install() { + dracut_install -o openssl wget tar # a trailing comment + dracut_install /usr/bin/awk /etc/services + if command -v dhclient >/dev/null 2>&1; then + dracut_install dhclient + fi +} -# 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); -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'); +installkernel() { + dracut_install notacommand +} +SH +my @commands = module_commands($module); # 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); +is_deeply(\@commands, [qw(/etc/services /usr/bin/awk openssl tar wget)], + 'the top-level names of install() are read back, without options, comments, conditionals + or other functions'); -# A module the verifier cannot read names for covers nothing, so say so instead of passing. +my %names = (commands => \@commands, source => $module, sshd => $OPENSSH_99); +is_deeply([ missing_paths(carries(@COMPLETE), %names) ], [], + 'a payload carrying every command the module names passes'); +is_deeply([ missing_paths(carries(grep({ $_ ne 'usr/bin/wget' } @COMPLETE), 'sbin/wget'), %names) ], + [], 'a command under sbin counts as present'); + +is_deeply([ missing_paths(all_but('usr/bin/openssl'), %names) ], + ["openssl (installed by $module)"], + 'a payload without openssl fails and names it'); + +# doxcat, getdestiny and the firmware wrappers all run awk. +is_deeply([ missing_paths(all_but('usr/bin/awk'), %names) ], + ["/usr/bin/awk (installed by $module)"], + 'a payload without the absolute path /usr/bin/awk fails'); + +# Genesis resolves service names with /etc/services. +is_deeply([ missing_paths(all_but('etc/services'), %names) ], + ["/etc/services (installed by $module)"], + 'a payload without the absolute path /etc/services fails'); + +# A module the verifier cannot read names from 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'); +ok(!eval { module_commands($unparsable); 1 }, 'a module with no install() names is refused'); +is($@, "verify-genesis-payload: no command name read from $unparsable\n", + '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'); +ok(!eval { module_commands("$tmpdir/no-such-module"); 1 }, 'an unreadable module is refused'); +is($@, "verify-genesis-payload: cannot read $tmpdir/no-such-module\n", + 'the unreadable module is named'); -($rc, $err) = run("$tmpdir/does-not-exist"); -is($rc >> 0, 2, 'a missing payload directory is a usage error'); +# --- the command line reads a real payload tree --------------------------------------------- +my $good = payload_tree(@COMPLETE); +write_text("$good/usr/sbin/sshd", $OPENSSH_99); +my $nodhcp = payload_tree(grep { $_ ne 'usr/sbin/dhclient' } @COMPLETE); +my $nohelper = payload_tree(grep { $_ ne 'usr/libexec/openssh/sshd-session' } @COMPLETE); +write_text("$nohelper/usr/sbin/sshd", $OPENSSH_99); + +is_deeply([ check_payload($good, 'usr/sbin/dhclient') ], + [ 0, "verify-genesis-payload: $good is complete\n" ], + 'a complete payload exits 0 and says so'); +is_deeply([ check_payload($nodhcp, 'usr/sbin/dhclient') ], + [ 1, "verify-genesis-payload: $nodhcp is incomplete:\n" + . " usr/sbin/dhclient (required by the build)\n" ], + 'an incomplete payload exits 1 and lists what is missing'); +is_deeply([ check_payload($nohelper) ], + [ 1, "verify-genesis-payload: $nohelper is incomplete:\n" + . " usr/libexec/openssh/sshd-session (this sshd execs it for every connection)\n" ], + 'the command line reads usr/sbin/sshd to decide on sshd-session'); +is_deeply([ check_payload('--commands-from', $module, $nodhcp) ], + [ 0, "verify-genesis-payload: $nodhcp is complete\n" ], + '--commands-from does not require a name installed under a condition'); +my $noopenssl = payload_tree(grep { $_ ne 'usr/bin/openssl' } @COMPLETE); +is_deeply([ check_payload("--commands-from=$module", $noopenssl) ], + [ 1, "verify-genesis-payload: $noopenssl is incomplete:\n" + . " openssl (installed by $module)\n" ], + '--commands-from= checks the names of the module'); +is_deeply([ check_payload('--commands-from', $unparsable, $good) ], + [ 2, "verify-genesis-payload: no command name read from $unparsable\n" ], + 'a module with no command names is a usage error'); +is_deeply([ check_payload('--commands-from', "$tmpdir/no-such-module", $good) ], + [ 2, "verify-genesis-payload: cannot read $tmpdir/no-such-module\n" ], + 'a module file that cannot be read is a usage error'); +is_deeply([ check_payload("$tmpdir/does-not-exist") ], + [ 2, "verify-genesis-payload: not a payload directory: $tmpdir/does-not-exist\n" ], + 'a missing payload directory is a usage error'); +is_deeply([ check_payload() ], + [ 2, "verify-genesis-payload: not a payload directory: \n" ], + 'no payload directory at all is a usage error'); + +done_testing(); #--- -# build_payload: make a payload tree with the pieces the verifier reasons about. +# payload_tree: a payload directory that carries the given paths as empty files. #--- -sub build_payload { - my (%opt) = @_; +sub payload_tree { 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}; - 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"); + for my $path (@_) { + make_path(dirname("$root/$path")); + write_text("$root/$path", ''); } 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); -} - -#--- -# 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); -} diff --git a/xCAT-test/unit/genesis_spec_target_arch.t b/xCAT-test/unit/genesis_spec_target_arch.t index fa69c4c19..41c880079 100644 --- a/xCAT-test/unit/genesis_spec_target_arch.t +++ b/xCAT-test/unit/genesis_spec_target_arch.t @@ -29,7 +29,7 @@ my %tarch = ( my %spec = ( 'xCAT-genesis-scripts' => "$root/xCAT-genesis-scripts/xCAT-genesis-scripts.spec", - 'xCAT-genesis-base' => "$root/xCAT-genesis-builder/xCAT-genesis-base.spec", + 'xCAT-genesis-base' => "$root/xCAT-genesis-base/xCAT-genesis-base.spec", ); for my $pkg (sort keys %spec) { diff --git a/xCAT-test/unit/genesis_ubuntu_build_root.t b/xCAT-test/unit/genesis_ubuntu_build_root.t index fb598ccd9..3e706b2f7 100755 --- a/xCAT-test/unit/genesis_ubuntu_build_root.t +++ b/xCAT-test/unit/genesis_ubuntu_build_root.t @@ -3,24 +3,22 @@ # mandatory. dracut_install reports a missing command and returns 0, so a hole in the image # does not fail the build. # -# The mandatory list comes from RUNNING the module: module-setup.sh is sourced with -# dracut_install shadowed, _dracut_install_opt neutralised, and install() called. The -# package list comes from evaluating the REQUIRED_PACKAGES assignment in the build script. +# XCAT::GenesisBuildRoot::required_packages lists the packages of the build root, and +# XCAT::GenesisPayload::module_commands reads the mandatory commands from the module, as +# verify-genesis-payload does. use strict; use warnings; -use File::Temp qw(tempdir); use FindBin; use lib "$FindBin::Bin/../lib"; +use lib "$FindBin::Bin/../../xCAT-genesis-base/lib"; use Test::More; +use XCAT::GenesisBuildRoot qw(required_packages); +use XCAT::GenesisPayload qw(module_commands); use XCAT::Test::File qw(repo_path); -my $builder = repo_path('xCAT-genesis-builder/builddeb-genesis-base'); -my $module = repo_path('xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh'); -plan skip_all => 'builddeb-genesis-base not found' unless -f $builder; -plan skip_all => 'ubuntu module-setup.sh not found' unless -f $module; -plan tests => 9; +my $module = repo_path('xCAT-genesis-base/dracut_105/ubuntu/module-setup.sh'); # Mandatory commands a minimal Ubuntu server root does NOT already provide, and the package # that supplies each one on every release xCAT builds for. @@ -29,87 +27,53 @@ my %PACKAGE_FOR = ( ifenslave => 'ifenslave', ); +# A release carries exactly the names in its list. +sub release { + my %carried = map { $_ => 1 } @_; + return sub { $carried{ $_[0] } }; +} + # hwclock is not in that list because the package that carries it moved. Measured on the # four Ubuntu management nodes: focal and jammy have it in util-linux, which is essential # and always in the build root, and no util-linux-extra exists to install; noble and # resolute have it in util-linux-extra. util-linux only Suggests that package, and this # build passes --no-install-recommends, so the releases that split it must name it and the # releases that did not must not. +my @noble = required_packages('amd64', 'noble', release('util-linux-extra')); +my @jammy = required_packages('amd64', 'jammy', release()); -my %mandatory = map { $_ => 1 } mandatory_commands($module); -my @packages = required_packages($builder); +# An absolute path is a data file, not a command. +my %mandatory = map { $_ => 1 } grep { !m{^/} } module_commands($module); +my %packages = map { $_ => 1 } @jammy; for my $command (sort keys %PACKAGE_FOR) { ok($mandatory{$command}, "the Ubuntu dracut module installs '$command' unconditionally"); - ok(scalar(grep { $_ eq $PACKAGE_FOR{$command} } @packages), + ok($packages{ $PACKAGE_FOR{$command} }, "the build root installs $PACKAGE_FOR{$command}, which provides '$command'"); } # doxcat asks dhclient for the provisioning lease. -ok($mandatory{dhclient} && scalar(grep { $_ eq 'isc-dhcp-client' } @packages), +ok($mandatory{dhclient} && $packages{'isc-dhcp-client'}, 'the Genesis image can obtain a DHCP lease'); ok($mandatory{hwclock}, "the Ubuntu dracut module installs 'hwclock' unconditionally"); # Naming a package apt cannot locate fails the whole install, and the script runs under # set -e, so an unconditional util-linux-extra stops the build on focal and jammy. -ok(!scalar(grep { $_ eq 'util-linux-extra' } @packages), - 'the unconditional list does not name util-linux-extra'); +is_deeply([ grep { $_ eq 'util-linux-extra' } @jammy ], [], + 'a release without util-linux-extra does not get it'); +is_deeply([ @noble[ 0 .. $#noble - 1 ] ], \@jammy, + 'a release that carries util-linux-extra gets the same list ...'); +is($noble[-1], 'util-linux-extra', '... with util-linux-extra last'); -# What the script does instead: keep a package only where apt has a candidate for it. -{ - is_deeply(optional_packages($builder, 'util-linux-extra', 0), ['util-linux-extra'], - 'a release that carries util-linux-extra installs it'); - is_deeply(optional_packages($builder, 'util-linux-extra', 1), [], - 'a release without it installs nothing in its place'); -} +my @ppc = required_packages('ppc64el', 'noble', release('util-linux-extra')); +is_deeply([ @noble[ -3 .. -1 ] ], [qw(dmidecode efibootmgr util-linux-extra)], + 'amd64 adds dmidecode and efibootmgr'); +is_deeply([ @ppc[ 0 .. $#ppc - 1 ] ], [ @noble[ 0 .. $#noble - 3 ] ], + 'ppc64el gets neither dmidecode nor efibootmgr'); -# An absolute path in the install() output is a data file, not a command. -sub mandatory_commands { - my ($path) = @_; - my $dir = tempdir(CLEANUP => 1); - my $driver = "$dir/collect.sh"; - open my $fh, '>', $driver or die "$driver: $!"; - print $fh <<"BASH"; -dracut_install() { printf '%s\\n' "\$\@"; } -instmods() { :; } -inst_multiple() { :; } -inst() { :; } -dpkg-architecture() { echo x86_64-linux-gnu; } -. '$path' -# _dracut_install_opt installs only what the build root already has. Neutralise it after -# sourcing, so its commands stay out of the mandatory set. -_dracut_install_opt() { :; } -install -BASH - close $fh; - my @out = qx{bash '$driver' 2>/dev/null}; - die("running install() from $path produced nothing") unless @out; - my %seen; - my @names = grep { !$seen{$_}++ } grep { length && !m{^/} } map { chomp; $_ } @out; - die("install() from $path named no bare commands") unless @names; - return @names; -} +my @asked; +required_packages('amd64', 'noble', sub { push @asked, $_[0]; 1 }); +is_deeply(\@asked, ['util-linux-extra'], 'apt is asked only about the optional package'); -# Run the script's own selector with apt-cache shadowed, so the decision is exercised -# rather than read. $rc is what the shadow returns: 0 for a release that has the package. -sub optional_packages { - my ($path, $package, $rc) = @_; - my $text = do { open my $fh, '<', $path or die "$path: $!"; local $/; <$fh> }; - my ($block) = $text =~ /^(optional_packages\(\)\s*\{.*?^\})/ms; - BAIL_OUT("no optional_packages() in $path") unless $block; - my $out = qx{bash -c 'set -u; apt-cache() { return $rc; }; $block; optional_packages $package' 2>/dev/null}; - return [ grep { length } split /\s+/, ($out // '') ]; -} - -# Evaluate the assignment rather than parse it, so the list is the value the script uses. -sub required_packages { - my ($path) = @_; - my $text = do { open my $fh, '<', $path or die "$path: $!"; local $/; <$fh> }; - my ($block) = $text =~ /^(REQUIRED_PACKAGES="[^"]*")/ms; - die("no REQUIRED_PACKAGES assignment in $path") unless $block; - my $out = qx{bash -c 'set -u; $block; printf "%s\\n" \$REQUIRED_PACKAGES' 2>/dev/null}; - my @packages = grep { length } split /\s+/, ($out // ''); - die("REQUIRED_PACKAGES in $path evaluated to nothing") unless @packages; - return @packages; -} +done_testing();