-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathMockBuildUtils.pm
More file actions
726 lines (682 loc) · 36.1 KB
/
Copy pathMockBuildUtils.pm
File metadata and controls
726 lines (682 loc) · 36.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
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);
use File::Glob qw(bsd_glob);
use File::Find;
use Sys::Hostname;
use Digest::MD5 qw(md5_hex);
our @EXPORT_OK = qw(
install_deps_packages install_deps_command missing_perl_modules
sh_quote print_step
version_matches required_pkgs skipped_builder carry_over_rpms rpm_name rpm_arch rpm_source_rpm
source_package rpm_digests_ok
have_rpm read_manifest
verify_repo_packages verify_repo_signature verify_rpm_signatures
parse_evr evr_cmp evr_constraint_ok parse_pin rpmkeys_checksig_problem
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 rpm_in_cell
);
# install_deps_packages($os_id): the host packages mockbuild-all.pl needs to run at all, for the
# given /etc/os-release ID. Kept as data, beside the code that needs them, because the failure mode
# is a build host provisioned by hand: xcat-master-ub had no perl-File-Slurper and xcat-master-ppc no
# perl-IPC-Cmd, and each surfaced as a compile-time abort in the middle of a CD run.
sub install_deps_packages {
my ($os_id) = @_;
$os_id = '' unless defined $os_id;
# The perl modules are what actually break a run; the rest is the toolchain the script drives.
return qw(perl perl-File-Slurper perl-IPC-Cmd perl-Parallel-ForkManager perl-Digest-SHA
mock createrepo_c tar findutils rpm rpm-build rpm-sign rpmdevtools gnupg2 wget git)
if $os_id =~ /^(?:opensuse|sles|sled)/;
return qw(perl perl-File-Slurper perl-IPC-Cmd perl-Parallel-ForkManager perl-Digest-SHA
mock createrepo_c tar findutils rpm rpm-build rpm-sign rpmdevtools
dnf-plugins-core gnupg2 wget git);
}
# install_deps_command($os_id): the argv that installs them, non-interactively.
sub install_deps_command {
my ($os_id) = @_;
$os_id = '' unless defined $os_id;
my @pkgs = install_deps_packages($os_id);
return ('zypper', '--non-interactive', 'install', '--no-recommends', @pkgs)
if $os_id =~ /^(?:opensuse|sles|sled)/;
return ('dnf', '-y', 'install', @pkgs);
}
# missing_perl_modules(@modules): those that cannot be loaded, in order. The point of --install-deps
# is that the run AFTER it cannot die on a missing module, so the modules are proven by loading
# them, not by trusting the package manager's exit code.
sub missing_perl_modules {
my (@modules) = @_;
my @missing;
for my $m (@modules) {
my $file = $m;
$file =~ s{::}{/}g;
$file .= '.pm';
eval { require $file; 1 } or push @missing, $m;
}
return @missing;
}
# 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;
}
# rpm_name: %{name} from the header of one rpm file, undef when rpm cannot read it.
sub rpm_name {
my ($rpm) = @_;
my $name = `rpm -qp --qf '%{name}' ${\ sh_quote($rpm)} 2>/dev/null`;
return (defined $name && $name ne '') ? $name : undef;
}
# rpm_digests_ok: whether the header and payload digests of one rpm file verify, signatures aside.
sub rpm_digests_ok {
my ($rpm) = @_;
my $out = `rpmkeys --checksig --nosignature -v ${\ sh_quote($rpm)} 2>&1`;
return ($? == 0 && $out !~ /NOT OK/i) ? 1 : 0;
}
# rpm_source_rpm: the %{sourcerpm} header of one rpm file, undef when rpm cannot read it. Every
# subpackage of one build shares it.
sub rpm_source_rpm {
my ($rpm) = @_;
my $srpm = `rpm -qp --qf '%{sourcerpm}' ${\ sh_quote($rpm)} 2>/dev/null`;
return (defined $srpm && $srpm =~ /\.src\.rpm$/) ? $srpm : undef;
}
# source_package: the source package name of a source rpm file name.
sub source_package {
my ($srpm) = @_;
return (defined $srpm && $srpm =~ /^(.+)-[^-]+-[^-]+\.src\.rpm$/) ? $1 : undef;
}
# skipped_builder: whether the builder that produces a source package was skipped by the flags.
# The classes are those of required_pkgs: xCAT-genesis-base, perl-*, and everything else the
# xcat-dep builders make. The OpenEmbedded Genesis is published from a release, not built here.
sub skipped_builder {
my ($source, $skipped) = @_;
return 0 if $source =~ /^xCAT-genesis-openembedded(?:-|$)/;
return $skipped->{genesis} ? 1 : 0 if $source =~ /^xCAT-genesis-base(?:-|$)/;
return $skipped->{perl} ? 1 : 0 if $source =~ /^perl-/;
return $skipped->{dep} ? 1 : 0;
}
# carry_over_rpms: copy the binary rpms a skipped builder published in $from into the run repository
# $to, for the builds whose source package the target manifest still names and of which the run
# carries no member yet. %$skipped holds the
# genesis, perl and dep flags; @$manifest_names is the target's manifest section; $name_of and
# $source_of map an rpm path to its package name and its source rpm (rpm_name and rpm_source_rpm
# in production, injectable for tests), and $trusted says whether an rpm may be re-signed at all
# (signed by the configured key in production), $arch is the cell's architecture and $arch_of
# reads an rpm's. A build is selected when one of its rpms is a manifest package, and is then
# carried whole, subpackages included, so a package the manifest dropped is not republished. The
# carry-over dies instead of publishing a partial or doubtful set: an unreadable rpm in the cell
# outside the OpenEmbedded Genesis family, a
# member of a selected build the key did not sign or of another architecture than the cell's or
# noarch, or a package name published more than once. Returns the copied basenames.
sub carry_over_rpms {
my ($from, $to, $skipped, $manifest_names, $name_of, $source_of, $trusted, $arch, $arch_of) = @_;
my %named = map { $_ => 1 } @$manifest_names;
my %staged = map { my $n = $name_of->($_); defined $n ? ($n => 1) : () }
grep { !/\.src\.rpm$/ } bsd_glob("$to/*.rpm");
my (%members, %selected, @problems);
for my $rpm (sort(bsd_glob("$from/*.rpm"))) {
next if $rpm =~ /\.src\.rpm$/;
# The OpenEmbedded Genesis is published from a release and pruned by name, so a stale file
# of that family is not read at all.
next if basename($rpm) =~ /^xCAT-genesis-openembedded-/;
my $srpm = $source_of->($rpm);
my $name = $name_of->($rpm);
my $source = source_package($srpm);
if (!defined $source || !defined $name) {
push @problems, basename($rpm) . ": header unreadable";
next;
}
next unless skipped_builder($source, $skipped);
push @{ $members{$srpm} }, { rpm => $rpm, name => $name };
$selected{$srpm} = 1
if $named{$name} || ($named{'xCAT-genesis-base'} && $name =~ /^xCAT-genesis-base-/);
}
my %seen;
for my $srpm (sort keys %selected) {
for my $m (@{ $members{$srpm} }) {
push @problems, basename($m->{rpm}) . ": not signed by the configured key" unless $trusted->($m->{rpm});
my $rpm_arch = $arch_of->($m->{rpm});
push @problems, basename($m->{rpm}) . ": architecture " . ($rpm_arch // 'unreadable') . " is not $arch or noarch"
unless defined $rpm_arch && ($rpm_arch eq $arch || $rpm_arch eq 'noarch');
push @problems, "$m->{name}: published more than once" if $seen{ $m->{name} }++ == 1;
}
}
die "FATAL: the published cell $from cannot be carried over:\n " . join("\n ", @problems) . "\n"
if @problems;
my @copied;
for my $srpm (sort keys %selected) {
# A build the run already carries in part is the run's, whole; mixing generations of one
# source package is never right.
next if grep { $staged{ $_->{name} } } @{ $members{$srpm} };
for my $m (@{ $members{$srpm} }) {
my $base = basename($m->{rpm});
copy($m->{rpm}, "$to/$base") or die "Failed to carry $m->{rpm} -> $to: $!\n";
push @copied, $base;
}
}
return sort @copied;
}
# verify_repo_packages: the PURE completeness-decision layer of the repo gate. Given the manifest's
# %expected { pkg => version-pin } and the %present { pkg => version-found-or-undef } actually in a
# built repo, return a list of human-readable problem strings (empty list = every package present at
# its pin):
# "MISSING <pkg> (manifest requires <pin>)" when %present has no (defined) version for <pkg>
# "VERSION <pkg>: repo has <got>, manifest pins <pin>" when present but !version_matches(got, pin)
# Uses version_matches (same semantics as the in-line manifest pin loop), so a '*' or glob pin is
# accepted exactly as there. No file/manifest I/O here -- the disk layer builds %present and passes
# both hashes in, keeping this unit-testable in isolation.
# parse_evr($s): split an EVR string "[epoch:]version[-release]" into ($epoch, $version, $release).
# epoch defaults to '0' when absent or '(none)'; release is undef when the string carries none (so a
# release-less constraint compares version-only). Neither version nor release may contain '-', so the
# single '-' cleanly separates them.
sub parse_evr {
my ($s) = @_;
$s = '' unless defined $s;
my ($epoch, $rest);
if ($s =~ /^\s*(\d+):(.*)$/) { ($epoch, $rest) = ($1, $2); }
else { ($epoch, $rest) = ('0', $s); }
$epoch = '0' if !defined $epoch || $epoch eq '' || lc($epoch) eq '(none)';
my ($ver, $rel) = split /-/, $rest, 2;
return ($epoch, $ver, $rel); # $rel undef when no release given
}
# evr_cmp($got, $want, $vercmp): compare two EVRs with rpm's labelCompare semantics -- epoch first
# (numeric), then version, then release -- returning -1/0/1 (got vs want). $vercmp->($a,$b) is an
# injected rpm-native segment comparator (rpm's rpmvercmp) returning -1/0/1, so this stays pure and
# unit-testable. Release is compared only when the CONSTRAINT specifies one (rpm's EVR semantics: a
# version-only requirement ignores the built release).
sub evr_cmp {
my ($got, $want, $vercmp) = @_;
my ($ge, $gv, $gr) = parse_evr($got);
my ($we, $wv, $wr) = parse_evr($want);
return (($ge <=> $we) <=> 0) if ($ge <=> $we) != 0; # epoch: numeric
my $c = $vercmp->($gv, $wv);
return $c if $c;
return 0 unless defined $wr && $wr ne ''; # constraint release-agnostic
$gr = '' unless defined $gr;
return $vercmp->($gr, $wr);
}
# evr_constraint_ok($got, $op, $want, $vercmp): does the observed EVR satisfy "<op> <want>"?
sub evr_constraint_ok {
my ($got, $op, $want, $vercmp) = @_;
my $c = evr_cmp($got, $want, $vercmp);
return $c >= 0 if $op eq '>=';
return $c > 0 if $op eq '>';
return $c <= 0 if $op eq '<=';
return $c < 0 if $op eq '<';
return $c == 0 if $op eq '=' || $op eq '==';
return undef; # unknown operator
}
# parse_pin($pin): classify a manifest version pin.
# '*' -> ('any')
# '<op> <evr>' (>=,>,<=,<,=) -> ('evr', $op, $evr) full EPOCH:VERSION-RELEASE constraint
# glob or exact version -> ('version') %{VERSION}-only match (version_matches)
sub parse_pin {
my ($pin) = @_;
return ('any') if !defined($pin) || $pin eq '*';
return ('evr', $1, $2) if $pin =~ /^\s*(>=|<=|==|=|>|<)\s*(\S+)\s*$/;
return ('version');
}
sub verify_repo_packages {
my ($expected, $present_ver, $present_evr, $vercmp) = @_;
$present_evr //= $present_ver;
my @problems;
for my $pkg (sort keys %$expected) {
my $pin = $expected->{$pkg};
my $got_ver = $present_ver->{$pkg};
if (!defined $got_ver) {
push @problems, "MISSING $pkg (manifest requires " . (defined($pin) ? $pin : '*') . ")";
next;
}
my ($kind, $op, $want) = parse_pin($pin);
if ($kind eq 'evr') {
my $got_evr = $present_evr->{$pkg} // $got_ver;
if (!$vercmp) {
push @problems, "EVR $pkg: no EVR comparator available to check '$op $want'";
} elsif (!evr_constraint_ok($got_evr, $op, $want, $vercmp)) {
push @problems, "EVR $pkg: repo has $got_evr, manifest requires $op $want";
}
} elsif ($kind eq 'version') { # VERSION glob/exact (unchanged)
push @problems, "VERSION $pkg: repo has $got_ver, manifest pins $pin"
if !version_matches($got_ver, $pin);
}
# 'any' -> accept
}
return @problems;
}
# rpmkeys_checksig_problem($name, $rc, $out): pure verdict for one `rpmkeys --checksig -v` run against
# an isolated keyring holding only the signing key. A clean rpm exits 0 and every digest/signature
# line reads OK; a tampered digest reads NOT OK; an rpm signed by another key (or unsigned) reads
# NOKEY. Return a problem string (or empty list) so the gate is testable without rpm.
sub rpmkeys_checksig_problem {
my ($name, $rc, $out) = @_;
$out = '' unless defined $out;
return () if ($rc // 0) == 0 && $out !~ /NOT OK|NOKEY|MISSING KEYS/i;
my $why = $out =~ /NOT OK/i ? 'digest/signature NOT OK'
: $out =~ /NOKEY/i ? 'NOKEY (unsigned or signed by an unaccepted key)'
: $out =~ /MISSING KEYS/i ? 'MISSING KEYS'
: "rpmkeys --checksig failed (rc=" . ($rc // '?') . ")";
return "BADSIG rpm $name: $why";
}
# verify_repo_signature: the PURE signature-decision layer of the repo gate. Given %expected
# { unit => expected signing-key identity } and %observed { unit => key that ACTUALLY signed (a
# string the script extracts from gpg), or undef/'' when unsigned / verification failed }, return a
# list of problem strings (empty = every unit signed by the expected key). For EL the single unit is
# 'repomd'. This does a plain string compare only -- it invokes NO gpg: the caller runs gpg --verify,
# extracts the observed key id, and resolves --gpg-key-name to the SAME identity form before calling.
# "UNSIGNED <unit> (expected <key>)" when %observed is absent/empty for <unit>
# "WRONGKEY <unit>: signed by <observed>, expected <key>" when both defined but differ
sub verify_repo_signature {
my ($expected, $observed) = @_;
my @problems;
for my $unit (sort keys %$expected) {
my $exp = $expected->{$unit};
my $obs = $observed->{$unit};
if (!defined($obs) || $obs eq '') {
push @problems, "UNSIGNED $unit (expected " . (defined($exp) ? $exp : '') . ")";
} elsif (defined($exp) && $obs ne $exp) {
push @problems, "WRONGKEY $unit: signed by $obs, expected $exp";
}
}
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) = @_;
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_is_signed: does the rpm carry a PGP/GPG header signature? SIGMD5 (above) is content-only and
# is identical whether or not the rpm is signed, so a cross-copied genesis that was copied but not
# yet signed (a crash between the copy and the rpmsign) still matches by SIGMD5. finalize uses this
# to treat such a rpm as NOT up to date so the copy+sign path re-runs and heals it.
sub rpm_is_signed {
my ($f) = @_;
return 0 unless defined $f && -f $f;
my $v = `rpm -qp --qf '%{SIGPGP}%{SIGGPG}' ${\ sh_quote($f)} 2>/dev/null`;
return 0 if !defined $v;
$v =~ s/\(none\)//g; # unsigned rpms report "(none)" for both tags
$v =~ s/\s+//g;
return $v ne '' ? 1 : 0;
}
# restamp_release_line: given a spec `Release: ...` line and a CD suffix (".snap<YYYYMMDDHHMM>.<n>"),
# return (new_line, changed). Idempotent: a line already ending in exactly $suffix is returned
# unchanged (changed=0). A line carrying a DIFFERENT prior .snap stamp (or several, from an earlier
# corrupted run) has it stripped before the new suffix is appended, so a re-run in a reused tree
# REPLACES the stamp instead of accumulating a second one (…snap...57 -> …snap...58, never
# …snap...57.snap...58). Only the Release token is touched; a non-Release line is returned as-is.
sub restamp_release_line {
my ($line, $suffix) = @_;
return ($line, 0) unless defined $line && $line =~ /^Release:\s*\S/i;
my $qs = quotemeta($suffix);
return ($line, 0) if $line =~ /$qs\s*$/; # already carries THIS suffix
(my $new = $line) =~ s/(?:\.snap\d{12}\.\d+)+(\s*)$/$1/; # drop any prior CD stamp(s)
$new =~ s/(^Release:\s*\S+)/$1$suffix/i;
return ($new, 1);
}
# rpm_version: %{version} of the built binary rpm named <name> 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";
my %vers; # distinct %{version}s of the matching binary rpms
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;
$vers{$v} = 1 if $v ne '';
}
return undef unless %vers;
# More than one distinct version present means a stale artifact was not cleaned before the
# build -- a version pin could then pass against the wrong rpm and both could be shipped.
# (For genesis both arches share the same Version, so a normal x86_64+ppc64 pair is one entry.)
die "Multiple versions of $name present in $dir: " . join(', ', sort keys %vers)
. " (stale artifact not cleaned before the build)\n" if keys(%vers) > 1;
my ($v) = keys %vers;
return $v;
}
# rpm_release: %{release} of the built binary rpm named <name> under $dir (undef if absent). Same
# name-matching as rpm_version. Used to confirm a CD --build-number/--release-suffix bump actually
# landed in the built rpm's Release (validating %{VERSION} alone can't catch a silently un-bumped NVR).
sub rpm_release {
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 $r = `rpm -qp --qf '%{release}' ${\ sh_quote($f)} 2>/dev/null`;
chomp $r;
return $r if $r ne '';
}
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-<tarch>-*.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";
my $src_sig = rpm_sigmd5($want{$base});
# An empty SIGMD5 (unreadable rpm) means "cannot confirm identical" -> refresh rather
# than risk skipping on a false match (two '' would otherwise compare equal).
if (!-f $dst || $src_sig eq '' || $src_sig ne rpm_sigmd5($dst)) { $up_to_date = 0; last; }
# Content matches, but SIGMD5 cannot see the signature: a crash between the copy and the
# per-rpm sign leaves a same-content-but-UNSIGNED rpm. When a signer is configured, treat
# an unsigned dst as not-up-to-date so the copy+sign path re-runs and signs it.
if ($sign && !rpm_is_signed($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
# <os>/x86_64 and <os>/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.
# 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};
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";
# 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;
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 %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> 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');
}
# bump_dep_release_suffix: append $suffix (e.g. ".snap202607161200.57") to the Release: line of
# every xcat-dep package spec under $repo_root, so the CD build stamps a fresh, monotonic NVR.
# Idempotent: a spec already carrying this exact suffix is left alone (a re-run in the same tree
# does not double-stamp). Preserves any %{?dist}/%{?distver} macro already on the line. Returns the
# count of specs newly stamped. Dies only if NO spec under $repo_root carries a Release: line.
# Pure (takes everything as args) so t/mockbuild-all.t can exercise it directly.
sub bump_dep_release_suffix {
my ($repo_root, $suffix) = @_;
my @specs;
# Only stamp xcat-dep's OWN specs. If someone checked xcat-core out NESTED under $repo_root (the
# legacy `xcat-source-code`/`xcat-core` layout), do NOT descend into it -- rewriting a core spec
# (e.g. xCAT-genesis-base.spec's dynamic Release) would break the lockstep with genesis-scripts.
find(sub {
if (-d $_ && ($_ eq 'xcat-core' || $_ eq 'xcat-source-code')) { $File::Find::prune = 1; return; }
push @specs, $File::Find::name if /\.spec$/ && -f $_;
}, $repo_root);
my ($with_release, $bumped, $already) = (0, 0, 0);
for my $spec (sort @specs) {
open my $in, '<', $spec or die "open $spec: $!\n";
my @lines = <$in>;
close $in;
my ($has_release, $changed) = (0, 0);
for my $line (@lines) {
# case-insensitive: some specs (e.g. Sys-Virt.spec) use a lowercase `release:`
next unless $line =~ /^Release:\s*\S/i;
$has_release = 1;
# restamp_release_line is idempotent (no-op if already carrying $suffix) and strips any
# prior .snap stamp before applying the new one, so a re-run with a different
# --build-number replaces rather than accumulates (unit-tested in t/mockbuild-all.t).
my ($new, $ch) = restamp_release_line($line, $suffix);
if ($ch) { $line = $new; $changed = 1; }
last; # only the first Release: line
}
$with_release++ if $has_release;
$already++ if $has_release && !$changed;
next unless $changed;
# atomic write (temp + rename) so a concurrent per-arch build on the shared NFS tree never
# sees a torn spec; identical suffix -> identical content, so last-writer-wins is safe. The
# temp name carries the hostname AND pid: the two arch build hosts share the NFS tree and can
# reuse the same pid, so pid alone could collide across hosts.
my $tmp = "$spec.bump." . hostname() . ".$$";
open my $out, '>', $tmp or die "open> $tmp: $!\n";
print {$out} @lines;
close $out;
rename $tmp, $spec or die "rename $tmp -> $spec: $!\n";
$bumped++;
}
print "Release bump '$suffix': $bumped newly stamped, $already already stamped, of $with_release spec(s) with a Release line under $repo_root\n";
# Only a genuine "no dep specs at all" is fatal. All-already-stamped is the expected idempotent
# case (re-run in the same tree, or the other arch bumped first) -- NOT an error.
die "FATAL: --build-number given but NO spec carried a Release: line under $repo_root (wrong tree?)\n"
if $with_release == 0;
return $bumped;
}
# build_mock_uniqueext: a mock --uniqueext UNIQUE per (run, build-step) so concurrent mock builds
# never share a chroot root (/var/lib/mock/<cfg>-<uniqueext>). $run is the per-target run id
# (e.g. "alma+epel-8-ppc64le-<n>"), $seq orders the step, $label names the package.
#
# The run id must NOT be blindly tail-truncated. The per-target id leads with the EL/arch token, and
# for the 7-char "ppc64le" arch the EL digit is exactly what falls off the front of a keep-the-last-24
# truncation -- so alma+epel-{8,9,10}-ppc64le all collapse to the same run part. That is catastrophic
# for goconserver, which compiles EVERY EL in the el10 chroot (build_cfg rewritten to -10-): the
# chroot NAME is then identical across the three ELs, and the uniqueext is the ONLY thing keeping
# their roots apart, so three concurrent el8/el9/el10 ppc64le goconserver builds race in one root.
# When the id is too long, keep a readable leading token AND append a short digest of the FULL id, so
# distinct ids always yield distinct uniqueext regardless of where in the string they differ.
# rpm_arch($rpm): the architecture of an rpm. The header decides when the file can be read, so a
# renamed file does not pass for another architecture; a bare file name falls back to its suffix.
sub rpm_arch {
my ($rpm) = @_;
return unless defined $rpm;
if (-f $rpm) {
my $arch = `rpm -qp --qf '%{ARCH}' ${\ sh_quote($rpm)} 2>/dev/null`;
chomp $arch;
return $arch if $arch ne '';
}
my ($arch) = $rpm =~ /\.([A-Za-z0-9_]+)\.rpm$/;
return $arch;
}
# rpm_in_cell($rpm, $target_arch): whether an rpm belongs in the repository cell of $target_arch.
# A noarch builder run in another architecture's chroot (the x86 boot loaders for riscv64) can
# emit that chroot's native rpms beside the noarch one; only noarch and the cell's own
# architecture are kept.
sub rpm_in_cell {
my ($rpm, $target_arch) = @_;
my $arch = rpm_arch($rpm);
return 0 unless defined $arch && defined $target_arch;
return 1 if $arch eq 'noarch';
return $arch eq $target_arch ? 1 : 0;
}
sub build_mock_uniqueext {
my ($run, $seq, $label) = @_;
my $run_part = defined($run) ? $run : 'run';
$run_part =~ s/[^A-Za-z0-9_.-]+/-/g;
$run_part =~ s/^-+|-+$//g;
$run_part = 'run' if $run_part eq '';
if (length($run_part) > 24) {
my $digest = substr(md5_hex($run_part), 0, 8);
(my $head = substr($run_part, 0, 15)) =~ s/-+$//;
$run_part = "$head-$digest";
}
my $label_part = defined($label) ? $label : 'step';
$label_part =~ s/[^A-Za-z0-9_.-]+/-/g;
$label_part =~ s/^-+|-+$//g;
$label_part = 'step' if $label_part eq '';
$label_part = substr($label_part, 0, 20) if length($label_part) > 20;
my $idx = defined($seq) ? int($seq) : 0;
$idx = 0 if $idx < 0;
return sprintf("mba-%02d-%s-%s", $idx, $run_part, $label_part);
}
1;