diff --git a/plugins/modules/copr.py b/plugins/modules/copr.py new file mode 120000 index 0000000000..f98edc454a --- /dev/null +++ b/plugins/modules/copr.py @@ -0,0 +1 @@ +./packaging/os/copr.py \ No newline at end of file diff --git a/plugins/modules/packaging/os/copr.py b/plugins/modules/packaging/os/copr.py new file mode 100644 index 0000000000..4bf665e045 --- /dev/null +++ b/plugins/modules/packaging/os/copr.py @@ -0,0 +1,490 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Silvie Chlupova +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: copr +short_description: Manage one of the Copr repositories +version_added: 2.0.0 +description: This module can enable, disable or remove the specified repository. +author: Silvie Chlupova (@schlupov) +requirements: + - dnf + - dnf-plugins-core +notes: + - Supports C(check_mode). +options: + host: + description: The Copr host to work with. + default: copr.fedorainfracloud.org + type: str + protocol: + description: This indicate which protocol to use with the host. + default: https + type: str + name: + description: Copr directory name, for example C(@copr/copr-dev). + required: true + type: str + state: + description: + - Whether to set this project as C(enabled), C(disabled) or C(absent). + default: enabled + type: str + choices: [absent, enabled, disabled] + chroot: + description: + - The name of the chroot that you want to enable/disable/remove in the project, + for example C(epel-7-x86_64). Default chroot is determined by the operating system, + version of the operating system, and architecture on which the module is run. + type: str +""" + +EXAMPLES = r""" +- name: Enable project Test of the user schlupov + community.general.copr: + host: copr.fedorainfracloud.org + state: enabled + name: schlupov/Test + chroot: fedora-31-x86_64 + +- name: Remove project integration_tests of the group copr + community.general.copr: + state: absent + name: '@copr/integration_tests' +""" + +RETURN = r""" +repo_filename: + description: The name of the repo file in which the copr project information is stored. + returned: success + type: str + sample: _copr:copr.fedorainfracloud.org:group_copr:integration_tests.repo + +repo: + description: Path to the project on the host. + returned: success + type: str + sample: copr.fedorainfracloud.org/group_copr/integration_tests +""" + +import stat +import os +import traceback + +try: + import dnf + import dnf.cli + import dnf.repodict + from dnf.conf import Conf + HAS_DNF_PACKAGES = True +except ImportError: + DNF_IMP_ERR = traceback.format_exc() + HAS_DNF_PACKAGES = False + +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils import distro # pylint: disable=import-error +from ansible.module_utils.basic import AnsibleModule # pylint: disable=import-error +from ansible.module_utils.urls import open_url # pylint: disable=import-error + + +class CoprModule(object): + """The class represents a copr module. + + The class contains methods that take care of the repository state of a project, + whether the project is enabled, disabled or missing. + """ + + ansible_module = None + + def __init__(self, host, name, state, protocol, chroot=None, check_mode=False): + self.host = host + self.name = name + self.state = state + self.chroot = chroot + self.protocol = protocol + self.check_mode = check_mode + if not chroot: + self.chroot = self.chroot_conf() + else: + self.chroot = chroot + self.get_base() + + @property + def short_chroot(self): + """str: Chroot (distribution-version-architecture) shorten to distribution-version.""" + chroot_parts = self.chroot.split("-") + return "{0}-{1}".format(chroot_parts[0], chroot_parts[1]) + + @property + def arch(self): + """str: Target architecture.""" + chroot_parts = self.chroot.split("-") + return chroot_parts[-1] + + @property + def user(self): + """str: Copr user (this can also be the name of the group).""" + return self._sanitize_username(self.name.split("/")[0]) + + @property + def project(self): + """str: The name of the copr project.""" + return self.name.split("/")[1] + + @classmethod + def need_root(cls): + """Check if the module was run as root.""" + if os.geteuid() != 0: + cls.raise_exception("This command has to be run under the root user.") + + @classmethod + def get_base(cls): + """Initialize the configuration from dnf. + + Returns: + An instance of the BaseCli class. + """ + cls.base = dnf.cli.cli.BaseCli(Conf()) + return cls.base + + @classmethod + def raise_exception(cls, msg): + """Raise either an ansible exception or a python exception. + + Args: + msg: The message to be displayed when an exception is thrown. + """ + if cls.ansible_module: + raise cls.ansible_module.fail_json(msg=msg, changed=False) + raise Exception(msg) + + def _get(self, chroot): + """Send a get request to the server to obtain the necessary data. + + Args: + chroot: Chroot in the form of distribution-version. + + Returns: + Info about a repository and status code of the get request. + """ + repo_info = None + url = "{0}://{1}/coprs/{2}/repo/{3}/dnf.repo?arch={4}".format( + self.protocol, self.host, self.name, chroot, self.arch + ) + try: + r = open_url(url) + status_code = r.getcode() + repo_info = r.read().decode("utf-8") + except HTTPError as e: + status_code = e.getcode() + return repo_info, status_code + + def _download_repo_info(self): + """Download information about the repository. + + Returns: + Information about the repository. + """ + distribution, version = self.short_chroot.split("-") + chroot = self.short_chroot + while True: + repo_info, status_code = self._get(chroot) + if repo_info: + return repo_info + if distribution == "rhel": + chroot = "centos-stream" + distribution = "centos" + elif distribution == "centos": + if version == "stream": + version = "8" + chroot = "epel-{0}".format(version) + distribution = "epel" + else: + if str(status_code) != "404": + self.raise_exception( + "This repository does not have any builds yet so you cannot enable it now." + ) + else: + self.raise_exception( + "Chroot {0} does not exist in {1}".format(self.chroot, self.name) + ) + + def _enable_repo(self, repo_filename_path, repo_content=None): + """Write information to a repo file. + + Args: + repo_filename_path: Path to repository. + repo_content: Repository information from the host. + + Returns: + True, if the information in the repo file matches that stored on the host, + False otherwise. + """ + if not repo_content: + repo_content = self._download_repo_info() + if self._compare_repo_content(repo_filename_path, repo_content): + return False + if not self.check_mode: + with open(repo_filename_path, "w+") as file: + file.write(repo_content) + os.chmod( + repo_filename_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH, + ) + return True + + def _get_repo_with_old_id(self): + """Try to get a repository with the old name.""" + repo_id = "{0}-{1}".format(self.user, self.project) + if repo_id in self.base.repos and "_copr" in self.base.repos[repo_id].repofile: + file_name = self.base.repos[repo_id].repofile.split("/")[-1] + try: + copr_hostname = file_name.rsplit(":", 2)[0].split(":", 1)[1] + if copr_hostname != self.host: + return None + return file_name + except IndexError: + return file_name + return None + + def _read_all_repos(self, repo_id=None): + """The method is used to initialize the base variable by + repositories using the RepoReader class from dnf. + + Args: + repo_id: Repo id of the repository we want to work with. + """ + reader = dnf.conf.read.RepoReader(self.base.conf, None) + for repo in reader: + try: + if repo_id: + if repo.id == repo_id: + self.base.repos.add(repo) + break + else: + self.base.repos.add(repo) + except dnf.exceptions.ConfigError as e: + self.raise_exception(str(e)) + + def _get_copr_repo(self): + """Return one specific repository from all repositories on the system. + + Returns: + The repository that a user wants to enable, disable, or remove. + """ + repo_id = "copr:{0}:{1}:{2}".format(self.host, self.user, self.project) + if repo_id not in self.base.repos: + if self._get_repo_with_old_id() is None: + return None + return self.base.repos[repo_id] + + def _disable_repo(self, repo_filename_path): + """Disable the repository. + + Args: + repo_filename_path: Path to repository. + + Returns: + False, if the repository is already disabled on the system, + True otherwise. + """ + self._read_all_repos() + repo = self._get_copr_repo() + if repo is None: + if self.check_mode: + return True + self._enable_repo(repo_filename_path) + self._read_all_repos("copr:{0}:{1}:{2}".format(self.host, self.user, self.project)) + repo = self._get_copr_repo() + for repo_id in repo.cfg.sections(): + repo_content_api = self._download_repo_info() + with open(repo_filename_path, "r") as file: + repo_content_file = file.read() + if repo_content_file != repo_content_api: + if not self.resolve_differences( + repo_content_file, repo_content_api, repo_filename_path + ): + return False + if not self.check_mode: + self.base.conf.write_raw_configfile( + repo.repofile, repo_id, self.base.conf.substitutions, {"enabled": "0"}, + ) + return True + + def resolve_differences(self, repo_content_file, repo_content_api, repo_filename_path): + """Detect differences between the contents of the repository stored on the + system and the information about the repository on the server. + + Args: + repo_content_file: The contents of the repository stored on the system. + repo_content_api: The information about the repository from the server. + repo_filename_path: Path to repository. + + Returns: + False, if the contents of the repo file and the information on the server match, + True otherwise. + """ + repo_file_lines = repo_content_file.split("\n") + repo_api_lines = repo_content_api.split("\n") + repo_api_lines.remove("enabled=1") + if "enabled=0" in repo_file_lines: + repo_file_lines.remove("enabled=0") + if " ".join(repo_api_lines) == " ".join(repo_file_lines): + return False + if not self.check_mode: + os.remove(repo_filename_path) + self._enable_repo(repo_filename_path, repo_content_api) + else: + repo_file_lines.remove("enabled=1") + if " ".join(repo_api_lines) != " ".join(repo_file_lines): + if not self.check_mode: + os.remove(repo_filename_path) + self._enable_repo(repo_filename_path, repo_content_api) + return True + + def _remove_repo(self): + """Remove the required repository. + + Returns: + True, if the repository has been removed, False otherwise. + """ + self._read_all_repos() + repo = self._get_copr_repo() + if not repo: + return False + if not self.check_mode: + try: + os.remove(repo.repofile) + except OSError as e: + self.raise_exception(str(e)) + return True + + def run(self): + """The method uses methods of the CoprModule class to change the state of the repository. + + Returns: + Dictionary with information that the ansible module displays to the user at the end of the run. + """ + self.need_root() + state = dict() + repo_filename = "_copr:{0}:{1}:{2}.repo".format(self.host, self.user, self.project) + state["repo"] = "{0}/{1}/{2}".format(self.host, self.user, self.project) + state["repo_filename"] = repo_filename + repo_filename_path = "{0}/_copr:{1}:{2}:{3}.repo".format( + self.base.conf.get_reposdir, self.host, self.user, self.project + ) + if self.state == "enabled": + enabled = self._enable_repo(repo_filename_path) + state["msg"] = "enabled" + state["state"] = bool(enabled) + elif self.state == "disabled": + disabled = self._disable_repo(repo_filename_path) + state["msg"] = "disabled" + state["state"] = bool(disabled) + elif self.state == "absent": + removed = self._remove_repo() + state["msg"] = "absent" + state["state"] = bool(removed) + return state + + @staticmethod + def _compare_repo_content(repo_filename_path, repo_content_api): + """Compare the contents of the stored repository with the information from the server. + + Args: + repo_filename_path: Path to repository. + repo_content_api: The information about the repository from the server. + + Returns: + True, if the information matches, False otherwise. + """ + if not os.path.isfile(repo_filename_path): + return False + with open(repo_filename_path, "r") as file: + repo_content_file = file.read() + return repo_content_file == repo_content_api + + @staticmethod + def chroot_conf(): + """Obtain information about the distribution, version, and architecture of the target. + + Returns: + Chroot info in the form of distribution-version-architecture. + """ + (distribution, version, codename) = distro.linux_distribution(full_distribution_name=False) + base = CoprModule.get_base() + return "{0}-{1}-{2}".format(distribution, version, base.conf.arch) + + @staticmethod + def _sanitize_username(user): + """Modify the group name. + + Args: + user: User name. + + Returns: + Modified user name if it is a group name with @. + """ + if user[0] == "@": + return "group_{0}".format(user[1:]) + return user + + +def run_module(): + """The function takes care of the functioning of the whole ansible copr module.""" + module_args = dict( + host=dict(type="str", default="copr.fedorainfracloud.org"), + protocol=dict(type="str", default="https"), + name=dict(type="str", required=True), + state=dict(type="str", choices=["enabled", "disabled", "absent"], default="enabled"), + chroot=dict(type="str"), + ) + module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) + params = module.params + + if not HAS_DNF_PACKAGES: + module.fail_json(msg=missing_required_lib("dnf"), exception=DNF_IMP_ERR) + + CoprModule.ansible_module = module + copr_module = CoprModule( + host=params["host"], + name=params["name"], + state=params["state"], + protocol=params["protocol"], + chroot=params["chroot"], + check_mode=module.check_mode, + ) + state = copr_module.run() + + info = "Please note that this repository is not part of the main distribution" + + if params["state"] == "enabled" and state["state"]: + module.exit_json( + changed=state["state"], + msg=state["msg"], + repo=state["repo"], + repo_filename=state["repo_filename"], + info=info, + ) + module.exit_json( + changed=state["state"], + msg=state["msg"], + repo=state["repo"], + repo_filename=state["repo_filename"], + ) + + +def main(): + """Launches ansible Copr module.""" + run_module() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/copr/aliases b/tests/integration/targets/copr/aliases new file mode 100644 index 0000000000..0ad5e1c80c --- /dev/null +++ b/tests/integration/targets/copr/aliases @@ -0,0 +1,5 @@ +shippable/posix/group1 +needs/root +skip/macos +skip/osx +skip/freebsd diff --git a/tests/integration/targets/copr/tasks/main.yml b/tests/integration/targets/copr/tasks/main.yml new file mode 100644 index 0000000000..32ce67208d --- /dev/null +++ b/tests/integration/targets/copr/tasks/main.yml @@ -0,0 +1,64 @@ +--- +- when: ansible_distribution == 'Fedora' + block: + - name: enable copr project + copr: + host: copr.fedorainfracloud.org + state: enabled + name: '@copr/integration_tests' + chroot: centos-stream-x86_64 + register: result + + - name: assert that the copr project was enabled + assert: + that: + - 'result is changed' + - result.msg == 'enabled' + - result.info == 'Please note that this repository is not part of the main distribution' + + - name: enable copr project + check_mode: yes + copr: + state: enabled + name: '@copr/integration_tests' + chroot: centos-stream-x86_64 + register: result + + - name: assert that the copr project was enabled + assert: + that: + - result is not changed + - result.msg == 'enabled' + + - name: remove copr project + copr: + state: absent + name: '@copr/integration_tests' + register: result + + - name: assert that the copr project was removed + assert: + that: + - 'result is changed' + - result.msg == 'absent' + + - name: disable copr project + copr: + state: disabled + name: '@copr/integration_tests' + chroot: centos-stream-x86_64 + register: result + + - name: assert that the copr project was disabled + assert: + that: + - 'result is changed' + - result.msg == 'disabled' + + always: + - name: clean up + copr: + host: copr.fedorainfracloud.org + state: absent + name: '@copr/integration_tests' + chroot: centos-stream-x86_64