2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-09-25 01:04:05 +00:00

Merge pull request #7835 from VersatusHPC/fix/ubuntu-genesis-native-sbuild

fix(xcat-core): the Ubuntu Genesis image is built from the build host kernel
This commit is contained in:
Daniel Hilst
2026-09-24 19:24:23 -03:00
committed by GitHub
11 changed files with 1136 additions and 403 deletions
+134
View File
@@ -36,6 +36,9 @@ our @EXPORT_OK = qw(
rewrite_file write_script read_line
buildinfo_text
targetarch_from_target
genesis_chroot_name genesis_target_arch genesis_build_plan
genesis_log_errors genesis_log_deny_rules deb_belongs_to_dist
genesis_dists genesis_dist_reason
);
# Both builders echo the commands they run under --verbose. Set once, after
@@ -690,4 +693,135 @@ sub targetarch_from_target {
return $parts[-1];
}
# ------------------------------------------------------------------ Genesis --
#
# The Genesis image carries the kernel and the kernel modules of the release it boots,
# because dracut copies them out of the root it runs in. So the Ubuntu Genesis deb is built
# once per Ubuntu codename, inside that codename's chroot. A single build on the build host
# gives every codename the build host's kernel.
# genesis_chroot_name: the schroot chroot that builds the Genesis deb for one codename.
#
# Same name xcat-dep's sbuild-all.pl ensure_chroots creates, so both repositories build in
# the same chroots and neither has to bootstrap a second set.
sub genesis_chroot_name {
my ($codename, $arch) = @_;
die "genesis_chroot_name: a codename is required\n"
unless defined $codename && length $codename;
die "genesis_chroot_name: an architecture is required\n"
unless defined $arch && length $arch;
return "$codename-$arch-sbuild";
}
# genesis_target_arch: the directory xCAT reads the Genesis image from, for a deb
# architecture. mknb reads /opt/xcat/share/xcat/netboot/genesis/<arch>, and that name is
# the rpm architecture, not the deb one.
my %GENESIS_TARGET_ARCH = (
amd64 => 'x86_64',
ppc64el => 'ppc64',
);
sub genesis_target_arch {
my ($arch) = @_;
my $target = $GENESIS_TARGET_ARCH{ $arch // '' };
die "genesis_target_arch: no Genesis image directory for '" . ($arch // '') . "'\n"
unless $target;
return $target;
}
# genesis_build_plan: one Genesis build per codename, for one architecture.
#
# The set of codenames comes from the caller, so a pipeline builds exactly the releases it
# publishes.
sub genesis_build_plan {
my ($dists, $arch) = @_;
my @dists = @{ $dists || [] };
die "genesis_build_plan: at least one codename is required\n" unless @dists;
die "genesis_build_plan: an architecture is required\n"
unless defined $arch && length $arch;
my %seen;
return map {
{
codename => $_,
arch => $arch,
chroot => genesis_chroot_name($_, $arch),
package => "xcat-genesis-base-$arch",
target => genesis_target_arch($arch),
}
} grep { !$seen{$_}++ } @dists;
}
# genesis_log_deny_rules / genesis_log_errors: what a Genesis build log says when the build
# failed but the exit status did not.
#
# dracut reports a command it cannot install with a FAILED: line and returns 0. apt has the
# same shape: a missing package leaves a diagnostic and a zero status behind a `|| true`. So
# the log is the gate, not the exit status.
my @GENESIS_LOG_DENY = (
[ qr/\bFAILED:/ => 'dracut could not install a command' ],
[ qr/Cannot find module/ => 'a kernel module the build names is absent' ],
[ qr/dracut: Cannot/ => 'dracut refused the request' ],
[ qr/command not found/ => 'the build root has no such command' ],
[ qr/E: Unable to locate package/ => 'apt has no such package' ],
[ qr/Unable to correct problems/ => 'apt could not resolve the build root' ],
);
sub genesis_log_deny_rules { return @GENESIS_LOG_DENY; }
# The package built once per codename. It is the only one: it carries the kernel and the kernel
# modules of the root that built it.
my $GENESIS_IMAGE_DEB = qr{\Axcat-genesis-base-};
# Releases whose stock chroot cannot build that package, and why. focal ships debhelper 12 and
# xCAT-genesis-base declares debhelper-compat (= 13), so sbuild stops on the build dependencies
# before dracut runs.
my %GENESIS_DIST_UNSUPPORTED = (
focal => 'debhelper 12 cannot satisfy debhelper-compat (= 13)',
);
# genesis_dists: the releases of @dists a Genesis image can be built on.
sub genesis_dists {
my (@dists) = @_;
return grep { !exists $GENESIS_DIST_UNSUPPORTED{$_} } @dists;
}
# genesis_dist_reason: why a release was left out, or undef.
sub genesis_dist_reason { return $GENESIS_DIST_UNSUPPORTED{ $_[0] // '' }; }
# deb_belongs_to_dist: whether a built .deb may be published into one release.
#
# Almost every xcat-core deb is Architecture:all and the same file serves every release, so the
# answer is yes. The Genesis image is not: it is built per codename and carries that codename in
# its version (2.19.0-snap...~noble). Publishing all three into every suite lets apt serve the
# newest, which is the image of another release.
#
# Only that package is asked. A ~ in a version is Debian's prerelease separator before it is
# anything else, and --release takes whatever the caller gives it, so reading every ~ as a
# codename drops a whole `--release 1~rc1` build from every suite.
sub deb_belongs_to_dist {
my ($deb, $dist) = @_;
return 1 unless defined $deb && defined $dist && $dist ne '';
my $base = basename($deb);
my ($name) = $base =~ /\A([^_]+)_/;
return 1 unless defined $name && $name =~ $GENESIS_IMAGE_DEB;
return 1 unless $base =~ /_[^_]*~([A-Za-z0-9.]+)_[^_]*\.deb\z/;
return $1 eq $dist ? 1 : 0;
}
sub genesis_log_errors {
my ($text) = @_;
return () unless defined $text && length $text;
my @found;
for my $line (split /\n/, $text) {
for my $rule (@GENESIS_LOG_DENY) {
my ($pattern, $why) = @{$rule};
next unless $line =~ $pattern;
push @found, { line => $line, why => $why };
last;
}
}
return @found;
}
1;
+202 -13
View File
@@ -8,9 +8,10 @@
# The central fact this design rests on: xcat-core debs are Perl. They are byte-identical
# for every Ubuntu release, so they are built ONCE and the same files are published into
# every codename. Only xCAT, xCATsn and xCAT-genesis-scripts carry an architecture, and
# even there the difference is packaging metadata, not compiled output. That is why this
# needs no sbuild and no per-codename chroot -- unlike xcat-dep, whose compiled packages
# genuinely differ per release.
# even there the difference is packaging metadata, not compiled output.
#
# The Genesis image is the one exception. --genesis builds it once per codename, inside
# that codename's sbuild chroot.
use strict;
use warnings;
use feature 'say';
@@ -38,6 +39,8 @@ use XCAT::BuildUtils qw(
reprepro_distributions reprepro_options
lock_id_for take_build_lock sh_quote
sh sh_or_die usage rewrite_file write_script read_line buildinfo_text
genesis_build_plan genesis_log_errors deb_belongs_to_dist
genesis_dists genesis_dist_reason
);
# The xcat-core packages that ship as debs. xCAT-openbmc-py, xCAT-rmc and xCAT-release
@@ -61,10 +64,14 @@ my @PACKAGES = qw(
my @DISTS = default_dists();
my %opts;
my (@cli_packages, @cli_dists);
my (@cli_packages, @cli_dists, @cli_genesis_dists);
GetOptions(
"dist=s@" => \@cli_dists,
"package=s@" => \@cli_packages,
"genesis" => \$opts{genesis},
"genesis-only" => \$opts{genesis_only},
"genesis-dist=s@" => \@cli_genesis_dists,
"genesis-arch=s" => \$opts{genesis_arch},
"dest=s" => \$opts{dest},
"builddir=s" => \$opts{builddir},
"release=s" => \$opts{release},
@@ -83,6 +90,23 @@ $opts{packages} = @cli_packages ? \@cli_packages : \@PACKAGES;
$opts{dists} = @cli_dists ? \@cli_dists : \@DISTS;
$opts{gpg_key_name} //= 'xCAT Signing Key';
# The Genesis step is off unless it is asked for, so today's runs keep their behaviour.
$opts{genesis} = 1 if $opts{genesis_only};
# A release named on the command line is built as asked. The default list is the one the rest
# of the build uses, and not every release on it can build the image, so those are dropped and
# named rather than failing the run on its first codename.
if (@cli_genesis_dists) {
$opts{genesis_dists} = \@cli_genesis_dists;
} else {
$opts{genesis_dists} = [ genesis_dists($opts{dists}->@*) ];
for my $dist ($opts{dists}->@*) {
my $why = genesis_dist_reason($dist) or next;
say "genesis: leaving out $dist -- $why";
}
}
die "FATAL: --genesis-dist needs --genesis\n" if @cli_genesis_dists && !$opts{genesis};
die "FATAL: --genesis-arch needs --genesis\n" if $opts{genesis_arch} && !$opts{genesis};
for my $pkg ($opts{packages}->@*) {
die "FATAL: unknown package '$pkg'. Known: @PACKAGES\n"
unless grep { $_ eq $pkg } @PACKAGES;
@@ -268,6 +292,132 @@ sub collect_debs {
return $moved;
}
# ----------------------------------------------------------- the Genesis deb --
#
# dracut copies the kernel, the kernel modules and every command out of the root it runs in,
# so the Genesis image belongs to the release that built it. One build on the build host
# serves every codename with the build host's kernel. This step builds one image per
# codename, inside that codename's sbuild chroot.
#
# The chroots are the ones xcat-dep's sbuild-all.pl creates on the Ubuntu build host
# (<codename>-<arch>-sbuild). A session is a disposable overlay, so the next codename starts
# from the pristine base.
# Where the build runs inside the chroot. A directory of its own at the chroot root, because
# every other candidate is shared: /build and /opt/xcat-ci-shared are bind mounts the sbuild
# chroots give to every session.
my $GENESIS_STAGE = '/xcat-genesis-build';
sub host_deb_arch {
my $arch = `dpkg --print-architecture 2>/dev/null` // '';
chomp $arch;
die "FATAL: dpkg does not report a host architecture\n" unless $arch;
return $arch;
}
# begin_chroot_session: start a disposable schroot session and return its id and its root.
sub begin_chroot_session {
my ($chroot) = @_;
my $id = `schroot --begin-session --chroot @{[ sh_quote($chroot) ]} 2>&1` // '';
my $rc = $? >> 8;
chomp $id;
die "FATAL: cannot start a session in chroot '$chroot' (exit $rc): $id\n"
. " sbuild-all.pl ensure_chroots creates it; run it on this host first.\n"
if $rc != 0 || $id !~ /\A\S+\z/;
my $root = `schroot --location -c @{[ sh_quote("session:$id") ]} 2>/dev/null` // '';
chomp $root;
unless ($root && -d $root) {
sh("schroot --end-session -c " . sh_quote("session:$id") . " >/dev/null 2>&1");
die "FATAL: schroot reports no location for session:$id\n";
}
return ($id, $root);
}
# genesis_build_log_problems: what the log says went wrong when the exit status did not.
#
# Report the first few offending lines only, so a build console stays readable. The message
# names the log file that has the rest.
sub genesis_build_log_problems {
my ($logfile) = @_;
my $text = -f $logfile ? read_text($logfile) : '';
my @errors = genesis_log_errors($text);
return '' unless @errors;
my $shown = @errors > 10 ? 10 : scalar @errors;
my $report = "FATAL: the Genesis build log reports " . scalar(@errors) . " error(s):\n";
$report .= " $_->{line}\n ($_->{why})\n" for @errors[0 .. $shown - 1];
$report .= " ... " . (@errors - $shown) . " more\n" if @errors > $shown;
$report .= " the whole log is at $logfile\n";
return $report;
}
sub build_one_genesis_deb {
my ($step, $pkgdir) = @_;
my ($codename, $chroot) = ($step->{codename}, $step->{chroot});
say "Building $step->{package} for $codename in $chroot";
my ($id, $root) = begin_chroot_session($chroot);
my $logfile = "$pkgdir/$step->{package}-$codename.buildlog";
my $err;
eval {
# Copy the builder in rather than bind-mount the checkout: the build rewrites
# debian/control and debian/changelog, and it must not rewrite them in the tree the
# pipeline builds from.
my $stage = "$root$GENESIS_STAGE";
sh_or_die("rm -rf " . sh_quote($stage) . " && mkdir -p "
. sh_quote("$stage/xCAT-genesis-builder"),
"FATAL: cannot make the build directory in session:$id\n");
sh_or_die("cp -a " . sh_quote("$ROOT/xCAT-genesis-builder") . "/. "
. sh_quote("$stage/xCAT-genesis-builder") . "/",
"FATAL: cannot copy xCAT-genesis-builder into session:$id\n");
copy("$ROOT/Version", "$stage/Version")
or die "FATAL: cannot copy Version into session:$id: $!\n";
write_text("$stage/Release", "$RELEASE\n");
# --expect-codename is the guard that keeps the image and the root together: the
# builder stops when the root it woke up in is not the release it was asked for.
my $cmd = join ' ',
'schroot', '--run-session', '-c', sh_quote("session:$id"), '-u', 'root', '-d', '/',
'--', '/bin/bash', "$GENESIS_STAGE/xCAT-genesis-builder/builddeb-genesis-base",
'--expect-codename', sh_quote($codename), '--outdir', "$GENESIS_STAGE/out";
my $rc = sh("$cmd > " . sh_quote($logfile) . " 2>&1");
# The log is read whether or not the command failed: dracut exits 0 with FAILED:
# lines in its log.
my $problems = genesis_build_log_problems($logfile);
if ($rc != 0) {
die "FATAL: the Genesis build for $codename failed (exit $rc); log: $logfile\n"
. $problems;
}
die $problems if $problems;
my @debs = glob("$root$GENESIS_STAGE/out/*.deb");
die "FATAL: the Genesis build for $codename produced no .deb; log: $logfile\n"
unless @debs;
for my $deb (@debs) {
my $dest = "$pkgdir/" . basename($deb);
copy($deb, $dest) or die "FATAL: cannot collect $deb: $!\n";
say " $dest";
}
1;
} or $err = $@;
sh("schroot --end-session -c " . sh_quote("session:$id") . " >/dev/null 2>&1");
die $err if $err;
return;
}
sub build_genesis_debs {
my ($pkgdir) = @_;
my $arch = $opts{genesis_arch} || host_deb_arch();
my @plan = genesis_build_plan($opts{genesis_dists}, $arch);
say "Genesis: @{[ scalar @plan ]} build(s) for $arch: "
. join(' ', map { $_->{codename} } @plan);
build_one_genesis_deb($_, $pkgdir) for @plan;
return scalar @plan;
}
# ------------------------------------------------------------- apt assembly --
sub gpg_key_id {
my ($name) = @_;
@@ -304,6 +454,9 @@ sub assemble_repo {
for my $deb (@debs) {
# A release that predates an architecture must not be handed its packages.
next if basename($deb) =~ /_(\w+)\.deb\z/ && $1 ne 'all' && !$ok{$1};
# Nor an image built for another release: the Genesis deb carries the codename it
# was built on, because it carries that release's kernel.
next unless deb_belongs_to_dist($deb, $dist);
sh_or_die("cd " . sh_quote($repodir) . " && reprepro -b ./ includedeb "
. sh_quote($dist) . ' ' . sh_quote($deb),
"FATAL: reprepro could not add $deb to $dist\n");
@@ -369,16 +522,25 @@ unlink glob("$ROOT/*.deb"), glob("$ROOT/*.buildinfo"), glob("$ROOT/*.changes"),
say "xcat-core $PKGVER -> $dest";
say "releases: @{[ join ' ', $opts{dists}->@* ]}";
for my $pkg ($opts{packages}->@*) {
for my $arch (deb_package_arches($pkg)) {
build_package($pkg, $arch, $pkgdir);
unless ($opts{genesis_only}) {
for my $pkg ($opts{packages}->@*) {
for my $arch (deb_package_arches($pkg)) {
build_package($pkg, $arch, $pkgdir);
}
collect_debs($pkg, $pkgdir);
}
collect_debs($pkg, $pkgdir);
}
my $count = assemble_repo($pkgdir, $repo);
write_repo_metadata($repo);
say "published $count package(s) into @{[ scalar $opts{dists}->@* ]} release(s) at $repo";
build_genesis_debs($pkgdir) if $opts{genesis};
if ($opts{genesis_only}) {
say "Genesis debs are in $pkgdir";
}
else {
my $count = assemble_repo($pkgdir, $repo);
write_repo_metadata($repo);
say "published $count package(s) into @{[ scalar $opts{dists}->@* ]} release(s) at $repo";
}
__END__
@@ -399,8 +561,15 @@ xcat-core packages are Perl. The same binary serves every Ubuntu release, so eac
package is built B<once> and the resulting C<.deb> files are published into every
codename the repository declares. Only C<xCAT>, C<xCATsn> and C<xCAT-genesis-scripts>
carry an architecture, and there the difference is packaging metadata rather than
compiled output. Consequently this builder needs no C<sbuild> and no per-codename
chroot. (xcat-dep is different: its packages are compiled, so it builds per codename.)
compiled output.
C<xcat-genesis-base> is the exception. dracut copies the kernel, the kernel modules
and every command out of the root it runs in, so the Genesis image belongs to the
release that built it. With C<--genesis> this builder makes one image per codename,
each inside that codename's C<< <codename>-<arch>-sbuild >> schroot -- the chroots
xcat-dep's C<sbuild-all.pl> creates on the Ubuntu build host. The build refuses to run
in a root of another release, and it reads its own log: dracut reports a command it
cannot install with a C<FAILED:> line and still exits 0.
Replaced C<build-ubunturepo>, removed in 2.19. The GSA upload paths, the C<PROMOTE>/C<PREGA> release
flows and the C<-d> xcat-dep repository mode were not carried over: publishing is done
@@ -418,6 +587,25 @@ Publish into this release. Repeatable. Defaults to focal, jammy, noble and resol
Build only this package. Repeatable. Defaults to every xcat-core deb package.
=item B<--genesis>
Also build C<xcat-genesis-base-E<lt>archE<gt>>, one C<.deb> per codename, each inside
that codename's schroot. Off by default.
=item B<--genesis-only>
Build the Genesis debs and nothing else, and assemble no repository. This is what
xcat-dep needs: it consumes the debs with C<sbuild-all.pl --genesis-deb>.
=item B<--genesis-dist>=I<CODENAME>
Build the Genesis image for this release. Repeatable. Defaults to the C<--dist> list.
=item B<--genesis-arch>=I<ARCH>
Build the Genesis image for this Debian architecture. Defaults to the architecture of
the build host, because the chroot has to match it.
=item B<--dest>=I<DIR>
Write the build under this directory: packages in C<debs/>, the apt repository in
@@ -459,5 +647,6 @@ This message.
./builddebs.pl
./builddebs.pl --dist noble --package perl-xCAT
./builddebs.pl --dest /srv/out --gpg-sign --gpg-home /keys/xcat-gpg-home
./builddebs.pl --genesis-only --genesis-dist jammy --genesis-dist noble --dest /srv/out
=cut
+3
View File
@@ -349,6 +349,9 @@ sub buildsources_genesis_base($) {
# %install runs this against the extracted payload before it becomes an rpm.
cp "xCAT-genesis-builder/verify-genesis-payload",
"$staging_root/verify-genesis-payload";
make_path("$staging_root/lib/XCAT");
cp "xCAT-genesis-builder/lib/XCAT/GenesisPayload.pm",
"$staging_root/lib/XCAT/GenesisPayload.pm";
unlink $support_tarball if -f $support_tarball;
sh_or_die(qq(tar --sort=name --owner=0 --group=0 --mtime="\@$SOURCE_DATE_EPOCH" -cjf "$support_tarball" -C "$staging_parent" xCAT-genesis-base-build-support),
@@ -63,6 +63,20 @@ ppc64el, and every release the repository serves declares the architecture;
``xCAT-genesis-scripts`` keeps the two architectures it has control files for,
because riscv64 Genesis ships as an OpenEmbedded package instead.
The Genesis image is the exception, and it is off unless it is asked for::
./builddebs.pl --genesis-only --genesis-dist jammy --genesis-dist noble
``dracut`` copies the kernel, the kernel modules and every command out of the root
it runs in, so the image belongs to the release that built it. ``--genesis`` builds
one image per ``--genesis-dist`` codename inside that codename's
``<codename>-<arch>-sbuild`` schroot -- the chroots xcat-dep's ``sbuild-all.pl``
creates on the Ubuntu build host -- for the architecture of the build host. It
refuses to run in a root of another release, it fails the build on an error in the
log even when the exit status is 0, and it checks the extracted payload against the
commands the dracut module installs. ``--genesis-only`` builds the images and
nothing else, which is what xcat-dep consumes with ``--genesis-deb``.
Helpers shared by both builders live in ``build-utils/lib/XCAT/BuildUtils.pm``.
``buildcore.sh`` builds the architecture specific packages (``xCAT``, ``xCATsn``,
+107 -42
View File
@@ -1,13 +1,35 @@
#!/bin/bash
# Build xcat-genesis-base .deb package natively on Ubuntu.
# Must run as root on an Ubuntu system (22.04, 24.04, or 26.04).
# Parallel to buildrpm for EL targets.
# Build the xcat-genesis-base .deb for ONE Ubuntu codename.
#
# dracut copies the kernel, the kernel modules and every command out of the root it runs
# in, so this must run inside a root of the target codename. builddebs.pl --genesis starts
# it in that codename's <codename>-<arch>-sbuild chroot, one build per codename.
# --expect-codename is what stops a run on the build host: a single build there gives every
# Ubuntu release the build host's kernel.
#
# Parallel to xCAT-genesis-base.spec, which does the same for EL.
set -euo pipefail
DIR=$(readlink -f "$(dirname "$0")")
OS_RELEASE=${OS_RELEASE:-/etc/os-release}
expect_codename=""
outdir=""
while [ $# -gt 0 ]; do
case "$1" in
--expect-codename) expect_codename=${2:-}; shift 2 ;;
--expect-codename=*) expect_codename=${1#*=}; shift ;;
--outdir) outdir=${2:-}; shift 2 ;;
--outdir=*) outdir=${1#*=}; shift ;;
-h|--help)
echo "usage: builddeb-genesis-base [--expect-codename <codename>] [--outdir <dir>]"
exit 0 ;;
*) echo "ERROR: unknown argument: $1" >&2; exit 2 ;;
esac
done
BUILDARCH=$(dpkg --print-architecture)
TRIPLET=$(dpkg-architecture -qDEB_HOST_MULTIARCH)
case "$BUILDARCH" in
amd64) TARCH=x86_64 ;;
@@ -31,44 +53,45 @@ rewrite_control() {
VERSION=$(cat "$DIR/../Version" 2>/dev/null || echo "2.18.0")
RELEASE=$(cat "$DIR/../Release" 2>/dev/null || echo "snap$(date +%Y%m%d%H%M)")
CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME")
CODENAME=$(. "$OS_RELEASE" && echo "${VERSION_CODENAME:-}")
if [ -z "$CODENAME" ]; then
echo "ERROR: $OS_RELEASE names no VERSION_CODENAME" >&2
exit 1
fi
# The image is only valid for the release whose kernel it carries. Refuse to build a
# codename's image anywhere but in that codename's root.
if [ -n "$expect_codename" ] && [ "$expect_codename" != "$CODENAME" ]; then
echo "ERROR: this root is $CODENAME, not $expect_codename." >&2
echo " The image would carry the $CODENAME kernel and boot under $expect_codename." >&2
exit 1
fi
echo "Building xcat-genesis-base for $BUILDARCH ($TARCH) on Ubuntu $CODENAME"
export DEBIAN_FRONTEND=noninteractive
REQUIRED_PACKAGES="
dracut linux-image-generic
ipmitool lldpad ethtool iproute2 kexec-tools screen
openssh-server openssh-client rsyslog chrony
nfs-common rpcbind pciutils usbutils parted
dosfstools e2fsprogs lvm2 mdadm net-tools
bc psmisc rsync wget cpio
isc-dhcp-client ifenslave
dpkg-dev debhelper fakeroot devscripts vim-tiny
"
if [ "$BUILDARCH" = "amd64" ]; then
REQUIRED_PACKAGES="$REQUIRED_PACKAGES dmidecode efibootmgr"
fi
# Keep only the packages this release actually has. hwclock moved out of util-linux into
# util-linux-extra, so focal and jammy have no such package and naming it there fails the
# whole install. util-linux only Suggests it, and --no-install-recommends is passed, so the
# releases that did split it must name it.
optional_packages() {
local package
for package in "$@"; do
if apt-cache show "$package" >/dev/null 2>&1; then
echo "$package"
fi
done
}
echo "Installing build dependencies..."
apt-get update -qq
REQUIRED_PACKAGES="$REQUIRED_PACKAGES $(optional_packages util-linux-extra)"
# XCAT::GenesisBuildRoot holds the package list, so the unit tests can call it.
REQUIRED_PACKAGES=$(perl -I"$DIR/lib" -MXCAT::GenesisBuildRoot=required_packages \
-e 'print "$_\n" for required_packages(@ARGV)' "$BUILDARCH" "$CODENAME")
apt-get install -y --no-install-recommends $REQUIRED_PACKAGES
# dpkg-architecture comes from dpkg-dev, which the line above installs.
TRIPLET=$(dpkg-architecture -qDEB_HOST_MULTIARCH)
# ncurses-base put its terminfo entries under /lib/terminfo before 24.04, and both dracut's own
# terminfo module and the 97xcat module ask for /usr/share/terminfo. On jammy the build stopped
# on /usr/share/terminfo/l/linux. Give this build root the path they ask for; the chroot is
# thrown away when the build ends.
if [ -d /lib/terminfo ]; then
mkdir -p /usr/share/terminfo
cp -an /lib/terminfo/. /usr/share/terminfo/ 2>/dev/null || true
fi
# Set up dracut module
if [ -d /usr/lib/dracut/modules.d ]; then
DRACUT_PARENT=/usr/lib/dracut/modules.d
@@ -100,6 +123,8 @@ if [ "$BUILDARCH" != "amd64" ]; then
sed -i '/efibootmgr dmidecode/d' "$DRACUTMODDIR/module-setup.sh"
fi
# linux-image-generic pulls exactly one kernel into this root, and this root belongs to
# $CODENAME, so this is the target release's kernel.
KERNELVERSION=$(ls -1 /lib/modules | sort -V | tail -n 1)
if [ -z "$KERNELVERSION" ]; then
echo "ERROR: no kernel modules found in /lib/modules" >&2
@@ -152,24 +177,53 @@ if [ ! -e "$KERNEL_IMAGE" ]; then
for candidate in \
"/boot/vmlinux-$KERNELVERSION" \
"/usr/lib/modules/$KERNELVERSION/vmlinuz" \
"/lib/modules/$KERNELVERSION/vmlinuz" \
"$(find /usr/lib/modules/"$KERNELVERSION" -maxdepth 2 -name 'vmlinuz*' -o -name 'vmlinux*' 2>/dev/null | head -n 1)" \
"$(find /lib/modules/"$KERNELVERSION" -maxdepth 2 -name 'vmlinuz*' -o -name 'vmlinux*' 2>/dev/null | head -n 1)" \
"$(ls -1 /boot/vmlinuz-* /boot/vmlinux-* 2>/dev/null | sort -V | tail -n 1)"
"/lib/modules/$KERNELVERSION/vmlinuz"
do
if [ -n "$candidate" ] && [ -e "$candidate" ]; then
if [ -e "$candidate" ]; then
KERNEL_IMAGE="$candidate"
break
fi
done
fi
if [ ! -e "$KERNEL_IMAGE" ]; then
echo "ERROR: cannot find kernel image" >&2
echo "ERROR: cannot find the image of kernel $KERNELVERSION" >&2
exit 1
fi
echo "Adding kernel $KERNEL_IMAGE"
cp "$KERNEL_IMAGE" "$GENESIS_ROOT/kernel"
# dracut_install reports a missing command and returns, so a hole reaches the .deb with
# nothing in the log but one line. Read the commands back from the module and check them
# against the payload, the way xCAT-genesis-base.spec does for EL.
bash "$DIR/verify-genesis-payload" --commands-from "$DRACUTMODDIR/module-setup.sh" "$GENESIS_FS" \
usr/sbin/dhclient bin/sh sbin/xcatroot sbin/dhclient-script etc/rsyslog.conf
# What the verifier cannot know: that this image belongs to this chroot's kernel. An image
# built against another root's /lib/modules boots and then finds no driver for its NIC.
if [ ! -d "$GENESIS_FS/lib/modules/$KERNELVERSION" ]; then
echo "ERROR: the image carries no /lib/modules/$KERNELVERSION" >&2
ls -1 "$GENESIS_FS/lib/modules" 2>/dev/null >&2 || true
exit 1
fi
if [ -z "$(find "$GENESIS_FS/lib/modules/$KERNELVERSION" -name '*.ko*' -print -quit)" ]; then
echo "ERROR: /lib/modules/$KERNELVERSION in the image holds no kernel module" >&2
exit 1
fi
for stray in "$GENESIS_FS"/lib/modules/*; do
[ -d "$stray" ] || continue
if [ "$(basename "$stray")" != "$KERNELVERSION" ]; then
echo "ERROR: the image carries $(basename "$stray"), not the $KERNELVERSION of this root" >&2
exit 1
fi
done
# The 97xcat hooks are what makes the image xCAT's. dracut drops a module whose check()
# fails without a word, and the image then boots to a plain dracut shell.
if [ -z "$(find "$GENESIS_FS" -path '*/hooks/cmdline/*xcat-cmdline.sh' -print -quit)" ]; then
echo "ERROR: the image carries no 97xcat cmdline hook" >&2
exit 1
fi
find "$GENESIS_TMPDIR" -type c -delete
# Stage for dpkg-buildpackage
@@ -178,6 +232,11 @@ cp -a "$GENESIS_TMPDIR/opt" "$DIR/"
# Adjust control file for target arch
rewrite_control "$DIR/debian/control" "$BUILDARCH"
# debian/dirs names the image directory, which is the rpm architecture.
echo "/opt/xcat/share/xcat/netboot/genesis/$TARCH/" > "$DIR/debian/dirs"
# dch reads debian/control from the current directory, not from the file it writes.
cd "$DIR"
PKG_VERSION="${VERSION}-${RELEASE}~${CODENAME}"
rm -f "$DIR/debian/changelog"
@@ -187,8 +246,14 @@ dch --create --package "xcat-genesis-base-$BUILDARCH" \
"Native Ubuntu build on $CODENAME $BUILDARCH"
echo "Building .deb package..."
cd "$DIR"
dpkg-buildpackage -rfakeroot -uc -us -b
echo "Build complete. .deb files:"
ls -la "$DIR/../"xcat-genesis-base*.deb 2>/dev/null || echo "Check parent directory for .deb files"
if [ -n "$outdir" ]; then
mkdir -p "$outdir"
mv "$DIR/../"xcat-genesis-base-*_*.deb "$outdir/"
echo "Build complete. .deb files in $outdir:"
ls -la "$outdir"
else
echo "Build complete. .deb files:"
ls -la "$DIR/../"xcat-genesis-base*.deb 2>/dev/null || echo "Check parent directory for .deb files"
fi
@@ -0,0 +1,88 @@
package XCAT::GenesisBuildRoot;
# builddeb-genesis-base runs this module inside an Ubuntu build root, where only perl-base is
# installed. Use core modules only.
use strict;
use warnings;
use Exporter 'import';
our @EXPORT_OK = qw(required_packages apt_carries);
my @BASE_PACKAGES = qw(
dracut linux-image-generic
ipmitool lldpad ethtool iproute2 kexec-tools screen
openssh-server openssh-client rsyslog chrony
nfs-common rpcbind pciutils usbutils parted
dosfstools e2fsprogs lvm2 mdadm net-tools
bc psmisc rsync wget cpio
isc-dhcp-client ifenslave
systemd-sysv hwdata btrfs-progs netcat-openbsd iputils-ping fdisk ncurses-term
dpkg-dev debhelper fakeroot devscripts vim-tiny
);
# Two commands the image needs changed package between releases: nslookup left dnsutils for
# bind9-dnsutils in 22.04, and hwclock left util-linux for util-linux-extra in 23.04.
my @RENAMED_PACKAGES = ([qw(bind9-dnsutils dnsutils)], [qw(util-linux-extra util-linux)]);
#-------------------------------------------------------------------------------
=head3 apt_carries
Descriptions: ask apt-cache whether the release carries a package.
Arguments:
$package: the package name
Returns:
1 when apt-cache knows the package, 0 when it does not
=cut
#-------------------------------------------------------------------------------
sub apt_carries {
my ($package) = @_;
return system('sh', '-c', 'apt-cache show "$1" >/dev/null 2>&1', 'sh', $package) == 0
? 1 : 0;
}
#-------------------------------------------------------------------------------
=head3 required_packages
Descriptions: list the packages the Genesis build root needs.
For a renamed package, apt says which name the release carries, so the list does
not branch on the codename. 26.04 moved the backward-compatibility zone names that
the dracut module installs out of tzdata into tzdata-legacy.
Arguments:
$arch: the dpkg architecture (amd64, ppc64el)
$codename: the release name, used in the error message
$carries: optional code ref. It takes a package name and returns true when the
release carries that package. The default is apt_carries.
Returns:
the package names, in install order.
Dies with "ERROR: <codename> carries none of these packages: ..." when the release
carries neither name of a renamed package.
=cut
#-------------------------------------------------------------------------------
sub required_packages {
my ($arch, $codename, $carries) = @_;
$carries ||= \&apt_carries;
my @packages = @BASE_PACKAGES;
push @packages, qw(dmidecode efibootmgr) if $arch eq 'amd64';
RENAMED:
for my $alternatives (@RENAMED_PACKAGES) {
for my $package (@$alternatives) {
if ($carries->($package)) {
push @packages, $package;
next RENAMED;
}
}
die "ERROR: $codename carries none of these packages: @$alternatives\n";
}
push @packages, 'tzdata-legacy' if $carries->('tzdata-legacy');
return @packages;
}
1;
@@ -0,0 +1,196 @@
package XCAT::GenesisPayload;
# verify-genesis-payload runs this module in the rpm %install of xCAT-genesis-base and inside
# the Ubuntu build root, where only perl-base is installed. Use core modules only.
use strict;
use warnings;
use Exporter 'import';
our @EXPORT_OK = qw(module_commands missing_paths check_payload main);
my $ME = 'verify-genesis-payload';
#-------------------------------------------------------------------------------
=head3 module_commands
Descriptions: read back the names a dracut module installs.
Only the top level of install() counts. A name under a condition is
release-dependent, so the caller names it as a required path instead.
An option to dracut_install (a word that starts with "-") is not a name.
Arguments:
$module_setup: the path of the module-setup.sh
Returns:
the names, sorted, each one once. A name that starts with "/" is an
absolute path; any other name is a command.
Dies with "verify-genesis-payload: cannot read <file>" when the file
cannot be read, and with "verify-genesis-payload: no command name read
from <file>" when install() names nothing.
=cut
#-------------------------------------------------------------------------------
sub module_commands {
my ($module_setup) = @_;
open(my $fh, '<', $module_setup)
or die "$ME: cannot read $module_setup\n";
my ($in_install, %names);
while (my $line = <$fh>) {
if ($line =~ /^install\(\)/) { $in_install = 1; next }
$in_install = 0 if $in_install && $line =~ /^}/;
next unless $in_install && $line =~ s/^ dracut_install //;
$line =~ s/#.*//s;
$names{$_} = 1 for grep { length && !/^-/ } split /\s+/, $line;
}
close($fh);
die "$ME: no command name read from $module_setup\n" unless %names;
return sort keys %names;
}
#-------------------------------------------------------------------------------
=head3 missing_paths
Descriptions: list what a Genesis payload lacks.
dracut_install reports a missing binary and returns, so the image can
ship without it. This check runs on the extracted payload before it is
packaged.
Arguments:
$have: code ref. It takes a path relative to the payload root and
returns true when the payload carries it.
%opt:
required: paths relative to the payload root that the caller needs
commands: names from module_commands. A bare command is looked
for in bin, sbin, usr/bin and usr/sbin; an absolute
path is looked for under the payload root.
source: the module the commands came from, for the message
sshd: the content of usr/sbin/sshd, or undef without one
Returns:
one "<path> (<reason>)" string per missing item, in the order checked.
An empty list means the payload is complete.
=cut
#-------------------------------------------------------------------------------
sub missing_paths {
my ($have, %opt) = @_;
my @missing;
for my $path (@{ $opt{required} || [] }) {
push @missing, "$path (required by the build)" unless $have->($path);
}
for my $want (@{ $opt{commands} || [] }) {
my $found =
$want =~ m{^/(.*)}
? $have->($1)
: scalar(grep { $have->("$_/$want") } qw(bin sbin usr/bin usr/sbin));
push @missing, "$want (installed by $opt{source})" unless $found;
}
push @missing, "usr/sbin/sshd (Genesis is reached over ssh)"
unless $have->('usr/sbin/sshd');
push @missing, "usr/bin/mktemp (getdestiny makes its request file with it)"
unless $have->('usr/bin/mktemp');
# OpenSSH 9.8 split the per-connection work into sshd-session, which sshd execs by
# absolute path. EL9 carries OpenSSH 9.9, so an image with sshd alone refuses every
# connection.
if (index($opt{sshd} // '', 'sshd-session') >= 0
&& !$have->('usr/libexec/openssh/sshd-session')
&& !$have->('usr/lib/openssh/sshd-session'))
{
push @missing,
"usr/libexec/openssh/sshd-session (this sshd execs it for every connection)";
}
# tmux exits under the C locale. The hook then runs doxcat directly, but a Genesis
# shell without tmux loses the console attach.
if ($have->('usr/bin/tmux') && !$have->('usr/lib/locale/C.utf8/LC_CTYPE')) {
push @missing,
"usr/lib/locale/C.utf8/LC_CTYPE (tmux refuses to start without a UTF-8 locale)";
}
return @missing;
}
#-------------------------------------------------------------------------------
=head3 check_payload
Descriptions: the command line of verify-genesis-payload, without the output:
[--commands-from <module-setup.sh>] <payload-root> [required-path ...]
Arguments:
@args: the command line
Returns:
($status, $message). $status is the exit status: 0 when the payload is
complete, 1 when it lacks something, 2 on a usage error. $message is
the text to print, one line or a list of missing items.
=cut
#-------------------------------------------------------------------------------
sub check_payload {
my @args = @_;
my $commands_from = '';
while (@args) {
if ($args[0] eq '--commands-from') {
shift @args;
$commands_from = shift(@args) // '';
} elsif ($args[0] =~ /^--commands-from=(.*)/s) {
$commands_from = $1;
shift @args;
} else {
last;
}
}
my $payload = shift(@args) // '';
if ($payload eq '' || !-d $payload) {
return (2, "$ME: not a payload directory: " . ($payload eq '' ? '<empty>' : $payload) . "\n");
}
my @commands;
if ($commands_from ne '') {
@commands = eval { module_commands($commands_from) };
return (2, $@) if $@;
}
my $sshd;
if (open(my $fh, '<:raw', "$payload/usr/sbin/sshd")) {
local $/;
$sshd = <$fh> // '';
close($fh);
}
my @missing = missing_paths(sub { -e "$payload/$_[0]" },
required => \@args,
commands => \@commands,
source => $commands_from,
sshd => $sshd);
return (1, "$ME: $payload is incomplete:" . join('', map { "\n $_" } @missing) . "\n")
if @missing;
return (0, "$ME: $payload is complete\n");
}
#-------------------------------------------------------------------------------
=head3 main
Descriptions: run check_payload and print its message: on STDOUT when the
payload is complete, on STDERR otherwise.
Arguments:
@args: the command line
Returns:
the exit status from check_payload
=cut
#-------------------------------------------------------------------------------
sub main {
my ($status, $message) = check_payload(@_);
print { $status ? *STDERR : *STDOUT } $message;
return $status;
}
1;
+5 -111
View File
@@ -6,115 +6,9 @@
# going and the image ships without it. Check the extracted payload before it is packaged.
#
# Paths given on the command line are relative to <payload-root>. --commands-from reads back
# what the dracut module installs: a bare command name is looked for in the four binary
# directories, an absolute path under <payload-root> itself. The caller adds what only it
# knows (the DHCP client is not the same package on every release); the rules below come from
# the payload itself.
# what the dracut module installs. XCAT::GenesisPayload, in lib/ beside this script, holds
# the rules, so the unit tests call the same code.
#
# Exit status: 0 complete, 1 something missing, 2 usage error.
set -u
commands_from=""
while [ $# -gt 0 ]; do
case "$1" in
--commands-from)
commands_from=${2:-}
shift 2 || true
;;
--commands-from=*)
commands_from=${1#*=}
shift
;;
*)
break
;;
esac
done
payload=${1:-}
if [ -z "$payload" ] || [ ! -d "$payload" ]; then
echo "verify-genesis-payload: not a payload directory: ${payload:-<empty>}" >&2
exit 2
fi
shift
missing=""
# have PATH: true when the payload carries PATH as a file, following the usr-merge symlinks
# the image ships (/sbin -> usr/sbin).
have() {
[ -e "$payload/$1" ]
}
require() {
local path=$1 why=$2
have "$path" || missing="$missing
$path ($why)"
}
for path in "$@"; do
require "$path" "required by the build"
done
# The dracut module names every command and every data file Genesis needs. A name the build
# root does not supply installs nothing and says nothing, so read the names back and check
# each one. Names under a condition are release-dependent, so only the top level of install()
# counts.
if [ -n "$commands_from" ]; then
if [ ! -r "$commands_from" ]; then
echo "verify-genesis-payload: cannot read $commands_from" >&2
exit 2
fi
commands=$(awk '
/^install\(\)/ { in_install = 1; next }
in_install && /^}/ { in_install = 0 }
in_install && /^ dracut_install / {
sub(/#.*/, "")
sub(/^ dracut_install /, "")
print
}' "$commands_from" | tr ' \t' '\n\n' | grep -v '^$' | grep -v '^-' | sort -u)
if [ -z "$commands" ]; then
echo "verify-genesis-payload: no command name read from $commands_from" >&2
exit 2
fi
for want in $commands; do
case "$want" in
# dracut_install installs an absolute path at that same path, so read it back
# under the payload root. Dropping these let an image with no /usr/bin/awk pass.
/*) have "${want#/}" || missing="$missing
$want (installed by $commands_from)"
;;
*) have "bin/$want" || have "sbin/$want" \
|| have "usr/bin/$want" || have "usr/sbin/$want" \
|| missing="$missing
$want (installed by $commands_from)"
;;
esac
done
fi
require usr/sbin/sshd "Genesis is reached over ssh"
require usr/bin/mktemp "getdestiny makes its request file with it"
# OpenSSH 9.8 split the per-connection work into sshd-session, which sshd execs by absolute
# path. EL9 carries OpenSSH 9.9, so an image with sshd alone refuses every connection.
if have usr/sbin/sshd && grep -qa 'sshd-session' "$payload/usr/sbin/sshd" 2>/dev/null; then
if ! have usr/libexec/openssh/sshd-session && ! have usr/lib/openssh/sshd-session; then
missing="$missing
usr/libexec/openssh/sshd-session (this sshd execs it for every connection)"
fi
fi
# tmux exits under the C locale. The hook falls back to running doxcat directly, so this is
# not fatal to booting, but a Genesis shell without tmux loses the console attach.
if have usr/bin/tmux && ! have usr/lib/locale/C.utf8/LC_CTYPE; then
missing="$missing
usr/lib/locale/C.utf8/LC_CTYPE (tmux refuses to start without a UTF-8 locale)"
fi
if [ -n "$missing" ]; then
echo "verify-genesis-payload: $payload is incomplete:$missing" >&2
exit 1
fi
echo "verify-genesis-payload: $payload is complete"
exit 0
exec perl -I"$(dirname "$0")/lib" -MXCAT::GenesisPayload=main -e 'exit main(@ARGV)' -- "$@"
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env perl
# The Genesis image carries the kernel and the kernel modules of the root that built it, so
# builddebs.pl --genesis builds one image per codename, in that codename's chroot. Each
# assertion here reads the value the code returns.
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 lib "$FindBin::Bin/../../build-utils/lib";
use Test::More;
use XCAT::Test::File qw(repo_path);
require XCAT::BuildUtils;
my @WANTED = qw(genesis_chroot_name genesis_target_arch genesis_build_plan genesis_log_errors);
for my $sub (@WANTED) {
ok(XCAT::BuildUtils->can($sub), "XCAT::BuildUtils provides $sub");
}
unless (scalar(grep { XCAT::BuildUtils->can($_) } @WANTED) == scalar @WANTED) {
diag('builddebs.pl has no Genesis step: the per-codename build does not exist yet');
done_testing();
exit;
}
# --- one build per codename, in that codename's chroot ---------------------------------
my @dists = qw(jammy noble resolute);
my @plan = XCAT::BuildUtils::genesis_build_plan(\@dists, 'amd64');
is(scalar @plan, scalar @dists, 'one Genesis build per codename');
is_deeply([ map { $_->{codename} } @plan ], \@dists, 'the plan keeps the codename order');
is_deeply([ map { $_->{chroot} } @plan ],
[ 'jammy-amd64-sbuild', 'noble-amd64-sbuild', 'resolute-amd64-sbuild' ],
'each codename builds in its own sbuild chroot');
is_deeply([ map { $_->{package} } @plan ],
[ ('xcat-genesis-base-amd64') x 3 ],
'every codename produces the same package name');
my @ppc = XCAT::BuildUtils::genesis_build_plan(['noble'], 'ppc64el');
is($ppc[0]{chroot}, 'noble-ppc64el-sbuild', 'the architecture selects the chroot');
is($ppc[0]{package}, 'xcat-genesis-base-ppc64el', 'the architecture is in the package name');
is($ppc[0]{target}, 'ppc64', 'ppc64el reads its image from the ppc64 directory');
is(XCAT::BuildUtils::genesis_target_arch('amd64'), 'x86_64',
'amd64 reads its image from the x86_64 directory');
is(scalar(() = XCAT::BuildUtils::genesis_build_plan([qw(noble noble)], 'amd64')), 1,
'a repeated codename does not build twice');
ok(!eval { XCAT::BuildUtils::genesis_build_plan([], 'amd64'); 1 },
'a plan with no codename is an error');
ok(!eval { XCAT::BuildUtils::genesis_target_arch('riscv64'); 1 },
'an architecture with no Genesis image directory is an error');
# --- a per-codename image reaches only its own suite --------------------------------------
ok(XCAT::BuildUtils->can('deb_belongs_to_dist'),
'XCAT::BuildUtils decides which suite a deb belongs to');
if (XCAT::BuildUtils->can('deb_belongs_to_dist')) {
my $noble = 'xcat-genesis-base-amd64_2.19.0-snap202609121200~noble_all.deb';
ok(XCAT::BuildUtils::deb_belongs_to_dist($noble, 'noble'),
'the noble image is published into noble');
ok(!XCAT::BuildUtils::deb_belongs_to_dist($noble, 'jammy'),
'the noble image is not published into jammy');
# Everything else in xcat-core is the same file for every release.
ok(XCAT::BuildUtils::deb_belongs_to_dist('perl-xcat_2.19.0-snap1_all.deb', 'jammy'),
'a deb with no codename in its version reaches every suite');
ok(XCAT::BuildUtils::deb_belongs_to_dist('xcat_2.19.0-snap1_amd64.deb', 'resolute'),
'an architecture deb reaches every suite');
# A `~` in a version is not a codename. Debian uses it for a prerelease, and --release
# takes whatever the caller gives it, so `--release 1~rc1` puts one in every package
# name. Reading it as a codename drops the whole build from every suite, and the run
# still reports the packages it published.
for my $dist (qw(focal jammy noble resolute)) {
ok(XCAT::BuildUtils::deb_belongs_to_dist('xcat-client_2.19.0-1~rc1_all.deb', $dist),
"a prerelease version reaches $dist");
}
ok(XCAT::BuildUtils::deb_belongs_to_dist('xcat-genesis-scripts-amd64_2.19.0-1~rc1_amd64.deb', 'noble'),
'a prerelease Genesis SCRIPTS deb reaches every suite: only the image is per codename');
# And the rule the exclusion exists for still holds under a prerelease version.
ok(XCAT::BuildUtils::deb_belongs_to_dist('xcat-genesis-base-amd64_2.19.0-1~rc1~noble_amd64.deb', 'noble'),
'a prerelease Genesis image reaches its own suite');
ok(!XCAT::BuildUtils::deb_belongs_to_dist('xcat-genesis-base-amd64_2.19.0-1~rc1~noble_amd64.deb', 'jammy'),
'and no other');
}
# --- the releases a Genesis image can be built on ------------------------------------------
# A plain --genesis run takes the release list the rest of the build uses. focal is on it and
# cannot build the package: it ships debhelper 12.10 and xCAT-genesis-base declares
# debhelper-compat (= 13), so sbuild stops on the build dependencies before dracut runs and
# the run ends on its first release.
ok(XCAT::BuildUtils->can('genesis_dists'),
'XCAT::BuildUtils says which releases a Genesis image can be built on');
if (XCAT::BuildUtils->can('genesis_dists')) {
is_deeply([ XCAT::BuildUtils::genesis_dists(XCAT::BuildUtils::default_dists()) ],
[qw(jammy noble resolute)],
'the default plan leaves out the release whose chroot cannot build the package');
is_deeply([ XCAT::BuildUtils::genesis_dists(qw(jammy noble)) ], [qw(jammy noble)],
'a list with none of them is unchanged');
is_deeply([ XCAT::BuildUtils::genesis_dists('focal') ], [],
'a list of only that release plans nothing');
}
# --- the log guard ---------------------------------------------------------------------
#
# dracut prints FAILED: for a command it cannot install and exits 0.
my $dracut_log = <<'LOG';
Installing build dependencies...
dracut: Executing: /usr/bin/dracut --compress gzip -m xcat base -N -f /tmp/genesis.rfs 6.8.0-45-generic
dracut-install: ERROR: installing 'dhclient'
dracut: FAILED: /usr/lib/dracut/dracut-install -D /var/tmp/dracut.XXXX -a dhclient
dracut: *** Creating initramfs image file '/tmp/genesis.rfs' done ***
Extracting initramfs...
LOG
my @errors = XCAT::BuildUtils::genesis_log_errors($dracut_log);
ok(scalar @errors, 'a dracut log with a FAILED: line is an error');
like($errors[0]{line}, qr/FAILED:/, 'the offending line is reported');
ok(length $errors[0]{why}, 'the reason is named');
is_deeply([ XCAT::BuildUtils::genesis_log_errors(<<'LOG') ], [], 'a clean build log is not an error');
Installing build dependencies...
dracut: *** Creating initramfs image file '/tmp/genesis.rfs' done ***
Extracting initramfs...
dpkg-deb: building package 'xcat-genesis-base-amd64'
LOG
for my $case (
[ 'E: Unable to locate package isc-dhcp-client' => 'a package apt cannot find' ],
[ 'dracut: Cannot find module directory /lib/modules/6.8.0' => 'a module directory dracut cannot find' ],
[ '/build/builddeb-genesis-base: line 9: dch: command not found' => 'a command the build root lacks' ],
[ 'E: Unable to correct problems, you have held broken packages.' => 'a build root apt cannot resolve' ],
)
{
my ($line, $what) = @{$case};
ok(scalar XCAT::BuildUtils::genesis_log_errors("before\n$line\nafter\n"),
"the log guard catches $what");
}
is_deeply([ XCAT::BuildUtils::genesis_log_errors(undef) ], [], 'no log is not an error');
# --- the builder refuses a root of another release -------------------------------------
my $builder = repo_path('xCAT-genesis-builder/builddeb-genesis-base');
if (!-f $builder) {
fail('xCAT-genesis-builder/builddeb-genesis-base is missing');
done_testing();
exit;
}
my $tmp = tempdir(CLEANUP => 1);
mkdir "$tmp/bin";
# dpkg is shadowed so the guard is tested on any host, and so the test cannot reach apt.
write_text("$tmp/bin/dpkg", "#!/bin/sh\necho amd64\n");
chmod 0755, "$tmp/bin/dpkg";
sub build_in_a_root_of {
my ($codename, $expected) = @_;
write_text("$tmp/os-release", "ID=ubuntu\nVERSION_CODENAME=$codename\n");
my $err = "$tmp/err";
my $cmd = sprintf('PATH=%s:$PATH OS_RELEASE=%s /bin/bash %s --expect-codename %s >/dev/null 2>%s',
"'$tmp/bin'", "'$tmp/os-release'", "'$builder'", "'$expected'", "'$err'");
system('/bin/bash', '-c', $cmd);
return ($? >> 8, -f $err ? read_text($err) : '');
}
my ($rc, $err) = build_in_a_root_of('jammy', 'noble');
isnt($rc, 0, 'the builder refuses to build noble in a jammy root');
like($err, qr/jammy/, 'the message names the root it woke up in');
like($err, qr/noble/, 'the message names the release that was asked for');
done_testing();
+145 -146
View File
@@ -1,186 +1,185 @@
#!/usr/bin/env perl
# Drive verify-genesis-payload against payload trees that each leave out one thing the image
# needs.
# XCAT::GenesisPayload decides whether an extracted Genesis payload is complete.
# verify-genesis-payload calls it in the EL spec and in the Ubuntu builder. Each payload
# below leaves out one thing the image needs.
use strict;
use warnings;
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Slurper qw(read_text write_text);
use File::Slurper qw(write_text);
use File::Temp qw(tempdir);
use FindBin;
use lib "$FindBin::Bin/../lib";
use lib "$FindBin::Bin/../../xCAT-genesis-builder/lib";
use Test::More;
use XCAT::Test::File qw(repo_path);
use XCAT::GenesisPayload qw(module_commands missing_paths check_payload);
my $verifier = repo_path('xCAT-genesis-builder/verify-genesis-payload');
plan skip_all => 'verify-genesis-payload not found' unless -f $verifier;
plan tests => 22;
my $tmpdir = tempdir(CLEANUP => 1);
my $module_seq = 0;
my $OPENSSH_99 = "OpenSSH_9.9p1\n/usr/libexec/openssh/sshd-session\n";
my $OPENSSH_80 = "OpenSSH_8.0p1\n";
# A complete payload: OpenSSH 9.9 sshd plus its session helper, tmux plus a UTF-8 locale.
my $good = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 1, mktemp => 1);
my ($rc, $err) = run($good, 'usr/sbin/dhclient');
is($rc, 0, 'a complete payload passes') or diag($err);
my @COMPLETE = qw(
usr/sbin/sshd usr/libexec/openssh/sshd-session usr/bin/tmux
usr/lib/locale/C.utf8/LC_CTYPE usr/sbin/dhclient usr/bin/mktemp
usr/bin/awk etc/services usr/bin/openssl usr/bin/wget usr/bin/tar
);
# The payload carries exactly the paths it is given.
sub carries {
my %present = map { $_ => 1 } @_;
return sub { $present{ $_[0] } };
}
sub all_but { my %gone = map { $_ => 1 } @_; return carries(grep { !$gone{$_} } @COMPLETE) }
is_deeply([ missing_paths(carries(@COMPLETE), sshd => $OPENSSH_99, required => ['usr/sbin/dhclient']) ],
[], 'a complete payload passes');
# doxcat calls dhclient with ISC flags. dhclient.conf and dhclient-script are not enough.
my $nodhcp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 0, mktemp => 1);
($rc, $err) = run($nodhcp, 'usr/sbin/dhclient');
isnt($rc, 0, 'a payload without dhclient fails');
like($err, qr{usr/sbin/dhclient}, 'the missing dhclient is named');
is_deeply([ missing_paths(all_but('usr/sbin/dhclient'), sshd => $OPENSSH_99, required => ['usr/sbin/dhclient']) ],
['usr/sbin/dhclient (required by the build)'],
'a payload without dhclient fails and names it');
# sshd 9.9 execs /usr/libexec/openssh/sshd-session for every connection.
my $nohelper = build_payload(sshd_execs_session => 1, session_helper => 0, tmux => 1, locale => 1, dhclient => 1, mktemp => 1);
($rc, $err) = run($nohelper, 'usr/sbin/dhclient');
isnt($rc, 0, 'a payload whose sshd execs sshd-session but does not ship it fails');
like($err, qr{sshd-session}, 'the missing sshd-session is named');
# OpenSSH 8 does not use the helper, so el8 must still pass without it.
my $openssh8 = build_payload(sshd_execs_session => 0, session_helper => 0, tmux => 1, locale => 1, dhclient => 1, mktemp => 1);
($rc, $err) = run($openssh8, 'usr/sbin/dhclient');
is($rc, 0, 'an OpenSSH 8 payload passes without sshd-session') or diag($err);
is_deeply([ missing_paths(all_but('usr/libexec/openssh/sshd-session'), sshd => $OPENSSH_99) ],
['usr/libexec/openssh/sshd-session (this sshd execs it for every connection)'],
'a payload whose sshd execs sshd-session but does not ship it fails');
# tmux without a UTF-8 locale is what stopped doxcat from ever running.
my $nolocale = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 0, dhclient => 1, mktemp => 1);
($rc, $err) = run($nolocale, 'usr/sbin/dhclient');
isnt($rc, 0, 'a payload with tmux and no UTF-8 locale fails');
like($err, qr{C\.utf8}, 'the missing locale is named');
is_deeply([ missing_paths(all_but('usr/lib/locale/C.utf8/LC_CTYPE'), sshd => $OPENSSH_99) ],
['usr/lib/locale/C.utf8/LC_CTYPE (tmux refuses to start without a UTF-8 locale)'],
'a payload with tmux and no UTF-8 locale fails');
# getdestiny makes its request file with mktemp.
my $nomktemp = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1, dhclient => 1, mktemp => 0);
($rc, $err) = run($nomktemp, 'usr/sbin/dhclient');
isnt($rc, 0, 'a payload without mktemp fails');
like($err, qr{usr/bin/mktemp}, 'the missing mktemp is named');
is_deeply([ missing_paths(all_but('usr/bin/mktemp'), sshd => $OPENSSH_99) ],
['usr/bin/mktemp (getdestiny makes its request file with it)'],
'a payload without mktemp fails');
# Genesis is reached over ssh.
is_deeply([ missing_paths(all_but('usr/sbin/sshd')) ],
['usr/sbin/sshd (Genesis is reached over ssh)'],
'a payload without sshd fails');
is_deeply([ missing_paths(carries(grep({ $_ ne 'usr/libexec/openssh/sshd-session' } @COMPLETE),
'usr/lib/openssh/sshd-session'), sshd => $OPENSSH_99) ],
[], 'the Debian path of sshd-session counts');
# OpenSSH 8 does not use the helper, so el8 must still pass without it.
is_deeply([ missing_paths(all_but('usr/libexec/openssh/sshd-session'), sshd => $OPENSSH_80) ],
[], 'an OpenSSH 8 payload passes without sshd-session');
is_deeply([ missing_paths(all_but('usr/lib/locale/C.utf8/LC_CTYPE', 'usr/bin/tmux'), sshd => $OPENSSH_99) ],
[], 'a payload without tmux needs no locale');
# dracut_install reports a missing binary and returns, so every name the dracut module
# installs has to be checked against the payload.
my $module = write_module_setup([qw(openssl wget tar)]);
my $full = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1,
dhclient => 1, mktemp => 1, commands => [qw(openssl wget tar)]);
($rc, $err) = run_with_commands($module, $full);
is($rc, 0, 'a payload carrying every command the module names passes') or diag($err);
# installs has to be checked against the payload. A name starting with "/" is installed at
# that same path; the rest are commands.
my $tmpdir = tempdir(CLEANUP => 1);
my $module = "$tmpdir/module-setup.sh";
write_text($module, <<'SH');
#!/bin/bash
my $noopenssl = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1,
dhclient => 1, mktemp => 1, commands => [qw(wget tar)]);
($rc, $err) = run_with_commands($module, $noopenssl);
isnt($rc, 0, 'a payload without openssl fails');
like($err, qr/openssl/, 'the missing openssl is named');
install() {
dracut_install -o openssl wget tar # a trailing comment
dracut_install /usr/bin/awk /etc/services
if command -v dhclient >/dev/null 2>&1; then
dracut_install dhclient
fi
}
# dracut_install installs an absolute path at that same path, so a name starting with "/" is a
# command the payload must carry. doxcat, getdestiny and the firmware wrappers all run awk.
my $noawk = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1,
dhclient => 1, mktemp => 1, commands => [qw(openssl wget tar)], absent => ['usr/bin/awk']);
($rc, $err) = run_with_commands($module, $noawk);
isnt($rc, 0, 'a payload without the absolute path /usr/bin/awk fails');
like($err, qr{/usr/bin/awk}, 'the missing /usr/bin/awk is named');
# The module names data files by absolute path too. Genesis resolves service names with
# /etc/services.
my $noservices = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1,
dhclient => 1, mktemp => 1, commands => [qw(openssl wget tar)], absent => ['etc/services']);
($rc, $err) = run_with_commands($module, $noservices);
isnt($rc, 0, 'a payload without the absolute path /etc/services fails');
like($err, qr{/etc/services}, 'the missing /etc/services is named');
installkernel() {
dracut_install notacommand
}
SH
my @commands = module_commands($module);
# The DHCP client is release-dependent, so the module installs it inside a conditional. Those
# names are not the contract; the spec passes the one it wants as a required path.
my $conditional = write_module_setup(['wget'], ['dhclient']);
my $nodhclient = build_payload(sshd_execs_session => 1, session_helper => 1, tmux => 1, locale => 1,
dhclient => 0, mktemp => 1, commands => ['wget']);
($rc, $err) = run_with_commands($conditional, $nodhclient);
is($rc, 0, 'a name installed under a condition is not required') or diag($err);
is_deeply(\@commands, [qw(/etc/services /usr/bin/awk openssl tar wget)],
'the top-level names of install() are read back, without options, comments, conditionals
or other functions');
# A module the verifier cannot read names for covers nothing, so say so instead of passing.
my %names = (commands => \@commands, source => $module, sshd => $OPENSSH_99);
is_deeply([ missing_paths(carries(@COMPLETE), %names) ], [],
'a payload carrying every command the module names passes');
is_deeply([ missing_paths(carries(grep({ $_ ne 'usr/bin/wget' } @COMPLETE), 'sbin/wget'), %names) ],
[], 'a command under sbin counts as present');
is_deeply([ missing_paths(all_but('usr/bin/openssl'), %names) ],
["openssl (installed by $module)"],
'a payload without openssl fails and names it');
# doxcat, getdestiny and the firmware wrappers all run awk.
is_deeply([ missing_paths(all_but('usr/bin/awk'), %names) ],
["/usr/bin/awk (installed by $module)"],
'a payload without the absolute path /usr/bin/awk fails');
# Genesis resolves service names with /etc/services.
is_deeply([ missing_paths(all_but('etc/services'), %names) ],
["/etc/services (installed by $module)"],
'a payload without the absolute path /etc/services fails');
# A module the verifier cannot read names from covers nothing, so say so instead of passing.
my $unparsable = "$tmpdir/module-setup-unparsable.sh";
write_text($unparsable, "#!/bin/bash\nsetup() {\n dracut_install wget\n}\n");
($rc, $err) = run_with_commands($unparsable, $full);
is($rc, 2, 'a module the verifier finds no command names in is a usage error');
like($err, qr/command name/, 'the empty command list is named');
ok(!eval { module_commands($unparsable); 1 }, 'a module with no install() names is refused');
is($@, "verify-genesis-payload: no command name read from $unparsable\n",
'the empty command list is named');
($rc, $err) = run_with_commands("$tmpdir/no-such-module", $full);
is($rc, 2, 'a module file that cannot be read is a usage error');
ok(!eval { module_commands("$tmpdir/no-such-module"); 1 }, 'an unreadable module is refused');
is($@, "verify-genesis-payload: cannot read $tmpdir/no-such-module\n",
'the unreadable module is named');
($rc, $err) = run("$tmpdir/does-not-exist");
is($rc >> 0, 2, 'a missing payload directory is a usage error');
# --- the command line reads a real payload tree ---------------------------------------------
my $good = payload_tree(@COMPLETE);
write_text("$good/usr/sbin/sshd", $OPENSSH_99);
my $nodhcp = payload_tree(grep { $_ ne 'usr/sbin/dhclient' } @COMPLETE);
my $nohelper = payload_tree(grep { $_ ne 'usr/libexec/openssh/sshd-session' } @COMPLETE);
write_text("$nohelper/usr/sbin/sshd", $OPENSSH_99);
is_deeply([ check_payload($good, 'usr/sbin/dhclient') ],
[ 0, "verify-genesis-payload: $good is complete\n" ],
'a complete payload exits 0 and says so');
is_deeply([ check_payload($nodhcp, 'usr/sbin/dhclient') ],
[ 1, "verify-genesis-payload: $nodhcp is incomplete:\n"
. " usr/sbin/dhclient (required by the build)\n" ],
'an incomplete payload exits 1 and lists what is missing');
is_deeply([ check_payload($nohelper) ],
[ 1, "verify-genesis-payload: $nohelper is incomplete:\n"
. " usr/libexec/openssh/sshd-session (this sshd execs it for every connection)\n" ],
'the command line reads usr/sbin/sshd to decide on sshd-session');
is_deeply([ check_payload('--commands-from', $module, $nodhcp) ],
[ 0, "verify-genesis-payload: $nodhcp is complete\n" ],
'--commands-from does not require a name installed under a condition');
my $noopenssl = payload_tree(grep { $_ ne 'usr/bin/openssl' } @COMPLETE);
is_deeply([ check_payload("--commands-from=$module", $noopenssl) ],
[ 1, "verify-genesis-payload: $noopenssl is incomplete:\n"
. " openssl (installed by $module)\n" ],
'--commands-from=<file> checks the names of the module');
is_deeply([ check_payload('--commands-from', $unparsable, $good) ],
[ 2, "verify-genesis-payload: no command name read from $unparsable\n" ],
'a module with no command names is a usage error');
is_deeply([ check_payload('--commands-from', "$tmpdir/no-such-module", $good) ],
[ 2, "verify-genesis-payload: cannot read $tmpdir/no-such-module\n" ],
'a module file that cannot be read is a usage error');
is_deeply([ check_payload("$tmpdir/does-not-exist") ],
[ 2, "verify-genesis-payload: not a payload directory: $tmpdir/does-not-exist\n" ],
'a missing payload directory is a usage error');
is_deeply([ check_payload() ],
[ 2, "verify-genesis-payload: not a payload directory: <empty>\n" ],
'no payload directory at all is a usage error');
done_testing();
#---
# build_payload: make a payload tree with the pieces the verifier reasons about.
# payload_tree: a payload directory that carries the given paths as empty files.
#---
sub build_payload {
my (%opt) = @_;
sub payload_tree {
my $root = tempdir(DIR => $tmpdir, CLEANUP => 1);
make_path("$root/usr/sbin", "$root/usr/bin", "$root/usr/libexec/openssh");
write_text("$root/usr/sbin/sshd",
$opt{sshd_execs_session}
? "OpenSSH_9.9p1\n/usr/libexec/openssh/sshd-session\n"
: "OpenSSH_8.0p1\n");
write_text("$root/usr/libexec/openssh/sshd-session", "helper\n") if $opt{session_helper};
write_text("$root/usr/bin/tmux", "tmux\n") if $opt{tmux};
if ($opt{locale}) {
make_path("$root/usr/lib/locale/C.utf8");
write_text("$root/usr/lib/locale/C.utf8/LC_CTYPE", "ctype\n");
}
write_text("$root/usr/sbin/dhclient", "dhclient\n") if $opt{dhclient};
write_text("$root/usr/bin/mktemp", "mktemp\n") if $opt{mktemp};
write_text("$root/usr/bin/$_", "$_\n") for @{ $opt{commands} || [] };
# The module written by write_module_setup names these two by absolute path.
my %absent = map { $_ => 1 } @{ $opt{absent} || [] };
for my $path (qw(usr/bin/awk etc/services)) {
next if $absent{$path};
my ($dir) = $path =~ m{^(.*)/};
make_path("$root/$dir");
write_text("$root/$path", "$path\n");
for my $path (@_) {
make_path(dirname("$root/$path"));
write_text("$root/$path", '');
}
return $root;
}
#---
# run: run the verifier and return its exit status and stderr.
#---
sub run {
my ($root, @required) = @_;
my $errfile = "$tmpdir/err.$$";
my $cmd = join ' ', map { "'$_'" } ($verifier, $root, @required);
system("/bin/bash $cmd >/dev/null 2>$errfile");
my $status = $? >> 8;
my $err = -f $errfile ? read_text($errfile) : '';
unlink $errfile;
return ($status, $err);
}
#---
# write_module_setup: a dracut module whose install() names commands at the top level, and
# optionally more inside a conditional.
#---
sub write_module_setup {
my ($top, $conditional) = @_;
my $path = "$tmpdir/module-setup." . ++$module_seq . ".sh";
my $text = "#!/bin/bash\n\ninstall() {\n";
$text .= " dracut_install " . join(' ', @$top) . " # a trailing comment\n";
$text .= " dracut_install /usr/bin/awk /etc/services\n";
if ($conditional) {
$text .= " if command -v " . $conditional->[0] . " >/dev/null 2>&1; then\n";
$text .= " dracut_install " . join(' ', @$conditional) . "\n";
$text .= " fi\n";
}
$text .= "}\n";
write_text($path, $text);
return $path;
}
#---
# run_with_commands: run the verifier with the command list read back from a dracut module.
#---
sub run_with_commands {
my ($module, $root) = @_;
my $errfile = "$tmpdir/err.commands.$$";
my $cmd = join ' ', map { "'$_'" } ($verifier, '--commands-from', $module, $root);
system("/bin/bash $cmd >/dev/null 2>$errfile");
my $status = $? >> 8;
my $err = -f $errfile ? read_text($errfile) : '';
unlink $errfile;
return ($status, $err);
}
+67 -91
View File
@@ -3,113 +3,89 @@
# mandatory. dracut_install reports a missing command and returns 0, so a hole in the image
# does not fail the build.
#
# The mandatory list comes from RUNNING the module: module-setup.sh is sourced with
# dracut_install shadowed, _dracut_install_opt neutralised, and install() called. The
# package list comes from evaluating the REQUIRED_PACKAGES assignment in the build script.
# XCAT::GenesisBuildRoot::required_packages lists the packages of the build root, and
# XCAT::GenesisPayload::module_commands reads the mandatory commands from the module, as
# verify-genesis-payload does. The refusal of a root of another release is in
# genesis_deb_per_codename.t.
use strict;
use warnings;
use File::Slurper qw(read_text);
use File::Temp qw(tempdir);
use FindBin;
use lib "$FindBin::Bin/../lib";
use lib "$FindBin::Bin/../../xCAT-genesis-builder/lib";
use Test::More;
use XCAT::GenesisBuildRoot qw(required_packages);
use XCAT::GenesisPayload qw(module_commands);
use XCAT::Test::File qw(repo_path);
my $builder = repo_path('xCAT-genesis-builder/builddeb-genesis-base');
my $module = repo_path('xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh');
plan skip_all => 'builddeb-genesis-base not found' unless -f $builder;
plan skip_all => 'ubuntu module-setup.sh not found' unless -f $module;
plan tests => 9;
my $builder = repo_path('xCAT-genesis-builder/builddeb-genesis-base');
my $module = repo_path('xCAT-genesis-builder/dracut_105/ubuntu/module-setup.sh');
# Mandatory commands a minimal Ubuntu server root does NOT already provide, and the package
# that supplies each one on every release xCAT builds for.
my %PACKAGE_FOR = (
dhclient => 'isc-dhcp-client',
ifenslave => 'ifenslave',
# Mandatory commands a minimal Ubuntu server root does NOT already provide, and the packages
# that supply each one. hwclock has two names: it left util-linux for util-linux-extra in
# 23.04.
my %PACKAGES_FOR = (
dhclient => ['isc-dhcp-client'],
ifenslave => ['ifenslave'],
hwclock => [ 'util-linux-extra', 'util-linux' ],
);
# hwclock is not in that list because the package that carries it moved. Measured on the
# four Ubuntu management nodes: focal and jammy have it in util-linux, which is essential
# and always in the build root, and no util-linux-extra exists to install; noble and
# resolute have it in util-linux-extra. util-linux only Suggests that package, and this
# build passes --no-install-recommends, so the releases that split it must name it and the
# releases that did not must not.
my %mandatory = map { $_ => 1 } mandatory_commands($module);
my @packages = required_packages($builder);
for my $command (sort keys %PACKAGE_FOR) {
ok($mandatory{$command}, "the Ubuntu dracut module installs '$command' unconditionally");
ok(scalar(grep { $_ eq $PACKAGE_FOR{$command} } @packages),
"the build root installs $PACKAGE_FOR{$command}, which provides '$command'");
# A release carries exactly the names in its list.
sub release {
my %carried = map { $_ => 1 } @_;
return sub { $carried{ $_[0] } };
}
my $NOBLE = release(qw(bind9-dnsutils dnsutils util-linux-extra util-linux tzdata-legacy));
my $FOCAL = release(qw(dnsutils util-linux));
my @noble = required_packages('amd64', 'noble', $NOBLE);
is_deeply([ @noble[ -5 .. -1 ] ],
[qw(dmidecode efibootmgr bind9-dnsutils util-linux-extra tzdata-legacy)],
'amd64 adds dmidecode and efibootmgr, then the first name the release carries');
my @focal = required_packages('amd64', 'focal', $FOCAL);
is_deeply([ @focal[ -2 .. -1 ] ], [qw(dnsutils util-linux)],
'a release without the new names gets dnsutils and util-linux, and no tzdata-legacy');
my @ppc = required_packages('ppc64el', 'noble', $NOBLE);
is_deeply([ @ppc[ -3 .. -1 ] ], [qw(bind9-dnsutils util-linux-extra tzdata-legacy)],
'ppc64el gets neither dmidecode nor efibootmgr');
is_deeply([ @ppc[ 0 .. $#ppc - 3 ] ], [ @noble[ 0 .. $#noble - 5 ] ],
'ppc64el and amd64 share the base packages');
my @asked;
required_packages('amd64', 'noble', sub { push @asked, $_[0]; $NOBLE->($_[0]) });
is_deeply(\@asked, [qw(bind9-dnsutils util-linux-extra tzdata-legacy)],
'apt is asked for the older name only when the newer one is absent');
ok(!eval { required_packages('amd64', 'oddball', release('bind9-dnsutils')); 1 },
'a release that carries no hwclock package fails');
is($@, "ERROR: oddball carries none of these packages: util-linux-extra util-linux\n",
'the failure names the release and the missing alternatives');
# --- every mandatory command has a package in the build root -----------------------------
# An absolute path is a data file, not a command.
my %mandatory = map { $_ => 1 } grep { !m{^/} } module_commands($module);
my %packages = map { $_ => 1 } @noble;
for my $command (sort keys %PACKAGES_FOR) {
my @provider = @{ $PACKAGES_FOR{$command} };
ok($mandatory{$command}, "the Ubuntu dracut module installs '$command' unconditionally");
ok(scalar(grep { $packages{$_} } @provider),
"the build root installs @{[ join ' or ', @provider ]}, which provides '$command'");
}
ok(grep({ $_ eq 'util-linux' } @focal), 'a release before 23.04 gets hwclock from util-linux');
# doxcat asks dhclient for the provisioning lease.
ok($mandatory{dhclient} && scalar(grep { $_ eq 'isc-dhcp-client' } @packages),
ok($mandatory{dhclient} && $packages{'isc-dhcp-client'},
'the Genesis image can obtain a DHCP lease');
ok($mandatory{hwclock}, "the Ubuntu dracut module installs 'hwclock' unconditionally");
# The build cannot run here, so the call to the payload gate is read from the script. The
# gate itself is exercised by genesis_payload_verification.t.
like(read_text($builder),
qr{^bash "\$DIR/verify-genesis-payload" --commands-from "\$DRACUTMODDIR/module-setup\.sh" "\$GENESIS_FS"}m,
'builddeb-genesis-base verifies the payload it packages against the module');
# Naming a package apt cannot locate fails the whole install, and the script runs under
# set -e, so an unconditional util-linux-extra stops the build on focal and jammy.
ok(!scalar(grep { $_ eq 'util-linux-extra' } @packages),
'the unconditional list does not name util-linux-extra');
# What the script does instead: keep a package only where apt has a candidate for it.
{
is_deeply(optional_packages($builder, 'util-linux-extra', 0), ['util-linux-extra'],
'a release that carries util-linux-extra installs it');
is_deeply(optional_packages($builder, 'util-linux-extra', 1), [],
'a release without it installs nothing in its place');
}
# An absolute path in the install() output is a data file, not a command.
sub mandatory_commands {
my ($path) = @_;
my $dir = tempdir(CLEANUP => 1);
my $driver = "$dir/collect.sh";
open my $fh, '>', $driver or die "$driver: $!";
print $fh <<"BASH";
dracut_install() { printf '%s\\n' "\$\@"; }
instmods() { :; }
inst_multiple() { :; }
inst() { :; }
dpkg-architecture() { echo x86_64-linux-gnu; }
. '$path'
# _dracut_install_opt installs only what the build root already has. Neutralise it after
# sourcing, so its commands stay out of the mandatory set.
_dracut_install_opt() { :; }
install
BASH
close $fh;
my @out = qx{bash '$driver' 2>/dev/null};
die("running install() from $path produced nothing") unless @out;
my %seen;
my @names = grep { !$seen{$_}++ } grep { length && !m{^/} } map { chomp; $_ } @out;
die("install() from $path named no bare commands") unless @names;
return @names;
}
# Run the script's own selector with apt-cache shadowed, so the decision is exercised
# rather than read. $rc is what the shadow returns: 0 for a release that has the package.
sub optional_packages {
my ($path, $package, $rc) = @_;
my $text = do { open my $fh, '<', $path or die "$path: $!"; local $/; <$fh> };
my ($block) = $text =~ /^(optional_packages\(\)\s*\{.*?^\})/ms;
BAIL_OUT("no optional_packages() in $path") unless $block;
my $out = qx{bash -c 'set -u; apt-cache() { return $rc; }; $block; optional_packages $package' 2>/dev/null};
return [ grep { length } split /\s+/, ($out // '') ];
}
# Evaluate the assignment rather than parse it, so the list is the value the script uses.
sub required_packages {
my ($path) = @_;
my $text = do { open my $fh, '<', $path or die "$path: $!"; local $/; <$fh> };
my ($block) = $text =~ /^(REQUIRED_PACKAGES="[^"]*")/ms;
die("no REQUIRED_PACKAGES assignment in $path") unless $block;
my $out = qx{bash -c 'set -u; $block; printf "%s\\n" \$REQUIRED_PACKAGES' 2>/dev/null};
my @packages = grep { length } split /\s+/, ($out // '');
die("REQUIRED_PACKAGES in $path evaluated to nothing") unless @packages;
return @packages;
}
done_testing();