diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 90443becac..c46f431429 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -819,7 +819,7 @@ files: $modules/packaging/os/opkg.py: maintainers: skinp $modules/packaging/os/pacman.py: - maintainers: elasticdog indrajitr tchernomax + maintainers: elasticdog indrajitr tchernomax jraby labels: pacman ignore: elasticdog $modules/packaging/os/pacman_key.py: diff --git a/changelogs/fragments/3907-pacman-speedup.yml b/changelogs/fragments/3907-pacman-speedup.yml new file mode 100644 index 0000000000..59b43ee666 --- /dev/null +++ b/changelogs/fragments/3907-pacman-speedup.yml @@ -0,0 +1,5 @@ +minor_changes: + - pacman - the module has been rewritten and is now much faster when using ``state=latest``. + Operations are now done all packages at once instead of package per package + and the configured output format of ``pacman`` no longer affect the module's operation. + (https://github.com/ansible-collections/community.general/pull/3907, https://github.com/ansible-collections/community.general/issues/3783, https://github.com/ansible-collections/community.general/issues/4079) diff --git a/plugins/modules/packaging/os/pacman.py b/plugins/modules/packaging/os/pacman.py index b60ee2b182..af1590ee1a 100644 --- a/plugins/modules/packaging/os/pacman.py +++ b/plugins/modules/packaging/os/pacman.py @@ -4,12 +4,14 @@ # Copyright: (c) 2012, Afterburn # Copyright: (c) 2013, Aaron Bull Schaefer # Copyright: (c) 2015, Indrajit Raychaudhuri +# Copyright: (c) 2022, Jean Raby # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function + __metaclass__ = type -DOCUMENTATION = ''' +DOCUMENTATION = """ --- module: pacman short_description: Manage packages with I(pacman) @@ -19,6 +21,7 @@ author: - Indrajit Raychaudhuri (@indrajitr) - Aaron Bull Schaefer (@elasticdog) - Maxime de Roucy (@tchernomax) + - Jean Raby (@jraby) options: name: description: @@ -66,7 +69,7 @@ options: - Whether or not to refresh the master package lists. - This can be run as part of a package installation or as a separate step. - Alias C(update-cache) has been deprecated and will be removed in community.general 5.0.0. - default: no + - If not specified, it defaults to C(false). type: bool aliases: [ update-cache ] @@ -80,7 +83,7 @@ options: description: - Whether or not to upgrade the whole system. Can't be used in combination with C(name). - default: no + - If not specified, it defaults to C(false). type: bool upgrade_extra_args: @@ -94,9 +97,9 @@ notes: it is much more efficient to pass the list directly to the I(name) option. - To use an AUR helper (I(executable) option), a few extra setup steps might be required beforehand. For example, a dedicated build user with permissions to install packages could be necessary. -''' +""" -RETURN = ''' +RETURN = """ packages: description: a list of packages that have been changed returned: when upgrade is set to yes @@ -116,9 +119,9 @@ stderr: type: str sample: "warning: libtool: local (2.4.6+44+gb9b44533-14) is newer than core (2.4.6+42+gb88cebd5-15)\nwarning ..." version_added: 4.1.0 -''' +""" -EXAMPLES = ''' +EXAMPLES = """ - name: Install package foo from repo community.general.pacman: name: foo @@ -180,357 +183,468 @@ EXAMPLES = ''' name: baz state: absent force: yes -''' - -import re +""" +import shlex from ansible.module_utils.basic import AnsibleModule +from collections import defaultdict, namedtuple -def get_version(pacman_output): - """Take pacman -Q or pacman -S output and get the Version""" - fields = pacman_output.split() - if len(fields) == 2: - return fields[1] - return None +Package = namedtuple("Package", ["name", "source"]) +VersionTuple = namedtuple("VersionTuple", ["current", "latest"]) -def get_name(module, pacman_output): - """Take pacman -Q or pacman -S output and get the package name""" - fields = pacman_output.split() - if len(fields) == 2: - return fields[0] - module.fail_json(msg="get_name: fail to retrieve package name from pacman output") +class Pacman(object): + def __init__(self, module): + self.m = module + self.m.run_command_environ_update = dict(LC_ALL="C") + p = self.m.params -def query_package(module, pacman_path, name, state): - """Query the package status in both the local system and the repository. Returns a boolean to indicate if the package is installed, a second - boolean to indicate if the package is up-to-date and a third boolean to indicate whether online information were available - """ + self._msgs = [] + self._stdouts = [] + self._stderrs = [] + self.changed = False + self.exit_params = {} - lcmd = "%s --query %s" % (pacman_path, name) - lrc, lstdout, lstderr = module.run_command(lcmd, check_rc=False) - if lrc != 0: - # package is not installed locally - return False, False, False - else: - # a non-zero exit code doesn't always mean the package is installed - # for example, if the package name queried is "provided" by another package - installed_name = get_name(module, lstdout) - if installed_name != name: - return False, False, False + self.pacman_path = self.m.get_bin_path(p["executable"], True) - # no need to check the repository if state is present or absent - # return False for package version check, because we didn't check it - if state == 'present' or state == 'absent': - return True, False, False + # Normalize for old configs + if p["state"] == "installed": + self.target_state = "present" + elif p["state"] == "removed": + self.target_state = "absent" + else: + self.target_state = p["state"] - # get the version installed locally (if any) - lversion = get_version(lstdout) + def add_exit_infos(self, msg=None, stdout=None, stderr=None): + if msg: + self._msgs.append(msg) + if stdout: + self._stdouts.append(stdout) + if stderr: + self._stderrs.append(stderr) - rcmd = "%s --sync --print-format \"%%n %%v\" %s" % (pacman_path, name) - rrc, rstdout, rstderr = module.run_command(rcmd, check_rc=False) - # get the version in the repository - rversion = get_version(rstdout) + def _set_mandatory_exit_params(self): + msg = "\n".join(self._msgs) + stdouts = "\n".join(self._stdouts) + stderrs = "\n".join(self._stderrs) + if stdouts: + self.exit_params["stdout"] = stdouts + if stderrs: + self.exit_params["stderr"] = stderrs + self.exit_params["msg"] = msg # mandatory, but might be empty - if rrc == 0: - # Return True to indicate that the package is installed locally, and the result of the version number comparison - # to determine if the package is up-to-date. - return True, (lversion == rversion), False + def fail(self, msg=None, stdout=None, stderr=None, **kwargs): + self.add_exit_infos(msg, stdout, stderr) + self._set_mandatory_exit_params() + if kwargs: + self.exit_params.update(**kwargs) + self.m.fail_json(**self.exit_params) - # package is installed but cannot fetch remote Version. Last True stands for the error - return True, True, True + def success(self): + self._set_mandatory_exit_params() + self.m.exit_json(changed=self.changed, **self.exit_params) + def run(self): + if self.m.params["update_cache"]: + self.update_package_db() -def update_package_db(module, pacman_path): - if module.params['force']: - module.params["update_cache_extra_args"] += " --refresh --refresh" + if not (self.m.params["name"] or self.m.params["upgrade"]): + self.success() - cmd = "%s --sync --refresh %s" % (pacman_path, module.params["update_cache_extra_args"]) - rc, stdout, stderr = module.run_command(cmd, check_rc=False) + self.inventory = self._build_inventory() + if self.m.params["upgrade"]: + self.upgrade() + self.success() - if rc == 0: - return stdout, stderr - else: - module.fail_json(msg="could not update package db", stdout=stdout, stderr=stderr) + if self.m.params["name"]: + pkgs = self.package_list() - -def upgrade(module, pacman_path): - cmdupgrade = "%s --sync --sysupgrade --quiet --noconfirm %s" % (pacman_path, module.params["upgrade_extra_args"]) - cmdneedrefresh = "%s --query --upgrades" % (pacman_path) - rc, stdout, stderr = module.run_command(cmdneedrefresh, check_rc=False) - data = stdout.split('\n') - data.remove('') - packages = [] - diff = { - 'before': '', - 'after': '', - } - - if rc == 0: - # Match lines of `pacman -Qu` output of the form: - # (package name) (before version-release) -> (after version-release) - # e.g., "ansible 2.7.1-1 -> 2.7.2-1" - regex = re.compile(r'([\w+\-.@]+) (\S+-\S+) -> (\S+-\S+)') - for p in data: - if '[ignored]' not in p: - m = regex.search(p) - packages.append(m.group(1)) - if module._diff: - diff['before'] += "%s-%s\n" % (m.group(1), m.group(2)) - diff['after'] += "%s-%s\n" % (m.group(1), m.group(3)) - if module.check_mode: - if packages: - module.exit_json(changed=True, msg="%s package(s) would be upgraded" % (len(data)), packages=packages, diff=diff) + if self.target_state == "absent": + self.remove_packages(pkgs) + self.success() else: - module.exit_json(changed=False, msg='Nothing to upgrade', packages=packages) - rc, stdout, stderr = module.run_command(cmdupgrade, check_rc=False) + self.install_packages(pkgs) + self.success() + + # This shouldn't happen... + self.fail("This is a bug") + + def install_packages(self, pkgs): + pkgs_to_install = [] + for p in pkgs: + if ( + p.name not in self.inventory["installed_pkgs"] + or self.target_state == "latest" + and p.name in self.inventory["upgradable_pkgs"] + ): + pkgs_to_install.append(p) + + if len(pkgs_to_install) == 0: + self.add_exit_infos("package(s) already installed") + return + + self.changed = True + cmd_base = [ + self.pacman_path, + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + ] + if self.m.params["extra_args"]: + cmd_base.extend(self.m.params["extra_args"]) + + # Dry run first to gather what will be done + cmd = cmd_base + ["--print-format", "%n %v"] + [p.source for p in pkgs_to_install] + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + if rc != 0: + self.fail("Failed to list package(s) to install", stdout=stdout, stderr=stderr) + + name_ver = [l.strip() for l in stdout.splitlines()] + before = [] + after = [] + installed_pkgs = [] + self.exit_params["packages"] = [] + for p in name_ver: + name, version = p.split() + if name in self.inventory["installed_pkgs"]: + before.append("%s-%s" % (name, self.inventory["installed_pkgs"][name])) + after.append("%s-%s" % (name, version)) + installed_pkgs.append(name) + + self.exit_params["diff"] = { + "before": "\n".join(before) + "\n" if before else "", + "after": "\n".join(after) + "\n" if after else "", + } + + if self.m.check_mode: + self.add_exit_infos("Would have installed %d packages" % len(installed_pkgs)) + self.exit_params["packages"] = installed_pkgs + return + + # actually do it + cmd = cmd_base + [p.source for p in pkgs_to_install] + + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + if rc != 0: + self.fail("Failed to install package(s)", stdout=stdout, stderr=stderr) + + self.exit_params["packages"] = installed_pkgs + self.add_exit_infos( + "Installed %d package(s)" % len(installed_pkgs), stdout=stdout, stderr=stderr + ) + + def remove_packages(self, pkgs): + force_args = ["--nodeps", "--nodeps"] if self.m.params["force"] else [] + + # filter out pkgs that are already absent + pkg_names_to_remove = [p.name for p in pkgs if p.name in self.inventory["installed_pkgs"]] + + if len(pkg_names_to_remove) == 0: + self.add_exit_infos("package(s) already absent") + return + + # There's something to do, set this in advance + self.changed = True + + cmd_base = [self.pacman_path, "--remove", "--noconfirm", "--noprogressbar"] + if self.m.params["extra_args"]: + cmd_base.extend(self.m.params["extra_args"]) + if force_args: + cmd_base.extend(force_args) + + # This is a bit of a TOCTOU but it is better than parsing the output of + # pacman -R, which is different depending on the user config (VerbosePkgLists) + # Start by gathering what would be removed + cmd = cmd_base + ["--print-format", "%n-%v"] + pkg_names_to_remove + + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + if rc != 0: + self.fail("failed to list package(s) to remove", stdout=stdout, stderr=stderr) + + removed_pkgs = stdout.split() + self.exit_params["packages"] = removed_pkgs + self.exit_params["diff"] = { + "before": "\n".join(removed_pkgs) + "\n", # trailing \n to avoid diff complaints + "after": "", + } + + if self.m.check_mode: + self.exit_params["packages"] = removed_pkgs + self.add_exit_infos("Would have removed %d packages" % len(removed_pkgs)) + return + + # actually do it + cmd = cmd_base + pkg_names_to_remove + + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + if rc != 0: + self.fail("failed to remove package(s)", stdout=stdout, stderr=stderr) + self.exit_params["packages"] = removed_pkgs + self.add_exit_infos("Removed %d package(s)" % len(removed_pkgs), stdout=stdout, stderr=stderr) + + def upgrade(self): + """Runs pacman --sync --sysupgrade if there are upgradable packages""" + + if len(self.inventory["upgradable_pkgs"]) == 0: + self.add_exit_infos("Nothing to upgrade") + return + + self.changed = True # there are upgrades, so there will be changes + + # Build diff based on inventory first. + diff = {"before": "", "after": ""} + for pkg, versions in self.inventory["upgradable_pkgs"].items(): + diff["before"] += "%s-%s\n" % (pkg, versions.current) + diff["after"] += "%s-%s\n" % (pkg, versions.latest) + self.exit_params["diff"] = diff + self.exit_params["packages"] = self.inventory["upgradable_pkgs"].keys() + + if self.m.check_mode: + self.add_exit_infos( + "%d packages would have been upgraded" % (len(self.inventory["upgradable_pkgs"])) + ) + else: + cmd = [ + self.pacman_path, + "--sync", + "--sys-upgrade", + "--quiet", + "--noconfirm", + ] + if self.m.params["upgrade_extra_args"]: + cmd += self.m.params["upgrade_extra_args"] + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + if rc == 0: + self.add_exit_infos("System upgraded", stdout=stdout, stderr=stderr) + else: + self.fail("Could not upgrade", stdout=stdout, stderr=stderr) + + def update_package_db(self): + """runs pacman --sync --refresh""" + if self.m.check_mode: + self.add_exit_infos("Would have updated the package db") + self.changed = True + return + + cmd = [ + self.pacman_path, + "--sync", + "--refresh", + ] + if self.m.params["update_cache_extra_args"]: + cmd += self.m.params["update_cache_extra_args"] + if self.m.params["force"]: + cmd += ["--refresh"] + + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + + self.changed = True + if rc == 0: - if packages: - module.exit_json(changed=True, msg='System upgraded', packages=packages, diff=diff, stdout=stdout, stderr=stderr) - else: - module.exit_json(changed=False, msg='Nothing to upgrade', packages=packages) + self.add_exit_infos("Updated package db", stdout=stdout, stderr=stderr) else: - module.fail_json(msg="Could not upgrade", stdout=stdout, stderr=stderr) - else: - module.exit_json(changed=False, msg='Nothing to upgrade', packages=packages) + self.fail("could not update package db", stdout=stdout, stderr=stderr) + def package_list(self): + """Takes the input package list and resolves packages groups to their package list using the inventory, + extracts package names from packages given as files or URLs using calls to pacman -def remove_packages(module, pacman_path, packages): - data = [] - diff = { - 'before': '', - 'after': '', - } + Returns the expanded/resolved list as a list of Package + """ + pkg_list = [] + for pkg in self.m.params["name"]: + if not pkg: + continue - if module.params["force"]: - module.params["extra_args"] += " --nodeps --nodeps" - - remove_c = 0 - stdout_total = "" - stderr_total = "" - # Using a for loop in case of error, we can report the package that failed - for package in packages: - # Query the package first, to see if we even need to remove - installed, updated, unknown = query_package(module, pacman_path, package, 'absent') - if not installed: - continue - - cmd = "%s --remove --noconfirm --noprogressbar %s %s" % (pacman_path, module.params["extra_args"], package) - rc, stdout, stderr = module.run_command(cmd, check_rc=False) - - if rc != 0: - module.fail_json(msg="failed to remove %s" % (package), stdout=stdout, stderr=stderr) - - stdout_total += stdout - stderr_total += stderr - if module._diff: - d = stdout.split('\n')[2].split(' ')[2:] - for i, pkg in enumerate(d): - d[i] = re.sub('-[0-9].*$', '', d[i].split('/')[-1]) - diff['before'] += "%s\n" % pkg - data.append('\n'.join(d)) - - remove_c += 1 - - if remove_c > 0: - module.exit_json(changed=True, msg="removed %s package(s)" % remove_c, diff=diff, stdout=stdout_total, stderr=stderr_total) - - module.exit_json(changed=False, msg="package(s) already absent") - - -def install_packages(module, pacman_path, state, packages, package_files): - install_c = 0 - package_err = [] - message = "" - data = [] - diff = { - 'before': '', - 'after': '', - } - - to_install_repos = [] - to_install_files = [] - for i, package in enumerate(packages): - # if the package is installed and state == present or state == latest and is up-to-date then skip - installed, updated, latestError = query_package(module, pacman_path, package, state) - if latestError and state == 'latest': - package_err.append(package) - - if installed and (state == 'present' or (state == 'latest' and updated)): - continue - - if package_files[i]: - to_install_files.append(package_files[i]) - else: - to_install_repos.append(package) - - if to_install_repos: - cmd = "%s --sync --noconfirm --noprogressbar --needed %s %s" % (pacman_path, module.params["extra_args"], " ".join(to_install_repos)) - rc, stdout, stderr = module.run_command(cmd, check_rc=False) - - if rc != 0: - module.fail_json(msg="failed to install %s: %s" % (" ".join(to_install_repos), stderr), stdout=stdout, stderr=stderr) - - # As we pass `--needed` to pacman returns a single line of ` there is nothing to do` if no change is performed. - # The check for > 3 is here because we pick the 4th line in normal operation. - if len(stdout.split('\n')) > 3: - data = stdout.split('\n')[3].split(' ')[2:] - data = [i for i in data if i != ''] - for i, pkg in enumerate(data): - data[i] = re.sub('-[0-9].*$', '', data[i].split('/')[-1]) - if module._diff: - diff['after'] += "%s\n" % pkg - - install_c += len(to_install_repos) - - if to_install_files: - cmd = "%s --upgrade --noconfirm --noprogressbar --needed %s %s" % (pacman_path, module.params["extra_args"], " ".join(to_install_files)) - rc, stdout, stderr = module.run_command(cmd, check_rc=False) - - if rc != 0: - module.fail_json(msg="failed to install %s: %s" % (" ".join(to_install_files), stderr), stdout=stdout, stderr=stderr) - - # As we pass `--needed` to pacman returns a single line of ` there is nothing to do` if no change is performed. - # The check for > 3 is here because we pick the 4th line in normal operation. - if len(stdout.split('\n')) > 3: - data = stdout.split('\n')[3].split(' ')[2:] - data = [i for i in data if i != ''] - for i, pkg in enumerate(data): - data[i] = re.sub('-[0-9].*$', '', data[i].split('/')[-1]) - if module._diff: - diff['after'] += "%s\n" % pkg - - install_c += len(to_install_files) - - if state == 'latest' and len(package_err) > 0: - message = "But could not ensure 'latest' state for %s package(s) as remote version could not be fetched." % (package_err) - - if install_c > 0: - module.exit_json(changed=True, msg="installed %s package(s). %s" % (install_c, message), diff=diff, stdout=stdout, stderr=stderr) - - module.exit_json(changed=False, msg="package(s) already installed. %s" % (message), diff=diff) - - -def check_packages(module, pacman_path, packages, state): - would_be_changed = [] - diff = { - 'before': '', - 'after': '', - 'before_header': '', - 'after_header': '' - } - - for package in packages: - installed, updated, unknown = query_package(module, pacman_path, package, state) - if ((state in ["present", "latest"] and not installed) or - (state == "absent" and installed) or - (state == "latest" and not updated)): - would_be_changed.append(package) - if would_be_changed: - if state == "absent": - state = "removed" - - if module._diff and (state == 'removed'): - diff['before_header'] = 'removed' - diff['before'] = '\n'.join(would_be_changed) + '\n' - elif module._diff and ((state == 'present') or (state == 'latest')): - diff['after_header'] = 'installed' - diff['after'] = '\n'.join(would_be_changed) + '\n' - - module.exit_json(changed=True, msg="%s package(s) would be %s" % ( - len(would_be_changed), state), diff=diff) - else: - module.exit_json(changed=False, msg="package(s) already %s" % state, diff=diff) - - -def expand_package_groups(module, pacman_path, pkgs): - expanded = [] - - __, stdout, __ = module.run_command([pacman_path, "--sync", "--groups", "--quiet"], check_rc=True) - available_groups = stdout.splitlines() - - for pkg in pkgs: - if pkg: # avoid empty strings - if pkg in available_groups: - # A group was found matching the package name: expand it - cmd = [pacman_path, "--sync", "--groups", "--quiet", pkg] - rc, stdout, stderr = module.run_command(cmd, check_rc=True) - expanded.extend([name.strip() for name in stdout.splitlines()]) + if pkg in self.inventory["available_groups"]: + # Expand group members + for group_member in self.inventory["available_groups"][pkg]: + pkg_list.append(Package(name=group_member, source=group_member)) + elif pkg in self.inventory["available_pkgs"]: + # just a regular pkg + pkg_list.append(Package(name=pkg, source=pkg)) else: - expanded.append(pkg) + # Last resort, call out to pacman to extract the info, + # pkg is possibly in the / format, or a filename or a URL - return expanded + # Start with / case + cmd = [self.pacman_path, "--sync", "--print-format", "%n", pkg] + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + if rc != 0: + # fallback to filename / URL + cmd = [self.pacman_path, "--upgrade", "--print-format", "%n", pkg] + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + if rc != 0: + if self.target_state == "absent": + continue # Don't bark for unavailable packages when trying to remove them + else: + self.fail( + msg="Failed to list package %s" % (pkg), + cmd=cmd, + stdout=stdout, + stderr=stderr, + rc=rc, + ) + pkg_name = stdout.strip() + pkg_list.append(Package(name=pkg_name, source=pkg)) + + return pkg_list + + def _build_inventory(self): + """Build a cache datastructure used for all pkg lookups + Returns a dict: + { + "installed_pkgs": {pkgname: version}, + "installed_groups": {groupname: set(pkgnames)}, + "available_pkgs": {pkgname: version}, + "available_groups": {groupname: set(pkgnames)}, + "upgradable_pkgs": {pkgname: (current_version,latest_version)}, + } + + Fails the module if a package requested for install cannot be found + """ + + installed_pkgs = {} + dummy, stdout, dummy = self.m.run_command([self.pacman_path, "--query"], check_rc=True) + # Format of a line: "pacman 6.0.1-2" + for l in stdout.splitlines(): + l = l.strip() + if not l: + continue + pkg, ver = l.split() + installed_pkgs[pkg] = ver + + installed_groups = defaultdict(set) + dummy, stdout, dummy = self.m.run_command( + [self.pacman_path, "--query", "--group"], check_rc=True + ) + # Format of lines: + # base-devel file + # base-devel findutils + # ... + for l in stdout.splitlines(): + l = l.strip() + if not l: + continue + group, pkgname = l.split() + installed_groups[group].add(pkgname) + + available_pkgs = {} + dummy, stdout, dummy = self.m.run_command([self.pacman_path, "--sync", "--list"], check_rc=True) + # Format of a line: "core pacman 6.0.1-2" + for l in stdout.splitlines(): + l = l.strip() + if not l: + continue + repo, pkg, ver = l.split()[:3] + available_pkgs[pkg] = ver + + available_groups = defaultdict(set) + dummy, stdout, dummy = self.m.run_command( + [self.pacman_path, "--sync", "--group", "--group"], check_rc=True + ) + # Format of lines: + # vim-plugins vim-airline + # vim-plugins vim-airline-themes + # vim-plugins vim-ale + # ... + for l in stdout.splitlines(): + l = l.strip() + if not l: + continue + group, pkg = l.split() + available_groups[group].add(pkg) + + upgradable_pkgs = {} + rc, stdout, stderr = self.m.run_command( + [self.pacman_path, "--query", "--upgrades"], check_rc=False + ) + + # non-zero exit with nothing in stdout -> nothing to upgrade, all good + # stderr can have warnings, so not checked here + if rc == 1 and stdout == "": + pass # nothing to upgrade + elif rc == 0: + # Format of lines: + # strace 5.14-1 -> 5.15-1 + # systemd 249.7-1 -> 249.7-2 [ignored] + for l in stdout.splitlines(): + l = l.strip() + if not l: + continue + if "[ignored]" in l: + continue + s = l.split() + if len(s) != 4: + self.fail(msg="Invalid line: %s" % l) + + pkg = s[0] + current = s[1] + latest = s[3] + upgradable_pkgs[pkg] = VersionTuple(current=current, latest=latest) + else: + # stuff in stdout but rc!=0, abort + self.fail( + "Couldn't get list of packages available for upgrade", + stdout=stdout, + stderr=stderr, + rc=rc, + ) + + return dict( + installed_pkgs=installed_pkgs, + installed_groups=installed_groups, + available_pkgs=available_pkgs, + available_groups=available_groups, + upgradable_pkgs=upgradable_pkgs, + ) -def main(): +def setup_module(): module = AnsibleModule( argument_spec=dict( - name=dict(type='list', elements='str', aliases=['pkg', 'package']), - state=dict(type='str', default='present', choices=['present', 'installed', 'latest', 'absent', 'removed']), - force=dict(type='bool', default=False), - executable=dict(type='str', default='pacman'), - extra_args=dict(type='str', default=''), - upgrade=dict(type='bool', default=False), - upgrade_extra_args=dict(type='str', default=''), + name=dict(type="list", elements="str", aliases=["pkg", "package"]), + state=dict( + type="str", + default="present", + choices=["present", "installed", "latest", "absent", "removed"], + ), + force=dict(type="bool", default=False), + executable=dict(type="str", default="pacman"), + extra_args=dict(type="str", default=""), + upgrade=dict(type="bool"), + upgrade_extra_args=dict(type="str", default=""), update_cache=dict( - type='bool', default=False, aliases=['update-cache'], - deprecated_aliases=[dict(name='update-cache', version='5.0.0', collection_name='community.general')]), - update_cache_extra_args=dict(type='str', default=''), + type="bool", + aliases=["update-cache"], + deprecated_aliases=[ + dict( + name="update-cache", + version="5.0.0", + collection_name="community.general", + ) + ], + ), + update_cache_extra_args=dict(type="str", default=""), ), - required_one_of=[['name', 'update_cache', 'upgrade']], - mutually_exclusive=[['name', 'upgrade']], + required_one_of=[["name", "update_cache", "upgrade"]], + mutually_exclusive=[["name", "upgrade"]], supports_check_mode=True, ) - module.run_command_environ_update = dict(LC_ALL='C') + # Split extra_args as the shell would for easier handling later + for str_args in ["extra_args", "upgrade_extra_args", "update_cache_extra_args"]: + module.params[str_args] = shlex.split(module.params[str_args]) - p = module.params + return module - # find pacman binary - pacman_path = module.get_bin_path(p['executable'], True) - # normalize the state parameter - if p['state'] in ['present', 'installed']: - p['state'] = 'present' - elif p['state'] in ['absent', 'removed']: - p['state'] = 'absent' +def main(): - if p["update_cache"] and not module.check_mode: - stdout, stderr = update_package_db(module, pacman_path) - if not (p['name'] or p['upgrade']): - module.exit_json(changed=True, msg='Updated the package master lists', stdout=stdout, stderr=stderr) - - if p['update_cache'] and module.check_mode and not (p['name'] or p['upgrade']): - module.exit_json(changed=True, msg='Would have updated the package cache') - - if p['upgrade']: - upgrade(module, pacman_path) - - if p['name']: - pkgs = expand_package_groups(module, pacman_path, p['name']) - - pkg_files = [] - for i, pkg in enumerate(pkgs): - if not pkg: # avoid empty strings - continue - elif re.match(r".*\.pkg\.tar(\.(gz|bz2|xz|lrz|lzo|Z|zst))?$", pkg): - # The package given is a filename, extract the raw pkg name from - # it and store the filename - pkg_files.append(pkg) - pkgs[i] = re.sub(r'-[0-9].*$', '', pkgs[i].split('/')[-1]) - else: - pkg_files.append(None) - - if module.check_mode: - check_packages(module, pacman_path, pkgs, p['state']) - - if p['state'] in ['present', 'latest']: - install_packages(module, pacman_path, p['state'], pkgs, pkg_files) - elif p['state'] == 'absent': - remove_packages(module, pacman_path, pkgs) - else: - module.exit_json(changed=False, msg="No package specified to work on.") + Pacman(setup_module()).run() if __name__ == "__main__": diff --git a/tests/unit/plugins/modules/packaging/os/test_pacman.py b/tests/unit/plugins/modules/packaging/os/test_pacman.py new file mode 100644 index 0000000000..e1d3ae2d06 --- /dev/null +++ b/tests/unit/plugins/modules/packaging/os/test_pacman.py @@ -0,0 +1,947 @@ +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import sys + + +from ansible.module_utils import basic +from ansible_collections.community.general.tests.unit.compat import mock, unittest +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + set_module_args, + exit_json, + fail_json, +) + +from ansible_collections.community.general.plugins.modules.packaging.os import pacman +from ansible_collections.community.general.plugins.modules.packaging.os.pacman import ( + Package, + VersionTuple, +) + +import pytest +import json + + +def get_bin_path(self, arg, required=False): + """Mock AnsibleModule.get_bin_path""" + return arg + + +# This inventory data is tightly coupled with the inventory test and the mock_valid_inventory fixture +valid_inventory = { + "installed_pkgs": { + "file": "5.41-1", + "filesystem": "2021.11.11-1", + "findutils": "4.8.0-1", + "gawk": "5.1.1-1", + "gettext": "0.21-1", + "grep": "3.7-1", + "gzip": "1.11-1", + "pacman": "6.0.1-2", + "pacman-mirrorlist": "20211114-1", + "sed": "4.8-1", + "sqlite": "3.36.0-1", + }, + "installed_groups": { + "base-devel": set(["gawk", "grep", "file", "findutils", "pacman", "sed", "gzip", "gettext"]) + }, + "available_pkgs": { + "acl": "2.3.1-1", + "amd-ucode": "20211027.1d00989-1", + "archlinux-keyring": "20211028-1", + "argon2": "20190702-3", + "attr": "2.5.1-1", + "audit": "3.0.6-5", + "autoconf": "2.71-1", + "automake": "1.16.5-1", + "b43-fwcutter": "019-3", + "gawk": "5.1.1-1", + "grep": "3.7-1", + "sqlite": "3.37.0-1", + "sudo": "1.9.8.p2-3", + }, + "available_groups": { + "base-devel": set( + [ + "libtool", + "gawk", + "which", + "texinfo", + "fakeroot", + "grep", + "findutils", + "autoconf", + "gzip", + "pkgconf", + "flex", + "patch", + "groff", + "m4", + "bison", + "gcc", + "gettext", + "make", + "file", + "pacman", + "sed", + "automake", + "sudo", + "binutils", + ] + ), + "some-group": set(["libtool", "sudo", "binutils"]), + }, + "upgradable_pkgs": { + "sqlite": VersionTuple(current="3.36.0-1", latest="3.37.0-1"), + }, +} + +empty_inventory = { + "installed_pkgs": {}, + "available_pkgs": {}, + "installed_groups": {}, + "available_groups": {}, + "upgradable_pkgs": {}, +} + + +class TestPacman: + @pytest.fixture(autouse=True) + def run_command(self, mocker): + self.mock_run_command = mocker.patch.object(basic.AnsibleModule, "run_command", autospec=True) + + @pytest.fixture + def mock_package_list(self, mocker): + return mocker.patch.object(pacman.Pacman, "package_list", autospec=True) + + @pytest.fixture(autouse=True) + def common(self, mocker): + self.mock_module = mocker.patch.multiple( + basic.AnsibleModule, + exit_json=exit_json, + fail_json=fail_json, + get_bin_path=get_bin_path, + ) + + @pytest.fixture + def mock_empty_inventory(self, mocker): + inv = empty_inventory + return mocker.patch.object(pacman.Pacman, "_build_inventory", return_value=inv) + + @pytest.fixture + def mock_valid_inventory(self, mocker): + return mocker.patch.object(pacman.Pacman, "_build_inventory", return_value=valid_inventory) + + def test_fail_without_required_args(self): + with pytest.raises(AnsibleFailJson) as e: + set_module_args({}) + pacman.main() + assert e.match(r"one of the following is required") + + def test_success(self, mock_empty_inventory): + set_module_args({"update_cache": True}) # Simplest args to let init go through + P = pacman.Pacman(pacman.setup_module()) + with pytest.raises(AnsibleExitJson) as e: + P.success() + + def test_fail(self, mock_empty_inventory): + set_module_args({"update_cache": True}) + P = pacman.Pacman(pacman.setup_module()) + + args = dict( + msg="msg", stdout="something", stderr="somethingelse", cmd=["command", "with", "args"], rc=1 + ) + with pytest.raises(AnsibleFailJson) as e: + P.fail(**args) + + assert all(item in e.value.args[0] for item in args) + + @pytest.mark.parametrize( + "expected, run_command_side_effect, raises", + [ + ( + # Regular run + valid_inventory, + [ + [ # pacman --query + 0, + """file 5.41-1 + filesystem 2021.11.11-1 + findutils 4.8.0-1 + gawk 5.1.1-1 + gettext 0.21-1 + grep 3.7-1 + gzip 1.11-1 + pacman 6.0.1-2 + pacman-mirrorlist 20211114-1 + sed 4.8-1 + sqlite 3.36.0-1 + """, + "", + ], + ( # pacman --query --group + 0, + """base-devel file + base-devel findutils + base-devel gawk + base-devel gettext + base-devel grep + base-devel gzip + base-devel pacman + base-devel sed + """, + "", + ), + ( # pacman --sync --list + 0, + """core acl 2.3.1-1 [installed] + core amd-ucode 20211027.1d00989-1 + core archlinux-keyring 20211028-1 [installed] + core argon2 20190702-3 [installed] + core attr 2.5.1-1 [installed] + core audit 3.0.6-5 [installed: 3.0.6-2] + core autoconf 2.71-1 + core automake 1.16.5-1 + core b43-fwcutter 019-3 + core gawk 5.1.1-1 [installed] + core grep 3.7-1 [installed] + core sqlite 3.37.0-1 [installed: 3.36.0-1] + code sudo 1.9.8.p2-3 + """, + "", + ), + ( # pacman --sync --group --group + 0, + """base-devel autoconf + base-devel automake + base-devel binutils + base-devel bison + base-devel fakeroot + base-devel file + base-devel findutils + base-devel flex + base-devel gawk + base-devel gcc + base-devel gettext + base-devel grep + base-devel groff + base-devel gzip + base-devel libtool + base-devel m4 + base-devel make + base-devel pacman + base-devel patch + base-devel pkgconf + base-devel sed + base-devel sudo + base-devel texinfo + base-devel which + some-group libtool + some-group sudo + some-group binutils + """, + "", + ), + ( # pacman --query --upgrades + 0, + """sqlite 3.36.0-1 -> 3.37.0-1 + systemd 249.6-3 -> 249.7-2 [ignored] + """, + "", + ), + ], + None, + ), + ( + # All good, but call to --query --upgrades return 1. aka nothing to upgrade + # with a pacman warning + empty_inventory, + [ + (0, "", ""), + (0, "", ""), + (0, "", ""), + (0, "", ""), + ( + 1, + "", + "warning: config file /etc/pacman.conf, line 34: directive 'TotalDownload' in section 'options' not recognized.", + ), + ], + None, + ), + ( + # failure + empty_inventory, + [ + (0, "", ""), + (0, "", ""), + (0, "", ""), + (0, "", ""), + ( + 1, + "partial\npkg\\nlist", + "some warning", + ), + ], + AnsibleFailJson, + ), + ], + ) + def test_build_inventory(self, expected, run_command_side_effect, raises): + self.mock_run_command.side_effect = run_command_side_effect + + set_module_args({"update_cache": True}) + if raises: + with pytest.raises(raises): + P = pacman.Pacman(pacman.setup_module()) + P._build_inventory() + else: + P = pacman.Pacman(pacman.setup_module()) + assert P._build_inventory() == expected + + @pytest.mark.parametrize("check_mode_value", [True, False]) + def test_upgrade_check_empty_inventory(self, mock_empty_inventory, check_mode_value): + set_module_args({"upgrade": True, "_ansible_check_mode": check_mode_value}) + P = pacman.Pacman(pacman.setup_module()) + with pytest.raises(AnsibleExitJson) as e: + P.run() + self.mock_run_command.call_count == 0 + out = e.value.args[0] + assert "packages" not in out + assert not out["changed"] + assert "diff" not in out + + def test_update_db_check(self, mock_empty_inventory): + set_module_args({"update_cache": True, "_ansible_check_mode": True}) + P = pacman.Pacman(pacman.setup_module()) + + with pytest.raises(AnsibleExitJson) as e: + P.run() + self.mock_run_command.call_count == 0 + out = e.value.args[0] + assert out["changed"] + + @pytest.mark.parametrize( + "module_args,expected_call", + [ + ({}, ["pacman", "--sync", "--refresh"]), + ({"force": True}, ["pacman", "--sync", "--refresh", "--refresh"]), + ( + {"update_cache_extra_args": "--some-extra args"}, + ["pacman", "--sync", "--refresh", "--some-extra", "args"], # shlex test + ), + ( + {"force": True, "update_cache_extra_args": "--some-extra args"}, + ["pacman", "--sync", "--refresh", "--some-extra", "args", "--refresh"], + ), + ], + ) + def test_update_db(self, mock_empty_inventory, module_args, expected_call): + args = {"update_cache": True} + args.update(module_args) + set_module_args(args) + + self.mock_run_command.return_value = [0, "stdout", "stderr"] + with pytest.raises(AnsibleExitJson) as e: + P = pacman.Pacman(pacman.setup_module()) + P.run() + + self.mock_run_command.assert_called_with(mock.ANY, expected_call, check_rc=False) + out = e.value.args[0] + assert out["changed"] + + @pytest.mark.parametrize( + "check_mode_value, run_command_data, upgrade_extra_args", + [ + # just check + (True, None, None), + ( + # for real + False, + { + "args": ["pacman", "--sync", "--sys-upgrade", "--quiet", "--noconfirm"], + "return_value": [0, "stdout", "stderr"], + }, + None, + ), + ( + # with extra args + False, + { + "args": [ + "pacman", + "--sync", + "--sys-upgrade", + "--quiet", + "--noconfirm", + "--some", + "value", + ], + "return_value": [0, "stdout", "stderr"], + }, + "--some value", + ), + ], + ) + def test_upgrade(self, mock_valid_inventory, check_mode_value, run_command_data, upgrade_extra_args): + args = {"upgrade": True, "_ansible_check_mode": check_mode_value} + if upgrade_extra_args: + args["upgrade_extra_args"] = upgrade_extra_args + set_module_args(args) + + if run_command_data and "return_value" in run_command_data: + self.mock_run_command.return_value = run_command_data["return_value"] + + P = pacman.Pacman(pacman.setup_module()) + + with pytest.raises(AnsibleExitJson) as e: + P.run() + out = e.value.args[0] + + if check_mode_value: + self.mock_run_command.call_count == 0 + + if run_command_data and "args" in run_command_data: + self.mock_run_command.assert_called_with(mock.ANY, run_command_data["args"], check_rc=False) + assert out["stdout"] == "stdout" + assert out["stderr"] == "stderr" + + assert len(out["packages"]) == 1 and "sqlite" in out["packages"] + assert out["changed"] + assert out["diff"]["before"] and out["diff"]["after"] + + def test_upgrade_fail(self, mock_valid_inventory): + set_module_args({"upgrade": True}) + self.mock_run_command.return_value = [1, "stdout", "stderr"] + P = pacman.Pacman(pacman.setup_module()) + + with pytest.raises(AnsibleFailJson) as e: + P.run() + self.mock_run_command.call_count == 1 + out = e.value.args[0] + assert out["failed"] + assert out["stdout"] == "stdout" + assert out["stderr"] == "stderr" + + @pytest.mark.parametrize( + "state, pkg_names, expected, run_command_data, raises", + [ + # regular packages, no resolving required + ( + "present", + ["acl", "attr"], + [Package(name="acl", source="acl"), Package(name="attr", source="attr")], + None, + None, + ), + ( + # group expansion + "present", + ["acl", "some-group", "attr"], + [ + Package(name="acl", source="acl"), + Package(name="binutils", source="binutils"), + Package(name="libtool", source="libtool"), + Package(name="sudo", source="sudo"), + Package(name="attr", source="attr"), + ], + None, + None, + ), + ( + # / format -> call to pacman to resolve + "present", + ["community/elixir"], + [Package(name="elixir", source="community/elixir")], + { + "calls": [ + mock.call( + mock.ANY, + ["pacman", "--sync", "--print-format", "%n", "community/elixir"], + check_rc=False, + ) + ], + "side_effect": [(0, "elixir", "")], + }, + None, + ), + ( + # catch all -> call to pacman to resolve (--sync and --upgrade) + "present", + ["somepackage-12.3-x86_64.pkg.tar.zst"], + [Package(name="somepackage", source="somepackage-12.3-x86_64.pkg.tar.zst")], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--print-format", + "%n", + "somepackage-12.3-x86_64.pkg.tar.zst", + ], + check_rc=False, + ), + mock.call( + mock.ANY, + [ + "pacman", + "--upgrade", + "--print-format", + "%n", + "somepackage-12.3-x86_64.pkg.tar.zst", + ], + check_rc=False, + ), + ], + "side_effect": [(1, "", "nope"), (0, "somepackage", "")], + }, + None, + ), + ( + # install a package that doesn't exist. call pacman twice and give up + "present", + ["unknown-package"], + [], + { + # no call validation, since it will fail + "side_effect": [(1, "", "nope"), (1, "", "stillnope")], + }, + AnsibleFailJson, + ), + ( + # Edge case: resolve a pkg that doesn't exist when trying to remove it (state == absent). + # will fallback to file + url format but not complain since it is already not there + # Can happen if a pkg is removed for the repos (or if a repo is disabled/removed) + "absent", + ["unknown-package-to-remove"], + [], + { + "calls": [ + mock.call( + mock.ANY, + ["pacman", "--sync", "--print-format", "%n", "unknown-package-to-remove"], + check_rc=False, + ), + mock.call( + mock.ANY, + ["pacman", "--upgrade", "--print-format", "%n", "unknown-package-to-remove"], + check_rc=False, + ), + ], + "side_effect": [(1, "", "nope"), (1, "", "stillnope")], + }, + None, # Doesn't fail + ), + ], + ) + def test_package_list( + self, mock_valid_inventory, state, pkg_names, expected, run_command_data, raises + ): + set_module_args({"name": pkg_names, "state": state}) + P = pacman.Pacman(pacman.setup_module()) + P.inventory = P._build_inventory() + if run_command_data: + self.mock_run_command.side_effect = run_command_data["side_effect"] + + if raises: + with pytest.raises(raises): + P.package_list() + else: + assert sorted(P.package_list()) == sorted(expected) + if run_command_data: + assert self.mock_run_command.mock_calls == run_command_data["calls"] + + @pytest.mark.parametrize("check_mode_value", [True, False]) + @pytest.mark.parametrize( + "name, state, package_list", + [ + (["already-absent"], "absent", [Package("already-absent", "already-absent")]), + (["grep"], "present", [Package("grep", "grep")]), + ], + ) + def test_op_packages_nothing_to_do( + self, mock_valid_inventory, mock_package_list, check_mode_value, name, state, package_list + ): + set_module_args({"name": name, "state": state, "_ansible_check_mode": check_mode_value}) + mock_package_list.return_value = package_list + P = pacman.Pacman(pacman.setup_module()) + with pytest.raises(AnsibleExitJson) as e: + P.run() + out = e.value.args[0] + assert "packages" not in out + assert not out["changed"] + assert "diff" not in out + self.mock_run_command.call_count == 0 + + @pytest.mark.parametrize( + "module_args, expected_packages, run_command_data, raises", + [ + ( + # remove pkg: Check mode -- call to print format but that's it + {"_ansible_check_mode": True, "name": ["grep"], "state": "absent"}, + ["grep-version"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--remove", + "--noconfirm", + "--noprogressbar", + "--print-format", + "%n-%v", + "grep", + ], + check_rc=False, + ), + ], + "side_effect": [(0, "grep-version", "")], + }, + AnsibleExitJson, + ), + ( + # remove pkg for real now -- with 2 packages + {"name": ["grep", "gawk"], "state": "absent"}, + ["grep-version", "gawk-anotherversion"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--remove", + "--noconfirm", + "--noprogressbar", + "--print-format", + "%n-%v", + "grep", + "gawk", + ], + check_rc=False, + ), + mock.call( + mock.ANY, + ["pacman", "--remove", "--noconfirm", "--noprogressbar", "grep", "gawk"], + check_rc=False, + ), + ], + "side_effect": [ + (0, "grep-version\ngawk-anotherversion", ""), + (0, "stdout", "stderr"), + ], + }, + AnsibleExitJson, + ), + ( + # remove pkg force + extra_args + { + "name": ["grep"], + "state": "absent", + "force": True, + "extra_args": "--some --extra arg", + }, + ["grep-version"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--remove", + "--noconfirm", + "--noprogressbar", + "--some", + "--extra", + "arg", + "--nodeps", + "--nodeps", + "--print-format", + "%n-%v", + "grep", + ], + check_rc=False, + ), + mock.call( + mock.ANY, + [ + "pacman", + "--remove", + "--noconfirm", + "--noprogressbar", + "--some", + "--extra", + "arg", + "--nodeps", + "--nodeps", + "grep", + ], + check_rc=False, + ), + ], + "side_effect": [ + (0, "grep-version", ""), + (0, "stdout", "stderr"), + ], + }, + AnsibleExitJson, + ), + ( + # remove pkg -- Failure to list + {"name": ["grep"], "state": "absent"}, + ["grep-3.7-1"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--remove", + "--noconfirm", + "--noprogressbar", + "--print-format", + "%n-%v", + "grep", + ], + check_rc=False, + ) + ], + "side_effect": [ + (1, "stdout", "stderr"), + ], + }, + AnsibleFailJson, + ), + ( + # remove pkg -- Failure to remove + {"name": ["grep"], "state": "absent"}, + ["grep-3.7-1"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--remove", + "--noconfirm", + "--noprogressbar", + "--print-format", + "%n-%v", + "grep", + ], + check_rc=False, + ), + mock.call( + mock.ANY, + ["pacman", "--remove", "--noconfirm", "--noprogressbar", "grep"], + check_rc=False, + ), + ], + "side_effect": [ + (0, "grep", ""), + (1, "stdout", "stderr"), + ], + }, + AnsibleFailJson, + ), + ( + # install pkg: Check mode + {"_ansible_check_mode": True, "name": ["sudo"], "state": "present"}, + ["sudo"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + "--print-format", + "%n %v", + "sudo", + ], + check_rc=False, + ), + ], + "side_effect": [(0, "sudo version", "")], + }, + AnsibleExitJson, + ), + ( + # install 2 pkgs, one already present + {"name": ["sudo", "grep"], "state": "present"}, + ["sudo"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + "--print-format", + "%n %v", + "sudo", + ], + check_rc=False, + ), + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + "sudo", + ], + check_rc=False, + ), + ], + "side_effect": [(0, "sudo version", ""), (0, "", "")], + }, + AnsibleExitJson, + ), + ( + # install pkg, extra_args + {"name": ["sudo"], "state": "present", "extra_args": "--some --thing else"}, + ["sudo"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + "--some", + "--thing", + "else", + "--print-format", + "%n %v", + "sudo", + ], + check_rc=False, + ), + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + "--some", + "--thing", + "else", + "sudo", + ], + check_rc=False, + ), + ], + "side_effect": [(0, "sudo version", ""), (0, "", "")], + }, + AnsibleExitJson, + ), + ( + # latest pkg: Check mode + {"_ansible_check_mode": True, "name": ["sqlite"], "state": "latest"}, + ["sqlite"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + "--print-format", + "%n %v", + "sqlite", + ], + check_rc=False, + ), + ], + "side_effect": [(0, "sqlite new-version", "")], + }, + AnsibleExitJson, + ), + ( + # latest pkg -- one already latest + {"name": ["sqlite", "grep"], "state": "latest"}, + ["sqlite"], + { + "calls": [ + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + "--print-format", + "%n %v", + "sqlite", + ], + check_rc=False, + ), + mock.call( + mock.ANY, + [ + "pacman", + "--sync", + "--noconfirm", + "--noprogressbar", + "--needed", + "sqlite", + ], + check_rc=False, + ), + ], + "side_effect": [(0, "sqlite new-version", ""), (0, "", "")], + }, + AnsibleExitJson, + ), + ], + ) + def test_op_packages( + self, + mock_valid_inventory, + module_args, + expected_packages, + run_command_data, + raises, + ): + set_module_args(module_args) + self.mock_run_command.side_effect = run_command_data["side_effect"] + + P = pacman.Pacman(pacman.setup_module()) + with pytest.raises(raises) as e: + P.run() + out = e.value.args[0] + + assert self.mock_run_command.mock_calls == run_command_data["calls"] + if raises == AnsibleExitJson: + assert out["packages"] == expected_packages + assert out["changed"] + assert "diff" in out + else: + assert out["stdout"] == "stdout" + assert out["stderr"] == "stderr"