From a8fcf40e6ba0a56b43eac4ba34f2b3af2d65eb1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:24:02 -0300 Subject: [PATCH] test(openeuler): cover native dependency builds --- t/buildinfo_provenance.t | 100 +++++++++++ t/goconserver-openeuler.t | 304 +++++++++++++++++++++++++++++++++ t/openeuler-power-inputs.t | 338 +++++++++++++++++++++++++++++++++++++ t/openeuler-srpm.t | 250 +++++++++++++++++++++++++++ t/openeuler.t | 127 ++++++++++++++ t/xnba-release-suffix.t | 134 +++++++++++++++ 6 files changed, 1253 insertions(+) create mode 100644 t/buildinfo_provenance.t create mode 100644 t/goconserver-openeuler.t create mode 100644 t/openeuler-power-inputs.t create mode 100644 t/openeuler-srpm.t create mode 100644 t/openeuler.t create mode 100644 t/xnba-release-suffix.t diff --git a/t/buildinfo_provenance.t b/t/buildinfo_provenance.t new file mode 100644 index 0000000..99303a1 --- /dev/null +++ b/t/buildinfo_provenance.t @@ -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(); diff --git a/t/goconserver-openeuler.t b/t/goconserver-openeuler.t new file mode 100644 index 0000000..90c7b35 --- /dev/null +++ b/t/goconserver-openeuler.t @@ -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(); diff --git a/t/openeuler-power-inputs.t b/t/openeuler-power-inputs.t new file mode 100644 index 0000000..d3d7efe --- /dev/null +++ b/t/openeuler-power-inputs.t @@ -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(); diff --git a/t/openeuler-srpm.t b/t/openeuler-srpm.t new file mode 100644 index 0000000..49e3f45 --- /dev/null +++ b/t/openeuler-srpm.t @@ -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(); diff --git a/t/openeuler.t b/t/openeuler.t new file mode 100644 index 0000000..d18f46d --- /dev/null +++ b/t/openeuler.t @@ -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(); diff --git a/t/xnba-release-suffix.t b/t/xnba-release-suffix.t new file mode 100644 index 0000000..c004d32 --- /dev/null +++ b/t/xnba-release-suffix.t @@ -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();