debian-koji/plugins/builder/runroot.py
Tomas Kopecek 5f9fbf660e runroot: use --chroot instead of chroot mock command
Former is no officially supported anymore

Fixes: https://pagure.io/koji/issue/3133
2021-11-18 11:05:34 +01:00

344 lines
14 KiB
Python

# kojid plugin
from __future__ import absolute_import
import glob
import os
import platform
import re
import subprocess
import six.moves.configparser
import koji
import koji.tasks
from __main__ import BuildRoot
from koji.daemon import log_output
from koji.tasks import scan_mounts
from koji.util import isSuccess, parseStatus
__all__ = ('RunRootTask',)
CONFIG_FILE = '/etc/kojid/plugins/runroot.conf'
class RunRootTask(koji.tasks.BaseTaskHandler):
Methods = ['runroot']
_taskWeight = 2.0
def __init__(self, *args, **kwargs):
self._read_config()
return super(RunRootTask, self).__init__(*args, **kwargs)
def _get_path_params(self, path, rw=False):
found = False
for mount_data in self.config['paths']:
if path.startswith(mount_data['mountpoint']):
found = True
break
if not found:
raise koji.GenericError("bad config: missing corresponding mountpoint")
options = []
for o in mount_data['options'].split(','):
if rw and o == 'ro':
options.append('rw')
else:
options.append(o)
rel_path = path[len(mount_data['mountpoint']):]
rel_path = rel_path[1:] if rel_path.startswith('/') else rel_path
res = (os.path.join(mount_data['path'], rel_path), path, mount_data['fstype'],
','.join(options))
return res
def _read_config(self):
cp = koji.read_config_files(CONFIG_FILE)
self.config = {
'default_mounts': [],
'safe_roots': [],
'path_subs': [],
'paths': [],
'internal_dev_setup': None,
}
# main options
if cp.has_option('runroot', 'internal_dev_setup'):
self.config['internal_dev_setup'] = cp.getboolean('runroot', 'internal_dev_setup')
# path options
if cp.has_option('paths', 'default_mounts'):
self.config['default_mounts'] = cp.get('paths', 'default_mounts').split(',')
if cp.has_option('paths', 'safe_roots'):
self.config['safe_roots'] = cp.get('paths', 'safe_roots').split(',')
if cp.has_option('paths', 'path_subs'):
self.config['path_subs'] = []
for line in cp.get('paths', 'path_subs').splitlines():
line = line.strip()
if not line:
continue
sub = line.split(',')
if len(sub) != 2:
raise koji.GenericError('bad runroot substitution: %s' % sub)
self.config['path_subs'].append(sub)
# path section are in form 'path%d' while order is important as some
# paths can be mounted inside other mountpoints
path_sections = [p for p in cp.sections() if re.match(r'path\d+', p)]
for section_name in sorted(path_sections, key=lambda x: int(x[4:])):
try:
self.config['paths'].append({
'mountpoint': cp.get(section_name, 'mountpoint'),
'path': cp.get(section_name, 'path'),
'fstype': cp.get(section_name, 'fstype'),
'options': cp.get(section_name, 'options'),
})
except six.moves.configparser.NoOptionError:
raise koji.GenericError("bad config: missing options in %s section" % section_name)
for path in self.config['default_mounts'] + self.config['safe_roots'] + \
[x[0] for x in self.config['path_subs']]:
if not path.startswith('/'):
raise koji.GenericError(
"bad config: all paths (default_mounts, safe_roots, path_subs) needs to be "
"absolute: %s" % path)
def handler(self, root, arch, command, keep=False, packages=[], mounts=[], repo_id=None,
skip_setarch=False, weight=None, upload_logs=None, new_chroot=None):
"""Create a buildroot and run a command (as root) inside of it
Command may be a string or a list.
Returns a message indicating success if the command was successful, and
raises an error otherwise. Command output will be available in
runroot.log in the task output directory on the hub.
skip_setarch is a rough approximation of an old hack
the keep option is not used. keeping for compatibility for now...
upload_logs is list of absolute paths which will be uploaded for
archiving on hub. It always consists of /tmp/runroot.log, but can be
used for additional logs (pungi.log, etc.)
"""
if weight is not None:
weight = max(weight, 0.5)
self.session.host.setTaskWeight(self.id, weight)
# noarch is funny
if arch == "noarch":
# we need a buildroot arch. Pick one that:
# a) this host can handle
# b) the build tag can support
# c) is canonical
host_arches = self.session.host.getHost()['arches']
if not host_arches:
raise koji.BuildError("No arch list for this host")
tag_arches = self.session.getBuildConfig(root)['arches']
if not tag_arches:
raise koji.BuildError("No arch list for tag: %s" % root)
# index canonical host arches
host_arches = set([koji.canonArch(a) for a in host_arches.split()])
# pick the first suitable match from tag's archlist
for br_arch in tag_arches.split():
br_arch = koji.canonArch(br_arch)
if br_arch in host_arches:
# we're done
break
else:
# no overlap
raise koji.BuildError(
"host does not match tag arches: %s (%s)" % (root, tag_arches))
else:
br_arch = arch
if repo_id:
repo_info = self.session.repoInfo(repo_id, strict=True)
if repo_info['tag_name'] != root:
raise koji.BuildError(
"build tag (%s) does not match repo tag (%s)" % (root, repo_info['tag_name']))
if repo_info['state'] not in (koji.REPO_STATES['READY'], koji.REPO_STATES['EXPIRED']):
raise koji.BuildError(
"repos in the %s state may not be used by runroot" %
koji.REPO_STATES[repo_info['state']])
else:
repo_info = self.session.getRepo(root)
if not repo_info:
# wait for it
task_id = self.session.host.subtask(method='waitrepo',
arglist=[root, None, None],
parent=self.id)
repo_info = self.wait(task_id)[task_id]
broot = BuildRoot(self.session, self.options, root, br_arch, self.id,
repo_id=repo_info['id'], setup_dns=True,
internal_dev_setup=self.config['internal_dev_setup'])
broot.workdir = self.workdir
broot.init()
rootdir = broot.rootdir()
# workaround for rpm oddness
for f in glob.glob(os.path.join(rootdir, '/var/lib/rpm/__db*')):
try:
os.unlink(f)
except OSError:
pass
# update buildroot state (so that updateBuildRootList() will work)
self.session.host.setBuildRootState(broot.id, 'BUILDING')
try:
if packages:
# pkglog = '%s/%s' % (broot.resultdir(), 'packages.log')
pkgcmd = ['--install'] + packages
status = broot.mock(pkgcmd)
self.session.host.updateBuildRootList(broot.id, broot.getPackageList())
if not isSuccess(status):
raise koji.BuildrootError(parseStatus(status, pkgcmd))
if isinstance(command, str):
cmdstr = command
else:
# we were passed an arglist
# we still have to run this through the shell (for redirection)
# but we can preserve the list structure precisely with careful escaping
cmdstr = ' '.join(["'%s'" % arg.replace("'", r"'\''") for arg in command])
# A nasty hack to put command output into its own file until mock can be
# patched to do something more reasonable than stuff everything into build.log
cmdargs = ['/bin/sh', '-c',
"{ %s; } < /dev/null 2>&1 | /usr/bin/tee /builddir/runroot.log; exit "
"${PIPESTATUS[0]}" % cmdstr]
# always mount /mnt/redhat (read-only)
# always mount /mnt/iso (read-only)
# also need /dev bind mount
self.do_mounts(rootdir,
[self._get_path_params(x) for x in self.config['default_mounts']])
self.do_extra_mounts(rootdir, mounts)
mock_cmd = []
if new_chroot:
mock_cmd.append('--new-chroot')
elif new_chroot is False: # None -> no option added
mock_cmd.append('--old-chroot')
if skip_setarch:
# we can't really skip it, but we can set it to the current one instead of of the
# chroot one
myarch = platform.uname()[5]
mock_cmd.extend(['--arch', myarch])
mock_cmd.extend(['--chroot', '--'])
mock_cmd.extend(cmdargs)
rv = broot.mock(mock_cmd)
log_paths = ['builddir/runroot.log']
if upload_logs is not None:
log_paths += upload_logs
for log_path in log_paths:
self.uploadFile(os.path.join(rootdir, log_path))
finally:
# mock should umount its mounts, but it will not handle ours
self.undo_mounts(rootdir, fatal=False)
broot.expire()
if isinstance(command, str):
cmdlist = command.split()
else:
cmdlist = command
cmdlist = [param for param in cmdlist if '=' not in param]
if cmdlist:
cmd = os.path.basename(cmdlist[0])
else:
cmd = '(none)'
if isSuccess(rv):
return '%s completed successfully' % cmd
else:
raise koji.BuildrootError(parseStatus(rv, cmd))
def do_extra_mounts(self, rootdir, mounts):
mnts = []
for mount in mounts:
mount = os.path.normpath(mount)
for safe_root in self.config['safe_roots']:
if mount.startswith(safe_root):
break
else:
# no match
raise koji.GenericError("read-write mount point is not safe: %s" % mount)
# normpath should have removed any .. dirs, but just in case...
if mount.find('/../') != -1:
raise koji.GenericError("read-write mount point is not safe: %s" % mount)
for rep, sub in self.config['path_subs']:
mount = mount.replace(rep, sub)
mnts.append(self._get_path_params(mount, rw=True))
self.do_mounts(rootdir, mnts)
def do_mounts(self, rootdir, mounts):
if not mounts:
return
self.logger.info('New runroot')
self.logger.info("Runroot mounts: %s" % mounts)
fn = '%s/tmp/runroot_mounts' % rootdir
with koji._open_text_file(fn, 'at') as fslog:
logfile = "%s/do_mounts.log" % self.workdir
uploadpath = self.getUploadDir()
error = None
for dev, path, type, opts in mounts:
if not path.startswith('/'):
raise koji.GenericError("invalid mount point: %s" % path)
mpoint = "%s%s" % (rootdir, path)
if opts is None:
opts = []
else:
opts = opts.split(',')
if 'bind' in opts:
# make sure dir exists
if not os.path.isdir(dev):
error = koji.GenericError("No such directory or mount: %s" % dev)
break
type = 'none'
if 'bg' in opts:
error = koji.GenericError("bad config: background mount not allowed")
break
opts = ','.join(opts)
cmd = ['mount', '-t', type, '-o', opts, dev, mpoint]
self.logger.info("Mount command: %r" % cmd)
koji.ensuredir(mpoint)
status = log_output(self.session, cmd[0], cmd, logfile, uploadpath,
logerror=True, append=True)
if not isSuccess(status):
error = koji.GenericError("Unable to mount %s: %s"
% (mpoint, parseStatus(status, cmd)))
break
fslog.write("%s\n" % mpoint)
fslog.flush()
if error is not None:
self.undo_mounts(rootdir, fatal=False)
raise error
def undo_mounts(self, rootdir, fatal=True):
self.logger.debug("Unmounting runroot mounts")
mounts = set()
fn = '%s/tmp/runroot_mounts' % rootdir
if os.path.exists(fn):
with koji._open_text_file(fn) as fslog:
for line in fslog.readlines():
mounts.add(line.strip())
# also, check /proc/mounts just in case
mounts |= set(scan_mounts(rootdir))
mounts = sorted(mounts)
# deeper directories first
mounts.reverse()
failed = []
self.logger.info("Unmounting (runroot): %s" % mounts)
for dir in mounts:
proc = subprocess.Popen(["umount", "-l", dir],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.wait() != 0:
output = proc.stdout.read()
output += proc.stderr.read()
failed.append("%s: %s" % (dir, output))
if failed:
msg = "Unable to unmount: %s" % ', '.join(failed)
self.logger.warning(msg)
if fatal:
raise koji.GenericError(msg)
else:
# remove the mount list when everything is unmounted
try:
os.unlink(fn)
except OSError:
pass