diff --git a/BUILD.md b/BUILD.md index 4e882b9..e74a6a5 100644 --- a/BUILD.md +++ b/BUILD.md @@ -304,6 +304,19 @@ find /build-output/mockbuild-all//build-logs -type f | sort - `mock target not found` - Validate with `mock -r --print-root-path` and install the required mock config packages. +# Tests + +The reusable, side-effect-free helpers live in `MockBuildUtils.pm` (package selection under the +`--skip-*` flags, version-pin matching incl. globs, RPM-identity comparison, and the cross-arch +genesis `finalize` logic). Focused fixture tests cover them: + +```bash +prove t/ # or: perl t/mockbuild-all.t +``` + +The RPM-identity / `cross_copy_genesis` cases build tiny fixture rpms and are skipped +automatically if `rpmbuild` is unavailable. + # References - [mock project repository](https://github.com/rpm-software-management/mock) diff --git a/MockBuildUtils.pm b/MockBuildUtils.pm new file mode 100644 index 0000000..7924f6a --- /dev/null +++ b/MockBuildUtils.pm @@ -0,0 +1,204 @@ +package MockBuildUtils; +# Reusable, unit-testable helpers factored out of mockbuild-all.pl. Kept free of that script's +# globals so t/mockbuild-all.t can exercise them directly. The two orchestration helpers that +# need signing / re-indexing (cross_copy_genesis, finalize_xcat_dep) take those as injected +# callbacks instead of reaching for gpg/createrepo state, so they stay pure and testable. +use strict; +use warnings; +use Exporter 'import'; +use File::Basename qw(basename); +use File::Copy qw(copy); + +our @EXPORT_OK = qw( + sh_quote print_step + version_matches required_pkgs have_rpm read_manifest + rpm_version rpm_sigmd5 + cross_copy_genesis finalize_xcat_dep +); + +# sh_quote: single-quote a string for safe use in a shell command. +sub sh_quote { + my ($s) = @_; + $s = '' if !defined $s; + $s =~ s/'/'"'"'/g; + return "'$s'"; +} + +# print_step: print a step banner. +sub print_step { + my ($msg) = @_; + print "\n== $msg ==\n"; +} + +# version_matches: does the built version $got satisfy the manifest pin $want? $want may be an +# exact version (2.19.0), a shell-style glob (2.* or 2.19.*), or '*' (any). Globs support * and +# ? and are anchored. Used so xCAT-genesis-base can pin 2.* (its Version walks with xcat-core) +# while the real xcat-dep packages stay exactly pinned. +sub version_matches { + my ($got, $want) = @_; + return 1 if !defined($want) || $want eq '*'; + return ($got eq $want) unless $want =~ /[*?]/; + my $re = quotemeta($want); + $re =~ s/\\\*/.*/g; + $re =~ s/\\\?/./g; + return $got =~ /\A$re\z/ ? 1 : 0; +} + +# required_pkgs: given a list of manifest package names and the skip flags, return the subset +# that must actually be built and validated. A package whose builder was skipped is NOT required: +# --skip-genesis drops xCAT-genesis-base, --skip-perl drops perl-*, --skip-xcat-dep drops the dep +# builders (everything that is neither genesis nor perl). Pure function (flags passed in) so both +# the version-pin check and assert_required_deps use it and it is unit-testable. +sub required_pkgs { + my ($pkgs, $skip_genesis, $skip_perl, $skip_dep) = @_; + return grep { + !($skip_genesis && $_ eq 'xCAT-genesis-base') + && !($skip_perl && /^perl-/) + && !($skip_dep && $_ ne 'xCAT-genesis-base' && $_ !~ /^perl-/) + } @$pkgs; +} + +# have_rpm: is there a non-src rpm named -... under $dir? +sub have_rpm { + my ($dir, $name) = @_; + my @m = grep { !/\.src\.rpm$/ } glob("$dir/${name}-*.rpm"); + return scalar(@m) > 0; +} + +# rpm_sigmd5: the SIGMD5 of an rpm -- the digest of its header+payload, independent of the GPG +# signature. Used to compare RPM identity/content: two rpms that share a basename but differ in +# content have different SIGMD5 (a bare filename match is not enough to call them identical). +sub rpm_sigmd5 { + my ($f) = @_; + return '' unless defined $f && -f $f; + my $v = `rpm -qp --qf '%{SIGMD5}' ${\ sh_quote($f)} 2>/dev/null`; + chomp $v; + return $v; +} + +# rpm_version: %{version} of the built binary rpm named under $dir (undef if absent). +# Skips src/debug rpms and confirms the rpm's real %{name} matches (glob can over-match). +# 'xCAT-genesis-base' matches the arch-suffixed rpm name (xCAT-genesis-base-x86_64 / -ppc64). +sub rpm_version { + my ($dir, $name) = @_; + my $glob = ($name eq 'xCAT-genesis-base') + ? "$dir/xCAT-genesis-base-*.rpm" + : "$dir/${name}-*.rpm"; + for my $f (sort glob($glob)) { + next if $f =~ /\.src\.rpm$/ || $f =~ /-debug(?:info|source)-/; + my $n = `rpm -qp --qf '%{name}' ${\ sh_quote($f)} 2>/dev/null`; + my $match = ($name eq 'xCAT-genesis-base') + ? ($n =~ /^xCAT-genesis-base-/) : ($n eq $name); + next unless $match; + my $v = `rpm -qp --qf '%{version}' ${\ sh_quote($f)} 2>/dev/null`; + chomp $v; + return $v; + } + return undef; +} + +# read_manifest: parse packages-manifest.conf into %{ target => { package => version|'*' } }. +# INI format: [target] sections; "package=version|*" entries; blank / "#" / ";" lines ignored. +# Returns an empty hash if the file is absent (callers that build require a section per target). +sub read_manifest { + my ($path) = @_; + my %m; + return %m unless -f $path; + open my $fh, '<', $path or die "Cannot read manifest $path: $!\n"; + my $sec; + while (my $line = <$fh>) { + $line =~ s/\r?\n\z//; + $line =~ s/^\s+|\s+$//g; + next if $line eq '' || $line =~ /^[#;]/; + if ($line =~ /^\[(.+?)\]$/) { $sec = $1; $m{$sec} ||= {}; next; } + next unless defined $sec; + my ($k, $v) = split /=/, $line, 2; + $k =~ s/\s+\z//; + $v = defined($v) ? ($v =~ s/^\s+//r) : ''; + $m{$sec}{$k} = ($v ne '') ? $v : '*'; + } + close $fh; + return %m; +} + +# cross_copy_genesis: copy the noarch xCAT-genesis-base--*.rpm from $from into $to, dropping +# any stale foreign-arch genesis already in $to so the repo ends with exactly the fresh set. +# Returns the count of rpms newly copied (0 = already up to date, so the caller can skip +# re-indexing). Idempotent. $sign is an optional coderef ($rpm_path) invoked on each copied rpm +# (e.g. to re-sign it); pass undef to skip signing. Content is compared by SIGMD5, so a stale +# same-name rpm is refreshed rather than mistaken for up to date. +sub cross_copy_genesis { + my ($from, $to, $tarch, $sign) = @_; + my @src = grep { !/\.src\.rpm$/ } glob("$from/xCAT-genesis-base-$tarch-*.rpm"); + return 0 if !@src; + my %want = map { basename($_) => $_ } @src; + my @existing = grep { !/\.src\.rpm$/ } glob("$to/xCAT-genesis-base-$tarch-*.rpm"); + if (scalar(@existing) == scalar(keys %want)) { + my $up_to_date = 1; + for my $base (keys %want) { + my $dst = "$to/$base"; + if (!-f $dst || rpm_sigmd5($want{$base}) ne rpm_sigmd5($dst)) { $up_to_date = 0; last; } + } + return 0 if $up_to_date; + } + for my $old (@existing) { + unlink $old or die "Failed to remove stale genesis $old: $!\n"; + print "[finalize] - " . basename($old) . " (stale foreign-arch, removed from $to)\n"; + } + my $copied = 0; + for my $base (sort keys %want) { + copy($want{$base}, "$to/$base") + or die "Failed to cross-copy genesis $want{$base} -> $to: $!\n"; + print "[finalize] + $base ($from -> $to)\n"; + $sign->("$to/$base") if $sign; # e.g. re-sign so the deploy gate never sees an unsigned rpm + $copied++; + } + return $copied; +} + +# finalize_xcat_dep: cross-populate the noarch xCAT-genesis-base between each matching +# /x86_64 and /ppc64le repo pair (issue #7610), then re-index the repos that changed. +# %opt: sign => coderef($rpm) applied to copied rpms (or undef); reindex => coderef($dir) run on +# a repo whose rpm set changed (or undef). Both injected so this stays free of gpg/createrepo +# state and is unit-testable. Requires each arch's own genesis rpm to be present (a pair with no +# genesis is a hard error, never a silent no-op) and fails if no repo pair is found at all. +sub finalize_xcat_dep { + my ($x86_64_repo, $ppc64le_repo, %opt) = @_; + my $sign = $opt{sign}; + my $reindex = $opt{reindex}; + print_step('Finalize xcat-dep: cross-arch genesis-base provisioning (issue #7610)'); + print "x86_64-repo: $x86_64_repo\n"; + print "ppc64le-repo: $ppc64le_repo\n"; + my @osdirs = grep { -d "$_/x86_64" } glob("$x86_64_repo/*"); + my $pairs = 0; + for my $p (sort @osdirs) { + my $osdir = basename($p); + my $x86dir = "$x86_64_repo/$osdir/x86_64"; + my $ppcdir = "$ppc64le_repo/$osdir/ppc64le"; + if (!-d $ppcdir) { + print "[finalize] $osdir: no ppc64le peer at $ppcdir -- skipping\n"; + next; + } + # Require the expected inputs: each arch's build must have produced its OWN genesis rpm + # before finalize cross-populates them. Without this, a pair whose builds produced no + # genesis rpms would make finalize a silent no-op that still exits 0 (the bug this guards). + die "FATAL: [finalize] $osdir: no x86_64 xCAT-genesis-base rpm in $x86dir\n" + if !grep { !/\.src\.rpm$/ } glob("$x86dir/xCAT-genesis-base-x86_64-*.rpm"); + die "FATAL: [finalize] $osdir: no ppc64 xCAT-genesis-base rpm in $ppcdir\n" + if !grep { !/\.src\.rpm$/ } glob("$ppcdir/xCAT-genesis-base-ppc64-*.rpm"); + # xCAT collapses ppc/ppc64/ppc64le into tarch=ppc64, so the ppc genesis rpm is + # named xCAT-genesis-base-ppc64-*. Cross-copy both directions. + my $to_x86 = cross_copy_genesis($ppcdir, $x86dir, 'ppc64', $sign); + my $to_ppc = cross_copy_genesis($x86dir, $ppcdir, 'x86_64', $sign); + $reindex->($x86dir) if $to_x86 && $reindex; + $reindex->($ppcdir) if $to_ppc && $reindex; + printf "[finalize] %s: %d ppc64 genesis -> x86_64, %d x86_64 genesis -> ppc64le\n", + $osdir, $to_x86, $to_ppc; + $pairs++; + } + die "FATAL: --finalize-xcat-dep found no /x86_64 + /ppc64le repo pair under\n" + . " --x86_64-repo '$x86_64_repo'\n --ppc64le-repo '$ppc64le_repo'\n" if $pairs == 0; + print_step('Finalize complete'); +} + +1; diff --git a/mockbuild-all.pl b/mockbuild-all.pl index fd6a09c..0ae4ff2 100755 --- a/mockbuild-all.pl +++ b/mockbuild-all.pl @@ -11,6 +11,10 @@ use File::Path qw(make_path); use Getopt::Long qw(GetOptions); use Parallel::ForkManager; use POSIX qw(strftime); +use FindBin qw($RealBin); +use lib $RealBin; +use MockBuildUtils qw(sh_quote print_step version_matches required_pkgs have_rpm + read_manifest rpm_version rpm_sigmd5 cross_copy_genesis finalize_xcat_dep); my $script_dir = abs_path(dirname(__FILE__)); my $repo_root = abs_path($script_dir); @@ -131,7 +135,16 @@ if ($finalize_xcat_dep) { my $ppc = abs_path($ppc64le_repo) or die "--ppc64le-repo '$ppc64le_repo' not found\n"; die "--x86_64-repo '$x86' is not a directory\n" if !-d $x86; die "--ppc64le-repo '$ppc' is not a directory\n" if !-d $ppc; - finalize_xcat_dep($x86, $ppc); + # Inject the per-rpm gpg re-sign and the repo re-index as callbacks so the finalize logic in + # MockBuildUtils stays free of this script's gpg/createrepo state. + finalize_xcat_dep($x86, $ppc, + sign => ($gpg_sign ? sub { + my ($rpm) = @_; + local $ENV{GNUPGHOME} = $gpg_home if $gpg_home; + run_simple(qq(rpmsign --define "%_gpg_name $gpg_key_name" --addsign ) . sh_quote($rpm)); + } : undef), + reindex => \&reindex_and_sign_repo, + ); exit 0; } @@ -517,22 +530,12 @@ if (!$skip_build) { } } - # Zero-tolerance: any required (manifest) package that failed to build fails the run. - # genesis is the one exception -- xcat-core's buildrpms.pl exits non-zero on an unrelated - # post-build xCAT-release-latest cp even when the genesis rpm IS produced, so genesis - # counts as failed only if its rpm is absent, not on exit code. - my @hard; - for my $id (@failed) { - if ($id eq 'genesis') { - my @g = grep { !/\.src\.rpm$/ } - glob("$xcat_src/dist/$target/rpms/xCAT-genesis-base-*.rpm"); - push @hard, $id unless @g; - } - else { - push @hard, $id; - } - } - die "FATAL: required build step(s) failed for $target: @hard\n" if @hard; + # Zero-tolerance: any build step that failed fails the whole run -- genesis included. + # (xcat-core #7696 is merged: buildrpms.pl now exits 0 iff it actually produced the + # genesis rpm, so there is no cosmetic non-zero exit left to tolerate. The old workaround + # -- ignore a genesis failure when a matching rpm already exists in dist/ -- is gone; a + # stale artifact from a previous build must never mask a failed genesis build.) + die "FATAL: required build step(s) failed for $target: @failed\n" if @failed; } } @@ -590,7 +593,9 @@ if (!$skip_genesis && !$dry_run) { # (which carries the per-EL dist tag and the genesis snap timestamp). if (!$dry_run && !$skip_build) { my @vmiss; - for my $pkg (sort keys %req) { + # Only validate packages whose builder was NOT skipped -- so a clean --skip-* run does not + # fail on packages it deliberately did not build. + for my $pkg (required_pkgs([sort keys %req], $skip_genesis, $skip_perl, $skip_xcat_dep)) { my $want = $req{$pkg}; next if !defined($want) || $want eq '*'; my $got = rpm_version($repo_dir, $pkg); @@ -798,77 +803,7 @@ EOF close $b; } -# --finalize-xcat-dep: cross-populate the noarch xCAT-genesis-base between each matching -# /x86_64 and /ppc64le repo pair, then re-index + re-sign the repos that changed. -# / are the two per-arch repo roots (each holding rh8/rh9/rh10/). -# They may be the same path (both arches built into one tree) or two separate trees (one per -# build host); either way pairs are matched by subdir. -sub finalize_xcat_dep { - my ($x86_64_repo, $ppc64le_repo) = @_; - print_step('Finalize xcat-dep: cross-arch genesis-base provisioning (issue #7610)'); - print "x86_64-repo: $x86_64_repo\n"; - print "ppc64le-repo: $ppc64le_repo\n"; - # Every OS-release dir under the x86_64 repo that actually has an x86_64 sub-repo. - my @osdirs = grep { -d "$_/x86_64" } glob("$x86_64_repo/*"); - my $pairs = 0; - for my $p (sort @osdirs) { - my $osdir = basename($p); - my $x86dir = "$x86_64_repo/$osdir/x86_64"; - my $ppcdir = "$ppc64le_repo/$osdir/ppc64le"; - if (!-d $ppcdir) { - print "[finalize] $osdir: no ppc64le peer at $ppcdir -- skipping\n"; - next; - } - # xCAT collapses ppc/ppc64/ppc64le into tarch=ppc64, so the ppc genesis rpm is - # named xCAT-genesis-base-ppc64-*. Cross-copy both directions. - my $to_x86 = cross_copy_genesis($ppcdir, $x86dir, 'ppc64'); - my $to_ppc = cross_copy_genesis($x86dir, $ppcdir, 'x86_64'); - reindex_and_sign_repo($x86dir) if $to_x86; - reindex_and_sign_repo($ppcdir) if $to_ppc; - printf "[finalize] %s: %d ppc64 genesis -> x86_64, %d x86_64 genesis -> ppc64le\n", - $osdir, $to_x86, $to_ppc; - $pairs++; - } - die "FATAL: --finalize-xcat-dep found no /x86_64 + /ppc64le repo pair under\n" - . " --x86_64-repo '$x86_64_repo'\n --ppc64le-repo '$ppc64le_repo'\n" if $pairs == 0; - print_step('Finalize complete'); -} -# Cross-copy the noarch xCAT-genesis-base--*.rpm from $from into $to. Drops any -# stale foreign-arch genesis already in $to (e.g. issue #7610's 2.16.3 ppc leftover) so -# the repo ends with exactly the fresh set. Returns the number of rpms newly copied -# (0 = already up to date, so the caller can skip re-indexing). Idempotent. -sub cross_copy_genesis { - my ($from, $to, $tarch) = @_; - my @src = grep { !/\.src\.rpm$/ } glob("$from/xCAT-genesis-base-$tarch-*.rpm"); - return 0 if !@src; - my %want = map { basename($_) => $_ } @src; - my @existing = grep { !/\.src\.rpm$/ } glob("$to/xCAT-genesis-base-$tarch-*.rpm"); - my %have = map { basename($_) => 1 } @existing; - # Already exactly the fresh set (same basenames)? idempotent no-op. - if (scalar(keys %want) == scalar(keys %have) && !grep { !$have{$_} } keys %want) { - return 0; - } - for my $old (@existing) { - unlink $old or die "Failed to remove stale genesis $old: $!\n"; - print "[finalize] - " . basename($old) . " (stale foreign-arch, removed from $to)\n"; - } - my $copied = 0; - for my $base (sort keys %want) { - copy($want{$base}, "$to/$base") - or die "Failed to cross-copy genesis $want{$base} -> $to: $!\n"; - print "[finalize] + $base ($from -> $to)\n"; - # The source rpm is already signed by the build, but re-assert it under --gpg-sign - # so the deploy signing gate never sees an unsigned cross-copied rpm. - if ($gpg_sign) { - local $ENV{GNUPGHOME} = $gpg_home if $gpg_home; - run_simple(qq(rpmsign --define "%_gpg_name $gpg_key_name" --addsign ) - . sh_quote("$to/$base")); - } - $copied++; - } - return $copied; -} # Re-run createrepo_c on a repo whose rpm set changed, and (under --gpg-sign) re-sign + # re-export repomd. Does NOT re-sign the rpms (cross_copy_genesis already did the copied @@ -950,10 +885,6 @@ Notes: USAGE } -sub print_step { - my ($msg) = @_; - print "\n== $msg ==\n"; -} sub require_command { my ($cmd) = @_; @@ -1119,71 +1050,11 @@ sub run_build_steps_parallel { return sort keys %failed; } -# have_rpm: is there a non-src rpm named -... under $dir? -sub have_rpm { - my ($dir, $name) = @_; - my @m = grep { !/\.src\.rpm$/ } glob("$dir/${name}-*.rpm"); - return scalar(@m) > 0; -} -# rpm_version: %{version} of the built binary rpm named under $dir (undef if absent). -# Skips src/debug rpms and confirms the rpm's real %{name} matches (glob can over-match). -# 'xCAT-genesis-base' matches the arch-suffixed rpm name (xCAT-genesis-base-x86_64 / -ppc64). -# version_matches: does the built version $got satisfy the manifest pin $want? $want may be an -# exact version (2.19.0), a shell-style glob (2.* or 2.19.*), or '*' (any). Globs support * and -# ? and are anchored. Used so xCAT-genesis-base can pin 2.* (its Version walks with xcat-core) -# while the real xcat-dep packages stay exactly pinned. -sub version_matches { - my ($got, $want) = @_; - return 1 if !defined($want) || $want eq '*'; - return ($got eq $want) unless $want =~ /[*?]/; - my $re = quotemeta($want); - $re =~ s/\\\*/.*/g; - $re =~ s/\\\?/./g; - return $got =~ /\A$re\z/ ? 1 : 0; -} -sub rpm_version { - my ($dir, $name) = @_; - my $glob = ($name eq 'xCAT-genesis-base') - ? "$dir/xCAT-genesis-base-*.rpm" - : "$dir/${name}-*.rpm"; - for my $f (sort glob($glob)) { - next if $f =~ /\.src\.rpm$/ || $f =~ /-debug(?:info|source)-/; - my $n = `rpm -qp --qf '%{name}' ${\ sh_quote($f)} 2>/dev/null`; - my $match = ($name eq 'xCAT-genesis-base') - ? ($n =~ /^xCAT-genesis-base-/) : ($n eq $name); - next unless $match; - my $v = `rpm -qp --qf '%{version}' ${\ sh_quote($f)} 2>/dev/null`; - chomp $v; - return $v; - } - return undef; -} -# read_manifest: parse packages-manifest.conf into %{ target => { package => version|'*' } }. -# INI format: [target] sections; "package=version|*" entries; blank / "#" / ";" lines ignored. -# Returns an empty hash if the file is absent (callers that build require a section per target). -sub read_manifest { - my ($path) = @_; - my %m; - return %m unless -f $path; - open my $fh, '<', $path or die "Cannot read manifest $path: $!\n"; - my $sec; - while (my $line = <$fh>) { - $line =~ s/\r?\n\z//; - $line =~ s/^\s+|\s+$//g; - next if $line eq '' || $line =~ /^[#;]/; - if ($line =~ /^\[(.+?)\]$/) { $sec = $1; $m{$sec} ||= {}; next; } - next unless defined $sec; - my ($k, $v) = split /=/, $line, 2; - $k =~ s/\s+\z//; - $v = defined($v) ? ($v =~ s/^\s+//r) : ''; - $m{$sec}{$k} = ($v ne '') ? $v : '*'; - } - close $fh; - return %m; -} + + # assert_required_deps: the per-EL dep repo is unusable without these, so a MISSING one is # fatal even though individual builder failures are tolerated above. genesis-base is required @@ -1196,9 +1067,10 @@ sub assert_required_deps { # elilo-xcat is noarch but xCAT hard-requires it (Requires: elilo-xcat >= 3.14-6) on EVERY arch, # so a missing elilo makes the whole dep repo uninstallable -- it MUST be required here, not # silently tolerated (it builds from a tracked prebuilt on ppc64le/EL8, compiled elsewhere). - my @req = qw(elilo-xcat ipmitool-xcat syslinux-xcat grub2-xcat xnba-undi - perl-IO-Stty perl-HTTP-Async perl-Net-HTTPS-NB); - push @req, 'xCAT-genesis-base' unless $skip_genesis; + # A package whose builder was skipped is not required (else a clean --skip-* run fails). + my @all = qw(elilo-xcat ipmitool-xcat syslinux-xcat grub2-xcat xnba-undi + perl-IO-Stty perl-HTTP-Async perl-Net-HTTPS-NB xCAT-genesis-base); + my @req = required_pkgs(\@all, $skip_genesis, $skip_perl, $skip_xcat_dep); my @missing = grep { !have_rpm($dir, $_) } @req; die "FATAL: required deps missing from $dir: @missing\n" if @missing; print "[deps] required set present in $dir: @req\n"; @@ -1443,9 +1315,3 @@ sub slurp_chomp { return $line // ''; } -sub sh_quote { - my ($s) = @_; - $s = '' if !defined $s; - $s =~ s/'/'"'"'/g; - return "'$s'"; -} diff --git a/t/mockbuild-all.t b/t/mockbuild-all.t new file mode 100644 index 0000000..9f0b690 --- /dev/null +++ b/t/mockbuild-all.t @@ -0,0 +1,142 @@ +#!/usr/bin/perl +# Focused fixture tests for the xcat-dep build helpers (MockBuildUtils.pm), covering the review +# feedback on PR #62: skip-mode package selection, version pins, RPM-identity comparison in the +# cross-arch genesis finalize, and the "require the genesis input" guard. +use strict; +use warnings; +use Test::More; +use FindBin qw($RealBin); +use lib "$RealBin/.."; +use File::Temp qw(tempdir); +use File::Path qw(make_path); +use File::Basename qw(basename); +use MockBuildUtils qw(required_pkgs version_matches rpm_sigmd5 + cross_copy_genesis finalize_xcat_dep read_manifest); + +# Run a printing sub with STDOUT muted so its progress lines do not pollute TAP. +sub quiet(&) { + my ($code) = @_; + open(my $save, '>&', \*STDOUT) or die "dup STDOUT: $!"; + open(STDOUT, '>', '/dev/null') or die "mute STDOUT: $!"; + my @r = eval { $code->() }; + my $err = $@; + open(STDOUT, '>&', $save) or die "restore STDOUT: $!"; + die $err if $err; + return wantarray ? @r : $r[0]; +} + +# ---- required_pkgs: a skipped builder's packages are not required (clean --skip-* runs) ------- +my @all = qw(elilo-xcat ipmitool-xcat perl-IO-Stty perl-Sys-Virt xCAT-genesis-base); +is_deeply([required_pkgs(\@all, 0, 0, 0)], \@all, + 'no skips -> every package required'); +is_deeply([required_pkgs(\@all, 1, 0, 0)], [qw(elilo-xcat ipmitool-xcat perl-IO-Stty perl-Sys-Virt)], + '--skip-genesis drops xCAT-genesis-base'); +is_deeply([required_pkgs(\@all, 0, 1, 0)], [qw(elilo-xcat ipmitool-xcat xCAT-genesis-base)], + '--skip-perl drops perl-*'); +is_deeply([required_pkgs(\@all, 0, 0, 1)], [qw(perl-IO-Stty perl-Sys-Virt xCAT-genesis-base)], + '--skip-xcat-dep drops the dep builders'); +is_deeply([required_pkgs(\@all, 1, 1, 1)], [], + 'all skips -> nothing required (a clean skip run validates nothing)'); + +# ---- version_matches: exact + shell-glob pins ------------------------------------------------ +ok( version_matches('2.19.0', '2.*'), '2.* matches 2.19.0'); +ok( version_matches('2.18.2', '2.*'), '2.* matches 2.18.2 (walks with xcat-core)'); +ok(!version_matches('3.0.0', '2.*'), '2.* rejects 3.0.0'); +ok(!version_matches('20.0', '2.*'), '2.* rejects 20.0 (anchored, literal dot)'); +ok( version_matches('2.19.0', '2.19.*'), '2.19.* matches 2.19.0'); +ok(!version_matches('2.20.0', '2.19.*'), '2.19.* rejects 2.20.0'); +ok( version_matches('1.8.18', '1.8.18'), 'exact pin matches'); +ok(!version_matches('1.8.19', '1.8.18'), 'exact pin rejects a different version'); +ok( version_matches('anything', '*'), "'*' matches any version"); + +# ---- read_manifest: sections + entries ------------------------------------------------------- +{ + my $dir = tempdir(CLEANUP => 1); + my $f = "$dir/m.conf"; + open my $fh, '>', $f or die; + print $fh "# comment\n[alma+epel-8-x86_64]\nelilo-xcat=3.14\nxCAT-genesis-base=2.*\n\n" + . "[alma+epel-9-x86_64]\nperl-Sys-Virt=11.10.0\n"; + close $fh; + my %m = read_manifest($f); + is($m{'alma+epel-8-x86_64'}{'elilo-xcat'}, '3.14', 'read_manifest: exact pin'); + is($m{'alma+epel-8-x86_64'}{'xCAT-genesis-base'},'2.*', 'read_manifest: glob pin'); + is($m{'alma+epel-9-x86_64'}{'perl-Sys-Virt'}, '11.10.0', 'read_manifest: second section'); + is_deeply({read_manifest("$dir/nope.conf")}, {}, 'read_manifest: missing file -> empty'); +} + +# ---- RPM-identity comparison + cross_copy_genesis (needs rpmbuild for real rpms) -------------- +SKIP: { + skip 'rpmbuild not available', 5 if system('command -v rpmbuild >/dev/null 2>&1') != 0; + my $tmp = tempdir(CLEANUP => 1); + my $seq = 0; + my $mk = sub { # build a genesis-named rpm with a given marker payload + my ($tarch, $content) = @_; + my $out = "$tmp/out" . (++$seq); # unique dir: same NVR would overwrite in a shared one + my $spec = "$tmp/$tarch-$seq.spec"; + open my $fh, '>', $spec or die; + print $fh <<"SPEC"; +Name: xCAT-genesis-base-$tarch +Version: 2.19.0 +Release: snapTEST +Summary: test fixture +License: EPL +BuildArch: noarch +%description +test fixture +%install +mkdir -p %{buildroot}/opt/xcat/t +echo '$content' > %{buildroot}/opt/xcat/t/marker +%files +/opt/xcat/t/marker +SPEC + close $fh; + system("rpmbuild -bb --quiet --define '_topdir $tmp/rpmb$seq' --define '_rpmdir $out' " + . "'$spec' >/dev/null 2>&1") == 0 or die "rpmbuild failed for $tarch/$content"; + my ($rpm) = glob("$out/noarch/xCAT-genesis-base-$tarch-*.rpm"); + return $rpm; + }; + my $rpmA = $mk->('ppc64', 'CONTENT_A'); + my $rpmB = $mk->('ppc64', 'CONTENT_B_is_different'); # same NVR/basename, different payload + + isnt(rpm_sigmd5($rpmA), rpm_sigmd5($rpmB), + 'rpm_sigmd5 differs for same-name rpms with different content'); + + my $base = basename($rpmA); + my ($from, $to) = ("$tmp/from", "$tmp/to"); + make_path($from, $to); + system("cp '$rpmA' '$from/$base'"); # the fresh source + system("cp '$rpmB' '$to/$base'"); # a STALE dest rpm sharing the filename + + my $n = quiet { cross_copy_genesis($from, $to, 'ppc64', undef) }; + ok($n >= 1, "cross_copy refreshes a stale same-name rpm by content (copied=$n)"); + is(rpm_sigmd5("$to/$base"), rpm_sigmd5($rpmA), + 'after cross_copy the dest matches the source content'); + + my $n2 = quiet { cross_copy_genesis($from, $to, 'ppc64', undef) }; + is($n2, 0, 'cross_copy is a no-op when content is already identical (idempotent)'); + + # A signer callback is invoked for each copied rpm. + my ($from2, $to2) = ("$tmp/from2", "$tmp/to2"); + make_path($from2, $to2); + system("cp '$rpmA' '$from2/$base'"); + my @signed; + quiet { cross_copy_genesis($from2, $to2, 'ppc64', sub { push @signed, $_[0] }) }; + is_deeply(\@signed, ["$to2/$base"], 'the sign callback runs on each copied rpm'); +} + +# ---- finalize_xcat_dep: require the genesis inputs (no silent no-op) -------------------------- +{ + my $tmp = tempdir(CLEANUP => 1); + make_path("$tmp/x/rh9/x86_64", "$tmp/p/rh9/ppc64le"); # a pair exists, but NO genesis rpms + my $ok = eval { quiet { finalize_xcat_dep("$tmp/x", "$tmp/p") }; 1 }; + ok(!$ok, 'finalize dies when a repo pair has no genesis rpms (was a silent success)'); + like($@, qr/no (x86_64|ppc64) xCAT-genesis-base/, + 'finalize error names the missing genesis input'); + + my $tmp2 = tempdir(CLEANUP => 1); # no /x86_64 pair at all + make_path("$tmp2/x", "$tmp2/p"); + my $ok2 = eval { quiet { finalize_xcat_dep("$tmp2/x", "$tmp2/p") }; 1 }; + ok(!$ok2, 'finalize dies when no /x86_64 + /ppc64le pair is found'); +} + +done_testing;