From f06d02a4e6b770fd28720a8c2999f862eb0d14eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:00:11 -0300 Subject: [PATCH 01/14] test(syncfiles): verify emitted xdcp requests The identity test checked source text without observing the request sent to xdcp. Run the complete syncfiles plugin and inspect each outgoing request. Keep production code unchanged. --- xCAT-test/unit/syncfiles_xdcp_identity.t | 206 ++++++++++++++++++----- 1 file changed, 161 insertions(+), 45 deletions(-) diff --git a/xCAT-test/unit/syncfiles_xdcp_identity.t b/xCAT-test/unit/syncfiles_xdcp_identity.t index a31de3ca8..b08591e30 100644 --- a/xCAT-test/unit/syncfiles_xdcp_identity.t +++ b/xCAT-test/unit/syncfiles_xdcp_identity.t @@ -3,62 +3,178 @@ use strict; use warnings; use FindBin; -use File::Spec; +use lib "$FindBin::Bin/../lib"; +use Storable qw(dclone); use Test::More; +use XCAT::Test::File qw(repo_path); -my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' ); +our $RCP; -sub slurp { - my ($rel) = @_; - my $path = File::Spec->catfile( $repo_root, $rel ); - return unless -r $path; - open( my $fh, '<', $path ) or die "Unable to read $path: $!"; - my $c = do { local $/; <$fh> }; - close($fh); - return $c; +BEGIN { + package xCAT::Utils; + $INC{'xCAT/Utils.pm'} = __FILE__; + + package xCAT::Postage; + $INC{'xCAT/Postage.pm'} = __FILE__; + + package xCAT::SvrUtils; + our $synclist; + sub getsynclistfile { + my ( $class, $nodes ) = @_; + return unless defined $synclist; + return { map { $_ => $synclist->{$_} } @$nodes }; + } + $INC{'xCAT/SvrUtils.pm'} = __FILE__; + + package xCAT::MsgUtils; + our @messages; + sub message { + my ( $class, @message ) = @_; + push @messages, \@message; + return; + } + $INC{'xCAT/MsgUtils.pm'} = __FILE__; + + package xCAT::NodeRange; + use Exporter qw(import); + our @EXPORT = qw(noderange); + our %nodes; + sub noderange { return $nodes{ $_[0] }; } + $INC{'xCAT/NodeRange.pm'} = __FILE__; } -my $syncfiles = slurp('xCAT-server/lib/xcat/plugins/syncfiles.pm'); -my $updatenode = slurp('xCAT-server/lib/xcat/plugins/updatenode.pm'); -my $xdsh = slurp('xCAT-server/lib/xcat/plugins/xdsh.pm'); +my $plugin = repo_path('xCAT-server/lib/xcat/plugins/syncfiles.pm'); +require $plugin; -plan skip_all => 'plugins not found' - unless defined($syncfiles) && defined($updatenode) && defined($xdsh); +sub run_syncfiles { + my ($case) = @_; + local %xCAT::NodeRange::nodes = ( + 'node1.example.test' => 'node1', + 'node2.example.test' => 'node2', + ); + local $xCAT::SvrUtils::synclist = $case->{synclist}; + local @xCAT::MsgUtils::messages; + local @ARGV; + local $RCP; + my @sent; + my $callback = sub { return; }; + my $request = dclone($case->{request} || { + command => ['syncfiles'], + username => ['operator'], + arg => $case->{args}, + _xcat_clienthost => [ $case->{client} ], + }); + my $original = dclone($request); -# The xdcp subrequest has to state the identity the sync runs as. -my ($call) = $syncfiles =~ /(\$subreq->\(\{[^}]*command\s*=>\s*\['xdcp'\][^}]*\})/s; -ok( $call, 'the xdcp subrequest was located in syncfiles' ) - or BAIL_OUT('syncfiles.pm no longer matches the expected subrequest shape'); + xCAT_plugin::syncfiles::process_request( + $request, + $callback, + sub { + my ( $outgoing, $response_callback ) = @_; + push @sent, [ dclone($outgoing), $response_callback ]; + return; + }, + ); -like( $call, qr/username\s*=>/, 'the xdcp subrequest names a username' ); + is_deeply( $request, $original, 'the caller request is unchanged' ); + is( scalar @sent, scalar @{ $case->{expected} }, 'the request count matches' ); + for my $index ( 0 .. $#{ $case->{expected} } ) { + my ( $node, $file, $copy_args ) = @{ $case->{expected}->[$index] }; + my $sent = $sent[$index] || []; + is_deeply( + $sent->[0], + { + command => ['xdcp'], + username => ['root'], + node => [$node], + arg => [ '-F', $file, @$copy_args ], + env => ["DSH_RSYNC_FILE=$file"], + }, + "request $index carries the root identity and copy parameters", + ); + is( $sent->[1], $callback, "request $index retains the response callback" ); + } + is( scalar @xCAT::MsgUtils::messages, scalar @{ $case->{messages} }, 'the diagnostic count matches' ); + for my $index ( 0 .. $#{ $case->{messages} } ) { + my $message = $xCAT::MsgUtils::messages[$index] || []; + is( $message->[0], 'S', 'the diagnostic goes to the system log' ); + like( $message->[1], $case->{messages}->[$index], 'the diagnostic identifies the failure' ); + } + return; +} -# It must be an array reference. A bare string was the original form of this -# change and broke the non-hierarchical path, because the consumers index it as -# $request->{username}->[0]. -like( - $call, - qr/username\s*=>\s*\['root'\]/, - 'the username is the arrayref form the consumers index into' -); -unlike( - $call, - qr/username\s*=>\s*'root'\s*,/, - 'the username is not a bare string, which would not survive ->[0]' +my @cases = ( + { + name => 'daemon request without arguments or username', + request => { command => ['syncfiles'], _xcat_clienthost => ['node1.example.test'] }, + synclist => { node1 => '/install/custom/sync-a' }, + expected => [ [ 'node1', '/install/custom/sync-a', [] ] ], + messages => [], + }, + { + name => 'one sync file', + client => 'node1.example.test', + args => [], + synclist => { node1 => '/install/custom/sync-a' }, + expected => [ [ 'node1', '/install/custom/sync-a', [] ] ], + messages => [], + }, + { + name => 'multiple sync files retain order and identity', + client => 'node2.example.test', + args => [], + synclist => { node2 => '/install/custom/sync-b,/install/custom/sync-a,/install/custom/sync-c' }, + expected => [ + [ 'node2', '/install/custom/sync-b', [] ], + [ 'node2', '/install/custom/sync-a', [] ], + [ 'node2', '/install/custom/sync-c', [] ], + ], + messages => [], + }, ); -# The consumers this has to satisfy, pinned so the shape cannot drift apart. -like( - $xdsh, - qr/\$ENV\{DSH_FROM_USERID\}\s*=\s*\$request->\{username\}->\[0\]/, - 'xdsh still derives DSH_FROM_USERID from the request username' -); +for my $option ( '-r', '-c', '--node-rcp' ) { + push @cases, { + name => "copy override $option retains identity on every request", + client => 'node1.example.test', + args => [ $option, '/usr/bin/scp' ], + synclist => { node1 => '/install/custom/sync-a,/install/custom/sync-b' }, + expected => [ + [ 'node1', '/install/custom/sync-a', [ '-r', '/usr/bin/scp' ] ], + [ 'node1', '/install/custom/sync-b', [ '-r', '/usr/bin/scp' ] ], + ], + messages => [], + }; +} -# updatenode makes the same xdcp call and already passes a username. The two -# should not diverge again. -like( - $updatenode, - qr/command\s*=>\s*\["xdcp"\][^;]*username\s*=>/s, - 'updatenode still passes a username on its own xdcp call' -); +push @cases, + { + name => 'unavailable synclist lookup sends no request', + client => 'node1.example.test', + args => [], + synclist => undef, + expected => [], + messages => [ qr/\ACannot find synclist file for the node1\z/ ], + }, + { + name => 'node without a synclist sends no request', + client => 'node1.example.test', + args => [], + synclist => { node1 => undef }, + expected => [], + messages => [], + }, + { + name => 'unresolved client sends no request', + client => 'unknown.example.test', + args => [], + synclist => { node1 => '/install/custom/sync-a' }, + expected => [], + messages => [ qr/couldn't be correlated to a node/ ], + }; + +for my $case (@cases) { + subtest $case->{name} => sub { run_syncfiles($case); }; +} done_testing(); 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 02/14] 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 03/14] 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 04/14] 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 b4eb93be866282eb0f6efe8a5740319f25872db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:33:01 -0300 Subject: [PATCH 05/14] fix(mknb): pass the boot MAC to BIOS Genesis on stock iPXE mknb wrote BOOTIF=01-${netX/machyp} into the BIOS Genesis script. machyp is an xNBA setting, and stock iPXE expands it to an empty string. Without the boot MAC, the legacy Genesis stops with "Unable to find boot device" after at least 10 minutes. The OpenEmbedded Genesis fails at once with BOOT_INTERFACE_NOT_FOUND. Use ${netX/mac:hexhyp}, as the UEFI Genesis script and xnba.pm do. xNBA expands both settings to the same value, so boot with xNBA is unchanged. --- xCAT-server/lib/xcat/plugins/mknb.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xCAT-server/lib/xcat/plugins/mknb.pm b/xCAT-server/lib/xcat/plugins/mknb.pm index 01ddc5b95..baaac6e97 100644 --- a/xCAT-server/lib/xcat/plugins/mknb.pm +++ b/xCAT-server/lib/xcat/plugins/mknb.pm @@ -828,7 +828,7 @@ sub process_request { open($cfg, ">", "$tftpdir/xcat/xnba/nets/$net"); print $cfg "#!gpxe\n"; if ($invisibletouch) { - print $cfg 'imgfetch -n kernel http://${next-server}'.$portsuffix.'/tftpboot/xcat/genesis.kernel.' . "$arch xcatd=" . $xcatd_address . ":$xcatdport $consolecmdline BOOTIF=01-" . '${netX/machyp}' . "\n"; + print $cfg 'imgfetch -n kernel http://${next-server}'.$portsuffix.'/tftpboot/xcat/genesis.kernel.' . "$arch xcatd=" . $xcatd_address . ":$xcatdport $consolecmdline BOOTIF=01-" . '${netX/mac:hexhyp}' . "\n"; print $cfg 'imgfetch -n nbfs http://${next-server}'.$portsuffix . "$initrd_file\n"; } else { print $cfg 'imgfetch -n kernel http://${next-server}'.$portsuffix.'/tftpboot/xcat/nbk.' . "$arch xcatd=" . $xcatd_address . ":$xcatdport $consolecmdline\n"; From 43e008cf996e7b3903b4a01ef8f0c0355a31a97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:33:01 -0300 Subject: [PATCH 06/14] test(mknb): cover the BIOS Genesis BOOTIF format Check both paths that write the script: mknb --configfileonly on an installed Genesis, and a full mknb run from an x86_64 OpenEmbedded export. --- xCAT-test/unit/mknb_exported_genesis.t | 28 ++++++++++++++++++++++++++ xCAT-test/unit/mknb_xcatd_address.t | 14 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/xCAT-test/unit/mknb_exported_genesis.t b/xCAT-test/unit/mknb_exported_genesis.t index 5f523a9bf..3fd26df00 100644 --- a/xCAT-test/unit/mknb_exported_genesis.t +++ b/xCAT-test/unit/mknb_exported_genesis.t @@ -553,4 +553,32 @@ my $unsafe = xCAT_plugin::mknb::_select_genesis_source( ); is($unsafe, undef, 'unsupported architecture names cannot escape the image root'); +$::XCATROOT = "$tmpdir/openembedded-x86_64-xcatroot"; +prepare_export( + "$::XCATROOT/share/xcat/netboot/genesis-openembedded/x86_64", + 'openembedded x86_64 kernel', + 'openembedded x86_64 initramfs', + 'x86_64', +); +$xCAT::TableUtils::tftpdir = "$tmpdir/openembedded-x86_64-tftpboot"; +@responses = (); +xCAT_plugin::mknb::process_request( + { arg => ['x86_64'] }, + sub { push(@responses, @_); }, +); +ok( + !grep({ ref($_) eq 'HASH' && $_->{error} } @responses), + 'mknb installs an x86_64 OpenEmbedded export', +); +is( + read_file("$xCAT::TableUtils::tftpdir/xcat/genesis.kernel.x86_64"), + 'openembedded x86_64 kernel', + 'mknb publishes the OpenEmbedded x86_64 kernel', +); +like( + read_file("$xCAT::TableUtils::tftpdir/xcat/xnba/nets/192.0.2.0_24"), + qr{^imgfetch -n kernel \S+/xcat/genesis\.kernel\.x86_64 .* BOOTIF=01-\$\{netX/mac:hexhyp\}$}m, + 'the OpenEmbedded BIOS Genesis script takes BOOTIF from mac:hexhyp', +); + done_testing(); diff --git a/xCAT-test/unit/mknb_xcatd_address.t b/xCAT-test/unit/mknb_xcatd_address.t index 0a69dd2e0..281a1af8a 100644 --- a/xCAT-test/unit/mknb_xcatd_address.t +++ b/xCAT-test/unit/mknb_xcatd_address.t @@ -232,6 +232,20 @@ foreach my $relative_path ( ); } +my $bios_genesis = read_config( + "$xCAT::TableUtils::tftpdir/xcat/xnba/nets/192.168.144.0_20" +); +like( + $bios_genesis, + qr{^imgfetch -n kernel \S+/xcat/genesis\.kernel\.x86_64 .* BOOTIF=01-\$\{netX/mac:hexhyp\}$}m, + 'the BIOS Genesis script takes BOOTIF from mac:hexhyp', +); +unlike( + $bios_genesis, + qr/machyp/, + 'the BIOS Genesis script does not use the xNBA-only machyp setting', +); + use_reporter_address_maps(); prepare_tftpdir($tmpdir, 'tftpboot-x86-legacy', 'x86_64', 'legacy'); $responses = run_mknb('x86_64'); From fe0fc68af8b95b4b033696d3012bd9c4051c2b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:56:30 -0300 Subject: [PATCH 07/14] test(syncfiles): cover invalid-option rejection --- xCAT-test/unit/syncfiles_xdcp_identity.t | 32 +++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/xCAT-test/unit/syncfiles_xdcp_identity.t b/xCAT-test/unit/syncfiles_xdcp_identity.t index b08591e30..5c0910c95 100644 --- a/xCAT-test/unit/syncfiles_xdcp_identity.t +++ b/xCAT-test/unit/syncfiles_xdcp_identity.t @@ -65,17 +65,22 @@ sub run_syncfiles { _xcat_clienthost => [ $case->{client} ], }); my $original = dclone($request); + my @warnings; - xCAT_plugin::syncfiles::process_request( - $request, - $callback, - sub { - my ( $outgoing, $response_callback ) = @_; - push @sent, [ dclone($outgoing), $response_callback ]; - return; - }, - ); + { + local $SIG{__WARN__} = sub { push @warnings, @_ } if $case->{warnings}; + xCAT_plugin::syncfiles::process_request( + $request, + $callback, + sub { + my ( $outgoing, $response_callback ) = @_; + push @sent, [ dclone($outgoing), $response_callback ]; + return; + }, + ); + } + is_deeply( \@warnings, $case->{warnings}, 'the option diagnostics match' ) if $case->{warnings}; is_deeply( $request, $original, 'the caller request is unchanged' ); is( scalar @sent, scalar @{ $case->{expected} }, 'the request count matches' ); for my $index ( 0 .. $#{ $case->{expected} } ) { @@ -148,6 +153,15 @@ for my $option ( '-r', '-c', '--node-rcp' ) { } push @cases, + { + name => 'invalid option sends no request', + client => 'node1.example.test', + args => ['--bogus'], + synclist => { node1 => '/install/custom/sync-a' }, + expected => [], + messages => [ qr/Received syncfiles from node1\.example\.test, with invalid options\b/ ], + warnings => ["Unknown option: bogus\n"], + }, { name => 'unavailable synclist lookup sends no request', client => 'node1.example.test', From 5cd0424e2844d6594731881a19bedf3a63c87790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:57:53 -0300 Subject: [PATCH 08/14] test(syncfiles): characterize same-process copy overrides --- xCAT-test/unit/syncfiles_xdcp_identity.t | 55 +++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/xCAT-test/unit/syncfiles_xdcp_identity.t b/xCAT-test/unit/syncfiles_xdcp_identity.t index 5c0910c95..e281e88dd 100644 --- a/xCAT-test/unit/syncfiles_xdcp_identity.t +++ b/xCAT-test/unit/syncfiles_xdcp_identity.t @@ -55,7 +55,6 @@ sub run_syncfiles { local $xCAT::SvrUtils::synclist = $case->{synclist}; local @xCAT::MsgUtils::messages; local @ARGV; - local $RCP; my @sent; my $callback = sub { return; }; my $request = dclone($case->{request} || { @@ -116,14 +115,6 @@ my @cases = ( expected => [ [ 'node1', '/install/custom/sync-a', [] ] ], messages => [], }, - { - name => 'one sync file', - client => 'node1.example.test', - args => [], - synclist => { node1 => '/install/custom/sync-a' }, - expected => [ [ 'node1', '/install/custom/sync-a', [] ] ], - messages => [], - }, { name => 'multiple sync files retain order and identity', client => 'node2.example.test', @@ -153,6 +144,14 @@ for my $option ( '-r', '-c', '--node-rcp' ) { } push @cases, + { + name => 'one sync file', + client => 'node1.example.test', + args => [], + synclist => { node1 => '/install/custom/sync-a' }, + expected => [ [ 'node1', '/install/custom/sync-a', [] ] ], + messages => [], + }, { name => 'invalid option sends no request', client => 'node1.example.test', @@ -188,7 +187,43 @@ push @cases, }; for my $case (@cases) { - subtest $case->{name} => sub { run_syncfiles($case); }; + subtest $case->{name} => sub { + local $RCP; + run_syncfiles($case); + }; +} + +# Direct same-process calls share the existing override; daemon process isolation is outside this test. +for my $option ( '-r', '-c', '--node-rcp' ) { + subtest "same-process copy override $option" => sub { + local $RCP; + for my $step ( + [ 'clean sequence state', [], 'node1', 'sync-default', undef ], + [ 'initial override', [ $option, '/usr/bin/scp' ], 'node1', 'sync-a', '/usr/bin/scp' ], + [ 'rejected override', [ $option, '/usr/bin/false', '--bogus' ], 'node1', 'sync-rejected', undef, 1 ], + [ 'request without override', [], 'node2', 'sync-b', '/usr/bin/scp' ], + [ 'replacement override', [ $option, '/usr/bin/rsync' ], 'node1', 'sync-c', '/usr/bin/rsync' ], + [ 'request after replacement', [], 'node2', 'sync-d', '/usr/bin/rsync' ], + ) + { + my ( $name, $args, $node, $list, $copy_command, $invalid ) = @$step; + subtest $name => sub { + my $file = "/install/custom/$list"; + my $copy_args = defined $copy_command ? [ '-r', $copy_command ] : []; + my %diagnostics = $invalid ? ( + messages => [ qr/Received syncfiles from \Q$node.example.test\E, with invalid options\b/ ], + warnings => ["Unknown option: bogus\n"], + ) : ( messages => [] ); + run_syncfiles({ + client => "$node.example.test", + args => $args, + synclist => { $node => $file }, + expected => $invalid ? [] : [ [ $node, $file, $copy_args ] ], + %diagnostics, + }); + }; + } + }; } done_testing(); 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 09/14] 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 10/14] 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: ``` From 733bc3219c8764b86e15920490897913a2ca7f6d Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:24:59 -0300 Subject: [PATCH 11/14] docs(xcat-core): the documentation title names 2.17.0 Read the Docs takes the version in the title of each page from release in docs/source/conf.py, which was last set for 2.17.0. Every build since, 2.18.x and 2.19.0 included, is titled "xCAT 2.17.0 documentation". Set it to 2.20.0, the Version of master. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 57880777a..8449c33f2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -60,7 +60,7 @@ author = u'IBM Corporation, xCAT Consortium' # The short X.Y version. version = '2' # The full version, including alpha/beta/rc tags. -release = '2.17.0' +release = '2.20.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. From 4fa1f2545a873ed5a7de7319fb096e0d8eea0bc5 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:24:59 -0300 Subject: [PATCH 12/14] docs(developers): published .repo files name their series The release checklist said that the published .repo files point at latest. latest is a link into the newest series, so a file that names it gives the next series to users of this one as soon as that series ships. Each file under repos/yum/X.Y/ now names repos/yum/X.Y/, and the installation check uses the files as published. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- docs/source/developers/releases/checklist.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/source/developers/releases/checklist.rst b/docs/source/developers/releases/checklist.rst index db884ad2d..6809f492b 100644 --- a/docs/source/developers/releases/checklist.rst +++ b/docs/source/developers/releases/checklist.rst @@ -106,8 +106,10 @@ The release is the last candidate that passed the tests. xcat-dep/2.x_Linux/xcat-dep-X.Y.Z-linux.tar.bz2 xcat-dep/2.x_Ubuntu/xcat-dep-X.Y.Z-ubuntu.tar.bz2 -#. Make sure that the ``.repo`` files in the published repositories point at the published - location, not at ``devel``. +#. Make sure that each ``.repo`` file under ``repos/yum/X.Y/`` points at ``repos/yum/X.Y/``, not at + ``devel`` or ``latest``. ``latest`` is a link into the newest series, so a series path is right + through both. A file that names ``latest`` gives the next series to users of this one as soon as + that series ships. #. If X.Y is the newest series, point ``latest`` at ``X.Y`` for yum and for apt. @@ -146,9 +148,9 @@ Verify the Published Packages ``go-xcat`` installation does not prove the signatures. On EL, use the three published ``.repo`` files, which set ``gpgcheck=1``. Enable the other - repositories that the installation guide requires first. The published files point at - ``latest``. For an older series, replace ``latest`` with ``X.Y`` in the three files. Install the - Genesis image for the host architecture by name, because ``xCAT`` only recommends it. :: + repositories that the installation guide requires first. Use the files as published: each one + must point at ``repos/yum/X.Y/``. Install the Genesis image for the host architecture by name, + because ``xCAT`` only recommends it. :: $ curl -fsSo /etc/yum.repos.d/xcat-core.repo \ https://xcat.org/files/xcat/repos/yum/X.Y/xcat-core/xcat-core.repo From 2c7a6f07869e458c9b65a6a9515b298ba20c343d Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:15:17 -0300 Subject: [PATCH 13/14] docs(xcat-core): the release table stops at 2.18.0 The release information page lists every release up to 2.18.0. 2.18.2, 2.19.0 and 2.19.1 are published and absent from it, so a reader cannot tell from the documentation which releases exist, when each one shipped, or where its notes are. docs/source/overview/_files/2.19.x.csv is new and holds the 2.19.0 and 2.19.1 rows, xcat2_release.rst gains its section above 2.18.x, and 2.18.x.csv gains the 2.18.2 row. Each date is the date of that release on GitHub. 2.18.1 has no GitHub release of its own, so it has no row. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- docs/source/overview/_files/2.18.x.csv | 1 + docs/source/overview/_files/2.19.x.csv | 3 +++ docs/source/overview/xcat2_release.rst | 9 +++++++++ 3 files changed, 13 insertions(+) create mode 100644 docs/source/overview/_files/2.19.x.csv diff --git a/docs/source/overview/_files/2.18.x.csv b/docs/source/overview/_files/2.18.x.csv index fd704eb94..03e944485 100644 --- a/docs/source/overview/_files/2.18.x.csv +++ b/docs/source/overview/_files/2.18.x.csv @@ -1,2 +1,3 @@ Version,Release Date,New OS Supported,Release Notes +2.18.2,2026-08-01,,`2.18.2 Release Notes `_ 2.18.0,2026-06-22,"RHEL 10,AlmaLinux 10",`2.18.0 Release Notes `_ diff --git a/docs/source/overview/_files/2.19.x.csv b/docs/source/overview/_files/2.19.x.csv new file mode 100644 index 000000000..71ce99390 --- /dev/null +++ b/docs/source/overview/_files/2.19.x.csv @@ -0,0 +1,3 @@ +Version,Release Date,New OS Supported,Release Notes +2.19.1,2026-09-25,,`2.19.1 Release Notes `_ +2.19.0,2026-09-18,"Ubuntu 26.04, riscv64 on EL10, Ubuntu 24.04 and Ubuntu 26.04",`2.19.0 Release Notes `_ diff --git a/docs/source/overview/xcat2_release.rst b/docs/source/overview/xcat2_release.rst index 07188d3a3..da1e5d468 100644 --- a/docs/source/overview/xcat2_release.rst +++ b/docs/source/overview/xcat2_release.rst @@ -6,6 +6,15 @@ The following tables documents the xCAT release versions and release dates. For .. tabularcolumns:: |p{1cm}|p{4cm}|p{7cm}|p{7cm} +xCAT 2.19.x +----------- + +.. csv-table:: 2.19.x Release Information + :file: _files/2.19.x.csv + :header-rows: 1 + :class: longtable + :widths: 1 1 1 1 + xCAT 2.18.x ----------- From fad44f62818b6789ce59dbe936032bf875f75ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:46:26 -0300 Subject: [PATCH 14/14] fix(ci): install xcat-dep from the devel channel The GitHub check of master installs the xcat-dep packages of the latest channel, which serves the stable release. A master change that needs a new xcat-dep package then fails the check until that package is in a stable release. The check now reads the devel channel, which carries the xcat-dep packages of the next release. The 2.19 branch keeps the latest channel. --- github_action_xcat_test.pl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/github_action_xcat_test.pl b/github_action_xcat_test.pl index 34fc6b675..93a3da0f9 100644 --- a/github_action_xcat_test.pl +++ b/github_action_xcat_test.pl @@ -360,8 +360,8 @@ sub install_xcat{ my @cmds = ("sudo $repo/mklocalrepo.sh", "sudo chmod 777 /etc/apt/sources.list", - "sudo echo \"deb [arch=amd64 allow-insecure=yes] http://xcat.org/files/xcat/repos/apt/latest/xcat-dep noble main\" >> /etc/apt/sources.list", - "sudo echo \"deb [arch=ppc64el allow-insecure=yes] http://xcat.org/files/xcat/repos/apt/latest/xcat-dep noble main\" >> /etc/apt/sources.list", + "sudo echo \"deb [arch=amd64 allow-insecure=yes] http://xcat.org/files/xcat/repos/apt/devel/xcat-dep noble main\" >> /etc/apt/sources.list", + "sudo echo \"deb [arch=ppc64el allow-insecure=yes] http://xcat.org/files/xcat/repos/apt/devel/xcat-dep noble main\" >> /etc/apt/sources.list", "sudo timeout 600 apt-get -qq -o Acquire::Retries=3 -o Acquire::http::Timeout=30 --allow-insecure-repositories update"); chdir $ENV{RUNNER_WORKSPACE};;