From 7af7b97a78f6f8ba7a8a818a2bbb3089cb1ee23c 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, 9 Oct 2026 15:02:07 -0300
Subject: [PATCH 1/5] test(provisioning): execute rendered RISC-V install
checks
Render the selected profiles with an isolated database. Check the installer data and run the emitted post-install blocks against a private filesystem, including the EFI fallback and missing-loader paths.
Run the native case from the source snapshot preserved before CI package builds.
---
.github/workflows/xcat_test.yml | 9 +
xCAT-test/fixtures/install-profile/command | 37 +++
xCAT-test/lib/XCAT/Test/Sandbox.pm | 51 ++++
xCAT-test/lib/XCAT/Test/Template.pm | 79 ++++++
xCAT-test/native/install_profile_riscv64.t | 265 +++++++++++++++++++++
xCAT-test/unit/install_profile_riscv64.t | 178 --------------
6 files changed, 441 insertions(+), 178 deletions(-)
create mode 100644 xCAT-test/fixtures/install-profile/command
create mode 100644 xCAT-test/lib/XCAT/Test/Sandbox.pm
create mode 100644 xCAT-test/lib/XCAT/Test/Template.pm
create mode 100644 xCAT-test/native/install_profile_riscv64.t
delete mode 100644 xCAT-test/unit/install_profile_riscv64.t
diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml
index 7159916c7..9d874410d 100644
--- a/.github/workflows/xcat_test.yml
+++ b/.github/workflows/xcat_test.yml
@@ -70,6 +70,15 @@ jobs:
echo 'a mock result directory test was skipped' >&2
exit 1
fi
+ - name: Run profile artifact tests
+ if: ${{ !cancelled() }}
+ working-directory: ${{ runner.temp }}/xcat-core-unitsrc
+ shell: bash
+ run: |
+ log=$(mktemp)
+ trap 'rm -f -- "$log"' EXIT
+ prove -v xCAT-test/native/install_profile_riscv64.t | tee "$log"
+ ! grep -qi skipped "$log"
rpm_lifecycle_test:
runs-on: ubuntu-24.04
timeout-minutes: 30
diff --git a/xCAT-test/fixtures/install-profile/command b/xCAT-test/fixtures/install-profile/command
new file mode 100644
index 000000000..3f8fe9f2e
--- /dev/null
+++ b/xCAT-test/fixtures/install-profile/command
@@ -0,0 +1,37 @@
+#!/bin/sh
+
+case ${0##*/} in
+ curl)
+ output=
+ url=
+ while [ "$#" -gt 0 ]; do
+ case $1 in
+ --fail) shift ;;
+ --retry|--max-time) shift 2 ;;
+ -o) output=$2; shift 2 ;;
+ http://*) url=$1; shift ;;
+ *) break ;;
+ esac
+ done
+ if [ "$#" -eq 0 ]; then
+ case $url in
+ */postscripts/)
+ printf 'xcatlib.sh\n'
+ exit 0 ;;
+ */postscripts/xcatlib.sh)
+ exec cp /fixture/xcatlib.sh "$output" ;;
+ */mypostscripts/mypostscript.node)
+ exec cp /fixture/mypostscript.node "$output" ;;
+ esac
+ fi
+ ;;
+ logger|updateflag.awk|nmcli) exit 0 ;;
+ sleep)
+ case $* in
+ ''|*[!0-9]*) ;;
+ *) exit 0 ;;
+ esac
+ ;;
+esac
+printf '%s %s\n' "${0##*/}" "$*" >> /fixture/unexpected-command
+exit 64
diff --git a/xCAT-test/lib/XCAT/Test/Sandbox.pm b/xCAT-test/lib/XCAT/Test/Sandbox.pm
new file mode 100644
index 000000000..9bcc0734e
--- /dev/null
+++ b/xCAT-test/lib/XCAT/Test/Sandbox.pm
@@ -0,0 +1,51 @@
+package XCAT::Test::Sandbox;
+
+use strict;
+use warnings;
+use Exporter qw(import);
+use File::Path qw(make_path);
+use File::Temp qw(tempdir);
+use XCAT::Test::Process qw(run_command);
+
+our @EXPORT_OK = qw(sandbox_root sandbox_run);
+
+sub sandbox_root {
+ my $root = tempdir(CLEANUP => 1);
+ make_path(map { "$root/$_" } qw(bin boot etc log target tmp));
+ return $root;
+}
+
+sub sandbox_run {
+ my ($root, @command) = @_;
+ my $options = ref($command[0]) eq 'HASH' ? shift @command : {};
+ my $bwrap = -x '/usr/bin/bwrap' ? '/usr/bin/bwrap' : '/bin/bwrap';
+ die 'Install bubblewrap to run this test' unless -x $bwrap;
+ my @sandbox = ($bwrap, '--unshare-all', '--die-with-parent', '--new-session',
+ '--tmpfs', '/', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/run',
+ '--setenv', 'PATH', '/fixture/bin:/usr/bin:/bin',
+ '--setenv', 'LC_ALL', 'C', '--bind', $root, '/fixture', '--chdir', '/fixture');
+ for my $directory (qw(/usr /bin /sbin /lib /lib64)) {
+ push @sandbox, '--ro-bind', $directory, $directory if -d $directory;
+ }
+ for my $mount (qw(boot etc target tmp)) {
+ push @sandbox, '--bind', "$root/$mount", "/$mount";
+ }
+ push @sandbox, '--bind', "$root/log", '/var/log';
+ push @sandbox, '--ro-bind', '/etc/alternatives', '/etc/alternatives' if -d '/etc/alternatives';
+ my @mounts;
+ for my $kind (qw(read_only writable)) {
+ my $flag = $kind eq 'read_only' ? '--ro-bind' : '--bind';
+ push @mounts, map { [$flag, $_, $options->{$kind}{$_}] }
+ keys %{$options->{$kind} // {}};
+ }
+ for my $mount (sort { length($a->[2]) <=> length($b->[2]) || $a->[2] cmp $b->[2] } @mounts) {
+ push @sandbox, @$mount;
+ }
+ for my $name (keys %{$options->{env} // {}}) {
+ push @sandbox, '--setenv', $name, $options->{env}{$name};
+ }
+ local %ENV;
+ return run_command(@sandbox, '/usr/bin/timeout', '--kill-after=5', '45', @command);
+}
+
+1;
diff --git a/xCAT-test/lib/XCAT/Test/Template.pm b/xCAT-test/lib/XCAT/Test/Template.pm
new file mode 100644
index 000000000..eed942869
--- /dev/null
+++ b/xCAT-test/lib/XCAT/Test/Template.pm
@@ -0,0 +1,79 @@
+package XCAT::Test::Template;
+
+use strict;
+use warnings;
+use Exporter qw(import);
+use File::Path qw(make_path);
+use File::Temp qw(tempdir);
+use File::Slurper qw(write_text);
+use JSON::PP qw(encode_json);
+use XCAT::Test::File qw(repo_path);
+use XCAT::Test::Sandbox qw(sandbox_root sandbox_run);
+
+our @EXPORT_OK = qw(template_database set_row render_template);
+
+sub template_database {
+ my $dir = tempdir(CLEANUP => 1);
+ make_path("$dir/db", "$dir/install");
+ symlink(repo_path('xCAT/postscripts'), "$dir/install/postscripts") or die $!;
+ $ENV{XCATROOT} = repo_path('xCAT-server');
+ $ENV{XCATCFG} = "SQLite:$dir/db";
+ require xCAT::Table;
+ require xCAT::Template;
+ require xCAT::Postage;
+ my %site = (
+ installdir => "$dir/install", tftpdir => "$dir/tftpboot",
+ master => '192.0.2.1', timezone => 'UTC', domain => 'example.invalid',
+ xcatiport => 3002, xcatdport => 3001, httpport => 8080,
+ xcatdebugmode => 0, nodestatus => 1, secureroot => 1,
+ managedaddressmode => 'dhcp',
+ );
+ set_row('site', {key => $_}, {value => $site{$_}}) for keys %site;
+ %::XCATSITEVALS = %site;
+ set_row('nodelist', {node => 'node'}, {groups => 'all'});
+ set_row('noderes', {node => 'node'}, {
+ xcatmaster => '192.0.2.1', nfsserver => '192.0.2.1', installnic => 'mac',
+ });
+ set_row('mac', {node => 'node'}, {mac => '52:54:00:12:34:56'});
+ set_row('bootparams', {node => 'node'}, {kcmdline => 'console=ttyS0 quiet'});
+ return $dir;
+}
+
+sub set_row {
+ my ($name, $key, $values) = @_;
+ my $table = xCAT::Table->new($name, -create => 1) or die "Cannot create $name";
+ $table->setAttribs($key, $values);
+ $table->close();
+ return;
+}
+
+sub render_template {
+ my ($database, @arguments) = @_;
+ my $root = sandbox_root();
+ write_text("$root/arguments.json", encode_json(\@arguments));
+ write_text("$root/render.pl", <<'PERL');
+use strict;
+use warnings;
+use File::Slurper qw(read_text);
+use JSON::PP qw(decode_json);
+use xCAT::Template;
+use xCAT::Postage;
+my $site = xCAT::Table->new('site');
+my @attributes = $site->getAllAttribs('key', 'value');
+%::XCATSITEVALS = map { $_->{key} => $_->{value} } @attributes;
+$site->close();
+my $arguments = decode_json(read_text('/fixture/arguments.json'));
+my $error = xCAT::Template->subvars(@$arguments);
+die "$error\n" if $error;
+PERL
+ my $source = repo_path('.');
+ my ($status, $output) = sandbox_run($root, {
+ read_only => {$source => $source, repo_path('xCAT/postscripts') => '/install/postscripts'},
+ writable => {$database => $database},
+ env => {XCATROOT => $ENV{XCATROOT}, XCATCFG => $ENV{XCATCFG}},
+ }, $^X, '-I' . repo_path('perl-xCAT'), '-I' . repo_path('xCAT-server/lib/perl'), '/fixture/render.pl');
+ die "Template rendering failed ($status): $output" if $status;
+ return;
+}
+
+1;
diff --git a/xCAT-test/native/install_profile_riscv64.t b/xCAT-test/native/install_profile_riscv64.t
new file mode 100644
index 000000000..15b035011
--- /dev/null
+++ b/xCAT-test/native/install_profile_riscv64.t
@@ -0,0 +1,265 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+
+use File::Copy qw(copy);
+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 lib "$FindBin::Bin/../../perl-xCAT", "$FindBin::Bin/../../xCAT-server/lib/perl";
+use Test::More;
+use Text::ParseWords qw(shellwords);
+
+use XCAT::Test::File qw(repo_path);
+use XCAT::Test::Sandbox qw(sandbox_root sandbox_run);
+use XCAT::Test::Template qw(template_database set_row render_template);
+
+plan skip_all => 'profile execution requires Linux' unless $^O eq 'linux';
+my $database = template_database();
+require xCAT::SvrUtils;
+my $password = '$6$fixture$encrypted';
+set_row('passwd', {key => 'system', username => 'root'}, {password => $password});
+set_row('site', {key => 'secureroot'}, {value => 0});
+
+my $install = File::Spec->catdir( 'xCAT-server', 'share', 'xcat', 'install' );
+my @rendered_profiles;
+
+sub installer_settings {
+ my ($text) = @_;
+ $text =~ s/^%(?:packages|pre|post|addon)\b.*?^%end[^\n]*\n?//msg;
+ return [map { [shellwords($_)] } grep { /\S/ && !/^\s*#/ } split /\n/, $text];
+}
+
+sub package_set {
+ my ($text) = @_;
+ my ($packages) = $text =~ /^%packages[^\n]*\n(.*?)^%end/ms;
+ return {map { $_ => 1 } grep { length && !/^#/ } split /\n/, $packages // ''};
+}
+
+for my $family ( [ 'rocky', 'rocky10' ], [ 'rh', 'rhels10' ] ) {
+ my ( $dir, $osbase ) = @$family;
+ for my $profile (qw(compute service)) {
+ subtest "$osbase $profile" => sub {
+ my $base = repo_path("$install/$dir");
+ my $tmpl = xCAT::SvrUtils->get_tmpl_file_name($base, $profile, "$osbase.2", 'riscv64');
+ is($tmpl, "$base/$profile.$osbase.riscv64.tmpl", 'selects the architecture-specific template');
+ my $pkglist = xCAT::SvrUtils->get_pkglist_file_name($base, $profile, "$osbase.2", 'riscv64');
+ is($pkglist, "$base/$profile.$osbase.riscv64.pkglist", 'selects the architecture-specific package list');
+ set_row('nodetype', {node => 'node'}, {os => "$osbase.2", arch => 'riscv64', provmethod => 'install'});
+ my $output = "$database/$osbase-$profile.ks";
+ render_template($database, $tmpl, $output, 'node',
+ $pkglist, '/install/media', $dir, undef,
+ {xcatmaster => '192.0.2.1'});
+ my $rendered = read_text($output);
+ my $shared_output = "$database/$osbase-$profile-shared.ks";
+ render_template($database, "$base/$profile.$osbase.tmpl", $shared_output, 'node',
+ "$base/$profile.$osbase.pkglist", '/install/media', $dir, undef,
+ {xcatmaster => '192.0.2.1'});
+ my $shared = read_text($shared_output);
+ is_deeply(installer_settings($rendered), installer_settings($shared),
+ 'preserves the shared installer settings');
+ my $expected_packages = package_set($shared);
+ $expected_packages->{$_} = 1 for qw(grub2-efi-riscv64 efibootmgr);
+ is_deeply(package_set($rendered), $expected_packages,
+ 'adds the RISC-V boot packages to the complete shared package set');
+ my @pre = $rendered =~ /^%pre[^\n]*\n(.*?)^%end/msg;
+ my @shared_pre = $shared =~ /^%pre[^\n]*\n(.*?)^%end/msg;
+ is_deeply(\@pre, \@shared_pre, 'delivers the shared pre-install artifact unchanged');
+ unlike($rendered, qr/#(?:INCLUDE|TABLE|CRYPT|ENV|XCATVAR)[^\n]*#/, 'resolves template inputs');
+ like($rendered, qr/^rootpw --iscrypted \Q$password\E$/m, 'renders the configured password');
+ like($rendered, qr/^timezone UTC --utc$/m, 'renders the site timezone');
+ my ($options, $packages) = $rendered =~ /^%packages([^\n]*)\n(.*?)^%end/ms;
+ like($options // '', qr/(?:^|\s)--ignoremissing(?:\s|$)/, 'tolerates unavailable installer packages');
+ my %packages = map { $_ => 1 } grep { length && !/^#/ } split /\n/, $packages // '';
+ ok($packages{'grub2-efi-riscv64'} && $packages{efibootmgr}, 'includes RISC-V boot packages');
+ ok(!$packages{'grub2-efi-x64'} && !$packages{'shim-x64'} && !$packages{'grub2-efi-aa64'}, 'excludes foreign boot packages');
+ like($rendered, qr/^%addon com_redhat_kdump --disable\n%end$/m, 'disables the unsupported default crash reservation');
+ my @posts;
+ my @all_posts;
+ while ($rendered =~ /^%post([^\n]*)\n(.*?)^%end/msg) {
+ my ($flags, $body) = ($1, $2);
+ push @all_posts, [$flags, $body];
+ push @posts, [$flags, $body] if $flags =~ /(?:^|\s)--erroronfail(?:\s|$)/;
+ }
+ push @rendered_profiles, ["$osbase $profile", \@all_posts];
+ is(scalar @posts, 1, 'has one fatal boot-loader check');
+ return unless @posts == 1;
+ my ($interpreter) = $posts[0][0] =~ /--interpreter=(\S+)/;
+ is($interpreter, '/bin/bash', 'runs the check with the requested interpreter');
+ for my $loader ('BOOT/BOOTRISCV64.EFI', 'rocky/grubriscv64.efi', 'rocky/grubx64.efi', '') {
+ my $root = sandbox_root();
+ make_path("$root/boot/efi/EFI/BOOT", "$root/boot/efi/EFI/rocky", "$root/log/xcat");
+ write_text("$root/boot/efi/EFI/$loader", "loader\n") if $loader;
+ write_text("$root/check.sh", $posts[0][1]);
+ my ($rc, $log) = sandbox_run($root, $interpreter, '/fixture/check.sh');
+ my $valid = $loader && $loader ne 'rocky/grubx64.efi';
+ is($rc, $valid ? 0 : 1, $valid ? "accepts $loader" : "rejects an ESP without a RISC-V loader ($loader)") or diag($log);
+ if (!$valid) {
+ like(read_text("$root/log/xcat/xcat.log"), qr/no grub2 UEFI boot loader/, 'explains the missing loader');
+ }
+ }
+ };
+ }
+}
+
+my $post = File::Spec->catfile( $install, 'scripts', 'post.rhels10.riscv64' );
+my $post_path = repo_path($post);
+ok( -r $post_path, 'post.rhels10.riscv64 exists' );
+
+is(system('sh', '-n', $post_path), 0, 'post.rhels10.riscv64 parses as POSIX shell');
+
+my $tools = tempdir(CLEANUP => 1);
+my $efi_log = File::Spec->catfile($tools, 'efibootmgr.log');
+
+sub fake_command {
+ my ($name, $body) = @_;
+ my $path = File::Spec->catfile($tools, $name);
+ write_text($path, "#!/bin/sh\n$body");
+ chmod 0755, $path or die "Unable to make $path executable: $!";
+ return $path;
+}
+
+my $uname = fake_command('uname', <<'SH');
+printf '%s\n' "${XCAT_TEST_ARCH:-riscv64}"
+SH
+my $findmnt = fake_command('findmnt', <<'SH');
+printf '/dev/vda1\n'
+SH
+my $lsblk = fake_command('lsblk', <<'SH');
+case "$*" in
+ *PKNAME*) printf "vda\n" ;;
+ *PARTN*) printf "1\n" ;;
+esac
+SH
+my $efibootmgr = fake_command('efibootmgr', <<'SH');
+printf '%s\n' "$*" >> "$XCAT_EFI_LOG"
+case "$*" in
+ '') exit 0 ;;
+ '-v')
+ printf 'Boot0001* Rocky HD(1,GPT,...)/File(\\EFI\\rocky\\shimx64.efi)\n'
+ printf 'Boot0002* OldRiscv HD(1,GPT,...)/File(\\EFI\\rocky\\grubriscv64.efi)\n'
+ printf 'Boot0003* Network PXE\n'
+ exit 0
+ ;;
+ *'-c'*) exit "${XCAT_EFI_CREATE_RC:-0}" ;;
+esac
+exit 0
+SH
+
+sub installed_root {
+ my ($with_loader, $root) = @_;
+ $root //= tempdir(CLEANUP => 1);
+ make_path(File::Spec->catdir($root, 'boot', 'efi', 'EFI', 'rocky'));
+ make_path(File::Spec->catdir($root, 'etc'));
+ write_text(File::Spec->catfile($root, 'etc', 'os-release'), "NAME=\"Rocky Linux\"\n");
+ if ($with_loader) {
+ write_text(
+ File::Spec->catfile($root, 'boot', 'efi', 'EFI', 'rocky', 'grubriscv64.efi'),
+ "riscv loader\n",
+ );
+ }
+ return $root;
+}
+
+for my $rendered (@rendered_profiles) {
+ my ($label, $posts) = @$rendered;
+ subtest "$label post-install sequence" => sub {
+ my $root = installed_root(1, sandbox_root());
+ make_path("$root/etc/systemd/system/multi-user.target.wants", "$root/etc/yum.repos.d");
+ write_text("$root/etc/yum.repos.d/rocky.repo", "[baseos]\nname=BaseOS\nenabled=1\n");
+ write_text("$root/tmp/pre-install.log", "pre-install fixture diagnostic\n");
+ copy(repo_path('xCAT/postscripts/xcatlib.sh'), "$root/xcatlib.sh") or die $!;
+ write_text("$root/mypostscript.node", "#!/bin/bash\nMASTER=192.0.2.1\ntouch /fixture/postscript-ran\n");
+ for my $name (qw(uname findmnt lsblk efibootmgr)) {
+ copy("$tools/$name", "$root/bin/$name") or die $!;
+ chmod 0755, "$root/bin/$name" or die $!;
+ }
+ for my $name (qw(curl logger sleep updateflag.awk nmcli openssl)) {
+ copy(repo_path('xCAT-test/fixtures/install-profile/command'), "$root/bin/$name") or die $!;
+ chmod 0755, "$root/bin/$name" or die $!;
+ }
+ my $index = 0;
+ for my $post (@$posts) {
+ my ($interpreter) = $post->[0] =~ /--interpreter=(\S+)/;
+ $interpreter //= '/bin/sh';
+ write_text("$root/post.sh", $post->[1]);
+ my ($rc, $log) = sandbox_run($root, {
+ env => {XCAT_EFI_LOG => '/fixture/efi.log'},
+ }, 'timeout', '30', $interpreter, '/fixture/post.sh');
+ is($rc, 0, 'post block ' . ++$index . ' completes')
+ or diag($log, read_text("$root/log/xcat/xcat.log"));
+ }
+ my $fallback = "$root/boot/efi/EFI/BOOT/BOOTRISCV64.EFI";
+ is(-f $fallback ? read_text($fallback) : '', "riscv loader\n",
+ 'the rendered profile installs the fallback EFI loader');
+ my $calls = -f "$root/efi.log" ? read_text("$root/efi.log") : '';
+ like($calls, qr/^-q -c -d \/dev\/vda -p 1 -L Rocky Linux -l \\EFI\\rocky\\grubriscv64\.efi$/m,
+ 'the rendered profile registers the vendor EFI loader');
+ ok(!-e "$root/unexpected-command", 'external fixtures accept the requested operations');
+ ok(-e "$root/postscript-ran", 'runs the downloaded postscript from the rendered profile');
+ is(read_text("$root/etc/yum.repos.d/rocky.repo"), "[baseos]\nname=BaseOS\nenabled=0\n",
+ 'disables the installed vendor repository');
+ like(read_text("$root/log/xcat/xcat.log"), qr/^pre-install fixture diagnostic$/m,
+ 'preserves the pre-install diagnostics in the deployment log');
+ };
+}
+
+sub run_post {
+ my ($root, %extra) = @_;
+ local %ENV = (
+ %ENV,
+ XCAT_INSTALL_ROOT => $root,
+ XCAT_UNAME => $uname,
+ XCAT_EFIBOOTMGR => $efibootmgr,
+ XCAT_FINDMNT => $findmnt,
+ XCAT_LSBLK => $lsblk,
+ XCAT_EFI_LOG => $efi_log,
+ XCAT_TEST_ARCH => 'riscv64',
+ %extra,
+ );
+ open(my $fh, '-|', 'sh', $post_path) or die "Unable to run $post_path: $!";
+ my $output = do { local $/; <$fh> };
+ close($fh);
+ return ($? >> 8, $output);
+}
+
+my $root = installed_root(1);
+my ($status, $output) = run_post($root);
+is($status, 0, 'the RISC-V fix-up completes successfully');
+is(
+ read_text(File::Spec->catfile($root, 'boot', 'efi', 'EFI', 'BOOT', 'BOOTRISCV64.EFI')),
+ "riscv loader\n",
+ 'the distro loader is copied to the removable-media fallback path',
+);
+like($output, qr/UEFI boot entry "Rocky Linux"/, 'the created UEFI entry is reported');
+my $efi_calls = read_text($efi_log);
+like($efi_calls, qr/^-q -b 0001 -B$/m, 'the invalid x86 boot entry is removed');
+like($efi_calls, qr/^-q -b 0002 -B$/m, 'the prior RISC-V boot entry is removed');
+unlike($efi_calls, qr/0003/, 'unrelated firmware entries are retained');
+like(
+ $efi_calls,
+ qr/^-q -c -d \/dev\/vda -p 1 -L Rocky Linux -l \\EFI\\rocky\\grubriscv64\.efi$/m,
+ 'the new entry points at the distro RISC-V loader on the ESP disk',
+);
+
+unlink($efi_log);
+my $x86_root = installed_root(1);
+($status, $output) = run_post($x86_root, XCAT_TEST_ARCH => 'x86_64');
+is($status, 0, 'the post-install script is a no-op on x86_64');
+is($output, '', 'the x86_64 no-op reports nothing');
+ok(!-e $efi_log, 'the x86_64 no-op never invokes efibootmgr');
+
+my $bare_root = installed_root(0);
+($status, $output) = run_post($bare_root);
+is($status, 0, 'a missing distro loader remains nonfatal');
+like($output, qr/no \\EFI\\\*\\grubriscv64\.efi/, 'a missing distro loader is diagnosed');
+
+unlink($efi_log);
+($status, $output) = run_post($root, XCAT_EFI_CREATE_RC => 1);
+is($status, 0, 'an efibootmgr create failure remains nonfatal');
+like($output, qr/firmware will use \\EFI\\BOOT\\BOOTRISCV64\.EFI/, 'the fallback path is reported after an efibootmgr failure');
+
+done_testing();
diff --git a/xCAT-test/unit/install_profile_riscv64.t b/xCAT-test/unit/install_profile_riscv64.t
deleted file mode 100644
index 117110baf..000000000
--- a/xCAT-test/unit/install_profile_riscv64.t
+++ /dev/null
@@ -1,178 +0,0 @@
-#!/usr/bin/env perl
-use strict;
-use warnings;
-
-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 XCAT::Test::File qw(repo_path slurp_repo_file);
-
-# EL10 riscv64 kickstart installs: the EL10 anaconda has no RISC-V EFI
-# platform, so the riscv64 templates tolerate its x86 boot loader package
-# request, pull grub2-efi-riscv64/efibootmgr in through the riscv64 package
-# lists, and run a %post that points the UEFI boot entry at grubriscv64.efi
-# and places the removable-media fallback loader.
-
-my $install = File::Spec->catdir( 'xCAT-server', 'share', 'xcat', 'install' );
-
-for my $family ( [ 'rocky', 'rocky10' ], [ 'rh', 'rhels10' ] ) {
- my ( $dir, $osbase ) = @$family;
- for my $profile (qw(compute service)) {
- my $tmpl = File::Spec->catfile( $install, $dir, "$profile.$osbase.riscv64.tmpl" );
- my $pkglist = File::Spec->catfile( $install, $dir, "$profile.$osbase.riscv64.pkglist" );
- my $base = File::Spec->catfile( $install, $dir, "$profile.$osbase.tmpl" );
- ok( -r repo_path($tmpl), "$dir/$profile.$osbase.riscv64.tmpl exists" );
- ok( -r repo_path($pkglist), "$dir/$profile.$osbase.riscv64.pkglist exists" );
- my $t = slurp_repo_file($tmpl);
- like( $t, qr/^%packages --ignoremissing$/m, "$dir/$profile.$osbase riscv64 template tolerates anaconda's x86 boot loader package request" );
- like( $t, qr/^#INCLUDE_DEFAULT_PKGLIST#$/m, "$dir/$profile.$osbase riscv64 template still includes the default package list" );
- like( $t, qr{^#INCLUDE:#ENV:XCATROOT#/share/xcat/install/scripts/post\.rhels10\.riscv64#$}m, "$dir/$profile.$osbase riscv64 template runs the riscv64 UEFI boot entry fix-up" );
- like( $t, qr/^%addon com_redhat_kdump --disable\n%end$/m, "$dir/$profile.$osbase riscv64 template keeps the installer from writing a crash kernel reservation EL10 riscv64 cannot honour" );
- like( $t, qr/^%post --erroronfail --interpreter=\/bin\/bash$/m, "$dir/$profile.$osbase riscv64 template fails the install when no boot loader reached the ESP" );
- like( $t, qr{/boot/efi/EFI/BOOT/BOOTRISCV64\.EFI}, "$dir/$profile.$osbase riscv64 template checks the removable-media loader" );
- like( $t, qr{/boot/efi/EFI/\*/grubriscv64\.efi}, "$dir/$profile.$osbase riscv64 template also accepts a vendor directory loader" );
- like( $t, qr/^rootpw --iscrypted #CRYPT:passwd:key=system,username=root:password#$/m, "$dir/$profile.$osbase riscv64 template keeps the shared root password directive" );
-
- # the riscv64 template is the shared one plus the riscv64 changes
- my $b = slurp_repo_file($base);
- ( my $t_norm = $t ) =~ s/^# riscv64:.*\n(?:#.*\n)*%addon com_redhat_kdump --disable\n%end\n\n//m;
- $t_norm =~ s/^# riscv64:.*\n(?:#.*\n)*//m;
- $t_norm =~ s/^%packages --ignoremissing$/%packages/m;
- $t_norm =~ s{^#INCLUDE:#ENV:XCATROOT#/share/xcat/install/scripts/post\.rhels10\.riscv64#\n}{}m;
- $t_norm =~ s/\n# --ignoremissing above.*?\n%post --erroronfail.*?\n%end\n//s;
- is( $t_norm, $b, "$dir/$profile.$osbase riscv64 template only differs from the shared template in the riscv64 changes" );
-
- my $p = slurp_repo_file($pkglist);
- like( $p, qr/^grub2-efi-riscv64$/m, "$dir/$profile.$osbase riscv64 package list installs grub2-efi-riscv64" );
- like( $p, qr/^efibootmgr$/m, "$dir/$profile.$osbase riscv64 package list installs efibootmgr" );
- unlike( $p, qr/shim|grub2-efi-x64|grub2-efi-aa64/, "$dir/$profile.$osbase riscv64 package list asks for no other architecture's boot loader" );
- my $shared = slurp_repo_file(
- File::Spec->catfile( $install, $dir, "$profile.$osbase.pkglist" )
- );
- ( my $p_norm = $p ) =~ s/^grub2-efi-riscv64\n//m;
- $p_norm =~ s/^efibootmgr\n//m;
- s/\s+\z/\n/ for ( $p_norm, $shared );
- is( $p_norm, $shared, "$dir/$profile.$osbase riscv64 package list is the shared list plus the boot loader packages" );
- }
-}
-
-my $post = File::Spec->catfile( $install, 'scripts', 'post.rhels10.riscv64' );
-my $post_path = repo_path($post);
-ok( -r $post_path, 'post.rhels10.riscv64 exists' );
-
-is(system('sh', '-n', $post_path), 0, 'post.rhels10.riscv64 parses as POSIX shell');
-
-my $tools = tempdir(CLEANUP => 1);
-my $efi_log = File::Spec->catfile($tools, 'efibootmgr.log');
-
-sub fake_command {
- my ($name, $body) = @_;
- my $path = File::Spec->catfile($tools, $name);
- write_text($path, "#!/bin/sh\n$body");
- chmod 0755, $path or die "Unable to make $path executable: $!";
- return $path;
-}
-
-my $uname = fake_command('uname', <<'SH');
-printf '%s\n' "${XCAT_TEST_ARCH:-riscv64}"
-SH
-my $findmnt = fake_command('findmnt', <<'SH');
-printf '/dev/vda1\n'
-SH
-my $lsblk = fake_command('lsblk', <<'SH');
-case "$*" in
- *PKNAME*) printf "vda\n" ;;
- *PARTN*) printf "1\n" ;;
-esac
-SH
-my $efibootmgr = fake_command('efibootmgr', <<'SH');
-printf '%s\n' "$*" >> "$XCAT_EFI_LOG"
-case "$*" in
- '') exit 0 ;;
- '-v')
- printf 'Boot0001* Rocky HD(1,GPT,...)/File(\\EFI\\rocky\\shimx64.efi)\n'
- printf 'Boot0002* OldRiscv HD(1,GPT,...)/File(\\EFI\\rocky\\grubriscv64.efi)\n'
- printf 'Boot0003* Network PXE\n'
- exit 0
- ;;
- *'-c'*) exit "${XCAT_EFI_CREATE_RC:-0}" ;;
-esac
-exit 0
-SH
-
-sub installed_root {
- my ($with_loader) = @_;
- my $root = tempdir(CLEANUP => 1);
- make_path(File::Spec->catdir($root, 'boot', 'efi', 'EFI', 'rocky'));
- make_path(File::Spec->catdir($root, 'etc'));
- write_text(File::Spec->catfile($root, 'etc', 'os-release'), "NAME=\"Rocky Linux\"\n");
- if ($with_loader) {
- write_text(
- File::Spec->catfile($root, 'boot', 'efi', 'EFI', 'rocky', 'grubriscv64.efi'),
- "riscv loader\n",
- );
- }
- return $root;
-}
-
-sub run_post {
- my ($root, %extra) = @_;
- local %ENV = (
- %ENV,
- XCAT_INSTALL_ROOT => $root,
- XCAT_UNAME => $uname,
- XCAT_EFIBOOTMGR => $efibootmgr,
- XCAT_FINDMNT => $findmnt,
- XCAT_LSBLK => $lsblk,
- XCAT_EFI_LOG => $efi_log,
- XCAT_TEST_ARCH => 'riscv64',
- %extra,
- );
- open(my $fh, '-|', 'sh', $post_path) or die "Unable to run $post_path: $!";
- my $output = do { local $/; <$fh> };
- close($fh);
- return ($? >> 8, $output);
-}
-
-my $root = installed_root(1);
-my ($status, $output) = run_post($root);
-is($status, 0, 'the RISC-V fix-up completes successfully');
-is(
- read_text(File::Spec->catfile($root, 'boot', 'efi', 'EFI', 'BOOT', 'BOOTRISCV64.EFI')),
- "riscv loader\n",
- 'the distro loader is copied to the removable-media fallback path',
-);
-like($output, qr/UEFI boot entry "Rocky Linux"/, 'the created UEFI entry is reported');
-my $efi_calls = read_text($efi_log);
-like($efi_calls, qr/^-q -b 0001 -B$/m, 'the invalid x86 boot entry is removed');
-like($efi_calls, qr/^-q -b 0002 -B$/m, 'the prior RISC-V boot entry is removed');
-unlike($efi_calls, qr/0003/, 'unrelated firmware entries are retained');
-like(
- $efi_calls,
- qr/^-q -c -d \/dev\/vda -p 1 -L Rocky Linux -l \\EFI\\rocky\\grubriscv64\.efi$/m,
- 'the new entry points at the distro RISC-V loader on the ESP disk',
-);
-
-unlink($efi_log);
-my $x86_root = installed_root(1);
-($status, $output) = run_post($x86_root, XCAT_TEST_ARCH => 'x86_64');
-is($status, 0, 'the post-install script is a no-op on x86_64');
-is($output, '', 'the x86_64 no-op reports nothing');
-ok(!-e $efi_log, 'the x86_64 no-op never invokes efibootmgr');
-
-my $bare_root = installed_root(0);
-($status, $output) = run_post($bare_root);
-is($status, 0, 'a missing distro loader remains nonfatal');
-like($output, qr/no \\EFI\\\*\\grubriscv64\.efi/, 'a missing distro loader is diagnosed');
-
-unlink($efi_log);
-($status, $output) = run_post($root, XCAT_EFI_CREATE_RC => 1);
-is($status, 0, 'an efibootmgr create failure remains nonfatal');
-like($output, qr/firmware will use \\EFI\\BOOT\\BOOTRISCV64\.EFI/, 'the fallback path is reported after an efibootmgr failure');
-
-done_testing();
From 4c47d8b3ec478bc941a43b5dfbb06641b94234bd 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, 9 Oct 2026 15:02:33 -0300
Subject: [PATCH 2/5] test(netboot): execute selected RISC-V postinstall
scripts
Run each selected postinstall script in an isolated filesystem. Check SELinux settings and fstab after repeated execution instead of comparing shell source.
---
.github/workflows/xcat_test.yml | 2 +-
.../netboot_profile_riscv64.t | 57 +++++++++++++++----
2 files changed, 46 insertions(+), 13 deletions(-)
rename xCAT-test/{unit => native}/netboot_profile_riscv64.t (59%)
diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml
index 9d874410d..8495fcf54 100644
--- a/.github/workflows/xcat_test.yml
+++ b/.github/workflows/xcat_test.yml
@@ -77,7 +77,7 @@ jobs:
run: |
log=$(mktemp)
trap 'rm -f -- "$log"' EXIT
- prove -v xCAT-test/native/install_profile_riscv64.t | tee "$log"
+ prove -v xCAT-test/native/install_profile_riscv64.t xCAT-test/native/netboot_profile_riscv64.t | tee "$log"
! grep -qi skipped "$log"
rpm_lifecycle_test:
runs-on: ubuntu-24.04
diff --git a/xCAT-test/unit/netboot_profile_riscv64.t b/xCAT-test/native/netboot_profile_riscv64.t
similarity index 59%
rename from xCAT-test/unit/netboot_profile_riscv64.t
rename to xCAT-test/native/netboot_profile_riscv64.t
index 40c00dab9..be5109d4c 100644
--- a/xCAT-test/unit/netboot_profile_riscv64.t
+++ b/xCAT-test/native/netboot_profile_riscv64.t
@@ -8,15 +8,18 @@ use lib "$FindBin::Bin/../lib";
use lib "$FindBin::Bin/../../perl-xCAT";
use lib "$FindBin::Bin/../../xCAT-server/lib/perl";
use File::Spec;
+use File::Path qw(make_path);
+use File::Slurper qw(read_text write_text);
+use File::Temp qw(tempdir);
use Test::More;
use XCAT::Test::File qw(repo_path slurp_repo_file);
-use xCAT::SvrUtils;
-
-# EL10 riscv64 diskless and service profiles are plain data files resolved by
-# imgutils::get_profile_def_filename (osver.arch first, then osbase.arch, then
-# the arch-less fallbacks). Pin that the riscv64 files exist, win the lookup
-# for rocky10/rhels10 point releases, and carry the right content.
+use XCAT::Test::Sandbox qw(sandbox_root sandbox_run);
+plan skip_all => 'postinstall execution requires Linux' unless $^O eq 'linux';
+my $database = tempdir(CLEANUP => 1);
+$ENV{XCATROOT} = repo_path('xCAT-server');
+$ENV{XCATCFG} = "SQLite:$database";
+require xCAT::SvrUtils;
my $share_relative = File::Spec->catdir( 'xCAT-server', 'share', 'xcat' );
my $share = repo_path($share_relative);
@@ -24,7 +27,6 @@ my $imgutils_relative = File::Spec->catfile(
$share_relative, 'netboot', 'imgutils', 'imgutils.pm'
);
my $imgutils = repo_path($imgutils_relative);
-plan skip_all => "$imgutils not found" unless -r $imgutils;
require $imgutils;
my @families = (
@@ -64,11 +66,42 @@ for my $family (@families) {
unlike( $exlist, qr{^\./lib/kdb/}m, "$dir/$profile.$osbase riscv64 exlist has no kdb typo" );
is( scalar( () = $exlist =~ m{^\./usr/share/man\*$}mg ), 1, "$dir/$profile.$osbase riscv64 exlist lists usr/share/man once" );
- my $postinstall = slurp_repo_file(
- File::Spec->catfile( $base_relative, "$profile.$osbase.riscv64.postinstall" )
- );
- like( $postinstall, qr/^#!\/bin\/sh/, "$dir/$profile.$osbase riscv64 postinstall is a shell script" );
- like( $postinstall, qr/SELINUX=disabled/, "$dir/$profile.$osbase riscv64 postinstall disables SELinux in the image" );
+ my $postinstall = imgutils::get_profile_def_filename($osver, $profile, 'riscv64', $base, 'postinstall');
+ for my $mode (qw(enforcing permissive disabled absent)) {
+ subtest "$osver $profile SELinux $mode" => sub {
+ my $root = sandbox_root();
+ my $image = "$root/target";
+ make_path("$image/etc/selinux", "$root/etc/selinux");
+ write_text("$root/etc/fstab", "host fstab\n");
+ write_text("$root/etc/selinux/config", "SELINUX=enforcing\n# host policy\n");
+ write_text("$image/etc/fstab", "obsolete\n");
+ write_text("$image/etc/selinux/config", "SELINUX=$mode\nSELINUXTYPE=targeted\n")
+ unless $mode eq 'absent';
+ for my $pass (1, 2) {
+ # EL uses Bash as /bin/sh for these postinstall scripts.
+ my ($status, $output) = sandbox_run($root,
+ {read_only => {repo_path('.') => repo_path('.'), '/bin/bash' => '/bin/sh'}},
+ $postinstall, '/target', $osver, 'riscv64', $profile);
+ is($status, 0, "pass $pass executes the selected script") or diag($output);
+ is(read_text("$root/etc/fstab"), "host fstab\n", "pass $pass leaves the host fstab unchanged");
+ is(read_text("$root/etc/selinux/config"), "SELINUX=enforcing\n# host policy\n", "pass $pass leaves host SELinux unchanged");
+ if ($mode eq 'absent') {
+ ok(!-e "$image/etc/selinux/config", "pass $pass leaves absent SELinux configuration absent");
+ } else {
+ local $TODO = 'The existing postinstall duplicates SELINUX= for permissive mode'
+ if $mode eq 'permissive';
+ is(read_text("$image/etc/selinux/config"), "SELINUX=disabled\nSELINUXTYPE=targeted\n",
+ "pass $pass disables SELinux without changing the policy type");
+ }
+ my @mounts = map { [split /\s+/] } grep { /\S/ } split /\n/, read_text("$image/etc/fstab");
+ is_deeply(\@mounts, [
+ [qw(proc /proc proc rw 0 0)],
+ [qw(sysfs /sys sysfs rw 0 0)],
+ ['devpts', '/dev/pts', 'devpts', 'rw,gid=5,mode=620', 0, 0],
+ ], "pass $pass writes the diskless virtual filesystems once");
+ }
+ };
+ }
}
my $otherpkgs = File::Spec->catfile( $base, "service.$osbase.riscv64.otherpkgs.pkglist" );
From fdf18e24c36366b351f282786795bf16a39e9df4 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, 9 Oct 2026 15:02:35 -0300
Subject: [PATCH 3/5] test(packaging): run the generated Debian repository
helper
Build a real package and repository, then execute the generated helper with representative host architectures and releases. Check its apt configuration and preserve unrelated sources.
---
.github/workflows/xcat_test.yml | 2 +-
xCAT-test/native/builddebs_riscv64.t | 69 ++++++++++++++++++++++
xCAT-test/unit/builddebs_riscv64.t | 88 ----------------------------
3 files changed, 70 insertions(+), 89 deletions(-)
create mode 100644 xCAT-test/native/builddebs_riscv64.t
delete mode 100644 xCAT-test/unit/builddebs_riscv64.t
diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml
index 8495fcf54..89f910c64 100644
--- a/.github/workflows/xcat_test.yml
+++ b/.github/workflows/xcat_test.yml
@@ -77,7 +77,7 @@ jobs:
run: |
log=$(mktemp)
trap 'rm -f -- "$log"' EXIT
- prove -v xCAT-test/native/install_profile_riscv64.t xCAT-test/native/netboot_profile_riscv64.t | tee "$log"
+ prove -v xCAT-test/native/install_profile_riscv64.t xCAT-test/native/netboot_profile_riscv64.t xCAT-test/native/builddebs_riscv64.t | tee "$log"
! grep -qi skipped "$log"
rpm_lifecycle_test:
runs-on: ubuntu-24.04
diff --git a/xCAT-test/native/builddebs_riscv64.t b/xCAT-test/native/builddebs_riscv64.t
new file mode 100644
index 000000000..37b1c2ca5
--- /dev/null
+++ b/xCAT-test/native/builddebs_riscv64.t
@@ -0,0 +1,69 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use File::Path qw(make_path);
+use File::Slurper qw(read_text write_text);
+use File::Temp qw(tempdir);
+use FindBin;
+use lib "$FindBin::Bin/../lib";
+use Test::More;
+use XCAT::Test::File qw(repo_path);
+use XCAT::Test::Process qw(run_command);
+use XCAT::Test::Sandbox qw(sandbox_root sandbox_run);
+
+plan skip_all => 'requires a Debian-family package builder'
+ unless $^O eq 'linux' && -f '/etc/debian_version';
+require Dpkg::Control::Info;
+
+my $build = tempdir(CLEANUP => 1);
+make_path("$build/source", "$build/home");
+for my $entry (qw(builddebs.pl build-utils Version Release xCAT-buildkit)) {
+ system('cp', '-a', repo_path($entry), "$build/source/") == 0 or die "Cannot copy $entry";
+}
+{
+ local %ENV = (%ENV, HOME => "$build/home", SOURCE_DATE_EPOCH => 1750000000,
+ PATH => '/usr/bin:/bin', LC_ALL => 'C');
+ my ($status, $output) = run_command($^X, "$build/source/builddebs.pl",
+ '--package', 'xCAT-buildkit', '--dist', 'noble', '--release', 'test1',
+ '--dest', "$build/output");
+ is($status, 0, 'the real Debian builder creates a package and repository') or diag($output);
+ die 'the builder did not complete' if $status;
+}
+my $repository = "$build/output/xcat-core";
+ok(-x "$repository/mklocalrepo.sh", 'the builder delivers an executable repository helper');
+ok(-f "$repository/dists/noble/Release", 'the helper accompanies a published repository');
+
+for my $case (['riscv64', 'riscv64'], ['ppc64le', 'ppc64el'], ['x86_64', 'amd64']) {
+ my ($machine, $architecture) = @$case;
+ for my $release (qw(noble jammy)) {
+ subtest "$machine $release" => sub {
+ my $root = sandbox_root();
+ make_path("$root/etc/apt/sources.list.d");
+ write_text("$root/etc/lsb-release", "DISTRIB_CODENAME=$release\n");
+ write_text("$root/bin/uname", "#!/bin/sh\n[ \"\$*\" = -m ] || exit 2\nprintf '%s\\n' '$machine'\n");
+ chmod 0755, "$root/bin/uname" or die $!;
+ write_text("$root/etc/apt/sources.list.d/administrator.list", "preserved\n");
+ for my $pass (1, 2) {
+ my ($status, $output) = sandbox_run($root,
+ {read_only => {$repository => '/repo'}}, '/repo/mklocalrepo.sh');
+ is($status, 0, "pass $pass executes the generated helper unchanged") or diag($output);
+ is(read_text("$root/etc/apt/sources.list.d/xcat-core.list"),
+ "deb [arch=$architecture] file:///repo $release main\n",
+ "pass $pass selects the host architecture and distribution");
+ is(read_text("$root/etc/apt/sources.list.d/administrator.list"), "preserved\n",
+ "pass $pass preserves unrelated sources");
+ }
+ };
+ }
+}
+
+for my $case (['xCAT', 'xcat'], ['xCATsn', 'xcatsn']) {
+ my ($component, $package) = @$case;
+ my $control = Dpkg::Control::Info->new(repo_path("$component/debian/control"));
+ my ($binary) = grep { $_->{Package} eq $package } $control->get_packages();
+ ok($binary, "$package has a binary package stanza");
+ my %architecture = map { $_ => 1 } split /\s+/, $binary->{Architecture} // '';
+ ok($architecture{$_}, "$package supports $_") for qw(riscv64 amd64 ppc64el);
+}
+
+done_testing();
diff --git a/xCAT-test/unit/builddebs_riscv64.t b/xCAT-test/unit/builddebs_riscv64.t
deleted file mode 100644
index 5dc2fc813..000000000
--- a/xCAT-test/unit/builddebs_riscv64.t
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/usr/bin/env perl
-use strict;
-use warnings;
-
-use File::Spec;
-use File::Temp qw(tempdir);
-use FindBin;
-use Test::More;
-
-# A riscv64 management node needs an xcat and xcatsn deb built for the architecture and a
-# mklocalrepo.sh that points the host at the matching repository instead of amd64.
-#
-# The generated script is extracted from builddebs.pl and run with a stub uname ahead of
-# $PATH, so the mapping under test is the shipped code. Only the path it writes is
-# redirected into the sandbox, because it writes an apt source list.
-
-my $repo_root = File::Spec->rel2abs( File::Spec->catdir( $FindBin::Bin, '..', '..' ) );
-my $builder = File::Spec->catfile( $repo_root, 'builddebs.pl' );
-plan skip_all => "builddebs.pl not found" unless -f $builder;
-
-my $src = do { local $/; open my $fh, '<', $builder or die $!; <$fh> };
-
-# BAIL_OUT rather than skip: a rename that stops this matching must fail loudly instead of
-# silently covering nothing.
-my ($script) = $src =~ /write_script\("\$repodir\/mklocalrepo\.sh", <<'SCRIPT'\);\n(.*?)\nSCRIPT\n/ms;
-BAIL_OUT('could not extract mklocalrepo.sh from builddebs.pl') unless defined $script;
-
-my $dir = tempdir( CLEANUP => 1 );
-my $run = 0;
-
-# Run the generated script for one host architecture and return the apt source line it wrote.
-sub sources_line_for {
- my ($uname) = @_;
- $run++;
- my $root = File::Spec->catdir( $dir, "run$run" );
- mkdir $root;
- mkdir "$root/bin";
-
- open( my $stub, '>', "$root/bin/uname" ) or die $!;
- print {$stub} "#!/bin/bash\necho $uname\n";
- close($stub);
- chmod 0755, "$root/bin/uname";
-
- my $release = File::Spec->catfile( $root, 'lsb-release' );
- open( my $rel, '>', $release ) or die $!;
- print {$rel} "DISTRIB_CODENAME=noble\n";
- close($rel);
-
- my $listed = File::Spec->catfile( $root, 'sources.list' );
- ( my $sandboxed = $script ) =~ s{/etc/lsb-release}{$release};
- $sandboxed =~ s{/etc/apt/sources\.list\.d/\S+}{$listed};
-
- my $harness = File::Spec->catfile( $root, 'harness.sh' );
- open( my $fh, '>', $harness ) or die $!;
- print {$fh} "#!/bin/bash\n$sandboxed\n";
- close($fh);
-
- local $ENV{PATH} = "$root/bin:$ENV{PATH}";
- system( '/bin/bash', $harness );
- open( my $out, '<', $listed ) or die $!;
- my $line = do { local $/; <$out> };
- close($out);
- return $line;
-}
-
-for my $case (
- [ 'riscv64', 'riscv64' ],
- [ 'ppc64le', 'ppc64el' ],
- [ 'x86_64', 'amd64' ],
- )
-{
- my ( $uname, $want ) = @$case;
- like( sources_line_for($uname), qr/^deb \[arch=\Q$want\E\] /,
- "mklocalrepo.sh gives a $uname host the $want repository" );
-}
-
-# The debs themselves must exist for the architecture.
-for my $case ( [ 'xCAT', 'xcat' ], [ 'xCATsn', 'xcatsn' ] ) {
- my ( $component, $package ) = @$case;
- my $control = File::Spec->catfile( $repo_root, $component, 'debian', 'control' );
- my $text = do { local $/; open my $fh, '<', $control or die $!; <$fh> };
- my ($arches) = $text =~ /^Package: \Q$package\E\nArchitecture: (.*)$/m;
- ok( defined $arches, "$package declares an architecture" );
- like( $arches || '', qr/\briscv64\b/, "$package is built for riscv64" );
- like( $arches || '', qr/\bamd64\b.*\bppc64el\b/, "$package keeps amd64 and ppc64el" );
-}
-
-done_testing();
From 1d362211e70c7c7ca2eaa7e04bdbfccf32cf661f 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, 9 Oct 2026 15:02:53 -0300
Subject: [PATCH 4/5] test(ubuntu): execute rendered Subiquity command
sequences
Render Subiquity configuration with real database rows and execute the emitted commands in private namespaces. Check resulting files, failure ordering and the install-monitor handshake without rewriting shell fragments.
---
.github/workflows/xcat_test.yml | 4 +-
xCAT-test/fixtures/subiquity/command | 41 +++++
xCAT-test/fixtures/subiquity/run.pl | 46 +++++
xCAT-test/native/ubuntu_subiquity_template.t | 173 +++++++++++++++++++
xCAT-test/unit/ubuntu_subiquity_template.t | 120 -------------
5 files changed, 262 insertions(+), 122 deletions(-)
create mode 100644 xCAT-test/fixtures/subiquity/command
create mode 100644 xCAT-test/fixtures/subiquity/run.pl
create mode 100644 xCAT-test/native/ubuntu_subiquity_template.t
delete mode 100644 xCAT-test/unit/ubuntu_subiquity_template.t
diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml
index 89f910c64..ebd003245 100644
--- a/.github/workflows/xcat_test.yml
+++ b/.github/workflows/xcat_test.yml
@@ -9,7 +9,7 @@ jobs:
- 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 cpio xz-utils gzip gawk mawk rpm rsyslog build-essential fakeroot reprepro devscripts debhelper libcapture-tiny-perl libfile-slurper-perl libjson-perl libyaml-pp-perl libparallel-forkmanager-perl libsoap-lite-perl libdbi-perl libdbd-sqlite3-perl libcgi-pm-perl quilt openssh-server dpkg looptools genometools software-properties-common grub-common grub-efi-amd64-bin squashfs-tools pigz
+ run: sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-install-suggests bats bubblewrap cpio xz-utils gzip gawk mawk rpm rsyslog build-essential fakeroot reprepro devscripts debhelper libcapture-tiny-perl libfile-slurper-perl libjson-perl libyaml-pp-perl libparallel-forkmanager-perl libsoap-lite-perl libdbi-perl libdbd-sqlite3-perl libcgi-pm-perl quilt openssh-server dpkg looptools genometools software-properties-common grub-common grub-efi-amd64-bin squashfs-tools pigz python3-yaml
- name: Enable postscript test namespaces
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Install lifecycle test dependencies
@@ -77,7 +77,7 @@ jobs:
run: |
log=$(mktemp)
trap 'rm -f -- "$log"' EXIT
- prove -v xCAT-test/native/install_profile_riscv64.t xCAT-test/native/netboot_profile_riscv64.t xCAT-test/native/builddebs_riscv64.t | tee "$log"
+ prove -v xCAT-test/native/install_profile_riscv64.t xCAT-test/native/netboot_profile_riscv64.t xCAT-test/native/builddebs_riscv64.t xCAT-test/native/ubuntu_subiquity_template.t | tee "$log"
! grep -qi skipped "$log"
rpm_lifecycle_test:
runs-on: ubuntu-24.04
diff --git a/xCAT-test/fixtures/subiquity/command b/xCAT-test/fixtures/subiquity/command
new file mode 100644
index 000000000..e294478db
--- /dev/null
+++ b/xCAT-test/fixtures/subiquity/command
@@ -0,0 +1,41 @@
+#!/bin/sh
+set -eu
+case "${0##*/}" in
+ ip) exit 0 ;;
+ getent)
+ [ "$*" = 'ahostsv4 master' ] || exit 64
+ [ "$XCAT_TEST_SCENARIO" != unresolved ] || exit 2
+ printf '127.0.0.1 STREAM master\n'
+ ;;
+ wget)
+ printf '%s\n' "$*" >>/fixture/downloads
+ output=
+ timeout=
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -T) timeout=$2; shift 2 ;;
+ -O) output=$2; shift 2 ;;
+ http://127.0.0.1:8080/install/autoinst/*) url=$1; shift ;;
+ *) exit 64 ;;
+ esac
+ done
+ [ "$timeout" = 30 ] || exit 64
+ name=${url##*/}
+ [ -n "$output" ] || output=$name
+ case "$XCAT_TEST_SCENARIO:$name" in
+ fail-disk:getinstdisk|fail-pre:node.pre) exit 8 ;;
+ empty-disk:getinstdisk|empty-pre:node.pre) : >"$output"; exit 0 ;;
+ esac
+ cp "/fixture/payloads/$name" "$output"
+ ;;
+ curtin)
+ [ "$1 $2 $3" = 'in-target --target /target' ] || exit 64
+ shift 3
+ case "$*" in
+ update-grub2) cp /target/etc/default/grub /fixture/grub-input ;;
+ /root/post.script) exec /target/root/post.script ;;
+ *) exit 64 ;;
+ esac
+ ;;
+ *) exit 64 ;;
+esac
diff --git a/xCAT-test/fixtures/subiquity/run.pl b/xCAT-test/fixtures/subiquity/run.pl
new file mode 100644
index 000000000..9d815202c
--- /dev/null
+++ b/xCAT-test/fixtures/subiquity/run.pl
@@ -0,0 +1,46 @@
+use strict;
+use warnings;
+use File::Copy qw(copy);
+use File::Slurper qw(read_text write_text);
+use IO::Socket::INET;
+use JSON::PP qw(decode_json encode_json);
+use POSIX qw(_exit);
+
+alarm 90;
+my $commands = decode_json(read_text('/fixture/commands.json'));
+copy('/fixture/autoinstall.yaml', '/autoinstall.yaml') or die $!;
+my $listener = IO::Socket::INET->new(
+ LocalAddr => '127.0.0.1', LocalPort => 3002, Listen => 1, ReuseAddr => 1,
+) or die "Cannot start install monitor: $!";
+my $pid = fork();
+die "fork: $!" unless defined $pid;
+if (!$pid) {
+ alarm 60;
+ my $client = $listener->accept() or _exit(1);
+ $client->autoflush(1);
+ print {$client} "ready\n";
+ my $request = <$client>;
+ write_text('/fixture/monitor-request', $request // '');
+ print {$client} "done\n" if defined($request) && $request eq "next\n";
+ close($client);
+ _exit(0);
+}
+close($listener);
+my @statuses;
+my $status = 0;
+for my $phase (qw(early-commands late-commands)) {
+ for my $command (@{$commands->{$phase}}) {
+ my @arguments = ref($command) eq 'ARRAY' ? @$command : ('/bin/sh', '-c', $command);
+ system(@arguments);
+ $status = $? == -1 ? 255 : $? & 127 ? 128 + ($? & 127) : $? >> 8;
+ push @statuses, [$phase, $status];
+ last if $status;
+ }
+ last if $status;
+}
+kill 'TERM', $pid if $status;
+waitpid($pid, 0);
+write_text('/fixture/monitor-status', "$?\n");
+write_text('/fixture/statuses.json', encode_json(\@statuses));
+copy('/autoinstall.yaml', '/fixture/final-autoinstall.yaml') or die $!;
+exit $status;
diff --git a/xCAT-test/native/ubuntu_subiquity_template.t b/xCAT-test/native/ubuntu_subiquity_template.t
new file mode 100644
index 000000000..b5b19978d
--- /dev/null
+++ b/xCAT-test/native/ubuntu_subiquity_template.t
@@ -0,0 +1,173 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use File::Copy qw(copy);
+use Capture::Tiny qw(capture);
+use File::Path qw(make_path);
+use File::Slurper qw(read_text write_text);
+use FindBin;
+use lib "$FindBin::Bin/../lib", "$FindBin::Bin/../../perl-xCAT", "$FindBin::Bin/../../xCAT-server/lib/perl";
+use JSON::PP qw(encode_json decode_json);
+use Test::More;
+use XCAT::Test::File qw(repo_path);
+use XCAT::Test::Sandbox qw(sandbox_root sandbox_run);
+use XCAT::Test::Template qw(template_database set_row render_template);
+
+plan skip_all => 'Subiquity command execution requires Linux' unless $^O eq 'linux';
+my $database = template_database();
+require xCAT::SvrUtils;
+set_row('noderes', {node => 'node'}, {xcatmaster => 'master'});
+write_text("$database/extra.pkglist", "htop\nwget\n");
+write_text("$database/image.pkglist", "#INCLUDE:$database/extra.pkglist#\nvim\n");
+
+sub load_yaml {
+ my ($file) = @_;
+ my ($stdout, $stderr, $status) = capture {
+ system('/usr/bin/python3', '-I', '-c',
+ 'import json, sys, yaml; json.dump(yaml.safe_load(open(sys.argv[1])), sys.stdout)', $file)
+ };
+ die "Cannot parse $file: $stderr" if $status;
+ return decode_json($stdout);
+}
+
+sub render {
+ my ($version, $arch, $nic, $primary) = @_;
+ set_row('nodetype', {node => 'node'}, {os => "ubuntu$version", arch => $arch, provmethod => 'image'});
+ set_row('noderes', {node => 'node'}, {installnic => $nic, primarynic => $primary});
+ my $template = xCAT::SvrUtils->get_tmpl_file_name(
+ repo_path('xCAT-server/share/xcat/install/ubuntu'), 'compute', "ubuntu$version", $arch, 'subiquity');
+ my $output = "$database/autoinstall.yaml";
+ render_template($database, $template, $output, 'node', "$database/image.pkglist",
+ "/install/ubuntu$version/$arch", 'ubuntu', undef, {xcatmaster => '127.0.0.1'}, osarch => $arch);
+ my $text = read_text($output);
+ like($text, qr/\A#cloud-config\n/, 'renders a cloud-config document');
+ unlike($text, qr/#(?:TABLE|CRYPT|SUBIQUITY|INCLUDE|XCATVAR)[^\n]*#/, 'resolves all template inputs');
+ return (load_yaml($output)->{autoinstall}, $text);
+}
+
+for my $release (qw(20.04 22.04 24.04 26.04)) {
+ subtest "Ubuntu $release data" => sub {
+ my ($config) = render($release, 'x86_64', '', '');
+ is($config->{version}, 1, 'uses autoinstall version 1');
+ is_deeply($config->{identity}, {
+ realname => 'xCAT Admin', username => 'xcatadm', hostname => 'node', password => '*',
+ }, 'supplies a noninteractive identity with a locked password');
+ is($config->{kernel}{package}, 'linux-generic', 'selects the generic kernel');
+ ok($config->{ssh}{'install-server'}, 'installs the SSH server');
+ ok(!$config->{'user-data'}{package_update} && !$config->{'user-data'}{package_upgrade}, 'leaves automatic package updates disabled');
+ is($config->{'user-data'}{timezone}, 'UTC', 'uses the site timezone');
+ my %packages;
+ $packages{$_}++ for @{$config->{packages}};
+ is($packages{$_}, 1, "installs $_ once") for qw(openssh-server wget htop vim);
+ ok(!$packages{'nfs-common'}, 'does not require NFS tools from the offline media');
+ ok(!$config->{apt}{geoip}, 'keeps mirror geolocation disabled');
+ is($config->{apt}{'mirror-selection'}{primary}[0]{uri}, 'http://archive.ubuntu.com/ubuntu', 'selects the x86 archive');
+ if ($release eq '20.04' || $release eq '22.04') {
+ is($config->{apt}{sources}{'xcat-ubuntu-archive.list'}{source},
+ 'deb http://archive.ubuntu.com/ubuntu $RELEASE main restricted universe multiverse',
+ 'defers the legacy apt suite to the installer release');
+ } else {
+ ok(!exists $config->{apt}{sources}{'xcat-ubuntu-archive.list'}, 'does not duplicate the Deb822 primary mirror');
+ }
+ };
+}
+
+my @cases = (
+ ['named', 'eno2', '', 'eno2', 0],
+ ['primary', '', 'ens3', 'ens3', 0],
+ ['mac', '', '', undef, 0],
+ ['literal-mac', '52:54:00:12:34:56', 'ens3', undef, 0],
+ ['unresolved', 'eno2', '', 'eno2', 0],
+ ['fail-disk', '', '', undef, 8],
+ ['empty-disk', '', '', undef, 1],
+ ['disk-failure', '', '', undef, 9],
+ ['fail-pre', '', '', undef, 8],
+ ['empty-pre', '', '', undef, 1],
+ ['pre-failure', '', '', undef, 7],
+ ['no-partition', '', '', undef, 1],
+ ['post-failure', '', '', undef, 42],
+);
+for my $case (@cases) {
+ my ($name, $nic, $primary, $rename, $expected_status) = @$case;
+ subtest "installer $name" => sub {
+ my ($config) = render('24.04', 'riscv64', $nic, $primary);
+ is($config->{apt}{'mirror-selection'}{primary}[0]{uri}, 'http://ports.ubuntu.com/ubuntu-ports', 'selects the RISC-V ports archive');
+ my $root = sandbox_root();
+ make_path(map { "$root/$_" } qw(payloads target/etc/default target/etc/apt/sources.list.d target/etc/apt/apt.conf.d target/root));
+ write_text("$root/commands.json", encode_json($config));
+ # Subiquity removes the cloud-config wrapper before executing early commands.
+ my ($rewritten, $stderr, $rewrite_status) = capture {
+ system('/usr/bin/python3', '-I', '-c',
+ 'import json, sys, yaml; yaml.safe_dump(json.load(open(sys.argv[1])), sys.stdout, explicit_end=True)',
+ "$root/commands.json")
+ };
+ die "Cannot serialize autoinstall config: $stderr" if $rewrite_status;
+ write_text("$root/autoinstall.yaml", $rewritten);
+ write_text("$root/etc/resolv.conf", "nameserver 192.0.2.53\n");
+ my $hosts = "127.0.0.1 localhost\n";
+ $hosts .= "127.0.1.1 oldname\n" if $name eq 'named';
+ write_text("$root/target/etc/hosts", $hosts);
+ write_text("$root/target/etc/default/grub", "GRUB_TIMEOUT=5\n");
+ my @temporary_sources = qw(xcat-otherpkgs-0.list xcat-otherpkgs-1.sources xcat-pkgdir-0.list xcat-pkgdir-1.sources);
+ write_text("$root/target/etc/apt/sources.list.d/$_", "temporary\n") for @temporary_sources;
+ write_text("$root/target/etc/apt/sources.list.d/admin.list", "preserved\n");
+ write_text("$root/target/etc/apt/apt.conf.d/94curtin-config", "temporary\n");
+ write_text("$root/payloads/getinstdisk", <<'SH');
+#!/bin/sh
+[ "$XCAT_TEST_SCENARIO" != disk-failure ] || exit 9
+printf /dev/vda >/tmp/xcat.install_disk
+SH
+ write_text("$root/payloads/node.pre", <<'SH');
+#!/bin/sh
+[ "$(cat /tmp/xcat.install_disk 2>/dev/null)" = /dev/vda ] || exit 3
+[ "$XCAT_TEST_SCENARIO" != pre-failure ] || exit 7
+printf ran >/fixture/pre-ran
+[ "$XCAT_TEST_SCENARIO" != no-partition ] || exit 0
+printf 'storage:\n layout:\n name: direct\n' >/tmp/partitionfile
+SH
+ write_text("$root/payloads/node.post", <<'SH');
+#!/bin/sh
+printf ran >/fixture/post-ran
+[ "$XCAT_TEST_SCENARIO" != post-failure ] || exit 42
+SH
+ for my $command (qw(ip getent wget curtin)) {
+ copy(repo_path('xCAT-test/fixtures/subiquity/command'), "$root/bin/$command") or die $!;
+ chmod 0755, "$root/bin/$command" or die $!;
+ }
+ copy(repo_path('xCAT-test/fixtures/subiquity/run.pl'), "$root/run.pl") or die $!;
+ my ($status, $output) = sandbox_run($root, {env => {XCAT_TEST_SCENARIO => $name}}, $^X, '/fixture/run.pl');
+ unless (is($status, $expected_status, 'the complete command sequence returns the expected status')) {
+ diag($output);
+ diag(read_text("$root/$_")) for grep { -f "$root/$_" } qw(tmp/pre-install.log target/var/log/xcat/xcat.log statuses.json);
+ return;
+ }
+ my $statuses = decode_json(read_text("$root/statuses.json"));
+ is($statuses->[-1][1], $expected_status, 'the result comes from an installer command');
+ if ($expected_status) {
+ ok(!-e "$root/monitor-request", 'does not advance the boot chain after failure');
+ is(read_text("$root/target/etc/apt/apt.conf.d/94curtin-config"), "temporary\n", 'does not run later cleanup after failure');
+ ok(!-e "$root/post-ran", 'does not run the postscript after early failure') unless $name eq 'post-failure';
+ return;
+ }
+ is(read_text("$root/monitor-request"), "next\n", 'advances through the real monitor handshake');
+ is(read_text("$root/monitor-status"), "0\n", 'completes the monitor exchange');
+ is(read_text("$root/pre-ran"), 'ran', 'executes the downloaded pre-script');
+ is(read_text("$root/post-ran"), 'ran', 'executes the downloaded postscript');
+ is(load_yaml("$root/final-autoinstall.yaml")->{storage}{layout}{name}, 'direct', 'injects the supplied storage layout');
+ is(read_text("$root/etc/resolv.conf"), ($name eq 'unresolved' ? "nameserver 192.0.2.53\n" : "nameserver 127.0.0.1\n") . "domain example.invalid\n", 'writes a numeric resolver or retains DHCP DNS');
+ my $network = load_yaml("$root/target/etc/netplan/00-xcat-install.yaml")->{network};
+ my %interface = (match => {macaddress => '52:54:00:12:34:56'}, dhcp4 => JSON::PP::true, 'dhcp4-overrides' => {'use-domains' => JSON::PP::true});
+ $interface{'set-name'} = $rename if defined $rename;
+ is_deeply($network, {version => 2, ethernets => {'xcat-install' => \%interface}}, 'writes the resolved interface and DHCP policy');
+ is((stat("$root/target/etc/netplan/00-xcat-install.yaml"))[2] & oct('0777'), oct('0600'), 'restricts netplan permissions');
+ is(read_text("$root/target/etc/hostname"), "node\n", 'writes the node hostname');
+ is(read_text("$root/target/etc/hosts"), "127.0.0.1 localhost\n127.0.1.1 node\n", 'replaces or appends the local hostname');
+ ok(-f "$root/target/etc/cloud/cloud-init.disabled", 'disables target cloud-init');
+ is(read_text("$root/grub-input"), "GRUB_TIMEOUT=5\nGRUB_CMDLINE_LINUX=\"console=ttyS0 quiet\"\n", 'passes the quoted kernel command line to update-grub2');
+ ok(!-e "$root/target/etc/apt/sources.list.d/$_", "removes $_ after the postscript") for @temporary_sources;
+ ok(!-e "$root/target/etc/apt/apt.conf.d/94curtin-config", 'removes installer apt settings');
+ is(read_text("$root/target/etc/apt/sources.list.d/admin.list"), "preserved\n", 'retains administrator sources');
+ };
+}
+
+done_testing();
diff --git a/xCAT-test/unit/ubuntu_subiquity_template.t b/xCAT-test/unit/ubuntu_subiquity_template.t
deleted file mode 100644
index 466e8ae21..000000000
--- a/xCAT-test/unit/ubuntu_subiquity_template.t
+++ /dev/null
@@ -1,120 +0,0 @@
-#!/usr/bin/env perl
-use strict;
-use warnings;
-use Test::More;
-
-my $tmpl_path = defined $ENV{XCATROOT} ? "$ENV{XCATROOT}/share/xcat/install/ubuntu/compute.subiquity.tmpl" : '';
-$tmpl_path = "xCAT-server/share/xcat/install/ubuntu/compute.subiquity.tmpl"
- unless -f $tmpl_path;
-
-plan skip_all => "compute.subiquity.tmpl not found" unless -f $tmpl_path;
-
-my $tmpl = do { local $/; open my $fh, '<', $tmpl_path or die $!; <$fh> };
-
-like($tmpl, qr/^#cloud-config/, 'template starts with #cloud-config');
-like($tmpl, qr/autoinstall:/, 'template has autoinstall: key');
-like($tmpl, qr/version:\s*1/, 'template has version: 1');
-
-like($tmpl, qr/^\s*identity:/m, 'template has an identity section so subiquity does not prompt');
-like($tmpl, qr/^ - #INCLUDE_DEFAULT_PKGLIST_AUTOINSTALL#\n/m, 'the packages list carries the osimage pkglist through the autoinstall token');
-like($tmpl, qr/^ - openssh-server\n/m, '... and keeps openssh-server, which xCAT needs on the node');
-like($tmpl, qr/^ - wget\n/m, '... and wget, which the early and late commands use');
-like($tmpl, qr/kernel:/, 'template has kernel section');
-like($tmpl, qr/package:\s*linux-generic/, 'template specifies linux-generic kernel');
-like($tmpl, qr/#UBUNTU_SUBIQUITY_APT_CONFIG#/, 'template renders apt section from osimage context');
-unlike($tmpl, qr/^\s*apt:/m, 'template does not carry a static apt section');
-unlike($tmpl, qr/WARN: no partitionfile/, 'template does not silently fall back when xCAT pre-script fails');
-unlike($tmpl, qr/INSTALL_DISK=""/, 'template does not guess an install disk in early-commands');
-unlike($tmpl, qr/geoip:\s*true/, 'template does not enable geoip');
-
-like($tmpl, qr/ssh:/, 'template has ssh section');
-like($tmpl, qr/install-server:\s*true/, 'template enables ssh install-server');
-
-unlike($tmpl, qr/package_update:\s*true/, 'template does not enable package_update');
-unlike($tmpl, qr/^\s*-\s+nfs-common\s*$/m, 'template does not require nfs-common from offline ISO packages');
-
-# YAML safety: use printf with single-quoted arguments instead of shell-specific
-# escape sequences. dash does not portably interpret printf \xNN.
-unlike($tmpl, qr/echo.*GRUB_CMDLINE.*\\"/, 'no escaped double quotes in echo GRUB line');
-unlike($tmpl, qr/\\\\x22/, 'template does not rely on non-portable printf hex escapes');
-like($tmpl, qr/printf ''%s\\n'' ''GRUB_CMDLINE_LINUX="#TABLEBLANKOKAY:bootparams:\$NODE:kcmdline#"''/, 'GRUB line uses portable printf quoting');
-like($tmpl, qr/\/target\/etc\/netplan\/00-xcat-install\.yaml/, 'template writes an xCAT-owned target netplan file');
-# Regression: an UNSET noderes.installnic must not fatally break Subiquity xnba generation, and
-# the interface must still be resolved in xCAT's order (installnic -> primarynic -> mac.mac). The
-# template read #TABLE:noderes:$NODE:installnic#, and Template.pm's tabdb raises "Unable to find
-# requested field from table " -> "Failed to generate xnba configurations"
-# when the node carries no installnic, so the Ubuntu diskful install never started. The resolution
-# now happens in Perl (xCAT::Template, via xCAT::NetworkUtils::gen_net_boot_params) and reaches the
-# template already resolved, so no part of that order is re-derived in shell. The resolution itself
-# and the netplan it produces are covered by ubuntu_subiquity_installnic.t.
-unlike($tmpl, qr/noderes:\$NODE:(?:installnic|primarynic)/,
- 'the template does not read installnic/primarynic itself (fatal when unset, and it would have to redo the fallback)');
-like($tmpl, qr/installnic="#SUBIQUITYINSTALLNIC#"/,
- 'the target netplan uses the interface name xCAT resolved for this node');
-like($tmpl, qr/installmac="#SUBIQUITYINSTALLMAC#"/,
- 'the target netplan uses the MAC address xCAT resolved for this node');
-like($tmpl, qr/if \[ -z "\$\{installnic\}" \]; then/,
- 'no resolved interface name means match by MAC alone, with no set-name rename');
-unlike($tmpl, qr/tr ''A-F'' ''a-f''/,
- 'the shell no longer normalizes the MAC (Template.pm resolves mac.mac entries)');
-unlike($tmpl, qr/cut -d''\|'' -f1/,
- 'the shell no longer splits mac.mac entries (Template.pm resolves them for this node)');
-like($tmpl, qr/printf ''%s\\n'' "network:" " version: 2" " ethernets:" " xcat-install:" " match:" " macaddress: \\"\$\{installmac\}\\"" " set-name: \$\{installnic\}" " dhcp4: true" " dhcp4-overrides:" " use-domains: true" >\/target\/etc\/netplan\/00-xcat-install\.yaml;/, 'target netplan printf stays on one shell line');
-like($tmpl, qr/" macaddress: \\"\$\{installmac\}\\""/, 'target netplan matches by MAC address');
-like($tmpl, qr/" set-name: \$\{installnic\}"/, 'target netplan sets the expected installnic name');
-like($tmpl, qr/"\s+dhcp4: true"/, 'target netplan enables DHCPv4 on installnic');
-like($tmpl, qr/"\s+use-domains: true"/, 'target netplan takes the search domain DHCP offers, so short names resolve on the node');
-like($tmpl, qr/printf ''%s\\n'' ''#HOSTNAME#'' >\/target\/etc\/hostname/, 'template writes target hostname before disabling cloud-init');
-like($tmpl, qr/sed -i ''s\/\^127\\\.0\\\.1\\\.1\.\*\/127\.0\.1\.1 #HOSTNAME#\/'' \/target\/etc\/hosts/, 'template updates target hosts entry for hostname');
-like($tmpl, qr/touch \/target\/etc\/cloud\/cloud-init\.disabled/, 'target cloud-init is disabled after target netplan is written');
-
-# Regression: downloaded files are required and checked with test -s before use.
-like($tmpl, qr/wget -T 30 -O \/tmp\/getinstdisk http:\/\/#XCATVAR:XCATMASTER#/, 'getinstdisk download is required');
-like($tmpl, qr/test -s \/tmp\/getinstdisk/, 'getinstdisk checked with -s not -x');
-like($tmpl, qr/wget -T 30 -O \/tmp\/pre\.sh http:\/\/#XCATVAR:XCATMASTER#/, 'pre.sh download is required');
-like($tmpl, qr/test -s \/tmp\/pre\.sh/, 'pre.sh checked with -s not -x');
-like($tmpl, qr/test -s \/tmp\/partitionfile/, 'partitionfile from pre-script is required');
-unlike($tmpl, qr/wget .*?\|\| true/, 'xCAT control artifact downloads are not masked');
-unlike($tmpl, qr/if \[ -x \/tmp\/getinstdisk \]/, 'getinstdisk not checked with -x');
-unlike($tmpl, qr/if \[ -x \/tmp\/pre\.sh \]/, 'pre.sh not checked with -x');
-
-# Regression: apt configuration is generated by Template.pm so release-specific
-# Subiquity behavior can be handled without cloning this template per release.
-unlike($tmpl, qr/noble-|jammy-|focal-/, 'template avoids release-specific apt suite names');
-like($tmpl, qr/#UBUNTU_SUBIQUITY_APT_CONFIG#/, 'template keeps dynamic apt renderer marker');
-like($tmpl, qr{2>&1'\n(?:\s*#[^\n]*\n)*\s*- rm -f (?:/target/etc/apt/sources\.list\.d/xcat-(?:otherpkgs|pkgdir)-\*\.(?:list|sources)\s+){4}/target/etc/apt/apt\.conf\.d/94curtin-config$}m,
- 'the installer sources for the otherpkgs repository and the pkgdir mirrors, and the apt configuration curtin wrote, are removed from the target by a late-command of their own, after the post script block');
-
-# the post script block: its status is the post script's, so a failed post script stops the install
-{
- my ($block) = $tmpl =~ /\n - '(\{\n.*?\n \} >>\/target\/var\/log\/xcat\/xcat\.log 2>&1)'\n/s;
- ok( defined $block, 'the post script block is found' ) or last;
- $block =~ s/''/'/g;
- require File::Temp;
- my $root = File::Temp->newdir();
- mkdir "$root/bin" or die;
- for my $tool ( 'curtin', 'wget' ) {
- open( my $fh, '>', "$root/bin/$tool" ) or die;
- print {$fh} $tool eq 'curtin' ? "#!/bin/sh\ncase \"\$*\" in *post.script*) exit 42;; esac\nexit 0\n" : "#!/bin/sh\nfor a; do case \"\$a\" in http*) touch \"\${a##*/}\";; esac; done\nexit 0\n";
- close $fh; chmod 0755, "$root/bin/$tool";
- }
- ( my $script = $block ) =~ s{/target}{$root/target}g;
- $script =~ s{/tmp/pre-install\.log}{$root/pre-install.log}g;
- for my $token ( [ '#SUBIQUITYINSTALLNIC#', '' ], [ '#SUBIQUITYINSTALLMAC#', '52:54:00:00:00:01' ], [ '#HOSTNAME#', 'cn1' ], [ '#XCATVAR:XCATMASTER#', '192.0.2.10' ],
- [ '#COLONHTTPPORT#', '' ], [ '#TABLEBLANKOKAY:bootparams:$NODE:kcmdline#', '' ] ) {
- $script =~ s/\Q$token->[0]\E/$token->[1]/g;
- }
- require File::Path;
- File::Path::make_path( map { "$root/target/$_" } qw(etc/default root var/log/xcat) );
- open( my $hosts, '>', "$root/target/etc/hosts" ) or die; print {$hosts} "127.0.0.1 localhost\n"; close $hosts;
- open( my $pre, '>', "$root/pre-install.log" ) or die; close $pre;
- my $cwd = File::Spec->rel2abs('.');
- chdir $root or die;
- local $ENV{PATH} = "$root/bin:$ENV{PATH}";
- system( 'sh', '-c', $script );
- my $status = $? >> 8;
- chdir $cwd or die;
- is( $status, 42, 'a failing post script fails the late-command block, so Subiquity stops the install instead of switching the node to disk boot' );
-}
-
-done_testing();
From c2d64d4e58d2d430da518343c6fd0d38fbaa145d 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, 9 Oct 2026 15:02:54 -0300
Subject: [PATCH 5/5] docs(test): describe profile artifact test prerequisites
List the native cases selected by CI, their dependencies and their isolation requirements. Distinguish rendered-artifact execution from operating-system and firmware boot qualification.
---
xCAT-test/README.md | 2 +-
xCAT-test/native/README.md | 36 +++++++++++++++++++++++++++++++++++-
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/xCAT-test/README.md b/xCAT-test/README.md
index 8582d7d73..ddbbe67d0 100644
--- a/xCAT-test/README.md
+++ b/xCAT-test/README.md
@@ -8,7 +8,7 @@ language:
| Perl unit tests | `xCAT-test/unit/*.t` | `prove -r xCAT-test/unit` |
| Shell unit tests | `xCAT-test/bats/*.bats` | `bats -r xCAT-test/bats` |
| Black-box service tests | `xCAT-test/blackboxtest/` | `python3 -m unittest discover -s tests`, then `blackboxtest run ` on a management node |
-| Native host tests | `xCAT-test/native/*.t`, `xCAT-test/native/*.bats` | Explicit lab runs; see [native/README.md](native/README.md) |
+| Native host tests | `xCAT-test/native/*.t`, `xCAT-test/native/*.bats` | Selected CI cases and explicit lab runs; see [native/README.md](native/README.md) |
| CLI functional tests | `xCAT-test/autotest/testcase/` and `xCAT-test/autotest/bundle/` | `xcattest -f -t ` or `xcattest -f -b ` |
Use Perl `.t` tests for Perl modules, Perl scripts, templates, and repository
diff --git a/xCAT-test/native/README.md b/xCAT-test/native/README.md
index f5d6f834c..95258f7a4 100644
--- a/xCAT-test/native/README.md
+++ b/xCAT-test/native/README.md
@@ -2,7 +2,8 @@
These tests use the source checkout with RPM tools, Linux namespaces or real
service binaries. They are separate from `unit/` and are not discovered by the
-default pull-request unit run. Run them explicitly on a disposable Linux host.
+unit runner. CI selects specific cases explicitly; run the others on a
+disposable Linux host.
The postinstall case requires bubblewrap and the Perl modules used by the xCAT
template renderer. It runs complete rendered installer and postboot scripts in
@@ -246,3 +247,36 @@ The unit test retains checks of the vendored release key, requested kernel
fragments and console service configuration. The BATS tests retain runtime,
console, hardware and packet behavior. None of these checks replaces a full
image build, inspection of its resolved kernel configuration or a boot test.
+
+## Profile artifacts
+
+These cases render install profiles, run netboot postscripts, build a Debian
+repository, and execute rendered Subiquity commands. Run them as an ordinary
+user with Bubblewrap and unprivileged user namespaces enabled:
+
+```
+prove xCAT-test/native/install_profile_riscv64.t \
+ xCAT-test/native/netboot_profile_riscv64.t \
+ xCAT-test/native/builddebs_riscv64.t \
+ xCAT-test/native/ubuntu_subiquity_template.t
+```
+
+The renderers need the system Perl with the xCAT dependencies, XML::Simple,
+DBD::SQLite, Capture::Tiny, and File::Slurper. The sandbox clears the environment
+and does not mount perlbrew or local::lib directories. The Subiquity case also
+needs /usr/bin/python3 with PyYAML; on EL8, install python36 for that path.
+The Debian case needs debhelper, devscripts, fakeroot, and reprepro, and skips
+on non-Debian hosts. Each case creates its own temporary files and database.
+The temporary directory must allow execution; set TMPDIR if /tmp is noexec.
+Sandbox commands require GNU timeout and have a 45-second limit.
+Pull-request CI runs these cases and rejects whole-file skips. The permissive
+SELinux case records an existing postinstall defect as TODO, without changing
+production behavior.
+
+The netboot case executes the selected postinstall file directly. Its private
+/bin/sh uses Bash, as on EL, including when the test host uses another shell.
+
+The Subiquity case replaces downloads and external commands, and uses a
+loopback install-monitor peer. It does not install an operating system.
+Architecture names in these fixtures do not qualify native firmware or boot
+behavior on those architectures.