From 36168bc4f22f354fc94aece182a9108b7edbb39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:41:33 -0300 Subject: [PATCH 1/5] test(otherpkgs): exercise repository-scoped upgrades --- .github/workflows/xcat_test.yml | 4 +- xCAT-test/unit/otherpkgs_upgrade_scope.t | 252 +++++++++++++++++++---- 2 files changed, 211 insertions(+), 45 deletions(-) diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml index 04d50aff0..a6b6feda2 100644 --- a/.github/workflows/xcat_test.yml +++ b/.github/workflows/xcat_test.yml @@ -7,7 +7,9 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install dependencies - run: sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-install-suggests bats build-essential fakeroot reprepro devscripts debhelper libcapture-tiny-perl libfile-slurper-perl libjson-perl libparallel-forkmanager-perl libsoap-lite-perl libdbi-perl libcgi-pm-perl quilt openssh-server dpkg looptools genometools software-properties-common + run: sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-install-suggests bats bubblewrap build-essential fakeroot reprepro devscripts debhelper libcapture-tiny-perl libfile-slurper-perl libjson-perl libparallel-forkmanager-perl libsoap-lite-perl libdbi-perl libcgi-pm-perl quilt openssh-server dpkg looptools genometools software-properties-common + - name: Enable postscript test namespaces + run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Run tests run: perl github_action_xcat_test.pl diff --git a/xCAT-test/unit/otherpkgs_upgrade_scope.t b/xCAT-test/unit/otherpkgs_upgrade_scope.t index c34db50f5..933564809 100644 --- a/xCAT-test/unit/otherpkgs_upgrade_scope.t +++ b/xCAT-test/unit/otherpkgs_upgrade_scope.t @@ -2,57 +2,221 @@ use strict; use warnings; -use FindBin; +use Capture::Tiny qw(capture_merged); +use File::Glob qw(bsd_glob); +use File::Path qw(make_path); +use File::Slurper qw(read_text write_text); use File::Spec; +use File::Temp qw(tempdir); +use FindBin; +use lib "$FindBin::Bin/../lib"; use Test::More; +use Text::ParseWords qw(shellwords); -my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' ); -my $script_path = File::Spec->catfile( $repo_root, 'xCAT/postscripts/otherpkgs' ); +use XCAT::Test::File qw(repo_path); -plan skip_all => "$script_path not found" unless -r $script_path; +plan skip_all => 'otherpkgs filesystem isolation requires Linux' + unless $^O eq 'linux'; -open( my $fh, '<', $script_path ) or die "Unable to read $script_path: $!"; -my $script = do { local $/; <$fh> }; -close($fh); - -# The postscript writes its repositories as [xcat-otherpkgs]. The upgrade -# below is scoped with --enablerepo=xcat-otherpkgs*, so the two have to agree or -# the upgrade silently matches no repository at all. -like( - $script, - qr/echo\s+"\[xcat-otherpkgs\$urlrepoindex\]"/, - 'remote repositories are still defined as xcat-otherpkgs' -); -like( - $script, - qr/echo\s+"\[xcat-otherpkgs\$localrepoindex\]"/, - 'local repositories are still defined as xcat-otherpkgs' +my $temporary_root = File::Spec->tmpdir(); +local %ENV = ( PATH => '/usr/bin:/bin', LC_ALL => 'C' ); +my $command_utils = repo_path('perl-xCAT/xCAT/CommandUtils.pm'); +require $command_utils; +my $bwrap = xCAT::CommandUtils::find_executable('bwrap'); +die "Install bubblewrap to run the otherpkgs test\n" unless $bwrap; +my $postscripts = repo_path('xCAT/postscripts'); +my @utilities = qw(bash sh basename dirname cat cp expr grep ls mkdir rm uname wc); +push @utilities, 'coreutils' if xCAT::CommandUtils::find_executable('coreutils'); +my @sandbox = ( + $bwrap, '--unshare-all', '--die-with-parent', '--new-session', + '--ro-bind', '/', '/', '--tmpfs', '/etc', '--tmpfs', '/usr/bin', + '--tmpfs', '/tmp', '--proc', '/proc', '--dev', '/dev', + '--setenv', 'PATH', '/usr/bin', '--setenv', 'LC_ALL', 'C', ); +for my $utility (@utilities) { + my $source = xCAT::CommandUtils::find_executable($utility); + die "Required utility is unavailable: $utility\n" unless $source; + push @sandbox, '--ro-bind', $source, "/usr/bin/$utility"; +} -# Both the verbose echo and the command actually executed must carry the same -# scoping, otherwise verbose output reports a command that was never run. -my @scoped = $script =~ /\$yumcmd\s+-y\s+--disablerepo=\*\s+--enablerepo=xcat-otherpkgs\*\s+upgrade/g; -is( - scalar(@scoped), - 2, - 'the yum/dnf upgrade is scoped to the xcat-otherpkgs repositories in both the verbose echo and the executed command' -); +my ( $probe_output, $probe_status ) = capture_merged { + system( @sandbox, '/usr/bin/sh', '-c', 'test ! -e /etc/os-release' ); +}; +die "Cannot isolate otherpkgs: $probe_output" if $probe_status; -# Counted rather than matched with unlike(), so that a failure reports the count -# instead of dumping the whole postscript into the test output. -my @unscoped = $script =~ /(\$yumcmd\s+-y\s+upgrade)/g; -is( - scalar(@unscoped), - 0, - 'no unscoped yum/dnf upgrade remains, which would also apply unrelated distribution updates' -); - -# The install path must keep every repository enabled so that dependencies of -# the otherpkgs packages can still be resolved from the distribution. -like( - $script, - qr/\$yumcmd\s+-y\s+install\s+\$repo_pkgs/, - 'the package install path is left unscoped so dependencies still resolve' -); +for my $manager (qw(dnf yum)) { + for my $case ( + { name => 'HTTP and local repositories', verbose => 1, remote => 1 }, + { name => 'mounted repositories', mounted => 1 }, + { name => 'upgrade failure', upgrade_status => 17, verbose => 1 }, + { name => 'install failure', install_status => 23, verbose => 1 }, + { name => 'repository-only mode', repoonly => 1, remote => 1 }, + { name => 'separate package lists', multiple => 1, verbose => 1 }, + { name => 'remote repository without installs', remote => 1, empty => 1 }, + ) + { + subtest "$manager: $case->{name}" => sub { + run_case( $manager, $case ); + }; + } +} done_testing(); + +sub run_case { + my ( $manager, $case ) = @_; + my $fixture = tempdir( DIR => $temporary_root, CLEANUP => 1 ); + make_path("$fixture/bin"); + write_command( "$fixture/bin/logger", "exit 0\n" ); + write_command( "$fixture/bin/dpkg", "exit 1\n" ); + write_command( "$fixture/bin/rpm", '[ "$*" = --version ]' . "\n" ); + write_command( + "$fixture/bin/mount", + $case->{mounted} + ? "printf '%s\\n' 'package-server:/install on /install type nfs (rw)'\n" + : "exit 0\n" + ); + write_command( "$fixture/bin/$manager", <<'SH' ); +printf '%s\t' "${0##*/}" "SCOPE_ENV=${SCOPE_ENV:-}" "$@" >> /tmp/fixture/commands +printf '\n' >> /tmp/fixture/commands +sequence=$(wc -l < /tmp/fixture/commands) +mkdir "/tmp/fixture/repos.$sequence" +cp /etc/yum.repos.d/*.repo "/tmp/fixture/repos.$sequence/" 2>/dev/null || : +for argument do + case "$argument" in + upgrade) + printf '%s\n' upgrade-result + exit "$UPGRADE_STATUS" + ;; + install) + printf '%s\n' install-result + exit "$INSTALL_STATUS" + ;; + esac +done +exit 0 +SH + + my %environment = ( + OSVER => 'rhel9', ARCH => 'x86_64', UPDATENODE => 1, + NFSSERVER => 'package-server', HTTPPORT => 80, INSTALLDIR => '/install', + OTHERPKGDIR => '/install/other', OTHERPKGS_INDEX => 1, + OTHERPKGS1 => $case->{empty} ? '' : 'alpha/tool-one,beta/tool-two', + ENVLIST1 => 'SCOPE_ENV=first', VERBOSE => $case->{verbose} ? 1 : '', + UPGRADE_STATUS => $case->{upgrade_status} || 0, + INSTALL_STATUS => $case->{install_status} || 0, + ); + $environment{OTHERPKGDIR} = + 'https://packages.example.invalid/extra,/install/other' if $case->{remote}; + if ( $case->{multiple} ) { + @environment{qw(OTHERPKGS_INDEX OTHERPKGS1 OTHERPKGS2 ENVLIST2)} = + ( 2, 'alpha/tool-one,beta/tool-two', 'gamma/tool-three', 'SCOPE_ENV=second' ); + } + + my @command = ( + @sandbox, '--bind', $fixture, '/tmp/fixture', + '--ro-bind', $postscripts, '/tmp/postscripts', '--chdir', '/tmp/fixture', + ); + for my $tool (qw(logger dpkg rpm mount), $manager) { + push @command, '--ro-bind', "$fixture/bin/$tool", "/usr/bin/$tool"; + } + for my $key ( sort keys %environment ) { + push @command, '--setenv', $key, $environment{$key}; + } + push @command, '/usr/bin/sh', '-c', <<'SH', 'otherpkgs-test'; +/usr/bin/bash /tmp/postscripts/otherpkgs "$@" +status=$? +mkdir /tmp/fixture/final-repos +cp /etc/yum.repos.d/*.repo /tmp/fixture/final-repos/ 2>/dev/null || : +exit "$status" +SH + push @command, '--repoonly' if $case->{repoonly}; + + my ( $output, $status ) = capture_merged { system(@command) }; + is( $status, ( $case->{upgrade_status} || $case->{install_status} || 0 ) << 8, + 'the postscript returns the package-manager status' ) or diag($output); + ok( -f "$fixture/commands", 'the real postscript reaches the package manager' ); + return unless -f "$fixture/commands"; + + my @calls = map { [ split /\t/ ] } split /\n/, read_text("$fixture/commands"); + my @transactions; + for my $index ( 0 .. $#calls ) { + my $call = $calls[$index]; + my @operands = grep { !/^-/ } @{$call}[ 2 .. $#{$call} ]; + next if @operands && ( $operands[0] eq 'clean' || $operands[0] eq 'list' ); + push @transactions, [ $index + 1, $call ]; + } + my @expected; + my @groups = $case->{multiple} + ? ( [ first => qw(tool-one tool-two) ], [ second => 'tool-three' ] ) + : ( [ first => ( $case->{empty} ? () : qw(tool-one tool-two) ) ] ); + unless ( $case->{repoonly} ) { + for my $group (@groups) { + my ( $label, @packages ) = @{$group}; + push @expected, + [ $manager, "SCOPE_ENV=$label", '-y', '--disablerepo=*', + '--enablerepo=xcat-otherpkgs*', 'upgrade' ]; + push @expected, [ $manager, "SCOPE_ENV=$label", '-y', 'install', @packages ] + if @packages; + } + } + is_deeply( [ map { $_->[1] } @transactions ], \@expected, + 'only upgrades are repository-scoped; installs retain dependency repositories' ); + + my @printed = map { [ shellwords($_) ] } + grep { /^SCOPE_ENV=/ } split /\n/, $output; + my @expected_printed = $case->{verbose} + ? map { [ $_->[1], $_->[0], @{$_}[ 2 .. $#{$_} ] ] } @expected : (); + is_deeply( \@printed, \@expected_printed, + 'verbose commands describe the executed transactions and quiet mode omits them' ); + + for my $transaction (@transactions) { + my ( $sequence, $call ) = @{$transaction}; + my @paths = $case->{multiple} + ? ( $call->[1] eq 'SCOPE_ENV=first' ? qw(alpha beta) : 'gamma' ) + : ( $case->{empty} ? () : qw(alpha beta) ); + check_repositories( "$fixture/repos.$sequence", $case, \@paths ); + } + my @final_paths = $case->{multiple} ? ('gamma') + : $case->{empty} ? () : qw(alpha beta); + check_repositories( "$fixture/final-repos", $case, \@final_paths ); + if ( $case->{verbose} && !$case->{repoonly} ) { + like( $output, qr/^upgrade-result$/m, 'upgrade output reaches the caller' ); + like( $output, qr/^install-result$/m, 'install output reaches the caller' ) + unless $case->{empty}; + } +} + +sub check_repositories { + my ( $directory, $case, $paths ) = @_; + my $base = $case->{mounted} ? 'file://' : 'http://package-server:80'; + my @expected = ( + [ 'xCAT-rhel9-path0', "$base/install/rhel9/x86_64/BaseOS", '1' ], + [ 'xCAT-rhel9-path1', "$base/install/rhel9/x86_64/AppStream", '1' ], + ); + my $index = 0; + push @expected, [ 'xcat-otherpkgs' . $index++, + 'https://packages.example.invalid/extra', '1' ] if $case->{remote}; + push @expected, [ 'xcat-otherpkgs' . $index++, "$base/install/other/$_", '1' ] + for @{$paths}; + my @actual; + for my $file ( bsd_glob("$directory/*.repo") ) { + my $contents = read_text($file); + my @sections = $contents =~ /^\[([^\]\n]+)\]$/mg; + my @urls = $contents =~ /^baseurl=(.*?)\s*$/mg; + my @enabled = $contents =~ /^enabled=(.*?)\s*$/mg; + my @gpgcheck = $contents =~ /^gpgcheck=(.*?)\s*$/mg; + push @actual, [ @sections, @urls, @enabled, @gpgcheck ]; + } + is_deeply( + [ sort { $a->[0] cmp $b->[0] } @actual ], + [ map { [ @{$_}, '0' ] } sort { $a->[0] cmp $b->[0] } @expected ], + 'generated repositories match the upgrade scope and retain the OS repositories' + ); +} + +sub write_command { + my ( $file, $body ) = @_; + write_text( $file, "#!/usr/bin/sh\n$body" ); + chmod 0755, $file or die "Cannot make $file executable: $!"; +} From 3d09f4a0685c868d6c0d88c4692f217df07bfa43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:08:26 -0300 Subject: [PATCH 2/5] docs(test): describe the otherpkgs sandbox prerequisites --- xCAT-test/bats/README.md | 3 +++ xCAT-test/unit/README.md | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/xCAT-test/bats/README.md b/xCAT-test/bats/README.md index ac65379ab..11dce7d9d 100644 --- a/xCAT-test/bats/README.md +++ b/xCAT-test/bats/README.md @@ -10,6 +10,9 @@ The GitHub Actions `xcat_test` workflow runs this command after the Perl `.t` unit tests. Use BATS for shell behavior that can be exercised from the source tree without an installed xCAT, a live management node, or real services. +The existing `otherpkgs_upgrade_scope.t` remains in `unit/` for its structured +command and repository assertions. See its [sandbox prerequisites](../unit/README.md#postscript-sandbox-prerequisites). + Prefer sourcing an existing shell library or sourceable script and calling the function under test. Keep reusable install-template helpers in `xCAT-server/share/xcat/install/scripts/scriptlib`, and reusable postscript diff --git a/xCAT-test/unit/README.md b/xCAT-test/unit/README.md index daff2691c..ce343b18d 100644 --- a/xCAT-test/unit/README.md +++ b/xCAT-test/unit/README.md @@ -10,6 +10,19 @@ which calls `run_unit_tests()` in `github_action_xcat_test.pl`: prove -r xCAT-test/unit ``` +## Postscript sandbox prerequisites + +`otherpkgs_upgrade_scope.t` runs the complete postscript in a Linux filesystem +sandbox. It requires Bubblewrap, Bash, GNU core utilities, and permission to create +user namespaces. The CI workflow installs Bubblewrap and enables those namespaces. +Missing prerequisites fail this test without stopping unrelated test files. +Non-Linux hosts report a skip. Set `TMPDIR` to a writable, executable filesystem +if the default temporary directory is mounted with `noexec`. + +This test is an exception to the shell-test placement rule below. It stays in Perl +to compare structured command arguments and generated repository records with the +existing test helpers. It does not read or extract postscript source. + You can run exactly the same thing from a clean checkout: ``` From c7052489d265243f4e85e4e9f19bac2b3add4069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:41:43 -0300 Subject: [PATCH 3/5] ci(test): refresh package indexes before installing dependencies --- .github/workflows/xcat_test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml index a6b6feda2..a9ba8b5fe 100644 --- a/.github/workflows/xcat_test.yml +++ b/.github/workflows/xcat_test.yml @@ -6,6 +6,8 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v6 + - name: Refresh package indexes + run: sudo apt-get update - name: Install dependencies run: sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-install-suggests bats bubblewrap build-essential fakeroot reprepro devscripts debhelper libcapture-tiny-perl libfile-slurper-perl libjson-perl libparallel-forkmanager-perl libsoap-lite-perl libdbi-perl libcgi-pm-perl quilt openssh-server dpkg looptools genometools software-properties-common - name: Enable postscript test namespaces From 76d8dfdd14a4f39ea0b49ff4910d4deecb5d437e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:48:40 -0300 Subject: [PATCH 4/5] test(otherpkgs): move sandbox checks to BATS --- xCAT-test/bats/otherpkgs_upgrade_scope.bats | 239 ++++++++++++++++++++ xCAT-test/unit/otherpkgs_upgrade_scope.t | 222 ------------------ 2 files changed, 239 insertions(+), 222 deletions(-) create mode 100644 xCAT-test/bats/otherpkgs_upgrade_scope.bats delete mode 100644 xCAT-test/unit/otherpkgs_upgrade_scope.t diff --git a/xCAT-test/bats/otherpkgs_upgrade_scope.bats b/xCAT-test/bats/otherpkgs_upgrade_scope.bats new file mode 100644 index 000000000..d924f932b --- /dev/null +++ b/xCAT-test/bats/otherpkgs_upgrade_scope.bats @@ -0,0 +1,239 @@ +#!/usr/bin/env bats + +load 'helpers/shell_source' + +setup() +{ + [ "$(uname -s)" = Linux ] || skip 'otherpkgs filesystem isolation requires Linux' + local utility executable + local utilities=(bash sh basename dirname cat cp expr grep ls mkdir rm uname wc) + local bwrap + bwrap=$(PATH=/usr/bin:/bin type -P bwrap) || { + echo 'Install bubblewrap to run the otherpkgs test' >&2 + return 1 + } + postscripts=$(repo_path xCAT/postscripts) + fixture="$BATS_TEST_TMPDIR/fixture" + mkdir -p "$fixture/bin" + sandbox=(env -i PATH=/usr/bin:/bin LC_ALL=C "$bwrap" + --unshare-all --die-with-parent --new-session + --ro-bind / / --tmpfs /etc --tmpfs /usr/bin --tmpfs /tmp + --proc /proc --dev /dev --setenv PATH /usr/bin --setenv LC_ALL C) + if PATH=/usr/bin:/bin type -P coreutils >/dev/null; then + utilities+=(coreutils) + fi + for utility in "${utilities[@]}"; do + executable=$(PATH=/usr/bin:/bin type -P "$utility") || { + echo "Required utility is unavailable: $utility" >&2 + return 1 + } + sandbox+=(--ro-bind "$executable" "/usr/bin/$utility") + done + run "${sandbox[@]}" /usr/bin/sh -c 'test ! -e /etc/os-release' + if [ "$status" -ne 0 ]; then + echo "Cannot isolate otherpkgs: $output" >&2 + return 1 + fi +} + +run_case() +{ + local manager=$1 scenario=$2 + local verbose='' remote='' mounted='' repoonly='' multiple='' empty='' + local upgrade_status=0 install_status=0 + case "$scenario" in + http) verbose=1; remote=1 ;; + mounted) mounted=1 ;; + upgrade_failure) upgrade_status=17; verbose=1 ;; + install_failure) install_status=23; verbose=1 ;; + repoonly) repoonly=1; remote=1 ;; + multiple) multiple=1; verbose=1 ;; + empty) remote=1; empty=1 ;; + esac + printf '#!/usr/bin/sh\nexit 0\n' >"$fixture/bin/logger" + printf '#!/usr/bin/sh\nexit 1\n' >"$fixture/bin/dpkg" + printf '#!/usr/bin/sh\n[ "$*" = --version ]\n' >"$fixture/bin/rpm" + if [ "$mounted" ]; then + printf '#!/usr/bin/sh\nprintf "%%s\\n" "package-server:/install on /install type nfs (rw)"\n' >"$fixture/bin/mount" + else + printf '#!/usr/bin/sh\nexit 0\n' >"$fixture/bin/mount" + fi + cat >"$fixture/bin/$manager" <<'SH' +#!/usr/bin/sh +printf '%s\t' "${0##*/}" "SCOPE_ENV=${SCOPE_ENV:-}" "$@" >> /tmp/fixture/commands +printf '\n' >> /tmp/fixture/commands +sequence=$(wc -l < /tmp/fixture/commands) +mkdir "/tmp/fixture/repos.$sequence" +cp /etc/yum.repos.d/*.repo "/tmp/fixture/repos.$sequence/" 2>/dev/null || : +for argument do + case "$argument" in + upgrade) printf '%s\n' upgrade-result; exit "$UPGRADE_STATUS" ;; + install) printf '%s\n' install-result; exit "$INSTALL_STATUS" ;; + esac +done +exit 0 +SH + chmod +x "$fixture/bin/"* + + local otherpkgdir=/install/other packages=alpha/tool-one,beta/tool-two + local list_count=1 + [ ! "$remote" ] || otherpkgdir=https://packages.example.invalid/extra,/install/other + [ ! "$empty" ] || packages= + [ ! "$multiple" ] || list_count=2 + local command=("${sandbox[@]}" + --bind "$fixture" /tmp/fixture + --ro-bind "$postscripts" /tmp/postscripts --chdir /tmp/fixture) + local tool + for tool in logger dpkg rpm mount "$manager"; do + command+=(--ro-bind "$fixture/bin/$tool" "/usr/bin/$tool") + done + command+=( + --setenv OSVER rhel9 --setenv ARCH x86_64 --setenv UPDATENODE 1 + --setenv NFSSERVER package-server --setenv HTTPPORT 80 + --setenv INSTALLDIR /install --setenv OTHERPKGDIR "$otherpkgdir" + --setenv OTHERPKGS_INDEX "$list_count" --setenv OTHERPKGS1 "$packages" + --setenv ENVLIST1 SCOPE_ENV=first --setenv VERBOSE "$verbose" + --setenv UPGRADE_STATUS "$upgrade_status" --setenv INSTALL_STATUS "$install_status") + if [ "$multiple" ]; then + command+=(--setenv OTHERPKGS2 gamma/tool-three --setenv ENVLIST2 SCOPE_ENV=second) + fi + local runner + runner=$(cat <<'SH' +/usr/bin/bash /tmp/postscripts/otherpkgs "$@" +status=$? +mkdir /tmp/fixture/final-repos +cp /etc/yum.repos.d/*.repo /tmp/fixture/final-repos/ 2>/dev/null || : +exit "$status" +SH + ) + command+=(/usr/bin/sh -c "$runner" otherpkgs-test) + [ ! "$repoonly" ] || command+=(--repoonly) + + run "${command[@]}" + if [ "$status" -ne "$((upgrade_status + install_status))" ]; then + echo "$output" >&2 + return 1 + fi + [ -f "$fixture/commands" ] + + local line operand sequence=0 + local arguments=() paths=() + : >"$fixture/transactions" + while IFS= read -r line; do + sequence=$((sequence + 1)) + IFS=$'\t' read -r -a arguments <<<"$line" + operand= + for operand in "${arguments[@]:2}"; do + [[ "$operand" = -* ]] || break + done + case "$operand" in clean|list) continue ;; esac + printf '%s\n' "$line" >>"$fixture/transactions" + paths=(alpha beta) + [ ! "$empty" ] || paths=() + if [ "$multiple" ] && [ "${arguments[1]}" = SCOPE_ENV=second ]; then + paths=(gamma) + fi + check_repositories "$fixture/repos.$sequence" "${paths[@]}" + done <"$fixture/commands" + + : >"$fixture/expected-transactions" + : >"$fixture/expected-printed" + if [ ! "$repoonly" ]; then + expect_transaction first -y '--disablerepo=*' '--enablerepo=xcat-otherpkgs*' upgrade + if [ ! "$empty" ]; then + expect_transaction first -y install tool-one tool-two + fi + if [ "$multiple" ]; then + expect_transaction second -y '--disablerepo=*' '--enablerepo=xcat-otherpkgs*' upgrade + expect_transaction second -y install tool-three + fi + fi + diff -u "$fixture/expected-transactions" "$fixture/transactions" + + : >"$fixture/printed" + while IFS= read -r line; do + [[ "$line" = SCOPE_ENV=* ]] || continue + read -r -a arguments <<<"$line" + printf '%s\t' "${arguments[@]}" >>"$fixture/printed" + printf '\n' >>"$fixture/printed" + done <<<"$output" + diff -u "$fixture/expected-printed" "$fixture/printed" + + paths=(alpha beta) + [ ! "$empty" ] || paths=() + [ ! "$multiple" ] || paths=(gamma) + check_repositories "$fixture/final-repos" "${paths[@]}" + if [ "$verbose" ] && [ ! "$repoonly" ]; then + [[ $'\n'"$output"$'\n' = *$'\nupgrade-result\n'* ]] + if [ ! "$empty" ]; then + [[ $'\n'"$output"$'\n' = *$'\ninstall-result\n'* ]] + fi + fi +} + +expect_transaction() +{ + local label=$1 + shift + printf '%s\t' "$manager" "SCOPE_ENV=$label" "$@" >>"$fixture/expected-transactions" + printf '\n' >>"$fixture/expected-transactions" + if [ "$verbose" ]; then + printf '%s\t' "SCOPE_ENV=$label" "$manager" "$@" >>"$fixture/expected-printed" + printf '\n' >>"$fixture/expected-printed" + fi +} + +check_repositories() +{ + local directory=$1 + shift + local base=http://package-server:80 index=0 pkgpath file + [ ! "$mounted" ] || base=file:// + { + printf '%s\t%s\t1\t0\n' xCAT-rhel9-path0 "$base/install/rhel9/x86_64/BaseOS" + printf '%s\t%s\t1\t0\n' xCAT-rhel9-path1 "$base/install/rhel9/x86_64/AppStream" + if [ "$remote" ]; then + printf '%s\t%s\t1\t0\n' xcat-otherpkgs0 https://packages.example.invalid/extra + index=1 + fi + for pkgpath do + printf '%s\t%s\t1\t0\n' "xcat-otherpkgs$index" "$base/install/other/$pkgpath" + index=$((index + 1)) + done + } | LC_ALL=C sort >"$fixture/expected-repos" + : >"$fixture/repos" + for file in "$directory/"*.repo; do + [ -f "$file" ] || continue + awk ' + /^\[[^]]+\]$/ { sections = sections substr($0, 2, length($0) - 2) "\t" } + /^(baseurl|enabled|gpgcheck)=/ { + key = substr($0, 1, index($0, "=") - 1) + value = substr($0, index($0, "=") + 1) + sub(/[[:space:]]+$/, "", value) + values[key] = values[key] value "\t" + } + END { + record = sections values["baseurl"] values["enabled"] values["gpgcheck"] + sub(/\t$/, "", record) + print record + } + ' "$file" >>"$fixture/repos" + done + LC_ALL=C sort "$fixture/repos" >"$fixture/sorted-repos" + diff -u "$fixture/expected-repos" "$fixture/sorted-repos" +} + +@test "dnf: HTTP and local repositories" { run_case dnf http; } +@test "dnf: mounted repositories" { run_case dnf mounted; } +@test "dnf: upgrade failure" { run_case dnf upgrade_failure; } +@test "dnf: install failure" { run_case dnf install_failure; } +@test "dnf: repository-only mode" { run_case dnf repoonly; } +@test "dnf: separate package lists" { run_case dnf multiple; } +@test "dnf: remote repository without installs" { run_case dnf empty; } +@test "yum: HTTP and local repositories" { run_case yum http; } +@test "yum: mounted repositories" { run_case yum mounted; } +@test "yum: upgrade failure" { run_case yum upgrade_failure; } +@test "yum: install failure" { run_case yum install_failure; } +@test "yum: repository-only mode" { run_case yum repoonly; } +@test "yum: separate package lists" { run_case yum multiple; } +@test "yum: remote repository without installs" { run_case yum empty; } diff --git a/xCAT-test/unit/otherpkgs_upgrade_scope.t b/xCAT-test/unit/otherpkgs_upgrade_scope.t deleted file mode 100644 index 933564809..000000000 --- a/xCAT-test/unit/otherpkgs_upgrade_scope.t +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env perl -use strict; -use warnings; - -use Capture::Tiny qw(capture_merged); -use File::Glob qw(bsd_glob); -use File::Path qw(make_path); -use File::Slurper qw(read_text write_text); -use File::Spec; -use File::Temp qw(tempdir); -use FindBin; -use lib "$FindBin::Bin/../lib"; -use Test::More; -use Text::ParseWords qw(shellwords); - -use XCAT::Test::File qw(repo_path); - -plan skip_all => 'otherpkgs filesystem isolation requires Linux' - unless $^O eq 'linux'; - -my $temporary_root = File::Spec->tmpdir(); -local %ENV = ( PATH => '/usr/bin:/bin', LC_ALL => 'C' ); -my $command_utils = repo_path('perl-xCAT/xCAT/CommandUtils.pm'); -require $command_utils; -my $bwrap = xCAT::CommandUtils::find_executable('bwrap'); -die "Install bubblewrap to run the otherpkgs test\n" unless $bwrap; -my $postscripts = repo_path('xCAT/postscripts'); -my @utilities = qw(bash sh basename dirname cat cp expr grep ls mkdir rm uname wc); -push @utilities, 'coreutils' if xCAT::CommandUtils::find_executable('coreutils'); -my @sandbox = ( - $bwrap, '--unshare-all', '--die-with-parent', '--new-session', - '--ro-bind', '/', '/', '--tmpfs', '/etc', '--tmpfs', '/usr/bin', - '--tmpfs', '/tmp', '--proc', '/proc', '--dev', '/dev', - '--setenv', 'PATH', '/usr/bin', '--setenv', 'LC_ALL', 'C', -); -for my $utility (@utilities) { - my $source = xCAT::CommandUtils::find_executable($utility); - die "Required utility is unavailable: $utility\n" unless $source; - push @sandbox, '--ro-bind', $source, "/usr/bin/$utility"; -} - -my ( $probe_output, $probe_status ) = capture_merged { - system( @sandbox, '/usr/bin/sh', '-c', 'test ! -e /etc/os-release' ); -}; -die "Cannot isolate otherpkgs: $probe_output" if $probe_status; - -for my $manager (qw(dnf yum)) { - for my $case ( - { name => 'HTTP and local repositories', verbose => 1, remote => 1 }, - { name => 'mounted repositories', mounted => 1 }, - { name => 'upgrade failure', upgrade_status => 17, verbose => 1 }, - { name => 'install failure', install_status => 23, verbose => 1 }, - { name => 'repository-only mode', repoonly => 1, remote => 1 }, - { name => 'separate package lists', multiple => 1, verbose => 1 }, - { name => 'remote repository without installs', remote => 1, empty => 1 }, - ) - { - subtest "$manager: $case->{name}" => sub { - run_case( $manager, $case ); - }; - } -} - -done_testing(); - -sub run_case { - my ( $manager, $case ) = @_; - my $fixture = tempdir( DIR => $temporary_root, CLEANUP => 1 ); - make_path("$fixture/bin"); - write_command( "$fixture/bin/logger", "exit 0\n" ); - write_command( "$fixture/bin/dpkg", "exit 1\n" ); - write_command( "$fixture/bin/rpm", '[ "$*" = --version ]' . "\n" ); - write_command( - "$fixture/bin/mount", - $case->{mounted} - ? "printf '%s\\n' 'package-server:/install on /install type nfs (rw)'\n" - : "exit 0\n" - ); - write_command( "$fixture/bin/$manager", <<'SH' ); -printf '%s\t' "${0##*/}" "SCOPE_ENV=${SCOPE_ENV:-}" "$@" >> /tmp/fixture/commands -printf '\n' >> /tmp/fixture/commands -sequence=$(wc -l < /tmp/fixture/commands) -mkdir "/tmp/fixture/repos.$sequence" -cp /etc/yum.repos.d/*.repo "/tmp/fixture/repos.$sequence/" 2>/dev/null || : -for argument do - case "$argument" in - upgrade) - printf '%s\n' upgrade-result - exit "$UPGRADE_STATUS" - ;; - install) - printf '%s\n' install-result - exit "$INSTALL_STATUS" - ;; - esac -done -exit 0 -SH - - my %environment = ( - OSVER => 'rhel9', ARCH => 'x86_64', UPDATENODE => 1, - NFSSERVER => 'package-server', HTTPPORT => 80, INSTALLDIR => '/install', - OTHERPKGDIR => '/install/other', OTHERPKGS_INDEX => 1, - OTHERPKGS1 => $case->{empty} ? '' : 'alpha/tool-one,beta/tool-two', - ENVLIST1 => 'SCOPE_ENV=first', VERBOSE => $case->{verbose} ? 1 : '', - UPGRADE_STATUS => $case->{upgrade_status} || 0, - INSTALL_STATUS => $case->{install_status} || 0, - ); - $environment{OTHERPKGDIR} = - 'https://packages.example.invalid/extra,/install/other' if $case->{remote}; - if ( $case->{multiple} ) { - @environment{qw(OTHERPKGS_INDEX OTHERPKGS1 OTHERPKGS2 ENVLIST2)} = - ( 2, 'alpha/tool-one,beta/tool-two', 'gamma/tool-three', 'SCOPE_ENV=second' ); - } - - my @command = ( - @sandbox, '--bind', $fixture, '/tmp/fixture', - '--ro-bind', $postscripts, '/tmp/postscripts', '--chdir', '/tmp/fixture', - ); - for my $tool (qw(logger dpkg rpm mount), $manager) { - push @command, '--ro-bind', "$fixture/bin/$tool", "/usr/bin/$tool"; - } - for my $key ( sort keys %environment ) { - push @command, '--setenv', $key, $environment{$key}; - } - push @command, '/usr/bin/sh', '-c', <<'SH', 'otherpkgs-test'; -/usr/bin/bash /tmp/postscripts/otherpkgs "$@" -status=$? -mkdir /tmp/fixture/final-repos -cp /etc/yum.repos.d/*.repo /tmp/fixture/final-repos/ 2>/dev/null || : -exit "$status" -SH - push @command, '--repoonly' if $case->{repoonly}; - - my ( $output, $status ) = capture_merged { system(@command) }; - is( $status, ( $case->{upgrade_status} || $case->{install_status} || 0 ) << 8, - 'the postscript returns the package-manager status' ) or diag($output); - ok( -f "$fixture/commands", 'the real postscript reaches the package manager' ); - return unless -f "$fixture/commands"; - - my @calls = map { [ split /\t/ ] } split /\n/, read_text("$fixture/commands"); - my @transactions; - for my $index ( 0 .. $#calls ) { - my $call = $calls[$index]; - my @operands = grep { !/^-/ } @{$call}[ 2 .. $#{$call} ]; - next if @operands && ( $operands[0] eq 'clean' || $operands[0] eq 'list' ); - push @transactions, [ $index + 1, $call ]; - } - my @expected; - my @groups = $case->{multiple} - ? ( [ first => qw(tool-one tool-two) ], [ second => 'tool-three' ] ) - : ( [ first => ( $case->{empty} ? () : qw(tool-one tool-two) ) ] ); - unless ( $case->{repoonly} ) { - for my $group (@groups) { - my ( $label, @packages ) = @{$group}; - push @expected, - [ $manager, "SCOPE_ENV=$label", '-y', '--disablerepo=*', - '--enablerepo=xcat-otherpkgs*', 'upgrade' ]; - push @expected, [ $manager, "SCOPE_ENV=$label", '-y', 'install', @packages ] - if @packages; - } - } - is_deeply( [ map { $_->[1] } @transactions ], \@expected, - 'only upgrades are repository-scoped; installs retain dependency repositories' ); - - my @printed = map { [ shellwords($_) ] } - grep { /^SCOPE_ENV=/ } split /\n/, $output; - my @expected_printed = $case->{verbose} - ? map { [ $_->[1], $_->[0], @{$_}[ 2 .. $#{$_} ] ] } @expected : (); - is_deeply( \@printed, \@expected_printed, - 'verbose commands describe the executed transactions and quiet mode omits them' ); - - for my $transaction (@transactions) { - my ( $sequence, $call ) = @{$transaction}; - my @paths = $case->{multiple} - ? ( $call->[1] eq 'SCOPE_ENV=first' ? qw(alpha beta) : 'gamma' ) - : ( $case->{empty} ? () : qw(alpha beta) ); - check_repositories( "$fixture/repos.$sequence", $case, \@paths ); - } - my @final_paths = $case->{multiple} ? ('gamma') - : $case->{empty} ? () : qw(alpha beta); - check_repositories( "$fixture/final-repos", $case, \@final_paths ); - if ( $case->{verbose} && !$case->{repoonly} ) { - like( $output, qr/^upgrade-result$/m, 'upgrade output reaches the caller' ); - like( $output, qr/^install-result$/m, 'install output reaches the caller' ) - unless $case->{empty}; - } -} - -sub check_repositories { - my ( $directory, $case, $paths ) = @_; - my $base = $case->{mounted} ? 'file://' : 'http://package-server:80'; - my @expected = ( - [ 'xCAT-rhel9-path0', "$base/install/rhel9/x86_64/BaseOS", '1' ], - [ 'xCAT-rhel9-path1', "$base/install/rhel9/x86_64/AppStream", '1' ], - ); - my $index = 0; - push @expected, [ 'xcat-otherpkgs' . $index++, - 'https://packages.example.invalid/extra', '1' ] if $case->{remote}; - push @expected, [ 'xcat-otherpkgs' . $index++, "$base/install/other/$_", '1' ] - for @{$paths}; - my @actual; - for my $file ( bsd_glob("$directory/*.repo") ) { - my $contents = read_text($file); - my @sections = $contents =~ /^\[([^\]\n]+)\]$/mg; - my @urls = $contents =~ /^baseurl=(.*?)\s*$/mg; - my @enabled = $contents =~ /^enabled=(.*?)\s*$/mg; - my @gpgcheck = $contents =~ /^gpgcheck=(.*?)\s*$/mg; - push @actual, [ @sections, @urls, @enabled, @gpgcheck ]; - } - is_deeply( - [ sort { $a->[0] cmp $b->[0] } @actual ], - [ map { [ @{$_}, '0' ] } sort { $a->[0] cmp $b->[0] } @expected ], - 'generated repositories match the upgrade scope and retain the OS repositories' - ); -} - -sub write_command { - my ( $file, $body ) = @_; - write_text( $file, "#!/usr/bin/sh\n$body" ); - chmod 0755, $file or die "Cannot make $file executable: $!"; -} From afa6e05789d35002fd7514d8de5c94baf6bffbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:49:09 -0300 Subject: [PATCH 5/5] docs(test): document the BATS postscript sandbox --- xCAT-test/bats/README.md | 14 +++++++++++--- xCAT-test/unit/README.md | 13 ------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/xCAT-test/bats/README.md b/xCAT-test/bats/README.md index 11dce7d9d..2ed29552e 100644 --- a/xCAT-test/bats/README.md +++ b/xCAT-test/bats/README.md @@ -10,9 +10,6 @@ The GitHub Actions `xcat_test` workflow runs this command after the Perl `.t` unit tests. Use BATS for shell behavior that can be exercised from the source tree without an installed xCAT, a live management node, or real services. -The existing `otherpkgs_upgrade_scope.t` remains in `unit/` for its structured -command and repository assertions. See its [sandbox prerequisites](../unit/README.md#postscript-sandbox-prerequisites). - Prefer sourcing an existing shell library or sourceable script and calling the function under test. Keep reusable install-template helpers in `xCAT-server/share/xcat/install/scripts/scriptlib`, and reusable postscript @@ -22,3 +19,14 @@ commands so tests cannot write to the host. Extraction helpers in `helpers/shell_source.bash` are only for legacy code that cannot safely be sourced yet. Do not add Perl `.t` tests that grep shell source when the behavior can be tested with BATS. + +## Postscript sandbox prerequisites + +`otherpkgs_upgrade_scope.bats` runs the complete postscript in a Linux filesystem +sandbox because it writes to `/etc/yum.repos.d`. It requires Bubblewrap, Bash, +GNU core utilities, and permission to create user namespaces. The CI workflow +installs Bubblewrap and enables those namespaces. + +Missing prerequisites fail this test without stopping unrelated test files. +Non-Linux hosts report a skip. Set `TMPDIR` to a writable, executable filesystem +if the default temporary directory is mounted with `noexec`. diff --git a/xCAT-test/unit/README.md b/xCAT-test/unit/README.md index ce343b18d..daff2691c 100644 --- a/xCAT-test/unit/README.md +++ b/xCAT-test/unit/README.md @@ -10,19 +10,6 @@ which calls `run_unit_tests()` in `github_action_xcat_test.pl`: prove -r xCAT-test/unit ``` -## Postscript sandbox prerequisites - -`otherpkgs_upgrade_scope.t` runs the complete postscript in a Linux filesystem -sandbox. It requires Bubblewrap, Bash, GNU core utilities, and permission to create -user namespaces. The CI workflow installs Bubblewrap and enables those namespaces. -Missing prerequisites fail this test without stopping unrelated test files. -Non-Linux hosts report a skip. Set `TMPDIR` to a writable, executable filesystem -if the default temporary directory is mounted with `noexec`. - -This test is an exception to the shell-test placement rule below. It stays in Perl -to compare structured command arguments and generated repository records with the -existing test helpers. It does not read or extract postscript source. - You can run exactly the same thing from a clean checkout: ```