2
0
mirror of https://github.com/xcat2/xcat-dep.git synced 2026-09-30 14:55:17 +00:00

test(openeuler): cover native dependency builds

This commit is contained in:
Vinícius Ferrão
2026-09-21 18:24:02 -03:00
parent 0c0dfb787e
commit a8fcf40e6b
6 changed files with 1253 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
use strict;
use warnings;
use Cwd qw(abs_path);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use FindBin qw($RealBin);
use Test::More;
use lib "$RealBin/../lib", "$RealBin/lib";
use XCAT::BuildUtils qw(capture_command command_exists digest_file read_binary write_binary);
use XCAT::GenesisReleaseTest qw(run_capture);
plan skip_all => 'Linux RPM repository tools required'
unless $^O eq 'linux' && !grep { !command_exists($_) } qw(git rpm rpmbuild createrepo_c unshare);
my $tmp = tempdir(CLEANUP => 1);
my @namespace = $> == 0 ? () : ('unshare', '--user', '--map-root-user');
plan skip_all => 'An unprivileged user namespace is required for the collector root check'
if @namespace && run_capture("$tmp/namespace.log", @namespace, 'true') != 0;
my $collector = $ENV{XCAT_TEST_COLLECTOR} // abs_path("$RealBin/../mockbuild-all.pl");
my $arch = capture_command('uname', '-m');
my $epoch = 1788718796;
my $snapshot = ('a' x 40) . '-dirty-snapshot-' . ('b' x 64);
my $top = "$tmp/rpmbuild";
make_path("$top/SPECS");
write_binary("$top/SPECS/provenance-fixture.spec", <<'SPEC');
Name: provenance-fixture
Version: 1
Release: 1
Summary: Repository metadata fixture
License: MIT
BuildArch: noarch
%description
Repository metadata fixture.
%install
mkdir -p %{buildroot}/usr/share/provenance-fixture
%files
/usr/share/provenance-fixture
SPEC
is(run_capture("$tmp/rpm-build.log", 'rpmbuild', '--quiet', '-bb', '--define', "_topdir $top",
"$top/SPECS/provenance-fixture.spec"), 0, 'build an isolated RPM fixture')
or BAIL_OUT(read_binary("$tmp/rpm-build.log"));
my $fixture = "$top/RPMS/noarch/provenance-fixture-1-1.noarch.rpm";
my $fixture_hash = digest_file($fixture);
for my $case (qw(checkout export missing empty)) {
my $root = "$tmp/$case source";
make_path($root);
write_binary("$root/packages-manifest.conf",
"[openeuler-24.03sp3-$arch]\nprovenance-fixture=1\n"
. "[alma+epel-10-$arch]\nprovenance-fixture=1\n");
write_binary("$root/Gitepoch", "$epoch\n");
my $expected = 'unknown';
if ($case eq 'checkout') {
is(run_capture("$tmp/git-init.log", 'git', '-C', $root, 'init', '--quiet'), 0,
'initialize the real checkout fixture');
is(run_capture("$tmp/git-add.log", 'git', '-C', $root, 'add', 'packages-manifest.conf', 'Gitepoch'), 0,
'stage the checkout fixture');
is(run_capture("$tmp/git-commit.log", 'git', '-C', $root,
'-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid',
'-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'Fixture'), 0,
'record the checkout fixture revision');
$expected = capture_command('git', '-C', $root, 'rev-parse', 'HEAD');
write_binary("$root/Gitinfo", "$snapshot\n");
} elsif ($case eq 'export') {
write_binary("$root/Gitinfo", "$snapshot\r\n");
$expected = $snapshot;
} elsif ($case eq 'empty') {
write_binary("$root/Gitinfo", " \t\r\n");
}
for my $target ("openeuler-24.03sp3-$arch", "alma+epel-10-$arch") {
my $output = "$tmp/$case-$target-output";
my $repo = "$tmp/$case-$target-repo";
my $log = "$tmp/$case-$target.log";
local $ENV{MOCKBUILD_ALL_MOUNTNS} = 1;
my $status = run_capture($log, @namespace, $^X, $collector,
'--repo-root', $root, '--target', $target, '--output', $output,
'--repo-dep', $repo, '--run-id', 'provenance', '--build-timestamp', $epoch,
'--skip-build', '--skip-genesis', '--skip-xcat-dep', '--skip-perl',
'--skip-createrepo', '--skip-tarball', '--no-verify-repo',
'--collect-dir', "$top/RPMS/noarch");
is($status, 0, "$case $target full collector succeeds") or diag(read_binary($log));
my $subdir = $target =~ /^openeuler/ ? "openeuler24.03sp3/$arch" : "rh10/$arch";
my $metadata_path = "$repo/$subdir/buildinfo.txt";
ok(-f $metadata_path, "$case $target writes repository buildinfo");
next unless -f $metadata_path;
my %metadata = map { split /=/, $_, 2 } split /\n/, read_binary($metadata_path);
is($metadata{COMMIT_ID_LONG}, $expected, "$case $target preserves the complete source identity");
is($metadata{COMMIT_ID}, substr($expected, 0, 7), "$case $target preserves the short identity contract");
is($metadata{SOURCE_DATE_EPOCH}, "$epoch", "$case $target retains the explicit epoch");
is($metadata{TARGET}, $subdir, "$case $target retains the target repository path");
is(digest_file("$repo/$subdir/provenance-fixture-1-1.noarch.rpm"), $fixture_hash,
"$case $target collection preserves the RPM bytes");
}
}
done_testing();
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin qw($RealBin);
use File::Basename qw(dirname);
use File::Copy qw(copy);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use Digest::SHA qw(sha256_hex);
use JSON::PP qw(decode_json);
use Test::More;
plan skip_all => 'Linux user namespaces are required for the unchanged root-only CLI' unless $^O eq 'linux';
my @namespace = $> ? ('unshare', '--user', '--map-root-user', '--') : ();
if (@namespace) {
my $pid = fork();
die $! unless defined $pid;
if (!$pid) {
open(STDOUT, '>', '/dev/null') or die $!;
open(STDERR, '>&', \*STDOUT) or die $!;
exec(@namespace, $^X, '-e', 'exit($> != 0)') or die $!;
}
waitpid($pid, 0);
plan skip_all => 'Unprivileged user namespaces are unavailable' if $?;
}
my $root = "$RealBin/..";
my $builder = $ENV{XCAT_TEST_GO_BUILDER} || "$root/goconserver/mockbuild.pl";
my $tmp = tempdir(CLEANUP => 1);
my $commit = '0123456789abcdef0123456789abcdef01234567';
my $payload = "private compiler download fixture\n";
my $hash = sha256_hex($payload);
my $sequence = 0;
sub write_file {
my ($path, $text) = @_;
make_path(dirname($path));
open(my $fh, '>', $path) or die "$path: $!";
print {$fh} $text;
close($fh) or die "$path: $!";
}
sub read_file {
my ($path) = @_;
return '' unless -f $path;
open(my $fh, '<', $path) or die "$path: $!";
my $text = do { local $/; <$fh> };
close($fh) or die "$path: $!";
return $text // '';
}
my $double = <<'DOUBLE';
#!/usr/bin/perl
use strict;
use warnings;
use File::Basename qw(basename dirname);
use File::Path qw(make_path);
use JSON::PP qw(encode_json);
my $command = basename($0);
open(my $log, '>>', $ENV{FIXTURE_LOG}) or die $!;
print {$log} encode_json({command => $command, args => [@ARGV], goarch => $ENV{GOARCH} // ''}), "\n";
close($log) or die $!;
sub put {
my ($path, $text) = @_;
make_path(dirname($path));
open(my $fh, '>', $path) or die $!;
print {$fh} $text;
close($fh) or die $!;
}
sub option {
my ($name) = @_;
for my $i (0 .. $#ARGV - 1) { return $ARGV[$i + 1] if $ARGV[$i] eq $name; }
die "Missing option $name";
}
if ($command eq 'uname') {
die 'Unexpected uname arguments' unless "@ARGV" eq '-m';
print "$ENV{FIXTURE_ARCH}\n";
} elsif ($command eq 'bash') {
die 'Unexpected bash invocation' unless @ARGV == 2 && $ARGV[0] eq '-lc';
if ($ARGV[1] eq 'source /etc/os-release; echo $ID') {
print "$ENV{FIXTURE_OS}\n";
} elsif ($ARGV[1] eq 'source /etc/os-release; echo "$VERSION"') {
print "$ENV{FIXTURE_VERSION}\n";
} else { die "Unexpected OS query: $ARGV[1]"; }
} elsif ($command eq 'git') {
if ($ARGV[0] eq 'init') {
my $path = $ARGV[-1];
make_path("$path/.git");
put("$path/goconserver.go", "package main\n");
put("$path/cmd/congo.go", "package main\n");
put("$path/storage/etcd.go", "package storage\n");
} elsif ($ARGV[0] eq '-C' && $ARGV[2] eq 'rev-parse') {
die 'Commit queried after .git removal' unless -d "$ARGV[1]/.git";
die 'Unexpected commit query' unless "@ARGV[3 .. $#ARGV]" eq '--verify HEAD^{commit}';
print "$ENV{FIXTURE_COMMIT}\n";
exit($ENV{FIXTURE_COMMIT_RC} || 0);
} elsif ($ARGV[0] eq '-C' && $ARGV[2] eq 'log') {
print "1600000000\n";
} elsif ($ARGV[0] eq '-C' && $ARGV[2] =~ /\A(?:remote|fetch|checkout)\z/) {
exit 0;
} else { die "Unexpected git arguments: @ARGV"; }
} elsif ($command eq 'wget') {
put(option('-O'), $ENV{FIXTURE_DOWNLOAD});
} elsif ($command eq 'mock') {
if (grep { $_ eq '--buildsrpm' } @ARGV) {
put(option('--resultdir') . '/goconserver-0.3.3-4.src.rpm', "fixture source RPM\n");
} elsif (grep { $_ eq '--rebuild' } @ARGV) {
put(option('--resultdir') . "/goconserver-0.3.3-4.$ENV{FIXTURE_TARGET}.rpm", "fixture binary RPM\n");
} elsif (grep { $_ eq '--print-root-path' || /^--scrub=/ } @ARGV) {
print "/private/mock/root\n";
} else { die "Unexpected mock arguments: @ARGV"; }
} elsif ($command eq 'go') {
die 'Unexpected go invocation' unless $ARGV[0] eq 'build';
my $output = option('-o');
put($output, "#!/bin/sh\nexit 0\n");
chmod 0755, $output;
} elsif ($command eq 'rpmbuild') {
my ($top) = map { /^_topdir (.+)$/ ? $1 : () } @ARGV;
die 'Missing rpmbuild topdir' unless defined $top;
my $arch = option('--target');
put("$top/RPMS/$arch/goconserver-0.3.3-4.$arch.rpm", "fixture binary RPM\n");
put("$top/SRPMS/goconserver-0.3.3-4.src.rpm", "fixture source RPM\n");
} elsif ($command eq 'cpio') {
for my $name (qw(goconserver congo)) {
put("usr/bin/$name", "#!/bin/sh\nexit 0\n");
chmod 0755, "usr/bin/$name";
}
} elsif ($command ne 'rpm' && $command ne 'rpm2cpio') {
die "Unexpected command $command";
}
DOUBLE
sub run_case {
my (%options) = @_;
my $directory = "$tmp/" . ++$sequence;
my $checkout = "$directory/source";
my $bin = "$directory/bin";
make_path($checkout, $bin);
for my $relative ('MockBuildUtils.pm', 'lib/XCAT/BuildUtils.pm', 'goconserver/gomod/go.mod', 'goconserver/gomod/go.sum') {
make_path(dirname("$checkout/$relative"));
copy("$root/$relative", "$checkout/$relative") or die $!;
}
copy($builder, "$checkout/goconserver/mockbuild.pl") or die $!;
write_file("$checkout/goconserver/toolchains/go1.25.12.sha256",
join('', map { "$hash go1.25.12.linux-$_.tar.gz\n" } qw(amd64 ppc64le)));
write_file("$bin/double", $double);
chmod 0755, "$bin/double";
symlink('double', "$bin/$_") or die $! for qw(uname bash git wget mock rpm go rpmbuild rpm2cpio cpio);
my @arguments = ('--work-dir', "$directory/work", '--result-dir', "$directory/results",
'--log-dir', "$directory/logs", '--mock-uniqueext', 'contract', '--go-ref', 'refs/tags/fixture');
push @arguments, ('--build-timestamp', $options{epoch} // 1700000000) unless $options{omit_epoch};
push @arguments, ('--mock-cfg', $options{config}) if defined $options{config};
push @arguments, ('--target-arch', $options{target}) if defined $options{target};
local $ENV{PATH} = "$bin:/usr/bin:/bin";
local $ENV{TZ} = 'Pacific/Honolulu';
local $ENV{SOURCE_DATE_EPOCH};
delete $ENV{SOURCE_DATE_EPOCH};
$ENV{SOURCE_DATE_EPOCH} = $options{environment_epoch} if exists $options{environment_epoch};
local $ENV{FIXTURE_LOG} = "$directory/commands.jsonl";
local $ENV{FIXTURE_ARCH} = $options{arch} || 'x86_64';
local $ENV{FIXTURE_TARGET} = $options{target} || $ENV{FIXTURE_ARCH};
local $ENV{FIXTURE_OS} = $options{os} || 'openEuler';
local $ENV{FIXTURE_VERSION} = $options{os_version} || '24.03 (LTS-SP3)';
local $ENV{FIXTURE_DOWNLOAD} = $options{corrupt} ? "corrupted payload\n" : $payload;
local $ENV{FIXTURE_COMMIT} = exists($options{commit}) ? $options{commit} : $commit;
local $ENV{FIXTURE_COMMIT_RC} = $options{commit_rc} || 0;
my $pid = fork();
die $! unless defined $pid;
if (!$pid) {
open(STDOUT, '>', "$directory/output") or die $!;
open(STDERR, '>&', \*STDOUT) or die $!;
exec(@namespace, $^X, "$checkout/goconserver/mockbuild.pl", @arguments) or die $!;
}
waitpid($pid, 0);
my $status = $?;
my @commands = map { decode_json($_) } grep { length } split /\n/, read_file("$directory/commands.jsonl");
return { directory => $directory, status => $status, output => read_file("$directory/output"),
spec => read_file("$directory/work/goconserver.spec"), commands => \@commands };
}
sub calls {
my ($case, $command, $argument) = @_;
return [grep { $_->{command} eq $command && (!defined($argument) || grep { $_ eq $argument } @{$_->{args}}) } @{$case->{commands}}];
}
sub build_metadata {
my ($case, $time, $label) = @_;
for my $binary (qw(goconserver congo)) {
like($case->{spec}, qr/^go build [^\n]*-ldflags "-X main.Version=%\{version\} -X main.Commit=\Q$commit\E -X main.BuildTime=\Q$time\E" -o \Q$binary\E /m,
"$label $binary records fetched commit and UTC build time");
}
}
my @cells = (
['20.03sp4', '20.03 (LTS-SP4)', 'x86_64', 'amd64'],
['22.03sp4', '22.03 (LTS-SP4)', 'x86_64', 'amd64'],
['24.03sp1', '24.03 (LTS-SP1)', 'x86_64', 'amd64'],
['24.03sp3', '24.03 (LTS-SP3)', 'x86_64', 'amd64'],
['24.03sp4', '24.03 (LTS-SP4)', 'x86_64', 'amd64'],
['24.03', '24.03 (LTS)', 'ppc64le', 'ppc64le'],
);
for my $cell (@cells) {
my ($version, $os_version, $arch, $goarch) = @$cell;
my $config = "openeuler-$version-$arch";
my $case = run_case(os_version => $os_version, arch => $arch);
is($case->{status}, 0, "$config full CLI succeeds with external build doubles") or diag($case->{output});
like(read_file("$case->{directory}/work/mock-deterministic.cfg"), qr/^include\('\/etc\/mock\/\Q$config\E\.cfg'\)/m,
"$config builds inside its exact native config");
like($case->{spec}, qr/^Release:\s+4$/m, "$config retains the native empty dist macro");
like($case->{spec}, qr/^BuildArch:\s+\Q$arch\E$/m, "$config retains its native architecture");
like($case->{spec}, qr/^Source3:\s+https:\/\/go\.dev\/dl\/go1\.25\.12\.linux-\Q$goarch\E\.tar\.gz$/m,
"$config stages the matching pinned compiler");
like($case->{spec}, qr/^BuildRequires:\s+coreutils tar gzip ca-certificates$/m, "$config uses the private compiler prerequisites");
like($case->{spec}, qr/^echo '\Q$hash\E %\{SOURCE3\}' \| sha256sum -c -$/m, "$config verifies the compiler again in RPM prep");
is(scalar @{calls($case, 'mock', '--buildsrpm')}, 1, "$config reaches SRPM construction after verification");
is(scalar @{calls($case, 'mock', '--rebuild')}, 1, "$config reaches native RPM reconstruction");
is(read_file("$case->{directory}/results/goconserver-0.3.3-4.$arch.rpm"), "fixture binary RPM\n", "$config collects the build output");
ok(!-d "$case->{directory}/work/goconserver-src/.git", "$config removes fetched Git metadata from the sources");
build_metadata($case, '2023-11-14T22:13:20Z', $config);
}
{
my $case = run_case(config => 'openeuler-22.03sp4-x86_64');
is($case->{status}, 0, 'explicit native target overrides host release detection');
is(scalar @{calls($case, 'bash')}, 0, 'explicit target requires no host release query');
like(read_file("$case->{directory}/work/mock-deterministic.cfg"), qr/openeuler-22\.03sp4-x86_64\.cfg/, 'explicit service pack is retained');
}
for my $options (
{ config => 'openeuler-24.03sp3-x86_64', target => 'ppc64le' },
{ config => 'openeuler-24.03-ppc64le', arch => 'x86_64' },
{ config => 'openeuler-24.03-ppc64le', arch => 'ppc64le', target => 'x86_64' },
) {
my $case = run_case(%$options);
isnt($case->{status}, 0, 'native target rejects a foreign builder or cross target');
like($case->{output}, qr/openEuler goconserver requires a native .* builder/, 'native mismatch reports its required builder');
ok(!-d "$case->{directory}/work", 'native mismatch fails before staging sources');
is(scalar @{calls($case, 'git')} + scalar @{calls($case, 'mock')} + scalar @{calls($case, 'wget')}, 0,
'native mismatch starts no source fetch, toolchain fetch, or package build');
}
for my $arch (qw(x86_64 ppc64le)) {
my $config = $arch eq 'x86_64' ? 'openeuler-24.03sp3-x86_64' : 'openeuler-24.03-ppc64le';
my $case = run_case(arch => $arch, config => $config, corrupt => 1);
isnt($case->{status}, 0, "$arch corrupt compiler fails");
like($case->{output}, qr/Go toolchain checksum mismatch:/, "$arch reports compiler checksum mismatch");
is(scalar @{calls($case, 'mock', '--buildsrpm')}, 0, "$arch corrupt compiler fails before SRPM construction");
ok(!-f "$case->{directory}/work/goconserver.spec", "$arch corrupt compiler leaves no generated spec");
}
for my $options ({ commit => '' }, { commit => 'not-a-commit' }, { commit_rc => 1 }) {
my $case = run_case(%$options);
isnt($case->{status}, 0, 'unresolved fetched commit fails');
like($case->{output}, qr/Cannot resolve fetched goconserver commit/, 'unresolved commit has a specific diagnostic');
is(scalar @{calls($case, 'wget')} + scalar @{calls($case, 'mock')}, 0, 'unresolved commit fails before compiler or package work');
}
for my $row (
[{ omit_epoch => 1, environment_epoch => 946684800 }, '2000-01-01T00:00:00Z', 'native environment epoch'],
[{ environment_epoch => 946684800 }, '2023-11-14T22:13:20Z', 'explicit epoch precedence'],
[{ epoch => 0 }, '1970-01-01T00:00:00Z', 'zero epoch'],
) {
my ($options, $time, $label) = @$row;
my $case = run_case(%$options);
is($case->{status}, 0, "$label succeeds") or diag($case->{output});
build_metadata($case, $time, $label);
}
for my $options ({ omit_epoch => 1, environment_epoch => 'invalid' }, { epoch => -1 }) {
my $case = run_case(%$options);
isnt($case->{status}, 0, 'invalid native epoch fails');
like($case->{output}, qr/Invalid native build timestamp:/, 'invalid native epoch has a specific diagnostic');
ok(!-d "$case->{directory}/work", 'invalid native epoch fails before source staging');
}
for my $row (
[{ config => 'rocky+epel-9-x86_64' }, 'rocky+epel-10-x86_64', '9'],
[{ os => 'rocky' }, 'rocky+epel-10-x86_64', '10'],
) {
my ($options, $config, $release) = @$row;
my $case = run_case(%$options);
is($case->{status}, 0, "EL$release full CLI succeeds") or diag($case->{output});
like(read_file("$case->{directory}/work/mock-deterministic.cfg"), qr/\Q$config\E\.cfg/, "EL$release retains the EL10 build peer");
like($case->{spec}, qr/^Release:\s+4\.el\Q$release\E$/m, "EL$release retains its target dist suffix");
like($case->{spec}, qr/^BuildRequires:\s+golang$/m, "EL$release retains the distro compiler");
unlike($case->{spec}, qr/^Source3:/m, "EL$release has no native compiler source");
is(scalar @{calls($case, 'wget')} + scalar @{calls($case, 'git', 'rev-parse')}, 0, "EL$release adds no native source or metadata fetch");
for my $binary (qw(goconserver congo)) {
like($case->{spec}, qr/^go build [^\n]*-ldflags "-X main.Version=%\{version\}" -o \Q$binary\E /m,
"EL$release $binary preserves its existing linker flags");
}
}
{
my $case = run_case(config => 'rocky-10-riscv64-xcat', target => 'riscv64');
is($case->{status}, 0, 'existing EL cross packaging completes with external build doubles') or diag($case->{output});
my $go = calls($case, 'go');
is(scalar @$go, 2, 'EL cross path invokes both host compiler outputs');
is_deeply([map { $_->{goarch} } @$go], ['riscv64', 'riscv64'], 'EL cross path selects the target GOARCH');
is(scalar @{calls($case, 'rpmbuild', 'riscv64')}, 1, 'EL cross path packages for the requested target');
is(scalar @{calls($case, 'mock')} + scalar @{calls($case, 'wget')}, 0, 'EL cross path does not invoke native mock or compiler staging');
is(read_file("$case->{directory}/results/goconserver-0.3.3-4.riscv64.rpm"), "fixture binary RPM\n", 'EL cross path collects its package output');
}
done_testing();
+338
View File
@@ -0,0 +1,338 @@
use strict;
use warnings;
use Cwd qw(abs_path);
use File::Copy qw(copy);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use FindBin qw($RealBin);
use JSON::PP;
use Test::More;
use lib "$RealBin/..", "$RealBin/../lib", "$RealBin/lib";
use MockBuildUtils qw(read_manifest);
use XCAT::BuildUtils qw(capture_command command_exists digest_file read_binary write_binary);
use XCAT::GenesisReleaseTest qw(run_capture dies_like);
use XCAT::NativeInputs qw(load_inputs stage_inputs publisher_trust verify_input validate_outputs);
plan skip_all => 'Native Linux RPM tools are required' unless $^O eq 'linux'
&& !grep { !command_exists($_) } qw(rpm rpmkeys rpmbuild rpmsign gpg gpgconf createrepo_c unshare python3);
my $build_user = $ENV{XCAT_TEST_BUILD_USER} // '';
plan skip_all => 'Set XCAT_TEST_BUILD_USER to an unprivileged fixture builder' if $> == 0 && !$build_user;
my $build_uid = $> == 0 ? getpwnam($build_user) : $>;
plan skip_all => 'The fixture builder must be unprivileged' unless defined($build_uid) && $build_uid != 0;
my @rpm_user = $> == 0 ? ('runuser', '-u', $build_user, '--') : ();
my $tmp = tempdir(CLEANUP => !$ENV{XCAT_TEST_KEEP});
diag("native input fixtures: $tmp");
my $repo = abs_path("$RealBin/..");
my $owner = $ENV{XCAT_TEST_COLLECTOR} // "$repo/mockbuild-all.pl";
my $target = 'openeuler-24.03-ppc64le';
my $json = JSON::PP->new->canonical->pretty;
my $epoch = 1788718796;
my $host_arch = capture_command('uname', '-m');
my %manifest = read_manifest("$repo/packages-manifest.conf");
my $production_plan = eval { load_inputs($repo, $manifest{$target}); };
ok($production_plan, 'the shipped full POWER manifest has an executable native input plan') or BAIL_OUT($@);
is($production_plan->{nodes}{'xCAT-genesis-base'}{build_uid}, 0, 'the shipped Genesis owner declares its root assembly exception');
my %homes;
my %keys;
make_path("$tmp/bin", "$tmp/rpmbuild/SPECS");
chmod 0755, $tmp;
chown $build_uid, -1, "$tmp/rpmbuild", "$tmp/rpmbuild/SPECS" if $> == 0;
for my $key (qw(publisher build foreign)) {
my $home = "$tmp/key-$key";
$homes{$key} = $home;
make_path($home);
chmod 0700, $home;
is(run_capture("$tmp/key-$key.log", 'gpg', '--homedir', $home, '--batch', '--pinentry-mode', 'loopback',
'--passphrase', '', '--quick-generate-key', "$key\@example.invalid", 'rsa2048', 'sign', '0'), 0,
"create private $key key") or BAIL_OUT(read_binary("$tmp/key-$key.log"));
my $listing = capture_command('gpg', '--homedir', $home, '--with-colons', '--list-keys');
($keys{$key}) = $listing =~ /^fpr:::::::::([0-9A-F]+):/m;
write_binary("$home/public.asc", capture_command('gpg', '--homedir', $home, '--armor', '--export', $keys{$key}));
}
END {
for my $home (values %homes) {
run_capture("$home/cleanup.log", 'gpgconf', '--homedir', $home, '--kill', 'gpg-agent') if -d $home;
}
}
my %rpm;
for my $name (qw(native-leaf native-child publisher-package publisher-elf publisher-arch)) {
my $arch = $name eq 'publisher-arch' ? $host_arch : 'noarch';
my $payload = $name eq 'publisher-elf' ? q{printf '\177ELFfixture\n'} : q{printf 'fixture\n'};
my $spec = <<'SPEC';
Name: NAME
Version: 1
Release: 1.oe2403
Summary: Native input contract fixture
License: MIT
BuildArch: ARCH
%description
Native input contract fixture.
%install
mkdir -p %{buildroot}/usr/share/native-inputs
PAYLOAD > %{buildroot}/usr/share/native-inputs/%{name}
%check
test "$(id -u)" -ne 0
%files
/usr/share/native-inputs/%{name}
SPEC
$spec =~ s/NAME/$name/;
$spec =~ s/ARCH/$arch/;
$spec =~ s/PAYLOAD/$payload/;
write_binary("$tmp/rpmbuild/SPECS/$name.spec", $spec);
is(run_capture("$tmp/fixture-$name.log", @rpm_user, 'rpmbuild', '-ba', '--define', "_topdir $tmp/rpmbuild",
"$tmp/rpmbuild/SPECS/$name.spec"), 0, "build real $name fixture with nonroot check")
or BAIL_OUT(read_binary("$tmp/fixture-$name.log"));
$rpm{$name} = "$tmp/rpmbuild/RPMS/$arch/$name-1-1.oe2403.$arch.rpm";
$rpm{"$name-src"} = "$tmp/rpmbuild/SRPMS/$name-1-1.oe2403.src.rpm";
}
sub signed_copy {
my ($source, $name, $key) = @_;
my $dest = "$tmp/$name.rpm";
copy($source, $dest) or die $!;
local $ENV{GNUPGHOME} = $homes{$key};
is(run_capture("$tmp/sign-$name.log", 'rpmsign', '--define', "_gpg_name $keys{$key}",
'--define', '__gpg /usr/bin/gpg', '--addsign', $dest), 0, "sign $name with $key key")
or BAIL_OUT(read_binary("$tmp/sign-$name.log"));
return $dest;
}
my %signed;
for my $name (qw(native-leaf-src native-child-src publisher-package publisher-elf publisher-arch)) {
$signed{$name} = signed_copy($rpm{$name}, "signed-$name", 'publisher');
}
my $foreign = signed_copy($rpm{'native-leaf-src'}, 'foreign-source', 'foreign');
write_binary("$tmp/bin/wget", <<'PY');
#!/usr/bin/python3
import json, os, pathlib, shutil, sys
args = sys.argv[1:]
source = json.loads(pathlib.Path(os.environ['NATIVE_DOWNLOADS']).read_text())[args[-1]]
with open(os.environ['NATIVE_CALLS'], 'a') as f: f.write(json.dumps({'wget': args[-1]}) + '\n')
shutil.copyfile(source, args[args.index('-O')+1])
PY
write_binary("$tmp/bin/mock", <<'PY');
#!/usr/bin/python3
import json, os, pathlib, shutil, subprocess, sys
args = sys.argv[1:]
call = {'mock': args}
def value(name): return args[args.index(name)+1]
if '--rebuild' in args or '--buildsrpm' in args:
loader = '''import json, pathlib, sys, mockbuild
from mockbuild.util import load_config
config = load_config('/etc/mock', sys.argv[1], None, 'native-contract', str(pathlib.Path(mockbuild.__file__).parent))
print(json.dumps({'uid': config['chrootuid'], 'dnf': config['dnf.conf']}))
'''
config = subprocess.run([sys.executable, '-c', loader, value('-r')],
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
call['config_rc'] = config.returncode
if config.returncode:
print(config.stdout)
sys.exit(config.returncode)
if '--rebuild' in args:
name = pathlib.Path(value('--rebuild')).name.split('-1-1.oe2403')[0]
call['name'] = name
if '--buildsrpm' in args:
call['spec'] = pathlib.Path(value('--spec')).read_text()
with open(os.environ['NATIVE_CALLS'], 'a') as f: f.write(json.dumps(call) + '\n')
if '--buildsrpm' in args:
dest = pathlib.Path(value('--resultdir')); dest.mkdir(parents=True, exist_ok=True)
source = json.loads(pathlib.Path(os.environ['NATIVE_OUTPUTS']).read_text())['native-leaf'][1]
shutil.copyfile(source, dest / pathlib.Path(source).name)
sys.exit(0)
if '--rebuild' not in args: sys.exit(0)
if name == os.environ.get('NATIVE_FAIL'): sys.exit(42)
dest = pathlib.Path(value('--resultdir')); dest.mkdir(parents=True, exist_ok=True)
if name == os.environ.get('NATIVE_EMPTY'): sys.exit(0)
fixtures = json.loads(pathlib.Path(os.environ['NATIVE_OUTPUTS']).read_text())
for source in fixtures[name]: shutil.copyfile(source, dest / pathlib.Path(source).name)
PY
chmod 0755, "$tmp/bin/wget", "$tmp/bin/mock";
local $ENV{PATH} = "$tmp/bin:$ENV{PATH}";
local $ENV{NATIVE_DOWNLOADS} = "$tmp/downloads.json";
local $ENV{NATIVE_OUTPUTS} = "$tmp/outputs.json";
local $ENV{NATIVE_CALLS} = "$tmp/calls.jsonl";
write_binary($ENV{NATIVE_OUTPUTS}, $json->encode({map { $_ => [$rpm{$_}, $rpm{"$_-src"}] } qw(native-leaf native-child)}));
sub catalog {
return {version => 1, target => $target, publisher_key => {
path => 'openeuler/publisher.asc', sha256 => digest_file("$homes{publisher}/public.asc"),
fingerprint => $keys{publisher}}, build_inputs => [], inputs => [
{name => 'native-leaf', type => 'srpm', build_uid => 1000, needs => [], outputs => ['native-leaf'],
url => 'https://repo.openeuler.org/openEuler-24.03-LTS/source/Packages/native-leaf-1-1.oe2403.src.rpm',
sha256 => digest_file($signed{'native-leaf-src'})},
{name => 'native-child', type => 'srpm', build_uid => 1000, needs => ['native-leaf'], outputs => ['native-child'],
url => 'https://repo.openeuler.org/openEuler-24.03-LTS/source/Packages/native-child-1-1.oe2403.src.rpm',
sha256 => digest_file($signed{'native-child-src'})},
{name => 'publisher-package', type => 'publisher', needs => [], outputs => ['publisher-package'],
url => 'https://repo.openeuler.org/openEuler-24.03-LTS/Everything/x86_64/Packages/publisher-package-1-1.oe2403.noarch.rpm',
sha256 => digest_file($signed{'publisher-package'})}]};
}
sub prepare {
my ($name, $mutate) = @_;
my $root = "$tmp/$name source";
make_path("$root/openeuler", "$root/mock-configs/templates");
my $data = catalog();
$mutate->($data) if $mutate;
copy("$homes{publisher}/public.asc", "$root/openeuler/publisher.asc") or die $!;
write_binary("$root/openeuler/24.03-ppc64le.inputs.json", $json->encode($data));
write_binary("$root/packages-manifest.conf", "[$target]\nnative-child=1\npublisher-package=1\n");
write_binary("$root/Gitepoch", "$epoch\n");
write_binary("$root/Gitinfo", ('a' x 40) . "\n");
write_binary("$root/mock-configs/$target.cfg", "config_opts['root'] = 'native-contract'\nconfig_opts['dnf.conf'] = ''\n");
my %downloads = map { $_->{url} => $signed{$_->{name} . ($_->{type} eq 'srpm' ? '-src' : '')} } @{$data->{inputs}};
write_binary($ENV{NATIVE_DOWNLOADS}, $json->encode(\%downloads));
unlink $ENV{NATIVE_CALLS};
return ($root, $data);
}
my ($valid) = prepare('valid');
my $plan = load_inputs($valid, {'native-child' => '1', 'publisher-package' => '1'});
is_deeply($plan->{order}, [qw(native-leaf native-child publisher-package)], 'public plan orders prerequisites before consumers');
stage_inputs($plan, "$tmp/valid-stage");
is(digest_file($plan->{nodes}{'publisher-package'}{staged}), digest_file($signed{'publisher-package'}), 'publisher admission preserves signed bytes');
is(digest_file($plan->{nodes}{'native-leaf'}{staged}), digest_file($signed{'native-leaf-src'}), 'source admission preserves signed bytes');
ok(-f "$tmp/valid-stage/inputs.json", 'admission records input identity and catalog digest');
validate_outputs($plan->{nodes}{'native-leaf'}, [$rpm{'native-leaf'}], 1);
pass('declared real native output passes ownership validation');
dies_like(sub { validate_outputs($plan->{nodes}{'native-leaf'}, [$rpm{'native-child'}], 1) }, qr/Unexpected output/, 'wrong owner output fails');
dies_like(sub { validate_outputs($plan->{nodes}{'native-leaf'}, [$rpm{'native-leaf'}, $rpm{'native-leaf'}], 1) }, qr/Duplicate output/, 'duplicate output fails');
dies_like(sub { validate_outputs($plan->{nodes}{'native-leaf'}, [], 1) }, qr/Missing output/, 'empty successful build fails');
my @bad = (
['cycle', sub { $_[0]{inputs}[0]{needs} = ['native-child'] }, qr/Cyclic native dependency/],
['missing', sub { $_[0]{inputs}[0]{needs} = ['absent'] }, qr/Missing native dependency/],
['conflict', sub { $_[0]{inputs}[1]{outputs} = ['native-leaf'] }, qr/Conflicting output ownership/],
['uid', sub { $_[0]{inputs}[0]{build_uid} = 0 }, qr/Invalid native build UID/],
['foreign-release', sub { $_[0]{inputs}[2]{url} =~ s/LTS\//LTS-SP3\// }, qr/Publisher binary must be exact GA/],
['unsafe-define', sub { $_[0]{inputs}[0]{defines} = ['llvmjit 0; touch injected'] }, qr/Invalid native spec definition/],
['missing-patch', sub { $_[0]{inputs}[0]{patches} = [{path => 'absent.patch', sha256 => 'a' x 64}] }, qr/Missing input/],
['source-needs-owner', sub {
push @{$_[0]{inputs}}, {name => 'goconserver', type => 'owner', build_uid => 1000,
outputs => ['goconserver'], needs => []};
$_[0]{inputs}[0]{needs} = ['goconserver'];
}, qr/Unsupported native execution edge/],
['owner-needs-owner', sub {
push @{$_[0]{inputs}}, {name => 'goconserver', type => 'owner', build_uid => 1000,
outputs => ['goconserver'], needs => ['ipmitool-xcat']},
{name => 'ipmitool-xcat', type => 'owner', build_uid => 1000, outputs => ['ipmitool-xcat'], needs => []};
}, qr/Unsupported native execution edge/],
);
for my $case (@bad) {
my ($root) = prepare($case->[0], $case->[1]);
dies_like(sub { load_inputs($root, {'native-child' => '1'}) }, $case->[2], "$case->[0] fails before input acquisition");
ok(!-f $ENV{NATIVE_CALLS}, "$case->[0] executes no downloader or builder");
}
for my $case (
['bad-hash', $signed{'native-leaf-src'}, 'b' x 64, qr/SHA256 mismatch/],
['unsigned', $rpm{'native-leaf-src'}, digest_file($rpm{'native-leaf-src'}), qr/Publisher signature missing/],
['wrong-key', $foreign, digest_file($foreign), qr/Command failed/],
) {
my %node = %{$plan->{nodes}{'native-leaf'}};
$node{sha256} = $case->[2];
dies_like(sub { verify_input($plan, \%node, $case->[1], $plan->{trust_db}) }, $case->[3], "$case->[0] cannot enter a native root");
}
for my $case (['publisher-elf', qr/ELF payload/], ['publisher-arch', qr/not a noarch binary/]) {
my %node = (%{$plan->{nodes}{'publisher-package'}}, name => $case->[0], sha256 => digest_file($signed{$case->[0]}));
dies_like(sub { verify_input($plan, \%node, $signed{$case->[0]}, $plan->{trust_db}) }, $case->[1], "$case->[0] is rejected using the real RPM payload/header");
}
my @namespace = ('unshare', ($> == 0 ? () : ('--user', '--map-root-user')), '--mount', '--propagation', 'private');
my $can_owner = $host_arch eq 'ppc64le'
&& run_capture("$tmp/mock-loader.log", 'python3', '-c', 'from mockbuild.util import load_config') == 0
&& run_capture("$tmp/namespace.log", @namespace, 'true') == 0;
SKIP: {
skip 'Whole native owner requires POWER, native Mock and a private mount namespace', 52 unless $can_owner;
for my $case (@bad[0..2, 7, 8]) {
my ($root) = prepare("owner-$case->[0]", $case->[1]);
my $rc = run_capture("$tmp/owner-$case->[0].log", @namespace,
$^X, $owner, '--repo-root', $root, '--target', $target, '--xcat-source', $root,
'--output', "$tmp/owner-$case->[0]-output", '--skip-genesis', '--skip-tarball', '--gpg-sign',
'--gpg-home', $homes{build}, '--gpg-key-name', $keys{build}, '--scrub-all-chroots');
isnt($rc, 0, "$case->[0] fails the whole owner");
like(read_binary("$tmp/owner-$case->[0].log"), $case->[2], "$case->[0] reports its graph error at the owner boundary");
ok(!-f $ENV{NATIVE_CALLS}, "$case->[0] precedes even mock scrub");
}
for my $scenario ('success', 'failed-child', 'empty-child', 'patch-path') {
my ($root, $data) = prepare("owner-$scenario");
if ($scenario eq 'patch-path') {
write_binary("$root/native.patch", "--- a/native-leaf.spec\n+++ b/native-leaf.spec\n@@ -4 +4 @@\n-Summary: Native input contract fixture\n+Summary: Patched native input contract fixture\n");
$data->{inputs}[0]{patches} = [{path => 'native.patch', sha256 => digest_file("$root/native.patch")}];
$data->{inputs}[0]{defines} = ['llvmjit 0', 'runselftest 1'];
write_binary("$root/openeuler/24.03-ppc64le.inputs.json", $json->encode($data));
}
my $out = "$tmp/owner-$scenario-output";
my $dest = "$out/xcat-dep/openeuler24.03/ppc64le";
make_path($dest, "$root/etc-mock");
copy("$root/mock-configs/$target.cfg", "$root/etc-mock/$target.cfg") or die $!;
write_binary("$dest/sentinel", 'old repository');
local $ENV{NATIVE_FAIL} = $scenario eq 'failed-child' ? 'native-child' : '';
local $ENV{NATIVE_EMPTY} = $scenario eq 'empty-child' ? 'native-child' : '';
local $ENV{MOCKBUILD_ALL_MOUNTNS} = 1;
my @cmd = ($^X, $owner, '--repo-root', $root, '--target', $target, '--xcat-source', $root,
'--output', $out, '--run-id', 'contract', '--skip-genesis', '--skip-tarball', '--gpg-sign',
'--gpg-home', $homes{build}, '--gpg-key-name', $keys{build}, '--max-parallel', 1);
my $rc = run_capture("$tmp/owner-$scenario.log", @namespace,
'sh', '-c', 'mount --bind "$1" /etc/mock && shift && exec "$@"', 'native-test', "$root/etc-mock", @cmd);
my @calls = -f $ENV{NATIVE_CALLS} ? map { JSON::PP->new->decode($_) } split /\n/, read_binary($ENV{NATIVE_CALLS}) : ();
my @built = map { $_->{name} } grep { exists $_->{name} } @calls;
is_deeply(\@built, ['native-leaf', 'native-child'], "$scenario executes the prerequisite then its dependent through the owner");
is_deeply([map { $_->{config_rc} } grep { exists $_->{config_rc} } @calls],
[map { 0 } 1 .. ($scenario eq 'patch-path' ? 3 : 2)],
"$scenario loads generated configurations through the installed native Mock");
my $publisher = "$dest/publisher-package-1-1.oe2403.noarch.rpm";
if ($scenario eq 'success' || $scenario eq 'patch-path') {
is($rc, 0, 'whole owner signs and collects the completed native chain') or diag(read_binary("$tmp/owner-$scenario.log"));
is(-f $publisher ? digest_file($publisher) : '', digest_file($signed{'publisher-package'}), 'final publisher package remains byte-identical');
ok(-f "$dest/native-child-1-1.oe2403.noarch.rpm", 'dependent native output reaches the repository');
ok(!-f "$dest/native-leaf-1-1.oe2403.noarch.rpm", 'build-only native prerequisite stays private');
if ($scenario eq 'success' && $rc == 0) {
my @verify = ($^X, $owner, '--repo-root', $root, '--target', $target,
'--verify-repo', $dest, '--gpg-home', $homes{build}, '--gpg-key-name', $keys{build});
is(run_capture("$tmp/final-verify.log", @verify), 0, 'standalone gate accepts the declared publisher and build signers');
copy($publisher, "$tmp/publisher-preserved.rpm") or die $!;
{
local $ENV{GNUPGHOME} = $homes{build};
is(run_capture("$tmp/publisher-resign.log", 'rpmsign', '--define', "_gpg_name $keys{build}",
'--define', '__gpg /usr/bin/gpg', '--resign', $publisher), 0, 'negative control re-signs a publisher copy with the build key');
}
isnt(run_capture("$tmp/final-resigned-publisher.log", @verify), 0, 'collector rejects a re-signed publisher input even with an otherwise allowed key');
like(read_binary("$tmp/final-resigned-publisher.log"), qr/SHA256 mismatch/, 'the publisher failure identifies lost byte identity');
copy("$tmp/publisher-preserved.rpm", $publisher) or die $!;
is(digest_file($publisher), digest_file($signed{'publisher-package'}), 'restore the original publisher bytes after the negative control');
my $generated = "$dest/native-child-1-1.oe2403.noarch.rpm";
copy($generated, "$tmp/generated-preserved.rpm") or die $!;
{
local $ENV{GNUPGHOME} = $homes{publisher};
is(run_capture("$tmp/generated-resign.log", 'rpmsign', '--define', "_gpg_name $keys{publisher}",
'--define', '__gpg /usr/bin/gpg', '--resign', $generated), 0, 'negative control signs generated output with the publisher key');
}
isnt(run_capture("$tmp/final-wrong-generated-key.log", @verify), 0, 'collector rejects the publisher key for generated outputs');
like(read_binary("$tmp/final-wrong-generated-key.log"), qr/NOKEY|WRONGKEY|checksig/i, 'the generated output failure reports its signer mismatch');
copy("$tmp/generated-preserved.rpm", $generated) or die $!;
}
if ($scenario eq 'patch-path') {
my @prepared = grep { exists $_->{spec} } @calls;
is(scalar @prepared, 1, 'tracked patch uses the existing native buildsrpm path once');
like($prepared[0]{spec} // '', qr/^Summary: Patched native input contract fixture$/m,
'the real patch modifies the extracted source spec');
my @args = @{$prepared[0]{mock} // []};
ok(grep($_ eq 'llvmjit 0', @args), 'vendor disable option remains one quoted argument');
ok(grep($_ eq 'runselftest 1', @args), 'vendor test option remains enabled');
my $original = "$out/mockbuild-all/$target-contract/native-inputs/native-leaf/native-leaf-1-1.oe2403.src.rpm";
is(digest_file($original), digest_file($signed{'native-leaf-src'}), 'patch preparation preserves the original signed source');
}
} else {
isnt($rc, 0, "$scenario fails collection");
is(read_binary("$dest/sentinel"), 'old repository', "$scenario preserves the old repository");
ok(!-f $publisher, "$scenario does not publish partial publisher inputs");
ok(!-f "$dest/native-child-1-1.oe2403.noarch.rpm", "$scenario does not publish partial native outputs");
}
}
}
done_testing();
+250
View File
@@ -0,0 +1,250 @@
use strict;
use warnings;
use Cwd qw(abs_path);
use File::Copy qw(copy);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use FindBin qw($RealBin);
use JSON::PP qw(decode_json);
use Test::More;
use lib "$RealBin/../lib", "$RealBin/lib";
use XCAT::BuildUtils qw(capture_command command_exists digest_file read_binary write_binary);
use XCAT::GenesisReleaseTest qw(run_capture);
plan skip_all => 'Linux RPM tools and user namespaces required'
unless $^O eq 'linux' && !grep { !command_exists($_) } qw(rpm rpmkeys rpmbuild createrepo_c unshare python3 gpg gpgconf);
my $tmp = tempdir(CLEANUP => 1);
my @namespace = $> == 0 ? () : ('unshare', '--user', '--map-root-user');
plan skip_all => 'User namespace unavailable for the collector root check'
if @namespace && run_capture("$tmp/namespace.log", @namespace, 'true') != 0;
my $collector = $ENV{XCAT_TEST_COLLECTOR} // abs_path("$RealBin/../mockbuild-all.pl");
my $source = abs_path("$RealBin/../python-scp/python-scp-0.14.5-1.oe2403.src.rpm");
my $hash = '3461d2a3fe0122cac2893d8465ad1271ae21e5570a31d4402e3f887ef545a0e8';
my $arch = capture_command('uname', '-m');
plan skip_all => 'Source package closure is selected only for x86_64' if $arch ne 'x86_64';
is(digest_file($source), $hash, 'the official source RPM is pinned');
my $epoch = 1788718796;
my $target = 'openeuler-20.03sp4-x86_64';
my $key_home = "$tmp/gnupg";
my $key_name = 'source-contract@example.invalid';
make_path("$tmp/bin", "$tmp/fixture/SPECS", $key_home);
chmod 0700, $key_home;
is(run_capture("$tmp/key.log", 'gpg', '--homedir', $key_home, '--batch', '--pinentry-mode', 'loopback',
'--passphrase', '', '--quick-generate-key', $key_name, 'rsa2048', 'sign', '0'), 0,
'create a private ephemeral signing identity for the unsigned-output gate')
or BAIL_OUT(read_binary("$tmp/key.log"));
END {
run_capture("$tmp/key-cleanup.log", 'gpgconf', '--homedir', $key_home, '--kill', 'gpg-agent')
if defined($key_home) && -d $key_home;
}
write_binary("$tmp/fixture/SPECS/python3-scp.spec", <<'SPEC');
Name: python3-scp
Version: 0.14.5
Release: 1
Summary: Collector contract fixture
License: MIT
BuildArch: noarch
%description
Collector contract fixture.
%install
mkdir -p %{buildroot}/usr/share/scp-contract
printf 'fixture\n' > %{buildroot}/usr/share/scp-contract/payload
%files
/usr/share/scp-contract
SPEC
is(run_capture("$tmp/fixture.log", 'rpmbuild', '--quiet', '-ba', '--define', "_topdir $tmp/fixture",
"$tmp/fixture/SPECS/python3-scp.spec"), 0, 'build real RPM fixtures for the command boundary')
or BAIL_OUT(read_binary("$tmp/fixture.log"));
write_binary("$tmp/bin/mock", <<'PYTHON');
#!/usr/bin/python3
import hashlib, json, os, pathlib, shutil, sys
args = sys.argv[1:]
entry = {'argv': args}
def option(name):
return args[args.index(name) + 1]
if '-r' in args and pathlib.Path(option('-r')).is_file():
entry['config'] = pathlib.Path(option('-r')).read_text()
if '--spec' in args:
entry['spec'] = pathlib.Path(option('--spec')).read_text()
if '--rebuild' in args:
src = pathlib.Path(option('--rebuild'))
entry['source'] = str(src)
entry['sha256'] = hashlib.sha256(src.read_bytes()).hexdigest()
with open(os.environ['SCP_CALLS'], 'a') as stream:
stream.write(json.dumps(entry) + '\n')
if any(x.startswith('--scrub=') for x in args):
sys.exit(0)
if '--buildsrpm' in args:
dest = pathlib.Path(option('--resultdir')); dest.mkdir(parents=True, exist_ok=True)
shutil.copyfile(os.environ['SCP_FIXTURE_SOURCE'], dest / 'python3-scp-0.14.5-1.src.rpm')
sys.exit(0)
if os.environ.get('SCP_MUTATE_SOURCE'):
with open(os.environ['SCP_MUTATE_SOURCE'], 'ab') as stream:
stream.write(b'changed after staging')
if os.environ.get('SCP_BUILD_STATUS', '43') != '0':
sys.exit(43)
if os.environ.get('SCP_EMPTY_OUTPUT') != '1':
dest = pathlib.Path(option('--resultdir')); dest.mkdir(parents=True, exist_ok=True)
for key in ('SCP_FIXTURE_BINARY', 'SCP_FIXTURE_SOURCE'):
source = pathlib.Path(os.environ[key]); shutil.copyfile(source, dest / source.name)
PYTHON
chmod 0755, "$tmp/bin/mock";
sub scenario {
my ($name, %opt) = @_;
my $root = "$tmp/$name source";
my $out = "$tmp/$name output";
my $repo = "$tmp/$name-repo";
my $selected = $opt{target} // $target;
my $manifest = $opt{packages} // 'python3-scp=0.14.5';
make_path("$root/python-scp", "$root/grub2-xcat", "$repo/openeuler20.03sp4/x86_64");
write_binary("$root/packages-manifest.conf", "[$selected]\n$manifest\n");
write_binary("$root/Gitinfo", ('a' x 40) . "-dirty-snapshot-" . ('b' x 64) . "\n");
write_binary("$root/Gitepoch", "$epoch\n");
write_binary("$root/grub2-xcat/grub2-xcat.spec", "Name: grub2-xcat\nRelease: 1\n");
write_binary("$root/grub2-xcat/mockbuild.pl", <<'PERL');
use strict;
use warnings;
use JSON::PP qw(encode_json);
open my $fh, '>>', $ENV{SCP_CALLS} or die $!;
print {$fh} encode_json({script => 'grub2-xcat', argv => \@ARGV}) . "\n";
close $fh or die $!;
exit 41;
PERL
my $input = "$root/python-scp/" . (split m{/}, $source)[-1];
copy($source, $input) or die $! unless $opt{missing};
write_binary($input, 'corrupt') if $opt{corrupt};
write_binary("$repo/openeuler20.03sp4/x86_64/sentinel", 'previous repository');
local $ENV{PATH} = "$tmp/bin:$ENV{PATH}";
local $ENV{MOCKBUILD_ALL_MOUNTNS} = 1;
local $ENV{SCP_CALLS} = "$tmp/$name calls.jsonl";
local $ENV{SCP_BUILD_STATUS} = $opt{success} ? '0' : '43';
local $ENV{SCP_EMPTY_OUTPUT} = $opt{empty} // '';
local $ENV{SCP_MUTATE_SOURCE} = $opt{mutate} ? $input : '';
local $ENV{SCP_FIXTURE_BINARY} = "$tmp/fixture/RPMS/noarch/python3-scp-0.14.5-1.noarch.rpm";
local $ENV{SCP_FIXTURE_SOURCE} = "$tmp/fixture/SRPMS/python3-scp-0.14.5-1.src.rpm";
my @options = @{$opt{options} // []};
my $rc = run_capture("$tmp/$name.log", @namespace, $^X, $collector,
'--repo-root', $root, '--xcat-source', $root, '--target', $selected,
'--output', $out, '--repo-dep', $repo, '--run-id', 'source-contract',
'--build-timestamp', $epoch, '--max-parallel', 1, '--parallel-builds', 1,
'--skip-genesis', '--skip-perl', '--skip-tarball', @options);
my @calls = -f $ENV{SCP_CALLS}
? map { decode_json($_) } split /\n/, read_binary($ENV{SCP_CALLS}) : ();
return {rc => $rc, calls => \@calls, log => read_binary("$tmp/$name.log"), input => $input,
repo => $repo, out => $out, root => $root};
}
my $selected = scenario('selected');
isnt($selected->{rc}, 0, 'a failed native source rebuild fails the full owner');
my @builds = grep { grep { $_ eq '--rebuild' } @{$_->{argv}} } @{$selected->{calls}};
is(scalar @builds, 1, 'the exact native manifest selects one source rebuild');
if (@builds) {
my $call = $builds[0];
like($call->{config}, qr/\Ainclude\('\/etc\/mock\/\Q$target\E\.cfg'\)\n/,
'the source rebuild includes the exact native target');
like($call->{config}, qr/\Qconfig_opts['environment']['SOURCE_DATE_EPOCH'] = '$epoch'\E/,
'the epoch enters the mock build environment');
unlike($call->{config}, qr/epel|forcearch|bootstrap_image/, 'the overlay introduces no foreign target policy');
isnt($call->{source}, $selected->{input}, 'mock consumes a private staged source');
is($call->{sha256}, $hash, 'the staged source retains the official digest');
my %args;
for my $i (0 .. $#{$call->{argv}} - 1) { $args{$call->{argv}[$i]} = $call->{argv}[$i + 1]; }
like($args{'--uniqueext'}, qr/^mba-01-openeuler-20\.03-[0-9a-f]{8}-python3-scp$/,
'mock uses the existing bounded target, run digest and package suffix');
like($args{'--resultdir'}, qr/\Q$target\E-source-contract\/build-results\/python3-scp\z/,
'result collection remains target and package specific');
for my $macro ('use_source_date_epoch_as_buildtime 1', 'clamp_mtime_to_source_date_epoch 1', '_buildhost xcat-build') {
ok(grep($_ eq $macro, @{$call->{argv}}), "mock retains deterministic macro $macro");
}
}
like($selected->{log}, qr/required build step|every build step failed/, 'mock failure reaches the owner failure gate');
is(read_binary("$selected->{repo}/openeuler20.03sp4/x86_64/sentinel"), 'previous repository',
'failed source build preserves the previous repository');
for my $case (['corrupt', 'SHA256 mismatch'], ['missing', 'Missing source RPM']) {
my $result = scenario($case->[0], $case->[0] => 1, options => ['--scrub-all-chroots']);
isnt($result->{rc}, 0, "$case->[0] selected source fails");
like($result->{log}, qr/\Q$case->[1]\E/, "$case->[0] source identifies the input failure");
is_deeply($result->{calls}, [], "$case->[0] source fails before any mock action, including scrub");
}
my $staged = scenario('immutable-stage', mutate => 1);
isnt(digest_file($staged->{input}), $hash, 'the command double changes the original after staging');
my @staged_builds = grep { exists $_->{sha256} } @{$staged->{calls}};
is(scalar @staged_builds, 1, 'the staged source reaches mock once');
is($staged_builds[0]{sha256}, $hash, 'changing the original does not change the build input') if @staged_builds;
for my $other ('openeuler-24.03sp3-x86_64', 'openeuler-24.03sp4-x86_64', 'alma+epel-9-x86_64') {
my $result = scenario("unselected-$other", target => $other, packages => 'grub2-xcat=1.0', missing => 1);
isnt($result->{rc}, 0, "$other propagates the existing script failure");
my @script = grep { ($_->{script} // '') eq 'grub2-xcat' } @{$result->{calls}};
is(scalar @script, 1, "$other keeps the existing script builder");
is(scalar(grep { exists $_->{source} } @{$result->{calls}}), 0, "$other does not select the source RPM");
unlike($result->{log}, qr/Missing source RPM|SHA256 mismatch/, "$other does not require the unselected source input");
if (@script) {
my %args = @{$script[0]{argv}};
is($args{'--mock-cfg'}, $other, "$other preserves the script target argument");
is($args{'--build-timestamp'}, "$epoch", "$other preserves the script epoch argument");
}
}
my $absent = scenario('native-manifest-absence', packages => 'grub2-xcat=1.0', missing => 1);
is(scalar(grep { exists $_->{source} } @{$absent->{calls}}), 0, '20 SP4 also requires explicit manifest selection');
unlike($absent->{log}, qr/Missing source RPM/, 'an absent native manifest entry needs no source input');
my $cross = scenario('cross', target => 'openeuler-24.03-ppc64le');
isnt($cross->{rc}, 0, 'native cross-architecture source build is rejected');
like($cross->{log}, qr/requires a ppc64le build host/, 'cross rejection names the native host requirement');
is_deeply($cross->{calls}, [], 'cross rejection precedes every build command');
my $dry = scenario('dry', options => ['--dry-run']);
is($dry->{rc}, 0, 'the selected source has a successful dry-run plan');
like($dry->{log}, qr/--rebuild.*python-scp-0\.14\.5-1\.oe2403\.src\.rpm/, 'dry run reports the source rebuild');
is_deeply($dry->{calls}, [], 'dry run executes no mock action');
ok(!-d "$dry->{out}/mockbuild-all/$target-source-contract/source-rpms", 'dry run stages no source or mock overlay');
my $restamp = scenario('restamp', options => ['--build-number', 7]);
isnt($restamp->{rc}, 0, 'restamped rebuild failure remains fatal');
my @sources = grep { exists $_->{spec} } @{$restamp->{calls}};
is(scalar @sources, 1, 'a build number first creates one native source RPM');
like($sources[0]{spec}, qr/^Release:\s+1\.snap202609061819\.7\s*$/m,
'source spec reuses the existing release suffix policy') if @sources;
my @restamped = grep { exists $_->{source} } @{$restamp->{calls}};
is(scalar @restamped, 1, 'the generated source RPM is rebuilt once');
like($restamped[0]{source}, qr/restamp-srpm\/python3-scp-0\.14\.5-1\.src\.rpm\z/,
'binary build consumes the new source RPM') if @restamped;
is(digest_file($restamp->{input}), $hash, 'Release restamping preserves the official input bytes');
my $empty = scenario('empty', success => 1, empty => 1);
isnt($empty->{rc}, 0, 'mock success without an RPM cannot close the owner build');
like($empty->{log}, qr/No binary RPMs were collected/, 'empty results reach the existing collection gate');
my $unsigned = scenario('unsigned', success => 1,
options => ['--gpg-home', $key_home, '--gpg-key-name', $key_name]);
isnt($unsigned->{rc}, 0, 'unsigned command-double output cannot pass the repository signature gate');
like($unsigned->{log}, qr/UNSIGNED repomd/,
'the unsigned output reports a repository trust failure');
like($unsigned->{log}, qr/UNSIGNED rpm python3-scp-0\.14\.5-1\.noarch\.rpm/,
'the existing gate also rejects the unsigned binary');
is(read_binary("$unsigned->{repo}/openeuler20.03sp4/x86_64/sentinel"), 'previous repository',
'a publication gate failure preserves the previous repository');
my $collected = scenario('collection', success => 1, options => ['--no-verify-repo']);
is($collected->{rc}, 0, 'the debug collection path accepts successful RPM-producing command output')
or diag($collected->{log});
my $published = "$collected->{repo}/openeuler20.03sp4/x86_64/python3-scp-0.14.5-1.noarch.rpm";
ok(-f $published, 'native binary reaches the exact repository subdirectory');
is(digest_file($published), digest_file("$tmp/fixture/RPMS/noarch/python3-scp-0.14.5-1.noarch.rpm"),
'the existing collector preserves binary bytes') if -f $published;
ok(-f "$collected->{out}/mockbuild-all/$target-source-contract/repo-src/python3-scp-0.14.5-1.src.rpm",
'the existing collector also retains the generated source RPM');
my $skipped = scenario('skip-dep', missing => 1, options => ['--skip-xcat-dep', '--dry-run']);
is($skipped->{rc}, 0, 'skipping dependency builds does not require the source RPM');
is_deeply($skipped->{calls}, [], 'skip-dep executes no source action');
my $replay = scenario('replay', missing => 1, options => ['--skip-build', '--no-verify-repo',
'--collect-dir', "$tmp/fixture/RPMS/noarch"]);
is($replay->{rc}, 0, 'build-free artifact collection does not require the original source RPM');
is_deeply($replay->{calls}, [], 'build-free collection executes no source action');
my $incomplete = scenario('incomplete', success => 1, packages => "python3-scp=0.14.5\nclosure-gap=1");
isnt($incomplete->{rc}, 0, 'a successful source rebuild does not bypass the manifest gate');
like($incomplete->{log}, qr/MISSING closure-gap\b/, 'the manifest gate identifies the missing required package');
done_testing();
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin qw($RealBin);
use lib "$RealBin/..";
use File::Temp qw(tempdir);
use File::Path qw(make_path);
use JSON::PP qw(decode_json);
use Test::More;
use MockBuildUtils qw(openeuler_build_target openeuler_repo_subdir install_deps_command install_deps_packages);
my @cells = (
['20.03sp4', '20.03-LTS-SP4', '20.03LTS_SP4', 'x86_64'],
['22.03sp4', '22.03-LTS-SP4', '22.03LTS_SP4', 'x86_64'],
['24.03sp1', '24.03-LTS-SP1', '24.03LTS_SP1', 'x86_64'],
['24.03sp3', '24.03-LTS-SP3', '24.03LTS_SP3', 'x86_64'],
['24.03sp4', '24.03-LTS-SP4', '24.03LTS_SP4', 'x86_64'],
['24.03', '24.03-LTS', '24.03LTS', 'ppc64le'],
);
for my $cell (@cells) {
my ($version, $release, undef, $arch) = @$cell;
my ($base, $sp) = $version =~ /^(\d+\.\d+)(?:sp(\d+))?$/;
my $native_version = "$base (LTS" . (defined($sp) ? "-SP$sp" : '') . ')';
my $target = "openeuler-$version-$arch";
is(openeuler_build_target({ID => 'openEuler', VERSION => $native_version}, $arch), $target,
"$target retains the native service pack");
is(openeuler_repo_subdir($target), "openeuler$version/$arch", "$target preserves repository provenance");
}
is(openeuler_build_target({ID => 'rocky', VERSION_ID => '9.6'}, 'x86_64'), undef, 'EL uses existing target selection');
is(openeuler_repo_subdir('alma+epel-10-x86_64'), undef, 'EL uses existing repository layout');
for my $target ('openeuler-24.09-x86_64', 'openeuler-24.03sp0-x86_64', 'openeuler-24.03-ppc64') {
eval {openeuler_repo_subdir($target)};
like($@, qr/Unsupported openEuler build target/, "$target is rejected");
}
my @native_install = install_deps_command('openEuler');
is_deeply([@native_install[0..6]], ['dnf', '--setopt=gpgcheck=1', '--setopt=*.gpgcheck=1', '--setopt=strict=1', '--setopt=install_weak_deps=False', '-y', 'install'],
'native prerequisites require signatures and dependency closure');
ok(grep($_ eq '/usr/bin/systemd-nspawn', @native_install), 'native prerequisites request the mock isolation executable across package splits');
ok(!grep(/epel|crb|codeready/i, @native_install), 'native prerequisites do not enable EL repositories');
is_deeply([install_deps_command('rocky')], ['dnf', '-y', 'install', install_deps_packages('rocky')], 'EL prerequisite command remains unchanged');
{
my $tmp = tempdir(CLEANUP => 1);
open(my $manifest, '>', "$tmp/packages-manifest.conf") or die $!;
for my $cell (@cells) {
my ($version, undef, undef, $arch) = @$cell;
print {$manifest} "[openeuler-$version-$arch]\nnative-fixture-$version=1\n";
}
close($manifest) or die $!;
for my $cell (@cells) {
my ($version, undef, undef, $arch) = @$cell;
my $repo = "$tmp/openeuler$version/$arch";
make_path($repo);
my $pid = fork();
die $! unless defined $pid;
if (!$pid) {
open(STDOUT, '>', "$tmp/output") or die $!;
open(STDERR, '>&', \*STDOUT) or die $!;
exec($^X, "$RealBin/../mockbuild-all.pl", '--verify-repo', $repo, '--repo-root', $tmp) or die $!;
}
waitpid($pid, 0);
isnt($? >> 8, 0, "$version/$arch empty repository fails the full publication gate");
open(my $output, '<', "$tmp/output") or die $!;
my $text = do {local $/; <$output>};
close($output);
like($text, qr/MISSING native-fixture-\Q$version\E\b/, "$version/$arch path selects its own exact manifest section");
}
}
SKIP: {
skip 'native mock Python library and templates required', 66
if system('python3 -c "import mockbuild.config" >/dev/null 2>&1') != 0
|| !-f '/etc/mock/templates/openeuler-24.03.tpl';
my $tmp = tempdir(CLEANUP => 1);
my $loader = "$tmp/load.py";
open(my $fh, '>', $loader) or die $!;
print {$fh} <<'PYTHON';
import configparser
import json
from pathlib import Path
import shutil
import sys
import tempfile
from mockbuild.config import load_config
source = Path(sys.argv[1]).resolve()
with tempfile.TemporaryDirectory() as directory:
config_path = Path(directory)
(config_path / 'templates').mkdir()
for parent in ('openeuler-20.03-sp4.tpl', 'openeuler-22.03-sp4.tpl', 'openeuler-24.03.tpl'):
shutil.copyfile(Path('/etc/mock/templates') / parent, config_path / 'templates' / parent)
shutil.copyfile(source / 'templates/openeuler-lts-xcat.tpl', config_path / 'templates/openeuler-lts-xcat.tpl')
result = {}
for wrapper in sorted(source.glob('openeuler-*.cfg')):
config = load_config(str(config_path), str(wrapper))
repos = configparser.ConfigParser(interpolation=None)
repos.read_string(config['dnf.conf'])
result[wrapper.stem] = {key: config[key] for key in ('root', 'target_arch', 'legal_host_arches', 'releasever', 'dist', 'use_bootstrap_image')}
result[wrapper.stem]['repos'] = {section: dict(repos[section]) for section in repos.sections()}
print(json.dumps(result))
PYTHON
close($fh) or die $!;
open(my $pipe, '-|', 'python3', $loader, "$RealBin/../mock-configs") or die $!;
my $json = do {local $/; <$pipe>};
close($pipe) or die "native mock config loader failed: $?";
my $configs = decode_json($json);
for my $cell (@cells) {
my ($version, $release, $releasever, $arch) = @$cell;
my $target = "openeuler-$version-$arch";
my $config = $configs->{$target};
is($config->{root}, $target, "$target selects its own buildroot");
is($config->{target_arch}, $arch, "$target selects its native architecture");
is_deeply($config->{legal_host_arches}, [$arch], "$target requires a native host");
is($config->{releasever}, $releasever, "$target retains the release package convention");
is($config->{dist}, '', "$target retains the native empty dist macro");
ok(!$config->{use_bootstrap_image}, "$target constructs its bootstrap from signed native RPMs");
my $repos = $config->{repos};
my @names = $arch eq 'ppc64le' ? ('OS') : ('OS', 'everything', 'update');
is_deeply([sort grep {$_ ne 'main'} keys %$repos], [sort @names], "$target selects only published native repositories");
is($repos->{main}{gpgcheck}, '1', "$target requires native package signatures");
my $base = "https://repo.openeuler.org/openEuler-$release";
is_deeply([map {$repos->{$_}{baseurl}} @names], [map {"$base/$_/$arch/"} @names], "$target pins repository URLs to its exact release");
is_deeply([map {$repos->{$_}{gpgkey}} @names], [map {"$base/OS/$arch/RPM-GPG-KEY-openEuler"} @names], "$target uses the release signing key");
ok(!grep({$repos->{$_}{gpgcheck} ne '1' || $repos->{$_}{skip_if_unavailable} ne '0'} @names), "$target fails on unsigned packages or unavailable repositories");
}
}
done_testing();
+134
View File
@@ -0,0 +1,134 @@
use strict;
use warnings;
use Cwd qw(abs_path);
use File::Copy qw(copy);
use File::Path qw(make_path);
use File::Glob qw(bsd_glob);
use File::Temp qw(tempdir);
use FindBin qw($RealBin);
use JSON::PP qw(encode_json);
use Test::More;
use Text::ParseWords qw(shellwords);
use lib "$RealBin/../lib", "$RealBin/lib";
use XCAT::BuildUtils qw(capture_command command_exists digest_file read_binary write_binary);
use XCAT::GenesisReleaseTest qw(run_capture);
plan skip_all => 'Linux RPM packaging tools and namespaces are required'
unless $^O eq 'linux' && !grep { !command_exists($_) } qw(rpm rpmbuild rpm2cpio cpio tar unshare);
my $tmp = tempdir(CLEANUP => !$ENV{XCAT_TEST_KEEP});
diag("xNBA release fixtures: $tmp");
my @namespace = $> == 0 ? () : ('unshare', '--user', '--map-root-user');
plan skip_all => 'User namespace is unavailable for the packaging owner root check'
if @namespace && run_capture("$tmp/namespace.log", @namespace, 'true') != 0;
my $repo = abs_path("$RealBin/..");
my $owner = $ENV{XCAT_TEST_XNBA} // "$repo/xnba/mockbuild.pl";
my $collector = $ENV{XCAT_TEST_COLLECTOR} // "$repo/mockbuild-all.pl";
my $epoch = 1788718796;
my $suffix = '.snap202609061819.21';
my $default_release = capture_command('rpm', '--eval', '1%{?dist}');
my $source = "$tmp/source tree";
make_path("$source/xnba/binary");
copy($owner, "$source/xnba/mockbuild.pl") or die $!;
copy("$repo/MockBuildUtils.pm", "$source/MockBuildUtils.pm") or die $!;
copy("$repo/xnba/xnba-undi.spec", "$source/xnba/xnba-undi.spec") or die $!;
copy("$repo/xnba/binary/$_", "$source/xnba/binary/$_") or die $! for qw(xnba.kpxe xnba.efi);
my %specs;
for my $case (['default', []], ['suffix', ['--release-suffix', $suffix]]) {
my ($name, $options) = @$case;
my $work = "$tmp/$name-work";
my $result = "$tmp/$name-result";
my $rc = run_capture("$tmp/$name.log", @namespace, $^X, "$source/xnba/mockbuild.pl",
'--mock-cfg', 'openeuler-24.03-ppc64le', '--work-dir', $work,
'--result-dir', $result, '--log-dir', "$tmp/$name logs", '--build-timestamp', $epoch, @$options);
is($rc, 0, "$name actual xNBA packaging owner succeeds") or diag(read_binary("$tmp/$name.log"));
next if $rc;
$specs{$name} = read_binary("$work/rpmbuild/SPECS/xnba-undi.spec");
my @rpms = bsd_glob("$result/*.rpm");
is(scalar @rpms, 2, "$name produces exactly a binary RPM and SRPM");
my ($binary) = grep { /\.noarch\.rpm\z/ } @rpms;
my ($srpm) = grep { /\.src\.rpm\z/ } @rpms;
ok($binary && $srpm, "$name output includes both RPM kinds");
next unless $binary && $srpm;
my $release = $default_release . ($name eq 'suffix' ? $suffix : '');
is(capture_command('rpm', '-qp', '--qf', '%{RELEASE}', $_), $release,
"$name records the expected Release in $_") for ($binary, $srpm);
is(capture_command('rpm', '-qp', '--qf', '%{SOURCEPACKAGE}', $srpm), '1', "$name source output is an SRPM");
my $unpack = "$tmp/$name payload";
make_path($unpack);
is(run_capture("$tmp/$name.cpio", 'rpm2cpio', $binary), 0, "$name native RPM payload decodes");
is(run_capture("$tmp/$name-extract.log", 'bash', '-c',
'cd "$1" && cpio --quiet -idm --no-absolute-filenames < "$2"', 'extract', $unpack, "$tmp/$name.cpio"),
0, "$name native cpio payload extracts");
for my $file (qw(xnba.kpxe xnba.efi)) {
is(digest_file("$unpack/tftpboot/xcat/$file"), digest_file("$repo/xnba/binary/$file"),
"$name preserves committed $file bytes");
}
is(capture_command('rpm', '-qpl', $binary), "/tftpboot/xcat/xnba.efi\n/tftpboot/xcat/xnba.kpxe",
"$name ships only the two boot payloads");
}
if (exists $specs{default} && exists $specs{suffix}) {
(my $without_suffix = $specs{suffix}) =~ s/\Q$suffix\E//;
is($without_suffix, $specs{default}, 'the suffix changes only the generated Release token');
}
my $arch = capture_command('uname', '-m');
my @targets = ("alma+epel-9-$arch");
push @targets, 'openeuler-24.03-ppc64le' if $arch eq 'ppc64le';
push @targets, 'openeuler-24.03sp3-x86_64' if $arch eq 'x86_64';
for my $target (@targets) {
for my $number (undef, 21) {
my $label = defined($number) ? 'suffix' : 'default';
my $root = "$tmp/plan $target $label";
make_path(map { "$root/$_" } qw(xnba goconserver grub2-xcat openeuler));
write_binary("$root/packages-manifest.conf", "[$target]\nxnba-undi=1.*\ngoconserver=0.*\ngrub2-xcat=2.*\n");
for my $dir (qw(xnba goconserver grub2-xcat)) {
write_binary("$root/$dir/mockbuild.pl", "die qq{dry-run executed a builder\\n};\n");
}
if ($target eq 'openeuler-24.03-ppc64le') {
my $key = 'openeuler/publisher.key';
write_binary("$root/$key", 'dry-run key fixture');
write_binary("$root/openeuler/24.03-ppc64le.inputs.json", encode_json({
version => 1, target => $target,
publisher_key => {path => $key, sha256 => digest_file("$root/$key"), fingerprint => ('A' x 40)},
inputs => [map { {name => $_, type => 'owner', outputs => [$_],
build_uid => ($_ eq 'xnba-undi' ? 0 : 1000)} } qw(xnba-undi goconserver grub2-xcat)],
}));
}
local $ENV{MOCKBUILD_ALL_MOUNTNS} = 1;
my $log = "$tmp/plan-$target-$label.log";
my $rc = run_capture($log, @namespace, $^X, $collector, '--repo-root', $root,
'--xcat-source', $root, '--target', $target, '--output', "$root/output",
'--build-timestamp', $epoch, '--run-id', 'release-contract', '--skip-genesis', '--gpg-sign',
'--max-parallel', 1, '--parallel-builds', 1, '--dry-run',
(defined($number) ? ('--build-number', $number) : ()));
is($rc, 0, "$target $label whole collector dry-run succeeds") or diag(read_binary($log));
my $text = read_binary($log);
for my $dir (qw(xnba goconserver grub2-xcat)) {
my ($command) = $text =~ /^\+ ([^\n]*\Q$root\/$dir\/mockbuild.pl\E[^\n]*)$/m;
ok(defined($command), "$target $label plans the $dir owner");
next unless defined $command;
my @argv = shellwords($command);
for (1 .. 3) {
last if $argv[0] eq 'perl';
@argv = shellwords($argv[-1]);
}
is($argv[0], 'perl', 'the planned command reaches the Perl owner');
my %options;
for my $i (0 .. $#argv - 1) { $options{$argv[$i]} = $argv[$i + 1]; }
if (defined($number) && $dir ne 'grub2-xcat') {
is($options{'--release-suffix'}, $suffix, "$dir receives the exact CD suffix");
} else {
ok(!exists($options{'--release-suffix'}), "$dir retains its existing suffix option behavior");
}
if ($dir eq 'goconserver') {
like($options{'--go-ref'}, qr/\A[0-9a-f]{40}\z/, 'goconserver retains its immutable source pin');
} else {
ok(!exists($options{'--go-ref'}), "$dir receives no Go-specific option");
}
}
ok(!-d "$root/output/mockbuild-all/$target-release-contract/build-results", 'the dry-run executes no package build');
}
}
done_testing();