From 6986b10a0ec47b8bf8b5be724972dc45520eea95 Mon Sep 17 00:00:00 2001 From: Stephen Gallagher Date: Fri, 20 Oct 2023 09:54:19 -0400 Subject: [PATCH] Add rpmautospec plugin This plugin preprocesses spec files using %autorelease and/or %autochangelog in the mock chroot prior to building SRPMs. The `rpmautospec` package will be installed into the chroot if necessary. Co-authored-by: Nils Philippsen Signed-off-by: Stephen Gallagher Signed-off-by: Nils Philippsen --- mock/py/mockbuild/config.py | 11 +- mock/py/mockbuild/plugins/__init__.py | 0 mock/py/mockbuild/plugins/rpmautospec.py | 117 ++++++++++++++ mock/requirements.txt | 1 + mock/tests/__init__.py | 0 mock/tests/compat.py | 12 ++ mock/tests/plugins/__init__.py | 0 mock/tests/plugins/test_rpmautospec.py | 185 +++++++++++++++++++++++ tox.ini | 5 +- 9 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 mock/py/mockbuild/plugins/__init__.py create mode 100644 mock/py/mockbuild/plugins/rpmautospec.py create mode 100644 mock/tests/__init__.py create mode 100644 mock/tests/compat.py create mode 100644 mock/tests/plugins/__init__.py create mode 100644 mock/tests/plugins/test_rpmautospec.py 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..87e3d7789 --- /dev/null +++ b/mock/py/mockbuild/plugins/rpmautospec.py @@ -0,0 +1,117 @@ +# -*- 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 +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): + self.buildroot.pkg_manager.install_as_root(*self.opts["requires"], check=True) + + # 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] # + + 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/compat.py b/mock/tests/compat.py new file mode 100644 index 000000000..f77a6e39d --- /dev/null +++ b/mock/tests/compat.py @@ -0,0 +1,12 @@ +"""Compatibility code for old Python versions:""" + +try: + from contextlib import nullcontext # noqa: F401 +except ImportError: + # Python < 3.7 + from contextlib import contextmanager + + @contextmanager + def nullcontext(enter_result=None): + """Trimmed down version of contextlib.nullcontext()""" + yield enter_result 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..3a390b2be --- /dev/null +++ b/mock/tests/plugins/test_rpmautospec.py @@ -0,0 +1,185 @@ +"""Test the rpmautospec plugin.""" + +from copy import deepcopy +from pathlib import Path +from unittest import mock + +import pytest + +from mockbuild.exception import ConfigError +from mockbuild.plugins import rpmautospec + +from ..compat import nullcontext + +UNSET = object() + + +@mock.patch("mockbuild.plugins.rpmautospec.RpmautospecPlugin") +def test_init(RpmautospecPlugin): # noqa: C0103 + """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", + ), + ) + def test_attempt_process_distgit(self, testcase, tmp_path): # noqa: R0912, R0915 + """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() + + 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 "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 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