From 56a966e54e3a1bffeeb177874f70534d55f38a60 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:36:28 -0300 Subject: [PATCH 1/9] test(xcat-core): nothing proves the core build keeps the uid its target declares PR #7897 appends config_opts['chrootuid'] = 0 to every per-package mock configuration so that mock creates its result directory as root. That also moves rpmbuild to uid 0 for the whole build. Assert the opposite: mock_config_text preserves the uid and gid the target declares, and mock_build_owner reports the owner the result directory needs. A target that declares no uid reports none, because mock then uses the caller. Two assertions fail on the unfixed tree: the rendered configuration reports chrootuid 0 for openeuler-24.03-ppc64le, and 0 for a target that declares nothing. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/buildrpms_mock_resultdir_uid.t | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t index 4d0cfc982..e84fda516 100644 --- a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t +++ b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t @@ -1,6 +1,7 @@ #!/usr/bin/env perl -# mock creates --resultdir as chrootuid, and buildrpms.pl passes a relative one under a -# root-owned tree. These assertions read the RENDERED configuration, not the builder. +# mock creates --resultdir and its logs as chrootuid, and buildrpms.pl passes a relative +# one under a tree it owns as root. These assertions read the RENDERED configuration and +# the owner derived from it, not the builder. use strict; use warnings; use FindBin; @@ -13,6 +14,7 @@ config_opts['root'] = 'openeuler-24.03-ppc64le' config_opts['target_arch'] = 'ppc64le' config_opts['chrootuid'] = 1000 config_opts['chrootgid'] = 1000 +config_opts['useradd'] = '/usr/sbin/useradd -o -m -u {{chrootuid}} -g {{chrootgid}} -d {{chroothome}} -N {{chrootuser}}' config_opts['use_bootstrap'] = False CFG @@ -33,12 +35,12 @@ sub effective { } my $out = mock_config_text('xCAT', 'openeuler-24.03-ppc64le', $ppc, 1790988617); -is(effective($out, 'chrootuid'), '0', 'the ppc64le core build writes its resultdir as root'); -is(effective($out, 'chrootgid'), '1000', 'the group the target declared is left alone'); +is(effective($out, 'chrootuid'), '1000', 'the build uid the target declares is preserved'); +is(effective($out, 'chrootgid'), '1000', 'and so is the group'); is(effective($out, 'root'), '"xCAT-openeuler-24.03-ppc64le"', 'the chroot is still named per package'); my $plain = mock_config_text('xCAT', 'openeuler-24.03sp4-x86_64', $x86, 1790988617); -is(effective($plain, 'chrootuid'), '0', 'a target that declares no uid is pinned to root too'); +is(effective($plain, 'chrootuid'), undef, 'a target that declares no uid is left to mock'); is(effective($plain, 'chrootgid'), undef, 'and no group is invented for it'); is(effective($plain, 'target_arch'), "'x86_64'", 'nothing else in the base configuration changes'); @@ -51,4 +53,18 @@ like(mock_config_text('perl-xCAT', 'openeuler-24.03-ppc64le', $ppc, 1), unlike(mock_config_text('perl-xCAT', 'opensuse-leap-15.6-x86_64', $x86, 1), qr/perl-generators/, 'and still does not on a SUSE target'); +# The owner the resultdir needs, read from the configuration mock will read. +is_deeply([ XCAT::BuildUtils::mock_build_owner($out) ], [ 1000, 1000 ], + 'the resultdir owner comes from the rendered configuration'); +is_deeply([ XCAT::BuildUtils::mock_build_owner($plain) ], [ undef, undef ], + 'a target that declares no uid needs no chown: mock uses the caller'); +is_deeply([ XCAT::BuildUtils::mock_build_owner("config_opts['chrootuid'] = 1000\nconfig_opts['chrootuid'] = 48\n") ], + [ 48, undef ], 'the last assignment wins, as it does in mock'); +is_deeply([ XCAT::BuildUtils::mock_build_owner("config_opts['chrootgid'] = 135\n") ], [ undef, 135 ], + 'a group without a uid is still reported'); +is_deeply([ XCAT::BuildUtils::mock_build_owner("# config_opts['chrootuid'] = 1000\n") ], [ undef, undef ], + 'a commented assignment is not one'); +is_deeply([ XCAT::BuildUtils::mock_build_owner("config_opts['chrootuid'] = '{{nobody}}'\n") ], [ undef, undef ], + 'a value that is not a plain number is left to mock'); + done_testing(); From d3f0fd55c446b434db8790426437954c3792b417 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:39:41 -0300 Subject: [PATCH 2/9] fix(xcat-core): the openEuler ppc64le core build runs rpmbuild as root PR #7897 made mock create its result directory as root by appending config_opts['chrootuid'] = 0 to every per-package configuration. chrootuid also selects the uid rpmbuild runs as, so every target now builds as root, and the 1000 that openeuler-24.03-ppc64le declares no longer applies inside the chroot. Remove the assignment and give the result directory to the uid the target declares. mock_build_owner reads chrootuid and chrootgid from the rendered configuration, and prepare_mock_resultdirs creates dist//rpms and its SRPMS subdirectory and chowns both before the build starts. openeuler-24.03-ppc64le is the only target that declares a uid; elsewhere mock builds as the caller and the directories are left alone. buildrpms_source_only.t records the stages buildall runs, so it now expects the new one between createmockconfig and buildsources. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- build-utils/lib/XCAT/BuildUtils.pm | 40 ++++++++++++++++--- buildrpms.pl | 22 +++++++++- xCAT-test/unit/buildrpms_mock_resultdir_uid.t | 14 +++---- xCAT-test/unit/buildrpms_source_only.t | 5 ++- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/build-utils/lib/XCAT/BuildUtils.pm b/build-utils/lib/XCAT/BuildUtils.pm index e20c29b72..3f165dbe9 100644 --- a/build-utils/lib/XCAT/BuildUtils.pm +++ b/build-utils/lib/XCAT/BuildUtils.pm @@ -42,7 +42,7 @@ our @EXPORT_OK = qw( buildinfo_text targetarch_from_target openeuler_build_target openeuler_repo_subdir - mock_config_text + mock_config_text mock_build_owner genesis_chroot_name genesis_target_arch genesis_build_plan genesis_log_errors genesis_log_deny_rules deb_belongs_to_dist genesis_dists genesis_dist_reason @@ -1153,11 +1153,39 @@ sub mock_config_text { # that only surfaces later as a confusing MN install failure. 'simple' isolation is a plain # chroot -- reliable for these RPM builds -- and sidesteps the nspawn cgroup race entirely. $text .= "config_opts['isolation'] = 'simple'\n"; - # mock creates --resultdir and its logs as chrootuid, and buildrpms.pl passes a relative - # one under a tree it owns as root. This assignment replaces any uid the target declares, - # so the in-chroot build user becomes uid 0 as well. - $text .= "config_opts['chrootuid'] = 0\n"; - # chrootgid is left unset on purpose: groupadd for the in-chroot group fails on gid 0. return $text; } +#------------------------------------------------------------------------------- + +=head3 mock_build_owner + + Descriptions: Report the uid and gid mock creates its --resultdir as. + + mock creates the result directory and its logs as chrootuid, and buildrpms.pl + passes a relative one under a tree it owns as root. The caller gives the + directory to this owner before the build starts. + + No mock template declares chrootuid or chrootgid (56 on the ppc64le builder, + none), so the target configuration carries the whole answer. + + Arguments: + $text - the rendered mock configuration + + Returns: ($uid, $gid), each undef when the configuration declares none. mock + then builds as the calling user, and the directory needs no change. + +=cut + +#------------------------------------------------------------------------------- +sub mock_build_owner { + my ($text) = @_; + my ($uid, $gid); + # mock takes the last assignment. + for my $line (split /\n/, $text) { + $uid = $1 if $line =~ /\Aconfig_opts\['chrootuid'\]\s*=\s*(\d+)\s*\z/; + $gid = $1 if $line =~ /\Aconfig_opts\['chrootgid'\]\s*=\s*(\d+)\s*\z/; + } + return ($uid, $gid); +} + diff --git a/buildrpms.pl b/buildrpms.pl index 1b2ca65e3..bc5d3ae86 100755 --- a/buildrpms.pl +++ b/buildrpms.pl @@ -60,7 +60,7 @@ use XCAT::BuildUtils qw(git_revision source_date_epoch sh sh_or_die usage buildi stage_genesis_base_sources write_script read_line targetarch_from_target openeuler_build_target openeuler_repo_subdir - mock_config_text); + mock_config_text mock_build_owner); use POSIX (); # EEXIST, for the directory build lock (see main()) use Getopt::Long qw(GetOptions); use POSIX qw(strftime); @@ -330,6 +330,25 @@ sub createmockconfig { mock_config_text($pkg, "$target$ext", read_text("/etc/mock/$target.cfg"), $SOURCE_DATE_EPOCH)); } +# mock creates --resultdir and the logs under it as chrootuid, and the two mock calls below +# pass a relative directory under dist/, which this script owns as root. Give the directory +# to the build uid before mock runs, so the build does not need to be root to write its own +# results. openeuler-24.03-ppc64le is the only target that declares a uid; everywhere else +# mock builds as the caller and there is nothing to change. +sub prepare_mock_resultdirs { + my ($pkg, $target) = @_; + my $ext = $opts{mock_uniqueext} ? "-$opts{mock_uniqueext}" : ""; + my ($uid, $gid) = mock_build_owner(read_text("/etc/mock/$pkg-$target$ext.cfg")); + return unless defined $uid || defined $gid; + for my $dir ("dist/$target/rpms", "dist/$target/rpms/SRPMS") { + make_path($dir); + chown(defined $uid ? $uid : -1, defined $gid ? $gid : -1, $dir) == 1 + or die "FATAL: cannot give $dir to the build uid: $!\n"; + # The group needs the write bit: mock writes the rpms as chrootuid:chrootgid. + chmod 0775, $dir; + } +} + sub buildsources_genesis_base($) { stage_genesis_base_sources(".", "$SOURCES/xCAT-genesis-base-build-support.tar.bz2", $SOURCE_DATE_EPOCH); @@ -518,6 +537,7 @@ sub buildall { # The parent deletes this directory when it reaps the child. $SOURCES = prepare_build_sources_dir($SOURCES_BASE, $$); createmockconfig($pkg, $target); + prepare_mock_resultdirs($pkg, $target); buildsources($pkg, $target); buildspkgs($pkg, $target); # --source-only stops here: buildspkgs has produced the src.rpm, and the binary diff --git a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t index e84fda516..555ff2522 100644 --- a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t +++ b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t @@ -7,7 +7,7 @@ use warnings; use FindBin; use lib "$FindBin::Bin/../../build-utils/lib"; use Test::More; -use XCAT::BuildUtils qw(mock_config_text); +use XCAT::BuildUtils qw(mock_config_text mock_build_owner); my $ppc = <<'CFG'; config_opts['root'] = 'openeuler-24.03-ppc64le' @@ -54,17 +54,17 @@ unlike(mock_config_text('perl-xCAT', 'opensuse-leap-15.6-x86_64', $x86, 1), qr/perl-generators/, 'and still does not on a SUSE target'); # The owner the resultdir needs, read from the configuration mock will read. -is_deeply([ XCAT::BuildUtils::mock_build_owner($out) ], [ 1000, 1000 ], +is_deeply([ mock_build_owner($out) ], [ 1000, 1000 ], 'the resultdir owner comes from the rendered configuration'); -is_deeply([ XCAT::BuildUtils::mock_build_owner($plain) ], [ undef, undef ], +is_deeply([ mock_build_owner($plain) ], [ undef, undef ], 'a target that declares no uid needs no chown: mock uses the caller'); -is_deeply([ XCAT::BuildUtils::mock_build_owner("config_opts['chrootuid'] = 1000\nconfig_opts['chrootuid'] = 48\n") ], +is_deeply([ mock_build_owner("config_opts['chrootuid'] = 1000\nconfig_opts['chrootuid'] = 48\n") ], [ 48, undef ], 'the last assignment wins, as it does in mock'); -is_deeply([ XCAT::BuildUtils::mock_build_owner("config_opts['chrootgid'] = 135\n") ], [ undef, 135 ], +is_deeply([ mock_build_owner("config_opts['chrootgid'] = 135\n") ], [ undef, 135 ], 'a group without a uid is still reported'); -is_deeply([ XCAT::BuildUtils::mock_build_owner("# config_opts['chrootuid'] = 1000\n") ], [ undef, undef ], +is_deeply([ mock_build_owner("# config_opts['chrootuid'] = 1000\n") ], [ undef, undef ], 'a commented assignment is not one'); -is_deeply([ XCAT::BuildUtils::mock_build_owner("config_opts['chrootuid'] = '{{nobody}}'\n") ], [ undef, undef ], +is_deeply([ mock_build_owner("config_opts['chrootuid'] = '{{nobody}}'\n") ], [ undef, undef ], 'a value that is not a plain number is left to mock'); done_testing(); diff --git a/xCAT-test/unit/buildrpms_source_only.t b/xCAT-test/unit/buildrpms_source_only.t index 54ebe5c27..2868f185d 100644 --- a/xCAT-test/unit/buildrpms_source_only.t +++ b/xCAT-test/unit/buildrpms_source_only.t @@ -49,6 +49,7 @@ our @STAGES; sub createrepo_dir { push @main::CREATEREPO, $_[0]; } # The four stages buildall drives. Each records that it was reached. sub createmockconfig { push @main::STAGES, 'createmockconfig'; } + sub prepare_mock_resultdirs { push @main::STAGES, 'prepare_mock_resultdirs'; } sub buildsources { push @main::STAGES, 'buildsources'; } sub buildspkgs { push @main::STAGES, 'buildspkgs'; } sub buildpkgs { push @main::STAGES, 'buildpkgs'; } @@ -128,11 +129,11 @@ sub stages_for { } is_deeply( stages_for(0), - [qw(createmockconfig buildsources buildspkgs buildpkgs)], + [qw(createmockconfig prepare_mock_resultdirs buildsources buildspkgs buildpkgs)], 'a normal run builds the source rpm and then rebuilds it into binaries' ); is_deeply( stages_for(1), - [qw(createmockconfig buildsources buildspkgs)], + [qw(createmockconfig prepare_mock_resultdirs buildsources buildspkgs)], 'a source-only run stops once the source rpm exists' ); ok( !grep( { $_ eq 'buildpkgs' } @{ stages_for(1) } ), From 926c076533c9e2db1313a97f71fe6fe999d4d813 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:27:55 -0300 Subject: [PATCH 3/9] test(xcat-core): the mock result directory owner ignores include() and stale logs mock_build_owner reads chrootuid and chrootgid from the text of the rendered configuration. It does not see a value set in an included file, an assignment with an inline comment or a double-quoted key, or an include that overrides an earlier assignment. prepare_mock_resultdirs then gives the result directories to the wrong uid, or to none. prepare_mock_resultdirs changes only the two directories. A root build that stopped part way leaves root-owned 0644 logs in them, and mock, running as uid 1000, cannot append to them on the next run. native/mock_resultdir_owner.t runs mock's loader on each configuration case, and checks as root that uid 1000 can append to the stale logs in both directories. Ubuntu ships no mock, so the workflow runs it in a Fedora container and fails when it skips. The unit test checks, with chown recorded, which owner the directories and their files get. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .github/workflows/xcat_test.yml | 11 +++ xCAT-test/native/README.md | 7 ++ xCAT-test/native/mock_resultdir_owner.t | 93 +++++++++++++++++++ xCAT-test/unit/buildrpms_mock_resultdir_uid.t | 62 +++++++++---- 4 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 xCAT-test/native/mock_resultdir_owner.t diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml index 7b9bd84c4..cec518716 100644 --- a/.github/workflows/xcat_test.yml +++ b/.github/workflows/xcat_test.yml @@ -22,6 +22,17 @@ jobs: - name: Run tests if: ${{ !cancelled() }} run: perl github_action_xcat_test.pl + # Ubuntu ships no mock, so the mock result directory test runs as root on Fedora. + - name: Run mock result directory test + run: | + set -o pipefail + docker run --rm -v "$PWD:/src:ro" -w /src fedora:42 bash -ec ' + dnf -y -q install mock perl perl-File-Slurper >/dev/null + prove -v xCAT-test/native/mock_resultdir_owner.t' 2>&1 | tee mock.tap + if grep -iE '# skip' mock.tap; then + echo 'a mock result directory test was skipped' >&2 + exit 1 + fi rpm_lifecycle_test: runs-on: ubuntu-24.04 timeout-minutes: 30 diff --git a/xCAT-test/native/README.md b/xCAT-test/native/README.md index 6dac73a77..9e0816eb8 100644 --- a/xCAT-test/native/README.md +++ b/xCAT-test/native/README.md @@ -38,6 +38,13 @@ sudo prove xCAT-test/native/ip_forwarding.t \ xCAT-test/native/syslog_openeuler*.t ``` +The mock result directory case needs the mock Python library, and root for its +ownership checks. The `xcat_test` workflow runs it as root in a Fedora container: + +``` +sudo prove xCAT-test/native/mock_resultdir_owner.t +``` + The PostgreSQL case starts a private server with only a Unix socket. Run it as an ordinary user with `XCAT_TEST_PG_BINDIR` set to the directory containing `initdb`, `pg_ctl`, `postgres`, `psql` and `createdb`: diff --git a/xCAT-test/native/mock_resultdir_owner.t b/xCAT-test/native/mock_resultdir_owner.t new file mode 100644 index 000000000..a0a884fe6 --- /dev/null +++ b/xCAT-test/native/mock_resultdir_owner.t @@ -0,0 +1,93 @@ +#!/usr/bin/env perl +# mock_build_owner must give the uid and gid mock itself builds as, so it runs mock's own +# loader. prepare_mock_resultdirs must leave nothing in a result directory that this uid +# cannot write. CI runs this file as root in a Fedora container. +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../../build-utils/lib"; +use File::Path qw(make_path); +use File::Slurper qw(write_text); +use File::Temp qw(tempdir); +use POSIX (); +use Test::More; +use XCAT::BuildUtils qw(mock_build_owner prepare_mock_resultdirs); + +plan skip_all => 'requires the mock Python library' + if system('python3 -c "import mockbuild.config" >/dev/null 2>&1') != 0; + +# Flush before the forks below, or each child repeats this file's TAP output on exit. +$| = 1; + +my $conf = tempdir(CLEANUP => 1); +make_path("$conf/templates"); +write_text("$conf/templates/owner.tpl", + "config_opts['chrootuid'] = 1000\nconfig_opts['chrootgid'] = 1000\n"); + +my %cfg = ( + 'inline-comment' => "config_opts['chrootuid'] = 1001 # build user\n" + . "config_opts['chrootgid'] = 1001 # build group\n", + 'double-quoted' => qq(config_opts["chrootuid"] = 1001\nconfig_opts["chrootgid"] = 1001\n), + 'included' => "include('templates/owner.tpl')\n", + 'set-after-include' => "include('templates/owner.tpl')\n" + . "config_opts['chrootuid'] = 1001\nconfig_opts['chrootgid'] = 1001\n", + 'include-after-set' => "config_opts['chrootuid'] = 1001\nconfig_opts['chrootgid'] = 1001\n" + . "include('templates/owner.tpl')\n", + 'plain' => "config_opts['root'] = 'plain'\n", +); +write_text("$conf/$_.cfg", $cfg{$_}) for keys %cfg; + +is_deeply([ mock_build_owner('inline-comment', $conf) ], [ 1001, 1001 ], + 'an assignment with an inline comment counts'); +is_deeply([ mock_build_owner('double-quoted', $conf) ], [ 1001, 1001 ], + 'an assignment with a double-quoted key counts'); +is_deeply([ mock_build_owner('included', $conf) ], [ 1000, 1000 ], + 'an include resolves against the configuration directory'); +is_deeply([ mock_build_owner('set-after-include', $conf) ], [ 1001, 1001 ], + 'an assignment after an include overrides it'); +is_deeply([ mock_build_owner('include-after-set', $conf) ], [ 1000, 1000 ], + 'an include after an assignment overrides it'); +my $mock_gid = (getgrnam 'mock')[2]; +is_deeply([ mock_build_owner('plain', $conf) ], [ $<, $mock_gid ], + 'with no chrootuid, mock builds as the caller and the mock group'); + +SKIP: { + skip 'giving files to uid 1000 requires root', 13 if $> != 0; + + # A root build that stopped part way left root-owned 0644 logs and a source rpm. + my $tmp = tempdir(CLEANUP => 1); + chmod 0755, $tmp or die "Cannot make $tmp traversable: $!\n"; + my @dirs = ("$tmp/dist/openeuler-24.03-ppc64le/rpms", "$tmp/dist/openeuler-24.03-ppc64le/rpms/SRPMS"); + make_path(@dirs); + my @logs = map { my $d = $_; map { "$d/$_" } qw(build.log root.log state.log hw_info.log installed_pkgs.log) } @dirs; + my @stale = (@logs, "$dirs[1]/xCAT-2.19.1-1.src.rpm"); + for my $file (@stale) { + write_text($file, "interrupted\n"); + chmod 0644, $file; + } + chmod 0755, $tmp, "$tmp/dist", "$tmp/dist/openeuler-24.03-ppc64le"; + + prepare_mock_resultdirs('included', $conf, @dirs); + ok(writable_as(1000, 1000, $_), "uid 1000 can append to $_") for @stale[0 .. $#logs]; + ok(writable_as(1000, 1000, $stale[-1]), 'uid 1000 can replace the stale source rpm'); + ok(writable_as(1000, 1000, "$_/new.log"), "uid 1000 can create a file in $_") for @dirs; +} + +done_testing(); + +# Fork, take the uid and gid, and append one line to $file. +sub writable_as { + my ($uid, $gid, $file) = @_; + my $pid = fork // die "fork: $!\n"; + if ($pid == 0) { + $) = "$gid $gid"; # drop root from the supplementary groups + POSIX::setgid($gid) or POSIX::_exit(2); + POSIX::setuid($uid) or POSIX::_exit(2); + open(my $fh, '>>', $file) or POSIX::_exit(1); + print {$fh} "retry\n" or POSIX::_exit(1); + close $fh or POSIX::_exit(1); + POSIX::_exit(0); + } + waitpid($pid, 0); + return $? == 0; +} diff --git a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t index 555ff2522..e0719bfb5 100644 --- a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t +++ b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t @@ -1,13 +1,27 @@ #!/usr/bin/env perl # mock creates --resultdir and its logs as chrootuid, and buildrpms.pl passes a relative -# one under a tree it owns as root. These assertions read the RENDERED configuration and -# the owner derived from it, not the builder. +# one under a tree it owns as root. These assertions read the RENDERED configuration, and +# check which owner the result directories get. native/mock_resultdir_owner.t runs mock's +# loader and the real chown. use strict; use warnings; use FindBin; use lib "$FindBin::Bin/../../build-utils/lib"; use Test::More; -use XCAT::BuildUtils qw(mock_config_text mock_build_owner); +use File::Path qw(make_path); +use File::Slurper qw(write_text); +use File::Temp qw(tempdir); + +# Record chown instead of doing it, so the test runs without root. +our @CHOWNED; +BEGIN { + *CORE::GLOBAL::chown = sub { + my ($uid, $gid, @files) = @_; + push @CHOWNED, map { [ $uid, $gid, $_ ] } @files; + return scalar @files; + }; +} +use XCAT::BuildUtils qw(mock_config_text); my $ppc = <<'CFG'; config_opts['root'] = 'openeuler-24.03-ppc64le' @@ -53,18 +67,34 @@ like(mock_config_text('perl-xCAT', 'openeuler-24.03-ppc64le', $ppc, 1), unlike(mock_config_text('perl-xCAT', 'opensuse-leap-15.6-x86_64', $x86, 1), qr/perl-generators/, 'and still does not on a SUSE target'); -# The owner the resultdir needs, read from the configuration mock will read. -is_deeply([ mock_build_owner($out) ], [ 1000, 1000 ], - 'the resultdir owner comes from the rendered configuration'); -is_deeply([ mock_build_owner($plain) ], [ undef, undef ], - 'a target that declares no uid needs no chown: mock uses the caller'); -is_deeply([ mock_build_owner("config_opts['chrootuid'] = 1000\nconfig_opts['chrootuid'] = 48\n") ], - [ 48, undef ], 'the last assignment wins, as it does in mock'); -is_deeply([ mock_build_owner("config_opts['chrootgid'] = 135\n") ], [ undef, 135 ], - 'a group without a uid is still reported'); -is_deeply([ mock_build_owner("# config_opts['chrootuid'] = 1000\n") ], [ undef, undef ], - 'a commented assignment is not one'); -is_deeply([ mock_build_owner("config_opts['chrootuid'] = '{{nobody}}'\n") ], [ undef, undef ], - 'a value that is not a plain number is left to mock'); +# prepare_mock_resultdirs gives the result directories, and the files a previous +# build left in them, to the owner mock's loader reports. +my $tmp = tempdir(CLEANUP => 1); +my @dirs = ("$tmp/rpms", "$tmp/rpms/SRPMS"); +make_path(@dirs); +write_text("$_/build.log", "interrupted\n") for @dirs; +write_text("$tmp/rpms/SRPMS/xCAT-2.19.1-1.src.rpm", ''); +my $chroot = 'xCAT-openeuler-24.03-ppc64le'; + +my (@asked, @owner); +no warnings 'redefine'; +local *XCAT::BuildUtils::mock_build_owner = sub { push @asked, [@_]; return @owner }; +use warnings 'redefine'; + +@owner = (1000, 135); +@CHOWNED = (); +XCAT::BuildUtils::prepare_mock_resultdirs($chroot, undef, @dirs); +is_deeply(\@asked, [ [ $chroot, undef ] ], 'the owner comes from the chroot mock builds with'); +is_deeply([ sort { $a->[2] cmp $b->[2] } @CHOWNED ], + [ map { [ 1000, 135, $_ ] } sort(@dirs, "$tmp/rpms/build.log", + "$tmp/rpms/SRPMS/build.log", "$tmp/rpms/SRPMS/xCAT-2.19.1-1.src.rpm") ], + 'both directories and the files left in them go to that uid and gid'); +is((stat $_)[2] & 07777, 0775, "$_ is group-writable") for @dirs; + +@owner = ($>, 135); +@CHOWNED = (); +XCAT::BuildUtils::prepare_mock_resultdirs($chroot, undef, "$tmp/caller"); +is_deeply(\@CHOWNED, [], 'nothing changes owner when mock builds as the caller'); +ok(-d "$tmp/caller", 'the directory is still created'); done_testing(); From 32eb096bfe167fb44f3e960d478cde9966bd3f75 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:31:07 -0300 Subject: [PATCH 4/9] fix(xcat-core): the mock result directory owner ignores include() and stale logs mock_build_owner parsed chrootuid and chrootgid from the text of the rendered configuration. A value set in an included file, an assignment with an inline comment or a double-quoted key, and an include after an assignment all gave a uid that mock does not build as. Also, a root build that stopped part way left root-owned 0644 logs in the result directories, and the next build, as uid 1000, could not append to them. mock_build_owner now asks mock's own loader, mockbuild.config.load_config, for chrootuid and chrootgid of the chroot that buildrpms.pl passes to mock -r. It fails with a clear message when mock is not installed. prepare_mock_resultdirs moves to XCAT::BuildUtils. It also gives the files already in both result directories to that owner, so a retry can append to the old logs and replace a package of the same version. When mock builds as the caller, nothing changes owner. native/mock_resultdir_owner.t gave the wrong uid for four of its configuration cases, and failed the 11 retry checks, before this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- build-utils/lib/XCAT/BuildUtils.pm | 85 +++++++++++++++++++++++------- buildrpms.pl | 19 ++----- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/build-utils/lib/XCAT/BuildUtils.pm b/build-utils/lib/XCAT/BuildUtils.pm index 3f165dbe9..91e2bb792 100644 --- a/build-utils/lib/XCAT/BuildUtils.pm +++ b/build-utils/lib/XCAT/BuildUtils.pm @@ -42,7 +42,7 @@ our @EXPORT_OK = qw( buildinfo_text targetarch_from_target openeuler_build_target openeuler_repo_subdir - mock_config_text mock_build_owner + mock_config_text mock_build_owner prepare_mock_resultdirs genesis_chroot_name genesis_target_arch genesis_build_plan genesis_log_errors genesis_log_deny_rules deb_belongs_to_dist genesis_dists genesis_dist_reason @@ -1160,32 +1160,77 @@ sub mock_config_text { =head3 mock_build_owner - Descriptions: Report the uid and gid mock creates its --resultdir as. - - mock creates the result directory and its logs as chrootuid, and buildrpms.pl - passes a relative one under a tree it owns as root. The caller gives the - directory to this owner before the build starts. - - No mock template declares chrootuid or chrootgid (56 on the ppc64le builder, - none), so the target configuration carries the whole answer. + Descriptions: Report the uid and gid mock builds as, from mock's own + configuration loader. The loader resolves include() and + the assignments around it as a build does. Arguments: - $text - the rendered mock configuration + $chroot - the name passed to mock -r + $configdir - the mock configuration directory; mock's default when undef - Returns: ($uid, $gid), each undef when the configuration declares none. mock - then builds as the calling user, and the directory needs no change. + Returns: ($uid, $gid). With no chrootuid, $uid is the caller, as in mock. + $gid is undef when the loader reports none. =cut #------------------------------------------------------------------------------- +my $MOCK_OWNER_LOADER = <<'PYTHON'; +import sys +try: + from mockbuild.config import MOCKCONFDIR, load_config +except ImportError: + sys.exit('mock is not installed: cannot import mockbuild.config') +opts = load_config(sys.argv[2] or MOCKCONFDIR, sys.argv[1]) +print(opts['chrootuid'], opts.get('chrootgid', '')) +PYTHON + sub mock_build_owner { - my ($text) = @_; - my ($uid, $gid); - # mock takes the last assignment. - for my $line (split /\n/, $text) { - $uid = $1 if $line =~ /\Aconfig_opts\['chrootuid'\]\s*=\s*(\d+)\s*\z/; - $gid = $1 if $line =~ /\Aconfig_opts\['chrootgid'\]\s*=\s*(\d+)\s*\z/; - } - return ($uid, $gid); + my ($chroot, $configdir) = @_; + open my $pipe, '-|', 'python3', '-c', $MOCK_OWNER_LOADER, $chroot, $configdir // '' + or die "Cannot run python3 to load the mock configuration $chroot: $!\n"; + my $out = do { local $/; <$pipe> } // ''; + close $pipe or die "mock cannot load the configuration $chroot\n"; + my ($uid, $gid) = $out =~ /\A(\d+) (\d*)\n\z/ + or die "mock gives no chrootuid for $chroot\n"; + return ($uid, length $gid ? $gid : undef); +} + +#------------------------------------------------------------------------------- + +=head3 prepare_mock_resultdirs + + Descriptions: Give the --resultdir directories, and the files a previous + build left in them, to the uid and gid mock builds as. + mock writes its logs and packages there as chrootuid. A + root build that stopped part way leaves root-owned 0644 + files that a non-root build cannot append to or replace. + + Arguments: + $chroot - the name passed to mock -r + $configdir - the mock configuration directory; mock's default when undef + @dirs - the result directories, created when missing + + Returns: nothing. Dies when a chown fails. + +=cut + +#------------------------------------------------------------------------------- +sub prepare_mock_resultdirs { + my ($chroot, $configdir, @dirs) = @_; + my ($uid, $gid) = mock_build_owner($chroot, $configdir); + make_path(@dirs); + return if $uid == $>; + $gid //= -1; + for my $dir (@dirs) { + opendir(my $dh, $dir) or die "Cannot read $dir: $!\n"; + my @files = grep { -f $_ && !-l $_ } map { "$dir/$_" } readdir $dh; + closedir $dh; + for my $path ($dir, @files) { + chown($uid, $gid, $path) == 1 + or die "FATAL: cannot give $path to uid $uid: $!\n"; + } + # mock writes the packages as chrootuid:chrootgid. + chmod 0775, $dir; + } } diff --git a/buildrpms.pl b/buildrpms.pl index bc5d3ae86..3c623dd07 100755 --- a/buildrpms.pl +++ b/buildrpms.pl @@ -60,7 +60,7 @@ use XCAT::BuildUtils qw(git_revision source_date_epoch sh sh_or_die usage buildi stage_genesis_base_sources write_script read_line targetarch_from_target openeuler_build_target openeuler_repo_subdir - mock_config_text mock_build_owner); + mock_config_text); use POSIX (); # EEXIST, for the directory build lock (see main()) use Getopt::Long qw(GetOptions); use POSIX qw(strftime); @@ -330,23 +330,12 @@ sub createmockconfig { mock_config_text($pkg, "$target$ext", read_text("/etc/mock/$target.cfg"), $SOURCE_DATE_EPOCH)); } -# mock creates --resultdir and the logs under it as chrootuid, and the two mock calls below -# pass a relative directory under dist/, which this script owns as root. Give the directory -# to the build uid before mock runs, so the build does not need to be root to write its own -# results. openeuler-24.03-ppc64le is the only target that declares a uid; everywhere else -# mock builds as the caller and there is nothing to change. +# mock writes --resultdir as chrootuid, and this script runs as root. sub prepare_mock_resultdirs { my ($pkg, $target) = @_; my $ext = $opts{mock_uniqueext} ? "-$opts{mock_uniqueext}" : ""; - my ($uid, $gid) = mock_build_owner(read_text("/etc/mock/$pkg-$target$ext.cfg")); - return unless defined $uid || defined $gid; - for my $dir ("dist/$target/rpms", "dist/$target/rpms/SRPMS") { - make_path($dir); - chown(defined $uid ? $uid : -1, defined $gid ? $gid : -1, $dir) == 1 - or die "FATAL: cannot give $dir to the build uid: $!\n"; - # The group needs the write bit: mock writes the rpms as chrootuid:chrootgid. - chmod 0775, $dir; - } + XCAT::BuildUtils::prepare_mock_resultdirs("$pkg-$target$ext", undef, + "dist/$target/rpms", "dist/$target/rpms/SRPMS"); } sub buildsources_genesis_base($) { From e5137981ffb205a02456a7c9ddf64ab2a4ff3de6 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:13:34 -0300 Subject: [PATCH 5/9] test(xcat-core): mock cannot read the sources staged under /root buildrpms.pl stages each build's sources under $HOME/rpmbuild/sources. mock now builds as the chrootuid of the target, uid 1000 for openEuler 24.03, and HOME of the Jenkins agents is /root, mode 0550. mock --sources then fails with "Permission denied" on the staging directory. The unit test asserts that the base directory is under the dist tree of the checkout and not under $HOME, and that the staging tree is readable by other users when the umask is 077. A native test, run as root in the Fedora container, reads the staged files as uid 1000. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .github/workflows/xcat_test.yml | 3 +- xCAT-test/native/README.md | 6 ++- xCAT-test/native/build_sources_other_uid.t | 50 +++++++++++++++++++ xCAT-test/unit/buildrpms_source_only.t | 5 +- .../unit/buildrpms_sources_per_package.t | 36 ++++++++++++- 5 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 xCAT-test/native/build_sources_other_uid.t diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml index cec518716..5ed312bd5 100644 --- a/.github/workflows/xcat_test.yml +++ b/.github/workflows/xcat_test.yml @@ -28,7 +28,8 @@ jobs: set -o pipefail docker run --rm -v "$PWD:/src:ro" -w /src fedora:42 bash -ec ' dnf -y -q install mock perl perl-File-Slurper >/dev/null - prove -v xCAT-test/native/mock_resultdir_owner.t' 2>&1 | tee mock.tap + prove -v xCAT-test/native/mock_resultdir_owner.t \ + xCAT-test/native/build_sources_other_uid.t' 2>&1 | tee mock.tap if grep -iE '# skip' mock.tap; then echo 'a mock result directory test was skipped' >&2 exit 1 diff --git a/xCAT-test/native/README.md b/xCAT-test/native/README.md index 9e0816eb8..eb1072c6e 100644 --- a/xCAT-test/native/README.md +++ b/xCAT-test/native/README.md @@ -39,10 +39,12 @@ sudo prove xCAT-test/native/ip_forwarding.t \ ``` The mock result directory case needs the mock Python library, and root for its -ownership checks. The `xcat_test` workflow runs it as root in a Fedora container: +ownership checks. The staged sources case needs root to read the sources as uid +1000. The `xcat_test` workflow runs both as root in a Fedora container: ``` -sudo prove xCAT-test/native/mock_resultdir_owner.t +sudo prove xCAT-test/native/mock_resultdir_owner.t \ + xCAT-test/native/build_sources_other_uid.t ``` The PostgreSQL case starts a private server with only a Unix socket. Run it as diff --git a/xCAT-test/native/build_sources_other_uid.t b/xCAT-test/native/build_sources_other_uid.t new file mode 100644 index 000000000..ea7f3d4c3 --- /dev/null +++ b/xCAT-test/native/build_sources_other_uid.t @@ -0,0 +1,50 @@ +#!/usr/bin/env perl +# mock reads the staged sources as the uid of the target's chrootuid while buildrpms.pl +# runs as root. A process with another uid must read them. CI runs this file as root. +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../../build-utils/lib"; +use File::Path qw(make_path); +use File::Slurper qw(read_text write_text); +use File::Temp qw(tempdir); +use POSIX (); +use Test::More; +use XCAT::BuildUtils qw(build_sources_base prepare_build_sources_dir share_build_sources_dir); + +plan skip_all => 'requires root to switch to another uid' if $> != 0; + +# Flush before the fork below, or the child repeats this file's TAP output on exit. +$| = 1; + +my $checkout = tempdir(CLEANUP => 1); +chmod 0755, $checkout or die "chmod $checkout: $!\n"; +# A root $HOME the build uid cannot enter, as on the build hosts. +make_path("$checkout/root"); +chmod 0550, "$checkout/root" or die "chmod: $!\n"; +local $ENV{HOME} = "$checkout/root"; + +my $umask = umask 077; +my $dir = prepare_build_sources_dir(build_sources_base($checkout), $$, 'hosta'); +make_path("$dir/sub"); +write_text("$dir/xCAT-2.20.0.tar.gz", "payload\n"); +write_text("$dir/sub/xcat.conf", "conf\n"); +share_build_sources_dir($dir); +umask $umask; + +my $pid = fork // die "fork: $!\n"; +if ($pid == 0) { + POSIX::setgid(1000); + POSIX::setuid(1000); + POSIX::_exit(2) if $> != 1000; + my $ok = eval { + opendir(my $dh, $dir) or die; + read_text("$dir/xCAT-2.20.0.tar.gz") eq "payload\n" + && read_text("$dir/sub/xcat.conf") eq "conf\n"; + }; + POSIX::_exit($ok ? 0 : 1); +} +waitpid($pid, 0); +is($? >> 8, 0, 'uid 1000 lists the staging directory and reads every staged file'); + +done_testing; diff --git a/xCAT-test/unit/buildrpms_source_only.t b/xCAT-test/unit/buildrpms_source_only.t index 2868f185d..b528a588e 100644 --- a/xCAT-test/unit/buildrpms_source_only.t +++ b/xCAT-test/unit/buildrpms_source_only.t @@ -51,6 +51,7 @@ our @STAGES; sub createmockconfig { push @main::STAGES, 'createmockconfig'; } sub prepare_mock_resultdirs { push @main::STAGES, 'prepare_mock_resultdirs'; } sub buildsources { push @main::STAGES, 'buildsources'; } + sub share_build_sources_dir { push @main::STAGES, 'share_build_sources_dir'; } sub buildspkgs { push @main::STAGES, 'buildspkgs'; } sub buildpkgs { push @main::STAGES, 'buildpkgs'; } # buildall assigns a staging directory per build process. @@ -129,11 +130,11 @@ sub stages_for { } is_deeply( stages_for(0), - [qw(createmockconfig prepare_mock_resultdirs buildsources buildspkgs buildpkgs)], + [qw(createmockconfig prepare_mock_resultdirs buildsources share_build_sources_dir buildspkgs buildpkgs)], 'a normal run builds the source rpm and then rebuilds it into binaries' ); is_deeply( stages_for(1), - [qw(createmockconfig prepare_mock_resultdirs buildsources buildspkgs)], + [qw(createmockconfig prepare_mock_resultdirs buildsources share_build_sources_dir buildspkgs)], 'a source-only run stops once the source rpm exists' ); ok( !grep( { $_ eq 'buildpkgs' } @{ stages_for(1) } ), diff --git a/xCAT-test/unit/buildrpms_sources_per_package.t b/xCAT-test/unit/buildrpms_sources_per_package.t index 0a97d9380..a55a97efa 100644 --- a/xCAT-test/unit/buildrpms_sources_per_package.t +++ b/xCAT-test/unit/buildrpms_sources_per_package.t @@ -4,6 +4,7 @@ use strict; use warnings; +use Cwd (); use File::Path qw(make_path); use File::Slurper qw(write_text); use File::Temp qw(tempdir); @@ -12,11 +13,23 @@ use lib "$FindBin::Bin/../../build-utils/lib"; use Test::More; use XCAT::BuildUtils qw(build_sources_base build_sources_dir prepare_build_sources_dir - remove_build_sources_dir sweep_build_sources_dirs); + remove_build_sources_dir sweep_build_sources_dirs share_build_sources_dir); +# mock reads the staged sources as the uid of the target's chrootuid, and $HOME of a +# root build is /root, mode 0550. The base belongs to the checkout, not to $HOME. my $home = tempdir(CLEANUP => 1); +local $ENV{HOME} = "$home/home"; my $base = build_sources_base($home); -is($base, "$home/rpmbuild/sources", 'the base directory is under the rpmbuild tree'); +is($base, "$home/dist/sources", 'the base directory is under the dist tree of the checkout'); +unlike($base, qr{^\Q$ENV{HOME}\E/}, 'the base directory is not under $HOME'); +{ + my $cwd = Cwd::getcwd(); + chdir $home or die "chdir $home: $!\n"; + my $default = build_sources_base(); + chdir $cwd or die "chdir $cwd: $!\n"; + is($default, Cwd::abs_path($home) . "/dist/sources", + 'the base directory defaults to the dist tree of the working directory'); +} # Two live processes on one host never have the same pid. The host name keeps two # hosts apart when $HOME is on NFS. @@ -89,4 +102,23 @@ ok(!-e $reaped, 'the default check deletes the directory of a reaped process'); is_deeply([sweep_build_sources_dirs("$home/absent", 'hosta', $is_alive)], [], 'a missing base directory is not an error'); +# Another uid must traverse every directory and read every file, whatever the umask. +{ + my $root = tempdir(CLEANUP => 1); + my $umask = umask 077; + my $sbase = build_sources_base($root); + my $dir = prepare_build_sources_dir($sbase, 401, 'hosta'); + make_path("$dir/sub"); + write_text("$dir/xCAT-2.20.0.tar.gz", "payload\n"); + write_text("$dir/sub/xcat.conf", "conf\n"); + share_build_sources_dir($dir); + umask $umask; + for my $d ("$root/dist", $sbase, $dir, "$dir/sub") { + is((stat $d)[2] & 0055, 0055, "other users can list and enter $d"); + } + for my $f ("$dir/xCAT-2.20.0.tar.gz", "$dir/sub/xcat.conf") { + is((stat $f)[2] & 0044, 0044, "other users can read $f"); + } +} + done_testing; From f71aabd027d19accd7ebbe6f0d6b47c6f1004e4e Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:31:43 -0300 Subject: [PATCH 6/9] fix(xcat-core): mock cannot read the sources staged under /root buildrpms.pl staged each build's sources under $HOME/rpmbuild/sources. mock now builds as the chrootuid of the target, uid 1000 for openEuler 24.03. On the Jenkins agents HOME is /root, mode 0550, so mock --sources failed with "Permission denied" on the staging directory. The staging directories now go under dist/sources in the checkout, next to the mock result directories. The per-process names, the delete on reap and the sweep of dead pids stay the same. buildrpms.pl makes the staging tree readable by every user after it stages the sources, so the umask of the build does not decide what mock can read. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- build-utils/lib/XCAT/BuildUtils.pm | 44 ++++++++++++++++++++++++------ buildrpms.pl | 3 +- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/build-utils/lib/XCAT/BuildUtils.pm b/build-utils/lib/XCAT/BuildUtils.pm index 91e2bb792..4a16b29f2 100644 --- a/build-utils/lib/XCAT/BuildUtils.pm +++ b/build-utils/lib/XCAT/BuildUtils.pm @@ -14,7 +14,9 @@ package XCAT::BuildUtils; use strict; use warnings; use Exporter 'import'; +use Cwd (); use File::Copy qw(copy move); +use File::Find qw(find); use File::Basename qw(basename); use File::Path qw(make_path remove_tree); use File::Temp qw(tempdir tempfile); @@ -34,7 +36,7 @@ our @EXPORT_OK = qw( reprepro_distributions reprepro_options lock_id_for take_build_lock build_sources_base build_sources_dir prepare_build_sources_dir - remove_build_sources_dir sweep_build_sources_dirs + remove_build_sources_dir sweep_build_sources_dirs share_build_sources_dir sh_quote clean_debian_residue git_revision backup_file restore_file sh sh_or_die usage @@ -981,18 +983,19 @@ sub genesis_log_errors { Descriptions: The directory that holds the per-process rpmbuild SOURCES directories. Arguments: - $home - the home directory; defaults to $ENV{HOME} + $checkout - the source checkout; defaults to the working directory Returns: the absolute path of the base directory. =cut #------------------------------------------------------------------------------- +# mock reads the sources as chrootuid, and $HOME of a root build is /root, mode 0550. sub build_sources_base { - my ($home) = @_; - $home = $ENV{HOME} unless defined $home && length $home; - die "build_sources_base: no home directory\n" unless defined $home && length $home; - return "$home/rpmbuild/sources"; + my ($checkout) = @_; + $checkout = Cwd::getcwd() unless defined $checkout && length $checkout; + die "build_sources_base: no checkout directory\n" unless defined $checkout && length $checkout; + return "$checkout/dist/sources"; } #------------------------------------------------------------------------------- @@ -1013,7 +1016,7 @@ Returns: the absolute path of the staging directory. #------------------------------------------------------------------------------- # Keyed by the process that stages. buildrpms.pl forks a child per package and target, # and mock --sources copies the whole directory, so a shared directory lets a peer's tar -# truncate an archive mid-copy. A pid is unique only on one host, and $HOME can be on NFS. +# truncate an archive mid-copy. A pid is unique only on one host, and the checkout can be on NFS. sub build_sources_dir { my ($base, $pid, $host) = @_; die "build_sources_dir: no base directory\n" unless defined $base && length $base; @@ -1047,13 +1050,38 @@ sub prepare_build_sources_dir { my $dir = build_sources_dir(@_); # A directory with this name belongs to a dead process that had the same pid. remove_tree($dir) if -e $dir; - make_path($dir); + chmod 0755, make_path($dir), $dir; die "build_sources_dir: $dir was not created\n" unless -d $dir; return $dir; } #------------------------------------------------------------------------------- +=head3 share_build_sources_dir + +Descriptions: Make a staging directory and its contents readable by every user, + whatever the umask was when they were written. + +Arguments: + $dir - the directory from prepare_build_sources_dir + +Returns: nothing. + +=cut + +#------------------------------------------------------------------------------- +# mock copies --sources into the chroot as chrootuid, not as the uid that staged them. +sub share_build_sources_dir { + my ($dir) = @_; + find({ no_chdir => 1, wanted => sub { + return if -l $_; + my $mode = (lstat $_)[2] & 07777; + chmod $mode | (-d _ ? 0555 : 0444), $_; + } }, $dir); +} + +#------------------------------------------------------------------------------- + =head3 remove_build_sources_dir Descriptions: Delete the staging directory of one build process. diff --git a/buildrpms.pl b/buildrpms.pl index 3c623dd07..b08feee60 100755 --- a/buildrpms.pl +++ b/buildrpms.pl @@ -55,7 +55,7 @@ use File::Temp qw(tempdir tempfile); use FindBin qw($Bin); use lib "$Bin/build-utils/lib"; use XCAT::BuildUtils qw(git_revision source_date_epoch sh sh_or_die usage buildinfo_text - build_sources_base prepare_build_sources_dir remove_build_sources_dir + build_sources_base prepare_build_sources_dir remove_build_sources_dir share_build_sources_dir sweep_build_sources_dirs stage_xcat_probe_sources stage_xcatsn_templates stage_genesis_base_sources write_script read_line targetarch_from_target @@ -528,6 +528,7 @@ sub buildall { createmockconfig($pkg, $target); prepare_mock_resultdirs($pkg, $target); buildsources($pkg, $target); + share_build_sources_dir($SOURCES); buildspkgs($pkg, $target); # --source-only stops here: buildspkgs has produced the src.rpm, and the binary # rebuild is the only thing buildpkgs does. Everything upstream of this point -- From 8898316a23e02094eb50be3dd9f5f8c26dfdb9b5 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:13:39 -0300 Subject: [PATCH 7/9] test(xcat-core): the mock owner ignores sudo and fresh output parents mock loads its configuration as the user that sudo names, so a configuration without chrootuid builds as SUDO_UID. mock_build_owner runs the loader as root and reports uid 0, and prepare_mock_resultdirs then leaves root-owned logs that the build user cannot append to. Under umask 077, prepare_mock_resultdirs creates dist/ as 0700. The build user cannot reach rpms or SRPMS below it. The previous regression changed those parents to 0755 before it called the helper. Add a sudo-caller case and a fresh-tree case to the native test, and a mode check of the new parents to the unit test. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/native/mock_resultdir_owner.t | 45 ++++++++++++++++++- xCAT-test/unit/buildrpms_mock_resultdir_uid.t | 14 ++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/xCAT-test/native/mock_resultdir_owner.t b/xCAT-test/native/mock_resultdir_owner.t index a0a884fe6..6c8a206e2 100644 --- a/xCAT-test/native/mock_resultdir_owner.t +++ b/xCAT-test/native/mock_resultdir_owner.t @@ -19,7 +19,12 @@ plan skip_all => 'requires the mock Python library' # Flush before the forks below, or each child repeats this file's TAP output on exit. $| = 1; +# mock takes its build user from these. Each case below sets the ones it needs. +delete @ENV{qw(SUDO_UID SUDO_GID SUDO_USER USERHELPER_UID PKEXEC_UID)}; + my $conf = tempdir(CLEANUP => 1); +# Under sudo, mock reads its configuration as the caller. +chmod 0755, $conf or die "Cannot make $conf readable: $!\n"; make_path("$conf/templates"); write_text("$conf/templates/owner.tpl", "config_opts['chrootuid'] = 1000\nconfig_opts['chrootgid'] = 1000\n"); @@ -65,7 +70,6 @@ SKIP: { write_text($file, "interrupted\n"); chmod 0644, $file; } - chmod 0755, $tmp, "$tmp/dist", "$tmp/dist/openeuler-24.03-ppc64le"; prepare_mock_resultdirs('included', $conf, @dirs); ok(writable_as(1000, 1000, $_), "uid 1000 can append to $_") for @stale[0 .. $#logs]; @@ -73,6 +77,45 @@ SKIP: { ok(writable_as(1000, 1000, "$_/new.log"), "uid 1000 can create a file in $_") for @dirs; } +SKIP: { + skip 'creating directories for uid 1000 requires root', 2 if $> != 0; + + # A first build under umask 077: the helper creates every directory below the checkout. + my $tmp = tempdir(CLEANUP => 1); + chmod 0755, $tmp or die "Cannot make $tmp traversable: $!\n"; + my @dirs = ("$tmp/dist/openeuler-24.03-ppc64le/rpms", "$tmp/dist/openeuler-24.03-ppc64le/rpms/SRPMS"); + my $umask = umask 077; + prepare_mock_resultdirs('included', $conf, @dirs); + umask $umask; + ok(writable_as(1000, 1000, "$_/new.log"), "uid 1000 can create a file in the fresh $_") for @dirs; +} + +SKIP: { + my $nobody = getpwnam 'nobody'; + skip 'acting as the sudo caller requires root and a nobody user', 8 + if $> != 0 || !defined $nobody; + + # sudo mock with no chrootuid builds as SUDO_UID and the mock group. + local $ENV{SUDO_UID} = $nobody; + local $ENV{SUDO_GID} = (getpwnam 'nobody')[3]; + local $ENV{SUDO_USER} = 'nobody'; + is_deeply([ mock_build_owner('plain', $conf) ], [ $nobody, $mock_gid ], + 'under sudo with no chrootuid, mock builds as the sudo caller and the mock group'); + + my $tmp = tempdir(CLEANUP => 1); + chmod 0755, $tmp or die "Cannot make $tmp traversable: $!\n"; + my @dirs = ("$tmp/dist/openeuler-24.03sp4-x86_64/rpms", "$tmp/dist/openeuler-24.03sp4-x86_64/rpms/SRPMS"); + make_path(@dirs); + my @logs = map { my $d = $_; map { "$d/$_" } qw(build.log root.log state.log) } @dirs; + for my $file (@logs) { + write_text($file, "interrupted\n"); + chmod 0644, $file; + } + prepare_mock_resultdirs('plain', $conf, @dirs); + ok(writable_as($nobody, $mock_gid, $_), "the sudo caller can append to $_") for @logs; + ok(writable_as($nobody, $mock_gid, "$dirs[1]/new.log"), 'the sudo caller can create a file in SRPMS'); +} + done_testing(); # Fork, take the uid and gid, and append one line to $file. diff --git a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t index e0719bfb5..4531b5bcc 100644 --- a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t +++ b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t @@ -97,4 +97,18 @@ XCAT::BuildUtils::prepare_mock_resultdirs($chroot, undef, "$tmp/caller"); is_deeply(\@CHOWNED, [], 'nothing changes owner when mock builds as the caller'); ok(-d "$tmp/caller", 'the directory is still created'); +# A first build under umask 077. mock must reach the result directories through +# the parents the helper creates. The existing checkout keeps its mode. +my $fresh = tempdir(CLEANUP => 1); +my $fresh_mode = (stat $fresh)[2] & 07777; +my $target = "$fresh/dist/openeuler-24.03-ppc64le"; +@owner = (1000, 135); +@CHOWNED = (); +my $umask = umask 077; +XCAT::BuildUtils::prepare_mock_resultdirs($chroot, undef, "$target/rpms", "$target/rpms/SRPMS"); +umask $umask; +is((stat $_)[2] & 07777, 0755, "the new parent $_ is traversable") + for "$fresh/dist", $target; +is((stat $fresh)[2] & 07777, $fresh_mode, 'the existing checkout keeps its mode'); + done_testing(); From a63489fd71e18fba11c58729ba07a29911f12296 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:16:01 -0300 Subject: [PATCH 8/9] fix(xcat-core): the mock owner ignores sudo and fresh output parents mock_build_owner ran mock's loader as root. mock itself loads its configuration as the user that sudo names, so with no chrootuid it builds as SUDO_UID. The helper reported uid 0, and prepare_mock_resultdirs then left root-owned logs in place. As root, the loader now builds the uid manager with mock's own setup_uid_manager() and loads the configuration through run_in_subprocess_without_privileges(), as mock main() does. prepare_mock_resultdirs gives mode 0755 to the parent directories that it creates, because make_path applies the umask. Directories that already exist keep their mode. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- build-utils/lib/XCAT/BuildUtils.pm | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/build-utils/lib/XCAT/BuildUtils.pm b/build-utils/lib/XCAT/BuildUtils.pm index 4a16b29f2..e24d6a760 100644 --- a/build-utils/lib/XCAT/BuildUtils.pm +++ b/build-utils/lib/XCAT/BuildUtils.pm @@ -1196,19 +1196,27 @@ sub mock_config_text { $chroot - the name passed to mock -r $configdir - the mock configuration directory; mock's default when undef - Returns: ($uid, $gid). With no chrootuid, $uid is the caller, as in mock. - $gid is undef when the loader reports none. + Returns: ($uid, $gid). With no chrootuid, $uid is the caller, as in mock: + as root, the user that SUDO_UID, USERHELPER_UID or PKEXEC_UID + names. $gid is undef when the loader reports none. =cut #------------------------------------------------------------------------------- my $MOCK_OWNER_LOADER = <<'PYTHON'; import sys +import os try: from mockbuild.config import MOCKCONFDIR, load_config + from mockbuild.uid import setup_uid_manager except ImportError: sys.exit('mock is not installed: cannot import mockbuild.config') -opts = load_config(sys.argv[2] or MOCKCONFDIR, sys.argv[1]) +args = (sys.argv[2] or MOCKCONFDIR, sys.argv[1]) +if os.geteuid() == 0: + # As mock main(): load the configuration as the user that sudo names. + opts = setup_uid_manager().run_in_subprocess_without_privileges(load_config, *args) +else: + opts = load_config(*args) print(opts['chrootuid'], opts.get('chrootgid', '')) PYTHON @@ -1238,7 +1246,8 @@ sub mock_build_owner { $configdir - the mock configuration directory; mock's default when undef @dirs - the result directories, created when missing - Returns: nothing. Dies when a chown fails. + Returns: nothing. Dies when a chown fails. The parent directories + that this sub creates get mode 0755. =cut @@ -1246,7 +1255,11 @@ sub mock_build_owner { sub prepare_mock_resultdirs { my ($chroot, $configdir, @dirs) = @_; my ($uid, $gid) = mock_build_owner($chroot, $configdir); - make_path(@dirs); + my %resultdir = map { $_ => 1 } @dirs; + # make_path applies the umask, and mock must pass through the new parents. + for my $parent (grep { !$resultdir{$_} } make_path(@dirs)) { + chmod 0755, $parent or die "Cannot make $parent traversable: $!\n"; + } return if $uid == $>; $gid //= -1; for my $dir (@dirs) { From 39c12e119b6fb06ab66dd228c258c7c05ba6f5a7 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:42:47 -0300 Subject: [PATCH 9/9] fix(xcat-core): give the release repository test a stand-in for mock's loader buildall now calls prepare_mock_resultdirs before the cache checks, so a cached build also loads the mock configuration through mockbuild.config. release_repository.t builds xCAT-release in a user namespace on a host without mock, and the build stopped with "mock is not installed: cannot import mockbuild.config". The namespace fixture already stands in for /etc/mock. It now also puts a stand-in mockbuild package on PYTHONPATH. Its load_config fails when the chroot configuration is missing, and reports the caller as chrootuid, as mock does when no chrootuid is set. buildrpms.pl is unchanged. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/native/fixtures/buildrpms-namespace.sh | 2 ++ xCAT-test/native/fixtures/mockbuild/__init__.py | 0 xCAT-test/native/fixtures/mockbuild/config.py | 11 +++++++++++ xCAT-test/native/fixtures/mockbuild/uid.py | 8 ++++++++ 4 files changed, 21 insertions(+) create mode 100644 xCAT-test/native/fixtures/mockbuild/__init__.py create mode 100644 xCAT-test/native/fixtures/mockbuild/config.py create mode 100644 xCAT-test/native/fixtures/mockbuild/uid.py diff --git a/xCAT-test/native/fixtures/buildrpms-namespace.sh b/xCAT-test/native/fixtures/buildrpms-namespace.sh index bd64f49bb..e726fddb0 100644 --- a/xCAT-test/native/fixtures/buildrpms-namespace.sh +++ b/xCAT-test/native/fixtures/buildrpms-namespace.sh @@ -1,6 +1,8 @@ #!/bin/bash set -eu mount --make-rslave / +# mockbuild/ stands in for the mock Python package that the build loads. +export PYTHONPATH="$(dirname "$0")${PYTHONPATH:+:$PYTHONPATH}" etc="$BUILD_FIXTURE/etc" mkdir -p "$etc/mock" "$etc/yum.repos.d" for file in passwd group nsswitch.conf ld.so.cache; do diff --git a/xCAT-test/native/fixtures/mockbuild/__init__.py b/xCAT-test/native/fixtures/mockbuild/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/xCAT-test/native/fixtures/mockbuild/config.py b/xCAT-test/native/fixtures/mockbuild/config.py new file mode 100644 index 000000000..731a080b2 --- /dev/null +++ b/xCAT-test/native/fixtures/mockbuild/config.py @@ -0,0 +1,11 @@ +# Stand-in for mock's configuration loader in the namespace build fixture. +# Without chrootuid, mock builds as the caller. +import os + +MOCKCONFDIR = '/etc/mock' + + +def load_config(config_path, name): + if not os.path.isfile(os.path.join(config_path, name + '.cfg')): + raise SystemExit('no mock configuration ' + name) + return {'chrootuid': os.getuid()} diff --git a/xCAT-test/native/fixtures/mockbuild/uid.py b/xCAT-test/native/fixtures/mockbuild/uid.py new file mode 100644 index 000000000..56ee44b83 --- /dev/null +++ b/xCAT-test/native/fixtures/mockbuild/uid.py @@ -0,0 +1,8 @@ +# Stand-in for mock's uid manager. The namespace has no sudo caller to drop to. +class _UidManager: + def run_in_subprocess_without_privileges(self, func, *args): + return func(*args) + + +def setup_uid_manager(): + return _UidManager()