mirror of
https://github.com/peridotbuild/pv2.git
synced 2024-11-25 06:31:26 +00:00
e48a54db3a
* Util Module * Provides: color class (for specialty stdout logging) * Provides: constants classes for rpm, errors, and mock * Provides: error classes for generic error handling and future fault handler * Provides: generic classes for generic, repeatable use cases * Provides: rpmutil with rpm utilities that range from basic to advanced metadata handling * Add mock module * Can generate a usable mock config based on input provided * Can generate mock plugin configuration as provided * cache related plugins are hardcoded as disabled * Supports plugins: chroot scanning, embedding files, bind mounts * Can generate basic dnf configs with repo information * (Currently limited) Error handler * Runs mock commands (such as build, buildsrpm, init, shell) * Add modularity module (very limited, doesn't really do much) * Add peridotpb example (does nothing, will likely be its own thing) * Add MIT license
424 lines
15 KiB
Python
424 lines
15 KiB
Python
# -*- mode:python; coding:utf-8; -*-
|
|
# Louis Abel <label@rockylinux.org>
|
|
"""
|
|
Mock runners and limited error handler
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import logging
|
|
from pv2.util import error as err
|
|
from pv2.util import fileutil
|
|
from pv2.util import constants as const
|
|
from pv2.util import processor
|
|
|
|
__all__ = [
|
|
'MockRunner',
|
|
'MockResult'
|
|
]
|
|
|
|
class MockRunner:
|
|
"""
|
|
Mock runner definitions
|
|
"""
|
|
def __init__(self, config_path: str):
|
|
"""
|
|
Initialize the runner
|
|
"""
|
|
self.logger = logging.getLogger(self.__module__)
|
|
self.config_path = config_path
|
|
|
|
def init(self, resultdir=None, quiet=None, isolation=None, foreground=False):
|
|
"""
|
|
Inits a mock root
|
|
"""
|
|
return self.__run_mock(mock_call='init', resultdir=resultdir,
|
|
quiet=quiet, isolation=isolation,
|
|
foreground=foreground)
|
|
|
|
# pylint: disable=too-many-arguments
|
|
def shell(
|
|
self,
|
|
command: str,
|
|
resultdir=None,
|
|
quiet=None,
|
|
isolation=None,
|
|
foreground=False
|
|
):
|
|
"""
|
|
Runs shell for a given mock root
|
|
"""
|
|
return self.__run_mock(mock_call='shell', mock_arg=command,
|
|
resultdir=resultdir, quiet=quiet,
|
|
isolation=isolation, foreground=foreground)
|
|
|
|
def clean(self, quiet=None, isolation=None, foreground=False):
|
|
"""
|
|
Clean up the mock root
|
|
"""
|
|
try:
|
|
self.__run_mock(mock_call='clean', quiet=quiet,
|
|
isolation=isolation, foreground=foreground)
|
|
except MockErrorResulter as exc:
|
|
self.logger.error('Unable to run clean on %s', self.config_path)
|
|
self.logger.error('Output:\n%s\n', exc)
|
|
|
|
self.__run_mock(mock_call='clean')
|
|
|
|
# pylint: disable=too-many-arguments
|
|
def buildsrpm(
|
|
self,
|
|
spec: str,
|
|
sources: str,
|
|
resultdir=None,
|
|
definitions=None,
|
|
timeout=None,
|
|
quiet=None,
|
|
isolation=None,
|
|
foreground=False
|
|
):
|
|
"""
|
|
Builds a source RPM, but does not actually build the package
|
|
"""
|
|
return self.__run_mock(
|
|
mock_call='buildsrpm',
|
|
spec=spec,
|
|
sources=sources,
|
|
resultdir=resultdir,
|
|
definitions=definitions,
|
|
rpmbuild_timeout=timeout,
|
|
quiet=quiet,
|
|
target='noarch',
|
|
isolation=isolation,
|
|
foreground=foreground
|
|
)
|
|
|
|
# pylint: disable=too-many-arguments
|
|
def build(
|
|
self,
|
|
srpm_path: str,
|
|
resultdir=None,
|
|
definitions=None,
|
|
timeout=None,
|
|
quiet=None,
|
|
isolation=None,
|
|
foreground=False
|
|
):
|
|
"""
|
|
Builds a given source package
|
|
"""
|
|
return self.__run_mock(
|
|
mock_call='rebuild',
|
|
mock_arg=srpm_path,
|
|
resultdir=resultdir,
|
|
rpmbuild_timeout=timeout,
|
|
definitions=definitions,
|
|
quiet=quiet,
|
|
isolation=isolation,
|
|
foreground=foreground
|
|
)
|
|
|
|
def __determine_resultdir(self):
|
|
"""
|
|
Receives no input. This should figure out where the resultdir
|
|
will ultimately be.
|
|
"""
|
|
|
|
mock_debug_args = [
|
|
'mock',
|
|
'--root', self.config_path,
|
|
'--debug-config-expanded'
|
|
]
|
|
|
|
mock_debug_run = processor.run_proc_no_output(command=mock_debug_args)
|
|
regex = r"^config_opts\['resultdir'\] = '(.*)'"
|
|
regex_search = re.search(regex, mock_debug_run.stdout, re.MULTILINE)
|
|
if regex_search:
|
|
return regex_search.group(1)
|
|
|
|
return None
|
|
|
|
# pylint: disable=too-many-locals,too-many-branches
|
|
def __run_mock(
|
|
self,
|
|
mock_call: str,
|
|
mock_arg: str = '',
|
|
resultdir=None,
|
|
foreground=False,
|
|
**kwargs
|
|
):
|
|
"""
|
|
Actually run mock.
|
|
|
|
mock_call should be the command being used (such as rebuild, shell, and
|
|
so on)
|
|
mock_arg is a string, and can be an additional argument (some mock
|
|
commands do not need an additional argument, thus default is an empty
|
|
string)
|
|
kwargs can be any set of additional arguments to add to mock as
|
|
key:value pairs. for example, lets say your function accepts an
|
|
argument like isolation and you set it to 'simple', the kwargs.items()
|
|
block will parse it as `--isolation simple`. if your function does not
|
|
require an argument, and it's not a matter of it being true or false,
|
|
you would send it as argument='' to ensure that an additional list item
|
|
is not added after the argument.
|
|
"""
|
|
# Note: You will notice that everything appears to be separate list
|
|
# items. This is on purpose to try to make sure subprocess is happy.
|
|
# Don't try to simplify it.
|
|
initial_args = [
|
|
'mock',
|
|
'--root', self.config_path,
|
|
f'--{mock_call}', mock_arg
|
|
]
|
|
|
|
if resultdir:
|
|
initial_args.append('--resultdir')
|
|
initial_args.append(resultdir)
|
|
|
|
# As you probably noticed, not all options being sent by the other
|
|
# methods are accounted for, so we are using kwargs to deal with them
|
|
# instead. This is because not all mock commands use the same options
|
|
# (or get the same effects out of them if they can be specified). But
|
|
# we are firm on on the ones that should be set.
|
|
for option, argument in kwargs.items():
|
|
if argument is None:
|
|
continue
|
|
|
|
# If we are sending mock specific macro definitions that are not in
|
|
# the config, this is how you do it. It's expected that definitions
|
|
# is a dict with only key value pairs.
|
|
if option == 'definitions':
|
|
for macro, value in argument.items():
|
|
initial_args.append('--define')
|
|
# Macro definitions require quotes between name and value.
|
|
# DO NOT UNDO THIS.
|
|
initial_args.append(f"'{macro} {value}'")
|
|
# "quiet" is a weird one because it doesn't accept a value in mock.
|
|
# We purposely set it to "None" so it gets passed over (way above).
|
|
# Setting to True will make this flag appear.
|
|
elif option == 'quiet':
|
|
initial_args.append('--quiet')
|
|
elif option == 'isolation':
|
|
if argument in ('simple', 'nspawn', 'simple'):
|
|
initial_args.append('--isolation')
|
|
initial_args.append(str(argument))
|
|
else:
|
|
raise err.ProvidedValueError(f'{argument} is an invalid isolation option.')
|
|
|
|
# If we're not covering the obvious ones above that we need special
|
|
# care for, this is where the rest happens. If an argument is sent
|
|
# with an empty string, it'll just show up as --option. Any
|
|
# argument will make it show up as --option argument.
|
|
else:
|
|
initial_args.append(f'--{option}')
|
|
if len(str(argument)) > 0:
|
|
initial_args.append(str(argument))
|
|
|
|
# Might not need this. This just makes sure our list is in order.
|
|
initial_args = [arg for arg in initial_args if arg]
|
|
mock_command = ' '.join(initial_args)
|
|
self.logger.info('The following mock command will be executed: %s', mock_command)
|
|
|
|
# If foreground is enabled, all output from mock will show up in the
|
|
# user's terminal (or wherever the output is being sent). This means
|
|
# stdout and stderr will NOT contain any data. It may be better to set
|
|
# "quiet" instead of foreground and then stream the actual log files
|
|
# themselves, but this requires you to be specific on the resultdir to
|
|
# find and stream them.
|
|
if foreground:
|
|
mock_run = processor.run_proc_foreground(command=initial_args)
|
|
else:
|
|
mock_run = processor.run_proc_no_output(command=initial_args)
|
|
|
|
# Assign vars based on what happened above.
|
|
mock_config = self.config_path
|
|
exit_code = mock_run.returncode
|
|
stdout = mock_run.stdout
|
|
stderr = mock_run.stderr
|
|
|
|
# If a resultdir wasn't presented, we try to look for it. We do this by
|
|
# running mock's debug commands to get the correct value and regex it
|
|
# out.
|
|
if not resultdir:
|
|
resultdir = self.__determine_resultdir()
|
|
|
|
if exit_code != 0:
|
|
raise MockErrorResulter(
|
|
mock_command,
|
|
exit_code,
|
|
resultdir)
|
|
|
|
return MockResult(
|
|
mock_command,
|
|
mock_config,
|
|
exit_code,
|
|
stdout,
|
|
stderr,
|
|
resultdir)
|
|
|
|
class MockResult:
|
|
"""
|
|
Mock result parser
|
|
"""
|
|
# pylint: disable=too-many-arguments
|
|
def __init__(
|
|
self,
|
|
mock_command,
|
|
mock_config,
|
|
exit_code,
|
|
stdout,
|
|
stderr,
|
|
resultdir=None
|
|
):
|
|
"""
|
|
Initialize the mock result parser
|
|
"""
|
|
self.mock_command = mock_command
|
|
self.mock_config = mock_config
|
|
self.exit_code = exit_code
|
|
self.__stdout = stdout
|
|
self.__stderr = stderr
|
|
self.resultdir = resultdir
|
|
|
|
@property
|
|
def srpm(self):
|
|
"""
|
|
Turns a string (or None) of the build source RPM package
|
|
"""
|
|
return next(iter(fileutil.filter_files(
|
|
self.resultdir,
|
|
lambda file: file.endswith('src.rpm'))),
|
|
None
|
|
)
|
|
|
|
@property
|
|
def rpms(self):
|
|
"""
|
|
Returns a list of RPM package paths in the resultdir.
|
|
"""
|
|
return fileutil.filter_files(
|
|
self.resultdir,
|
|
lambda file: re.search(r'(?<!\.src)\.rpm$', file)
|
|
)
|
|
|
|
@property
|
|
def logs(self):
|
|
"""
|
|
Returns a list of mock log files
|
|
"""
|
|
mock_log_files = fileutil.filter_files(self.resultdir,
|
|
lambda file: file.endswith('.log'))
|
|
|
|
# If we are using the chroot scan plugin, then let's search for other
|
|
# logs that we may have cared about in this build.
|
|
chroot_scan_dir = os.path.join(self.resultdir, 'chroot_scan')
|
|
if os.path.exists(chroot_scan_dir):
|
|
for dir_name, _, files in os.walk(chroot_scan_dir):
|
|
for file in files:
|
|
if file.endswith('.log'):
|
|
mock_log_files.append(os.path.join(os.path.abspath(dir_name), file))
|
|
|
|
return mock_log_files
|
|
|
|
@property
|
|
def stdout(self):
|
|
"""
|
|
Returns stdout
|
|
"""
|
|
return self.__stdout
|
|
|
|
@property
|
|
def stderr(self):
|
|
"""
|
|
Returns stdout
|
|
"""
|
|
return self.__stderr
|
|
|
|
# Is there a better way to do this?
|
|
# Note that this isn't in pv2.util.error because this *may* be used to parse
|
|
# logs at some point, and we do not want to add additional parsers to
|
|
# pv2.util.error or have it import mock modules if it's not actually required.
|
|
# I don't want to have to import more than needed in pv2.util.error.
|
|
class MockErrorResulter(Exception):
|
|
"""
|
|
Mock error result checker.
|
|
|
|
Takes in an exception and reports the exit code.
|
|
"""
|
|
def __init__(
|
|
self,
|
|
mock_command,
|
|
exit_code,
|
|
resultdir=None,
|
|
result_message=None
|
|
):
|
|
"""
|
|
Initialize the MockError class this way.
|
|
"""
|
|
|
|
# We probably don't need to do this, but it doesn't hurt. There should
|
|
# always be a resultdir to reference.
|
|
self.build_log = None
|
|
self.root_log = None
|
|
|
|
if resultdir:
|
|
self.build_log = os.path.join(resultdir, 'build.log')
|
|
self.root_log = os.path.join(resultdir, 'root.log')
|
|
|
|
if not result_message:
|
|
result_message = f'Command {mock_command} exited with code ' \
|
|
f'{exit_code}. Please review build.log and root.log ' \
|
|
f'located in the main root ({resultdir}) or bootstrap root.'
|
|
|
|
# This is awkward. I can't think of a better way to do this.
|
|
if exit_code == const.MockConstants.MOCK_EXIT_ERROR:
|
|
#error_object = errmock(self.root_log, self.build_log)
|
|
#error_dict = error_object.check_for_error()
|
|
|
|
#if len(error_dict) > 0:
|
|
# result_message = f'Command {mock_command} exited with code ' \
|
|
# '{error_dict["error_code"]}: {error_dict["error_message"]}'
|
|
|
|
# pylint: disable=non-parent-init-called
|
|
err.MockGenericError.__init__(self, result_message)
|
|
# Send to log parser to figure out what it actually is, and use the
|
|
# above to report it.
|
|
elif exit_code == const.MockConstants.MOCK_EXIT_SETUID:
|
|
# pylint: disable=non-parent-init-called
|
|
result_message = 'Either setuid/setgid is not available or ' \
|
|
'another error occurred (such as a bootstrap init failure). ' \
|
|
'Please review build.log or root.log, in the main root ' \
|
|
f'({resultdir}) or bootstrap root if applicable.'
|
|
err.MockGenericError.__init__(self, result_message)
|
|
|
|
elif exit_code == const.MockConstants.MOCK_EXIT_INVCONF:
|
|
# pylint: disable=non-parent-init-called
|
|
err.MockInvalidConfError.__init__(self, result_message)
|
|
|
|
elif exit_code == const.MockConstants.MOCK_EXIT_INVARCH:
|
|
# pylint: disable=non-parent-init-called
|
|
err.MockInvalidArchError.__init__(self, result_message)
|
|
|
|
elif exit_code in (const.MockConstants.MOCK_EXIT_DNF_ERROR,
|
|
const.MockConstants.MOCK_EXIT_EXTERNAL_DEP):
|
|
# pylint: disable=non-parent-init-called
|
|
err.MockDnfError.__init__(self, result_message)
|
|
|
|
elif exit_code == const.MockConstants.MOCK_EXIT_RESULTDIR_NOT_CREATED:
|
|
# pylint: disable=non-parent-init-called
|
|
err.MockResultdirError.__init__(self, result_message)
|
|
|
|
elif exit_code in (const.MockConstants.MOCK_EXIT_SIGHUP_RECEIVED,
|
|
const.MockConstants.MOCK_EXIT_SIGPIPE_RECEIVED,
|
|
const.MockConstants.MOCK_EXIT_SIGTERM_RECEIVED):
|
|
# pylint: disable=non-parent-init-called
|
|
err.MockSignalReceivedError.__init__(self, result_message)
|
|
|
|
else:
|
|
result_message = 'An unexpected mock error was caught. Review ' \
|
|
f'stdout/stderr or other logs to determine the issue. ' \
|
|
f'\n\nMock command: {mock_command}'
|
|
# pylint: disable=non-parent-init-called
|
|
err.MockUnexpectedError.__init__(self, result_message)
|