use strict; use warnings; use Cwd qw(abs_path); use File::Copy qw(copy); use File::Path qw(make_path remove_tree); use File::Temp qw(tempdir); use FindBin; use Test::More; use lib "$FindBin::Bin/../lib"; use lib "$FindBin::Bin/lib"; use XCAT::BuildUtils qw( command_exists digest_file read_binary write_binary ); use XCAT::GenesisRelease qw( architectures deb_package_name rpm_package_name validated_release_checksums validate_architecture validate_complete_release validate_export validate_release verify_release_file ); use XCAT::GenesisReleaseTest qw( copy_tree dies_like make_export run_capture write_checksums write_release_manifest ); my $repo_root = abs_path("$FindBin::Bin/.."); my $packager = "$repo_root/genesis-openembedded/package"; my $builder = "$repo_root/genesis-openembedded/build"; my $verifier = "$repo_root/genesis-openembedded/verify-release"; my $revision = 'a' x 40; my $version = '2.19.0'; my $release = 'snap202608210726'; my $epoch = 1787293573; if ($ENV{XCAT_GENESIS_CI}) { for my $command (qw(git dpkg-deb rpm rpmbuild tar)) { BAIL_OUT("CI requires $command") unless command_exists($command); } } is_deeply( [ architectures() ], [ qw(x86 x86_64 ppc64 ppc64le armv7hf aarch64 riscv64) ], 'supported architectures keep their exact xCAT names', ); is(rpm_package_name('ppc64le'), 'xCAT-genesis-openembedded-ppc64le', 'RPM package keeps ppc64le distinct'); is(deb_package_name('x86_64'), 'xcat-genesis-openembedded-x86-64', 'DEB package uses a legal spelling of x86_64'); dies_like(sub { validate_architecture('ppc') }, qr/Unsupported Genesis architecture/, 'legacy ppc alias is rejected'); my $tmp = tempdir(CLEANUP => 1); my $export = make_export("$tmp/export", 'x86_64'); ok(validate_export($export, 'x86_64'), 'valid export passes'); dies_like(sub { validate_export($export, 'ppc64le') }, qr/architecture mismatch/, 'wrong architecture fails'); my $missing = make_export("$tmp/missing", 'x86_64'); unlink("$missing/image.vex.json") or die $!; write_checksums($missing); dies_like(sub { validate_export($missing, 'x86_64') }, qr/missing image\.vex\.json/, 'missing release evidence fails'); my $corrupt = make_export("$tmp/corrupt", 'x86_64'); write_binary("$corrupt/kernel", 'changed'); dies_like(sub { validate_export($corrupt, 'x86_64') }, qr/Checksum mismatch for kernel/, 'corrupt payload fails'); my $unexpected = make_export("$tmp/unexpected", 'x86_64'); write_binary("$unexpected/extra", 'not part of the export'); write_checksums($unexpected); dies_like(sub { validate_export($unexpected, 'x86_64') }, qr/Unexpected Genesis export file/, 'unlisted export files fail'); my $linked = make_export("$tmp/linked", 'x86_64'); unlink("$linked/kernel") or die $!; symlink('initramfs.cpio.gz', "$linked/kernel") or die $!; dies_like(sub { validate_export($linked, 'x86_64') }, qr/Symbolic links are not allowed/, 'export symlinks fail'); my $riscv = make_export("$tmp/riscv", 'riscv64'); ok(-f "$riscv/fw_jump.elf", 'RISC-V export carries firmware'); ok(validate_export($riscv, 'riscv64'), 'RISC-V export passes'); my $release_dir = "$tmp/release"; make_path("$release_dir/rpm", "$release_dir/srpm", "$release_dir/deb"); for my $architecture (qw(x86_64 ppc64le)) { my $rpm = rpm_package_name($architecture); my $deb = deb_package_name($architecture); write_binary("$release_dir/rpm/$rpm-$version-$release.noarch.rpm", "rpm $architecture"); write_binary("$release_dir/srpm/$rpm-$version-$release.src.rpm", "srpm $architecture"); write_binary("$release_dir/deb/${deb}_${version}-${release}_all.deb", "deb $architecture"); } write_release_manifest( $release_dir, $version, $release, $revision, $epoch, 'x86_64,ppc64le', 'deb,rpm', ); write_checksums($release_dir); my $manifest = validate_release($release_dir); is($manifest->{xcat_revision}, $revision, 'release records xcat-core revision'); my $qualified_release = "$tmp/qualified-release"; my $qualified_release_name = '1+deb~1'; my $qualified_deb = deb_package_name('x86_64'); make_path("$qualified_release/deb"); write_binary( "$qualified_release/deb/${qualified_deb}_${version}-${qualified_release_name}_all.deb", 'deb x86_64', ); write_release_manifest( $qualified_release, $version, $qualified_release_name, $revision, $epoch, 'x86_64', 'deb', ); write_checksums($qualified_release); ok(validate_release($qualified_release), 'package filenames accept valid release qualifiers'); my $checksum_reads = 0; my $verified_checksums; { no warnings 'redefine'; my $reader = \&XCAT::GenesisRelease::read_checksum_manifest; local *XCAT::GenesisRelease::read_checksum_manifest = sub { $checksum_reads++; return $reader->(@_); }; $verified_checksums = validated_release_checksums($release_dir); } is($checksum_reads, 1, 'validated checksums use the verified manifest read'); my $verified_relative = "rpm/xCAT-genesis-openembedded-x86_64-$version-$release.noarch.rpm"; my $verified_copy = "$tmp/verified-copy.rpm"; copy("$release_dir/$verified_relative", $verified_copy) or die $!; ok(verify_release_file($verified_checksums, $verified_relative, $verified_copy), 'collected package matches the verified release'); write_binary($verified_copy, 'changed after verification'); dies_like( sub { verify_release_file($verified_checksums, $verified_relative, $verified_copy) }, qr/Collected release file checksum mismatch/, 'release changes after verification are rejected', ); copy("$release_dir/$verified_relative", $verified_copy) or die $!; my $copied_file_log = "$tmp/copied-file.log"; is( run_capture( $copied_file_log, $verifier, '--checksum-file', "$release_dir/SHA256SUMS", '--relative-file', $verified_relative, '--copied-file', $verified_copy, ), 0, 'verifier accepts a copied release file', ); write_binary($verified_copy, 'changed after verification'); isnt( run_capture( $copied_file_log, $verifier, '--checksum-file', "$release_dir/SHA256SUMS", '--relative-file', $verified_relative, '--copied-file', $verified_copy, ), 0, 'verifier rejects a changed copied file', ); like( read_binary($copied_file_log), qr/Collected release file checksum mismatch/, 'copied-file failure names the checksum mismatch', ); dies_like( sub { validate_complete_release($release_dir) }, qr/Genesis release is missing supported architectures/, 'partial release cannot be published', ); my $complete_release = "$tmp/complete-release"; make_path("$complete_release/rpm", "$complete_release/srpm", "$complete_release/deb"); for my $architecture (architectures()) { my $rpm = rpm_package_name($architecture); my $deb = deb_package_name($architecture); write_binary("$complete_release/rpm/$rpm-$version-$release.noarch.rpm", "rpm $architecture"); write_binary("$complete_release/srpm/$rpm-$version-$release.src.rpm", "srpm $architecture"); write_binary("$complete_release/deb/${deb}_${version}-${release}_all.deb", "deb $architecture"); } write_release_manifest( $complete_release, $version, $release, $revision, $epoch, join(',', architectures()), 'deb,rpm', ); write_checksums($complete_release); ok(validate_complete_release($complete_release), 'complete release can be published'); my $deb_only_release = "$tmp/deb-only-release"; make_path("$deb_only_release/deb"); for my $architecture (architectures()) { my $deb = deb_package_name($architecture); write_binary("$deb_only_release/deb/${deb}_${version}-${release}_all.deb", "deb $architecture"); } write_release_manifest( $deb_only_release, $version, $release, $revision, $epoch, join(',', architectures()), 'deb', ); write_checksums($deb_only_release); my $verify_all_log = "$tmp/verify-all.log"; isnt(run_capture($verify_all_log, $verifier, $deb_only_release), 0, 'all-format verification rejects a single-format release'); like(read_binary($verify_all_log), qr/Release does not contain rpm packages/, 'all-format failure names the missing format'); my $bad_release = "$tmp/bad-release"; copy_tree($release_dir, $bad_release); write_binary("$bad_release/rpm/stale.rpm", 'stale'); write_checksums($bad_release); dies_like(sub { validate_release($bad_release) }, qr/Unexpected Genesis release artifact/, 'stale package fails'); my $missing_release = "$tmp/missing-release"; copy_tree($release_dir, $missing_release); unlink("$missing_release/rpm/xCAT-genesis-openembedded-ppc64le-$version-$release.noarch.rpm") or die $!; write_checksums($missing_release); dies_like(sub { validate_release($missing_release) }, qr/Genesis release is missing/, 'incomplete architecture set fails'); SKIP: { skip 'git is not installed', 2 unless command_exists('git'); my $source = "$tmp/dirty-xcat-core"; make_path("$source/xCAT-genesis-builder/oe"); write_binary("$source/Version", "$version\n"); write_binary("$source/xCAT-genesis-builder/oe/build", "#!/bin/sh\nexit 99\n"); write_binary("$source/xCAT-genesis-builder/oe/export", "#!/bin/sh\nexit 99\n"); for my $command ( [ 'git', '-C', $source, 'init', '-q' ], [ 'git', '-C', $source, 'add', '.' ], [ 'git', '-C', $source, '-c', 'user.name=xCAT test', '-c', 'user.email=xcat-test@example.invalid', 'commit', '-qm', 'fixture' ], ) { die "Cannot prepare test repository\n" if run_capture("$tmp/git-fixture.log", @{$command}); } write_binary("$source/untracked", "not part of the commit\n"); my $log = "$tmp/dirty-source.log"; isnt( run_capture( $log, $builder, '--xcat-source', $source, '--output-dir', "$tmp/dirty-output", ), 0, 'release builder rejects untracked source files', ); like(read_binary($log), qr/xcat-core checkout is not clean/, 'dirty checkout failure is explicit'); } SKIP: { skip 'rpmbuild and rpm are not installed', 18 unless command_exists('rpmbuild') && command_exists('rpm'); exercise_packager('rpm'); } SKIP: { skip 'rpmbuild and rpm are not installed', 2 unless command_exists('rpmbuild') && command_exists('rpm'); skip 'root can traverse an unsearchable working directory', 2 if $> == 0; exercise_packager_from_unsearchable_cwd(); } SKIP: { skip 'dpkg-deb is not installed', 12 unless command_exists('dpkg-deb'); exercise_packager('deb'); } SKIP: { skip 'git and dpkg-deb are not installed', 6 unless command_exists('git') && command_exists('dpkg-deb'); exercise_builder_tmpdir(); } done_testing(); sub exercise_packager { my ($format) = @_; my $first = "$tmp/$format-first"; my $second = "$tmp/$format-second"; my $package_revision = sprintf('%032x%08x', time, $$); my @command = ( $packager, '--architecture', 'x86_64', '--export-dir', $export, '--version', $version, '--release', $release, '--revision', $package_revision, '--source-date-epoch', $epoch, '--format', $format, ); my ($legacy_rpm_top, $legacy_rpm_work); if ($format eq 'rpm') { $legacy_rpm_top = "/var/tmp/xcat-genesis-rpmbuild-$package_revision-x86_64-$version-$release"; $legacy_rpm_work = "$legacy_rpm_top/BUILD/stale"; make_path("$legacy_rpm_top/BUILD"); write_binary($legacy_rpm_work, 'stale work'); } my $original_umask = umask(0022); is(system(@command, '--output-dir', $first), 0, "$format package builds with umask 0022"); umask(0002); is(system(@command, '--output-dir', $second), 0, "$format package rebuilds with umask 0002"); umask($original_umask); is(sprintf('%04o', (stat($first))[2] & 0x0fff), '0755', "$format output is readable and searchable"); is(sprintf('%04o', (stat($second))[2] & 0x0fff), '0755', "$format rebuilt output is readable and searchable"); if (defined($legacy_rpm_work)) { ok(-e $legacy_rpm_work, 'RPM package ignores the legacy fixed work path'); remove_tree($legacy_rpm_top); } my ($relative, $source_relative); if ($format eq 'rpm') { $relative = "rpm/xCAT-genesis-openembedded-x86_64-$version-$release.noarch.rpm"; $source_relative = "srpm/xCAT-genesis-openembedded-x86_64-$version-$release.src.rpm"; } else { $relative = "deb/xcat-genesis-openembedded-x86-64_${version}-${release}_all.deb"; } ok(-f "$first/$relative", "$format binary exists"); is(digest_file("$first/$relative"), digest_file("$second/$relative"), "$format binary is reproducible across umasks"); if ($format eq 'rpm') { ok(-f "$first/$source_relative", 'source RPM exists'); is(digest_file("$first/$source_relative"), digest_file("$second/$source_relative"), 'source RPM is reproducible across umasks'); } my $release_root = "$tmp/$format-release"; make_path($release_root); copy_tree($first, $release_root); write_release_manifest( $release_root, $version, $release, $package_revision, $epoch, 'x86_64', $format, ); write_checksums($release_root); ok(validate_release($release_root), "$format release layout passes"); is(system($verifier, '--format', $format, $release_root), 0, "$format package metadata passes"); my $contents_log = "$tmp/$format-contents.log"; if ($format eq 'rpm') { is(run_capture($contents_log, 'rpm', '-qpl', "$first/$relative"), 0, 'RPM payload can be listed'); like(read_binary($contents_log), qr{/opt/xcat/share/xcat/netboot/genesis-openembedded/x86_64/kernel}, 'RPM uses the OpenEmbedded staging namespace'); like(read_binary($contents_log), qr{^/usr/share/doc/xCAT-genesis-openembedded-x86_64/?$}m, 'RPM owns its documentation directory'); my $ownership_log = "$tmp/rpm-ownership.log"; is( run_capture( $ownership_log, 'rpm', '-qp', '--qf', '[%{FILEUSERNAME}:%{FILEGROUPNAME}\n]', "$first/$relative", ), 0, 'RPM file ownership can be read', ); is_deeply( [ grep { $_ ne 'root:root' } split(/\n/, read_binary($ownership_log)) ], [], 'RPM owns every payload path as root', ); is(run_capture($contents_log, 'rpm', '-qp', '--scripts', "$first/$relative"), 0, 'RPM script metadata can be read'); like( read_binary($contents_log), qr{posttrans scriptlet.*\n/usr/libexec/xcat/genesis-openembedded-activate-x86_64 x86_64\n}s, 'RPM refreshes the installed architecture after the transaction', ); like( read_binary($contents_log), qr{postuninstall scriptlet.*mknb x86_64 --remove-openembedded}s, 'RPM retires published artifacts after erasing the image', ); } else { is(run_capture($contents_log, 'dpkg-deb', '-c', "$first/$relative"), 0, 'DEB payload can be listed'); like(read_binary($contents_log), qr{/opt/xcat/share/xcat/netboot/genesis-openembedded/x86_64/kernel}, 'DEB uses the OpenEmbedded staging namespace'); my $control = "$tmp/deb-control"; make_path($control); is(run_capture($contents_log, 'dpkg-deb', '-e', "$first/$relative", $control), 0, 'DEB control files can be extracted'); opendir(my $control_dh, $control) or die $!; my @control_files = sort grep { $_ ne '.' && $_ ne '..' } readdir($control_dh); closedir($control_dh) or die $!; is_deeply(\@control_files, [ qw(control md5sums postinst postrm) ], 'DEB contains the expected installation and removal scripts'); like( read_binary("$control/postinst"), qr{^#!/bin/sh\n/usr/libexec/xcat/genesis-openembedded-activate-x86_64 x86_64\nexit 0\n$}, 'DEB refreshes the installed architecture after configuration', ); like( read_binary("$control/postrm"), qr{mknb x86_64 --remove-openembedded}, 'DEB retires published artifacts after erasing the image', ); } } sub exercise_packager_from_unsearchable_cwd { my $cwd = "$tmp/unsearchable-cwd"; my $output = "$tmp/cwd-independent-rpm"; my $log = "$tmp/cwd-independent-rpm.log"; make_path($cwd); my $pid = fork(); die "Cannot fork: $!\n" unless defined($pid); if ($pid == 0) { chdir($cwd) or die "Cannot enter test directory: $!\n"; chmod(0000, $cwd) or die "Cannot restrict test directory: $!\n"; open(STDOUT, '>:raw', $log) or die $!; open(STDERR, '>&', STDOUT) or die $!; exec( $packager, '--architecture', 'x86_64', '--export-dir', $export, '--output-dir', $output, '--version', $version, '--release', $release, '--revision', $revision, '--source-date-epoch', $epoch, '--format', 'rpm', ) or die "Cannot run packager: $!\n"; } waitpid($pid, 0); my $status = $? >> 8; chmod(0700, $cwd) or die "Cannot restore test directory: $!\n"; is($status, 0, 'RPM package ignores an inaccessible inherited working directory'); ok( -f "$output/rpm/xCAT-genesis-openembedded-x86_64-$version-$release.noarch.rpm", 'RPM package is published from an inaccessible inherited working directory', ); } sub exercise_builder_tmpdir { my $source = "$tmp/tmpdir-xcat-core"; my $oe = "$source/xCAT-genesis-builder/oe"; make_path($oe); write_binary("$source/Version", "$version\n"); write_binary( "$oe/build", <<'BUILD', #!/bin/sh set -eu expected=$XCAT_GENESIS_WORK_DIR/build/tmp [ "${TMPDIR:-}" = "$expected" ] || exit 41 mkdir -p "$TMPDIR/deploy" BUILD ); write_binary( "$oe/export", <<'EXPORT', #!/bin/sh set -eu architecture=$1 deploy=$2 output=$3 [ "$deploy" = "$XCAT_GENESIS_WORK_DIR/build/tmp/deploy" ] || exit 42 mkdir -p "$output" printf '%s\n' kernel >"$output/kernel" printf '%s\n' initramfs >"$output/initramfs.cpio.gz" printf '%s\n' packages >"$output/image.manifest" printf '%s\n' '{}' >"$output/image.spdx.json" printf '%s\n' '{}' >"$output/image.vex.json" printf '%s\n' licenses >"$output/license.manifest" printf 'format=xcat-genesis\nversion=1\narchitecture=%s\n' "$architecture" \ >"$output/xcat-genesis.manifest" ( cd "$output" sha256sum -- * >SHA256SUMS ) EXPORT ); chmod(0755, "$oe/build", "$oe/export") or die $!; for my $command ( [ 'git', '-C', $source, 'init', '-q' ], [ 'git', '-C', $source, 'add', '.' ], [ 'git', '-C', $source, '-c', 'user.name=xCAT test', '-c', 'user.email=xcat-test@example.invalid', 'commit', '-qm', 'fixture' ], ) { die "Cannot prepare test repository\n" if run_capture("$tmp/tmpdir-git.log", @{$command}); } my $ambient_tmp = "$tmp/ambient-tmp"; my $persistent_work = "$tmp/persistent-work"; my $output = "$tmp/tmpdir-release"; my $log = "$tmp/tmpdir-builder.log"; make_path($ambient_tmp); my $status; { local $ENV{TMPDIR} = $ambient_tmp; $status = run_capture( $log, $builder, '--xcat-source', $source, '--output-dir', $output, '--work-dir', $persistent_work, '--format', 'deb', ); } is($status, 0, 'release builder isolates the OpenEmbedded tmpdir'); unlike(read_binary($log), qr/Invalid OpenEmbedded deploy directory/, 'release builder finds the configured deploy directory'); ok(-d "$persistent_work/openembedded/build/tmp", 'release builder preserves the requested work directory'); is((stat($output))[2] & oct('07777'), oct('0755'), 'release directory is readable by other users'); my $built = validate_release($output); is($built->{architectures}, 'x86_64', 'isolated build keeps the target architecture'); is($built->{formats}, 'deb', 'isolated build keeps the requested format'); }