mirror of
https://github.com/xcat2/xcat-dep.git
synced 2026-09-12 04:26:25 +00:00
feat(xcat-dep): arch-array cross-arch finalize; verify every rpm signature
Two review follow-ups: - Cross-arch genesis matrix is now driven by a single @GENESIS_ARCHES array (arch -> xCAT tarch) in MockBuildUtils. finalize_xcat_dep discovers, gates, and N-way cross-copies over that list, so adding an arch later (aarch64, riscv64) is one entry there plus wiring its repo root. No behaviour change for the current x86_64/ppc64le pair (tests unchanged + a contract assertion on the array). - PR #62 review #4: the repo completeness gate verified only the repomd.xml signature. It now ALSO verifies every binary rpm's header signature is by the configured signing key -- resolve the key's accepted id set (primary + subkey ids) and check each rpm's RSAHEADER/DSAHEADER pgpsig key id, failing on any unsigned or foreign-signed rpm. Runs per-target in deploy_target and per-cell after --finalize-xcat-dep. A signed repomd over an unsigned rpm otherwise passed the gate yet DNF rejects the package at install. Added pure fixtures. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com>
This commit is contained in:
+80
-37
@@ -15,7 +15,7 @@ use Digest::MD5 qw(md5_hex);
|
||||
our @EXPORT_OK = qw(
|
||||
sh_quote print_step
|
||||
version_matches required_pkgs have_rpm read_manifest
|
||||
verify_repo_packages verify_repo_signature
|
||||
verify_repo_packages verify_repo_signature verify_rpm_signatures
|
||||
rpm_version rpm_release rpm_sigmd5 rpm_is_signed restamp_release_line
|
||||
cross_copy_genesis finalize_xcat_dep bump_dep_release_suffix
|
||||
build_mock_uniqueext
|
||||
@@ -110,6 +110,26 @@ sub verify_repo_signature {
|
||||
return @problems;
|
||||
}
|
||||
|
||||
# verify_rpm_signatures: pure decision for the per-rpm signature gate. $rpm_sigs is an arrayref of
|
||||
# [rpm_basename, observed_keyid|undef] (rpm reports the signing SUBKEY id); $accept is a hashref set
|
||||
# of acceptable key ids (the signing key's primary + subkey ids, lowercased). Returns one problem per
|
||||
# rpm that is unsigned or signed by a key not in the set. A signed repomd over unsigned/foreign-signed
|
||||
# rpms still makes DNF reject the install, so the packages must be checked, not just the metadata.
|
||||
sub verify_rpm_signatures {
|
||||
my ($rpm_sigs, $accept) = @_;
|
||||
my @problems;
|
||||
for my $rs (@$rpm_sigs) {
|
||||
my ($name, $kid) = @$rs;
|
||||
if (!defined($kid) || $kid eq '') {
|
||||
push @problems, "UNSIGNED rpm $name";
|
||||
} elsif (!$accept->{ lc $kid }) {
|
||||
push @problems, "WRONGKEY rpm $name: signed by $kid, expected one of "
|
||||
. join('/', sort keys %$accept);
|
||||
}
|
||||
}
|
||||
return @problems;
|
||||
}
|
||||
|
||||
# have_rpm: is there a non-src rpm named <name>-... under $dir?
|
||||
sub have_rpm {
|
||||
my ($dir, $name) = @_;
|
||||
@@ -280,6 +300,17 @@ sub cross_copy_genesis {
|
||||
# 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.
|
||||
# Architectures whose xCAT-genesis-base is cross-provisioned into every peer repo, so a management
|
||||
# node can netboot nodes of any arch (issue #7610). Each entry maps the repo/subdir arch name to the
|
||||
# genesis rpm's xCAT "tarch" (xCAT collapses ppc/ppc64le into tarch ppc64; x86_64 stays x86_64). This
|
||||
# is the SINGLE SOURCE OF TRUTH for the cross-arch matrix -- to add an arch later (e.g. aarch64,
|
||||
# riscv64) add an entry here AND wire its repo root into finalize_xcat_dep's %repo (the caller passes
|
||||
# it). Discovery, the per-arch input gate, and the N-way cross-copy all iterate this list.
|
||||
our @GENESIS_ARCHES = (
|
||||
{ arch => 'x86_64', tarch => 'x86_64' },
|
||||
{ arch => 'ppc64le', tarch => 'ppc64' },
|
||||
);
|
||||
|
||||
sub finalize_xcat_dep {
|
||||
my ($x86_64_repo, $ppc64le_repo, %opt) = @_;
|
||||
my $sign = $opt{sign};
|
||||
@@ -287,47 +318,59 @@ sub finalize_xcat_dep {
|
||||
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";
|
||||
# Discover the UNION of OS dirs from BOTH arch repos. Anchoring discovery on x86_64 alone let a
|
||||
# ppc64le-only <os> (an rh<N> that built for ppc but not x86_64) slip through unseen -- finalize
|
||||
# then never cross-populated that cell's x86_64 genesis and still exited 0 (PR #62 review). Both
|
||||
# arch peers are required for every discovered <os> below, so the check is now symmetric.
|
||||
|
||||
# Per-arch repo root, keyed by the @GENESIS_ARCHES arch name. A new arch added to that list must
|
||||
# also get its root wired here (today both roots are the same CD tree); a missing one fails loudly
|
||||
# below rather than silently skipping.
|
||||
my %repo = ( x86_64 => $x86_64_repo, ppc64le => $ppc64le_repo );
|
||||
|
||||
# Discover the UNION of OS dirs across ALL arch repos. Anchoring discovery on one arch let an
|
||||
# <os> built for only the OTHER arch slip through unseen -- finalize then never cross-populated
|
||||
# that cell and still exited 0 (PR #62 review). Every discovered <os> must carry every arch below.
|
||||
my %os;
|
||||
$os{ basename($_) } = 1 for grep { -d "$_/x86_64" } glob("$x86_64_repo/*");
|
||||
$os{ basename($_) } = 1 for grep { -d "$_/ppc64le" } glob("$ppc64le_repo/*");
|
||||
for my $a (@GENESIS_ARCHES) {
|
||||
my $root = $repo{ $a->{arch} }
|
||||
// die "FATAL: [finalize] no repo root configured for arch '$a->{arch}' (wire it in %repo)\n";
|
||||
$os{ basename($_) } = 1 for grep { -d "$_/$a->{arch}" } glob("$root/*");
|
||||
}
|
||||
|
||||
my $pairs = 0;
|
||||
for my $osdir (sort keys %os) {
|
||||
my $x86dir = "$x86_64_repo/$osdir/x86_64";
|
||||
my $ppcdir = "$ppc64le_repo/$osdir/ppc64le";
|
||||
# Both arch peers must exist: in the CD both arches build every EL, so a one-arch <os> is an
|
||||
# incomplete input, not something to skip past (skipping would leave a cell without the
|
||||
# foreign-arch genesis and still exit 0). Symmetric -- catches an x86_64-only AND a
|
||||
# ppc64le-only <os>.
|
||||
die "FATAL: [finalize] $osdir: no x86_64 peer repo at $x86dir\n"
|
||||
. " (both arches must build every EL before finalize)\n" if !-d $x86dir;
|
||||
die "FATAL: [finalize] $osdir: no ppc64le peer repo at $ppcdir\n"
|
||||
. " (both arches must build every EL before finalize)\n" if !-d $ppcdir;
|
||||
# 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);
|
||||
# Re-index+sign BOTH repos of the pair every finalize, not only when an rpm was copied this
|
||||
# run. A crash after a prior run's copy+sign but before its createrepo leaves the genesis rpm
|
||||
# on disk (so cross_copy_genesis now returns 0) yet ABSENT from repomd.xml -- which no
|
||||
# signature gate catches. Re-indexing is cheap (these are tiny repos) and idempotent, and it
|
||||
# heals that partial state; skipped only when no signer/indexer was injected.
|
||||
if ($reindex) { $reindex->($x86dir); $reindex->($ppcdir); }
|
||||
printf "[finalize] %s: %d ppc64 genesis -> x86_64, %d x86_64 genesis -> ppc64le\n",
|
||||
$osdir, $to_x86, $to_ppc;
|
||||
my %adir = map { $_->{arch} => "$repo{$_->{arch}}/$osdir/$_->{arch}" } @GENESIS_ARCHES;
|
||||
# Pass 1 -- every arch peer repo dir must exist: a one-arch <os> is an incomplete input, not
|
||||
# something to skip past (skipping would leave a cell without a foreign-arch genesis and still
|
||||
# exit 0). Checked before the rpm pass so a missing peer is reported as such. Symmetric across
|
||||
# all arches (catches an x86_64-only AND a ppc64le-only <os>).
|
||||
for my $a (@GENESIS_ARCHES) {
|
||||
die "FATAL: [finalize] $osdir: no $a->{arch} peer repo at $adir{$a->{arch}}\n"
|
||||
. " (every arch must build every EL before finalize)\n" if !-d $adir{ $a->{arch} };
|
||||
}
|
||||
# Pass 2 -- every arch must have produced its OWN genesis rpm before finalize cross-populates
|
||||
# them; otherwise a pair with no genesis rpms would make finalize a silent no-op that still
|
||||
# exits 0. xCAT collapses ppc/ppc64le into tarch=ppc64, so match on each arch's tarch.
|
||||
for my $a (@GENESIS_ARCHES) {
|
||||
die "FATAL: [finalize] $osdir: no $a->{arch} xCAT-genesis-base rpm (tarch $a->{tarch}) in $adir{$a->{arch}}\n"
|
||||
if !grep { !/\.src\.rpm$/ } glob("$adir{$a->{arch}}/xCAT-genesis-base-$a->{tarch}-*.rpm");
|
||||
}
|
||||
# N-way cross-copy: put each arch's genesis into EVERY other arch's repo dir.
|
||||
my @summary;
|
||||
for my $src (@GENESIS_ARCHES) {
|
||||
for my $dst (@GENESIS_ARCHES) {
|
||||
next if $src->{arch} eq $dst->{arch};
|
||||
my $n = cross_copy_genesis($adir{$src->{arch}}, $adir{$dst->{arch}}, $src->{tarch}, $sign);
|
||||
push @summary, "$n $src->{tarch} -> $dst->{arch}";
|
||||
}
|
||||
}
|
||||
# Re-index+sign EVERY arch repo of this <os> each finalize, not only when an rpm was copied
|
||||
# this run: a crash after a prior run's copy+sign but before its createrepo leaves the genesis
|
||||
# rpm on disk (so cross_copy_genesis now returns 0) yet ABSENT from repomd.xml -- which no
|
||||
# signature gate catches. Re-indexing is cheap (tiny repos) and idempotent, and heals that
|
||||
# partial state; skipped only when no signer/indexer was injected.
|
||||
if ($reindex) { $reindex->($adir{$_->{arch}}) for @GENESIS_ARCHES; }
|
||||
print "[finalize] $osdir: " . join(', ', @summary) . "\n";
|
||||
$pairs++;
|
||||
}
|
||||
die "FATAL: --finalize-xcat-dep found no <os>/x86_64 + <os>/ppc64le repo pair under\n"
|
||||
die "FATAL: --finalize-xcat-dep found no <os> repo dir under any arch root\n"
|
||||
. " --x86_64-repo '$x86_64_repo'\n --ppc64le-repo '$ppc64le_repo'\n" if $pairs == 0;
|
||||
print_step('Finalize complete');
|
||||
}
|
||||
|
||||
+47
-2
@@ -14,7 +14,7 @@ use POSIX qw(strftime);
|
||||
use FindBin qw($RealBin);
|
||||
use lib $RealBin;
|
||||
use MockBuildUtils qw(sh_quote print_step version_matches required_pkgs
|
||||
read_manifest verify_repo_packages verify_repo_signature
|
||||
read_manifest verify_repo_packages verify_repo_signature verify_rpm_signatures
|
||||
rpm_version rpm_release rpm_sigmd5 restamp_release_line
|
||||
cross_copy_genesis finalize_xcat_dep bump_dep_release_suffix
|
||||
build_mock_uniqueext);
|
||||
@@ -1196,6 +1196,37 @@ sub gpg_key_fingerprint {
|
||||
return $fprs[0];
|
||||
}
|
||||
|
||||
# gpg_key_ids: all acceptable key ids (lowercased) for a signing key NAME -- the primary key id AND
|
||||
# every subkey id, in both 16-hex (long) and 8-hex (short) forms. rpm header signatures report the
|
||||
# signing SUBKEY id, so the per-rpm gate accepts any id belonging to the key rather than one exact
|
||||
# fingerprint. Returns a hashref set (empty if the key can't be listed).
|
||||
sub gpg_key_ids {
|
||||
my ($keyname, $home) = @_;
|
||||
my $h = ($home ne '') ? ' --homedir ' . sh_quote($home) : '';
|
||||
my $out = `gpg$h --with-colons --list-keys ${\ sh_quote($keyname)} 2>/dev/null` // '';
|
||||
my %ids;
|
||||
for my $line (split /\n/, $out) {
|
||||
my @f = split /:/, $line;
|
||||
next unless ($f[0] // '') =~ /^(?:pub|sub)$/ && defined $f[4] && $f[4] ne '';
|
||||
my $id = $f[4];
|
||||
$ids{ lc $id } = 1;
|
||||
$ids{ lc substr($id, -16) } = 1 if length($id) > 16;
|
||||
$ids{ lc substr($id, -8) } = 1 if length($id) > 8;
|
||||
}
|
||||
return \%ids;
|
||||
}
|
||||
|
||||
# rpm_signer_keyid: the signing key id (lowercased hex) of a built rpm's header signature, or undef
|
||||
# when the rpm is not signed. Reads the RSA (or DSA) header pgpsig and pulls the "Key ID <hex>" field.
|
||||
sub rpm_signer_keyid {
|
||||
my ($rpm) = @_;
|
||||
for my $tag (qw(RSAHEADER DSAHEADER)) {
|
||||
my $out = `rpm -qp --qf '%{$tag:pgpsig}' ${\ sh_quote($rpm)} 2>/dev/null` // '';
|
||||
return lc($1) if $out =~ /Key ID\s+([0-9A-Fa-f]+)/i;
|
||||
}
|
||||
return undef;
|
||||
}
|
||||
|
||||
# repomd_observed_signer: run gpg --verify on the detached repomd signature and extract the identity
|
||||
# of the key that actually signed it, as a primary-key fingerprint (the last field of the VALIDSIG
|
||||
# status line). Returns '' when the .asc is absent or verification fails (both read as "unsigned").
|
||||
@@ -1255,6 +1286,20 @@ sub verify_target_repo {
|
||||
my %exp_sig = ('repomd' => $exp_fpr);
|
||||
my %obs_sig = ('repomd' => repomd_observed_signer($asc, $repomd, $gpg_home));
|
||||
push @problems, verify_repo_signature(\%exp_sig, \%obs_sig);
|
||||
|
||||
# Per-rpm signature gate: a signed repomd over an unsigned or foreign-signed rpm still
|
||||
# makes DNF reject that package at install time, so verify EVERY binary rpm -- not just the
|
||||
# metadata -- is signed by this key (rpm reports the signing subkey id; accept any id of
|
||||
# the key). Closes the "approves a repo DNF later rejects" gap (PR #62 review #4).
|
||||
require_command('rpm');
|
||||
my $accept = gpg_key_ids($gpg_key_name, $gpg_home);
|
||||
if (!%$accept) {
|
||||
push @problems, "SIGKEY: cannot list key ids for '$gpg_key_name' to verify per-rpm signatures";
|
||||
} else {
|
||||
my @rpm_sigs = map { [ basename($_), rpm_signer_keyid($_) ] }
|
||||
grep { !/\.src\.rpm$/ } glob("$dir/*.rpm");
|
||||
push @problems, verify_rpm_signatures(\@rpm_sigs, $accept);
|
||||
}
|
||||
}
|
||||
} elsif ($sig_required) {
|
||||
# Standalone --verify-repo advertises a signature check; with no keyring we cannot resolve the
|
||||
@@ -1269,7 +1314,7 @@ sub verify_target_repo {
|
||||
die "FATAL: repo INCOMPLETE for $tgt at $dir (" . scalar(@problems) . " problem(s))\n";
|
||||
}
|
||||
print "[verify-repo] $tgt complete: " . scalar(@names)
|
||||
. " required packages present + version-pinned in $dir\n";
|
||||
. " required packages present + version-pinned, every rpm signed, in $dir\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -12,8 +12,8 @@ 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
|
||||
build_mock_uniqueext);
|
||||
verify_repo_packages verify_repo_signature verify_rpm_signatures
|
||||
bump_dep_release_suffix build_mock_uniqueext);
|
||||
|
||||
# Run a printing sub with STDOUT muted so its progress lines do not pollute TAP.
|
||||
sub quiet(&) {
|
||||
@@ -184,6 +184,11 @@ SPEC
|
||||
my $ok4 = eval { quiet { finalize_xcat_dep("$tmp4/x", "$tmp4/p") }; 1 };
|
||||
ok(!$ok4, 'finalize dies when a ppc64le OS has no x86_64 peer repo (was silently skipped)');
|
||||
like($@, qr/no x86_64 peer repo/, 'finalize error names the missing x86_64 peer');
|
||||
|
||||
# @GENESIS_ARCHES is the single source of truth for the cross-arch matrix (add arches there).
|
||||
my %tarch = map { $_->{arch} => $_->{tarch} } @MockBuildUtils::GENESIS_ARCHES;
|
||||
is($tarch{x86_64}, 'x86_64', 'GENESIS_ARCHES: x86_64 maps to tarch x86_64');
|
||||
is($tarch{ppc64le}, 'ppc64', 'GENESIS_ARCHES: ppc64le maps to xCAT tarch ppc64');
|
||||
}
|
||||
|
||||
# ---- restamp_release_line: CD --build-number Release stamping (PR #62 review point 1) ----------
|
||||
@@ -334,6 +339,24 @@ is(rpm_release(tempdir(CLEANUP => 1), 'nonexistent-pkg'), undef, 'rpm_release is
|
||||
'verify_repo_signature: mismatch reported as WRONGKEY <unit>: signed by <obs>, expected <exp>');
|
||||
}
|
||||
|
||||
# ---- verify_rpm_signatures: EVERY rpm must be signed by an accepted key (PR #62 review #4) -----
|
||||
{
|
||||
my %accept = ( '4123c420cb60ad43' => 1, 'cb60ad43' => 1 ); # signing key's long + short id
|
||||
|
||||
my @ok = verify_rpm_signatures(
|
||||
[ ['a-1.0.rpm', 'cb60ad43'], ['b-2.0.rpm', '4123C420CB60AD43'] ], \%accept);
|
||||
is_deeply(\@ok, [], 'verify_rpm_signatures: all rpms signed by an accepted key -> no problems (case-insensitive)');
|
||||
|
||||
my @uns = verify_rpm_signatures([ ['c-3.0.rpm', undef], ['d-4.0.rpm', ''] ], \%accept);
|
||||
is(scalar(@uns), 2, 'verify_rpm_signatures: undef and empty key id both flagged');
|
||||
like($uns[0], qr/^UNSIGNED rpm c-3\.0\.rpm$/, 'verify_rpm_signatures: unsigned rpm reported by name');
|
||||
|
||||
my @wrong = verify_rpm_signatures([ ['e-5.0.rpm', 'deadbeef'] ], \%accept);
|
||||
is(scalar(@wrong), 1, 'verify_rpm_signatures: a foreign-signed rpm yields one problem');
|
||||
like($wrong[0], qr/^WRONGKEY rpm e-5\.0\.rpm: signed by deadbeef, expected one of\b/,
|
||||
'verify_rpm_signatures: wrong key reported as WRONGKEY rpm <name>: signed by <obs>, expected one of ...');
|
||||
}
|
||||
|
||||
# ---- build_mock_uniqueext: distinct per target so concurrent mock roots never collide ---------
|
||||
# (PR #62 review) A long (timestamp) run id must not tail-truncate away the leading EL/arch token:
|
||||
# for the 7-char "ppc64le" arch that dropped the EL digit, so alma+epel-{8,9,10}-ppc64le collapsed to
|
||||
|
||||
Reference in New Issue
Block a user