diff --git a/lib/ansible/module_utils/yumdnf.py b/lib/ansible/module_utils/yumdnf.py new file mode 100644 index 00000000000..0d53914462a --- /dev/null +++ b/lib/ansible/module_utils/yumdnf.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# +# # Copyright: (c) 2012, Red Hat, Inc +# Written by Seth Vidal +# Contributing Authors: +# - Ansible Core Team +# - Eduard Snesarev (@verm666) +# - Berend De Schouwer (@berenddeschouwer) +# - Abhijeet Kasurde (@Akasurde) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import os +import tempfile +from abc import ABCMeta, abstractmethod + +from ansible.module_utils._text import to_native +from ansible.module_utils.six import with_metaclass + +yumdnf_argument_spec = dict( + argument_spec=dict( + allow_downgrade=dict(type='bool', default=False), + autoremove=dict(type='bool', default=False), + bugfix=dict(required=False, type='bool', default=False), + conf_file=dict(type='str'), + disable_excludes=dict(type='str', default=None, choices=['all', 'main', 'repoid']), + disable_gpg_check=dict(type='bool', default=False), + disable_plugin=dict(type='list', default=[]), + disablerepo=dict(type='list', default=[]), + download_only=dict(type='bool', default=False), + enable_plugin=dict(type='list', default=[]), + enablerepo=dict(type='list', default=[]), + exclude=dict(type='list', default=[]), + installroot=dict(type='str', default="/"), + install_repoquery=dict(type='bool', default=True), + list=dict(type='str'), + name=dict(type='list', aliases=['pkg'], default=[]), + releasever=dict(default=None), + security=dict(type='bool', default=False), + skip_broken=dict(type='bool', default=False), + # removed==absent, installed==present, these are accepted as aliases + state=dict(type='str', default='present', choices=['absent', 'installed', 'latest', 'present', 'removed']), + update_cache=dict(type='bool', default=False, aliases=['expire-cache']), + update_only=dict(required=False, default="no", type='bool'), + validate_certs=dict(type='bool', default=True), + # this should not be needed, but exists as a failsafe + ), + required_one_of=[['name', 'list']], + mutually_exclusive=[['name', 'list']], + supports_check_mode=True, +) + + +class YumDnf(with_metaclass(ABCMeta, object)): + """ + Abstract class that handles the population of instance variables that should + be identical between both YUM and DNF modules because of the feature parity + and shared argument spec + """ + + def __init__(self, module): + + self.module = module + + self.allow_downgrade = self.module.params['allow_downgrade'] + self.autoremove = self.module.params['autoremove'] + self.bugfix = self.module.params['bugfix'] + self.conf_file = self.module.params['conf_file'] + self.disable_excludes = self.module.params['disable_excludes'] + self.disable_gpg_check = self.module.params['disable_gpg_check'] + self.disable_plugin = self.module.params['disable_plugin'] + self.disablerepo = self.module.params.get('disablerepo', []) + self.download_only = self.module.params['download_only'] + self.enable_plugin = self.module.params['enable_plugin'] + self.enablerepo = self.module.params.get('enablerepo', []) + self.exclude = self.module.params['exclude'] + self.installroot = self.module.params['installroot'] + self.install_repoquery = self.module.params['install_repoquery'] + self.list = self.module.params['list'] + self.names = [p.strip() for p in self.module.params['name']] + self.releasever = self.module.params['releasever'] + self.security = self.module.params['security'] + self.skip_broken = self.module.params['skip_broken'] + self.state = self.module.params['state'] + self.update_only = self.module.params['update_only'] + self.update_cache = self.module.params['update_cache'] + self.validate_certs = self.module.params['validate_certs'] + + # It's possible someone passed a comma separated string since it used + # to be a string type, so we should handle that + if self.enablerepo and len(self.enablerepo) == 1 and ',' in self.enablerepo: + self.enablerepo = self.module.params['enablerepo'].split(',') + if self.disablerepo and len(self.disablerepo) == 1 and ',' in self.disablerepo: + self.disablerepo = self.module.params['disablerepo'].split(',') + if self.exclude and len(self.exclude) == 1 and ',' in self.exclude: + self.exclude = self.module.params['exclude'].split(',') + + @abstractmethod + def run(self): + raise NotImplementedError diff --git a/lib/ansible/modules/packaging/os/dnf.py b/lib/ansible/modules/packaging/os/dnf.py index bdcbdee3e60..5b9bcf07e8a 100644 --- a/lib/ansible/modules/packaging/os/dnf.py +++ b/lib/ansible/modules/packaging/os/dnf.py @@ -3,6 +3,7 @@ # Copyright 2015 Cristian van Ee # Copyright 2015 Igor Gnatenko +# Copyright 2018 Adam Miller # # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -88,6 +89,95 @@ options: type: bool default: false version_added: "2.4" + exclude: + description: + - Package name(s) to exclude when state=present, or latest. This can be a + list or a comma separated string. + version_added: "2.7" + skip_broken: + description: + - Skip packages with broken dependencies(devsolve) and are causing problems. + type: bool + default: "no" + version_added: "2.7" + update_cache: + description: + - Force yum to check if cache is out of date and redownload if needed. + Has an effect only if state is I(present) or I(latest). + type: bool + default: "no" + aliases: [ expire-cache ] + version_added: "2.7" + update_only: + description: + - When using latest, only update installed packages. Do not install packages. + - Has an effect only if state is I(latest) + required: false + default: "no" + type: bool + version_added: "2.7" + security: + description: + - If set to C(yes), and C(state=latest) then only installs updates that have been marked security related. + type: bool + default: "no" + version_added: "2.7" + bugfix: + description: + - If set to C(yes), and C(state=latest) then only installs updates that have been marked bugfix related. + required: false + default: "no" + type: bool + version_added: "2.7" + enable_plugin: + description: + - I(Plugin) name to enable for the install/update operation. + The enabled plugin will not persist beyond the transaction. + required: false + version_added: "2.7" + disable_plugin: + description: + - I(Plugin) name to disable for the install/update operation. + The disabled plugins will not persist beyond the transaction. + required: false + version_added: "2.7" + disable_excludes: + description: + - Disable the excludes defined in DNF config files. + - If set to C(all), disables all excludes. + - If set to C(main), disable excludes defined in [main] in yum.conf. + - If set to C(repoid), disable excludes defined for given repo id. + required: false + choices: [ all, main, repoid ] + version_added: "2.7" + validate_certs: + description: + - This only applies if using a https url as the source of the rpm. e.g. for localinstall. If set to C(no), the SSL certificates will not be validated. + - This should only set to C(no) used on personally controlled sites using self-signed certificates as it avoids verifying the source site. + type: bool + default: "yes" + version_added: "2.7" + allow_downgrade: + description: + - This is effectively a no-op in DNF as it is the default behavior of dnf, but is an accepted parameter for feature + parity/compatibility with the I(yum) module. + type: bool + default: False + version_added: "2.7" + install_repoquery: + description: + - This is effectively a no-op in DNF as it is not needed with DNF, but is an accepted parameter for feature + parity/compatibility with the I(yum) module. + type: bool + default: True + version_added: "2.7" + download_only: + description: + - Only download the packages, do not install them. + required: false + default: "no" + type: bool + version_added: "2.7" notes: - When used with a `loop:` each package will be processed individually, it is much more efficient to pass the list directly to the `name` option. requirements: @@ -98,6 +188,7 @@ author: - '"Igor Gnatenko (@ignatenkobrain)" ' - '"Cristian van Ee (@DJMuggs)" ' - "Berend De Schouwer (github.com/berenddeschouwer)" + - '"Adam Miller (@maxamillion)" "' ''' EXAMPLES = ''' @@ -147,7 +238,9 @@ EXAMPLES = ''' state: absent autoremove: no ''' + import os +import tempfile try: import dnf @@ -160,378 +253,476 @@ try: except ImportError: HAS_DNF = False -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils._text import to_native +from ansible.module_utils._text import to_native, to_text +from ansible.module_utils.urls import fetch_url from ansible.module_utils.six import PY2 from distutils.version import LooseVersion +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.yumdnf import YumDnf, yumdnf_argument_spec + +# 64k. Number of bytes to read at a time when manually downloading pkgs via a url +BUFSIZE = 65536 + + +class DnfModule(YumDnf): + """ + DNF Ansible module back-end implementation + """ -def _ensure_dnf(module): - if not HAS_DNF: - if PY2: - package = 'python2-dnf' + def __init__(self, module): + # This populates instance vars for all argument spec params + super(DnfModule, self).__init__(module) + + self._ensure_dnf() + + def fetch_rpm_from_url(self, spec): + # FIXME: Remove this once this PR is merged: + # https://github.com/ansible/ansible/pull/19172 + + # download package so that we can query it + package_name, dummy = os.path.splitext(str(spec.rsplit('/', 1)[1])) + package_file = tempfile.NamedTemporaryFile(dir=self.module.tmpdir, prefix=package_name, suffix='.rpm', delete=False) + self.module.add_cleanup_file(package_file.name) + try: + rsp, info = fetch_url(self.module, spec) + if not rsp: + self.module.fail_json(msg="Failure downloading %s, %s" % (spec, info['msg'])) + data = rsp.read(BUFSIZE) + while data: + package_file.write(data) + data = rsp.read(BUFSIZE) + package_file.close() + except Exception as e: + self.module.fail_json(msg="Failure downloading %s, %s" % (spec, to_native(e))) + + return package_file.name + + def _ensure_dnf(self): + if not HAS_DNF: + if PY2: + package = 'python2-dnf' + else: + package = 'python3-dnf' + + if self.module.check_mode: + self.module.fail_json( + msg="`{0}` is not installed, but it is required" + "for the Ansible dnf module.".format(package) + ) + + self.module.run_command(['dnf', 'install', '-y', package], check_rc=True) + global dnf + try: + import dnf + import dnf.cli + import dnf.const + import dnf.exceptions + import dnf.subject + import dnf.util + except ImportError: + self.module.fail_json( + msg="Could not import the dnf python module. " + "Please install `{0}` package.".format(package) + ) + + def _configure_base(self, base, conf_file, disable_gpg_check, installroot='/'): + """Configure the dnf Base object.""" + + if self.enable_plugin and self.disable_plugin: + base.init_plugins(self.disable_plugin, self.enable_plugin) + elif self.enable_plugin: + base.init_plugins(enable_plugins=self.enable_plugin) + elif self.disable_plugin: + base.init_plugins(self.disable_plugin) + + conf = base.conf + + # Turn off debug messages in the output + conf.debuglevel = 0 + + # Set whether to check gpg signatures + conf.gpgcheck = not disable_gpg_check + + # Don't prompt for user confirmations + conf.assumeyes = True + + # Set installroot + conf.installroot = installroot + + # Set excludes + if self.exclude: + conf.exclude(self.exclude) + + # Set disable_excludes + if self.disable_excludes: + conf.disable_excludes = [self.disable_excludes] + + # Set releasever + if self.releasever is not None: + conf.substitutions['releasever'] = self.releasever + + # Set skip_broken (in dnf this is strict=0) + if self.skip_broken: + conf.strict = 0 + + if self.download_only: + conf.downloadonly = True + + # Change the configuration file path if provided + if conf_file: + # Fail if we can't read the configuration file. + if not os.access(conf_file, os.R_OK): + self.module.fail_json( + msg="cannot read configuration file", conf_file=conf_file) + else: + conf.config_file_path = conf_file + + # Read the configuration file + conf.read() + + def _specify_repositories(self, base, disablerepo, enablerepo): + """Enable and disable repositories matching the provided patterns.""" + base.read_all_repos() + repos = base.repos + + # Disable repositories + for repo_pattern in disablerepo: + for repo in repos.get_matching(repo_pattern): + repo.disable() + + # Enable repositories + for repo_pattern in enablerepo: + for repo in repos.get_matching(repo_pattern): + repo.enable() + + def _base(self, conf_file, disable_gpg_check, disablerepo, enablerepo, installroot): + """Return a fully configured dnf Base object.""" + base = dnf.Base() + self._configure_base(base, conf_file, disable_gpg_check, installroot) + self._specify_repositories(base, disablerepo, enablerepo) + base.fill_sack(load_system_repo='auto') + if self.bugfix: + key = {'advisory_type__eq': 'bugfix'} + base._update_security_filters = [base.sack.query().filter(**key)] + if self.security: + key = {'advisory_type__eq': 'security'} + base._update_security_filters = [base.sack.query().filter(**key)] + if self.update_cache: + base.update_cache() + return base + + def _package_dict(self, package): + """Return a dictionary of information for the package.""" + # NOTE: This no longer contains the 'dnfstate' field because it is + # already known based on the query type. + result = { + 'name': package.name, + 'arch': package.arch, + 'epoch': str(package.epoch), + 'release': package.release, + 'version': package.version, + 'repo': package.repoid} + result['nevra'] = '{epoch}:{name}-{version}-{release}.{arch}'.format( + **result) + + return result + + def list_items(self, command): + """List package info based on the command.""" + # Rename updates to upgrades + if command == 'updates': + command = 'upgrades' + + # Return the corresponding packages + if command in ['installed', 'upgrades', 'available']: + results = [ + self._package_dict(package) + for package in getattr(self.base.sack.query(), command)()] + # Return the enabled repository ids + elif command in ['repos', 'repositories']: + results = [ + {'repoid': repo.id, 'state': 'enabled'} + for repo in self.base.repos.iter_enabled()] + # Return any matching packages else: - package = 'python3-dnf' + packages = dnf.subject.Subject(command).get_best_query(self.base.sack) + results = [self._package_dict(package) for package in packages] - if module.check_mode: - module.fail_json(msg="`{0}` is not installed, but it is required" - "for the Ansible dnf module.".format(package)) + self.module.exit_json(results=results) - module.run_command(['dnf', 'install', '-y', package], check_rc=True) - global dnf + def _mark_package_install(self, pkg_spec): + """Mark the package for install.""" try: - import dnf - import dnf.cli - import dnf.const - import dnf.exceptions - import dnf.subject - import dnf.util - except ImportError: - module.fail_json(msg="Could not import the dnf python module. " - "Please install `{0}` package.".format(package)) - - -def _configure_base(module, base, conf_file, disable_gpg_check, installroot='/', releasever=None): - """Configure the dnf Base object.""" - conf = base.conf - - # Turn off debug messages in the output - conf.debuglevel = 0 - - # Set whether to check gpg signatures - conf.gpgcheck = not disable_gpg_check - - # Don't prompt for user confirmations - conf.assumeyes = True - - # Set installroot - conf.installroot = installroot - - # Set releasever - if releasever is not None: - conf.substitutions['releasever'] = releasever - - # Change the configuration file path if provided - if conf_file: - # Fail if we can't read the configuration file. - if not os.access(conf_file, os.R_OK): - module.fail_json( - msg="cannot read configuration file", conf_file=conf_file) + self.base.install(pkg_spec) + except dnf.exceptions.MarkingError: + self.module.fail_json(msg="No package {0} available.".format(pkg_spec)) + + def _parse_spec_group_file(self): + pkg_specs, grp_specs, filenames = [], [], [] + for name in self.names: + if name.endswith(".rpm"): + if '://' in name: + name = self.fetch_rpm_from_url(name) + filenames.append(name) + elif name.startswith("@"): + grp_specs.append(name[1:]) + else: + pkg_specs.append(name) + return pkg_specs, grp_specs, filenames + + def _update_only(self, pkgs): + installed = self.base.sack.query().installed() + for pkg in pkgs: + if installed.filter(name=pkg): + self.base.package_upgrade(pkg) + + def _install_remote_rpms(self, filenames): + if int(dnf.__version__.split(".")[0]) >= 2: + pkgs = list(sorted(self.base.add_remote_rpms(list(filenames)), reverse=True)) else: - conf.config_file_path = conf_file - - # Read the configuration file - conf.read() - - -def _specify_repositories(base, disablerepo, enablerepo): - """Enable and disable repositories matching the provided patterns.""" - base.read_all_repos() - repos = base.repos - - # Disable repositories - for repo_pattern in disablerepo: - for repo in repos.get_matching(repo_pattern): - repo.disable() - - # Enable repositories - for repo_pattern in enablerepo: - for repo in repos.get_matching(repo_pattern): - repo.enable() - - -def _base(module, conf_file, disable_gpg_check, disablerepo, enablerepo, installroot, releasever): - """Return a fully configured dnf Base object.""" - base = dnf.Base() - _configure_base(module, base, conf_file, disable_gpg_check, installroot, releasever) - _specify_repositories(base, disablerepo, enablerepo) - base.fill_sack(load_system_repo='auto') - return base - - -def _package_dict(package): - """Return a dictionary of information for the package.""" - # NOTE: This no longer contains the 'dnfstate' field because it is - # already known based on the query type. - result = { - 'name': package.name, - 'arch': package.arch, - 'epoch': str(package.epoch), - 'release': package.release, - 'version': package.version, - 'repo': package.repoid} - result['nevra'] = '{epoch}:{name}-{version}-{release}.{arch}'.format( - **result) - - return result - - -def list_items(module, base, command): - """List package info based on the command.""" - # Rename updates to upgrades - if command == 'updates': - command = 'upgrades' - - # Return the corresponding packages - if command in ['installed', 'upgrades', 'available']: - results = [ - _package_dict(package) - for package in getattr(base.sack.query(), command)()] - # Return the enabled repository ids - elif command in ['repos', 'repositories']: - results = [ - {'repoid': repo.id, 'state': 'enabled'} - for repo in base.repos.iter_enabled()] - # Return any matching packages - else: - packages = dnf.subject.Subject(command).get_best_query(base.sack) - results = [_package_dict(package) for package in packages] - - module.exit_json(results=results) - - -def _mark_package_install(module, base, pkg_spec): - """Mark the package for install.""" - try: - base.install(pkg_spec) - except dnf.exceptions.MarkingError: - module.fail_json(msg="No package {0} available.".format(pkg_spec)) - - -def _parse_spec_group_file(names): - pkg_specs, grp_specs, filenames = [], [], [] - for name in names: - if name.endswith(".rpm"): - filenames.append(name) - elif name.startswith("@"): - grp_specs.append(name[1:]) + pkgs = [] + for filename in filenames: + pkgs.append(self.base.add_remote_rpm(filename)) + if self.update_only: + self._update_only(pkgs) else: - pkg_specs.append(name) - return pkg_specs, grp_specs, filenames - - -def _install_remote_rpms(base, filenames): - if int(dnf.__version__.split(".")[0]) >= 2: - pkgs = list(sorted(base.add_remote_rpms(list(filenames)), reverse=True)) - else: - pkgs = [] - for filename in filenames: - pkgs.append(base.add_remote_rpm(filename)) - for pkg in pkgs: - base.package_install(pkg) - - -def ensure(module, base, state, names, autoremove): - # Accumulate failures. Package management modules install what they can - # and fail with a message about what they can't. - failures = [] - allow_erasing = False - - # Autoremove is called alone - # Jump to remove path where base.autoremove() is run - if not names and autoremove: - names = [] - state = 'absent' - - if names == ['*'] and state == 'latest': - base.upgrade_all() - else: - pkg_specs, group_specs, filenames = _parse_spec_group_file(names) - if group_specs: - base.read_comps() - - pkg_specs = [p.strip() for p in pkg_specs] - filenames = [f.strip() for f in filenames] - groups = [] - environments = [] - for group_spec in (g.strip() for g in group_specs): - group = base.comps.group_by_pattern(group_spec) - if group: - groups.append(group.id) - else: - environment = base.comps.environment_by_pattern(group_spec) - if environment: - environments.append(environment.id) + for pkg in pkgs: + self.base.package_install(pkg) + + def ensure(self): + # Accumulate failures. Package management modules install what they can + # and fail with a message about what they can't. + failures = [] + allow_erasing = False + + # Autoremove is called alone + # Jump to remove path where base.autoremove() is run + if not self.names and self.autoremove: + self.names = [] + self.state = 'absent' + + if self.names == ['*'] and self.state == 'latest': + self.base.upgrade_all() + else: + pkg_specs, group_specs, filenames = self._parse_spec_group_file() + if group_specs: + self.base.read_comps() + + pkg_specs = [p.strip() for p in pkg_specs] + filenames = [f.strip() for f in filenames] + groups = [] + environments = [] + for group_spec in (g.strip() for g in group_specs): + group = self.base.comps.group_by_pattern(group_spec) + if group: + groups.append(group.id) + else: + environment = self.base.comps.environment_by_pattern(group_spec) + if environment: + environments.append(environment.id) + else: + self.module.fail_json( + msg="No group {0} available.".format(group_spec)) + + if self.state in ['installed', 'present']: + # Install files. + self._install_remote_rpms(filenames) + + # Install groups. + for group in groups: + try: + self.base.group_install(group, dnf.const.GROUP_PACKAGE_TYPES) + except dnf.exceptions.Error as e: + # In dnf 2.0 if all the mandatory packages in a group do + # not install, an error is raised. We want to capture + # this but still install as much as possible. + failures.append((group, to_native(e))) + + for environment in environments: + try: + self.base.environment_install(environment, dnf.const.GROUP_PACKAGE_TYPES) + except dnf.exceptions.Error as e: + failures.append((environment, to_native(e))) + + # Install packages. + if self.update_only: + self._update_only(pkg_specs) + else: + for pkg_spec in pkg_specs: + self._mark_package_install(pkg_spec) + + elif self.state == 'latest': + # "latest" is same as "installed" for filenames. + self._install_remote_rpms(filenames) + + for group in groups: + try: + try: + self.base.group_upgrade(group) + except dnf.exceptions.CompsError: + # If not already installed, try to install. + self.base.group_install(group, dnf.const.GROUP_PACKAGE_TYPES) + except dnf.exceptions.Error as e: + failures.append((group, to_native(e))) + + for environment in environments: + try: + try: + self.base.environment_upgrade(environment) + except dnf.exceptions.CompsError: + # If not already installed, try to install. + self.base.environment_install(environment, dnf.const.GROUP_PACKAGE_TYPES) + except dnf.exceptions.Error as e: + failures.append((environment, to_native(e))) + + if self.update_only: + self._update_only(pkg_specs) else: - module.fail_json( - msg="No group {0} available.".format(group_spec)) - - if state in ['installed', 'present']: - # Install files. - _install_remote_rpms(base, filenames) - - # Install groups. - for group in groups: - try: - base.group_install(group, dnf.const.GROUP_PACKAGE_TYPES) - except dnf.exceptions.Error as e: - # In dnf 2.0 if all the mandatory packages in a group do - # not install, an error is raised. We want to capture - # this but still install as much as possible. - failures.append((group, to_native(e))) - - for environment in environments: - try: - base.environment_install(environment, dnf.const.GROUP_PACKAGE_TYPES) - except dnf.exceptions.Error as e: - failures.append((environment, to_native(e))) - - # Install packages. - for pkg_spec in pkg_specs: - _mark_package_install(module, base, pkg_spec) - - elif state == 'latest': - # "latest" is same as "installed" for filenames. - _install_remote_rpms(base, filenames) - - for group in groups: - try: + for pkg_spec in pkg_specs: + # best effort causes to install the latest package + # even if not previously installed + self.base.conf.best = True + try: + self.base.install(pkg_spec) + except dnf.exceptions.MarkingError as e: + failures.append((pkg_spec, to_native(e))) + + else: + # state == absent + if self.autoremove: + self.base.conf.clean_requirements_on_remove = self.autoremove + + if filenames: + self.module.fail_json( + msg="Cannot remove paths -- please specify package name.") + + for group in groups: try: - base.group_upgrade(group) + self.base.group_remove(group) except dnf.exceptions.CompsError: - # If not already installed, try to install. - base.group_install(group, dnf.const.GROUP_PACKAGE_TYPES) - except dnf.exceptions.Error as e: - failures.append((group, to_native(e))) + # Group is already uninstalled. + pass - for environment in environments: - try: + for environment in environments: try: - base.environment_upgrade(environment) + self.base.environment_remove(environment) except dnf.exceptions.CompsError: - # If not already installed, try to install. - base.environment_install(environment, dnf.const.GROUP_PACKAGE_TYPES) - except dnf.exceptions.Error as e: - failures.append((environment, to_native(e))) - - for pkg_spec in pkg_specs: - # best effort causes to install the latest package - # even if not previously installed - base.conf.best = True - try: - base.install(pkg_spec) - except dnf.exceptions.MarkingError as e: - failures.append((pkg_spec, to_native(e))) + # Environment is already uninstalled. + pass + + installed = self.base.sack.query().installed() + for pkg_spec in pkg_specs: + if installed.filter(name=pkg_spec): + self.base.remove(pkg_spec) + + # Like the dnf CLI we want to allow recursive removal of dependent + # packages + allow_erasing = True + + if self.autoremove: + self.base.autoremove() + if not self.base.resolve(allow_erasing=allow_erasing): + if failures: + self.module.fail_json( + msg='Failed to install some of the specified packages', + failures=failures + ) + self.module.exit_json(msg="Nothing to do") else: - # state == absent - if autoremove: - base.conf.clean_requirements_on_remove = autoremove - - if filenames: - module.fail_json( - msg="Cannot remove paths -- please specify package name.") - - for group in groups: - try: - base.group_remove(group) - except dnf.exceptions.CompsError: - # Group is already uninstalled. - pass - - for environment in environments: - try: - base.environment_remove(environment) - except dnf.exceptions.CompsError: - # Environment is already uninstalled. - pass - - installed = base.sack.query().installed() - for pkg_spec in pkg_specs: - if installed.filter(name=pkg_spec): - base.remove(pkg_spec) - - # Like the dnf CLI we want to allow recursive removal of dependent - # packages - allow_erasing = True - - if autoremove: - base.autoremove() - - if not base.resolve(allow_erasing=allow_erasing): - if failures: - module.fail_json(msg='Failed to install some of the ' - 'specified packages', - failures=failures) - module.exit_json(msg="Nothing to do") - else: - if module.check_mode: + if self.module.check_mode: + if failures: + self.module.fail_json( + msg='Failed to install some of the specified packages', + failures=failures + ) + self.module.exit_json(changed=True) + + try: + self.base.download_packages(self.base.transaction.install_set) + except dnf.exceptions.DownloadError as e: + self.module.fail_json(msg="Failed to download packages: {0}".format(to_text(e))) + + response = {'changed': True, 'results': []} + if self.download_only: + for package in self.base.transaction.install_set: + response['results'].append("Downloaded: {0}".format(package)) + self.module.exit_json(**response) + else: + self.base.do_transaction() + for package in self.base.transaction.install_set: + response['results'].append("Installed: {0}".format(package)) + for package in self.base.transaction.remove_set: + response['results'].append("Removed: {0}".format(package)) + if failures: - module.fail_json(msg='Failed to install some of the ' - 'specified packages', - failures=failures) - module.exit_json(changed=True) - - base.download_packages(base.transaction.install_set) - base.do_transaction() - response = {'changed': True, 'results': []} - for package in base.transaction.install_set: - response['results'].append("Installed: {0}".format(package)) - for package in base.transaction.remove_set: - response['results'].append("Removed: {0}".format(package)) - - if failures: - module.fail_json(msg='Failed to install some of the ' - 'specified packages', - failures=failures) - module.exit_json(**response) + self.module.fail_json( + msg='Failed to install some of the specified packages', + failures=failures + ) + self.module.exit_json(**response) + + @staticmethod + def has_dnf(): + return HAS_DNF + + def run(self): + """The main function.""" + + # Check if autoremove is called correctly + if self.autoremove: + if LooseVersion(dnf.__version__) < LooseVersion('2.0.1'): + self.module.fail_json(msg="Autoremove requires dnf>=2.0.1. Current dnf version is %s" % dnf.__version__) + if self.state not in ["absent", None]: + self.module.fail_json(msg="Autoremove should be used alone or with state=absent") + + # Set state as installed by default + # This is not set in AnsibleModule() because the following shouldn't happend + # - dnf: autoremove=yes state=installed + if self.state is None: + self.state = 'installed' + + if self.list: + self.base = self._base( + self.conf_file, self.disable_gpg_check, self.disablerepo, + self.enablerepo, self.installroot + ) + self.list_items(self.module, self.list) + else: + # Note: base takes a long time to run so we want to check for failure + # before running it. + if not dnf.util.am_i_root(): + self.module.fail_json(msg="This command has to be run under the root user.") + self.base = self._base( + self.conf_file, self.disable_gpg_check, self.disablerepo, + self.enablerepo, self.installroot + ) + + self.ensure() def main(): - """The main function.""" + # state=installed name=pkgspec + # state=removed name=pkgspec + # state=latest name=pkgspec + # + # informational commands: + # list=installed + # list=updates + # list=available + # list=repos + # list=pkgspec + module = AnsibleModule( - argument_spec=dict( - name=dict(aliases=['pkg'], type='list'), - state=dict( - choices=['absent', 'present', 'installed', 'removed', 'latest'], - default='present', - ), - enablerepo=dict(type='list', default=[]), - disablerepo=dict(type='list', default=[]), - list=dict(), - conf_file=dict(default=None, type='path'), - disable_gpg_check=dict(default=False, type='bool'), - installroot=dict(default='/', type='path'), - autoremove=dict(type='bool', default=False), - releasever=dict(default=None), - ), - required_one_of=[['name', 'list', 'autoremove']], - mutually_exclusive=[['name', 'list'], ['autoremove', 'list']], - supports_check_mode=True) - params = module.params - - _ensure_dnf(module) - - # Check if autoremove is called correctly - if params['autoremove']: - if LooseVersion(dnf.__version__) < LooseVersion('2.0.1'): - module.fail_json(msg="Autoremove requires dnf>=2.0.1. Current dnf version is %s" % dnf.__version__) - if params['state'] not in ["absent", None]: - module.fail_json(msg="Autoremove should be used alone or with state=absent") - - # Set state as installed by default - # This is not set in AnsibleModule() because the following shouldn't happend - # - dnf: autoremove=yes state=installed - if params['state'] is None: - params['state'] = 'installed' - - if params['list']: - base = _base( - module, params['conf_file'], params['disable_gpg_check'], - params['disablerepo'], params['enablerepo'], params['installroot'], - params['releasever']) - list_items(module, base, params['list']) - else: - # Note: base takes a long time to run so we want to check for failure - # before running it. - if not dnf.util.am_i_root(): - module.fail_json(msg="This command has to be run under the root user.") - base = _base( - module, params['conf_file'], params['disable_gpg_check'], - params['disablerepo'], params['enablerepo'], params['installroot'], - params['releasever']) - - ensure(module, base, params['state'], params['name'], params['autoremove']) + **yumdnf_argument_spec + ) + + module_implementation = DnfModule(module) + try: + module_implementation.run() + except dnf.exceptions.RepoError as de: + module.exit_json(msg="Failed to synchronize repodata: {0}".format(de)) if __name__ == '__main__': diff --git a/lib/ansible/modules/packaging/os/yum.py b/lib/ansible/modules/packaging/os/yum.py index 1c2f9d66800..2fe93fb7a0b 100644 --- a/lib/ansible/modules/packaging/os/yum.py +++ b/lib/ansible/modules/packaging/os/yum.py @@ -53,12 +53,16 @@ options: - I(Repoid) of repositories to enable for the install/update operation. These repos will not persist beyond the transaction. When specifying multiple repos, separate them with a C(","). + - As of Ansible 2.7, this can alternatively be a list instead of C(",") + separated string version_added: "0.9" disablerepo: description: - I(Repoid) of repositories to disable for the install/update operation. These repos will not persist beyond the transaction. When specifying multiple repos, separate them with a C(","). + - As of Ansible 2.7, this can alternatively be a list instead of C(",") + separated string version_added: "0.9" conf_file: description: @@ -146,6 +150,22 @@ options: The disabled plugins will not persist beyond the transaction. required: false version_added: "2.5" + releasever: + description: + - Specifies an alternative release from which all packages will be + installed. + required: false + version_added: "2.7" + default: null + autoremove: + description: + - If C(yes), removes all "leaf" packages from the system that were originally + installed as dependencies of user-installed packages but which are no longer + required by any such package. Should be used alone or when state is I(absent) + - "NOTE: This feature requires yum >= 3.4.3 (RHEL/CentOS 7+)" + type: bool + default: false + version_added: "2.7" disable_excludes: description: - Disable the excludes defined in YUM config files. @@ -191,6 +211,7 @@ author: - Eduard Snesarev (@verm666) - Berend De Schouwer (@berenddeschouwer) - Abhijeet Kasurde (@Akasurde) + - Adam Miller (@maxamillion) ''' EXAMPLES = ''' @@ -285,6 +306,11 @@ EXAMPLES = ''' download_only: true ''' +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible.module_utils.urls import fetch_url +from ansible.module_utils.yumdnf import YumDnf, yumdnf_argument_spec + import os import re import tempfile @@ -310,1152 +336,1157 @@ except ImportError: from contextlib import contextmanager -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils._text import to_native -from ansible.module_utils.urls import fetch_url +def_qf = "%{epoch}:%{name}-%{version}-%{release}.%{arch}" +rpmbin = None # 64k. Number of bytes to read at a time when manually downloading pkgs via a url BUFSIZE = 65536 -def_qf = "%{epoch}:%{name}-%{version}-%{release}.%{arch}" -rpmbin = None +class YumModule(YumDnf): + """ + Yum Ansible module back-end implementation + """ -def yum_base(conf_file=None, installroot='/', enabled_plugins=None, - disabled_plugins=None, disable_excludes=None): - my = yum.YumBase() - my.preconf.debuglevel = 0 - my.preconf.errorlevel = 0 - my.preconf.plugins = True - my.preconf.enabled_plugins = enabled_plugins - my.preconf.disabled_plugins = disabled_plugins - # my.preconf.releasever = '/' - if installroot != '/': - # do not setup installroot by default, because of error - # CRITICAL:yum.cli:Config Error: Error accessing file for config file:////etc/yum.conf - # in old yum version (like in CentOS 6.6) - my.preconf.root = installroot - my.conf.installroot = installroot - if conf_file and os.path.exists(conf_file): - my.preconf.fn = conf_file - if os.geteuid() != 0: - if hasattr(my, 'setCacheDir'): - my.setCacheDir() - else: - cachedir = yum.misc.getCacheDir() - my.repos.setCacheDir(cachedir) - my.conf.cache = 0 - if disable_excludes: - my.conf.disable_excludes = disable_excludes - - return my - - -def ensure_yum_utils(module): - repoquerybin = module.get_bin_path('repoquery', required=False) - - if module.params['install_repoquery'] and not repoquerybin and not module.check_mode: - yum_path = module.get_bin_path('yum') - if yum_path: - module.run_command('%s -y install yum-utils' % yum_path) - repoquerybin = module.get_bin_path('repoquery', required=False) - - return repoquerybin - - -def fetch_rpm_from_url(spec, module=None): - # download package so that we can query it - package_name, _ = os.path.splitext(str(spec.rsplit('/', 1)[1])) - package_file = tempfile.NamedTemporaryFile(dir=module.tmpdir, prefix=package_name, suffix='.rpm', delete=False) - module.add_cleanup_file(package_file.name) - try: - rsp, info = fetch_url(module, spec) - if not rsp: - module.fail_json(msg="Failure downloading %s, %s" % (spec, info['msg'])) - data = rsp.read(BUFSIZE) - while data: - package_file.write(data) + def __init__(self, module): + + # state=installed name=pkgspec + # state=removed name=pkgspec + # state=latest name=pkgspec + # + # informational commands: + # list=installed + # list=updates + # list=available + # list=repos + # list=pkgspec + + # This populates instance vars for all argument spec params + super(YumModule, self).__init__(module) + + def fetch_rpm_from_url(self, spec): + # FIXME: Remove this once this PR is merged: + # https://github.com/ansible/ansible/pull/19172 + + # download package so that we can query it + package_name, dummy = os.path.splitext(str(spec.rsplit('/', 1)[1])) + package_file = tempfile.NamedTemporaryFile(dir=self.module.tmpdir, prefix=package_name, suffix='.rpm', delete=False) + self.module.add_cleanup_file(package_file.name) + try: + rsp, info = fetch_url(self.module, spec) + if not rsp: + self.module.fail_json(msg="Failure downloading %s, %s" % (spec, info['msg'])) data = rsp.read(BUFSIZE) - package_file.close() - except Exception as e: - if module: - module.fail_json(msg="Failure downloading %s, %s" % (spec, to_native(e))) - else: - raise e + while data: + package_file.write(data) + data = rsp.read(BUFSIZE) + package_file.close() + except Exception as e: + self.module.fail_json(msg="Failure downloading %s, %s" % (spec, to_native(e))) + + return package_file.name + + def yum_base(self): + my = yum.YumBase() + my.preconf.debuglevel = 0 + my.preconf.errorlevel = 0 + my.preconf.plugins = True + my.preconf.enabled_plugins = self.enable_plugin + my.preconf.disabled_plugins = self.disable_plugin + if self.releasever: + my.preconf.releasever = self.releasever + if self.installroot != '/': + # do not setup installroot by default, because of error + # CRITICAL:yum.cli:Config Error: Error accessing file for config file:////etc/yum.conf + # in old yum version (like in CentOS 6.6) + my.preconf.root = self.installroot + my.conf.installroot = self.installroot + if self.conf_file and os.path.exists(self.conf_file): + my.preconf.fn = self.conf_file + if os.geteuid() != 0: + if hasattr(my, 'setCacheDir'): + my.setCacheDir() + else: + cachedir = yum.misc.getCacheDir() + my.repos.setCacheDir(cachedir) + my.conf.cache = 0 + if self.disable_excludes: + my.conf.disable_excludes = self.disable_excludes - return package_file.name + return my + def po_to_envra(self, po): + if hasattr(po, 'ui_envra'): + return po.ui_envra -def po_to_envra(po): - if hasattr(po, 'ui_envra'): - return po.ui_envra + return '%s:%s-%s-%s.%s' % (po.epoch, po.name, po.version, po.release, po.arch) - return '%s:%s-%s-%s.%s' % (po.epoch, po.name, po.version, po.release, po.arch) + def is_group_env_installed(self, name): + name_lower = name.lower() + my = self.yum_base() + if yum.__version_info__ >= (3, 4): + groups_list = my.doGroupLists(return_evgrps=True) + else: + groups_list = my.doGroupLists() -def is_group_env_installed(name, conf_file, en_plugins=None, dis_plugins=None, - installroot='/', disable_excludes=None): - name_lower = name.lower() + # list of the installed groups on the first index + groups = groups_list[0] + for group in groups: + if name_lower.endswith(group.name.lower()) or name_lower.endswith(group.groupid.lower()): + return True - my = yum_base(conf_file, installroot, en_plugins, dis_plugins, disable_excludes) - if yum.__version_info__ >= (3, 4): - groups_list = my.doGroupLists(return_evgrps=True) - else: - groups_list = my.doGroupLists() + if yum.__version_info__ >= (3, 4): + # list of the installed env_groups on the third index + envs = groups_list[2] + for env in envs: + if name_lower.endswith(env.name.lower()) or name_lower.endswith(env.environmentid.lower()): + return True - # list of the installed groups on the first index - groups = groups_list[0] - for group in groups: - if name_lower.endswith(group.name.lower()) or name_lower.endswith(group.groupid.lower()): - return True + return False - if yum.__version_info__ >= (3, 4): - # list of the installed env_groups on the third index - envs = groups_list[2] - for env in envs: - if name_lower.endswith(env.name.lower()) or name_lower.endswith(env.environmentid.lower()): - return True + def is_installed(self, repoq, pkgspec, qf=None, is_pkg=False): + if qf is None: + qf = "%{epoch}:%{name}-%{version}-%{release}.%{arch}\n" - return False + if not repoq: + pkgs = [] + try: + my = self.yum_base() + for rid in self.disablerepo: + my.repos.disableRepo(rid) + for rid in self.enablerepo: + my.repos.enableRepo(rid) + e, m, _ = my.rpmdb.matchPackageNames([pkgspec]) + pkgs = e + m + if not pkgs and not is_pkg: + pkgs.extend(my.returnInstalledPackagesByDep(pkgspec)) + except Exception as e: + self.module.fail_json(msg="Failure talking to yum: %s" % to_native(e)) -def is_installed(module, repoq, pkgspec, conf_file, qf=None, en_repos=None, dis_repos=None, en_plugins=None, dis_plugins=None, is_pkg=False, - installroot='/', disable_excludes=None): - if en_repos is None: - en_repos = [] - if dis_repos is None: - dis_repos = [] - if qf is None: - qf = "%{epoch}:%{name}-%{version}-%{release}.%{arch}\n" + return [self.po_to_envra(p) for p in pkgs] - if not repoq: - pkgs = [] - try: - my = yum_base(conf_file, installroot, en_plugins, dis_plugins, disable_excludes) - for rid in dis_repos: - my.repos.disableRepo(rid) - for rid in en_repos: - my.repos.enableRepo(rid) - - e, m, _ = my.rpmdb.matchPackageNames([pkgspec]) - pkgs = e + m + else: + global rpmbin + if not rpmbin: + rpmbin = self.module.get_bin_path('rpm', required=True) + + cmd = [rpmbin, '-q', '--qf', qf, pkgspec] + if self.installroot != '/': + cmd.extend(['--root', self.installroot]) + # rpm localizes messages and we're screen scraping so make sure we use + # the C locale + lang_env = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C') + rc, out, err = self.module.run_command(cmd, environ_update=lang_env) + if rc != 0 and 'is not installed' not in out: + self.module.fail_json(msg='Error from rpm: %s: %s' % (cmd, err)) + if 'is not installed' in out: + out = '' + + pkgs = [p for p in out.replace('(none)', '0').split('\n') if p.strip()] if not pkgs and not is_pkg: - pkgs.extend(my.returnInstalledPackagesByDep(pkgspec)) - except Exception as e: - module.fail_json(msg="Failure talking to yum: %s" % to_native(e)) + cmd = [rpmbin, '-q', '--qf', qf, '--whatprovides', pkgspec] + if self.installroot != '/': + cmd.extend(['--root', self.installroot]) + rc2, out2, err2 = self.module.run_command(cmd, environ_update=lang_env) + else: + rc2, out2, err2 = (0, '', '') - return [po_to_envra(p) for p in pkgs] + if rc2 != 0 and 'no package provides' not in out2: + self.module.fail_json(msg='Error from rpm: %s: %s' % (cmd, err + err2)) + if 'no package provides' in out2: + out2 = '' + pkgs += [p for p in out2.replace('(none)', '0').split('\n') if p.strip()] + return pkgs - else: - global rpmbin - if not rpmbin: - rpmbin = module.get_bin_path('rpm', required=True) + return [] - cmd = [rpmbin, '-q', '--qf', qf, pkgspec] - if installroot != '/': - cmd.extend(['--root', installroot]) - # rpm localizes messages and we're screen scraping so make sure we use - # the C locale - lang_env = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C') - rc, out, err = module.run_command(cmd, environ_update=lang_env) - if rc != 0 and 'is not installed' not in out: - module.fail_json(msg='Error from rpm: %s: %s' % (cmd, err)) - if 'is not installed' in out: - out = '' - - pkgs = [p for p in out.replace('(none)', '0').split('\n') if p.strip()] - if not pkgs and not is_pkg: - cmd = [rpmbin, '-q', '--qf', qf, '--whatprovides', pkgspec] - if installroot != '/': - cmd.extend(['--root', installroot]) - rc2, out2, err2 = module.run_command(cmd, environ_update=lang_env) - else: - rc2, out2, err2 = (0, '', '') + def is_available(self, repoq, pkgspec, qf=def_qf): + if not repoq: - if rc2 != 0 and 'no package provides' not in out2: - module.fail_json(msg='Error from rpm: %s: %s' % (cmd, err + err2)) - if 'no package provides' in out2: - out2 = '' - pkgs += [p for p in out2.replace('(none)', '0').split('\n') if p.strip()] - return pkgs + pkgs = [] + try: + my = self.yum_base() + for rid in self.disablerepo: + my.repos.disableRepo(rid) + for rid in self.enablerepo: + my.repos.enableRepo(rid) - return [] + e, m, _ = my.pkgSack.matchPackageNames([pkgspec]) + pkgs = e + m + if not pkgs: + pkgs.extend(my.returnPackagesByDep(pkgspec)) + except Exception as e: + self.module.fail_json(msg="Failure talking to yum: %s" % to_native(e)) + return [self.po_to_envra(p) for p in pkgs] -def is_available(module, repoq, pkgspec, conf_file, qf=def_qf, en_repos=None, dis_repos=None, en_plugins=None, dis_plugins=None, - installroot='/', disable_excludes=None): - if en_repos is None: - en_repos = [] - if dis_repos is None: - dis_repos = [] + else: + myrepoq = list(repoq) - if not repoq: + r_cmd = ['--disablerepo', ','.join(self.disablerepo)] + myrepoq.extend(r_cmd) - pkgs = [] - try: - my = yum_base(conf_file, installroot, en_plugins, dis_plugins, disable_excludes) - for rid in dis_repos: - my.repos.disableRepo(rid) - for rid in en_repos: - my.repos.enableRepo(rid) - - e, m, _ = my.pkgSack.matchPackageNames([pkgspec]) - pkgs = e + m - if not pkgs: - pkgs.extend(my.returnPackagesByDep(pkgspec)) - except Exception as e: - module.fail_json(msg="Failure talking to yum: %s" % to_native(e)) + r_cmd = ['--enablerepo', ','.join(self.enablerepo)] + myrepoq.extend(r_cmd) - return [po_to_envra(p) for p in pkgs] + cmd = myrepoq + ["--qf", qf, pkgspec] + rc, out, err = self.module.run_command(cmd) + if rc == 0: + return [p for p in out.split('\n') if p.strip()] + else: + self.module.fail_json(msg='Error from repoquery: %s: %s' % (cmd, err)) - else: - myrepoq = list(repoq) + return [] - r_cmd = ['--disablerepo', ','.join(dis_repos)] - myrepoq.extend(r_cmd) + def is_update(self, repoq, pkgspec, qf=def_qf): + if not repoq: - r_cmd = ['--enablerepo', ','.join(en_repos)] - myrepoq.extend(r_cmd) + pkgs = [] + updates = [] - cmd = myrepoq + ["--qf", qf, pkgspec] - rc, out, err = module.run_command(cmd) - if rc == 0: - return [p for p in out.split('\n') if p.strip()] - else: - module.fail_json(msg='Error from repoquery: %s: %s' % (cmd, err)) + try: + my = self.yum_base() + for rid in self.disablerepo: + my.repos.disableRepo(rid) + for rid in self.enablerepo: + my.repos.enableRepo(rid) + + pkgs = my.returnPackagesByDep(pkgspec) + my.returnInstalledPackagesByDep(pkgspec) + if not pkgs: + e, m, _ = my.pkgSack.matchPackageNames([pkgspec]) + pkgs = e + m + updates = my.doPackageLists(pkgnarrow='updates').updates + except Exception as e: + self.module.fail_json(msg="Failure talking to yum: %s" % to_native(e)) - return [] + retpkgs = (pkg for pkg in pkgs if pkg in updates) + return set(self.po_to_envra(p) for p in retpkgs) -def is_update(module, repoq, pkgspec, conf_file, qf=def_qf, en_repos=None, dis_repos=None, en_plugins=None, dis_plugins=None, - installroot='/', disable_excludes=None): - if en_repos is None: - en_repos = [] - if dis_repos is None: - dis_repos = [] + else: + myrepoq = list(repoq) + r_cmd = ['--disablerepo', ','.join(self.disablerepo)] + myrepoq.extend(r_cmd) - if not repoq: + r_cmd = ['--enablerepo', ','.join(self.enablerepo)] + myrepoq.extend(r_cmd) - pkgs = [] - updates = [] + cmd = myrepoq + ["--pkgnarrow=updates", "--qf", qf, pkgspec] + rc, out, err = self.module.run_command(cmd) - try: - my = yum_base(conf_file, installroot, en_plugins, dis_plugins, disable_excludes) - for rid in dis_repos: - my.repos.disableRepo(rid) - for rid in en_repos: - my.repos.enableRepo(rid) - - pkgs = my.returnPackagesByDep(pkgspec) + my.returnInstalledPackagesByDep(pkgspec) - if not pkgs: - e, m, _ = my.pkgSack.matchPackageNames([pkgspec]) - pkgs = e + m - updates = my.doPackageLists(pkgnarrow='updates').updates - except Exception as e: - module.fail_json(msg="Failure talking to yum: %s" % to_native(e)) + if rc == 0: + return set(p for p in out.split('\n') if p.strip()) + else: + self.module.fail_json(msg='Error from repoquery: %s: %s' % (cmd, err)) - retpkgs = (pkg for pkg in pkgs if pkg in updates) + return set() - return set(po_to_envra(p) for p in retpkgs) + def what_provides(self, repoq, req_spec, qf=def_qf): + if not repoq: - else: - myrepoq = list(repoq) - r_cmd = ['--disablerepo', ','.join(dis_repos)] - myrepoq.extend(r_cmd) + pkgs = [] + try: + my = self.yum_base() + for rid in self.disablerepo: + my.repos.disableRepo(rid) + for rid in self.enablerepo: + my.repos.enableRepo(rid) - r_cmd = ['--enablerepo', ','.join(en_repos)] - myrepoq.extend(r_cmd) + try: + pkgs = my.returnPackagesByDep(req_spec) + my.returnInstalledPackagesByDep(req_spec) + except Exception as e: + # If a repo with `repo_gpgcheck=1` is added and the repo GPG + # key was never accepted, quering this repo will throw an + # error: 'repomd.xml signature could not be verified'. In that + # situation we need to run `yum -y makecache` which will accept + # the key and try again. + if 'repomd.xml signature could not be verified' in to_native(e): + self.module.run_command(self.yum_basecmd + ['makecache']) + pkgs = my.returnPackagesByDep(req_spec) + my.returnInstalledPackagesByDep(req_spec) + else: + raise + if not pkgs: + e, m, _ = my.pkgSack.matchPackageNames([req_spec]) + pkgs.extend(e) + pkgs.extend(m) + e, m, _ = my.rpmdb.matchPackageNames([req_spec]) + pkgs.extend(e) + pkgs.extend(m) + except Exception as e: + self.module.fail_json(msg="Failure talking to yum: %s" % to_native(e)) - cmd = myrepoq + ["--pkgnarrow=updates", "--qf", qf, pkgspec] - rc, out, err = module.run_command(cmd) + return set(self.po_to_envra(p) for p in pkgs) - if rc == 0: - return set(p for p in out.split('\n') if p.strip()) else: - module.fail_json(msg='Error from repoquery: %s: %s' % (cmd, err)) + myrepoq = list(repoq) + r_cmd = ['--disablerepo', ','.join(self.disablerepo)] + myrepoq.extend(r_cmd) + + r_cmd = ['--enablerepo', ','.join(self.enablerepo)] + myrepoq.extend(r_cmd) + + cmd = myrepoq + ["--qf", qf, "--whatprovides", req_spec] + rc, out, err = self.module.run_command(cmd) + cmd = myrepoq + ["--qf", qf, req_spec] + rc2, out2, err2 = self.module.run_command(cmd) + if rc == 0 and rc2 == 0: + out += out2 + pkgs = set([p for p in out.split('\n') if p.strip()]) + if not pkgs: + pkgs = self.is_installed(repoq, req_spec, qf=qf) + return pkgs + else: + self.module.fail_json(msg='Error from repoquery: %s: %s' % (cmd, err + err2)) - return set() + return set() + def transaction_exists(self, pkglist): + """ + checks the package list to see if any packages are + involved in an incomplete transaction + """ -def what_provides(module, repoq, yum_basecmd, req_spec, conf_file, qf=def_qf, - en_repos=None, dis_repos=None, en_plugins=None, - dis_plugins=None, installroot='/', disable_excludes=None): - if en_repos is None: - en_repos = [] - if dis_repos is None: - dis_repos = [] + conflicts = [] + if not transaction_helpers: + return conflicts + + # first, we create a list of the package 'nvreas' + # so we can compare the pieces later more easily + pkglist_nvreas = (splitFilename(pkg) for pkg in pkglist) + + # next, we build the list of packages that are + # contained within an unfinished transaction + unfinished_transactions = find_unfinished_transactions() + for trans in unfinished_transactions: + steps = find_ts_remaining(trans) + for step in steps: + # the action is install/erase/etc., but we only + # care about the package spec contained in the step + (action, step_spec) = step + (n, v, r, e, a) = splitFilename(step_spec) + # and see if that spec is in the list of packages + # requested for installation/updating + for pkg in pkglist_nvreas: + # if the name and arch match, we're going to assume + # this package is part of a pending transaction + # the label is just for display purposes + label = "%s-%s" % (n, a) + if n == pkg[0] and a == pkg[4]: + if label not in conflicts: + conflicts.append("%s-%s" % (n, a)) + break + return conflicts - if not repoq: + def local_envra(self, path): + """return envra of a local rpm passed in""" - pkgs = [] + ts = rpm.TransactionSet() + ts.setVSFlags(rpm._RPMVSF_NOSIGNATURES) + fd = os.open(path, os.O_RDONLY) try: - my = yum_base(conf_file, installroot, en_plugins, dis_plugins, disable_excludes) - for rid in dis_repos: - my.repos.disableRepo(rid) - for rid in en_repos: - my.repos.enableRepo(rid) + header = ts.hdrFromFdno(fd) + except rpm.error as e: + return None + finally: + os.close(fd) + + return '%s:%s-%s-%s.%s' % ( + header[rpm.RPMTAG_EPOCH] or '0', + header[rpm.RPMTAG_NAME], + header[rpm.RPMTAG_VERSION], + header[rpm.RPMTAG_RELEASE], + header[rpm.RPMTAG_ARCH] + ) + + @contextmanager + def set_env_proxy(self): + # setting system proxy environment and saving old, if exists + my = self.yum_base() + namepass = "" + scheme = ["http", "https"] + old_proxy_env = [os.getenv("http_proxy"), os.getenv("https_proxy")] + try: + if my.conf.proxy: + if my.conf.proxy_username: + namepass = namepass + my.conf.proxy_username + if my.conf.proxy_password: + namepass = namepass + ":" + my.conf.proxy_password + namepass = namepass + '@' + for item in scheme: + os.environ[item + "_proxy"] = re.sub( + r"(http://)", + r"\1" + namepass, my.conf.proxy + ) + yield + except yum.Errors.YumBaseError: + raise + finally: + # revert back to previously system configuration + for item in scheme: + if os.getenv("{0}_proxy".format(item)): + del os.environ["{0}_proxy".format(item)] + if old_proxy_env[0]: + os.environ["http_proxy"] = old_proxy_env[0] + if old_proxy_env[1]: + os.environ["https_proxy"] = old_proxy_env[1] + + def pkg_to_dict(self, pkgstr): + if pkgstr.strip(): + n, e, v, r, a, repo = pkgstr.split('|') + else: + return {'error_parsing': pkgstr} + + d = { + 'name': n, + 'arch': a, + 'epoch': e, + 'release': r, + 'version': v, + 'repo': repo, + 'envra': '%s:%s-%s-%s.%s' % (e, n, v, r, a) + } + + if repo == 'installed': + d['yumstate'] = 'installed' + else: + d['yumstate'] = 'available' - try: - pkgs = my.returnPackagesByDep(req_spec) + my.returnInstalledPackagesByDep(req_spec) - except Exception as e: - # If a repo with `repo_gpgcheck=1` is added and the repo GPG - # key was never accepted, quering this repo will throw an - # error: 'repomd.xml signature could not be verified'. In that - # situation we need to run `yum -y makecache` which will accept - # the key and try again. - if 'repomd.xml signature could not be verified' in to_native(e): - module.run_command(yum_basecmd + ['makecache']) - pkgs = my.returnPackagesByDep(req_spec) + my.returnInstalledPackagesByDep(req_spec) - else: - raise - if not pkgs: - e, m, _ = my.pkgSack.matchPackageNames([req_spec]) - pkgs.extend(e) - pkgs.extend(m) - e, m, _ = my.rpmdb.matchPackageNames([req_spec]) - pkgs.extend(e) - pkgs.extend(m) - except Exception as e: - module.fail_json(msg="Failure talking to yum: %s" % to_native(e)) - - return set(po_to_envra(p) for p in pkgs) - - else: - myrepoq = list(repoq) - r_cmd = ['--disablerepo', ','.join(dis_repos)] - myrepoq.extend(r_cmd) - - r_cmd = ['--enablerepo', ','.join(en_repos)] - myrepoq.extend(r_cmd) - - cmd = myrepoq + ["--qf", qf, "--whatprovides", req_spec] - rc, out, err = module.run_command(cmd) - cmd = myrepoq + ["--qf", qf, req_spec] - rc2, out2, err2 = module.run_command(cmd) - if rc == 0 and rc2 == 0: - out += out2 - pkgs = set(p for p in out.split('\n') if p.strip()) - if not pkgs: - pkgs = is_installed(module, repoq, req_spec, conf_file, qf=qf, installroot=installroot, disable_excludes=disable_excludes) - return pkgs + return d + + def repolist(self, repoq, qf="%{repoid}"): + cmd = repoq + ["--qf", qf, "-a"] + rc, out, _ = self.module.run_command(cmd) + if rc == 0: + return set(p for p in out.split('\n') if p.strip()) else: - module.fail_json(msg='Error from repoquery: %s: %s' % (cmd, err + err2)) + return [] - return set() + def list_stuff(self, repoquerybin, stuff): + qf = "%{name}|%{epoch}|%{version}|%{release}|%{arch}|%{repoid}" + # is_installed goes through rpm instead of repoquery so it needs a slightly different format + is_installed_qf = "%{name}|%{epoch}|%{version}|%{release}|%{arch}|installed\n" + repoq = [repoquerybin, '--show-duplicates', '--plugins', '--quiet'] + if self.disablerepo: + repoq.extend(['--disablerepo', ','.join(self.disablerepo)]) + if self.enablerepo: + repoq.extend(['--enablerepo', ','.join(self.enablerepo)]) + if self.installroot != '/': + repoq.extend(['--installroot', self.installroot]) + if self.conf_file and os.path.exists(self.conf_file): + repoq += ['-c', self.conf_file] -def transaction_exists(pkglist): - """ - checks the package list to see if any packages are - involved in an incomplete transaction - """ + if stuff == 'installed': + return [self.pkg_to_dict(p) for p in sorted(self.is_installed(repoq, '-a', qf=is_installed_qf)) if p.strip()] - conflicts = [] - if not transaction_helpers: - return conflicts + if stuff == 'updates': + return [self.pkg_to_dict(p) for p in sorted(self.is_update(repoq, '-a', qf=qf)) if p.strip()] - # first, we create a list of the package 'nvreas' - # so we can compare the pieces later more easily - pkglist_nvreas = (splitFilename(pkg) for pkg in pkglist) - - # next, we build the list of packages that are - # contained within an unfinished transaction - unfinished_transactions = find_unfinished_transactions() - for trans in unfinished_transactions: - steps = find_ts_remaining(trans) - for step in steps: - # the action is install/erase/etc., but we only - # care about the package spec contained in the step - (action, step_spec) = step - (n, v, r, e, a) = splitFilename(step_spec) - # and see if that spec is in the list of packages - # requested for installation/updating - for pkg in pkglist_nvreas: - # if the name and arch match, we're going to assume - # this package is part of a pending transaction - # the label is just for display purposes - label = "%s-%s" % (n, a) - if n == pkg[0] and a == pkg[4]: - if label not in conflicts: - conflicts.append("%s-%s" % (n, a)) - break - return conflicts - - -def local_envra(path): - """return envra of a local rpm passed in""" - - ts = rpm.TransactionSet() - ts.setVSFlags(rpm._RPMVSF_NOSIGNATURES) - fd = os.open(path, os.O_RDONLY) - try: - header = ts.hdrFromFdno(fd) - except rpm.error as e: - return None - finally: - os.close(fd) - - return '%s:%s-%s-%s.%s' % (header[rpm.RPMTAG_EPOCH] or '0', - header[rpm.RPMTAG_NAME], - header[rpm.RPMTAG_VERSION], - header[rpm.RPMTAG_RELEASE], - header[rpm.RPMTAG_ARCH]) - - -@contextmanager -def set_env_proxy(conf_file, installroot): - # setting system proxy environment and saving old, if exists - my = yum_base(conf_file, installroot) - namepass = "" - scheme = ["http", "https"] - old_proxy_env = [os.getenv("http_proxy"), os.getenv("https_proxy")] - try: - if my.conf.proxy: - if my.conf.proxy_username: - namepass = namepass + my.conf.proxy_username - if my.conf.proxy_password: - namepass = namepass + ":" + my.conf.proxy_password - namepass = namepass + '@' - for item in scheme: - os.environ[item + "_proxy"] = re.sub(r"(http://)", - r"\1" + namepass, my.conf.proxy) - yield - except yum.Errors.YumBaseError: - raise - finally: - # revert back to previously system configuration - for item in scheme: - if os.getenv("{0}_proxy".format(item)): - del os.environ["{0}_proxy".format(item)] - if old_proxy_env[0]: - os.environ["http_proxy"] = old_proxy_env[0] - if old_proxy_env[1]: - os.environ["https_proxy"] = old_proxy_env[1] - - -def pkg_to_dict(pkgstr): - if pkgstr.strip(): - n, e, v, r, a, repo = pkgstr.split('|') - else: - return {'error_parsing': pkgstr} - - d = { - 'name': n, - 'arch': a, - 'epoch': e, - 'release': r, - 'version': v, - 'repo': repo, - 'envra': '%s:%s-%s-%s.%s' % (e, n, v, r, a) - } - - if repo == 'installed': - d['yumstate'] = 'installed' - else: - d['yumstate'] = 'available' - - return d - - -def repolist(module, repoq, qf="%{repoid}"): - cmd = repoq + ["--qf", qf, "-a"] - rc, out, _ = module.run_command(cmd) - if rc == 0: - return set(p for p in out.split('\n') if p.strip()) - else: - return [] + if stuff == 'available': + return [self.pkg_to_dict(p) for p in sorted(self.is_available(repoq, '-a', qf=qf)) if p.strip()] + if stuff == 'repos': + return [dict(repoid=name, state='enabled') for name in sorted(self.repolist(repoq)) if name.strip()] -def list_stuff(module, repoquerybin, conf_file, stuff, installroot='/', disablerepo='', enablerepo='', disable_excludes=None): + return [ + self.pkg_to_dict(p) for p in + sorted(self.is_installed(repoq, stuff, qf=is_installed_qf) + self.is_available(repoq, stuff, qf=qf)) + if p.strip() + ] - qf = "%{name}|%{epoch}|%{version}|%{release}|%{arch}|%{repoid}" - # is_installed goes through rpm instead of repoquery so it needs a slightly different format - is_installed_qf = "%{name}|%{epoch}|%{version}|%{release}|%{arch}|installed\n" - repoq = [repoquerybin, '--show-duplicates', '--plugins', '--quiet'] - if disablerepo: - repoq.extend(['--disablerepo', disablerepo]) - if enablerepo: - repoq.extend(['--enablerepo', enablerepo]) - if installroot != '/': - repoq.extend(['--installroot', installroot]) - if conf_file and os.path.exists(conf_file): - repoq += ['-c', conf_file] + def exec_install(self, items, action, pkgs, res): + cmd = self.yum_basecmd + [action] + pkgs - if stuff == 'installed': - return [pkg_to_dict(p) for p in sorted(is_installed(module, repoq, '-a', conf_file, qf=is_installed_qf, - installroot=installroot, disable_excludes=disable_excludes)) if p.strip()] + if self.module.check_mode: + self.module.exit_json(changed=True, results=res['results'], changes=dict(installed=pkgs)) - if stuff == 'updates': - return [pkg_to_dict(p) for p in sorted(is_update(module, repoq, '-a', conf_file, qf=qf, - installroot=installroot, disable_excludes=disable_excludes)) if p.strip()] + lang_env = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C') + rc, out, err = self.module.run_command(cmd, environ_update=lang_env) - if stuff == 'available': - return [pkg_to_dict(p) for p in sorted(is_available(module, repoq, '-a', conf_file, qf=qf, - installroot=installroot, disable_excludes=disable_excludes)) if p.strip()] + if rc == 1: + for spec in items: + # Fail on invalid urls: + if ('://' in spec and ('No package %s available.' % spec in out or 'Cannot open: %s. Skipping.' % spec in err)): + err = 'Package at %s could not be installed' % spec + self.module.fail_json(changed=False, msg=err, rc=rc) - if stuff == 'repos': - return [dict(repoid=name, state='enabled') for name in sorted(repolist(module, repoq)) if name.strip()] + res['rc'] = rc + res['results'].append(out) + res['msg'] += err + res['changed'] = True - return [pkg_to_dict(p) for p in sorted(is_installed(module, repoq, stuff, conf_file, qf=is_installed_qf, - installroot=installroot, disable_excludes=disable_excludes) + - is_available(module, repoq, stuff, conf_file, qf=qf, installroot=installroot, - disable_excludes=disable_excludes)) if p.strip()] + if ('Nothing to do' in out and rc == 0) or ('does not have any packages' in err): + res['changed'] = False + if rc != 0: + res['changed'] = False + self.module.fail_json(**res) + + # Fail if yum prints 'No space left on device' because that means some + # packages failed executing their post install scripts because of lack of + # free space (e.g. kernel package couldn't generate initramfs). Note that + # yum can still exit with rc=0 even if some post scripts didn't execute + # correctly. + if 'No space left on device' in (out or err): + res['changed'] = False + res['msg'] = 'No space left on device' + self.module.fail_json(**res) + + # FIXME - if we did an install - go and check the rpmdb to see if it actually installed + # look for each pkg in rpmdb + # look for each pkg via obsoletes -def exec_install(module, items, action, pkgs, res, yum_basecmd): - cmd = yum_basecmd + [action] + pkgs + return res - if module.check_mode: - module.exit_json(changed=True, results=res['results'], changes=dict(installed=pkgs)) + def install(self, items, repoq): - lang_env = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C') - rc, out, err = module.run_command(cmd, environ_update=lang_env) + pkgs = [] + downgrade_pkgs = [] + res = {} + res['results'] = [] + res['msg'] = '' + res['rc'] = 0 + res['changed'] = False - if rc == 1: for spec in items: - # Fail on invalid urls: - if ('://' in spec and ('No package %s available.' % spec in out or 'Cannot open: %s. Skipping.' % spec in err)): - err = 'Package at %s could not be installed' % spec - module.fail_json(changed=False, msg=err, rc=rc) + pkg = None + downgrade_candidate = False - res['rc'] = rc - res['results'].append(out) - res['msg'] += err - res['changed'] = True + # check if pkgspec is installed (if possible for idempotence) + if spec.endswith('.rpm'): + if '://' not in spec and not os.path.exists(spec): + res['msg'] += "No RPM file matching '%s' found on system" % spec + res['results'].append("No RPM file matching '%s' found on system" % spec) + res['rc'] = 127 # Ensure the task fails in with-loop + self.module.fail_json(**res) - if ('Nothing to do' in out and rc == 0) or ('does not have any packages' in err): - res['changed'] = False + if '://' in spec: + with self.set_env_proxy(): + package = self.fetch_rpm_from_url(spec) + else: + package = spec - if rc != 0: - res['changed'] = False - module.fail_json(**res) - - # Fail if yum prints 'No space left on device' because that means some - # packages failed executing their post install scripts because of lack of - # free space (e.g. kernel package couldn't generate initramfs). Note that - # yum can still exit with rc=0 even if some post scripts didn't execute - # correctly. - if 'No space left on device' in (out or err): - res['changed'] = False - res['msg'] = 'No space left on device' - module.fail_json(**res) - - # FIXME - if we did an install - go and check the rpmdb to see if it actually installed - # look for each pkg in rpmdb - # look for each pkg via obsoletes - - return res - - -def install(module, items, repoq, yum_basecmd, conf_file, en_repos, dis_repos, en_plugins, dis_plugins, installroot='/', - allow_downgrade=False, disable_excludes=None): - - pkgs = [] - downgrade_pkgs = [] - res = {} - res['results'] = [] - res['msg'] = '' - res['rc'] = 0 - res['changed'] = False - - for spec in items: - pkg = None - downgrade_candidate = False - - # check if pkgspec is installed (if possible for idempotence) - if spec.endswith('.rpm'): - if '://' not in spec and not os.path.exists(spec): - res['msg'] += "No RPM file matching '%s' found on system" % spec - res['results'].append("No RPM file matching '%s' found on system" % spec) - res['rc'] = 127 # Ensure the task fails in with-loop - module.fail_json(**res) - - if '://' in spec: - with set_env_proxy(conf_file, installroot): - package = fetch_rpm_from_url(spec, module=module) - else: - package = spec - - # most common case is the pkg is already installed - envra = local_envra(package) - if envra is None: - module.fail_json(msg="Failed to get nevra information from RPM package: %s" % spec) - installed_pkgs = is_installed(module, repoq, envra, conf_file, en_repos=en_repos, - dis_repos=dis_repos, en_plugins=en_plugins, - dis_plugins=dis_plugins, installroot=installroot, disable_excludes=disable_excludes) - if installed_pkgs: - res['results'].append('%s providing %s is already installed' % (installed_pkgs[0], package)) - continue + # most common case is the pkg is already installed + envra = self.local_envra(package) + if envra is None: + self.module.fail_json(msg="Failed to get nevra information from RPM package: %s" % spec) + installed_pkgs = self.is_installed(repoq, envra) + if installed_pkgs: + res['results'].append('%s providing %s is already installed' % (installed_pkgs[0], package)) + continue - (name, ver, rel, epoch, arch) = splitFilename(envra) - installed_pkgs = is_installed(module, repoq, name, conf_file, en_repos=en_repos, - dis_repos=dis_repos, en_plugins=en_plugins, dis_plugins=dis_plugins, - installroot=installroot, disable_excludes=disable_excludes) - - # case for two same envr but differrent archs like x86_64 and i686 - if len(installed_pkgs) == 2: - (cur_name0, cur_ver0, cur_rel0, cur_epoch0, cur_arch0) = splitFilename(installed_pkgs[0]) - (cur_name1, cur_ver1, cur_rel1, cur_epoch1, cur_arch1) = splitFilename(installed_pkgs[1]) - cur_epoch0 = cur_epoch0 or '0' - cur_epoch1 = cur_epoch1 or '0' - compare = compareEVR((cur_epoch0, cur_ver0, cur_rel0), (cur_epoch1, cur_ver1, cur_rel1)) - if compare == 0 and cur_arch0 != cur_arch1: - for installed_pkg in installed_pkgs: - if installed_pkg.endswith(arch): - installed_pkgs = [installed_pkg] - - if len(installed_pkgs) == 1: - installed_pkg = installed_pkgs[0] - (cur_name, cur_ver, cur_rel, cur_epoch, cur_arch) = splitFilename(installed_pkg) - cur_epoch = cur_epoch or '0' - compare = compareEVR((cur_epoch, cur_ver, cur_rel), (epoch, ver, rel)) - - # compare > 0 -> higher version is installed - # compare == 0 -> exact version is installed - # compare < 0 -> lower version is installed - if compare > 0 and allow_downgrade: - downgrade_candidate = True - elif compare >= 0: + (name, ver, rel, epoch, arch) = splitFilename(envra) + installed_pkgs = self.is_installed(repoq, name) + + # case for two same envr but differrent archs like x86_64 and i686 + if len(installed_pkgs) == 2: + (cur_name0, cur_ver0, cur_rel0, cur_epoch0, cur_arch0) = splitFilename(installed_pkgs[0]) + (cur_name1, cur_ver1, cur_rel1, cur_epoch1, cur_arch1) = splitFilename(installed_pkgs[1]) + cur_epoch0 = cur_epoch0 or '0' + cur_epoch1 = cur_epoch1 or '0' + compare = compareEVR((cur_epoch0, cur_ver0, cur_rel0), (cur_epoch1, cur_ver1, cur_rel1)) + if compare == 0 and cur_arch0 != cur_arch1: + for installed_pkg in installed_pkgs: + if installed_pkg.endswith(arch): + installed_pkgs = [installed_pkg] + + if len(installed_pkgs) == 1: + installed_pkg = installed_pkgs[0] + (cur_name, cur_ver, cur_rel, cur_epoch, cur_arch) = splitFilename(installed_pkg) + cur_epoch = cur_epoch or '0' + compare = compareEVR((cur_epoch, cur_ver, cur_rel), (epoch, ver, rel)) + + # compare > 0 -> higher version is installed + # compare == 0 -> exact version is installed + # compare < 0 -> lower version is installed + if compare > 0 and self.allow_downgrade: + downgrade_candidate = True + elif compare >= 0: + continue + + # else: if there are more installed packages with the same name, that would mean + # kernel, gpg-pubkey or like, so just let yum deal with it and try to install it + + pkg = package + + # groups + elif spec.startswith('@'): + if self.is_group_env_installed(spec): continue - # else: if there are more installed packages with the same name, that would mean - # kernel, gpg-pubkey or like, so just let yum deal with it and try to install it + pkg = spec - pkg = package + # range requires or file-requires or pkgname :( + else: + # most common case is the pkg is already installed and done + # short circuit all the bs - and search for it as a pkg in is_installed + # if you find it then we're done + if not set(['*', '?']).intersection(set(spec)): + installed_pkgs = self.is_installed(repoq, spec, is_pkg=True) + if installed_pkgs: + res['results'].append('%s providing %s is already installed' % (installed_pkgs[0], spec)) + continue + + # look up what pkgs provide this + pkglist = self.what_provides(repoq, spec) + if not pkglist: + res['msg'] += "No package matching '%s' found available, installed or updated" % spec + res['results'].append("No package matching '%s' found available, installed or updated" % spec) + res['rc'] = 126 # Ensure the task fails in with-loop + self.module.fail_json(**res) + + # if any of the packages are involved in a transaction, fail now + # so that we don't hang on the yum operation later + conflicts = self.transaction_exists(pkglist) + if conflicts: + res['msg'] += "The following packages have pending transactions: %s" % ", ".join(conflicts) + res['rc'] = 125 # Ensure the task fails in with-loop + self.module.fail_json(**res) + + # if any of them are installed + # then nothing to do + + found = False + for this in pkglist: + if self.is_installed(repoq, this, is_pkg=True): + found = True + res['results'].append('%s providing %s is already installed' % (this, spec)) + break + + # if the version of the pkg you have installed is not in ANY repo, but there are + # other versions in the repos (both higher and lower) then the previous checks won't work. + # so we check one more time. This really only works for pkgname - not for file provides or virt provides + # but virt provides should be all caught in what_provides on its own. + # highly irritating + if not found: + if self.is_installed(repoq, spec): + found = True + res['results'].append('package providing %s is already installed' % (spec)) + + if found: + continue - # groups - elif spec.startswith('@'): - if is_group_env_installed(spec, conf_file, en_plugins=en_plugins, dis_plugins=dis_plugins, installroot=installroot, - disable_excludes=disable_excludes): - continue + # Downgrade - The yum install command will only install or upgrade to a spec version, it will + # not install an older version of an RPM even if specified by the install spec. So we need to + # determine if this is a downgrade, and then use the yum downgrade command to install the RPM. + if self.allow_downgrade: + for package in pkglist: + # Get the NEVRA of the requested package using pkglist instead of spec because pkglist + # contains consistently-formatted package names returned by yum, rather than user input + # that is often not parsed correctly by splitFilename(). + (name, ver, rel, epoch, arch) = splitFilename(package) + + # Check if any version of the requested package is installed + inst_pkgs = self.is_installed(repoq, name, is_pkg=True) + if inst_pkgs: + (cur_name, cur_ver, cur_rel, cur_epoch, cur_arch) = splitFilename(inst_pkgs[0]) + compare = compareEVR((cur_epoch, cur_ver, cur_rel), (epoch, ver, rel)) + if compare > 0: + downgrade_candidate = True + else: + downgrade_candidate = False + break + + # If package needs to be installed/upgraded/downgraded, then pass in the spec + # we could get here if nothing provides it but that's not + # the error we're catching here + pkg = spec + + if downgrade_candidate and self.allow_downgrade: + downgrade_pkgs.append(pkg) + else: + pkgs.append(pkg) - pkg = spec + if downgrade_pkgs: + res = self.exec_install(items, 'downgrade', downgrade_pkgs, res) - # range requires or file-requires or pkgname :( - else: - # most common case is the pkg is already installed and done - # short circuit all the bs - and search for it as a pkg in is_installed - # if you find it then we're done - if not set(['*', '?']).intersection(set(spec)): - installed_pkgs = is_installed(module, repoq, spec, conf_file, en_repos=en_repos, - dis_repos=dis_repos, en_plugins=en_plugins, - dis_plugins=dis_plugins, is_pkg=True, - installroot=installroot, disable_excludes=disable_excludes) - if installed_pkgs: - res['results'].append('%s providing %s is already installed' % (installed_pkgs[0], spec)) - continue + if pkgs: + res = self.exec_install(items, 'install', pkgs, res) - # look up what pkgs provide this - pkglist = what_provides(module, repoq, yum_basecmd, spec, conf_file, en_repos=en_repos, - dis_repos=dis_repos, en_plugins=en_plugins, dis_plugins=dis_plugins, - installroot=installroot, disable_excludes=disable_excludes) - if not pkglist: - res['msg'] += "No package matching '%s' found available, installed or updated" % spec - res['results'].append("No package matching '%s' found available, installed or updated" % spec) - res['rc'] = 126 # Ensure the task fails in with-loop - module.fail_json(**res) - - # if any of the packages are involved in a transaction, fail now - # so that we don't hang on the yum operation later - conflicts = transaction_exists(pkglist) - if conflicts: - res['msg'] += "The following packages have pending transactions: %s" % ", ".join(conflicts) - res['rc'] = 125 # Ensure the task fails in with-loop - module.fail_json(**res) - - # if any of them are installed - # then nothing to do - - found = False - for this in pkglist: - if is_installed(module, repoq, this, conf_file, en_repos=en_repos, dis_repos=dis_repos, - en_plugins=en_plugins, dis_plugins=dis_plugins, is_pkg=True, - installroot=installroot, disable_excludes=disable_excludes): - found = True - res['results'].append('%s providing %s is already installed' % (this, spec)) - break - - # if the version of the pkg you have installed is not in ANY repo, but there are - # other versions in the repos (both higher and lower) then the previous checks won't work. - # so we check one more time. This really only works for pkgname - not for file provides or virt provides - # but virt provides should be all caught in what_provides on its own. - # highly irritating - if not found: - if is_installed(module, repoq, spec, conf_file, en_repos=en_repos, dis_repos=dis_repos, - en_plugins=en_plugins, dis_plugins=dis_plugins, installroot=installroot, - disable_excludes=disable_excludes): - found = True - res['results'].append('package providing %s is already installed' % (spec)) - - if found: - continue + return res - # Downgrade - The yum install command will only install or upgrade to a spec version, it will - # not install an older version of an RPM even if specified by the install spec. So we need to - # determine if this is a downgrade, and then use the yum downgrade command to install the RPM. - if allow_downgrade: - for package in pkglist: - # Get the NEVRA of the requested package using pkglist instead of spec because pkglist - # contains consistently-formatted package names returned by yum, rather than user input - # that is often not parsed correctly by splitFilename(). - (name, ver, rel, epoch, arch) = splitFilename(package) - - # Check if any version of the requested package is installed - inst_pkgs = is_installed(module, repoq, name, conf_file, en_repos=en_repos, - dis_repos=dis_repos, en_plugins=en_plugins, - dis_plugins=dis_plugins, is_pkg=True, disable_excludes=disable_excludes) - if inst_pkgs: - (cur_name, cur_ver, cur_rel, cur_epoch, cur_arch) = splitFilename(inst_pkgs[0]) - compare = compareEVR((cur_epoch, cur_ver, cur_rel), (epoch, ver, rel)) - if compare > 0: - downgrade_candidate = True - else: - downgrade_candidate = False - break - - # If package needs to be installed/upgraded/downgraded, then pass in the spec - # we could get here if nothing provides it but that's not - # the error we're catching here - pkg = spec - - if downgrade_candidate and allow_downgrade: - downgrade_pkgs.append(pkg) - else: - pkgs.append(pkg) + def remove(self, items, repoq): - if downgrade_pkgs: - res = exec_install(module, items, 'downgrade', downgrade_pkgs, res, yum_basecmd) + pkgs = [] + res = {} + res['results'] = [] + res['msg'] = '' + res['changed'] = False + res['rc'] = 0 + + for pkg in items: + if pkg.startswith('@'): + installed = self.is_group_env_installed(pkg) + else: + installed = self.is_installed(repoq, pkg) - if pkgs: - res = exec_install(module, items, 'install', pkgs, res, yum_basecmd) + if installed: + pkgs.append(pkg) + else: + res['results'].append('%s is not installed' % pkg) - return res + if pkgs: + if self.module.check_mode: + self.module.exit_json(changed=True, results=res['results'], changes=dict(removed=pkgs)) + # run an actual yum transaction + if self.autoremove: + cmd = self.yum_basecmd + ["autoremove"] + pkgs + else: + cmd = self.yum_basecmd + ["remove"] + pkgs -def remove(module, items, repoq, yum_basecmd, conf_file, en_repos, dis_repos, en_plugins, dis_plugins, - installroot='/', disable_excludes=None): + rc, out, err = self.module.run_command(cmd) - pkgs = [] - res = {} - res['results'] = [] - res['msg'] = '' - res['changed'] = False - res['rc'] = 0 + res['rc'] = rc + res['results'].append(out) + res['msg'] = err - for pkg in items: - if pkg.startswith('@'): - installed = is_group_env_installed(pkg, conf_file, en_plugins=en_plugins, dis_plugins=dis_plugins, installroot=installroot, - disable_excludes=disable_excludes) - else: - installed = is_installed(module, repoq, pkg, conf_file, en_repos=en_repos, dis_repos=dis_repos, en_plugins=en_plugins, - dis_plugins=dis_plugins, installroot=installroot, disable_excludes=disable_excludes) + if rc != 0: + if self.autoremove: + if 'No such command' not in out: + self.module.fail_json(msg='Version of YUM too old for autoremove: Requires yum 3.4.3 (RHEL/CentOS 7+)') + else: + self.module.fail_json(**res) - if installed: - pkgs.append(pkg) - else: - res['results'].append('%s is not installed' % pkg) + # compile the results into one batch. If anything is changed + # then mark changed + # at the end - if we've end up failed then fail out of the rest + # of the process - if pkgs: - if module.check_mode: - module.exit_json(changed=True, results=res['results'], changes=dict(removed=pkgs)) + # at this point we check to see if the pkg is no longer present + for pkg in pkgs: + if pkg.startswith('@'): + installed = self.is_group_env_installed(pkg) + else: + installed = self.is_installed(repoq, pkg) - # run an actual yum transaction - cmd = yum_basecmd + ["remove"] + pkgs + if installed: + self.module.fail_json(**res) - rc, out, err = module.run_command(cmd) + res['changed'] = True - res['rc'] = rc - res['results'].append(out) - res['msg'] = err + return res - if rc != 0: - module.fail_json(**res) + def run_check_update(self): + # run check-update to see if we have packages pending + rc, out, err = self.module.run_command(self.yum_basecmd + ['check-update']) + return rc, out, err - # compile the results into one batch. If anything is changed - # then mark changed - # at the end - if we've end up failed then fail out of the rest - # of the process + @staticmethod + def parse_check_update(check_update_output): + updates = {} - # at this point we check to see if the pkg is no longer present - for pkg in pkgs: - if pkg.startswith('@'): - installed = is_group_env_installed(pkg, conf_file, en_plugins=en_plugins, dis_plugins=dis_plugins, - installroot=installroot, disable_excludes=disable_excludes) + # remove incorrect new lines in longer columns in output from yum check-update + # yum line wrapping can move the repo to the next line + # + # Meant to filter out sets of lines like: + # some_looooooooooooooooooooooooooooooooooooong_package_name 1:1.2.3-1.el7 + # some-repo-label + # + # But it also needs to avoid catching lines like: + # Loading mirror speeds from cached hostfile + # + # ceph.x86_64 1:11.2.0-0.el7 ceph + + # preprocess string and filter out empty lines so the regex below works + out = re.sub(r'\n[^\w]\W+(.*)', r' \1', check_update_output) + + available_updates = out.split('\n') + + # build update dictionary + for line in available_updates: + line = line.split() + # ignore irrelevant lines + # '*' in line matches lines like mirror lists: + # * base: mirror.corbina.net + # len(line) != 3 could be junk or a continuation + # + # FIXME: what is the '.' not in line conditional for? + + if '*' in line or len(line) != 3 or '.' not in line[0]: + continue else: - installed = is_installed(module, repoq, pkg, conf_file, en_repos=en_repos, dis_repos=dis_repos, en_plugins=en_plugins, - dis_plugins=dis_plugins, installroot=installroot, disable_excludes=disable_excludes) + pkg, version, repo = line + name, dist = pkg.rsplit('.', 1) + updates.update({name: {'version': version, 'dist': dist, 'repo': repo}}) + return updates - if installed: - module.fail_json(**res) + def latest(self, items, repoq): - res['changed'] = True + res = {} + res['results'] = [] + res['msg'] = '' + res['changed'] = False + res['rc'] = 0 + pkgs = {} + pkgs['update'] = [] + pkgs['install'] = [] + updates = {} + update_all = False + cmd = None + + # determine if we're doing an update all + if '*' in items: + update_all = True + + rc, out, err = self.run_check_update() + + if rc == 0 and update_all: + res['results'].append('Nothing to do here, all packages are up to date') + return res + elif rc == 100: + updates = self.parse_check_update(out) + elif rc == 1: + res['msg'] = err + res['rc'] = rc + self.module.fail_json(**res) + + if update_all: + cmd = self.yum_basecmd + ['update'] + will_update = set(updates.keys()) + will_update_from_other_package = dict() + else: + will_update = set() + will_update_from_other_package = dict() + for spec in items: + # some guess work involved with groups. update @ will install the group if missing + if spec.startswith('@'): + pkgs['update'].append(spec) + will_update.add(spec) + continue - return res + # check if pkgspec is installed (if possible for idempotence) + # localpkg + elif spec.endswith('.rpm') and '://' not in spec: + if not os.path.exists(spec): + res['msg'] += "No RPM file matching '%s' found on system" % spec + res['results'].append("No RPM file matching '%s' found on system" % spec) + res['rc'] = 127 # Ensure the task fails in with-loop + self.module.fail_json(**res) + # get the pkg e:name-v-r.arch + envra = self.local_envra(spec) -def run_check_update(module, yum_basecmd): - # run check-update to see if we have packages pending - rc, out, err = module.run_command(yum_basecmd + ['check-update']) - return rc, out, err + if envra is None: + self.module.fail_json(msg="Failed to get nevra information from RPM package: %s" % spec) + # local rpm files can't be updated + if not self.is_installed(repoq, envra): + pkgs['install'].append(spec) + continue -def parse_check_update(check_update_output): - updates = {} + # URL + elif '://' in spec: + # download package so that we can check if it's already installed + with self.set_env_proxy(): + package = self.fetch_rpm_from_url(spec) + envra = self.local_envra(package) - # remove incorrect new lines in longer columns in output from yum check-update - # yum line wrapping can move the repo to the next line - # - # Meant to filter out sets of lines like: - # some_looooooooooooooooooooooooooooooooooooong_package_name 1:1.2.3-1.el7 - # some-repo-label - # - # But it also needs to avoid catching lines like: - # Loading mirror speeds from cached hostfile - # - # ceph.x86_64 1:11.2.0-0.el7 ceph + if envra is None: + self.module.fail_json(msg="Failed to get nevra information from RPM package: %s" % spec) + + # local rpm files can't be updated + if not self.is_installed(repoq, envra): + pkgs['install'].append(package) + continue + + # dep/pkgname - find it + else: + if self.is_installed(repoq, spec) or self.update_only: + pkgs['update'].append(spec) + else: + pkgs['install'].append(spec) + pkglist = self.what_provides(repoq, spec) + # FIXME..? may not be desirable to throw an exception here if a single package is missing + if not pkglist: + res['msg'] += "No package matching '%s' found available, installed or updated" % spec + res['results'].append("No package matching '%s' found available, installed or updated" % spec) + res['rc'] = 126 # Ensure the task fails in with-loop + self.module.fail_json(**res) + + nothing_to_do = True + for pkg in pkglist: + if spec in pkgs['install'] and self.is_available(repoq, pkg): + nothing_to_do = False + break + + # this contains the full NVR and spec could contain wildcards + # or virtual provides (like "python-*" or "smtp-daemon") while + # updates contains name only. + pkgname, _, _, _, _ = splitFilename(pkg) + if spec in pkgs['update'] and pkgname in updates: + nothing_to_do = False + will_update.add(spec) + # Massage the updates list + if spec != pkgname: + # For reporting what packages would be updated more + # succinctly + will_update_from_other_package[spec] = pkgname + break + + if not self.is_installed(repoq, spec) and self.update_only: + res['results'].append("Packages providing %s not installed due to update_only specified" % spec) + continue + if nothing_to_do: + res['results'].append("All packages providing %s are up to date" % spec) + continue - # preprocess string and filter out empty lines so the regex below works - out = re.sub(r'\n[^\w]\W+(.*)', r' \1', - check_update_output) + # if any of the packages are involved in a transaction, fail now + # so that we don't hang on the yum operation later + conflicts = self.transaction_exists(pkglist) + if conflicts: + res['msg'] += "The following packages have pending transactions: %s" % ", ".join(conflicts) + res['results'].append("The following packages have pending transactions: %s" % ", ".join(conflicts)) + res['rc'] = 128 # Ensure the task fails in with-loop + self.module.fail_json(**res) + + # check_mode output + if self.module.check_mode: + to_update = [] + for w in will_update: + if w.startswith('@'): + to_update.append((w, None)) + elif w not in updates: + other_pkg = will_update_from_other_package[w] + to_update.append( + ( + w, + 'because of (at least) %s-%s.%s from %s' % ( + other_pkg, + updates[other_pkg]['version'], + updates[other_pkg]['dist'], + updates[other_pkg]['repo'] + ) + ) + ) + else: + to_update.append((w, '%s.%s from %s' % (updates[w]['version'], updates[w]['dist'], updates[w]['repo']))) - available_updates = out.split('\n') + res['changes'] = dict(installed=pkgs['install'], updated=to_update) - # build update dictionary - for line in available_updates: - line = line.split() - # ignore irrelevant lines - # '*' in line matches lines like mirror lists: - # * base: mirror.corbina.net - # len(line) != 3 could be junk or a continuation - # - # FIXME: what is the '.' not in line conditional for? + if will_update or pkgs['install']: + res['changed'] = True + + return res - if '*' in line or len(line) != 3 or '.' not in line[0]: - continue + # run commands + if cmd: # update all + rc, out, err = self.module.run_command(cmd) + res['changed'] = True + elif pkgs['install'] or will_update: + cmd = self.yum_basecmd + ['install'] + pkgs['install'] + pkgs['update'] + lang_env = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C') + rc, out, err = self.module.run_command(cmd, environ_update=lang_env) + out_lower = out.strip().lower() + if not out_lower.endswith("no packages marked for update") and \ + not out_lower.endswith("nothing to do"): + res['changed'] = True else: - pkg, version, repo = line - name, dist = pkg.rsplit('.', 1) - updates.update({name: {'version': version, 'dist': dist, 'repo': repo}}) - return updates - - -def latest(module, items, repoq, yum_basecmd, conf_file, en_repos, dis_repos, en_plugins, dis_plugins, update_only, - installroot='/', disable_excludes=None): - - res = {} - res['results'] = [] - res['msg'] = '' - res['changed'] = False - res['rc'] = 0 - pkgs = {} - pkgs['update'] = [] - pkgs['install'] = [] - updates = {} - update_all = False - cmd = None - - # determine if we're doing an update all - if '*' in items: - update_all = True - - rc, out, err = run_check_update(module, yum_basecmd) - - if rc == 0 and update_all: - res['results'].append('Nothing to do here, all packages are up to date') - return res - elif rc == 100: - updates = parse_check_update(out) - elif rc == 1: - res['msg'] = err + rc, out, err = [0, '', ''] + res['rc'] = rc - module.fail_json(**res) - - if update_all: - cmd = yum_basecmd + ['update'] - will_update = set(updates.keys()) - will_update_from_other_package = dict() - else: - will_update = set() - will_update_from_other_package = dict() - for spec in items: - # some guess work involved with groups. update @ will install the group if missing - if spec.startswith('@'): - pkgs['update'].append(spec) - will_update.add(spec) - continue + res['msg'] += err + res['results'].append(out) - # check if pkgspec is installed (if possible for idempotence) - # localpkg - elif spec.endswith('.rpm') and '://' not in spec: - if not os.path.exists(spec): - res['msg'] += "No RPM file matching '%s' found on system" % spec - res['results'].append("No RPM file matching '%s' found on system" % spec) - res['rc'] = 127 # Ensure the task fails in with-loop - module.fail_json(**res) + if rc: + res['failed'] = True - # get the pkg e:name-v-r.arch - envra = local_envra(spec) + return res - if envra is None: - module.fail_json(msg="Failed to get nevra information from RPM package: %s" % spec) + def ensure(self, repoq): - # local rpm files can't be updated - if not is_installed(module, repoq, envra, conf_file, en_repos=en_repos, dis_repos=dis_repos, en_plugins=en_plugins, - dis_plugins=dis_plugins, installroot=installroot, disable_excludes=disable_excludes): - pkgs['install'].append(spec) - continue + pkgs = self.names - # URL - elif '://' in spec: - # download package so that we can check if it's already installed - with set_env_proxy(conf_file, installroot): - package = fetch_rpm_from_url(spec, module=module) - envra = local_envra(package) + # autoremove was provided without `name` + if not self.names and self.autoremove: + pkgs = [] + self.state = 'absent' - if envra is None: - module.fail_json(msg="Failed to get nevra information from RPM package: %s" % spec) + if self.conf_file and os.path.exists(self.conf_file): + self.yum_basecmd += ['-c', self.conf_file] - # local rpm files can't be updated - if not is_installed(module, repoq, envra, conf_file, en_repos=en_repos, dis_repos=dis_repos, en_plugins=en_plugins, - dis_plugins=dis_plugins, installroot=installroot, disable_excludes=disable_excludes): - pkgs['install'].append(package) - continue + if repoq: + repoq += ['-c', self.conf_file] - # dep/pkgname - find it - else: - if is_installed(module, repoq, spec, conf_file, en_repos=en_repos, dis_repos=dis_repos, - en_plugins=en_plugins, dis_plugins=dis_plugins, installroot=installroot, - disable_excludes=disable_excludes) or update_only: - pkgs['update'].append(spec) - else: - pkgs['install'].append(spec) - pkglist = what_provides(module, repoq, yum_basecmd, spec, conf_file, en_repos=en_repos, - dis_repos=dis_repos, en_plugins=en_plugins, dis_plugins=dis_plugins, - installroot=installroot, disable_excludes=disable_excludes) - # FIXME..? may not be desirable to throw an exception here if a single package is missing - if not pkglist: - res['msg'] += "No package matching '%s' found available, installed or updated" % spec - res['results'].append("No package matching '%s' found available, installed or updated" % spec) - res['rc'] = 126 # Ensure the task fails in with-loop - module.fail_json(**res) - - nothing_to_do = True - for pkg in pkglist: - if spec in pkgs['install'] and is_available(module, repoq, pkg, conf_file, - en_repos=en_repos, dis_repos=dis_repos, - en_plugins=en_plugins, dis_plugins=dis_plugins, - installroot=installroot, disable_excludes=disable_excludes): - nothing_to_do = False - break - - # this contains the full NVR and spec could contain wildcards - # or virtual provides (like "python-*" or "smtp-daemon") while - # updates contains name only. - pkgname, _, _, _, _ = splitFilename(pkg) - if spec in pkgs['update'] and pkgname in updates: - nothing_to_do = False - will_update.add(spec) - # Massage the updates list - if spec != pkgname: - # For reporting what packages would be updated more - # succinctly - will_update_from_other_package[spec] = pkgname - break - - if not is_installed(module, repoq, spec, conf_file, en_repos=en_repos, - dis_repos=dis_repos, en_plugins=en_plugins, - dis_plugins=dis_plugins, installroot=installroot, disable_excludes=disable_excludes) and update_only: - res['results'].append("Packages providing %s not installed due to update_only specified" % spec) - continue - if nothing_to_do: - res['results'].append("All packages providing %s are up to date" % spec) - continue + if self.skip_broken: + self.yum_basecmd.extend(['--skip-broken']) - # if any of the packages are involved in a transaction, fail now - # so that we don't hang on the yum operation later - conflicts = transaction_exists(pkglist) - if conflicts: - res['msg'] += "The following packages have pending transactions: %s" % ", ".join(conflicts) - res['results'].append("The following packages have pending transactions: %s" % ", ".join(conflicts)) - res['rc'] = 128 # Ensure the task fails in with-loop - module.fail_json(**res) - - # check_mode output - if module.check_mode: - to_update = [] - for w in will_update: - if w.startswith('@'): - to_update.append((w, None)) - elif w not in updates: - other_pkg = will_update_from_other_package[w] - to_update.append((w, 'because of (at least) %s-%s.%s from %s' % (other_pkg, - updates[other_pkg]['version'], - updates[other_pkg]['dist'], - updates[other_pkg]['repo']))) - else: - to_update.append((w, '%s.%s from %s' % (updates[w]['version'], updates[w]['dist'], updates[w]['repo']))) + if self.disablerepo: + self.yum_basecmd.extend(['--disablerepo=%s' % ','.join(self.disablerepo)]) - res['changes'] = dict(installed=pkgs['install'], updated=to_update) + if self.enablerepo: + self.yum_basecmd.extend(['--enablerepo=%s' % ','.join(self.enablerepo)]) - if will_update or pkgs['install']: - res['changed'] = True + if self.enable_plugin: + self.yum_basecmd.extend(['--enableplugin', ','.join(self.enable_plugin)]) - return res + if self.disable_plugin: + self.yum_basecmd.extend(['--disableplugin', ','.join(self.disable_plugin)]) - # run commands - if cmd: # update all - rc, out, err = module.run_command(cmd) - res['changed'] = True - elif pkgs['install'] or will_update: - cmd = yum_basecmd + ['install'] + pkgs['install'] + pkgs['update'] - lang_env = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C') - rc, out, err = module.run_command(cmd, environ_update=lang_env) - out_lower = out.strip().lower() - if not out_lower.endswith("no packages marked for update") and \ - not out_lower.endswith("nothing to do"): - res['changed'] = True - else: - rc, out, err = [0, '', ''] + if self.exclude: + e_cmd = ['--exclude=%s' % ','.join(self.exclude)] + self.yum_basecmd.extend(e_cmd) - res['rc'] = rc - res['msg'] += err - res['results'].append(out) + if self.disable_excludes: + self.yum_basecmd.extend(['--disableexcludes=%s' % self.disable_excludes]) - if rc: - res['failed'] = True + if self.download_only: + self.yum_basecmd.extend(['--downloadonly']) - return res + if self.installroot != '/': + # do not setup installroot by default, because of error + # CRITICAL:yum.cli:Config Error: Error accessing file for config file:////etc/yum.conf + # in old yum version (like in CentOS 6.6) + e_cmd = ['--installroot=%s' % self.installroot] + self.yum_basecmd.extend(e_cmd) + if self.state in ('installed', 'present', 'latest'): + """ The need of this entire if conditional has to be chalanged + this function is the ensure function that is called + in the main section. -def ensure(module, state, pkgs, conf_file, enablerepo, disablerepo, - disable_gpg_check, exclude, repoq, skip_broken, update_only, security, - bugfix, installroot='/', allow_downgrade=False, disable_plugin=None, - enable_plugin=None, disable_excludes=None, download_only=False): + This conditional tends to disable/enable repo for + install present latest action, same actually + can be done for remove and absent action - # fedora will redirect yum to dnf, which has incompatibilities - # with how this module expects yum to operate. If yum-deprecated - # is available, use that instead to emulate the old behaviors. - if module.get_bin_path('yum-deprecated'): - yumbin = module.get_bin_path('yum-deprecated') - else: - yumbin = module.get_bin_path('yum') + As solution I would advice to cal + try: my.repos.disableRepo(disablerepo) + and + try: my.repos.enableRepo(enablerepo) + right before any yum_cmd is actually called regardless + of yum action. - # need debug level 2 to get 'Nothing to do' for groupinstall. - yum_basecmd = [yumbin, '-d', '2', '-y'] + Please note that enable/disablerepo options are general + options, this means that we can call those with any action + option. https://linux.die.net/man/8/yum - if conf_file and os.path.exists(conf_file): - yum_basecmd += ['-c', conf_file] - if repoq: - repoq += ['-c', conf_file] - - dis_repos = [] - en_repos = [] - - if skip_broken: - yum_basecmd.extend(['--skip-broken']) - - if disablerepo: - dis_repos = disablerepo.split(',') - r_cmd = ['--disablerepo=%s' % disablerepo] - yum_basecmd.extend(r_cmd) - if enablerepo: - en_repos = enablerepo.split(',') - r_cmd = ['--enablerepo=%s' % enablerepo] - yum_basecmd.extend(r_cmd) - - if enable_plugin: - yum_basecmd.extend(['--enableplugin', ','.join(enable_plugin)]) - - if disable_plugin: - yum_basecmd.extend(['--disableplugin', ','.join(disable_plugin)]) - - if exclude: - e_cmd = ['--exclude=%s' % exclude] - yum_basecmd.extend(e_cmd) - - if disable_excludes: - yum_basecmd.extend(['--disableexcludes=%s' % disable_excludes]) - - if download_only: - yum_basecmd.extend(['--downloadonly']) - - if installroot != '/': - # do not setup installroot by default, because of error - # CRITICAL:yum.cli:Config Error: Error accessing file for config file:////etc/yum.conf - # in old yum version (like in CentOS 6.6) - e_cmd = ['--installroot=%s' % installroot] - yum_basecmd.extend(e_cmd) - - if state in ('installed', 'present', 'latest'): - """ The need of this entire if conditional has to be chalanged - this function is the ensure function that is called - in the main section. - - This conditional tends to disable/enable repo for - install present latest action, same actually - can be done for remove and absent action - - As solution I would advice to cal - try: my.repos.disableRepo(disablerepo) - and - try: my.repos.enableRepo(enablerepo) - right before any yum_cmd is actually called regardless - of yum action. - - Please note that enable/disablerepo options are general - options, this means that we can call those with any action - option. https://linux.die.net/man/8/yum - - This docstring will be removed together when issue: #21619 - will be solved. - - This has been triggered by: #19587 + This docstring will be removed together when issue: #21619 + will be solved. + + This has been triggered by: #19587 + """ + + if self.update_cache: + self.module.run_command(self.yum_basecmd + ['clean', 'expire-cache']) + + my = self.yum_base() + try: + if self.disablerepo: + my.repos.disableRepo(self.disablerepo) + current_repos = my.repos.repos.keys() + if self.enablerepo: + try: + for rid in self.enablerepo: + my.repos.enableRepo(rid) + new_repos = my.repos.repos.keys() + for i in new_repos: + if i not in current_repos: + rid = my.repos.getRepo(i) + a = rid.repoXML.repoid # nopep8 - https://github.com/ansible/ansible/pull/21475#pullrequestreview-22404868 + current_repos = new_repos + except yum.Errors.YumBaseError as e: + self.module.fail_json(msg="Error setting/accessing repos: %s" % to_native(e)) + except yum.Errors.YumBaseError as e: + self.module.fail_json(msg="Error accessing repos: %s" % to_native(e)) + if self.state in ('installed', 'present'): + if self.disable_gpg_check: + self.yum_basecmd.append('--nogpgcheck') + res = self.install(pkgs, repoq) + elif self.state in ('removed', 'absent'): + res = self.remove(pkgs, repoq) + elif self.state == 'latest': + if self.disable_gpg_check: + self.yum_basecmd.append('--nogpgcheck') + if self.security: + self.yum_basecmd.append('--security') + if self.bugfix: + self.yum_basecmd.append('--bugfix') + res = self.latest(pkgs, repoq) + else: + # should be caught by AnsibleModule argument_spec + self.module.fail_json( + msg="we should never get here unless this all failed", + changed=False, + results='', + errors='unexpected state' + ) + return res + + @staticmethod + def has_yum(): + return HAS_YUM_PYTHON + + def run(self): + """ + actually execute the module code backend """ - if module.params.get('update_cache'): - module.run_command(yum_basecmd + ['clean', 'expire-cache']) + error_msgs = [] + if not HAS_RPM_PYTHON: + error_msgs.append('The Python 2 bindings for rpm are needed for this module. If you require Python 3 support use the `dnf` Ansible module instead.') + if not HAS_YUM_PYTHON: + error_msgs.append('The Python 2 yum module is needed for this module. If you require Python 3 support use the `dnf` Ansible module instead.') - my = yum_base(conf_file, installroot, enable_plugin, disable_plugin, disable_excludes) - try: - if disablerepo: - my.repos.disableRepo(disablerepo) - current_repos = my.repos.repos.keys() - if enablerepo: - try: - my.repos.enableRepo(enablerepo) - new_repos = my.repos.repos.keys() - for i in new_repos: - if i not in current_repos: - rid = my.repos.getRepo(i) - a = rid.repoXML.repoid # nopep8 - https://github.com/ansible/ansible/pull/21475#pullrequestreview-22404868 - current_repos = new_repos - except yum.Errors.YumBaseError as e: - module.fail_json(msg="Error setting/accessing repos: %s" % to_native(e)) - except yum.Errors.YumBaseError as e: - module.fail_json(msg="Error accessing repos: %s" % to_native(e)) - if state in ['installed', 'present']: - if disable_gpg_check: - yum_basecmd.append('--nogpgcheck') - res = install(module, pkgs, repoq, yum_basecmd, conf_file, en_repos, dis_repos, - enable_plugin, disable_plugin, installroot=installroot, - allow_downgrade=allow_downgrade, disable_excludes=disable_excludes) - elif state in ['removed', 'absent']: - res = remove(module, pkgs, repoq, yum_basecmd, conf_file, en_repos, dis_repos, enable_plugin, disable_plugin, - installroot=installroot, disable_excludes=disable_excludes) - elif state == 'latest': - if disable_gpg_check: - yum_basecmd.append('--nogpgcheck') - if security: - yum_basecmd.append('--security') - if bugfix: - yum_basecmd.append('--bugfix') - res = latest(module, pkgs, repoq, yum_basecmd, conf_file, en_repos, dis_repos, enable_plugin, disable_plugin, update_only, - installroot=installroot, disable_excludes=disable_excludes) - else: - # should be caught by AnsibleModule argument_spec - module.fail_json(msg="we should never get here unless this all failed", - changed=False, results='', errors='unexpected state') - return res + if self.disable_excludes and yum.__version_info__ < (3, 4): + self.module.fail_json(msg="'disable_includes' is available in yum version 3.4 and onwards.") + + if error_msgs: + self.module.fail_json(msg='. '.join(error_msgs)) + + # fedora will redirect yum to dnf, which has incompatibilities + # with how this module expects yum to operate. If yum-deprecated + # is available, use that instead to emulate the old behaviors. + if self.module.get_bin_path('yum-deprecated'): + yumbin = self.module.get_bin_path('yum-deprecated') + else: + yumbin = self.module.get_bin_path('yum') + + # need debug level 2 to get 'Nothing to do' for groupinstall. + self.yum_basecmd = [yumbin, '-d', '2', '-y'] + + repoquerybin = self.module.get_bin_path('repoquery', required=False) + + if self.install_repoquery and not repoquerybin and not self.module.check_mode: + yum_path = self.module.get_bin_path('yum') + if yum_path: + self.module.run_command('%s -y install yum-utils' % yum_path) + repoquerybin = self.module.get_bin_path('repoquery', required=False) + + if self.list: + if not repoquerybin: + self.module.fail_json(msg="repoquery is required to use list= with this module. Please install the yum-utils package.") + results = {'results': self.list_stuff(repoquerybin, self.list)} + else: + # If rhn-plugin is installed and no rhn-certificate is available on + # the system then users will see an error message using the yum API. + # Use repoquery in those cases. + + my = self.yum_base() + # A sideeffect of accessing conf is that the configuration is + # loaded and plugins are discovered + my.conf + repoquery = None + try: + yum_plugins = my.plugins._plugins + except AttributeError: + pass + else: + if 'rhnplugin' in yum_plugins: + if repoquerybin: + repoquery = [repoquerybin, '--show-duplicates', '--plugins', '--quiet'] + if self.installroot != '/': + repoquery.extend(['--installroot', self.installroot]) + + results = self.ensure(repoquery) + if repoquery: + results['msg'] = '%s %s' % ( + results.get('msg', ''), + 'Warning: Due to potential bad behaviour with rhnplugin and certificates, used slower repoquery calls instead of Yum API.' + ) + + self.module.exit_json(**results) def main(): @@ -1471,104 +1502,11 @@ def main(): # list=pkgspec module = AnsibleModule( - argument_spec=dict( - name=dict(type='list', aliases=['pkg']), - exclude=dict(type='str'), - # removed==absent, installed==present, these are accepted as aliases - state=dict(type='str', default='installed', choices=['absent', 'installed', 'latest', 'present', 'removed']), - enablerepo=dict(type='str'), - disablerepo=dict(type='str'), - list=dict(type='str'), - conf_file=dict(type='str'), - disable_gpg_check=dict(type='bool', default=False), - skip_broken=dict(type='bool', default=False), - update_cache=dict(type='bool', default=False, aliases=['expire-cache']), - validate_certs=dict(type='bool', default=True), - installroot=dict(type='str', default="/"), - update_only=dict(required=False, default="no", type='bool'), - # this should not be needed, but exists as a failsafe - install_repoquery=dict(type='bool', default=True), - allow_downgrade=dict(type='bool', default=False), - security=dict(type='bool', default=False), - bugfix=dict(required=False, type='bool', default=False), - enable_plugin=dict(type='list', default=[]), - disable_plugin=dict(type='list', default=[]), - disable_excludes=dict(type='str', default=None, choices=['all', 'main', 'repoid']), - download_only=dict(type='bool', default=False), - ), - required_one_of=[['name', 'list']], - mutually_exclusive=[['name', 'list']], - supports_check_mode=True, + **yumdnf_argument_spec ) - error_msgs = [] - if not HAS_RPM_PYTHON: - error_msgs.append('The Python 2 bindings for rpm are needed for this module. If you require Python 3 support use the `dnf` Ansible module instead.') - if not HAS_YUM_PYTHON: - error_msgs.append('The Python 2 yum module is needed for this module. If you require Python 3 support use the `dnf` Ansible module instead.') - - if error_msgs: - module.fail_json(msg='. '.join(error_msgs)) - - params = module.params - enable_plugin = params.get('enable_plugin') - disable_plugin = params.get('disable_plugin') - if params['disable_excludes'] and yum.__version_info__ < (3, 4): - module.fail_json(msg="'disable_includes' is available in yum version 3.4 and onwards.") - - if params['list']: - repoquerybin = ensure_yum_utils(module) - if not repoquerybin: - module.fail_json(msg="repoquery is required to use list= with this module. Please install the yum-utils package.") - results = {'results': list_stuff(module, repoquerybin, params['conf_file'], - params['list'], params['installroot'], - params['disablerepo'], params['enablerepo'], params['disable_excludes'])} - else: - # If rhn-plugin is installed and no rhn-certificate is available on - # the system then users will see an error message using the yum API. - # Use repoquery in those cases. - - my = yum_base(params['conf_file'], params['installroot'], enable_plugin, disable_plugin, params['disable_excludes']) - # A sideeffect of accessing conf is that the configuration is - # loaded and plugins are discovered - my.conf - repoquery = None - try: - yum_plugins = my.plugins._plugins - except AttributeError: - pass - else: - if 'rhnplugin' in yum_plugins: - repoquerybin = ensure_yum_utils(module) - if repoquerybin: - repoquery = [repoquerybin, '--show-duplicates', '--plugins', '--quiet'] - if params['installroot'] != '/': - repoquery.extend(['--installroot', params['installroot']]) - - pkg = [p.strip() for p in params['name']] - exclude = params['exclude'] - state = params['state'] - enablerepo = params.get('enablerepo', '') - disablerepo = params.get('disablerepo', '') - disable_gpg_check = params['disable_gpg_check'] - skip_broken = params['skip_broken'] - update_only = params['update_only'] - security = params['security'] - bugfix = params['bugfix'] - allow_downgrade = params['allow_downgrade'] - download_only = params['download_only'] - results = ensure(module, state, pkg, params['conf_file'], enablerepo, - disablerepo, disable_gpg_check, exclude, repoquery, - skip_broken, update_only, security, bugfix, params['installroot'], allow_downgrade, - disable_plugin=disable_plugin, enable_plugin=enable_plugin, - disable_excludes=params['disable_excludes'], download_only=download_only) - if repoquery: - results['msg'] = '%s %s' % ( - results.get('msg', ''), - 'Warning: Due to potential bad behaviour with rhnplugin and certificates, used slower repoquery calls instead of Yum API.' - ) - - module.exit_json(**results) + module_implementation = YumModule(module) + module_implementation.run() if __name__ == '__main__': diff --git a/test/integration/targets/dnf/tasks/dnf.yml b/test/integration/targets/dnf/tasks/dnf.yml index 811e8475e54..83f0dcb5e41 100644 --- a/test/integration/targets/dnf/tasks/dnf.yml +++ b/test/integration/targets/dnf/tasks/dnf.yml @@ -232,6 +232,37 @@ dnf: name=sos installroot='/' register: dnf_result +# Test download_only +- name: uninstall sos for downloadonly test + dnf: + name: sos + state: absent + +- name: install sos + dnf: + name: sos + state: latest + download_only: true + register: dnf_result + +- name: verify download of sos (part 1 -- dnf "install" succeeded) + assert: + that: + - "dnf_result is success" + - "dnf_result is changed" + +- name: uninstall sos (noop) + dnf: + name: sos + state: absent + register: dnf_result + +- name: verify download of sos (part 2 -- nothing removed during uninstall) + assert: + that: + - "dnf_result is success" + - "not dnf_result is changed" + # GROUP INSTALL # Using 'Books and Guides' because it is only 5 packages and a 7.3 M download on Fedora 26. # It also doesn't install anything that will tamper with our Python environment. @@ -308,7 +339,8 @@ - "'msg' in dnf_result" # cleanup until https://github.com/ansible/ansible/issues/27377 is resolved -- shell: dnf -y group install "Books and Guides" && dnf -y group remove "Books and Guides" +- shell: 'dnf -y group install "Books and Guides" && dnf -y group remove "Books and Guides"' + register: shell_dnf_result # GROUP UPGRADE - this will go to the same method as group install # but through group_update - it is its invocation we're testing here @@ -426,3 +458,188 @@ - "'non-existent-rpm' in dnf_result['failures'][0]" - "'no package matched' in dnf_result['failures'][0]" - "'Failed to install some of the specified packages' in dnf_result['msg']" + +- name: use latest to install httpd + dnf: + name: httpd + state: latest + register: dnf_result + +- name: verify httpd was installed + assert: + that: + - "'changed' in dnf_result" + +- name: uninstall httpd + dnf: + name: httpd + state: removed + +- name: update httpd only if it exists + dnf: + name: httpd + state: latest + update_only: yes + register: dnf_result + +- name: verify httpd not installed + assert: + that: + - "not dnf_result is changed" + +- name: try to install not compatible arch rpm, should fail + dnf: + name: http://download.fedoraproject.org/pub/epel/7/ppc64le/Packages/b/banner-1.3.4-3.el7.ppc64le.rpm + state: present + register: dnf_result + ignore_errors: True + +- name: verify that dnf failed + assert: + that: + - "not dnf_result is changed" + - "dnf_result is failed" + +# setup for testing installing an RPM from url + +- set_fact: + pkg_name: fpaste + +- name: cleanup + dnf: + name: "{{ pkg_name }}" + state: absent + +- set_fact: + pkg_url: https://download.fedoraproject.org/pub/fedora/linux/releases/27/Everything/x86_64/os/Packages/f/fpaste-0.3.9.1-1.fc27.noarch.rpm +# setup end + +- name: download an rpm + get_url: + url: "{{ pkg_url }}" + dest: "/tmp/{{ pkg_name }}.rpm" + +- name: install the downloaded rpm + dnf: + name: "/tmp/{{ pkg_name }}.rpm" + state: present + register: dnf_result + +- name: verify installation + assert: + that: + - "dnf_result is success" + - "dnf_result is changed" + +- name: install the downloaded rpm again + dnf: + name: "/tmp/{{ pkg_name }}.rpm" + state: present + register: dnf_result + +- name: verify installation + assert: + that: + - "dnf_result is success" + - "not dnf_result is changed" + +- name: clean up + dnf: + name: "{{ pkg_name }}" + state: absent + +- name: install from url + dnf: + name: "{{ pkg_url }}" + state: present + register: dnf_result + +- name: verify installation + assert: + that: + - "dnf_result is success" + - "dnf_result is changed" + - "dnf_result is not failed" + +- name: verify dnf module outputs + assert: + that: + - "'changed' in dnf_result" + - "'results' in dnf_result" + +- name: Create a temp RPM file which does not contain nevra information + file: + name: "/tmp/non_existent_pkg.rpm" + state: touch + +- name: Try installing RPM file which does not contain nevra information + dnf: + name: "/tmp/non_existent_pkg.rpm" + state: present + register: no_nevra_info_result + ignore_errors: yes + +- name: Verify RPM failed to install + assert: + that: + - "'changed' in no_nevra_info_result" + - "'msg' in no_nevra_info_result" + +- name: Delete a temp RPM file + file: + name: "/tmp/non_existent_pkg.rpm" + state: absent + +- name: uninstall lsof + dnf: + name: lsof + state: removed + +- name: check lsof with rpm + shell: rpm -q lsof + ignore_errors: True + register: rpm_lsof_result + +- name: verify lsof is uninstalled + assert: + that: + - "rpm_lsof_result is failed" + +- name: exclude lsof + lineinfile: + dest: /etc/dnf/dnf.conf + regexp: (^exclude=)(.)* + line: "exclude=lsof*" + state: present + +# begin test case where disable_excludes is supported +- name: Try install lsof without disable_excludes + dnf: name=lsof state=latest + register: dnf_lsof_result + ignore_errors: True + +- name: verify lsof did not install because it is in exclude list + assert: + that: + - "dnf_lsof_result is failed" + +- name: install lsof with disable_excludes + dnf: name=lsof state=latest disable_excludes=all + register: dnf_lsof_result_using_excludes + +- name: verify lsof did install using disable_excludes=all + assert: + that: + - "dnf_lsof_result_using_excludes is success" + - "dnf_lsof_result_using_excludes is changed" + - "dnf_lsof_result_using_excludes is not failed" + +- name: remove exclude lsof (cleanup dnf.conf) + lineinfile: + dest: /etc/dnf/dnf.conf + regexp: (^exclude=lsof*) + line: "exclude=" + state: present + + +# end test case where disable_excludes is supported diff --git a/test/units/modules/packaging/os/test_yum.py b/test/units/modules/packaging/os/test_yum.py index 59c22411b9d..ced0c4fc56f 100644 --- a/test/units/modules/packaging/os/test_yum.py +++ b/test/units/modules/packaging/os/test_yum.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from ansible.compat.tests import unittest -from ansible.modules.packaging.os import yum +from ansible.modules.packaging.os.yum import YumModule yum_plugin_load_error = """ @@ -141,34 +141,34 @@ class TestYumUpdateCheckParse(unittest.TestCase): self.assertIsInstance(result, dict) def test_empty_output(self): - res = yum.parse_check_update("") + res = YumModule.parse_check_update("") expected_pkgs = [] self._assert_expected(expected_pkgs, res) def test_longname(self): - res = yum.parse_check_update(longname) + res = YumModule.parse_check_update(longname) expected_pkgs = ['xxxxxxxxxxxxxxxxxxxxxxxxxx', 'glibc'] self._assert_expected(expected_pkgs, res) def test_plugin_load_error(self): - res = yum.parse_check_update(yum_plugin_load_error) + res = YumModule.parse_check_update(yum_plugin_load_error) expected_pkgs = [] self._assert_expected(expected_pkgs, res) def test_wrapped_output_1(self): - res = yum.parse_check_update(wrapped_output_1) + res = YumModule.parse_check_update(wrapped_output_1) expected_pkgs = ["vms-agent"] self._assert_expected(expected_pkgs, res) def test_wrapped_output_2(self): - res = yum.parse_check_update(wrapped_output_2) + res = YumModule.parse_check_update(wrapped_output_2) expected_pkgs = ["empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty-empty", "libtiff"] self._assert_expected(expected_pkgs, res) def test_wrapped_output_3(self): - res = yum.parse_check_update(wrapped_output_3) + res = YumModule.parse_check_update(wrapped_output_3) expected_pkgs = ["ceph", "ceph-base", "ceph-common", "ceph-mds", "ceph-mon", "ceph-osd", "ceph-selinux", "libcephfs1", "librados2", "libradosstriper1", "librbd1", "librgw2", @@ -176,16 +176,16 @@ class TestYumUpdateCheckParse(unittest.TestCase): self._assert_expected(expected_pkgs, res) def test_wrapped_output_4(self): - res = yum.parse_check_update(wrapped_output_4) + res = YumModule.parse_check_update(wrapped_output_4) expected_pkgs = ["ipxe-roms-qemu", "quota", "quota-nls", "rdma", "screen", "sos", "sssd-client"] self._assert_expected(expected_pkgs, res) def test_wrapped_output_rhel7(self): - res = yum.parse_check_update(unwrapped_output_rhel7) + res = YumModule.parse_check_update(unwrapped_output_rhel7) self._assert_expected(unwrapped_output_rhel7_expected_pkgs, res) def test_wrapped_output_rhel7_obsoletes(self): - res = yum.parse_check_update(unwrapped_output_rhel7_obsoletes) + res = YumModule.parse_check_update(unwrapped_output_rhel7_obsoletes) self._assert_expected(unwrapped_output_rhel7_expected_pkgs, res)