2
0
mirror of https://github.com/xcat2/xcat-dep.git synced 2026-09-14 10:13:49 +00:00
Files
xcat-dep/t/mockbuild-all.t
T
Daniel Hilst 25dfc3957a feat(build): manifest-driven repo completeness + signature gate, auto-run after build
Adds a real gate on the BUILT per-target repo, using packages-manifest.conf as the
single source of truth, layered so the decision is pure and unit-tested:

- MockBuildUtils::verify_repo_packages(\%expected,\%present) -- pure completeness
  (MISSING / VERSION vs the manifest pins); verify_repo_signature(\%expected,\%observed)
  -- pure signature identity (UNSIGNED / WRONGKEY). Both unit-tested (happy+sad).
- mockbuild-all.pl does the IO and calls both from one sub, verify_target_repo:
  reads the manifest, enumerates the repo via rpm_version, resolves --gpg-key-name to a
  primary-key fingerprint and extracts repomd.xml.asc's actual signer (VALIDSIG), then
  merges the two pure results and dies listing every problem.
- Runs AUTOMATICALLY at the end of deploy_target (after sign+index), replacing the old
  assert_required_deps + inline version-pin loop with one consolidated gate; suppressible
  with --no-verify-repo. Also a standalone build-free '--verify-repo=<repo>' mode (manifest
  from repo_root, gpg from --gpg-key-name/--gpg-home; target derived from the rh<N>/<arch>
  path or --target).

prove t/mockbuild-all.t: 68/68 (was 50). gpg round-trip smoke-tested: right key -> OK,
wrong key -> WRONGKEY, missing .asc -> UNSIGNED.

Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com>
2026-08-12 15:29:02 -03:00

311 lines
16 KiB
Perl

