diff --git a/mock/docs/site-defaults.cfg b/mock/docs/site-defaults.cfg index f8bd9ddaa..92aefb500 100644 --- a/mock/docs/site-defaults.cfg +++ b/mock/docs/site-defaults.cfg @@ -354,6 +354,14 @@ # config_opts['plugin_conf']['rpkg_preprocessor_opts']['requires'] = ['preproc-rpmspec'] # config_opts['plugin_conf']['rpkg_preprocessor_opts']['cmd'] = '/usr/bin/preproc-rpmspec %(source_spec)s --output %(target_spec)s' +# The rpmautospec plugin is disabled by default and distributed in the separate +# subpackage mock-rpmautospec. +# config_opts['plugin_conf']['rpmautospec_enable'] = True +# config_opts['plugin_conf']['rpmautospec_opts'] = { +# 'requires': ['rpmautospec'], +# 'cmd_base': ['/usr/bin/rpmautospec', 'process-distgit'], +# } + ############################################################################# # # environment for chroot diff --git a/mock/mock.spec b/mock/mock.spec index 64edf9249..35021d630 100644 --- a/mock/mock.spec +++ b/mock/mock.spec @@ -65,6 +65,7 @@ BuildRequires: python%{python3_pkgversion}-devel %if %{with lint} BuildRequires: python%{python3_pkgversion}-pylint %endif +BuildRequires: python%{python3_pkgversion}-rpmautospec-core %if 0%{?fedora} >= 38 # DNF5 stack @@ -132,6 +133,15 @@ Requires: lvm2 Mock plugin that enables using LVM as a backend and support creating snapshots of the buildroot. +%package rpmautospec +Summary: Rpmautospec plugin for mock +Requires: %{name} = %{version}-%{release} +# This lets mock determine if a spec file needs to be processed with rpmautospec. +Requires: python%{python3_pkgversion}-rpmautospec-core + +%description rpmautospec +Mock plugin that preprocesses spec files using rpmautospec. + %package filesystem Summary: Mock filesystem layout Requires(pre): shadow-utils @@ -266,6 +276,10 @@ pylint-3 py/mockbuild/ py/*.py py/mockbuild/plugins/* || : %{python_sitelib}/mockbuild/plugins/lvm_root.* %{python3_sitelib}/mockbuild/plugins/__pycache__/lvm_root.*.py* +%files rpmautospec +%{python_sitelib}/mockbuild/plugins/rpmautospec.* +%{python3_sitelib}/mockbuild/plugins/__pycache__/rpmautospec.*.py* + %files filesystem %license COPYING %dir %{_sysconfdir}/mock diff --git a/mock/py/mockbuild/config.py b/mock/py/mockbuild/config.py index 253a07fe1..406c33e3b 100644 --- a/mock/py/mockbuild/config.py +++ b/mock/py/mockbuild/config.py @@ -29,7 +29,8 @@ PLUGIN_LIST = ['tmpfs', 'root_cache', 'yum_cache', 'mount', 'bind_mount', 'ccache', 'selinux', 'package_state', 'chroot_scan', 'lvm_root', 'compress_logs', 'sign', 'pm_request', - 'hw_info', 'procenv', 'showrc', 'rpkg_preprocessor'] + 'hw_info', 'procenv', 'showrc', 'rpkg_preprocessor', + 'rpmautospec'] def nspawn_supported(): """Detect some situations where the systemd-nspawn chroot code won't work""" @@ -202,6 +203,14 @@ def setup_default_config_opts(): 'requires': ['preproc-rpmspec'], 'cmd': '/usr/bin/preproc-rpmspec %(source_spec)s --output %(target_spec)s', }, + 'rpmautospec_enable': False, + 'rpmautospec_opts': { + 'requires': ['rpmautospec'], + 'cmd_base': [ + '/usr/bin/rpmautospec', + 'process-distgit', + ] + }, } config_opts['environment'] = { diff --git a/mock/py/mockbuild/plugins/__init__.py b/mock/py/mockbuild/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mock/py/mockbuild/plugins/rpmautospec.py b/mock/py/mockbuild/plugins/rpmautospec.py new file mode 100644 index 000000000..82fda40d3 --- /dev/null +++ b/mock/py/mockbuild/plugins/rpmautospec.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# vim:expandtab:autoindent:tabstop=4:shiftwidth=4:filetype=python:textwidth=0: +# License: GPL2 or later see COPYING +# Copyright (C) 2023 Stephen Gallagher +# Copyright (C) 2023 Nils Philippsen +"""A mock plugin to pre-process spec files using rpmautospec.""" + +from pathlib import Path +from typing import Optional, Union + +from rpmautospec_core import specfile_uses_rpmautospec + +from mockbuild.exception import ConfigError, PkgError +from mockbuild.trace_decorator import getLog, traceLog + +requires_api_version = "1.1" + + +@traceLog() +def init(plugins, conf, buildroot): + """Register the rpmautospec plugin with mock.""" + RpmautospecPlugin(plugins, conf, buildroot) + + +class RpmautospecPlugin: + """Fill in release and changelog from git history using rpmautospec""" + + @traceLog() + def __init__(self, plugins, conf, buildroot): + self.buildroot = buildroot + self.config = buildroot.config + self.opts = conf + self.log = getLog() + + if "cmd_base" not in self.opts: + raise ConfigError("The 'rpmautospec_opts.cmd_base' is unset") + + plugins.add_hook("pre_srpm_build", self.attempt_process_distgit) + self.log.info("rpmautospec: initialized") + + @traceLog() + def attempt_process_distgit( + self, + host_chroot_spec: Union[Path, str], + host_chroot_sources: Optional[Union[Path, str]], + ) -> None: + """Attempt to process a spec file with rpmautospec.""" + # Set up variables and check prerequisites. + if not host_chroot_sources: + self.log.debug("Sources not specified, skipping rpmautospec preprocessing.") + return + + host_chroot_spec = Path(host_chroot_spec) + host_chroot_sources = Path(host_chroot_sources) + if not host_chroot_sources.is_dir(): + self.log.debug( + "Sources not a directory, skipping rpmautospec preprocessing." + ) + return + + distgit_git_dir = host_chroot_sources / ".git" + if not distgit_git_dir.is_dir(): + self.log.debug( + "Sources is not a git repository, skipping rpmautospec preprocessing." + ) + return + + host_chroot_sources_spec = host_chroot_sources / host_chroot_spec.name + if not host_chroot_sources_spec.is_file(): + self.log.debug( + "Sources doesn’t contain spec file, skipping rpmautospec preprocessing." + ) + return + + with host_chroot_spec.open("rb") as spec, host_chroot_sources_spec.open( + "rb" + ) as sources_spec: + if spec.read() != sources_spec.read(): + self.log.warning( + "Spec file inside and outside sources are different, skipping rpmautospec" + " preprocessing." + ) + return + + if not specfile_uses_rpmautospec(host_chroot_sources_spec): + self.log.debug( + "Spec file doesn’t use rpmautospec, skipping rpmautospec preprocessing." + ) + return + + # Install the `rpmautospec` command line tool into the build root. + if self.opts.get("requires", None): + try: + self.buildroot.pkg_manager.install_as_root(*self.opts["requires"], check=True) + except Exception as exc: + raise PkgError( + "Can’t install rpmautospec dependencies into chroot: " + + ", ".join(self.opts["requires"]) + ) from exc + + # Get paths inside the chroot by chopping off the leading paths + chroot_dir = Path(self.buildroot.make_chroot_path()) + chroot_spec = Path("/") / host_chroot_spec.relative_to(chroot_dir) + chroot_sources = Path("/") / host_chroot_sources.relative_to(chroot_dir) + chroot_sources_spec = Path("/") / host_chroot_sources_spec.relative_to(chroot_dir) + + # Call subprocess to perform the specfile rewrite + command = list(self.opts["cmd_base"]) + command += [chroot_sources_spec] # + command += [chroot_spec] # + + # Run the rpmautospec tool in the chroot sandbox. This minimizes + # external dependencies in the host, e.g. the Koji build system. As a + # bonus, spec files will be processed in the environment they will be + # built for, reducing the impact of the host system on the outcome, + # leading to more deterministic results and better repeatable builds. + self.buildroot.doChroot( + command, + shell=False, + cwd=chroot_sources, + logger=self.buildroot.build_log, + uid=self.buildroot.chrootuid, + gid=self.buildroot.chrootgid, + user=self.buildroot.chrootuser, + unshare_net=not self.config.get("rpmbuild_networking", False), + nspawn_args=self.config.get("nspawn_args", []), + printOutput=self.config.get("print_main_output", True), + ) diff --git a/mock/requirements.txt b/mock/requirements.txt index 5003c5970..d2ae52edc 100644 --- a/mock/requirements.txt +++ b/mock/requirements.txt @@ -2,4 +2,5 @@ distro jinja2 pyroute2 requests +rpmautospec-core templated-dictionary diff --git a/mock/tests/__init__.py b/mock/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mock/tests/conftest.py b/mock/tests/conftest.py new file mode 100644 index 000000000..2d70b28c2 --- /dev/null +++ b/mock/tests/conftest.py @@ -0,0 +1,14 @@ +"""Common pytest fixtures.""" + +import pytest + + +pytest_version_tuple = tuple(int(piece) for piece in pytest.__version__.split(".")) +if pytest_version_tuple < (3, 9): + # Old versions of pytest don’t have the tmp_path fixture, fill it in here. + from pathlib import Path + + @pytest.fixture + def tmp_path(tmpdir): + """Return temporary directory path object.""" + return Path(tmpdir) diff --git a/mock/tests/plugins/__init__.py b/mock/tests/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mock/tests/plugins/test_rpmautospec.py b/mock/tests/plugins/test_rpmautospec.py new file mode 100644 index 000000000..9108efdec --- /dev/null +++ b/mock/tests/plugins/test_rpmautospec.py @@ -0,0 +1,200 @@ +"""Test the rpmautospec plugin.""" + +from copy import deepcopy +from pathlib import Path +from unittest import mock + +import pytest + +from mockbuild.exception import ConfigError, PkgError +from mockbuild.plugins import rpmautospec +from mockbuild.util import nullcontext + +UNSET = object() + + +@mock.patch("mockbuild.plugins.rpmautospec.RpmautospecPlugin") +def test_init(RpmautospecPlugin): # pylint: disable=invalid-name + """Test the function which registers the plugin.""" + plugins = object() + conf = object() + buildroot = object() + + rpmautospec.init(plugins, conf, buildroot) + + RpmautospecPlugin.assert_called_once_with(plugins, conf, buildroot) + + +class TestRpmautospecPlugin: + """Test the RpmautospecPlugin class.""" + + DEFAULT_OPTS = { + "requires": ["rpmautospec"], + "cmd_base": ["rpmautospec", "process-distgit"], + } + + def create_plugin(self, plugins=UNSET, conf=UNSET, buildroot=UNSET): + """Create a plugin object and prepare it for testing.""" + if plugins is UNSET: + plugins = mock.Mock() + if conf is UNSET: + conf = deepcopy(self.DEFAULT_OPTS) + if buildroot is UNSET: + buildroot = mock.Mock() + + return rpmautospec.RpmautospecPlugin(plugins, conf, buildroot) + + @pytest.mark.parametrize( + "with_cmd_base", (True, False), ids=("with-cmd_base", "without-cmd_base") + ) + @mock.patch("mockbuild.plugins.rpmautospec.getLog") + def test___init__(self, getLog, with_cmd_base): + """Test the constructor.""" + plugins = mock.Mock() + + conf = deepcopy(self.DEFAULT_OPTS) + if with_cmd_base: + expectation = nullcontext() + else: + expectation = pytest.raises(ConfigError) + del conf["cmd_base"] + + buildroot = mock.Mock() + + with expectation as exc_info: + logger = getLog.return_value + plugin = self.create_plugin(plugins=plugins, conf=conf, buildroot=buildroot) + + if with_cmd_base: + assert plugin.buildroot is buildroot + assert plugin.config is buildroot.config + assert plugin.opts is conf + assert plugin.log is logger + plugins.add_hook.assert_called_once_with("pre_srpm_build", plugin.attempt_process_distgit) + logger.info.assert_called_once_with("rpmautospec: initialized") + else: + assert "rpmautospec_opts.cmd_base" in str(exc_info.value) + + @pytest.mark.parametrize( + "testcase", + ( + "happy-path", + "happy-path-no-requires", + "without-sources", + "sources-not-dir", + "sources-not-repo", + "sources-no-specfile", + "spec-files-different", + "specfile-no-rpmautospec", + "broken-requires", + ), + ) + def test_attempt_process_distgit( + self, testcase, tmp_path + ): # pylint: disable=too-many-branches disable=too-many-statements disable=too-many-locals + """Test the attempt_process_distgit() method.""" + # Set the stage + plugin = self.create_plugin() + plugin.log = log = mock.Mock() + plugin.buildroot.make_chroot_path.return_value = str(tmp_path) + if "no-requires" in testcase: + plugin.opts["requires"] = [] + + spec_dir = tmp_path / "SPECS" + spec_dir.mkdir() + sources_dir = tmp_path / "SOURCES" + sources_dir.mkdir() + + host_chroot_spec = spec_dir / "pkg.spec" + host_chroot_sources = sources_dir / "pkg" + host_chroot_sources.mkdir() + host_chroot_sources_git = host_chroot_sources / ".git" + host_chroot_sources_spec = host_chroot_sources / "pkg.spec" + + if "no-rpmautospec" not in testcase: + spec_contents = ( + "Release: %autorelease", + "%changelog", + "%autochangelog", + ) + else: + spec_contents = ( + "Release: 1", + "%changelog", + ) + + with host_chroot_spec.open("w") as fp: + for line in spec_contents: + print(line, file=fp) + + if "without-sources" in testcase: + host_chroot_sources = None + elif "sources-not-dir" not in testcase: + if "sources-no-specfile" not in testcase: + with host_chroot_sources_spec.open("w") as fp: + if "spec-files-different" in testcase: + print("# BOO", file=fp) + for line in spec_contents: + print(line, file=fp) + if "sources-not-repo" not in testcase: + host_chroot_sources_git.mkdir() + else: + host_chroot_sources = tmp_path / "pkg.tar" + host_chroot_sources.touch() + + if "broken-requires" in testcase: + plugin.buildroot.pkg_manager.install_as_root.side_effect = RuntimeError("FAIL") + expect_exception = pytest.raises(PkgError) + else: + expect_exception = nullcontext() + + with expect_exception as excinfo: + plugin.attempt_process_distgit(host_chroot_spec, host_chroot_sources) + + if "happy-path" in testcase: + chroot_spec = Path("/") / host_chroot_spec.relative_to(tmp_path) + chroot_sources = Path("/") / host_chroot_sources.relative_to(tmp_path) + chroot_sources_spec = Path("/") / host_chroot_sources_spec.relative_to(tmp_path) + + expected_command = plugin.opts["cmd_base"] + [chroot_sources_spec, chroot_spec] + + plugin.buildroot.doChroot.assert_called_once_with( + expected_command, + shell=False, + cwd=chroot_sources, + logger=plugin.buildroot.build_log, + uid=plugin.buildroot.chrootuid, + gid=plugin.buildroot.chrootgid, + user=plugin.buildroot.chrootuser, + unshare_net=not plugin.config.get("rpmbuild_networking", False), + nspawn_args=plugin.config.get("nspawn_args", []), + printOutput=plugin.config.get("print_main_output", True), + ) + else: + plugin.buildroot.doChroot.assert_not_called() + if "broken-requires" not in testcase: + if "spec-files-different" in testcase: + log_method = log.warning + else: + log_method = log.debug + log_method.assert_called_once() + log_string = log_method.call_args[0][0] + assert "skipping rpmautospec preprocessing" in log_string + + if "without-sources" in testcase: + assert "Sources not specified" in log_string + elif "sources-not-dir" in testcase: + assert "Sources not a directory" in log_string + elif "sources-not-repo" in testcase: + assert "Sources is not a git repository" in log_string + elif "sources-no-specfile" in testcase: + assert "Sources doesn’t contain spec file" in log_string + elif "spec-files-different" in testcase: + assert "Spec file inside and outside sources are different" in log_string + elif "specfile-no-rpmautospec" in testcase: + assert "Spec file doesn’t use rpmautospec" in log_string + else: + assert str(excinfo.value) == ( + "Can’t install rpmautospec dependencies into chroot: " + + ", ".join(self.DEFAULT_OPTS["requires"]) + ) diff --git a/releng/release-notes-next/rpmautospec-plugin.feature b/releng/release-notes-next/rpmautospec-plugin.feature new file mode 100644 index 000000000..ff1d746ac --- /dev/null +++ b/releng/release-notes-next/rpmautospec-plugin.feature @@ -0,0 +1,4 @@ +A new plugin to pre-process spec files with rpmautospec [has been implemented](PR#1253). + +If this plugin is enabled, mock pre-processes spec files that use rpmautospec features (for +automatic release numbering and changelog generation) before building a source RPM. diff --git a/tox.ini b/tox.ini index 2d458e04f..2775dd093 100644 --- a/tox.ini +++ b/tox.ini @@ -5,9 +5,12 @@ skipsdist = True [testenv] deps = -rmock/requirements.txt + coverage pytest pytest-cov backoff setenv = PYTHONPATH = ./mock/py -commands = python -m pytest -v {posargs} --cov-report term-missing --cov mock/py mock/tests +commands = + python -m pytest -v {posargs} --cov-report term-missing --cov-branch --cov mock/py mock/tests + python -m coverage report --fail-under=100 -m mock/py/mockbuild/plugins/rpmautospec.py