diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml index 14b63787d..aa23b49fd 100644 --- a/.github/workflows/xcat_test.yml +++ b/.github/workflows/xcat_test.yml @@ -55,6 +55,18 @@ jobs: - name: Check parsed package relations if: ${{ !cancelled() && steps.package-deps.outcome == 'success' }} run: prove xCAT-test/native/package_relations.t + # 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 \ + 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 + fi rpm_lifecycle_test: runs-on: ubuntu-24.04 timeout-minutes: 30 diff --git a/build-utils/lib/XCAT/BuildUtils.pm b/build-utils/lib/XCAT/BuildUtils.pm index e20c29b72..e24d6a760 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 @@ -42,7 +44,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 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 @@ -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. @@ -1153,11 +1181,97 @@ 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 builds as, from mock's own + configuration loader. The loader resolves include() and + the assignments around it as a build does. + + Arguments: + $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: + 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') +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 + +sub mock_build_owner { + 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. The parent directories + that this sub creates get mode 0755. + +=cut + +#------------------------------------------------------------------------------- +sub prepare_mock_resultdirs { + my ($chroot, $configdir, @dirs) = @_; + my ($uid, $gid) = mock_build_owner($chroot, $configdir); + 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) { + 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 fc756a621..b0ac601be 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 @@ -330,6 +330,14 @@ sub createmockconfig { mock_config_text($pkg, "$target$ext", read_text("/etc/mock/$target.cfg"), $SOURCE_DATE_EPOCH)); } +# 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}" : ""; + XCAT::BuildUtils::prepare_mock_resultdirs("$pkg-$target$ext", undef, + "dist/$target/rpms", "dist/$target/rpms/SRPMS"); +} + sub buildsources_genesis_base($) { stage_genesis_base_sources(".", "$SOURCES/xCAT-genesis-base-build-support.tar.bz2", $SOURCE_DATE_EPOCH); @@ -525,7 +533,9 @@ 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); + 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 -- diff --git a/xCAT-test/native/README.md b/xCAT-test/native/README.md index 677dad2be..2761506ea 100644 --- a/xCAT-test/native/README.md +++ b/xCAT-test/native/README.md @@ -112,6 +112,15 @@ 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 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 \ + xCAT-test/native/build_sources_other_uid.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/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/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() diff --git a/xCAT-test/native/mock_resultdir_owner.t b/xCAT-test/native/mock_resultdir_owner.t new file mode 100644 index 000000000..6c8a206e2 --- /dev/null +++ b/xCAT-test/native/mock_resultdir_owner.t @@ -0,0 +1,136 @@ +#!/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; + +# 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"); + +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; + } + + 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; +} + +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. +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 4d0cfc982..4531b5bcc 100644 --- a/xCAT-test/unit/buildrpms_mock_resultdir_uid.t +++ b/xCAT-test/unit/buildrpms_mock_resultdir_uid.t @@ -1,11 +1,26 @@ #!/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 +# 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 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'; @@ -13,6 +28,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 +49,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 +67,48 @@ 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'); +# 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'); + +# 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(); diff --git a/xCAT-test/unit/buildrpms_source_only.t b/xCAT-test/unit/buildrpms_source_only.t index 54ebe5c27..b528a588e 100644 --- a/xCAT-test/unit/buildrpms_source_only.t +++ b/xCAT-test/unit/buildrpms_source_only.t @@ -49,7 +49,9 @@ 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 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. @@ -128,11 +130,11 @@ sub stages_for { } is_deeply( stages_for(0), - [qw(createmockconfig 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 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;