#!/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 rpm_version rpm_release rpm_is_signed
restamp_release_line cross_copy_genesis finalize_xcat_dep read_manifest
verify_repo_packages verify_repo_signature bump_dep_release_suffix);
# 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_sigmd5 on a missing/unreadable rpm returns '' (so cross_copy treats it as "not identical").
is(rpm_sigmd5('/nonexistent/xCAT-genesis-base-ppc64-9.9.9.noarch.rpm'), '',
'rpm_sigmd5 returns empty for a missing rpm');
# ---- RPM-identity comparison + cross_copy_genesis (needs rpmbuild for real rpms) --------------
SKIP: {
skip 'rpmbuild not available', 6 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, $version) = @_;
$version ||= '2.19.0';
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: $version
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');
# rpm_version dies when a dir holds two DIFFERENT versions of the same package (stale artifact).
my $vdir = "$tmp/vers"; make_path($vdir);
system("cp '" . $mk->('ppc64', 'x', '2.19.0') . "' '$vdir/'");
system("cp '" . $mk->('ppc64', 'x', '2.18.0') . "' '$vdir/'");
my $vdied = !eval { rpm_version($vdir, 'xCAT-genesis-base'); 1 };
ok($vdied, 'rpm_version dies when a dir holds multiple distinct versions of a package');
}
# ---- 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 <os>/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 <os>/x86_64 + <os>/ppc64le pair is found');
# A missing ppc64le PEER repo (not just missing rpms) is fatal, not a silent skip.
my $tmp3 = tempdir(CLEANUP => 1);
make_path("$tmp3/x/rh9/x86_64"); # x86_64 OS present, but NO ppc64le peer dir at all
my $ok3 = eval { quiet { finalize_xcat_dep("$tmp3/x", "$tmp3/p") }; 1 };
ok(!$ok3, 'finalize dies when an x86_64 OS has no ppc64le peer repo (no silent skip)');
like($@, qr/no ppc64le peer repo/, 'finalize error names the missing peer');
}
# ---- restamp_release_line: CD --build-number Release stamping (PR #62 review point 1) ----------
# A fresh stamp is appended after the Release token, preserving any %{?dist} macro.
{
my ($l, $ch) = restamp_release_line("Release: 1%{?dist}\n", '.snap202607161200.57');
is($l, "Release: 1%{?dist}.snap202607161200.57\n", 'stamps a fresh Release, macro preserved');
is($ch, 1, 'reports changed');
}
# Idempotent: the exact same suffix is a no-op (concurrent per-arch build / same-tree re-run).
{
my $line = "Release: 1%{?dist}.snap202607161200.57\n";
my ($l, $ch) = restamp_release_line($line, '.snap202607161200.57');
is($l, $line, 're-stamping the SAME suffix is a no-op');
is($ch, 0, 'reports unchanged');
}
# A DIFFERENT build-number REPLACES the prior stamp (does not accumulate) -- the double-stamp bug.
{
my ($l, $ch) = restamp_release_line("Release: 1%{?dist}.snap202607161200.57\n", '.snap202607161200.58');
is($l, "Release: 1%{?dist}.snap202607161200.58\n", 'a new build-number replaces the old stamp');
is($ch, 1, 'reports changed');
unlike($l, qr/\.snap\d{12}\.\d+\.snap/, 'never leaves two stacked .snap stamps');
}
# Even an already-corrupted (double-stamped) line is healed back to a single stamp.
{
my ($l) = restamp_release_line("Release: 5.snap202601010000.1.snap202601020000.2\n", '.snap202607161200.9');
is($l, "Release: 5.snap202607161200.9\n", 'strips multiple stacked prior stamps before re-stamping');
}
# A non-Release line is never touched.
{
my ($l, $ch) = restamp_release_line("Version: 0.3.3\n", '.snap202607161200.57');
is($l, "Version: 0.3.3\n", 'non-Release line untouched');
is($ch, 0, 'reports unchanged');
}
# ---- rpm_is_signed: unreadable / missing -> not signed (used by the finalize idempotency fix) ---
is(rpm_is_signed(undef), 0, 'rpm_is_signed(undef) is 0');
is(rpm_is_signed("/no/such/file.rpm"), 0, 'rpm_is_signed on a missing file is 0');
# ---- rpm_release: absent package -> undef (used by the --build-number bump-landed check) ---------
is(rpm_release(tempdir(CLEANUP => 1), 'nonexistent-pkg'), undef, 'rpm_release is undef when no rpm matches');
# ---- manifest <-> docs consistency: conserver-xcat is in EVERY target section (PR #62 point 7c) --
# BUILD.md documents conserver-xcat as built for every target; guard that the manifest agrees so the
# doc and the manifest can never silently drift apart again.
{
my %m = read_manifest("$RealBin/../packages-manifest.conf");
my @targets = sort keys %m;
cmp_ok(scalar(@targets), '>=', 1, 'packages-manifest.conf has at least one target section');
my @missing = grep { !exists $m{$_}{'conserver-xcat'} } @targets;
is_deeply(\@missing, [], 'conserver-xcat is present in every manifest target section')
or diag("missing conserver-xcat in: @missing");
}
# ---- bump_dep_release_suffix: stamps xcat-dep specs, prunes nested xcat-core, idempotent --------
# Reviewer asked for a test on this path. It walks a tree, stamps the first Release: line of every
# xcat-dep spec, prunes a nested xcat-core/ checkout, and is idempotent on a re-run.
{
my $tmp = tempdir(CLEANUP => 1);
# (a) a top-level dep spec that MUST be stamped
open my $a, '>', "$tmp/a.spec" or die;
print $a "Name: a\nVersion: 1.0\nRelease: 5%{?dist}\n";
close $a;
# (b) a spec NESTED under xcat-core/ that MUST be pruned (left untouched)
make_path("$tmp/xcat-core");
open my $b, '>', "$tmp/xcat-core/b.spec" or die;
print $b "Name: b\nVersion: 1.0\nRelease: 9\n";
close $b;
# (c) a spec with no Release: line at all (ignored, never stamped)
open my $c, '>', "$tmp/c.spec" or die;
print $c "Name: c\nVersion: 1.0\n";
close $c;
my $n = quiet { bump_dep_release_suffix($tmp, '.snap202601010000') };
is($n, 1, 'bump_dep_release_suffix stamps exactly the one dep spec with a Release line');
my $a_after = do { open my $fh, '<', "$tmp/a.spec" or die; local $/; <$fh> };
like($a_after, qr/^Release: 5%\{\?dist\}\.snap202601010000$/m,
'a.spec Release now carries the CD suffix, macro preserved');
my $b_after = do { open my $fh, '<', "$tmp/xcat-core/b.spec" or die; local $/; <$fh> };
is($b_after, "Name: b\nVersion: 1.0\nRelease: 9\n",
'nested xcat-core/b.spec is pruned and left untouched');
# A SECOND call is idempotent: nothing newly stamped, a.spec content unchanged.
my $n2 = quiet { bump_dep_release_suffix($tmp, '.snap202601010000') };
is($n2, 0, 'a second bump_dep_release_suffix call stamps nothing (idempotent)');
my $a_again = do { open my $fh, '<', "$tmp/a.spec" or die; local $/; <$fh> };
is($a_again, $a_after, 'a.spec content unchanged on the idempotent second call');
}
# ---- verify_repo_packages: pure repo-completeness decision (MISSING + VERSION + wildcard) ---------
# The gate's completeness layer: given manifest pins and the versions actually present in a repo,
# return the list of problems (empty = complete). No I/O -- exercised directly with plain hashes.
{
# happy: every required package present, one exact-pinned + one wildcard -> no problems.
my @p = verify_repo_packages({ a => '1.0', b => '*' }, { a => '1.0', b => '9.9' });
is_deeply(\@p, [], 'verify_repo_packages: all present + pins satisfied -> 0 problems');
# missing: present lacks 'a' entirely -> exactly one MISSING problem naming 'a'.
my @m = verify_repo_packages({ a => '1.0', b => '*' }, { b => '9.9' });
is(scalar(@m), 1, 'verify_repo_packages: an absent package yields exactly one problem');
like($m[0], qr/^MISSING a\b/, 'verify_repo_packages: absent package reported as MISSING <pkg>');
# missing via explicit undef present value is treated the same as absent.
my @mu = verify_repo_packages({ a => '1.0' }, { a => undef });
is(scalar(@mu), 1, 'verify_repo_packages: undef present version counts as MISSING');
like($mu[0], qr/^MISSING a\b/, 'verify_repo_packages: undef present version reported as MISSING');
# version: present but the wrong version -> exactly one VERSION problem naming 'a'.
my @v = verify_repo_packages({ a => '1.0' }, { a => '2.0' });
is(scalar(@v), 1, 'verify_repo_packages: a mismatched version yields exactly one problem');
like($v[0], qr/^VERSION a\b/, 'verify_repo_packages: version mismatch reported as VERSION <pkg>');
# wildcard: a '*' pin accepts any present version -> no problem.
my @w = verify_repo_packages({ c => '*' }, { c => '0.0.1' });
is_deeply(\@w, [], "verify_repo_packages: '*' pin accepts any present version");
# combined: one MISSING and one VERSION -> two problems (sorted by package name: a before b).
my @c = verify_repo_packages({ a => '1.0', b => '2.0' }, { b => '9.9' });
is(scalar(@c), 2, 'verify_repo_packages: one MISSING + one VERSION -> two problems');
like($c[0], qr/^MISSING a\b/, 'verify_repo_packages: combined case reports MISSING a');
like($c[1], qr/^VERSION b\b/, 'verify_repo_packages: combined case reports VERSION b');
}
# ---- verify_repo_signature: pure signature decision (match / unsigned / wrongkey) ----------------
# Given the expected signing-key identity per unit and the key that actually signed, return the list
# of problems (empty = every unit signed by the expected key). Plain string compare -- no gpg here.
{
# happy: repomd signed by exactly the expected key -> no problems.
my @ok = verify_repo_signature({ repomd => 'KEYFPR' }, { repomd => 'KEYFPR' });
is_deeply(\@ok, [], 'verify_repo_signature: observed == expected -> 0 problems');
# unsigned: observed empty -> one UNSIGNED problem naming the unit + expected key.
my @us = verify_repo_signature({ repomd => 'KEYFPR' }, { repomd => '' });
is(scalar(@us), 1, 'verify_repo_signature: empty observed yields exactly one problem');
like($us[0], qr/^UNSIGNED repomd\b/, 'verify_repo_signature: empty observed reported as UNSIGNED');
like($us[0], qr/expected KEYFPR/, 'verify_repo_signature: UNSIGNED names the expected key');
# unsigned via explicit undef observed is treated the same as empty.
my @uu = verify_repo_signature({ repomd => 'KEYFPR' }, { repomd => undef });
like($uu[0], qr/^UNSIGNED repomd\b/, 'verify_repo_signature: undef observed reported as UNSIGNED');
# wrongkey: signed, but by a different key -> one WRONGKEY problem naming both.
my @wk = verify_repo_signature({ repomd => 'GOODFPR' }, { repomd => 'EVILFPR' });
is(scalar(@wk), 1, 'verify_repo_signature: a mismatched signer yields exactly one problem');
like($wk[0], qr/^WRONGKEY repomd: signed by EVILFPR, expected GOODFPR$/,
'verify_repo_signature: mismatch reported as WRONGKEY <unit>: signed by <obs>, expected <exp>');
}
done_testing;