homeserver/ansible_collections/community/general/plugins/modules/packaging/os/copr.py
mg c648a48c4c systemd: sanoid (#346)
Co-authored-by: Michael Grote <michael.grote@posteo.de>
Reviewed-on: mg/ansible#346
Co-authored-by: mg <michael.grote@posteo.de>
Co-committed-by: mg <michael.grote@posteo.de>
2022-04-03 11:04:27 +02:00

492 lines
16 KiB
Python

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2020, Silvie Chlupova <schlupov@redhat.com>
# 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) <schlupov@redhat.com>
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."""
return self.chroot.rsplit('-', 1)[0]
@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('-', 1)
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-8"
distribution = "centos"
elif distribution == "centos":
if version == "stream-8":
version = "8"
elif version == "stream-9":
version = "9"
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()