diff --git a/build-utils/lib/XCAT/BuildUtils.pm b/build-utils/lib/XCAT/BuildUtils.pm index db835ce5e..e4a0f755f 100644 --- a/build-utils/lib/XCAT/BuildUtils.pm +++ b/build-utils/lib/XCAT/BuildUtils.pm @@ -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 @@ -740,4 +743,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/, 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; diff --git a/builddebs.pl b/builddebs.pl index 4c53ca722..3c7778d69 100755 --- a/builddebs.pl +++ b/builddebs.pl @@ -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 +# (--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-base"), + "FATAL: cannot make the build directory in session:$id\n"); + sh_or_die("cp -a " . sh_quote("$ROOT/xCAT-genesis-base") . "/. " + . sh_quote("$stage/xCAT-genesis-base") . "/", + "FATAL: cannot copy xCAT-genesis-base 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-base/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 and the resulting C<.deb> files are published into every codename the repository declares. Only C, C and C carry an architecture, and there the difference is packaging metadata rather than -compiled output. Consequently this builder needs no C and no per-codename -chroot. (xcat-dep is different: its packages are compiled, so it builds per codename.) +compiled output. + +C 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<< --sbuild >> schroot -- the chroots +xcat-dep's C 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 line and still exits 0. Replaced C, removed in 2.19. The GSA upload paths, the C/C 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 CarchE>, 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. + +=item B<--genesis-dist>=I + +Build the Genesis image for this release. Repeatable. Defaults to the C<--dist> list. + +=item B<--genesis-arch>=I + +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 Write the build under this directory: packages in C, 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 diff --git a/docs/source/developers/guides/code/builds.rst b/docs/source/developers/guides/code/builds.rst index 021428aae..183910417 100644 --- a/docs/source/developers/guides/code/builds.rst +++ b/docs/source/developers/guides/code/builds.rst @@ -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 +``--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``, diff --git a/xCAT-genesis-base/builddeb-genesis-base b/xCAT-genesis-base/builddeb-genesis-base index 8758aa7a1..41b0696d5 100755 --- a/xCAT-genesis-base/builddeb-genesis-base +++ b/xCAT-genesis-base/builddeb-genesis-base @@ -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 --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 ] [--outdir ]" + 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,7 +53,20 @@ 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" @@ -45,6 +80,18 @@ 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 @@ -76,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 @@ -128,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 @@ -154,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" @@ -163,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 diff --git a/xCAT-genesis-base/lib/XCAT/GenesisBuildRoot.pm b/xCAT-genesis-base/lib/XCAT/GenesisBuildRoot.pm index afc7c9f62..58c981a3f 100644 --- a/xCAT-genesis-base/lib/XCAT/GenesisBuildRoot.pm +++ b/xCAT-genesis-base/lib/XCAT/GenesisBuildRoot.pm @@ -16,13 +16,13 @@ my @BASE_PACKAGES = qw( 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 ); -# hwclock moved out of util-linux into util-linux-extra. focal and jammy have no such package, -# and naming it there fails the whole install. util-linux only Suggests it, and the build -# passes --no-install-recommends, so the releases that split it must name it. -my @OPTIONAL_PACKAGES = qw(util-linux-extra); +# 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)]); #------------------------------------------------------------------------------- @@ -48,14 +48,18 @@ sub apt_carries { =head3 required_packages Descriptions: list the packages the Genesis build root needs. - An optional package is listed only when the release carries it. + 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 + $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: carries none of these packages: ..." when the release + carries neither name of a renamed package. =cut @@ -66,7 +70,18 @@ sub required_packages { my @packages = @BASE_PACKAGES; push @packages, qw(dmidecode efibootmgr) if $arch eq 'amd64'; - push @packages, grep { $carries->($_) } @OPTIONAL_PACKAGES; + + 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; } diff --git a/xCAT-test/unit/genesis_deb_per_codename.t b/xCAT-test/unit/genesis_deb_per_codename.t new file mode 100644 index 000000000..36ffc847f --- /dev/null +++ b/xCAT-test/unit/genesis_deb_per_codename.t @@ -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-base/builddeb-genesis-base'); +if (!-f $builder) { + fail('xCAT-genesis-base/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(); diff --git a/xCAT-test/unit/genesis_ubuntu_build_root.t b/xCAT-test/unit/genesis_ubuntu_build_root.t index 3e706b2f7..e04a74695 100755 --- a/xCAT-test/unit/genesis_ubuntu_build_root.t +++ b/xCAT-test/unit/genesis_ubuntu_build_root.t @@ -5,10 +5,13 @@ # # 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. +# 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-base/lib"; @@ -18,13 +21,16 @@ use XCAT::GenesisBuildRoot qw(required_packages); use XCAT::GenesisPayload qw(module_commands); use XCAT::Test::File qw(repo_path); -my $module = repo_path('xCAT-genesis-base/dracut_105/ubuntu/module-setup.sh'); +my $builder = repo_path('xCAT-genesis-base/builddeb-genesis-base'); +my $module = repo_path('xCAT-genesis-base/dracut_105/ubuntu/module-setup.sh'); -# Mandatory commands a minimal Ubuntu server root does NOT already provide, and the package -# that supplies each one on every release xCAT builds for. -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' ], ); # A release carries exactly the names in its list. @@ -32,48 +38,54 @@ 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)); -# hwclock is not in that list because the package that carries it moved. Measured on the -# four Ubuntu management nodes: focal and jammy have it in util-linux, which is essential -# and always in the build root, and no util-linux-extra exists to install; noble and -# resolute have it in util-linux-extra. util-linux only Suggests that package, and this -# build passes --no-install-recommends, so the releases that split it must name it and the -# releases that did not must not. -my @noble = required_packages('amd64', 'noble', release('util-linux-extra')); -my @jammy = required_packages('amd64', 'jammy', release()); +my @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 } @jammy; - -for my $command (sort keys %PACKAGE_FOR) { +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($packages{ $PACKAGE_FOR{$command} }, - "the build root installs $PACKAGE_FOR{$command}, which provides '$command'"); + 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} && $packages{'isc-dhcp-client'}, 'the Genesis image can obtain a DHCP lease'); -ok($mandatory{hwclock}, "the Ubuntu dracut module installs 'hwclock' unconditionally"); - -# Naming a package apt cannot locate fails the whole install, and the script runs under -# set -e, so an unconditional util-linux-extra stops the build on focal and jammy. -is_deeply([ grep { $_ eq 'util-linux-extra' } @jammy ], [], - 'a release without util-linux-extra does not get it'); -is_deeply([ @noble[ 0 .. $#noble - 1 ] ], \@jammy, - 'a release that carries util-linux-extra gets the same list ...'); -is($noble[-1], 'util-linux-extra', '... with util-linux-extra last'); - -my @ppc = required_packages('ppc64el', 'noble', release('util-linux-extra')); -is_deeply([ @noble[ -3 .. -1 ] ], [qw(dmidecode efibootmgr util-linux-extra)], - 'amd64 adds dmidecode and efibootmgr'); -is_deeply([ @ppc[ 0 .. $#ppc - 1 ] ], [ @noble[ 0 .. $#noble - 3 ] ], - 'ppc64el gets neither dmidecode nor efibootmgr'); - -my @asked; -required_packages('amd64', 'noble', sub { push @asked, $_[0]; 1 }); -is_deeply(\@asked, ['util-linux-extra'], 'apt is asked only about the optional package'); +# 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'); done_testing();