From 935f71169d7d7d9445c025da4d9d1527d6cea7e1 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, 24 Aug 2026 19:45:18 -0300 Subject: [PATCH 01/32] test(genesis): decouple consumer from repository layout --- t/genesis_openembedded_consumer.t | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 391238f..34ab4d7 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -49,7 +49,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 28 + skip 'RPM repository tools require a root Linux builder', 27 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -80,13 +80,13 @@ done_testing(); sub test_rpm_consumer { my $release_root = make_package_release("$tmp/rpm", 'rpm'); my $package = "xCAT-genesis-openembedded-x86_64-$version-$release.noarch.rpm"; - my $source_package = "xCAT-genesis-openembedded-x86_64-$version-$release.src.rpm"; my $output = "$tmp/rpm output"; my $target = 'test+epel-10-' . capture_command('uname', '-m'); my $run = "$target-consumer"; my $run_repo = "$output/mockbuild-all/$run/repo/" . capture_command('uname', '-m'); my $source_repo = "$output/mockbuild-all/$run/repo-src"; my $deploy_repo = "$output/xcat-dep/rh10/" . capture_command('uname', '-m'); + my $common_repo = "$output/xcat-dep/common"; make_path($run_repo, $source_repo, $deploy_repo); write_binary("$run_repo/xCAT-genesis-openembedded-stale.noarch.rpm", 'stale'); write_binary("$source_repo/xCAT-genesis-openembedded-stale.src.rpm", 'stale'); @@ -126,15 +126,16 @@ sub test_rpm_consumer { '--collect-dir', $dependencies, ); + my $published_repo = -f "$common_repo/$package" ? $common_repo : $deploy_repo; is($status, 0, 'RPM repository accepts a verified Genesis release'); - is(digest_file("$deploy_repo/$package"), digest_file("$release_root/rpm/$package"), + is(digest_file("$published_repo/$package"), digest_file("$release_root/rpm/$package"), 'deployed RPM matches the release'); is( - sprintf('%04o', (stat("$deploy_repo/$package"))[2] & 0x0fff), + sprintf('%04o', (stat("$published_repo/$package"))[2] & 0x0fff), sprintf('%04o', (stat("$release_root/rpm/$package"))[2] & 0x0fff), 'deployed RPM keeps the release file mode', ); - opendir(my $deploy_dh, $deploy_repo) or die $!; + opendir(my $deploy_dh, $published_repo) or die $!; my @staging_files = grep { /^\.xcat-deploy\./ } readdir($deploy_dh); closedir($deploy_dh) or die $!; is_deeply(\@staging_files, [], 'RPM deployment leaves no staging files'); @@ -144,18 +145,15 @@ sub test_rpm_consumer { 'stale source RPM is removed'); ok(!-e "$deploy_repo/xCAT-genesis-openembedded-stale.noarch.rpm", 'stale deployed RPM is removed'); - is(digest_file("$source_repo/$source_package"), - digest_file("$release_root/srpm/$source_package"), - 'source RPM matches the release'); - ok(-f "$deploy_repo/repodata/repomd.xml", 'RPM repository metadata is generated'); + ok(-f "$published_repo/repodata/repomd.xml", 'RPM repository metadata is generated'); ok(-f "$deploy_repo/xCAT-genesis-base-x86_64-1.noarch.rpm", 'legacy Genesis package remains available'); ok(!-e "$run_repo/xCAT-genesis-openembedded-x86_64-$version-old.noarch.rpm", 'stale OpenEmbedded RPM is not collected'); ok(!-e "$source_repo/xCAT-genesis-openembedded-x86_64-$version-old.src.rpm", 'stale OpenEmbedded SRPM is not collected'); - like(read_binary("$output/mockbuild-all/$run/summary.txt"), qr/^copied_rpms=15$/m, - 'release RPM is counted alongside required dependencies'); + like(read_binary("$output/mockbuild-all/$run/summary.txt"), qr/^copied_rpms=(?:8|15)$/m, + 'repository summary counts the collected dependencies'); } sub test_deb_consumer { @@ -422,10 +420,10 @@ sub test_dry_run_release { my $printed = read_binary($log); is($status, 0, 'a dry run accepts a verified Genesis release'); - like($printed, qr/^DRY-RUN install Genesis release package: .*\Q$package\E$/m, + like($printed, qr/^DRY-RUN (?:install|publish) Genesis release package: .*\Q$package\E$/m, 'the dry run reports the release packages it would install'); - like($printed, qr/^Collected binary RPMs: 15$/m, - 'the dry run counts the release packages a real run installs'); + like($printed, qr/^Collected binary RPMs: (?:8|15)$/m, + 'the dry run counts the packages collected for the target repository'); ok(!-e "$run_repo/$package", 'the dry run installs nothing'); } From cce4f0a6c8ad8be76c668be191e971f1332c9cf9 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, 24 Aug 2026 19:47:42 -0300 Subject: [PATCH 02/32] fix(genesis): publish RPMs from common repository --- mockbuild-all.pl | 106 +++++++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 45 deletions(-) diff --git a/mockbuild-all.pl b/mockbuild-all.pl index 608cc47..3259633 100755 --- a/mockbuild-all.pl +++ b/mockbuild-all.pl @@ -230,6 +230,8 @@ for my $tgt (@build_targets) { $tgt_pm->wait_all_children; die "FATAL: $tgt_fail target(s) failed\n" if $tgt_fail; +publish_genesis_common_repo() if $genesis_release; + print_step('All targets completed'); exit 0; @@ -519,12 +521,6 @@ if (!$dry_run && $copied == 0) { die "No binary RPMs were collected. Check build logs and collection roots.\n"; } -if ($genesis_release) { - $copied += $dry_run - ? preview_genesis_release_packages('rpm', $repo_dir) - : install_genesis_release_packages('rpm', $repo_dir); -} - # Ensure the OS-dependent xCAT-genesis-base rpm (built by the genesis step above) # lands in the dep repo even when the full xCAT core is built elsewhere (--skip-xcat). if (!$skip_genesis && !$dry_run) { @@ -550,15 +546,6 @@ if (!$dry_run && $copied_srpms == 0) { print "WARN: No source RPMs were collected. SRPM repo and tarball may be empty.\n"; } -if ($genesis_release) { - $copied_srpms += $dry_run - ? preview_genesis_release_packages('srpm', $srpm_repo_dir) - : install_genesis_release_packages('srpm', $srpm_repo_dir); -} - -assert_genesis_release_copied($repo_dir, $srpm_repo_dir) - if $genesis_release && !$dry_run; - if (!$skip_createrepo) { run_step( step => 'Run createrepo', @@ -652,11 +639,7 @@ sub deploy_target { my $destination = "$dest/" . basename($rpm); publish_file($rpm, $destination); } - if ($genesis_release) { - my @keep = map { basename($_) } genesis_release_files('rpm'); - remove_genesis_packages($dest, 0, \@keep); - verify_genesis_release_packages('rpm', $dest); - } + remove_genesis_packages($dest, 0) if $genesis_release; assert_required_deps($dest); sign_and_index_repo($dest); write_dep_repo_metadata($dest, $rel); @@ -664,6 +647,23 @@ sub deploy_target { print "Deployed rh$rel/$arch: $n rpms\n"; } +sub publish_genesis_common_repo { + my $dest = "$repo_dep/common"; + print_step("Publish OpenEmbedded Genesis -> $dest"); + + if ($dry_run) { + preview_genesis_release_packages('rpm', $dest); + return; + } + + make_path($dest); + my $published = publish_genesis_release_packages('rpm', $dest); + verify_genesis_release_packages('rpm', $dest); + sign_and_index_repo($dest); + write_common_repo_metadata($dest); + print "Published common Genesis repository: $published rpms\n"; +} + sub publish_file { my ($source, $destination) = @_; my ($temporary_fh, $temporary) = tempfile( @@ -735,6 +735,34 @@ $gpgkey_line EOF close $r; + write_local_repo_helper($dir); + write_buildinfo($dir, "rh$rel/$arch"); +} + +sub write_common_repo_metadata { + my ($dir) = @_; + my $baseurl = "https://xcat.org/files/xcat/repos/yum/devel/xcat-dep/common"; + my $gpgcheck = $gpg_sign ? 1 : 0; + my $gpgkey_line = $gpg_sign ? "gpgkey=$baseurl/repodata/repomd.xml.key" : "# gpgkey="; + open my $r, '>', "$dir/xcat-dep-common.repo" + or die "Cannot write $dir/xcat-dep-common.repo: $!\n"; + print {$r} <<"EOF"; +[xcat-dep-common] +name=xCAT 2 common dependencies +baseurl=$baseurl +enabled=1 +gpgcheck=$gpgcheck +$gpgkey_line +EOF + close $r; + + write_local_repo_helper($dir); + write_buildinfo($dir, 'common'); +} + +sub write_local_repo_helper { + my ($dir) = @_; + open my $m, '>', "$dir/mklocalrepo.sh" or die "Cannot write $dir/mklocalrepo.sh: $!\n"; print {$m} <<'EOS'; #!/bin/sh @@ -753,7 +781,10 @@ cd - EOS close $m; chmod 0775, "$dir/mklocalrepo.sh"; +} +sub write_buildinfo { + my ($dir, $target) = @_; my $build_time = strftime("%a %b %e %H:%M:%S %Z %Y", gmtime($SOURCE_DATE_EPOCH)); my $build_machine = `hostname`; chomp $build_machine; my $commit = `git -C "$repo_root" rev-parse HEAD 2>/dev/null`; chomp $commit; @@ -762,7 +793,7 @@ EOS my $release = strftime('snap%Y%m%d%H%M', gmtime($SOURCE_DATE_EPOCH)); open my $b, '>', "$dir/buildinfo.txt" or die "Cannot write $dir/buildinfo.txt: $!\n"; print {$b} <<"EOF"; -TARGET=rh$rel/$arch +TARGET=$target RELEASE=$release BUILD_TIME=$build_time BUILD_MACHINE=$build_machine @@ -786,10 +817,10 @@ Options: it. A fail-fast lock is held at /.lock, so pass distinct paths to run on two hosts in parallel on one NFS (default: /build-output) --output-root PATH Override the derived build tree root (default: /mockbuild-all) - --repo-dep PATH Override the derived deployable per-EL output root; rh8/rh9/rh10/ - are assembled + signed here (default: /xcat-dep) + --repo-dep PATH Override the deployable output root; rh8/rh9/rh10/ and common + are assembled and signed here (default: /xcat-dep) --force-unlock Remove a stale /.lock before acquiring it - --gpg-sign Sign rpms + repomd.xml of each per-EL repo + --gpg-sign Sign RPMs and repomd.xml in every published repository --gpg-key-name NAME GPG key name (default: "xCAT Signing Key") --gpg-home PATH GNUPGHOME for signing (default: system keyring) --target NAME Build only this target (+epel--); default is @@ -811,8 +842,7 @@ Options: --skip-genesis Skip the existing per-EL Genesis image build --skip-createrepo Skip createrepo --skip-tarball Skip binary/SRPM tarball creation - --genesis-release PATH Add a verified OpenEmbedded Genesis RPM release alongside the - existing per-EL Genesis packages + --genesis-release PATH Publish a verified OpenEmbedded Genesis RPM release in common --scrub-all-chroots Run mock -r --scrub=all before build/collect --collect-dir PATH Additional directory to scan recursively for RPMs (repeatable) --dry-run Print planned commands without executing @@ -1019,16 +1049,16 @@ sub remove_genesis_packages { } } -sub install_genesis_release_packages { +sub publish_genesis_release_packages { my ($prefix, $destination_root) = @_; remove_genesis_packages($destination_root, $prefix eq 'srpm'); + remove_genesis_packages($destination_root, 1) if $prefix eq 'rpm'; my $copied = 0; for my $relative (genesis_release_files($prefix)) { my $source = "$genesis_release/$relative"; my $destination = "$destination_root/" . basename($relative); - copy($source, $destination) - or die "Cannot install Genesis release package $source: $!\n"; + publish_file($source, $destination); verify_release_file($genesis_release_checksums, $relative, $destination); $copied++; } @@ -1036,16 +1066,13 @@ sub install_genesis_release_packages { return $copied; } -# Dry runs copy nothing, but they must still report what a real run would publish: -# collect_rpms and collect_srpms drop every xCAT-genesis-openembedded package whenever -# --genesis-release is given, so without this preview a dry run describes a repository -# with no Genesis packages at all while the real run installs the whole set. +# Dry runs copy nothing, but they must still report the shared packages a real run publishes. sub preview_genesis_release_packages { my ($prefix, $destination_root) = @_; my @files = genesis_release_files($prefix); die "Genesis release has no $prefix packages\n" unless @files; for my $relative (@files) { - print "DRY-RUN install Genesis release package: $genesis_release/$relative" + print "DRY-RUN publish Genesis release package: $genesis_release/$relative" . " -> $destination_root/" . basename($relative) . "\n"; } return scalar(@files); @@ -1070,17 +1097,6 @@ sub verify_genesis_release_packages { return scalar(@files); } -sub assert_genesis_release_copied { - my ($binary_directory, $source_directory) = @_; - for my $entry ( - [ 'rpm', $binary_directory ], - [ 'srpm', $source_directory ], - ) { - my ($prefix, $destination_root) = @{$entry}; - verify_genesis_release_packages($prefix, $destination_root); - } -} - sub collect_rpms { my (%args) = @_; my $roots = $args{roots} // []; From a528ed0800f74f52466343b62e234636f77377e4 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, 24 Aug 2026 19:49:42 -0300 Subject: [PATCH 03/32] test(genesis): cover common RPM repository --- t/genesis_openembedded_consumer.t | 80 +++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 34ab4d7..3192c26 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -49,7 +49,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 27 + skip 'RPM repository tools require a root Linux builder', 34 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -87,10 +87,12 @@ sub test_rpm_consumer { my $source_repo = "$output/mockbuild-all/$run/repo-src"; my $deploy_repo = "$output/xcat-dep/rh10/" . capture_command('uname', '-m'); my $common_repo = "$output/xcat-dep/common"; - make_path($run_repo, $source_repo, $deploy_repo); + make_path($run_repo, $source_repo, $deploy_repo, $common_repo); write_binary("$run_repo/xCAT-genesis-openembedded-stale.noarch.rpm", 'stale'); write_binary("$source_repo/xCAT-genesis-openembedded-stale.src.rpm", 'stale'); write_binary("$deploy_repo/xCAT-genesis-openembedded-stale.noarch.rpm", 'stale'); + write_binary("$common_repo/xCAT-genesis-openembedded-stale.noarch.rpm", 'stale'); + write_binary("$common_repo/xCAT-genesis-openembedded-stale.src.rpm", 'stale'); my $dependencies = "$tmp/rpm-dependencies"; my $scratch_repo_root = "$tmp/rpm-repo-root"; @@ -126,16 +128,15 @@ sub test_rpm_consumer { '--collect-dir', $dependencies, ); - my $published_repo = -f "$common_repo/$package" ? $common_repo : $deploy_repo; is($status, 0, 'RPM repository accepts a verified Genesis release'); - is(digest_file("$published_repo/$package"), digest_file("$release_root/rpm/$package"), + is(digest_file("$common_repo/$package"), digest_file("$release_root/rpm/$package"), 'deployed RPM matches the release'); is( - sprintf('%04o', (stat("$published_repo/$package"))[2] & 0x0fff), + sprintf('%04o', (stat("$common_repo/$package"))[2] & 0x0fff), sprintf('%04o', (stat("$release_root/rpm/$package"))[2] & 0x0fff), 'deployed RPM keeps the release file mode', ); - opendir(my $deploy_dh, $published_repo) or die $!; + opendir(my $deploy_dh, $common_repo) or die $!; my @staging_files = grep { /^\.xcat-deploy\./ } readdir($deploy_dh); closedir($deploy_dh) or die $!; is_deeply(\@staging_files, [], 'RPM deployment leaves no staging files'); @@ -145,14 +146,39 @@ sub test_rpm_consumer { 'stale source RPM is removed'); ok(!-e "$deploy_repo/xCAT-genesis-openembedded-stale.noarch.rpm", 'stale deployed RPM is removed'); - ok(-f "$published_repo/repodata/repomd.xml", 'RPM repository metadata is generated'); + ok(-f "$common_repo/repodata/repomd.xml", 'RPM repository metadata is generated'); ok(-f "$deploy_repo/xCAT-genesis-base-x86_64-1.noarch.rpm", 'legacy Genesis package remains available'); - ok(!-e "$run_repo/xCAT-genesis-openembedded-x86_64-$version-old.noarch.rpm", - 'stale OpenEmbedded RPM is not collected'); - ok(!-e "$source_repo/xCAT-genesis-openembedded-x86_64-$version-old.src.rpm", - 'stale OpenEmbedded SRPM is not collected'); - like(read_binary("$output/mockbuild-all/$run/summary.txt"), qr/^copied_rpms=(?:8|15)$/m, + my @expected_packages = sort map { + rpm_package_name($_) . "-$version-$release.noarch.rpm" + } architectures(); + my @common_packages = genesis_rpm_names($common_repo); + is_deeply(\@common_packages, \@expected_packages, + 'common repository contains one complete Genesis release'); + is_deeply( + [ genesis_rpm_names($deploy_repo) ], + [], + 'per-EL repository contains no OpenEmbedded Genesis packages', + ); + is_deeply( + [ genesis_rpm_names($run_repo) ], + [], + 'target staging repository contains no OpenEmbedded Genesis packages', + ); + is_deeply( + [ genesis_rpm_names($source_repo) ], + [], + 'target source repository contains no OpenEmbedded Genesis packages', + ); + my $common_config = read_binary("$common_repo/xcat-dep-common.repo"); + like($common_config, qr/^\[xcat-dep-common\]$/m, + 'common repository has its own repository ID'); + like($common_config, qr{/xcat-dep/common$}m, + 'common repository configuration uses the shared URL'); + ok(-x "$common_repo/mklocalrepo.sh", 'common repository supports offline setup'); + like(read_binary("$common_repo/buildinfo.txt"), qr/^TARGET=common$/m, + 'common repository records its target'); + like(read_binary("$output/mockbuild-all/$run/summary.txt"), qr/^copied_rpms=8$/m, 'repository summary counts the collected dependencies'); } @@ -265,6 +291,8 @@ sub test_legacy_rpm_consumer { [], 'RPM legacy path does not add OpenEmbedded packages', ); + ok(!-d "$output/xcat-dep/common", + 'RPM legacy path does not create the common repository'); } sub test_legacy_deb_consumer { @@ -299,9 +327,9 @@ sub test_partial_rpm_release { my $release_root = make_package_release("$tmp/rpm-partial", 'rpm', 'x86_64'); my $output = "$tmp/partial-output"; my $target = 'test+epel-10-' . capture_command('uname', '-m'); - my $deployed = "$output/xcat-dep/rh10/" . capture_command('uname', '-m'); - my $existing = "$deployed/xCAT-genesis-base-existing.noarch.rpm"; - make_path($deployed); + my $common = "$output/xcat-dep/common"; + my $existing = "$common/xCAT-genesis-openembedded-existing.noarch.rpm"; + make_path($common); write_binary($existing, 'existing release'); my $log = "$tmp/rpm-partial.log"; @@ -354,14 +382,14 @@ sub test_failed_build_release { my $package = "xCAT-genesis-openembedded-x86_64-$version-$release.noarch.rpm"; my $output = "$tmp/empty-output"; my $target = 'test+epel-10-' . capture_command('uname', '-m'); - my $deployed = "$output/xcat-dep/rh10/" . capture_command('uname', '-m'); + my $common = "$output/xcat-dep/common"; my $scratch_repo_root = "$tmp/rpm-empty-root"; my $collected = "$tmp/rpm-empty-collect"; my $run_repo = "$output/mockbuild-all/$target-empty/repo/" . capture_command('uname', '-m'); my $results = "$output/mockbuild-all/$target-empty/build-results/ipmitool-xcat"; my $stale = "$run_repo/ipmitool-xcat-0-stale.noarch.rpm"; my $kept = "$results/ipmitool-xcat-0-earlier.noarch.rpm"; - make_path($deployed, $scratch_repo_root, $collected, $run_repo, $results); + make_path($common, $scratch_repo_root, $collected, $run_repo, $results); write_binary($stale, 'package left by an earlier run'); write_binary($kept, 'build output an earlier run produced'); @@ -383,7 +411,7 @@ sub test_failed_build_release { isnt($status, 0, 'a run that built nothing fails even with a Genesis release'); like(read_binary($log), qr/No binary RPMs were collected/, 'the failure names the empty collection, not the missing dependencies'); - ok(!-e "$deployed/$package", + ok(!-e "$common/$package", 'a run that built nothing publishes no release package'); ok(!-e $stale, 'a package left by an earlier run is cleared from the staging repository'); @@ -420,9 +448,10 @@ sub test_dry_run_release { my $printed = read_binary($log); is($status, 0, 'a dry run accepts a verified Genesis release'); - like($printed, qr/^DRY-RUN (?:install|publish) Genesis release package: .*\Q$package\E$/m, - 'the dry run reports the release packages it would install'); - like($printed, qr/^Collected binary RPMs: (?:8|15)$/m, + like($printed, + qr{^DRY-RUN publish Genesis release package: .*\Q$package\E -> .*/xcat-dep/common/\Q$package\E$}m, + 'the dry run reports the shared package destination'); + like($printed, qr/^Collected binary RPMs: 8$/m, 'the dry run counts the packages collected for the target repository'); ok(!-e "$run_repo/$package", 'the dry run installs nothing'); } @@ -509,6 +538,15 @@ sub make_package_release { return $release_root; } +sub genesis_rpm_names { + my ($directory) = @_; + return () unless -d $directory; + opendir(my $dh, $directory) or die $!; + my @names = sort grep { /^xCAT-genesis-openembedded-.*\.rpm$/ } readdir($dh); + closedir($dh) or die $!; + return @names; +} + sub make_rpm_dependencies { my ($directory, $package) = @_; make_path($directory); From b44354950f60f5edc9383988b71feee165ceafd0 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, 24 Aug 2026 19:51:13 -0300 Subject: [PATCH 04/32] test(genesis): prepare shared APT publication --- t/genesis_openembedded_consumer.t | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 3192c26..bb32661 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -186,9 +186,8 @@ sub test_deb_consumer { my $release_root = make_package_release("$tmp/deb", 'deb'); my $package = "xcat-genesis-openembedded-x86-64_${version}-${release}_all.deb"; my $apt_root = "$tmp/apt"; - my $input = "$apt_root/ubuntu24.04"; + my $input = make_apt_inputs($apt_root, "$tmp/dummy-deb"); make_path($input, "$apt_root/pool/main/noble"); - make_legacy_deb("$tmp/dummy-deb", "$input/xcat-genesis-base-amd64_1_all.deb"); write_binary("$input/xcat-genesis-openembedded-stale.deb", 'stale'); write_binary("$apt_root/pool/main/noble/xcat-genesis-openembedded-old.deb", 'stale'); @@ -201,9 +200,10 @@ sub test_deb_consumer { '--apt-dir', $apt_root, '--skip-sign', '--genesis-release', $release_root, - 'ubuntu24.04', ); - my $pool_package = "$apt_root/pool/main/noble/$package"; + my $suite_package = "$apt_root/pool/main/noble/$package"; + my $shared_package = "$apt_root/pool/main/xcat-genesis-openembedded/$package"; + my $pool_package = -f $shared_package ? $shared_package : $suite_package; my $amd64 = "$apt_root/dists/noble/main/binary-amd64/Packages"; my $ppc64el = "$apt_root/dists/noble/main/binary-ppc64el/Packages"; @@ -233,7 +233,7 @@ sub test_deb_consumer { 'pooled DEB is verified again before the indexes are generated'); my $collision = "$tmp/apt-collision"; - make_path("$collision/ubuntu24.04"); + make_apt_inputs($collision, "$tmp/collision-deb"); write_binary("$collision/ubuntu24.04/$package", 'different'); my $collision_log = "$tmp/deb-collision.log"; my $collision_status = run_capture( @@ -243,10 +243,12 @@ sub test_deb_consumer { '--apt-dir', $collision, '--skip-sign', '--genesis-release', $release_root, - 'ubuntu24.04', ); + my $collision_suite = "$collision/pool/main/noble/$package"; + my $collision_shared = "$collision/pool/main/xcat-genesis-openembedded/$package"; + my $collision_package = -f $collision_shared ? $collision_shared : $collision_suite; is($collision_status, 0, 'verified release replaces a stale source package'); - is(digest_file("$collision/pool/main/noble/$package"), + is(digest_file($collision_package), digest_file("$release_root/deb/$package"), 'pooled package still matches the verified release'); } @@ -573,3 +575,15 @@ sub make_legacy_deb { "$root.log", 'dpkg-deb', '--root-owner-group', '--build', $root, $output, ); } + +sub make_apt_inputs { + my ($apt_root, $package_root) = @_; + my $package = "$package_root.deb"; + make_legacy_deb($package_root, $package); + for my $version (qw(ubuntu22.04 ubuntu24.04 ubuntu26.04)) { + my $input = "$apt_root/$version"; + make_path($input); + copy($package, "$input/xcat-genesis-base-amd64_1_all.deb") or die $!; + } + return "$apt_root/ubuntu24.04"; +} From f0e62c448d84e3dc9ba34f8d0993ad95dbd700e0 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, 24 Aug 2026 19:52:20 -0300 Subject: [PATCH 05/32] fix(genesis): share APT package pool --- build-apt-repo.sh | 56 ++++++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/build-apt-repo.sh b/build-apt-repo.sh index 5fbfe5c..29c54c1 100755 --- a/build-apt-repo.sh +++ b/build-apt-repo.sh @@ -16,6 +16,8 @@ DRY_RUN=0 GENESIS_RELEASE="" GENESIS_CHECKSUMS="" GENESIS_VERIFIER="" +GENESIS_POOL_RELATIVE="pool/main/xcat-genesis-openembedded" +GENESIS_POOL="" FORCE_UNLOCK=0 HELD_LOCK="" @@ -61,7 +63,7 @@ Options: --apt-dir PATH APT output directory (default: /repos/apt) --gpg-key-id ID GPG key ID for signing (default: xcat@megware.com) --skip-sign Skip GPG signing (for testing) - --genesis-release PATH Add a verified OpenEmbedded Genesis DEB release to each selected suite + --genesis-release PATH Publish a verified OpenEmbedded Genesis DEB release for all suites --dry-run Print planned actions without executing --force-unlock Remove a stale /.lock before acquiring it -h, --help Show this help @@ -100,6 +102,7 @@ done REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" APT_DIR="${APT_DIR:-$REPO_ROOT/repos/apt}" +GENESIS_POOL="$APT_DIR/$GENESIS_POOL_RELATIVE" # Everything from the pool wipe to the signature is one transaction over a shared tree, and # the packages are verified inside it: a second writer between the verification and @@ -160,6 +163,8 @@ if [[ -n "$GENESIS_RELEASE" ]]; then "$GENESIS_VERIFIER" --complete --format deb "$GENESIS_RELEASE" cmp -s "$GENESIS_CHECKSUMS" "$GENESIS_RELEASE/SHA256SUMS" \ || die "Genesis release changed during verification" + [[ $SUBSET -eq 0 ]] \ + || die "--genesis-release updates all suites; omit DIST arguments" echo "Genesis release: $GENESIS_RELEASE" fi @@ -205,6 +210,9 @@ for ver in "${SELECTED_VERS[@]}"; do run mkdir -p "$APT_DIR/dists/$codename/main/binary-$arch" done done +if [[ -n "$GENESIS_RELEASE" ]]; then + run mkdir -p "$GENESIS_POOL" +fi step "Populating pool" @@ -227,11 +235,8 @@ copy_genesis_deb() { name=$(basename "$source") destination="$directory/$name" relative="deb/$name" - # Every selected suite receives the whole release, so a plain copy spends hundreds of - # megabytes per suite. --reflink=auto lets a filesystem that can share extents - # copy-on-write avoid that, while still giving the pool a file of its own: a link would - # leave the published package and the verified release sharing one inode, where a write - # through either path changes what the other holds. + # The pool needs a file of its own. A hard link would let a later write through either + # path change both the verified release and the published package. cp --reflink=auto -- "$source" "$destination" 2>/dev/null \ || cp -- "$source" "$destination" "$GENESIS_VERIFIER" \ @@ -252,30 +257,29 @@ for ver in "${SELECTED_VERS[@]}"; do fi copy_deb "$deb" "$dst" done - if [[ -n "$GENESIS_RELEASE" ]]; then - for deb in "$GENESIS_RELEASE"/deb/*.deb; do - copy_genesis_deb "$deb" "$dst" - done - fi fi done +if [[ -n "$GENESIS_RELEASE" && $DRY_RUN -eq 0 ]]; then + echo "Genesis release -> $GENESIS_POOL_RELATIVE/" + for deb in "$GENESIS_RELEASE"/deb/*.deb; do + copy_genesis_deb "$deb" "$GENESIS_POOL" + done +fi + # The pool is indexed and the metadata is signed from what is on disk now, not from what # was copied earlier, so check the packages again here: anything that changed between the # copy and this point would otherwise be published and signed as verified. if [[ -n "$GENESIS_RELEASE" && $DRY_RUN -eq 0 ]]; then step "Re-verifying pooled Genesis packages" - for ver in "${SELECTED_VERS[@]}"; do - codename="${CODENAME_MAP[$ver]}" - for deb in "$GENESIS_RELEASE"/deb/*.deb; do - name=$(basename "$deb") - pooled="$APT_DIR/pool/main/$codename/$name" - "$GENESIS_VERIFIER" \ - --checksum-file "$GENESIS_CHECKSUMS" \ - --relative-file "deb/$name" \ - --copied-file "$pooled" - echo "Re-verified pooled Genesis package: $pooled" - done + for deb in "$GENESIS_RELEASE"/deb/*.deb; do + name=$(basename "$deb") + pooled="$GENESIS_POOL/$name" + "$GENESIS_VERIFIER" \ + --checksum-file "$GENESIS_CHECKSUMS" \ + --relative-file "deb/$name" \ + --copied-file "$pooled" + echo "Re-verified pooled Genesis package: $pooled" done fi @@ -290,7 +294,13 @@ for ver in "${SELECTED_VERS[@]}"; do continue fi - all_packages=$(cd "$APT_DIR" && apt-ftparchive packages "pool/main/$codename/") + all_packages=$( + cd "$APT_DIR" + apt-ftparchive packages "pool/main/$codename/" + if [[ -n "$GENESIS_RELEASE" ]]; then + apt-ftparchive packages "$GENESIS_POOL_RELATIVE/" + fi + ) for arch in "${ARCHITECTURES[@]}"; do pkg_file="$APT_DIR/dists/$codename/main/binary-$arch/Packages" From 8682cbd1bae67655c1f0de2263df0609348f1b59 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, 24 Aug 2026 19:54:30 -0300 Subject: [PATCH 06/32] test(genesis): cover shared APT package pool --- t/genesis_openembedded_consumer.t | 76 ++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index bb32661..5dacabd 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -63,7 +63,7 @@ SKIP: { } SKIP: { - skip 'APT repository tools are not installed', 17 + skip 'APT repository tools are not installed', 32 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -187,9 +187,10 @@ sub test_deb_consumer { my $package = "xcat-genesis-openembedded-x86-64_${version}-${release}_all.deb"; my $apt_root = "$tmp/apt"; my $input = make_apt_inputs($apt_root, "$tmp/dummy-deb"); - make_path($input, "$apt_root/pool/main/noble"); + my $shared_pool = "$apt_root/pool/main/xcat-genesis-openembedded"; + make_path($input, $shared_pool); write_binary("$input/xcat-genesis-openembedded-stale.deb", 'stale'); - write_binary("$apt_root/pool/main/noble/xcat-genesis-openembedded-old.deb", 'stale'); + write_binary("$shared_pool/xcat-genesis-openembedded-old.deb", 'stale'); local $ENV{SOURCE_DATE_EPOCH} = $epoch; my $log = "$tmp/deb-consumer.log"; @@ -201,24 +202,43 @@ sub test_deb_consumer { '--skip-sign', '--genesis-release', $release_root, ); - my $suite_package = "$apt_root/pool/main/noble/$package"; - my $shared_package = "$apt_root/pool/main/xcat-genesis-openembedded/$package"; - my $pool_package = -f $shared_package ? $shared_package : $suite_package; + my $pool_package = "$shared_pool/$package"; my $amd64 = "$apt_root/dists/noble/main/binary-amd64/Packages"; - my $ppc64el = "$apt_root/dists/noble/main/binary-ppc64el/Packages"; is($status, 0, 'APT repository accepts a verified Genesis release'); like(read_binary($log), qr/Verified copied Genesis package:/, 'APT repository uses the shared copied-package verifier'); is(digest_file($pool_package), digest_file("$release_root/deb/$package"), 'pooled DEB matches the release'); - ok(!-e "$apt_root/pool/main/noble/xcat-genesis-openembedded-old.deb", + ok(!-e "$shared_pool/xcat-genesis-openembedded-old.deb", 'stale pooled DEB is removed'); - like(read_binary($amd64), qr/^Package: xcat-genesis-openembedded-x86-64$/m, - 'all-architecture DEB is indexed for amd64'); - like(read_binary($ppc64el), qr/^Package: xcat-genesis-openembedded-x86-64$/m, - 'all-architecture DEB is indexed for ppc64el'); - ok(-f "$apt_root/dists/noble/Release", 'APT Release metadata is generated'); + my @expected_packages = sort map { + deb_package_name($_) . "_${version}-${release}_all.deb" + } architectures(); + is_deeply([ genesis_deb_names($shared_pool) ], \@expected_packages, + 'shared APT pool contains one complete Genesis release'); + my @suite_packages; + for my $codename (qw(jammy noble resolute)) { + push(@suite_packages, map { "$codename/$_" } + genesis_deb_names("$apt_root/pool/main/$codename")); + } + is_deeply(\@suite_packages, [], 'suite pools contain no OpenEmbedded Genesis packages'); + for my $codename (qw(jammy noble resolute)) { + for my $architecture (qw(amd64 ppc64el)) { + my $packages = read_binary( + "$apt_root/dists/$codename/main/binary-$architecture/Packages" + ); + like($packages, + qr{^Filename: pool/main/xcat-genesis-openembedded/\Q$package\E$}m, + "$codename $architecture index uses the shared Genesis package", + ); + } + } + is_deeply( + [ grep { !-f "$apt_root/dists/$_/Release" } qw(jammy noble resolute) ], + [], + 'APT Release metadata is generated for every suite', + ); like(read_binary($amd64), qr/^Package: xcat-genesis-base-amd64$/m, 'legacy Genesis DEB remains available'); ok(!-e "$apt_root/pool/main/noble/xcat-genesis-openembedded-stale.deb", @@ -244,13 +264,28 @@ sub test_deb_consumer { '--skip-sign', '--genesis-release', $release_root, ); - my $collision_suite = "$collision/pool/main/noble/$package"; - my $collision_shared = "$collision/pool/main/xcat-genesis-openembedded/$package"; - my $collision_package = -f $collision_shared ? $collision_shared : $collision_suite; + my $collision_package = "$collision/pool/main/xcat-genesis-openembedded/$package"; is($collision_status, 0, 'verified release replaces a stale source package'); is(digest_file($collision_package), digest_file("$release_root/deb/$package"), 'pooled package still matches the verified release'); + + my $subset_log = "$tmp/deb-subset.log"; + my $before_subset = digest_file($pool_package); + my $subset_status = run_capture( + $subset_log, + 'bash', $deb_consumer, + '--repo-root', $repo_root, + '--apt-dir', $apt_root, + '--skip-sign', + '--genesis-release', $release_root, + 'ubuntu24.04', + ); + isnt($subset_status, 0, 'Genesis publication rejects a partial suite update'); + like(read_binary($subset_log), qr/updates all suites; omit DIST arguments/, + 'partial suite failure explains the consistency requirement'); + is(digest_file($pool_package), $before_subset, + 'partial suite failure leaves the shared package unchanged'); } sub test_legacy_rpm_consumer { @@ -549,6 +584,15 @@ sub genesis_rpm_names { return @names; } +sub genesis_deb_names { + my ($directory) = @_; + return () unless -d $directory; + opendir(my $dh, $directory) or die $!; + my @names = sort grep { /^xcat-genesis-openembedded-.*\.deb$/ } readdir($dh); + closedir($dh) or die $!; + return @names; +} + sub make_rpm_dependencies { my ($directory, $package) = @_; make_path($directory); From 9af2edae997b79c496c0ec437de5d8eff50a2b9f 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, 24 Aug 2026 19:54:48 -0300 Subject: [PATCH 07/32] docs(genesis): describe shared package layout --- genesis-openembedded/README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/genesis-openembedded/README.md b/genesis-openembedded/README.md index ffdd194..b318f93 100644 --- a/genesis-openembedded/README.md +++ b/genesis-openembedded/README.md @@ -67,15 +67,21 @@ perl ./mockbuild-all.pl \ [other build options] ./build-apt-repo.sh \ - --genesis-release /path/to/xcat-genesis-release \ - [DIST ...] + --genesis-release /path/to/xcat-genesis-release ``` -The RPM builder keeps its old per-EL Genesis build and adds the new packages. -The APT builder does the same for each selected suite. Both consumers require -all seven architectures and verify package identities and checksums before -collecting packages. Every management-node repository receives every target -image so it can provision nodes of another architecture. +The RPM builder publishes the binary packages once under `xcat-dep/common`. +The per-EL repositories keep the old Genesis packages and contain no copies of +the OpenEmbedded packages. Source RPMs stay in the verified release directory. + +The APT builder publishes the DEBs once under +`pool/main/xcat-genesis-openembedded`. Every suite indexes those same files. +Genesis publication updates all suites together, so do not pass a `DIST` +argument with `--genesis-release`. + +Both consumers require all seven architectures and verify package identities +and checksums before publication. A management node can install an image for a +different target architecture. Without `--genesis-release`, both builders keep their existing behavior. The new packages do not provide, replace, or obsolete the old package names. A From 9e68d855fdf0f737d93563ecb28c7ab2f03f7e35 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, 24 Aug 2026 20:15:34 -0300 Subject: [PATCH 08/32] docs(genesis): fix shared repository instructions --- BUILD.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/BUILD.md b/BUILD.md index fa6c167..f564842 100644 --- a/BUILD.md +++ b/BUILD.md @@ -168,14 +168,14 @@ perl ./mockbuild-all.pl \ --genesis-release /path/to/xcat-genesis-release ``` -The release is checked before any package is collected. Its RPMs and SRPMs are -copied into each generated EL repository. Stale OpenEmbedded Genesis packages -are removed from the output first, while the existing per-EL Genesis package -remains available. +The release is checked before any package is collected. Its binary RPMs are +published once under `xcat-dep/common`. Source RPMs stay in the verified +release directory. Existing per-EL repositories keep the old Genesis packages +and contain no OpenEmbedded copies. Repository publication requires a release containing every supported Genesis -architecture. The packages are `noarch`, and every management-node repository -receives the full set of target images. +architecture. The packages are `noarch`, and the common repository contains +the full set of target images. The release checksums cover the unsigned input packages. If repository signing is enabled, `rpmsign` changes the deployed RPM bytes after collection. From 9fe14ce41d5561e7ac69e945deb5a67e880ba5d9 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, 24 Aug 2026 20:48:55 -0300 Subject: [PATCH 09/32] fix(genesis): preserve shared repository state --- build-apt-repo.sh | 15 +++++++++++---- mockbuild-all.pl | 6 ++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/build-apt-repo.sh b/build-apt-repo.sh index 29c54c1..3e8cb09 100755 --- a/build-apt-repo.sh +++ b/build-apt-repo.sh @@ -196,8 +196,15 @@ if [[ $DRY_RUN -eq 0 ]]; then done echo "Removed dists/ and pool/ for: ${SELECTED_VERS[*]}" else - rm -rf "$APT_DIR/dists" "$APT_DIR/pool" - echo "Removed dists/ and pool/" + rm -rf "$APT_DIR/dists" + for ver in "${!CODENAME_MAP[@]}"; do + rm -rf "$APT_DIR/pool/main/${CODENAME_MAP[$ver]}" + done + echo "Removed dists/ and suite pools" + fi + if [[ -n "$GENESIS_RELEASE" ]]; then + rm -rf "$GENESIS_POOL" + echo "Removed the previous shared Genesis pool" fi fi @@ -252,7 +259,7 @@ for ver in "${SELECTED_VERS[@]}"; do echo "$ver -> pool/main/$codename/" if [[ $DRY_RUN -eq 0 ]]; then for deb in "$src"/*.deb; do - if [[ -n "$GENESIS_RELEASE" && ${deb##*/} == xcat-genesis-openembedded-*.deb ]]; then + if [[ ${deb##*/} == xcat-genesis-openembedded-*.deb ]]; then continue fi copy_deb "$deb" "$dst" @@ -297,7 +304,7 @@ for ver in "${SELECTED_VERS[@]}"; do all_packages=$( cd "$APT_DIR" apt-ftparchive packages "pool/main/$codename/" - if [[ -n "$GENESIS_RELEASE" ]]; then + if [[ -d "$GENESIS_POOL" ]]; then apt-ftparchive packages "$GENESIS_POOL_RELATIVE/" fi ) diff --git a/mockbuild-all.pl b/mockbuild-all.pl index 3259633..ee20618 100755 --- a/mockbuild-all.pl +++ b/mockbuild-all.pl @@ -63,6 +63,7 @@ my $repo_dep = ''; my $gpg_sign = 0; my $gpg_key_name = 'xCAT Signing Key'; my $gpg_home = ''; +my $gpg_program = ''; my $force_unlock = 0; my $HELD_LOCK; # path of the output lock this process owns (for cleanup on exit) my $LOCK_OWNER_PID; # pid that created the lock; forked children must NOT remove it @@ -155,7 +156,7 @@ for my $bin (qw(perl uname createrepo_c tar find rpm)) { } require_command('mock') if $scrub_all_chroots; require_command('rpmsign') if $gpg_sign; -require_command('gpg') if $gpg_sign; +$gpg_program = require_command('gpg') if $gpg_sign; if ($genesis_release ne '') { $genesis_release = abs_path($genesis_release) @@ -706,7 +707,8 @@ sub sign_and_index_repo { my @rpms = grep { !/\.src\.rpm$/ } bsd_glob("$dir/*.rpm"); if ($gpg_sign && @rpms) { local $ENV{GNUPGHOME} = $gpg_home if $gpg_home; - run_simple(qq(rpmsign --define "%_gpg_name $gpg_key_name" --addsign ) + run_simple('rpmsign --define ' . shell_quote("%_gpg_name $gpg_key_name") + . ' --define ' . shell_quote("%__gpg $gpg_program") . ' --addsign ' . join(' ', map { shell_quote($_) } @rpms)); } run_simple(createrepo_c_cmd($dir)); From 5c455a9bbe966859fbeb0acf1334bc8b59aefb5d 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, 24 Aug 2026 20:48:55 -0300 Subject: [PATCH 10/32] test(genesis): cover repository refresh and signing --- t/genesis_openembedded_consumer.t | 129 +++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 5dacabd..9022f0c 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -49,13 +49,15 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 34 + skip 'RPM repository tools require a root Linux builder', 38 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') + && command_exists('rpmsign') && command_exists('rpm') && command_exists('createrepo_c'); test_rpm_consumer(); + test_signed_common_rpm_repository(); test_legacy_rpm_consumer(); test_partial_rpm_release(); test_failed_build_release(); @@ -63,7 +65,7 @@ SKIP: { } SKIP: { - skip 'APT repository tools are not installed', 32 + skip 'APT repository tools are not installed', 43 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -286,6 +288,129 @@ sub test_deb_consumer { 'partial suite failure explains the consistency requirement'); is(digest_file($pool_package), $before_subset, 'partial suite failure leaves the shared package unchanged'); + + my $jammy_before = digest_file( + "$apt_root/dists/jammy/main/binary-amd64/Packages" + ); + my $resolute_before = digest_file( + "$apt_root/dists/resolute/main/binary-amd64/Packages" + ); + write_binary( + "$apt_root/ubuntu24.04/xcat-genesis-openembedded-stale.deb", + 'stale suite package', + ); + my $refresh_log = "$tmp/deb-refresh.log"; + my $refresh_status = run_capture( + $refresh_log, + 'bash', $deb_consumer, + '--repo-root', $repo_root, + '--apt-dir', $apt_root, + '--skip-sign', + 'ubuntu24.04', + ); + is($refresh_status, 0, 'a suite refresh reuses the shared Genesis pool'); + is(digest_file($pool_package), $before_subset, + 'a suite refresh leaves the shared package unchanged'); + like( + read_binary("$apt_root/dists/noble/main/binary-amd64/Packages"), + qr{^Filename: pool/main/xcat-genesis-openembedded/\Q$package\E$}m, + 'the refreshed suite still indexes the shared package', + ); + ok( + !-e "$apt_root/pool/main/noble/xcat-genesis-openembedded-stale.deb", + 'a suite refresh does not restore the old per-suite package layout', + ); + is( + digest_file("$apt_root/dists/jammy/main/binary-amd64/Packages"), + $jammy_before, + 'a suite refresh leaves the jammy index alone', + ); + is( + digest_file("$apt_root/dists/resolute/main/binary-amd64/Packages"), + $resolute_before, + 'a suite refresh leaves the resolute index alone', + ); + + my $full_refresh_log = "$tmp/deb-full-refresh.log"; + my $full_refresh_status = run_capture( + $full_refresh_log, + 'bash', $deb_consumer, + '--repo-root', $repo_root, + '--apt-dir', $apt_root, + '--skip-sign', + ); + is($full_refresh_status, 0, 'a full refresh reuses the shared Genesis pool'); + is(digest_file($pool_package), $before_subset, + 'a full refresh leaves the shared package unchanged'); + for my $codename (qw(jammy noble resolute)) { + like( + read_binary("$apt_root/dists/$codename/main/binary-amd64/Packages"), + qr{^Filename: pool/main/xcat-genesis-openembedded/\Q$package\E$}m, + "$codename still indexes the shared package after a full refresh", + ); + } +} + +sub test_signed_common_rpm_repository { + my $release_root = make_package_release("$tmp/rpm-signed", 'rpm'); + my $package = "xCAT-genesis-openembedded-x86_64-$version-$release.noarch.rpm"; + my $output = "$tmp/rpm-signed-output"; + my $target = 'test+epel-10-' . capture_command('uname', '-m'); + my $dependencies = "$tmp/rpm-signed-dependencies"; + my $scratch_repo_root = "$tmp/rpm-signed-repo-root"; + my $common = "$output/xcat-dep/common"; + my $gpg_home = "$tmp/rpm-signing-key"; + my $identity = 'xCAT repository test '; + make_rpm_dependencies($dependencies, "$release_root/rpm/$package"); + make_path($scratch_repo_root, $gpg_home); + chmod(0700, $gpg_home) or die $!; + system( + 'gpg', '--batch', '--homedir', $gpg_home, '--passphrase', '', + '--quick-generate-key', $identity, 'rsa2048', 'sign', '0', + ) == 0 or die "could not create the repository test key"; + + my @perl_lib; + push(@perl_lib, write_forkmanager_stub("$tmp/perl-signed-stub")) + unless eval { require Parallel::ForkManager; 1 }; + push(@perl_lib, $ENV{PERL5LIB}) + if defined($ENV{PERL5LIB}) && $ENV{PERL5LIB} ne ''; + local $ENV{PERL5LIB} = join(':', @perl_lib); + + my $log = "$tmp/rpm-signed-consumer.log"; + my $status = run_capture( + $log, + $^X, $rpm_consumer, + '--repo-root', $scratch_repo_root, + '--xcat-source', $repo_root, + '--output', $output, + '--target', $target, + '--run-id', 'signed-consumer', + '--build-timestamp', $epoch, + '--skip-build', '--skip-xcat', '--skip-xcat-dep', '--skip-perl', + '--skip-genesis', '--skip-createrepo', '--skip-tarball', + '--collect-dir', $dependencies, + '--genesis-release', $release_root, + '--gpg-sign', '--gpg-key-name', $identity, '--gpg-home', $gpg_home, + ); + + diag(read_binary($log)) if $status; + is($status, 0, 'the common RPM repository can be signed'); + ok(-f "$common/repodata/repomd.xml.key", + 'the common repository exports its own public key'); + is( + system( + 'gpg', '--batch', '--homedir', $gpg_home, '--verify', + "$common/repodata/repomd.xml.asc", + "$common/repodata/repomd.xml", + ) >> 8, + 0, + 'the common repository metadata signature verifies', + ); + like( + read_binary("$common/xcat-dep-common.repo"), + qr{^gpgkey=https://xcat\.org/files/xcat/repos/yum/devel/xcat-dep/common/repodata/repomd\.xml\.key$}m, + 'the common repository file points to its own exported key', + ); } sub test_legacy_rpm_consumer { From ab6bfee31959c82c81e4ed77554a0ee415f00ba4 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, 24 Aug 2026 20:48:55 -0300 Subject: [PATCH 11/32] docs(genesis): explain shared APT refreshes --- genesis-openembedded/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/genesis-openembedded/README.md b/genesis-openembedded/README.md index b318f93..2016fe2 100644 --- a/genesis-openembedded/README.md +++ b/genesis-openembedded/README.md @@ -77,7 +77,8 @@ the OpenEmbedded packages. Source RPMs stay in the verified release directory. The APT builder publishes the DEBs once under `pool/main/xcat-genesis-openembedded`. Every suite indexes those same files. Genesis publication updates all suites together, so do not pass a `DIST` -argument with `--genesis-release`. +argument with `--genesis-release`. Later suite rebuilds keep using the shared +pool. Pass a new release only when replacing the Genesis packages. Both consumers require all seven architectures and verify package identities and checksums before publication. A management node can install an image for a From 919fe0152f893d9f7e8036a4a3e759779cdbc6ac 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, 24 Aug 2026 21:27:39 -0300 Subject: [PATCH 12/32] test(genesis): require package refresh hooks --- t/genesis_openembedded_consumer.t | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 9022f0c..7992ceb 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -49,7 +49,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 38 + skip 'RPM repository tools require a root Linux builder', 40 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -65,7 +65,7 @@ SKIP: { } SKIP: { - skip 'APT repository tools are not installed', 43 + skip 'APT repository tools are not installed', 45 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -96,6 +96,14 @@ sub test_rpm_consumer { write_binary("$common_repo/xCAT-genesis-openembedded-stale.noarch.rpm", 'stale'); write_binary("$common_repo/xCAT-genesis-openembedded-stale.src.rpm", 'stale'); + my $rpm_scripts = capture_command( + 'rpm', '-qp', '--scripts', "$release_root/rpm/$package" + ); + like($rpm_scripts, qr{/opt/xcat/sbin/mknb x86_64}, + 'the RPM refreshes its architecture after a package transaction'); + like($rpm_scripts, qr/sharedtftp/, + 'the RPM respects shared TFTP service nodes'); + my $dependencies = "$tmp/rpm-dependencies"; my $scratch_repo_root = "$tmp/rpm-repo-root"; make_rpm_dependencies($dependencies, "$release_root/rpm/$package"); @@ -194,6 +202,13 @@ sub test_deb_consumer { write_binary("$input/xcat-genesis-openembedded-stale.deb", 'stale'); write_binary("$shared_pool/xcat-genesis-openembedded-old.deb", 'stale'); + my $postinst = capture_command( + 'dpkg-deb', '--info', "$release_root/deb/$package", 'postinst' + ); + isnt($postinst, '', 'the DEB includes a post-installation script'); + like($postinst, qr{/opt/xcat/sbin/mknb x86_64}, + 'the DEB refreshes its architecture after configuration'); + local $ENV{SOURCE_DATE_EPOCH} = $epoch; my $log = "$tmp/deb-consumer.log"; my $status = run_capture( From 1c94bca3208e617cec5e59e7881889502d895c2b 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, 24 Aug 2026 21:30:16 -0300 Subject: [PATCH 13/32] fix(genesis): refresh updated boot images --- genesis-openembedded/activate | 46 ++++++++++ genesis-openembedded/package | 18 +++- .../rpm/xCAT-genesis-openembedded.spec | 6 ++ t/genesis_openembedded_consumer.t | 83 ++++++++++++++++++- 4 files changed, 148 insertions(+), 5 deletions(-) create mode 100755 genesis-openembedded/activate diff --git a/genesis-openembedded/activate b/genesis-openembedded/activate new file mode 100755 index 0000000..096ca7a --- /dev/null +++ b/genesis-openembedded/activate @@ -0,0 +1,46 @@ +#!/bin/sh + +architecture=$1 +root=${XCAT_GENESIS_ROOT:-} + +case "$architecture" in + x86|x86_64|ppc64|ppc64le|armv7hf|aarch64|riscv64) ;; + *) + echo "Invalid Genesis architecture: $architecture" >&2 + exit 0 + ;; +esac + +cmdline="$root/proc/cmdline" +init_root="$root/proc/1/root/." +mknb="$root/opt/xcat/sbin/mknb" +tabdump="$root/opt/xcat/sbin/tabdump" +profile="$root/etc/profile.d/xcat.sh" + +[ -f "$cmdline" ] || exit 0 +[ -x "$mknb" ] || exit 0 +[ "$(stat -c '%i %d' "$root/" 2>/dev/null)" = "$(stat -c '%i %d' "$init_root" 2>/dev/null)" ] \ + || exit 0 + +service_node=0 +if command -v rpm >/dev/null 2>&1 && rpm -q xCATsn >/dev/null 2>&1; then + service_node=1 +elif command -v dpkg-query >/dev/null 2>&1 \ + && dpkg-query -W -f='${Status}' xcatsn 2>/dev/null \ + | grep -q '^install ok installed$'; then + service_node=1 +fi + +if [ "$service_node" -eq 1 ] && [ -x "$tabdump" ]; then + sharedtftp=$( + "$tabdump" site 2>/dev/null \ + | awk -F '"' '$2 == "sharedtftp" { print $4; exit }' + ) + [ "$sharedtftp" = "1" ] && exit 0 +fi + +[ ! -r "$profile" ] || . "$profile" +if ! "$mknb" "$architecture"; then + echo "WARNING: mknb $architecture failed; rerun it after xcatd is available." >&2 +fi +exit 0 diff --git a/genesis-openembedded/package b/genesis-openembedded/package index 937e380..f6d0d3d 100755 --- a/genesis-openembedded/package +++ b/genesis-openembedded/package @@ -91,6 +91,10 @@ for my $entry (_flat_files($export_dir)) { copy("$export_dir/$entry", "$source_root/image/$entry") or die "Cannot copy export file $entry: $!\n"; } +copy("$FindBin::Bin/activate", "$source_root/activate") + or die "Cannot copy the Genesis activation helper: $!\n"; +chmod(0755, "$source_root/activate") + or die "Cannot set the Genesis activation helper mode: $!\n"; write_binary("$source_root/xcat-core-revision", "$revision\n"); local $ENV{SOURCE_DATE_EPOCH} = $source_date_epoch; @@ -158,7 +162,8 @@ sub _build_deb { my $root = "$work/deb-root"; my $image = "$root/opt/xcat/share/xcat/netboot/genesis-openembedded/$architecture"; my $doc = "$root/usr/share/doc/$package_name"; - make_path("$root/DEBIAN", $image, $doc, "$staging/deb"); + my $helper = "$root/usr/libexec/xcat/genesis-openembedded-activate-$architecture"; + make_path("$root/DEBIAN", $image, $doc, dirname($helper), "$staging/deb"); for my $entry (_flat_files("$source_root/image")) { copy("$source_root/image/$entry", "$image/$entry") @@ -168,6 +173,9 @@ sub _build_deb { copy("$source_root/xcat-core-revision", "$doc/xcat-core-revision") or die "Cannot stage DEB revision: $!\n"; chmod(0644, "$doc/xcat-core-revision") or die "Cannot set revision mode: $!\n"; + copy("$source_root/activate", $helper) + or die "Cannot stage DEB activation helper: $!\n"; + chmod(0755, $helper) or die "Cannot set activation helper mode: $!\n"; my $installed_kib = _tree_bytes($root); $installed_kib = int(($installed_kib + 1023) / 1024); @@ -182,6 +190,14 @@ sub _build_deb { . " The OpenEmbedded Genesis image used for node discovery, inventory,\n" . " and service actions.\n", ); + write_binary( + "$root/DEBIAN/postinst", + "#!/bin/sh\n" + . "/usr/libexec/xcat/genesis-openembedded-activate-$architecture $architecture\n" + . "exit 0\n", + ); + chmod(0755, "$root/DEBIAN/postinst") + or die "Cannot set post-installation script mode: $!\n"; _write_deb_md5sums($root); _normalize_mtime($root, $source_date_epoch); diff --git a/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec b/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec index 965203c..1954a06 100644 --- a/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec +++ b/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec @@ -30,6 +30,11 @@ install -m 0644 image/* "$RPM_BUILD_ROOT/opt/xcat/share/xcat/netboot/genesis-ope # Keep this path stable across RPM build hosts. %%{_docdir} differs on SUSE. install -d -m 0755 "$RPM_BUILD_ROOT/usr/share/doc/%{name}" install -m 0644 xcat-core-revision "$RPM_BUILD_ROOT/usr/share/doc/%{name}/" +install -d -m 0755 "$RPM_BUILD_ROOT/usr/libexec/xcat" +install -m 0755 activate "$RPM_BUILD_ROOT/usr/libexec/xcat/genesis-openembedded-activate-%{genesis_arch}" + +%posttrans +/usr/libexec/xcat/genesis-openembedded-activate-%{genesis_arch} %{genesis_arch} %files %defattr(-,root,root,-) @@ -41,3 +46,4 @@ install -m 0644 xcat-core-revision "$RPM_BUILD_ROOT/usr/share/doc/%{name}/" /opt/xcat/share/xcat/netboot/genesis-openembedded/%{genesis_arch} %dir /usr/share/doc/%{name} %doc /usr/share/doc/%{name}/xcat-core-revision +/usr/libexec/xcat/genesis-openembedded-activate-%{genesis_arch} diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 7992ceb..9f191be 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -41,6 +41,12 @@ my $release = 'snap202608210726'; my $epoch = 1787293573; my $tmp = tempdir(CLEANUP => 1); +SKIP: { + skip 'Genesis package activation requires Linux procfs semantics', 5 + unless $^O eq 'linux'; + test_activation_helper(); +} + if ($ENV{XCAT_GENESIS_CI}) { BAIL_OUT('CI requires Linux root') unless $^O eq 'linux' && $> == 0; for my $command (qw(apt-ftparchive bash createrepo_c dpkg-deb gpg rpm rpmbuild)) { @@ -99,10 +105,13 @@ sub test_rpm_consumer { my $rpm_scripts = capture_command( 'rpm', '-qp', '--scripts', "$release_root/rpm/$package" ); - like($rpm_scripts, qr{/opt/xcat/sbin/mknb x86_64}, + like($rpm_scripts, qr{genesis-openembedded-activate-x86_64 x86_64}, 'the RPM refreshes its architecture after a package transaction'); - like($rpm_scripts, qr/sharedtftp/, - 'the RPM respects shared TFTP service nodes'); + like( + capture_command('rpm', '-qpl', "$release_root/rpm/$package"), + qr{/usr/libexec/xcat/genesis-openembedded-activate-x86_64$}m, + 'the RPM contains its architecture-specific activation helper', + ); my $dependencies = "$tmp/rpm-dependencies"; my $scratch_repo_root = "$tmp/rpm-repo-root"; @@ -206,7 +215,7 @@ sub test_deb_consumer { 'dpkg-deb', '--info', "$release_root/deb/$package", 'postinst' ); isnt($postinst, '', 'the DEB includes a post-installation script'); - like($postinst, qr{/opt/xcat/sbin/mknb x86_64}, + like($postinst, qr{genesis-openembedded-activate-x86_64 x86_64}, 'the DEB refreshes its architecture after configuration'); local $ENV{SOURCE_DATE_EPOCH} = $epoch; @@ -666,6 +675,72 @@ sub test_apt_lock { ok(!-d "$apt_root/.lock", 'the lock is released when the run finishes'); } +sub test_activation_helper { + my $root = "$tmp/activation-root"; + my $bin = "$tmp/activation-bin"; + my $log = "$tmp/activation.log"; + my $output = "$tmp/activation-output.log"; + make_path( + "$root/proc/1", + "$root/opt/xcat/sbin", + $bin, + ); + write_binary("$root/proc/cmdline", "test\n"); + symlink($root, "$root/proc/1/root") or die "Cannot create proc root link: $!"; + write_binary( + "$root/opt/xcat/sbin/mknb", + <<'SH', +#!/bin/sh +echo "$*" >>"$XCAT_TEST_LOG" +exit "${XCAT_TEST_MKNB_STATUS:-0}" +SH + ); + write_binary( + "$root/opt/xcat/sbin/tabdump", + <<'SH', +#!/bin/sh +printf '"sharedtftp","%s"\n' "${XCAT_TEST_SHAREDTFTP:-0}" +SH + ); + write_binary( + "$bin/rpm", + <<'SH', +#!/bin/sh +[ "${XCAT_TEST_SERVICE_NODE:-0}" = "1" ] +SH + ); + chmod(0755, + "$root/opt/xcat/sbin/mknb", + "$root/opt/xcat/sbin/tabdump", + "$bin/rpm", + ) or die "Cannot make activation fixtures executable: $!"; + + local $ENV{XCAT_GENESIS_ROOT} = $root; + local $ENV{XCAT_TEST_LOG} = $log; + local $ENV{PATH} = "$bin:$ENV{PATH}"; + my $status = run_capture( + $output, "$repo_root/genesis-openembedded/activate", 'x86_64' + ); + is($status, 0, 'the activation helper accepts a local-TFTP node'); + is(read_binary($log), "x86_64\n", 'the activation helper runs mknb for one architecture'); + + write_binary($log, ''); + local $ENV{XCAT_TEST_SERVICE_NODE} = 1; + local $ENV{XCAT_TEST_SHAREDTFTP} = 1; + $status = run_capture( + $output, "$repo_root/genesis-openembedded/activate", 'ppc64le' + ); + is($status, 0, 'a shared-TFTP service node is accepted'); + is(read_binary($log), '', 'a shared-TFTP service node does not rebuild Genesis'); + + local $ENV{XCAT_TEST_SERVICE_NODE} = 0; + local $ENV{XCAT_TEST_MKNB_STATUS} = 1; + $status = run_capture( + $output, "$repo_root/genesis-openembedded/activate", 'ppc64le' + ); + is($status, 0, 'an mknb failure does not fail the package transaction'); +} + sub make_package_release { my ($root, $format, @requested_architectures) = @_; @requested_architectures = architectures() unless @requested_architectures; From de50ffeb694fa2ec8e4f7a93cb7dd3137b15456b 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, 24 Aug 2026 21:33:51 -0300 Subject: [PATCH 14/32] test(genesis): cover publication failures --- t/genesis_openembedded_consumer.t | 94 ++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 9f191be..ae72093 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -55,7 +55,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 40 + skip 'RPM repository tools require a root Linux builder', 45 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -68,10 +68,11 @@ SKIP: { test_partial_rpm_release(); test_failed_build_release(); test_dry_run_release(); + test_rpm_repository_lock(); } SKIP: { - skip 'APT repository tools are not installed', 45 + skip 'APT repository tools are not installed', 47 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -199,6 +200,47 @@ sub test_rpm_consumer { 'common repository records its target'); like(read_binary("$output/mockbuild-all/$run/summary.txt"), qr/^copied_rpms=8$/m, 'repository summary counts the collected dependencies'); + + my $retained = "$common_repo/xCAT-genesis-openembedded-retained.noarch.rpm"; + my $repomd = "$common_repo/repodata/repomd.xml"; + my $repomd_before = digest_file($repomd); + write_binary($retained, 'previous complete release'); + my $fail_bin = "$tmp/rpm-fail-bin"; + make_path($fail_bin); + write_binary( + "$fail_bin/createrepo_c", + <<'SH', +#!/bin/sh +for argument in "$@"; do last=$argument; done +case "$last" in + */common|*/.common.*) exit 1 ;; +esac +exec "$XCAT_TEST_CREATEREPO" "$@" +SH + ); + chmod(0755, "$fail_bin/createrepo_c") or die $!; + local $ENV{XCAT_TEST_CREATEREPO} = capture_command( + 'sh', '-c', 'command -v createrepo_c' + ); + local $ENV{PATH} = "$fail_bin:$ENV{PATH}"; + my $failed_log = "$tmp/rpm-publication-failure.log"; + my $failed_status = run_capture( + $failed_log, + $^X, $rpm_consumer, + '--repo-root', $scratch_repo_root, + '--output', $output, + '--target', $target, + '--run-id', 'publication-failure', + '--build-timestamp', $epoch, + '--skip-build', '--skip-xcat', '--skip-xcat-dep', '--skip-perl', + '--skip-createrepo', '--skip-tarball', + '--genesis-release', $release_root, + '--collect-dir', $dependencies, + ); + isnt($failed_status, 0, 'a failed RPM metadata build aborts publication'); + ok(-f $retained, 'a failed RPM publication keeps the previous package set'); + is(digest_file($repomd), $repomd_before, + 'a failed RPM publication keeps the previous metadata'); } sub test_deb_consumer { @@ -373,6 +415,25 @@ sub test_deb_consumer { "$codename still indexes the shared package after a full refresh", ); } + + my $retained = "$shared_pool/xcat-genesis-openembedded-retained.deb"; + write_binary($retained, 'previous complete release'); + my $fail_bin = "$tmp/apt-fail-bin"; + make_path($fail_bin); + write_binary("$fail_bin/apt-ftparchive", "#!/bin/sh\nexit 1\n"); + chmod(0755, "$fail_bin/apt-ftparchive") or die $!; + local $ENV{PATH} = "$fail_bin:$ENV{PATH}"; + my $failed_log = "$tmp/deb-publication-failure.log"; + my $failed_status = run_capture( + $failed_log, + 'bash', $deb_consumer, + '--repo-root', $repo_root, + '--apt-dir', $apt_root, + '--skip-sign', + '--genesis-release', $release_root, + ); + isnt($failed_status, 0, 'a failed APT metadata build aborts publication'); + ok(-f $retained, 'a failed APT publication keeps the previous package set'); } sub test_signed_common_rpm_repository { @@ -642,6 +703,35 @@ sub test_dry_run_release { ok(!-e "$run_repo/$package", 'the dry run installs nothing'); } +sub test_rpm_repository_lock { + my $output = "$tmp/rpm-lock-output"; + my $repository = "$tmp/rpm-shared-repository"; + make_path("$repository/.lock"); + write_binary("$repository/.lock/owner", "host=other\npid=1\nepoch=1\n"); + + my @perl_lib; + push(@perl_lib, write_forkmanager_stub("$tmp/perl-lock-stub")) + unless eval { require Parallel::ForkManager; 1 }; + push(@perl_lib, $ENV{PERL5LIB}) + if defined($ENV{PERL5LIB}) && $ENV{PERL5LIB} ne ''; + local $ENV{PERL5LIB} = join(':', @perl_lib); + + my $log = "$tmp/rpm-repository-lock.log"; + my $status = run_capture( + $log, + $^X, $rpm_consumer, + '--repo-root', $repo_root, + '--output', $output, + '--repo-dep', $repository, + '--target', 'test+epel-10-' . capture_command('uname', '-m'), + '--skip-build', '--skip-xcat', '--skip-xcat-dep', '--skip-perl', + '--skip-createrepo', '--skip-tarball', '--dry-run', + ); + isnt($status, 0, 'a shared RPM repository cannot have two publishers'); + like(read_binary($log), qr/repository \Q$repository\E is locked/, + 'the lock failure names the shared repository'); +} + sub test_apt_lock { my $apt_root = "$tmp/apt-lock"; my $input = "$apt_root/ubuntu24.04"; From 4c2fa1f7911e060df0dc5bf6d2561a32d3fc953a 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, 24 Aug 2026 21:44:00 -0300 Subject: [PATCH 15/32] fix(genesis): publish shared repositories safely --- build-apt-repo.sh | 240 +++++++++++++++++++++++------- mockbuild-all.pl | 103 ++++++++++--- t/genesis_openembedded_consumer.t | 19 ++- 3 files changed, 282 insertions(+), 80 deletions(-) diff --git a/build-apt-repo.sh b/build-apt-repo.sh index 3e8cb09..804e7bf 100755 --- a/build-apt-repo.sh +++ b/build-apt-repo.sh @@ -1,5 +1,6 @@ #!/bin/bash set -euo pipefail +shopt -s nullglob SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -18,13 +19,45 @@ GENESIS_CHECKSUMS="" GENESIS_VERIFIER="" GENESIS_POOL_RELATIVE="pool/main/xcat-genesis-openembedded" GENESIS_POOL="" +GENESIS_STAGE_RELATIVE="" +GENESIS_STAGE="" +PACKAGE_TEMPORARY="" +KEY_TEMPORARY="" +TRANSACTION_ROOT="" +METADATA_ROOT="" FORCE_UNLOCK=0 HELD_LOCK="" +PUBLICATION_COMMITTED=0 +declare -A GENESIS_EXPECTED=() +declare -A SUITE_EXPECTED=() +declare -A SUITE_STAGE=() +declare -a PUBLISHED_DESTINATIONS=() +declare -a PUBLISHED_BACKUPS=() cleanup() { + if [[ $PUBLICATION_COMMITTED -eq 0 ]]; then + local index destination backup + for ((index=${#PUBLISHED_DESTINATIONS[@]} - 1; index >= 0; index--)); do + destination="${PUBLISHED_DESTINATIONS[$index]}" + backup="${PUBLISHED_BACKUPS[$index]}" + rm -rf -- "$destination" + if [[ -n "$backup" && -d "$backup" ]]; then + mv -- "$backup" "$destination" + fi + done + fi if [[ -n "$GENESIS_CHECKSUMS" ]]; then rm -f -- "$GENESIS_CHECKSUMS" fi + if [[ -n "$TRANSACTION_ROOT" && -d "$TRANSACTION_ROOT" ]]; then + rm -rf -- "$TRANSACTION_ROOT" + fi + if [[ -n "$PACKAGE_TEMPORARY" ]]; then + rm -f -- "$PACKAGE_TEMPORARY" + fi + if [[ -n "$KEY_TEMPORARY" ]]; then + rm -f -- "$KEY_TEMPORARY" + fi if [[ -n "$HELD_LOCK" ]]; then rm -f -- "$HELD_LOCK/owner" rmdir -- "$HELD_LOCK" 2>/dev/null || true @@ -39,9 +72,8 @@ declare -A CODENAME_MAP=( ) ARCHITECTURES=(amd64 ppc64el) -# Versions to build. Populated from positional DIST args; defaults to all of -# CODENAME_MAP when none are given. SUBSET=1 means the user requested a subset -# (so cleanup is scoped to the selected dists instead of wiping the whole repo). +# Versions to build. Positional DIST arguments select a subset. With no DIST +# argument, metadata is rebuilt for every known suite. SELECTED_VERS=() SUBSET=0 @@ -104,10 +136,9 @@ REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" APT_DIR="${APT_DIR:-$REPO_ROOT/repos/apt}" GENESIS_POOL="$APT_DIR/$GENESIS_POOL_RELATIVE" -# Everything from the pool wipe to the signature is one transaction over a shared tree, and -# the packages are verified inside it: a second writer between the verification and -# apt-ftparchive would be indexed and signed unchecked. Hold the tree for the whole run, with -# an atomic mkdir (reliable over NFS, unlike flock) like mockbuild-all.pl does for its output. +# Publication is one transaction over a shared tree. The lock also covers verification, so a +# second writer cannot replace a package between verification and metadata generation. mkdir +# is used because it remains reliable when the repository is stored on NFS. acquire_apt_lock() { local lock="$APT_DIR/.lock" [[ $DRY_RUN -eq 0 ]] || return 0 @@ -185,40 +216,30 @@ for ver in "${SELECTED_VERS[@]}"; do echo "Found $count debs in $src" done -step "Cleaning previous repo metadata" - -if [[ $DRY_RUN -eq 0 ]]; then - if [[ $SUBSET -eq 1 ]]; then - # Subset build: only remove the selected dists, leave others intact. - for ver in "${SELECTED_VERS[@]}"; do - codename="${CODENAME_MAP[$ver]}" - rm -rf "$APT_DIR/dists/$codename" "$APT_DIR/pool/main/$codename" - done - echo "Removed dists/ and pool/ for: ${SELECTED_VERS[*]}" - else - rm -rf "$APT_DIR/dists" - for ver in "${!CODENAME_MAP[@]}"; do - rm -rf "$APT_DIR/pool/main/${CODENAME_MAP[$ver]}" - done - echo "Removed dists/ and suite pools" - fi - if [[ -n "$GENESIS_RELEASE" ]]; then - rm -rf "$GENESIS_POOL" - echo "Removed the previous shared Genesis pool" - fi -fi - -step "Creating directory structure" +step "Preparing repository transaction" for ver in "${SELECTED_VERS[@]}"; do codename="${CODENAME_MAP[$ver]}" - run mkdir -p "$APT_DIR/pool/main/$codename" - for arch in "${ARCHITECTURES[@]}"; do - run mkdir -p "$APT_DIR/dists/$codename/main/binary-$arch" - done + echo "$ver -> pool/main/$codename/" done -if [[ -n "$GENESIS_RELEASE" ]]; then - run mkdir -p "$GENESIS_POOL" + +if [[ $DRY_RUN -eq 0 ]]; then + mkdir -p "$APT_DIR" + TRANSACTION_ROOT=$(mktemp -d "$APT_DIR/.xcat-apt.XXXXXX") + METADATA_ROOT="$TRANSACTION_ROOT/dists" + for ver in "${SELECTED_VERS[@]}"; do + codename="${CODENAME_MAP[$ver]}" + SUITE_STAGE["$codename"]="$TRANSACTION_ROOT/pool/main/$codename" + mkdir -p "${SUITE_STAGE[$codename]}" + for arch in "${ARCHITECTURES[@]}"; do + mkdir -p "$METADATA_ROOT/$codename/main/binary-$arch" + done + done +fi +if [[ -n "$GENESIS_RELEASE" && $DRY_RUN -eq 0 ]]; then + GENESIS_STAGE="$TRANSACTION_ROOT/pool/main/xcat-genesis-openembedded" + mkdir -p "$GENESIS_STAGE" + GENESIS_STAGE_RELATIVE=${GENESIS_STAGE#"$APT_DIR/"} fi step "Populating pool" @@ -232,7 +253,8 @@ copy_deb() { || die "Package collision with different content: $destination" return fi - ln "$source" "$destination" 2>/dev/null || cp "$source" "$destination" + cp --reflink=auto -- "$source" "$destination" 2>/dev/null \ + || cp -- "$source" "$destination" } copy_genesis_deb() { @@ -242,6 +264,11 @@ copy_genesis_deb() { name=$(basename "$source") destination="$directory/$name" relative="deb/$name" + if [[ -e "$destination" ]]; then + cmp -s "$source" "$destination" \ + || die "Package collision with different content: $destination" + return + fi # The pool needs a file of its own. A hard link would let a later write through either # path change both the verified release and the published package. cp --reflink=auto -- "$source" "$destination" 2>/dev/null \ @@ -255,22 +282,22 @@ copy_genesis_deb() { for ver in "${SELECTED_VERS[@]}"; do codename="${CODENAME_MAP[$ver]}" src="$APT_DIR/$ver" - dst="$APT_DIR/pool/main/$codename" - echo "$ver -> pool/main/$codename/" + dst="${SUITE_STAGE[$codename]:-}" if [[ $DRY_RUN -eq 0 ]]; then for deb in "$src"/*.deb; do if [[ ${deb##*/} == xcat-genesis-openembedded-*.deb ]]; then continue fi + SUITE_EXPECTED["$codename/${deb##*/}"]=1 copy_deb "$deb" "$dst" done fi done if [[ -n "$GENESIS_RELEASE" && $DRY_RUN -eq 0 ]]; then - echo "Genesis release -> $GENESIS_POOL_RELATIVE/" + echo "Genesis release -> staged shared pool" for deb in "$GENESIS_RELEASE"/deb/*.deb; do - copy_genesis_deb "$deb" "$GENESIS_POOL" + copy_genesis_deb "$deb" "$GENESIS_STAGE" done fi @@ -281,7 +308,8 @@ if [[ -n "$GENESIS_RELEASE" && $DRY_RUN -eq 0 ]]; then step "Re-verifying pooled Genesis packages" for deb in "$GENESIS_RELEASE"/deb/*.deb; do name=$(basename "$deb") - pooled="$GENESIS_POOL/$name" + GENESIS_EXPECTED["$name"]=1 + pooled="$GENESIS_STAGE/$name" "$GENESIS_VERIFIER" \ --checksum-file "$GENESIS_CHECKSUMS" \ --relative-file "deb/$name" \ @@ -303,14 +331,19 @@ for ver in "${SELECTED_VERS[@]}"; do all_packages=$( cd "$APT_DIR" - apt-ftparchive packages "pool/main/$codename/" - if [[ -d "$GENESIS_POOL" ]]; then + suite_stage_relative=${SUITE_STAGE[$codename]#"$APT_DIR/"} + apt-ftparchive packages "$suite_stage_relative/" \ + | sed "s|^Filename: $suite_stage_relative/|Filename: pool/main/$codename/|" + if [[ -n "$GENESIS_STAGE_RELATIVE" ]]; then + apt-ftparchive packages "$GENESIS_STAGE_RELATIVE/" \ + | sed "s|^Filename: $GENESIS_STAGE_RELATIVE/|Filename: $GENESIS_POOL_RELATIVE/|" + elif [[ -d "$GENESIS_POOL" ]]; then apt-ftparchive packages "$GENESIS_POOL_RELATIVE/" fi ) for arch in "${ARCHITECTURES[@]}"; do - pkg_file="$APT_DIR/dists/$codename/main/binary-$arch/Packages" + pkg_file="$METADATA_ROOT/$codename/main/binary-$arch/Packages" echo -n "" > "$pkg_file" @@ -356,12 +389,12 @@ for ver in "${SELECTED_VERS[@]}"; do -o "APT::FTPArchive::Release::Architectures=amd64 ppc64el" \ -o "APT::FTPArchive::Release::Components=main" \ -o "APT::FTPArchive::Release::Description=xCAT dependency packages for $ver" \ - release "$APT_DIR/dists/$codename/" \ - > "$APT_DIR/dists/$codename/Release" + release "$METADATA_ROOT/$codename/" \ + > "$METADATA_ROOT/$codename/Release" if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then deterministic_date=$(date -R -d "@$SOURCE_DATE_EPOCH" --utc) - sed -i "s/^Date: .*/Date: $deterministic_date/" "$APT_DIR/dists/$codename/Release" + sed -i "s/^Date: .*/Date: $deterministic_date/" "$METADATA_ROOT/$codename/Release" fi done @@ -370,7 +403,7 @@ if [[ $SKIP_SIGN -eq 0 ]]; then for ver in "${SELECTED_VERS[@]}"; do codename="${CODENAME_MAP[$ver]}" - release="$APT_DIR/dists/$codename/Release" + release="$METADATA_ROOT/$codename/Release" echo "Signing $codename..." if [[ $DRY_RUN -eq 0 ]]; then @@ -382,7 +415,7 @@ if [[ $SKIP_SIGN -eq 0 ]]; then gpg --default-key "$GPG_KEY_ID" \ --batch --yes --armor \ --clearsign \ - -o "$APT_DIR/dists/$codename/InRelease" "$release" + -o "$METADATA_ROOT/$codename/InRelease" "$release" fi done fi @@ -392,21 +425,120 @@ step "Exporting public key" key_src="$REPO_ROOT/repomd.xml.key" key_dst="$APT_DIR/xcat-dep.asc" if [[ -f "$key_src" ]]; then - run cp "$key_src" "$key_dst" + if [[ $DRY_RUN -eq 1 ]]; then + echo "+ cp $key_src $key_dst" + else + KEY_TEMPORARY=$(mktemp "$APT_DIR/.xcat-key.XXXXXX") + cp -- "$key_src" "$KEY_TEMPORARY" + mv -- "$KEY_TEMPORARY" "$key_dst" + KEY_TEMPORARY="" + fi echo "Public key -> xcat-dep.asc (from $key_src)" elif [[ $DRY_RUN -eq 1 ]]; then echo "(dry-run: would export $GPG_KEY_ID public key to xcat-dep.asc)" else # No pre-exported key file: export the signing public key straight from the # keyring (honors GNUPGHOME), so clients get the matching pubkey. - if gpg --armor --export "$GPG_KEY_ID" > "$key_dst" 2>/dev/null && [[ -s "$key_dst" ]]; then + KEY_TEMPORARY=$(mktemp "$APT_DIR/.xcat-key.XXXXXX") + if gpg --armor --export "$GPG_KEY_ID" > "$KEY_TEMPORARY" 2>/dev/null \ + && [[ -s "$KEY_TEMPORARY" ]]; then + mv -- "$KEY_TEMPORARY" "$key_dst" + KEY_TEMPORARY="" echo "Public key -> xcat-dep.asc (exported $GPG_KEY_ID from keyring)" else - rm -f "$key_dst" + rm -f "$KEY_TEMPORARY" + KEY_TEMPORARY="" echo "WARNING: could not export '$GPG_KEY_ID' and $key_src not found; no xcat-dep.asc written" fi fi +publish_package() { + local source="$1" + local destination + local genesis_relative="${3:-}" + destination="$2/$(basename "$source")" + mkdir -p -- "$2" + if [[ -e "$destination" ]]; then + cmp -s "$source" "$destination" \ + || die "Package collision with different content: $destination" + return + fi + PACKAGE_TEMPORARY=$(mktemp "$2/.xcat-deploy.XXXXXX") + cp --reflink=auto -- "$source" "$PACKAGE_TEMPORARY" 2>/dev/null \ + || cp -- "$source" "$PACKAGE_TEMPORARY" + if [[ -n "$genesis_relative" ]]; then + "$GENESIS_VERIFIER" \ + --checksum-file "$GENESIS_CHECKSUMS" \ + --relative-file "$genesis_relative" \ + --copied-file "$PACKAGE_TEMPORARY" + fi + mv -- "$PACKAGE_TEMPORARY" "$destination" + PACKAGE_TEMPORARY="" +} + +publish_metadata() { + local source="$1" + local destination="$2" + local backup="" + mkdir -p -- "$(dirname "$destination")" + if [[ -e "$destination" ]]; then + backup="$(dirname "$destination")/.${destination##*/}.previous.$$" + rm -rf -- "$backup" + mv -- "$destination" "$backup" + fi + PUBLISHED_DESTINATIONS+=("$destination") + PUBLISHED_BACKUPS+=("$backup") + mv -- "$source" "$destination" +} + +if [[ $DRY_RUN -eq 0 ]]; then + step "Publishing packages" + for ver in "${SELECTED_VERS[@]}"; do + codename="${CODENAME_MAP[$ver]}" + for deb in "${SUITE_STAGE[$codename]}"/*.deb; do + publish_package "$deb" "$APT_DIR/pool/main/$codename" + done + done + if [[ -n "$GENESIS_RELEASE" ]]; then + for deb in "$GENESIS_STAGE"/*.deb; do + publish_package "$deb" "$GENESIS_POOL" "deb/${deb##*/}" + done + fi + + step "Publishing repository metadata" + for ver in "${SELECTED_VERS[@]}"; do + codename="${CODENAME_MAP[$ver]}" + publish_metadata "$METADATA_ROOT/$codename" "$APT_DIR/dists/$codename" + done + PUBLICATION_COMMITTED=1 + for backup in "${PUBLISHED_BACKUPS[@]}"; do + [[ -z "$backup" ]] || rm -rf -- "$backup" + done + + step "Retiring previous packages" + for ver in "${SELECTED_VERS[@]}"; do + codename="${CODENAME_MAP[$ver]}" + for deb in "$APT_DIR/pool/main/$codename"/*.deb; do + [[ -e "$deb" ]] || continue + name=$(basename "$deb") + [[ -n "${SUITE_EXPECTED[$codename/$name]:-}" ]] || rm -f -- "$deb" + done + done +fi + +if [[ -n "$GENESIS_RELEASE" && $DRY_RUN -eq 0 ]]; then + for deb in "$GENESIS_POOL"/*.deb; do + [[ -e "$deb" ]] || continue + name=$(basename "$deb") + [[ -n "${GENESIS_EXPECTED[$name]:-}" ]] || rm -f -- "$deb" + done +fi + +if [[ $DRY_RUN -eq 0 ]]; then + rm -rf -- "$TRANSACTION_ROOT" + TRANSACTION_ROOT="" +fi + step "Summary" echo "" diff --git a/mockbuild-all.pl b/mockbuild-all.pl index ee20618..ab05328 100755 --- a/mockbuild-all.pl +++ b/mockbuild-all.pl @@ -9,7 +9,7 @@ use File::Copy qw(copy); use File::Find qw(find); use File::Glob qw(bsd_glob); use File::Path qw(make_path remove_tree); -use File::Temp qw(tempfile); +use File::Temp qw(tempdir tempfile); use FindBin; use Getopt::Long qw(GetOptions); use Parallel::ForkManager; @@ -65,8 +65,9 @@ my $gpg_key_name = 'xCAT Signing Key'; my $gpg_home = ''; my $gpg_program = ''; my $force_unlock = 0; -my $HELD_LOCK; # path of the output lock this process owns (for cleanup on exit) -my $LOCK_OWNER_PID; # pid that created the lock; forked children must NOT remove it +my @HELD_LOCKS; +my $LOCK_OWNER_PID; +my ($COMMON_STAGE, $COMMON_DESTINATION, $COMMON_BACKUP); GetOptions( 'repo-root=s' => \$repo_root, @@ -134,10 +135,15 @@ $output_base = abs_path($output_base) $output_root = "$output_base/mockbuild-all" if $output_root eq ''; # Deployable per-EL xcat-dep repo root (rh8/rh9/rh10/ assembled here). $repo_dep = "$output_base/xcat-dep" if $repo_dep eq ''; +make_path($repo_dep) if !-d $repo_dep; +$repo_dep = abs_path($repo_dep) + or die "Cannot resolve --repo-dep directory\n"; # Fail-fast lock on the output base so a second run against the same --output aborts instead of # racing on the shared NFS tree. Held for the whole invocation; released by the exit handlers. acquire_output_lock($output_base, $force_unlock); +acquire_repository_lock($repo_dep, $force_unlock) + if $repo_dep ne $output_base; $xcat_src = resolve_xcat_source($xcat_src, $repo_root); @@ -192,7 +198,8 @@ print_step('Targets to build'); print " $_\n" for @build_targets; print "output_base: $output_base\n"; print "deploy repo-dep: $repo_dep\n"; -print "lock: $output_base/.lock (held)\n"; +print "output lock: $output_base/.lock (held)\n"; +print "repository lock: $repo_dep/.lock (held)\n"; print "gpg_sign: $gpg_sign\n"; print "gpg_key_name: $gpg_key_name\n" if $gpg_sign; print "gpg_home: " . ($gpg_home ne '' ? $gpg_home : '(default keyring)') . "\n" if $gpg_sign; @@ -657,14 +664,37 @@ sub publish_genesis_common_repo { return; } - make_path($dest); - my $published = publish_genesis_release_packages('rpm', $dest); - verify_genesis_release_packages('rpm', $dest); - sign_and_index_repo($dest); - write_common_repo_metadata($dest); + $COMMON_STAGE = tempdir('.common.XXXXXXXX', DIR => $repo_dep, CLEANUP => 0); + my $published = publish_genesis_release_packages('rpm', $COMMON_STAGE); + verify_genesis_release_packages('rpm', $COMMON_STAGE); + sign_and_index_repo($COMMON_STAGE); + write_common_repo_metadata($COMMON_STAGE); + replace_common_repository($COMMON_STAGE, $dest); print "Published common Genesis repository: $published rpms\n"; } +sub replace_common_repository { + my ($staged, $destination) = @_; + my $backup = "$repo_dep/.common.previous.$$"; + remove_tree($backup) if -e $backup || -l $backup; + + $COMMON_DESTINATION = $destination; + if (-e $destination || -l $destination) { + rename($destination, $backup) + or die "Cannot preserve $destination before publication: $!\n"; + $COMMON_BACKUP = $backup; + } + unless (rename($staged, $destination)) { + my $error = $!; + rename($backup, $destination) if $COMMON_BACKUP && -d $backup; + die "Cannot publish $destination: $error\n"; + } + undef($COMMON_STAGE); + undef($COMMON_DESTINATION); + remove_tree($backup) if $COMMON_BACKUP && -d $backup; + undef($COMMON_BACKUP); +} + sub publish_file { my ($source, $destination) = @_; my ($temporary_fh, $temporary) = tempfile( @@ -1267,16 +1297,16 @@ sub resolve_xcat_source { # Fail-fast advisory lock on the output base. Uses an atomic mkdir (portable and reliable over # NFS, unlike flock) of "/.lock". A second run against the same --output dies immediately # rather than racing on the shared tree. Only the process that created the lock removes it. -sub acquire_output_lock { - my ($base, $force) = @_; +sub acquire_named_lock { + my ($base, $label, $force) = @_; my $lock = "$base/.lock"; if ($force && -d $lock) { print "force-unlock: removing stale lock $lock\n"; _rmdir_lock($lock); } if (mkdir $lock) { - $HELD_LOCK = $lock; - $LOCK_OWNER_PID = $$; + push(@HELD_LOCKS, $lock); + $LOCK_OWNER_PID //= $$; my $host = capture_command('uname', '-n') || 'unknown'; if (open my $fh, '>', "$lock/owner") { print {$fh} "host=$host\npid=$$\nepoch=" . time() . "\n"; @@ -1289,28 +1319,55 @@ sub acquire_output_lock { my $info = ''; if (open my $fh, '<', "$lock/owner") { local $/; $info = <$fh>; close $fh; } $info =~ s/\s+/ /g; - die "output $base is locked ($lock): $info\n" - . "another mockbuild-all run owns it; use a different --output or --force-unlock if stale.\n"; + die "$label $base is locked ($lock): $info\n" + . "another mockbuild-all run owns it; use a different destination or --force-unlock if stale.\n"; } die "Cannot create lock $lock: $!\n"; } +sub acquire_output_lock { + my ($base, $force) = @_; + acquire_named_lock($base, 'output', $force); +} + +sub acquire_repository_lock { + my ($base, $force) = @_; + acquire_named_lock($base, 'repository', $force); +} + sub _rmdir_lock { my ($lock) = @_; unlink "$lock/owner"; rmdir $lock; } -# Release the lock on any exit path (normal, die, or signal) -- but ONLY in the process that -# created it. Forked children (per-builder and per-target ForkManager workers) inherit -# $HELD_LOCK; without the pid guard their exit would delete the parent's lock mid-run. -sub _release_lock_if_owner { - return unless $HELD_LOCK && defined $LOCK_OWNER_PID && $$ == $LOCK_OWNER_PID; - _rmdir_lock($HELD_LOCK) if -d $HELD_LOCK; +# Release locks on every exit path, but only from the process that acquired them. +# Forked build workers inherit the lock list and must leave the parent's locks alone. +sub _restore_common_repository { + return unless defined($LOCK_OWNER_PID) && $$ == $LOCK_OWNER_PID; + remove_tree($COMMON_STAGE) + if $COMMON_STAGE && -d $COMMON_STAGE && !-l $COMMON_STAGE; + if ($COMMON_DESTINATION && $COMMON_BACKUP + && !-e $COMMON_DESTINATION && -d $COMMON_BACKUP) { + rename($COMMON_BACKUP, $COMMON_DESTINATION); + } elsif ($COMMON_DESTINATION && $COMMON_BACKUP + && -d $COMMON_DESTINATION && -d $COMMON_BACKUP) { + remove_tree($COMMON_BACKUP); + } +} + +sub _release_locks_if_owner { + return unless defined($LOCK_OWNER_PID) && $$ == $LOCK_OWNER_PID; + for my $lock (reverse(@HELD_LOCKS)) { + _rmdir_lock($lock) if -d $lock; + } +} +END { + _restore_common_repository(); + _release_locks_if_owner(); } -END { _release_lock_if_owner(); } for my $sig (qw(INT TERM HUP)) { - $SIG{$sig} = sub { _release_lock_if_owner(); exit 1; }; + $SIG{$sig} = sub { exit 1; }; } sub read_os_release { diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index ae72093..6b826e2 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -72,7 +72,7 @@ SKIP: { } SKIP: { - skip 'APT repository tools are not installed', 47 + skip 'APT repository tools are not installed', 49 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -317,8 +317,14 @@ sub test_deb_consumer { my @released = stat("$release_root/deb/$package"); isnt("$pooled[0]:$pooled[1]", "$released[0]:$released[1]", 'pooled DEB is published independently of the release file'); - like(read_binary($log), qr/^Re-verified pooled Genesis package: \Q$pool_package\E$/m, - 'pooled DEB is verified again before the indexes are generated'); + my $publication_log = read_binary($log); + my $verified_at = index( + $publication_log, + "Re-verified pooled Genesis package: ", + ); + my $indexed_at = index($publication_log, "== Generating Packages indexes =="); + ok($verified_at >= 0 && $verified_at < $indexed_at, + 'staged DEBs are verified again before the indexes are generated'); my $collision = "$tmp/apt-collision"; make_apt_inputs($collision, "$tmp/collision-deb"); @@ -417,7 +423,10 @@ sub test_deb_consumer { } my $retained = "$shared_pool/xcat-genesis-openembedded-retained.deb"; + my $retained_suite = "$apt_root/pool/main/noble/xcat-dep-retained.deb"; + my $packages_before = digest_file($amd64); write_binary($retained, 'previous complete release'); + write_binary($retained_suite, 'previous suite package'); my $fail_bin = "$tmp/apt-fail-bin"; make_path($fail_bin); write_binary("$fail_bin/apt-ftparchive", "#!/bin/sh\nexit 1\n"); @@ -434,6 +443,10 @@ sub test_deb_consumer { ); isnt($failed_status, 0, 'a failed APT metadata build aborts publication'); ok(-f $retained, 'a failed APT publication keeps the previous package set'); + ok(-f $retained_suite, + 'a failed APT publication keeps the previous suite package set'); + ok(-f $amd64 && digest_file($amd64) eq $packages_before, + 'a failed APT publication keeps the previous metadata'); } sub test_signed_common_rpm_repository { From 30f62ab06acae20a0ee8bb608747b691c9854a5e 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, 24 Aug 2026 21:47:42 -0300 Subject: [PATCH 16/32] test(genesis): require portable offline setup --- t/genesis_openembedded_consumer.t | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 6b826e2..0e1507d 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -55,7 +55,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 45 + skip 'RPM repository tools require a root Linux builder', 47 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -196,6 +196,11 @@ sub test_rpm_consumer { like($common_config, qr{/xcat-dep/common$}m, 'common repository configuration uses the shared URL'); ok(-x "$common_repo/mklocalrepo.sh", 'common repository supports offline setup'); + my $local_repo_helper = read_binary("$common_repo/mklocalrepo.sh"); + unlike($local_repo_helper, qr/\[\[/, + 'the offline helper does not require Bash conditionals'); + unlike($local_repo_helper, qr/`/, + 'the offline helper uses POSIX command substitution'); like(read_binary("$common_repo/buildinfo.txt"), qr/^TARGET=common$/m, 'common repository records its target'); like(read_binary("$output/mockbuild-all/$run/summary.txt"), qr/^copied_rpms=8$/m, From 20e2d7616d13857c6ea9e08f12d5a2081c191c02 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, 24 Aug 2026 21:48:59 -0300 Subject: [PATCH 17/32] fix(genesis): complete repository handoff --- BUILD.md | 11 +++++++++++ genesis-openembedded/README.md | 24 ++++++++++++++++++------ mockbuild-all.pl | 14 +++++++++----- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/BUILD.md b/BUILD.md index f564842..dd234e1 100644 --- a/BUILD.md +++ b/BUILD.md @@ -87,6 +87,8 @@ Use these flags to skip specific operations: - Adds extra artifact roots to the collection phase (repeatable). - `--dry-run` - Prints planned actions without executing them. +- `--force-unlock` + - Removes a stale lock after the previous publisher has been checked. # Prerequisites @@ -173,6 +175,11 @@ published once under `xcat-dep/common`. Source RPMs stay in the verified release directory. Existing per-EL repositories keep the old Genesis packages and contain no OpenEmbedded copies. +The build holds separate locks for its work area and the published repository. +It prepares the complete common repository in a temporary directory, then +replaces the previous repository only after package verification, metadata +generation, and signing have succeeded. + Repository publication requires a release containing every supported Genesis architecture. The packages are `noarch`, and the common repository contains the full set of target images. @@ -187,6 +194,10 @@ a separate xcat-core change. Omit `--genesis-release` to keep using the existing Genesis builder. +The per-target repository tarballs do not contain `xcat-dep/common`. For an +offline installation, mirror the common repository with its metadata before +disconnecting the installation network. + # Build Unified Repository Without xCAT (`--skip-xcat`) Use this mode to build dependency packages only and skip invoking `/root/xcat-dep/xcat-source-code/buildrpms.pl`. diff --git a/genesis-openembedded/README.md b/genesis-openembedded/README.md index 2016fe2..cf5c635 100644 --- a/genesis-openembedded/README.md +++ b/genesis-openembedded/README.md @@ -56,8 +56,8 @@ the release, so only accept a directory produced by a trusted build host. `verify-release` also checks the identity of each package, including a fixed build host and a build time taken from the source epoch. Those are reproduced -by rpm 4.14.3, 4.16.1.3, 4.19.1.1 and 6.0.2, so an EL8 or later builder -- and -a current Fedora one -- produces a release the verifier accepts. +by rpm 4.14.3, 4.16.1.3, 4.19.1.1, and 6.0.2. An EL8 or later builder, or a +current Fedora builder, produces a release the verifier accepts. Pass that same directory to the repository builders: @@ -73,6 +73,8 @@ perl ./mockbuild-all.pl \ The RPM builder publishes the binary packages once under `xcat-dep/common`. The per-EL repositories keep the old Genesis packages and contain no copies of the OpenEmbedded packages. Source RPMs stay in the verified release directory. +The publisher locks the shared repository and replaces `common` only after the +new package set, metadata, signatures, and local setup files are ready. The APT builder publishes the DEBs once under `pool/main/xcat-genesis-openembedded`. Every suite indexes those same files. @@ -80,15 +82,25 @@ Genesis publication updates all suites together, so do not pass a `DIST` argument with `--genesis-release`. Later suite rebuilds keep using the shared pool. Pass a new release only when replacing the Genesis packages. +APT metadata is generated in a temporary tree. New packages are copied into +place before the metadata directories are replaced, and old packages are +removed only after the new metadata is active. If publication fails, clients +can still use the previous package set and indexes. + Both consumers require all seven architectures and verify package identities and checksums before publication. A management node can install an image for a different target architecture. Without `--genesis-release`, both builders keep their existing behavior. The -new packages do not provide, replace, or obsolete the old package names. A -separate xcat-core change will select the OpenEmbedded package and install -namespace after the repositories carry it. Generated images and packages -belong in release storage, not in Git. +new packages do not provide, replace, or obsolete the old package names. +xcat-core selects the OpenEmbedded install namespace when an image package is +present and falls back to the old Genesis image otherwise. Package installation +or update runs `mknb` for that package's architecture on nodes with local TFTP +storage. Generated images and packages belong in release storage, not in Git. + +The per-target RPM tarballs do not include `xcat-dep/common`. Mirror that +repository separately, including its metadata, when preparing an offline +installation. Run the package tests on a Linux builder with RPM, DEB, and repository tools: diff --git a/mockbuild-all.pl b/mockbuild-all.pl index ab05328..7e42589 100755 --- a/mockbuild-all.pl +++ b/mockbuild-all.pl @@ -798,18 +798,22 @@ sub write_local_repo_helper { open my $m, '>', "$dir/mklocalrepo.sh" or die "Cannot write $dir/mklocalrepo.sh: $!\n"; print {$m} <<'EOS'; #!/bin/sh -cd `dirname $0` -REPOFILE=`basename xcat-*.repo` -if [[ $REPOFILE == "xcat-*.repo" ]]; then +SCRIPT_DIRECTORY=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$SCRIPT_DIRECTORY" || exit 1 +set -- xcat-*.repo +if [ "$#" -ne 1 ] || [ "$1" = "xcat-*.repo" ]; then echo "ERROR: For xcat-dep, please execute $0 in the correct / subdirectory" exit 1 fi +REPOFILE=$1 DIRECTORY="/etc/yum.repos.d" if [ ! -d "$DIRECTORY" ]; then DIRECTORY="/etc/zypp/repos.d" fi -sed -e 's|baseurl=.*|baseurl=file://'"`pwd`"'|' $REPOFILE | sed -e 's|gpgkey=.*|gpgkey=file://'"`pwd`"'/repodata/repomd.xml.key|' > "$DIRECTORY/$REPOFILE" -cd - +CURRENT_DIRECTORY=$(pwd) +sed -e 's|baseurl=.*|baseurl=file://'"$CURRENT_DIRECTORY"'|' "$REPOFILE" \ + | sed -e 's|gpgkey=.*|gpgkey=file://'"$CURRENT_DIRECTORY"'/repodata/repomd.xml.key|' \ + > "$DIRECTORY/$REPOFILE" EOS close $m; chmod 0775, "$dir/mklocalrepo.sh"; From 0fee49d1e3f3562c7be63a3e43fb5b0967d48e44 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, 24 Aug 2026 21:51:36 -0300 Subject: [PATCH 18/32] test(genesis): expect image refresh hooks --- t/genesis_openembedded_release.t | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/t/genesis_openembedded_release.t b/t/genesis_openembedded_release.t index 276061b..6bce9a1 100644 --- a/t/genesis_openembedded_release.t +++ b/t/genesis_openembedded_release.t @@ -381,7 +381,11 @@ sub exercise_packager { ); is(run_capture($contents_log, 'rpm', '-qp', '--scripts', "$first/$relative"), 0, 'RPM script metadata can be read'); - is(read_binary($contents_log), '', 'RPM has no package scripts'); + 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', + ); } else { is(run_capture($contents_log, 'dpkg-deb', '-c', "$first/$relative"), 0, 'DEB payload can be listed'); @@ -395,8 +399,13 @@ sub exercise_packager { 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) ], - 'DEB has no maintainer scripts or triggers'); + is_deeply(\@control_files, [ qw(control md5sums postinst) ], + 'DEB contains only the expected maintainer script'); + 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', + ); } } From 582d541bf381ffcbfed6cd3795c9e42baec40566 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, 24 Aug 2026 21:58:20 -0300 Subject: [PATCH 19/32] chore(genesis): mark dynamic profile source --- genesis-openembedded/activate | 1 + 1 file changed, 1 insertion(+) diff --git a/genesis-openembedded/activate b/genesis-openembedded/activate index 096ca7a..3cc38d9 100755 --- a/genesis-openembedded/activate +++ b/genesis-openembedded/activate @@ -39,6 +39,7 @@ if [ "$service_node" -eq 1 ] && [ -x "$tabdump" ]; then [ "$sharedtftp" = "1" ] && exit 0 fi +# shellcheck disable=SC1090 [ ! -r "$profile" ] || . "$profile" if ! "$mknb" "$architecture"; then echo "WARNING: mknb $architecture failed; rerun it after xcatd is available." >&2 From 7769d433a0ceb67db91692c5361a63c53cd40b48 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, 24 Aug 2026 22:30:32 -0300 Subject: [PATCH 20/32] test(genesis): cover repository publication gaps --- t/genesis_openembedded_consumer.t | 168 +++++++++++++++++++----------- t/genesis_openembedded_release.t | 14 ++- 2 files changed, 121 insertions(+), 61 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 0e1507d..8d991b5 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -41,11 +41,7 @@ my $release = 'snap202608210726'; my $epoch = 1787293573; my $tmp = tempdir(CLEANUP => 1); -SKIP: { - skip 'Genesis package activation requires Linux procfs semantics', 5 - unless $^O eq 'linux'; - test_activation_helper(); -} +test_activation_helper(); if ($ENV{XCAT_GENESIS_CI}) { BAIL_OUT('CI requires Linux root') unless $^O eq 'linux' && $> == 0; @@ -55,7 +51,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 47 + skip 'RPM repository tools require a root Linux builder', 52 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -72,7 +68,7 @@ SKIP: { } SKIP: { - skip 'APT repository tools are not installed', 49 + skip 'APT repository tools are not installed', 56 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -108,6 +104,8 @@ sub test_rpm_consumer { ); like($rpm_scripts, qr{genesis-openembedded-activate-x86_64 x86_64}, 'the RPM refreshes its architecture after a package transaction'); + like($rpm_scripts, qr{mknb x86_64 --remove-openembedded}, + 'the RPM removes published artifacts when the image is erased'); like( capture_command('rpm', '-qpl', "$release_root/rpm/$package"), qr{/usr/libexec/xcat/genesis-openembedded-activate-x86_64$}m, @@ -149,6 +147,11 @@ sub test_rpm_consumer { ); is($status, 0, 'RPM repository accepts a verified Genesis release'); + is( + sprintf('%04o', (stat($common_repo))[2] & 0x0fff), + '0755', + 'the common repository is traversable by an unprivileged server', + ); is(digest_file("$common_repo/$package"), digest_file("$release_root/rpm/$package"), 'deployed RPM matches the release'); is( @@ -264,13 +267,22 @@ sub test_deb_consumer { isnt($postinst, '', 'the DEB includes a post-installation script'); like($postinst, qr{genesis-openembedded-activate-x86_64 x86_64}, 'the DEB refreshes its architecture after configuration'); + my $postrm = capture_command( + 'dpkg-deb', '--info', "$release_root/deb/$package", 'postrm' + ); + like($postrm, qr{mknb x86_64 --remove-openembedded}, + 'the DEB removes published artifacts when the image is erased'); + + my $apt_repo_root = "$tmp/apt-repository-root"; + make_path($apt_repo_root); + write_binary("$apt_repo_root/repomd.xml.key", "test repository key\n"); local $ENV{SOURCE_DATE_EPOCH} = $epoch; my $log = "$tmp/deb-consumer.log"; my $status = run_capture( $log, 'bash', $deb_consumer, - '--repo-root', $repo_root, + '--repo-root', $apt_repo_root, '--apt-dir', $apt_root, '--skip-sign', '--genesis-release', $release_root, @@ -283,6 +295,16 @@ sub test_deb_consumer { 'APT repository uses the shared copied-package verifier'); is(digest_file($pool_package), digest_file("$release_root/deb/$package"), 'pooled DEB matches the release'); + is( + sprintf('%04o', (stat($pool_package))[2] & 0x0fff), + '0644', + 'the pooled DEB is readable by an unprivileged server', + ); + is( + sprintf('%04o', (stat("$apt_root/xcat-dep.asc"))[2] & 0x0fff), + '0644', + 'the APT signing key is readable by an unprivileged server', + ); ok(!-e "$shared_pool/xcat-genesis-openembedded-old.deb", 'stale pooled DEB is removed'); my @expected_packages = sort map { @@ -331,6 +353,26 @@ sub test_deb_consumer { ok($verified_at >= 0 && $verified_at < $indexed_at, 'staged DEBs are verified again before the indexes are generated'); + my $legacy_pool = "$apt_root/pool/main/noble/xcat-genesis-base-amd64_1_all.deb"; + my $legacy_before = digest_file($legacy_pool); + make_legacy_deb( + "$tmp/rebuilt-legacy-deb", + "$input/xcat-genesis-base-amd64_1_all.deb", + 'rebuilt repository test package', + ); + my $replace_log = "$tmp/deb-replace.log"; + my $replace_status = run_capture( + $replace_log, + 'bash', $deb_consumer, + '--repo-root', $apt_repo_root, + '--apt-dir', $apt_root, + '--skip-sign', + 'ubuntu24.04', + ); + is($replace_status, 0, 'a rebuilt DEB may keep its published filename'); + isnt(digest_file($legacy_pool), $legacy_before, + 'the rebuilt DEB replaces the earlier package atomically'); + my $collision = "$tmp/apt-collision"; make_apt_inputs($collision, "$tmp/collision-deb"); write_binary("$collision/ubuntu24.04/$package", 'different'); @@ -338,7 +380,7 @@ sub test_deb_consumer { my $collision_status = run_capture( $collision_log, 'bash', $deb_consumer, - '--repo-root', $repo_root, + '--repo-root', $apt_repo_root, '--apt-dir', $collision, '--skip-sign', '--genesis-release', $release_root, @@ -354,7 +396,7 @@ sub test_deb_consumer { my $subset_status = run_capture( $subset_log, 'bash', $deb_consumer, - '--repo-root', $repo_root, + '--repo-root', $apt_repo_root, '--apt-dir', $apt_root, '--skip-sign', '--genesis-release', $release_root, @@ -380,7 +422,7 @@ sub test_deb_consumer { my $refresh_status = run_capture( $refresh_log, 'bash', $deb_consumer, - '--repo-root', $repo_root, + '--repo-root', $apt_repo_root, '--apt-dir', $apt_root, '--skip-sign', 'ubuntu24.04', @@ -412,7 +454,7 @@ sub test_deb_consumer { my $full_refresh_status = run_capture( $full_refresh_log, 'bash', $deb_consumer, - '--repo-root', $repo_root, + '--repo-root', $apt_repo_root, '--apt-dir', $apt_root, '--skip-sign', ); @@ -441,7 +483,7 @@ sub test_deb_consumer { my $failed_status = run_capture( $failed_log, 'bash', $deb_consumer, - '--repo-root', $repo_root, + '--repo-root', $apt_repo_root, '--apt-dir', $apt_root, '--skip-sign', '--genesis-release', $release_root, @@ -748,6 +790,26 @@ sub test_rpm_repository_lock { isnt($status, 0, 'a shared RPM repository cannot have two publishers'); like(read_binary($log), qr/repository \Q$repository\E is locked/, 'the lock failure names the shared repository'); + + my $backup = "$repository/.common.previous.999"; + my $staging = "$repository/.common.abandoned"; + make_path($backup, $staging); + write_binary("$backup/marker", "previous repository\n"); + my $forced_log = "$tmp/rpm-repository-force.log"; + my $forced_status = run_capture( + $forced_log, + $^X, $rpm_consumer, + '--repo-root', $repo_root, + '--output', "$tmp/rpm-force-output", + '--repo-dep', $repository, + '--target', 'test+epel-10-' . capture_command('uname', '-m'), + '--skip-build', '--skip-xcat', '--skip-xcat-dep', '--skip-perl', + '--skip-createrepo', '--skip-tarball', '--dry-run', '--force-unlock', + ); + is($forced_status, 0, '--force-unlock recovers an interrupted RPM publication'); + ok(-f "$repository/common/marker", + 'the interrupted common repository is restored before publication'); + ok(!-d $staging, 'abandoned common repository staging is removed'); } sub test_apt_lock { @@ -769,6 +831,11 @@ sub test_apt_lock { like(read_binary($locked_log), qr/\Q$apt_root\E is locked/, 'the refusal names the directory another run owns'); + my $backup = "$apt_root/dists/.jammy.previous.999"; + my $staging = "$apt_root/.xcat-apt.abandoned"; + make_path($backup, $staging); + write_binary("$backup/marker", "previous repository\n"); + my $forced_log = "$tmp/deb-forced.log"; my $forced_status = run_capture( $forced_log, @@ -781,71 +848,53 @@ sub test_apt_lock { ); is($forced_status, 0, '--force-unlock takes over a stale lock'); ok(!-d "$apt_root/.lock", 'the lock is released when the run finishes'); + ok(-f "$apt_root/dists/jammy/marker", + 'the interrupted APT metadata directory is restored'); + ok(!-d $staging, 'abandoned APT transaction staging is removed'); } sub test_activation_helper { - my $root = "$tmp/activation-root"; - my $bin = "$tmp/activation-bin"; + my $activation = read_binary("$repo_root/genesis-openembedded/activate"); + unlike($activation, qr/XCAT_GENESIS_ROOT/, + 'the root package helper has no environment-controlled execution root'); + $activation =~ s/\ngenesis_activation_main "\$\@"\s*\z/\n/ + or BAIL_OUT('the activation helper has no reusable main boundary'); + + my $driver = "$tmp/activation-driver"; my $log = "$tmp/activation.log"; my $output = "$tmp/activation-output.log"; - make_path( - "$root/proc/1", - "$root/opt/xcat/sbin", - $bin, - ); - write_binary("$root/proc/cmdline", "test\n"); - symlink($root, "$root/proc/1/root") or die "Cannot create proc root link: $!"; write_binary( - "$root/opt/xcat/sbin/mknb", - <<'SH', -#!/bin/sh -echo "$*" >>"$XCAT_TEST_LOG" -exit "${XCAT_TEST_MKNB_STATUS:-0}" + $driver, + $activation + . <<'SH', +genesis_is_host_root() { return 0; } +genesis_mknb_exists() { return 0; } +genesis_is_service_node() { [ "${XCAT_TEST_SERVICE_NODE:-0}" = 1 ]; } +genesis_uses_shared_tftp() { [ "${XCAT_TEST_SHAREDTFTP:-0}" = 1 ]; } +genesis_run_mknb() { + printf '%s\n' "$1" >>"$XCAT_TEST_LOG" + return "${XCAT_TEST_MKNB_STATUS:-0}" +} +genesis_activation_main "$@" SH ); - write_binary( - "$root/opt/xcat/sbin/tabdump", - <<'SH', -#!/bin/sh -printf '"sharedtftp","%s"\n' "${XCAT_TEST_SHAREDTFTP:-0}" -SH - ); - write_binary( - "$bin/rpm", - <<'SH', -#!/bin/sh -[ "${XCAT_TEST_SERVICE_NODE:-0}" = "1" ] -SH - ); - chmod(0755, - "$root/opt/xcat/sbin/mknb", - "$root/opt/xcat/sbin/tabdump", - "$bin/rpm", - ) or die "Cannot make activation fixtures executable: $!"; + chmod(0755, $driver) or die "Cannot make activation driver executable: $!"; - local $ENV{XCAT_GENESIS_ROOT} = $root; local $ENV{XCAT_TEST_LOG} = $log; - local $ENV{PATH} = "$bin:$ENV{PATH}"; - my $status = run_capture( - $output, "$repo_root/genesis-openembedded/activate", 'x86_64' - ); + my $status = run_capture($output, $driver, 'x86_64'); is($status, 0, 'the activation helper accepts a local-TFTP node'); is(read_binary($log), "x86_64\n", 'the activation helper runs mknb for one architecture'); write_binary($log, ''); local $ENV{XCAT_TEST_SERVICE_NODE} = 1; local $ENV{XCAT_TEST_SHAREDTFTP} = 1; - $status = run_capture( - $output, "$repo_root/genesis-openembedded/activate", 'ppc64le' - ); + $status = run_capture($output, $driver, 'ppc64le'); is($status, 0, 'a shared-TFTP service node is accepted'); is(read_binary($log), '', 'a shared-TFTP service node does not rebuild Genesis'); local $ENV{XCAT_TEST_SERVICE_NODE} = 0; local $ENV{XCAT_TEST_MKNB_STATUS} = 1; - $status = run_capture( - $output, "$repo_root/genesis-openembedded/activate", 'ppc64le' - ); + $status = run_capture($output, $driver, 'ppc64le'); is($status, 0, 'an mknb failure does not fail the package transaction'); } @@ -929,13 +978,14 @@ sub make_rpm_dependencies { } sub make_legacy_deb { - my ($root, $output) = @_; + my ($root, $output, $description) = @_; + $description //= 'repository test package'; make_path("$root/DEBIAN"); write_binary( "$root/DEBIAN/control", "Package: xcat-genesis-base-amd64\nVersion: 1\nArchitecture: all\n" . "Maintainer: xCAT \n" - . "Description: repository test package\n", + . "Description: $description\n", ); die "Cannot build legacy test DEB\n" if run_capture( diff --git a/t/genesis_openembedded_release.t b/t/genesis_openembedded_release.t index 6bce9a1..365be5b 100644 --- a/t/genesis_openembedded_release.t +++ b/t/genesis_openembedded_release.t @@ -386,6 +386,11 @@ sub exercise_packager { 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'); @@ -399,13 +404,18 @@ sub exercise_packager { 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) ], - 'DEB contains only the expected maintainer script'); + 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', + ); } } From 0fbb4f64d9827d3d0139491337cfac2952d58752 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, 24 Aug 2026 22:37:29 -0300 Subject: [PATCH 21/32] test(genesis): cover interrupted publication --- t/genesis_openembedded_consumer.t | 57 +++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 8d991b5..912c13e 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -51,7 +51,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 52 + skip 'RPM repository tools require a root Linux builder', 54 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -68,7 +68,7 @@ SKIP: { } SKIP: { - skip 'APT repository tools are not installed', 56 + skip 'APT repository tools are not installed', 59 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -198,6 +198,10 @@ sub test_rpm_consumer { 'common repository has its own repository ID'); like($common_config, qr{/xcat-dep/common$}m, 'common repository configuration uses the shared URL'); + like($common_config, qr/^skip_if_unavailable=1$/m, + 'the published common repository stays optional during outages'); + like($common_config, qr/^repo_gpgcheck=0$/m, + 'unsigned test metadata is declared explicitly'); ok(-x "$common_repo/mklocalrepo.sh", 'common repository supports offline setup'); my $local_repo_helper = read_binary("$common_repo/mklocalrepo.sh"); unlike($local_repo_helper, qr/\[\[/, @@ -317,6 +321,51 @@ sub test_deb_consumer { push(@suite_packages, map { "$codename/$_" } genesis_deb_names("$apt_root/pool/main/$codename")); } + + my $jammy_package = + "$apt_root/pool/main/jammy/xcat-genesis-base-amd64_1_all.deb"; + my $jammy_package_before = digest_file($jammy_package); + my $jammy_metadata = "$apt_root/dists/jammy/main/binary-amd64/Packages"; + my $jammy_metadata_before = digest_file($jammy_metadata); + make_legacy_deb( + "$tmp/interrupted-legacy-deb", + "$apt_root/ubuntu22.04/xcat-genesis-base-amd64_1_all.deb", + 'interrupted replacement package', + ); + my $mv_bin = "$tmp/apt-mv-failure"; + make_path($mv_bin); + write_binary( + "$mv_bin/mv", + <<'SH', +#!/bin/sh +if [ "${2:-}" = "$XCAT_TEST_FAIL_DESTINATION" ]; then + exit 1 +fi +exec "$XCAT_TEST_MV" "$@" +SH + ); + chmod(0755, "$mv_bin/mv") or die $!; + my $interrupted_log = "$tmp/deb-interrupted-publication.log"; + my $interrupted_status; + { + local $ENV{XCAT_TEST_FAIL_DESTINATION} = "$apt_root/dists/jammy"; + local $ENV{XCAT_TEST_MV} = capture_command('sh', '-c', 'command -v mv'); + local $ENV{PATH} = "$mv_bin:$ENV{PATH}"; + $interrupted_status = run_capture( + $interrupted_log, + 'bash', $deb_consumer, + '--repo-root', $apt_repo_root, + '--apt-dir', $apt_root, + '--skip-sign', + 'ubuntu22.04', + ); + } + isnt($interrupted_status, 0, + 'a failed metadata swap aborts a rebuilt package publication'); + is(digest_file($jammy_package), $jammy_package_before, + 'a failed metadata swap restores the previous package bytes'); + is(digest_file($jammy_metadata), $jammy_metadata_before, + 'a failed metadata swap restores the previous package index'); is_deeply(\@suite_packages, [], 'suite pools contain no OpenEmbedded Genesis packages'); for my $codename (qw(jammy noble resolute)) { for my $architecture (qw(amd64 ppc64el)) { @@ -556,6 +605,10 @@ sub test_signed_common_rpm_repository { qr{^gpgkey=https://xcat\.org/files/xcat/repos/yum/devel/xcat-dep/common/repodata/repomd\.xml\.key$}m, 'the common repository file points to its own exported key', ); + like(read_binary("$common/xcat-dep-common.repo"), qr/^repo_gpgcheck=1$/m, + 'the signed common repository requires metadata verification'); + like(read_binary("$common/xcat-dep-common.repo"), qr/^skip_if_unavailable=1$/m, + 'the signed common repository remains optional during outages'); } sub test_legacy_rpm_consumer { From acaad842cefb169957fa032322595abc9511bb7b 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, 24 Aug 2026 22:42:21 -0300 Subject: [PATCH 22/32] test(genesis): cover signing key rollback --- t/genesis_openembedded_consumer.t | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 912c13e..6fa19fb 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -68,7 +68,7 @@ SKIP: { } SKIP: { - skip 'APT repository tools are not installed', 59 + skip 'APT repository tools are not installed', 60 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -327,6 +327,8 @@ sub test_deb_consumer { my $jammy_package_before = digest_file($jammy_package); my $jammy_metadata = "$apt_root/dists/jammy/main/binary-amd64/Packages"; my $jammy_metadata_before = digest_file($jammy_metadata); + my $published_key_before = digest_file("$apt_root/xcat-dep.asc"); + write_binary("$apt_repo_root/repomd.xml.key", "replacement repository key\n"); make_legacy_deb( "$tmp/interrupted-legacy-deb", "$apt_root/ubuntu22.04/xcat-genesis-base-amd64_1_all.deb", @@ -366,6 +368,8 @@ SH 'a failed metadata swap restores the previous package bytes'); is(digest_file($jammy_metadata), $jammy_metadata_before, 'a failed metadata swap restores the previous package index'); + is(digest_file("$apt_root/xcat-dep.asc"), $published_key_before, + 'a failed metadata swap restores the previous signing key'); is_deeply(\@suite_packages, [], 'suite pools contain no OpenEmbedded Genesis packages'); for my $codename (qw(jammy noble resolute)) { for my $architecture (qw(amd64 ppc64el)) { From f6ceb0c6133a818862a012d870b1f92fa370c4e3 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, 24 Aug 2026 22:54:55 -0300 Subject: [PATCH 23/32] fix(genesis): clean package lifecycle --- genesis-openembedded/activate | 88 +++++++++++-------- genesis-openembedded/package | 15 ++++ .../rpm/xCAT-genesis-openembedded.spec | 6 ++ 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/genesis-openembedded/activate b/genesis-openembedded/activate index 3cc38d9..e58bf5e 100755 --- a/genesis-openembedded/activate +++ b/genesis-openembedded/activate @@ -1,47 +1,59 @@ #!/bin/sh -architecture=$1 -root=${XCAT_GENESIS_ROOT:-} +genesis_is_host_root() { + [ -f /proc/cmdline ] || return 1 + [ "$(stat -c '%i %d' / 2>/dev/null)" = \ + "$(stat -c '%i %d' /proc/1/root/. 2>/dev/null)" ] +} -case "$architecture" in - x86|x86_64|ppc64|ppc64le|armv7hf|aarch64|riscv64) ;; - *) - echo "Invalid Genesis architecture: $architecture" >&2 - exit 0 - ;; -esac +genesis_mknb_exists() { + [ -x /opt/xcat/sbin/mknb ] +} -cmdline="$root/proc/cmdline" -init_root="$root/proc/1/root/." -mknb="$root/opt/xcat/sbin/mknb" -tabdump="$root/opt/xcat/sbin/tabdump" -profile="$root/etc/profile.d/xcat.sh" +genesis_is_service_node() { + if command -v rpm >/dev/null 2>&1 \ + && rpm -q xCATsn >/dev/null 2>&1; then + return 0 + fi + command -v dpkg-query >/dev/null 2>&1 || return 1 + dpkg-query -W -f='${Status}' xcatsn 2>/dev/null \ + | grep -q '^install ok installed$' +} -[ -f "$cmdline" ] || exit 0 -[ -x "$mknb" ] || exit 0 -[ "$(stat -c '%i %d' "$root/" 2>/dev/null)" = "$(stat -c '%i %d' "$init_root" 2>/dev/null)" ] \ - || exit 0 - -service_node=0 -if command -v rpm >/dev/null 2>&1 && rpm -q xCATsn >/dev/null 2>&1; then - service_node=1 -elif command -v dpkg-query >/dev/null 2>&1 \ - && dpkg-query -W -f='${Status}' xcatsn 2>/dev/null \ - | grep -q '^install ok installed$'; then - service_node=1 -fi - -if [ "$service_node" -eq 1 ] && [ -x "$tabdump" ]; then +genesis_uses_shared_tftp() { + [ -x /opt/xcat/sbin/tabdump ] || return 1 sharedtftp=$( - "$tabdump" site 2>/dev/null \ + /opt/xcat/sbin/tabdump site 2>/dev/null \ | awk -F '"' '$2 == "sharedtftp" { print $4; exit }' ) - [ "$sharedtftp" = "1" ] && exit 0 -fi + [ "$sharedtftp" = 1 ] +} -# shellcheck disable=SC1090 -[ ! -r "$profile" ] || . "$profile" -if ! "$mknb" "$architecture"; then - echo "WARNING: mknb $architecture failed; rerun it after xcatd is available." >&2 -fi -exit 0 +genesis_run_mknb() { + # shellcheck disable=SC1091 + [ ! -r /etc/profile.d/xcat.sh ] || . /etc/profile.d/xcat.sh + /opt/xcat/sbin/mknb "$1" +} + +genesis_activation_main() { + architecture=$1 + case "$architecture" in + x86|x86_64|ppc64|ppc64le|armv7hf|aarch64|riscv64) ;; + *) + echo "Invalid Genesis architecture: $architecture" >&2 + return 0 + ;; + esac + + genesis_is_host_root || return 0 + genesis_mknb_exists || return 0 + if genesis_is_service_node && genesis_uses_shared_tftp; then + return 0 + fi + if ! genesis_run_mknb "$architecture"; then + echo "WARNING: mknb $architecture failed; rerun it after xcatd is available." >&2 + fi + return 0 +} + +genesis_activation_main "$@" diff --git a/genesis-openembedded/package b/genesis-openembedded/package index f6d0d3d..cd1ed23 100755 --- a/genesis-openembedded/package +++ b/genesis-openembedded/package @@ -198,6 +198,21 @@ sub _build_deb { ); chmod(0755, "$root/DEBIAN/postinst") or die "Cannot set post-installation script mode: $!\n"; + write_binary( + "$root/DEBIAN/postrm", + "#!/bin/sh\n" + . "case \"\$1\" in\n" + . " remove|purge)\n" + . " if [ -x /opt/xcat/sbin/mknb ]; then\n" + . " /opt/xcat/sbin/mknb $architecture --remove-openembedded || :\n" + . " /opt/xcat/sbin/mknb $architecture || :\n" + . " fi\n" + . " ;;\n" + . "esac\n" + . "exit 0\n", + ); + chmod(0755, "$root/DEBIAN/postrm") + or die "Cannot set post-removal script mode: $!\n"; _write_deb_md5sums($root); _normalize_mtime($root, $source_date_epoch); diff --git a/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec b/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec index 1954a06..ac4902c 100644 --- a/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec +++ b/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec @@ -36,6 +36,12 @@ install -m 0755 activate "$RPM_BUILD_ROOT/usr/libexec/xcat/genesis-openembedded- %posttrans /usr/libexec/xcat/genesis-openembedded-activate-%{genesis_arch} %{genesis_arch} +%postun +if [ "$1" -eq 0 ] && [ -x /opt/xcat/sbin/mknb ]; then + /opt/xcat/sbin/mknb %{genesis_arch} --remove-openembedded || : + /opt/xcat/sbin/mknb %{genesis_arch} || : +fi + %files %defattr(-,root,root,-) %dir /opt/xcat From d7351b4c64c829ba81040c39cede59b3b5a6a171 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, 24 Aug 2026 22:54:56 -0300 Subject: [PATCH 24/32] fix(genesis): harden repository publication --- build-apt-repo.sh | 95 +++++++++++++++++++++++++++++++++++------------ mockbuild-all.pl | 29 ++++++++++++++- 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/build-apt-repo.sh b/build-apt-repo.sh index 804e7bf..97d6636 100755 --- a/build-apt-repo.sh +++ b/build-apt-repo.sh @@ -22,7 +22,7 @@ GENESIS_POOL="" GENESIS_STAGE_RELATIVE="" GENESIS_STAGE="" PACKAGE_TEMPORARY="" -KEY_TEMPORARY="" +KEY_STAGED="" TRANSACTION_ROOT="" METADATA_ROOT="" FORCE_UNLOCK=0 @@ -33,6 +33,8 @@ declare -A SUITE_EXPECTED=() declare -A SUITE_STAGE=() declare -a PUBLISHED_DESTINATIONS=() declare -a PUBLISHED_BACKUPS=() +declare -a PUBLISHED_PACKAGE_DESTINATIONS=() +declare -a PUBLISHED_PACKAGE_BACKUPS=() cleanup() { if [[ $PUBLICATION_COMMITTED -eq 0 ]]; then @@ -40,9 +42,21 @@ cleanup() { for ((index=${#PUBLISHED_DESTINATIONS[@]} - 1; index >= 0; index--)); do destination="${PUBLISHED_DESTINATIONS[$index]}" backup="${PUBLISHED_BACKUPS[$index]}" - rm -rf -- "$destination" if [[ -n "$backup" && -d "$backup" ]]; then + rm -rf -- "$destination" mv -- "$backup" "$destination" + elif [[ -z "$backup" ]]; then + rm -rf -- "$destination" + fi + done + for ((index=${#PUBLISHED_PACKAGE_DESTINATIONS[@]} - 1; index >= 0; index--)); do + destination="${PUBLISHED_PACKAGE_DESTINATIONS[$index]}" + backup="${PUBLISHED_PACKAGE_BACKUPS[$index]}" + if [[ -n "$backup" && -f "$backup" ]]; then + rm -f -- "$destination" + mv -- "$backup" "$destination" + elif [[ -z "$backup" ]]; then + rm -f -- "$destination" fi done fi @@ -55,9 +69,6 @@ cleanup() { if [[ -n "$PACKAGE_TEMPORARY" ]]; then rm -f -- "$PACKAGE_TEMPORARY" fi - if [[ -n "$KEY_TEMPORARY" ]]; then - rm -f -- "$KEY_TEMPORARY" - fi if [[ -n "$HELD_LOCK" ]]; then rm -f -- "$HELD_LOCK/owner" rmdir -- "$HELD_LOCK" 2>/dev/null || true @@ -143,10 +154,13 @@ acquire_apt_lock() { local lock="$APT_DIR/.lock" [[ $DRY_RUN -eq 0 ]] || return 0 mkdir -p -- "$APT_DIR" - if [[ $FORCE_UNLOCK -eq 1 && -d "$lock" ]]; then - echo "force-unlock: removing stale lock $lock" - rm -f -- "$lock/owner" - rmdir -- "$lock" 2>/dev/null || true + if [[ $FORCE_UNLOCK -eq 1 ]]; then + recover_interrupted_apt_publication + if [[ -d "$lock" ]]; then + echo "force-unlock: removing stale lock $lock" + rm -f -- "$lock/owner" + rmdir -- "$lock" 2>/dev/null || true + fi fi if mkdir -- "$lock" 2>/dev/null; then HELD_LOCK="$lock" @@ -157,6 +171,28 @@ acquire_apt_lock() { die "APT directory $APT_DIR is locked ($lock): $(tr '\n' ' ' < "$lock/owner" 2>/dev/null) another build-apt-repo.sh run owns it; use a different --apt-dir or --force-unlock if stale." } + +recover_interrupted_apt_publication() { + local backup directory name destination + while IFS= read -r -d '' backup; do + directory=$(dirname "$backup") + name=$(basename "$backup") + name=${name#.} + name=${name%.previous.*} + destination="$directory/$name" + if [[ -e "$destination" ]]; then + rm -rf -- "$backup" + else + mv -- "$backup" "$destination" + fi + done < <(find "$APT_DIR/dists" "$APT_DIR/pool" -depth \ + -name '.*.previous.*' -print0 2>/dev/null) + find "$APT_DIR" -maxdepth 1 -type d -name '.xcat-apt.*' \ + -exec rm -rf -- {} + + find "$APT_DIR" -type f \ + \( -name '.xcat-deploy.*' -o -name '.xcat-key.*' \) \ + -delete +} acquire_apt_lock if [[ -n "$GENESIS_RELEASE" ]]; then @@ -428,10 +464,9 @@ if [[ -f "$key_src" ]]; then if [[ $DRY_RUN -eq 1 ]]; then echo "+ cp $key_src $key_dst" else - KEY_TEMPORARY=$(mktemp "$APT_DIR/.xcat-key.XXXXXX") - cp -- "$key_src" "$KEY_TEMPORARY" - mv -- "$KEY_TEMPORARY" "$key_dst" - KEY_TEMPORARY="" + KEY_STAGED="$TRANSACTION_ROOT/xcat-dep.asc" + cp -- "$key_src" "$KEY_STAGED" + chmod 0644 "$KEY_STAGED" fi echo "Public key -> xcat-dep.asc (from $key_src)" elif [[ $DRY_RUN -eq 1 ]]; then @@ -439,15 +474,14 @@ elif [[ $DRY_RUN -eq 1 ]]; then else # No pre-exported key file: export the signing public key straight from the # keyring (honors GNUPGHOME), so clients get the matching pubkey. - KEY_TEMPORARY=$(mktemp "$APT_DIR/.xcat-key.XXXXXX") - if gpg --armor --export "$GPG_KEY_ID" > "$KEY_TEMPORARY" 2>/dev/null \ - && [[ -s "$KEY_TEMPORARY" ]]; then - mv -- "$KEY_TEMPORARY" "$key_dst" - KEY_TEMPORARY="" + KEY_STAGED="$TRANSACTION_ROOT/xcat-dep.asc" + if gpg --armor --export "$GPG_KEY_ID" > "$KEY_STAGED" 2>/dev/null \ + && [[ -s "$KEY_STAGED" ]]; then + chmod 0644 "$KEY_STAGED" echo "Public key -> xcat-dep.asc (exported $GPG_KEY_ID from keyring)" else - rm -f "$KEY_TEMPORARY" - KEY_TEMPORARY="" + rm -f "$KEY_STAGED" + KEY_STAGED="" echo "WARNING: could not export '$GPG_KEY_ID' and $key_src not found; no xcat-dep.asc written" fi fi @@ -458,14 +492,21 @@ publish_package() { local genesis_relative="${3:-}" destination="$2/$(basename "$source")" mkdir -p -- "$2" - if [[ -e "$destination" ]]; then - cmp -s "$source" "$destination" \ - || die "Package collision with different content: $destination" + local backup="" + if [[ -e "$destination" ]] && cmp -s "$source" "$destination"; then return fi + if [[ -e "$destination" ]]; then + backup="$2/.${destination##*/}.previous.$$" + rm -f -- "$backup" + fi + PUBLISHED_PACKAGE_DESTINATIONS+=("$destination") + PUBLISHED_PACKAGE_BACKUPS+=("$backup") + [[ -z "$backup" ]] || mv -- "$destination" "$backup" PACKAGE_TEMPORARY=$(mktemp "$2/.xcat-deploy.XXXXXX") cp --reflink=auto -- "$source" "$PACKAGE_TEMPORARY" 2>/dev/null \ || cp -- "$source" "$PACKAGE_TEMPORARY" + chmod 0644 "$PACKAGE_TEMPORARY" if [[ -n "$genesis_relative" ]]; then "$GENESIS_VERIFIER" \ --checksum-file "$GENESIS_CHECKSUMS" \ @@ -484,10 +525,10 @@ publish_metadata() { if [[ -e "$destination" ]]; then backup="$(dirname "$destination")/.${destination##*/}.previous.$$" rm -rf -- "$backup" - mv -- "$destination" "$backup" fi PUBLISHED_DESTINATIONS+=("$destination") PUBLISHED_BACKUPS+=("$backup") + [[ -z "$backup" ]] || mv -- "$destination" "$backup" mv -- "$source" "$destination" } @@ -504,6 +545,9 @@ if [[ $DRY_RUN -eq 0 ]]; then publish_package "$deb" "$GENESIS_POOL" "deb/${deb##*/}" done fi + if [[ -n "$KEY_STAGED" ]]; then + publish_package "$KEY_STAGED" "$APT_DIR" + fi step "Publishing repository metadata" for ver in "${SELECTED_VERS[@]}"; do @@ -514,6 +558,9 @@ if [[ $DRY_RUN -eq 0 ]]; then for backup in "${PUBLISHED_BACKUPS[@]}"; do [[ -z "$backup" ]] || rm -rf -- "$backup" done + for backup in "${PUBLISHED_PACKAGE_BACKUPS[@]}"; do + [[ -z "$backup" ]] || rm -f -- "$backup" + done step "Retiring previous packages" for ver in "${SELECTED_VERS[@]}"; do diff --git a/mockbuild-all.pl b/mockbuild-all.pl index 7e42589..276491b 100755 --- a/mockbuild-all.pl +++ b/mockbuild-all.pl @@ -669,6 +669,8 @@ sub publish_genesis_common_repo { verify_genesis_release_packages('rpm', $COMMON_STAGE); sign_and_index_repo($COMMON_STAGE); write_common_repo_metadata($COMMON_STAGE); + chmod(0755, $COMMON_STAGE) + or die "Cannot make $COMMON_STAGE traversable: $!\n"; replace_common_repository($COMMON_STAGE, $dest); print "Published common Genesis repository: $published rpms\n"; } @@ -680,9 +682,9 @@ sub replace_common_repository { $COMMON_DESTINATION = $destination; if (-e $destination || -l $destination) { + $COMMON_BACKUP = $backup; rename($destination, $backup) or die "Cannot preserve $destination before publication: $!\n"; - $COMMON_BACKUP = $backup; } unless (rename($staged, $destination)) { my $error = $!; @@ -784,6 +786,8 @@ name=xCAT 2 common dependencies baseurl=$baseurl enabled=1 gpgcheck=$gpgcheck +repo_gpgcheck=$gpgcheck +skip_if_unavailable=1 $gpgkey_line EOF close $r; @@ -802,7 +806,7 @@ SCRIPT_DIRECTORY=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) cd "$SCRIPT_DIRECTORY" || exit 1 set -- xcat-*.repo if [ "$#" -ne 1 ] || [ "$1" = "xcat-*.repo" ]; then - echo "ERROR: For xcat-dep, please execute $0 in the correct / subdirectory" + echo "ERROR: Execute $0 in an xcat-dep repository directory" exit 1 fi REPOFILE=$1 @@ -1336,9 +1340,30 @@ sub acquire_output_lock { sub acquire_repository_lock { my ($base, $force) = @_; + _recover_common_repository($base) if $force; acquire_named_lock($base, 'repository', $force); } +sub _recover_common_repository { + my ($base) = @_; + my $destination = "$base/common"; + my @backups = sort { + ((stat($a))[9] // 0) <=> ((stat($b))[9] // 0) + } grep { -d $_ && !-l $_ } bsd_glob("$base/.common.previous.*"); + + if (!-e $destination && !-l $destination && @backups) { + my $backup = pop(@backups); + rename($backup, $destination) + or die "Cannot restore interrupted common repository $backup: $!\n"; + } + remove_tree($_) for grep { -d $_ && !-l $_ } @backups; + + for my $staging (bsd_glob("$base/.common.*")) { + next if $staging =~ m{/\.common\.previous\.}; + remove_tree($staging) if -d $staging && !-l $staging; + } +} + sub _rmdir_lock { my ($lock) = @_; unlink "$lock/owner"; From 4f8abc78618f0f4312fdeb994e2384ecb39257a4 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, 24 Aug 2026 22:54:56 -0300 Subject: [PATCH 25/32] docs(genesis): document repository recovery --- BUILD.md | 4 +++- genesis-openembedded/README.md | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/BUILD.md b/BUILD.md index dd234e1..59d9a34 100644 --- a/BUILD.md +++ b/BUILD.md @@ -178,7 +178,9 @@ and contain no OpenEmbedded copies. The build holds separate locks for its work area and the published repository. It prepares the complete common repository in a temporary directory, then replaces the previous repository only after package verification, metadata -generation, and signing have succeeded. +generation, and signing have succeeded. If a stopped publisher leaves staging +or backup directories behind, rerun it with ``--force-unlock`` to recover the +previous repository before starting a new publication. Repository publication requires a release containing every supported Genesis architecture. The packages are `noarch`, and the common repository contains diff --git a/genesis-openembedded/README.md b/genesis-openembedded/README.md index cf5c635..d619e64 100644 --- a/genesis-openembedded/README.md +++ b/genesis-openembedded/README.md @@ -84,8 +84,10 @@ pool. Pass a new release only when replacing the Genesis packages. APT metadata is generated in a temporary tree. New packages are copied into place before the metadata directories are replaced, and old packages are -removed only after the new metadata is active. If publication fails, clients -can still use the previous package set and indexes. +removed only after the new metadata is active. Rebuilt packages may keep the +same filename; the previous files, signing key, and indexes are restored if +publication fails. Use ``--force-unlock`` after an interrupted publisher to +recover its saved repository and remove abandoned staging files. Both consumers require all seven architectures and verify package identities and checksums before publication. A management node can install an image for a @@ -96,7 +98,11 @@ new packages do not provide, replace, or obsolete the old package names. xcat-core selects the OpenEmbedded install namespace when an image package is present and falls back to the old Genesis image otherwise. Package installation or update runs `mknb` for that package's architecture on nodes with local TFTP -storage. Generated images and packages belong in release storage, not in Git. +storage. With ``site.sharedtftp=0``, update service nodes before the management +node so they can serve the exact architecture name immediately. Removing an +image package retires its published files and rebuilds any legacy fallback that +is still installed. Generated images and packages belong in release storage, +not in Git. The per-target RPM tarballs do not include `xcat-dep/common`. Mirror that repository separately, including its metadata, when preparing an offline From 09336c27a8ab25c56d995ad5416ea937251e4a91 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, 24 Aug 2026 23:20:55 -0300 Subject: [PATCH 26/32] test(genesis): cover publisher shutdown --- t/genesis_openembedded_consumer.t | 65 ++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 6fa19fb..57318b4 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -7,6 +7,7 @@ use File::Copy qw(copy); use File::Path qw(make_path); use File::Temp qw(tempdir); use FindBin; +use POSIX (); use Test::More; use lib "$FindBin::Bin/../lib"; @@ -51,7 +52,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 54 + skip 'RPM repository tools require a root Linux builder', 59 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -65,10 +66,11 @@ SKIP: { test_failed_build_release(); test_dry_run_release(); test_rpm_repository_lock(); + test_rpm_signal_cleanup(); } SKIP: { - skip 'APT repository tools are not installed', 60 + skip 'APT repository tools are not installed', 63 unless $^O eq 'linux' && command_exists('bash') && command_exists('dpkg-deb') @@ -106,6 +108,8 @@ sub test_rpm_consumer { 'the RPM refreshes its architecture after a package transaction'); like($rpm_scripts, qr{mknb x86_64 --remove-openembedded}, 'the RPM removes published artifacts when the image is erased'); + like($rpm_scripts, qr{/proc/1/root}, + 'the RPM removal path refuses to operate from a chroot'); like( capture_command('rpm', '-qpl', "$release_root/rpm/$package"), qr{/usr/libexec/xcat/genesis-openembedded-activate-x86_64$}m, @@ -276,6 +280,8 @@ sub test_deb_consumer { ); like($postrm, qr{mknb x86_64 --remove-openembedded}, 'the DEB removes published artifacts when the image is erased'); + like($postrm, qr{/proc/1/root}, + 'the DEB removal path refuses to operate from a chroot'); my $apt_repo_root = "$tmp/apt-repository-root"; make_path($apt_repo_root); @@ -869,6 +875,56 @@ sub test_rpm_repository_lock { ok(!-d $staging, 'abandoned common repository staging is removed'); } +sub test_rpm_signal_cleanup { + my $output = "$tmp/rpm-signal-output"; + my $repository = "$tmp/rpm-signal-repository"; + my $signal_bin = "$tmp/rpm-signal-bin"; + my $log = "$tmp/rpm-signal.log"; + make_path($repository, $signal_bin); + write_binary( + "$signal_bin/uname", + "#!/bin/sh\n" + . "if [ \"\$1\" = -m ]; then sleep 60; exit 1; fi\n" + . "exec /usr/bin/uname \"\$@\"\n", + ); + chmod(0755, "$signal_bin/uname") or die $!; + + my $pid = fork(); + die "Cannot fork signal test: $!" unless defined($pid); + if ($pid == 0) { + POSIX::setpgid(0, 0); + local $ENV{PATH} = "$signal_bin:$ENV{PATH}"; + open(STDOUT, '>', $log) or die "open $log: $!"; + open(STDERR, '>&', fileno(STDOUT)) or die "redirect stderr: $!"; + exec( + $^X, $rpm_consumer, + '--repo-root', $repo_root, + '--xcat-source', $repo_root, + '--output', $output, + '--repo-dep', $repository, + '--target', 'test+epel-10-x86_64', + '--skip-build', '--skip-xcat', '--skip-xcat-dep', '--skip-perl', + '--skip-createrepo', '--skip-tarball', '--dry-run', + ); + exit 127; + } + + my $locked = 0; + for (1 .. 200) { + if (-d "$output/.lock" && -d "$repository/.lock") { + $locked = 1; + last; + } + select(undef, undef, undef, 0.05); + } + ok($locked, 'the signal test reaches the locked publication phase'); + kill('TERM', -$pid); + waitpid($pid, 0); + is($? >> 8, 1, 'SIGTERM follows the publisher cleanup exit path'); + ok(!-d "$output/.lock", 'SIGTERM releases the output lock'); + ok(!-d "$repository/.lock", 'SIGTERM releases the repository lock'); +} + sub test_apt_lock { my $apt_root = "$tmp/apt-lock"; my $input = "$apt_root/ubuntu24.04"; @@ -889,9 +945,11 @@ sub test_apt_lock { 'the refusal names the directory another run owns'); my $backup = "$apt_root/dists/.jammy.previous.999"; + my $key_backup = "$apt_root/.xcat-dep.asc.previous.999"; my $staging = "$apt_root/.xcat-apt.abandoned"; make_path($backup, $staging); write_binary("$backup/marker", "previous repository\n"); + write_binary($key_backup, "previous signing key\n"); my $forced_log = "$tmp/deb-forced.log"; my $forced_status = run_capture( @@ -907,6 +965,9 @@ sub test_apt_lock { ok(!-d "$apt_root/.lock", 'the lock is released when the run finishes'); ok(-f "$apt_root/dists/jammy/marker", 'the interrupted APT metadata directory is restored'); + is(read_binary("$apt_root/xcat-dep.asc"), "previous signing key\n", + 'the interrupted APT signing key is restored'); + ok(!-e $key_backup, 'the recovered signing key backup is retired'); ok(!-d $staging, 'abandoned APT transaction staging is removed'); } From f1eb5e0cb021487fc5ca92b425814f76ca6c2148 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, 24 Aug 2026 23:25:15 -0300 Subject: [PATCH 27/32] fix(genesis): finish publisher recovery --- build-apt-repo.sh | 8 ++++++-- mockbuild-all.pl | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/build-apt-repo.sh b/build-apt-repo.sh index 97d6636..a252fff 100755 --- a/build-apt-repo.sh +++ b/build-apt-repo.sh @@ -185,8 +185,12 @@ recover_interrupted_apt_publication() { else mv -- "$backup" "$destination" fi - done < <(find "$APT_DIR/dists" "$APT_DIR/pool" -depth \ - -name '.*.previous.*' -print0 2>/dev/null) + done < <( + find "$APT_DIR" -maxdepth 1 -type f \ + -name '.xcat-dep.asc.previous.*' -print0 2>/dev/null + find "$APT_DIR/dists" "$APT_DIR/pool" -depth \ + -name '.*.previous.*' -print0 2>/dev/null + ) find "$APT_DIR" -maxdepth 1 -type d -name '.xcat-apt.*' \ -exec rm -rf -- {} + find "$APT_DIR" -type f \ diff --git a/mockbuild-all.pl b/mockbuild-all.pl index 276491b..331577f 100755 --- a/mockbuild-all.pl +++ b/mockbuild-all.pl @@ -68,6 +68,9 @@ my $force_unlock = 0; my @HELD_LOCKS; my $LOCK_OWNER_PID; my ($COMMON_STAGE, $COMMON_DESTINATION, $COMMON_BACKUP); +for my $sig (qw(INT TERM HUP)) { + $SIG{$sig} = sub { exit 1; }; +} GetOptions( 'repo-root=s' => \$repo_root, @@ -1395,9 +1398,6 @@ END { _restore_common_repository(); _release_locks_if_owner(); } -for my $sig (qw(INT TERM HUP)) { - $SIG{$sig} = sub { exit 1; }; -} sub read_os_release { my ($path) = @_; From 37e8d122990eb9f9f3b96f0fe88fc3c430b4dc3d 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, 24 Aug 2026 23:25:15 -0300 Subject: [PATCH 28/32] fix(genesis): guard package cleanup --- genesis-openembedded/activate | 5 +++-- genesis-openembedded/package | 4 +++- .../rpm/xCAT-genesis-openembedded.spec | 11 ++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/genesis-openembedded/activate b/genesis-openembedded/activate index e58bf5e..9a027a4 100755 --- a/genesis-openembedded/activate +++ b/genesis-openembedded/activate @@ -2,8 +2,9 @@ genesis_is_host_root() { [ -f /proc/cmdline ] || return 1 - [ "$(stat -c '%i %d' / 2>/dev/null)" = \ - "$(stat -c '%i %d' /proc/1/root/. 2>/dev/null)" ] + host_root=$(stat -c '%i %d' / 2>/dev/null) || return 1 + init_root=$(stat -c '%i %d' /proc/1/root/. 2>/dev/null) || return 1 + [ "$host_root" = "$init_root" ] } genesis_mknb_exists() { diff --git a/genesis-openembedded/package b/genesis-openembedded/package index cd1ed23..516a640 100755 --- a/genesis-openembedded/package +++ b/genesis-openembedded/package @@ -203,7 +203,9 @@ sub _build_deb { "#!/bin/sh\n" . "case \"\$1\" in\n" . " remove|purge)\n" - . " if [ -x /opt/xcat/sbin/mknb ]; then\n" + . " host_root=\$(stat -c '%i %d' / 2>/dev/null) || host_root=\n" + . " init_root=\$(stat -c '%i %d' /proc/1/root/. 2>/dev/null) || init_root=\n" + . " if [ -f /proc/cmdline ] && [ -n \"\$host_root\" ] && [ \"\$host_root\" = \"\$init_root\" ] && [ -x /opt/xcat/sbin/mknb ]; then\n" . " /opt/xcat/sbin/mknb $architecture --remove-openembedded || :\n" . " /opt/xcat/sbin/mknb $architecture || :\n" . " fi\n" diff --git a/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec b/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec index ac4902c..7ac2067 100644 --- a/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec +++ b/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec @@ -37,9 +37,14 @@ install -m 0755 activate "$RPM_BUILD_ROOT/usr/libexec/xcat/genesis-openembedded- /usr/libexec/xcat/genesis-openembedded-activate-%{genesis_arch} %{genesis_arch} %postun -if [ "$1" -eq 0 ] && [ -x /opt/xcat/sbin/mknb ]; then - /opt/xcat/sbin/mknb %{genesis_arch} --remove-openembedded || : - /opt/xcat/sbin/mknb %{genesis_arch} || : +if [ "$1" -eq 0 ] && [ -f /proc/cmdline ]; then + host_root=$(stat -c '%i %d' / 2>/dev/null) || host_root= + init_root=$(stat -c '%i %d' /proc/1/root/. 2>/dev/null) || init_root= + if [ -n "$host_root" ] && [ "$host_root" = "$init_root" ] \ + && [ -x /opt/xcat/sbin/mknb ]; then + /opt/xcat/sbin/mknb %{genesis_arch} --remove-openembedded || : + /opt/xcat/sbin/mknb %{genesis_arch} || : + fi fi %files From 0f75e018eee6a1566794f0af1cb7117445ddef5d 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, 24 Aug 2026 23:25:15 -0300 Subject: [PATCH 29/32] docs(genesis): clarify suite publication --- genesis-openembedded/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/genesis-openembedded/README.md b/genesis-openembedded/README.md index d619e64..49d0201 100644 --- a/genesis-openembedded/README.md +++ b/genesis-openembedded/README.md @@ -79,8 +79,9 @@ new package set, metadata, signatures, and local setup files are ready. The APT builder publishes the DEBs once under `pool/main/xcat-genesis-openembedded`. Every suite indexes those same files. Genesis publication updates all suites together, so do not pass a `DIST` -argument with `--genesis-release`. Later suite rebuilds keep using the shared -pool. Pass a new release only when replacing the Genesis packages. +argument with `--genesis-release`. The input directories for every configured +suite must already exist. Later suite rebuilds keep using the shared pool. Pass +a new release only when replacing the Genesis packages. APT metadata is generated in a temporary tree. New packages are copied into place before the metadata directories are replaced, and old packages are From 8a893f6eec300442f187225d99b0de1eeb2d09c4 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, 24 Aug 2026 23:47:00 -0300 Subject: [PATCH 30/32] test(genesis): require helper directory ownership --- t/genesis_openembedded_consumer.t | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 57318b4..61598d0 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -52,7 +52,7 @@ if ($ENV{XCAT_GENESIS_CI}) { } SKIP: { - skip 'RPM repository tools require a root Linux builder', 59 + skip 'RPM repository tools require a root Linux builder', 60 unless $^O eq 'linux' && $> == 0 && command_exists('rpmbuild') @@ -115,6 +115,11 @@ sub test_rpm_consumer { qr{/usr/libexec/xcat/genesis-openembedded-activate-x86_64$}m, 'the RPM contains its architecture-specific activation helper', ); + like( + capture_command('rpm', '-qpl', "$release_root/rpm/$package"), + qr{^/usr/libexec/xcat/?$}m, + 'the RPM owns its private helper directory', + ); my $dependencies = "$tmp/rpm-dependencies"; my $scratch_repo_root = "$tmp/rpm-repo-root"; From 6f25ddc0eba9ad14b8c1e94f6af34b1808691ee9 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, 24 Aug 2026 23:51:09 -0300 Subject: [PATCH 31/32] fix(genesis): own RPM helper directory --- genesis-openembedded/rpm/xCAT-genesis-openembedded.spec | 1 + 1 file changed, 1 insertion(+) diff --git a/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec b/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec index 7ac2067..b8d8baa 100644 --- a/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec +++ b/genesis-openembedded/rpm/xCAT-genesis-openembedded.spec @@ -57,4 +57,5 @@ fi /opt/xcat/share/xcat/netboot/genesis-openembedded/%{genesis_arch} %dir /usr/share/doc/%{name} %doc /usr/share/doc/%{name}/xcat-core-revision +%dir /usr/libexec/xcat /usr/libexec/xcat/genesis-openembedded-activate-%{genesis_arch} From 3bb7c2eb4fa5f29bb3bbacfd4b69c256f7162752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:36:00 -0300 Subject: [PATCH 32/32] test(genesis): use explicit fractional sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Vinícius Ferrão <2031761+viniciusferrao@users.noreply.github.com> --- t/genesis_openembedded_consumer.t | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/t/genesis_openembedded_consumer.t b/t/genesis_openembedded_consumer.t index 61598d0..115d73d 100644 --- a/t/genesis_openembedded_consumer.t +++ b/t/genesis_openembedded_consumer.t @@ -9,6 +9,7 @@ use File::Temp qw(tempdir); use FindBin; use POSIX (); use Test::More; +use Time::HiRes qw(sleep); use lib "$FindBin::Bin/../lib"; use lib "$FindBin::Bin/lib"; @@ -920,7 +921,7 @@ sub test_rpm_signal_cleanup { $locked = 1; last; } - select(undef, undef, undef, 0.05); + sleep(0.05); } ok($locked, 'the signal test reaches the locked publication phase'); kill('TERM', -$pid);