# Copyright (c) Ansible project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later 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 import pacman from ansible_collections.community.general.plugins.modules.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"), }, "pkg_reasons": { "file": "explicit", "filesystem": "explicit", "findutils": "explicit", "gawk": "explicit", "gettext": "explicit", "grep": "explicit", "gzip": "explicit", "pacman": "explicit", "pacman-mirrorlist": "dependency", "sed": "explicit", "sqlite": "explicit", }, } empty_inventory = { "installed_pkgs": {}, "available_pkgs": {}, "installed_groups": {}, "available_groups": {}, "upgradable_pkgs": {}, "pkg_reasons": {}, } 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] """, "", ), ( # pacman --query --explicit 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 sed 4.8-1 sqlite 3.36.0-1 """, "", ), ( # pacman --query --deps 0, """pacman-mirrorlist 20211114-1 """, "", ), ], 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.", ), (0, "", ""), (0, "", ""), ], None, ), ( # failure empty_inventory, [ (0, "", ""), (0, "", ""), (0, "", ""), (0, "", ""), ( 1, "partial\npkg\\nlist", "some warning", ), (0, "", ""), (0, "", ""), ], 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 "packages" not in out assert out["changed"] @pytest.mark.parametrize( "module_args,expected_calls,changed", [ ( {}, [ (["pacman", "--sync", "--list"], {'check_rc': True}, 0, 'a\nb\nc', ''), (["pacman", "--sync", "--refresh"], {'check_rc': False}, 0, 'stdout', 'stderr'), (["pacman", "--sync", "--list"], {'check_rc': True}, 0, 'b\na\nc', ''), ], False, ), ( {"force": True}, [ (["pacman", "--sync", "--refresh", "--refresh"], {'check_rc': False}, 0, 'stdout', 'stderr'), ], True, ), ( {"update_cache_extra_args": "--some-extra args"}, # shlex test [ (["pacman", "--sync", "--list"], {'check_rc': True}, 0, 'a\nb\nc', ''), (["pacman", "--sync", "--refresh", "--some-extra", "args"], {'check_rc': False}, 0, 'stdout', 'stderr'), (["pacman", "--sync", "--list"], {'check_rc': True}, 0, 'a changed\nb\nc', ''), ], True, ), ( {"force": True, "update_cache_extra_args": "--some-extra args"}, [ (["pacman", "--sync", "--refresh", "--some-extra", "args", "--refresh"], {'check_rc': False}, 0, 'stdout', 'stderr'), ], True, ), ( # Test whether pacman --sync --list is not called more than twice {"upgrade": True}, [ (["pacman", "--sync", "--list"], {'check_rc': True}, 0, 'core foo 1.0.0-1 [installed]', ''), (["pacman", "--sync", "--refresh"], {'check_rc': False}, 0, 'stdout', 'stderr'), (["pacman", "--sync", "--list"], {'check_rc': True}, 0, 'core foo 1.0.0-1 [installed]', ''), # The following is _build_inventory: (["pacman", "--query"], {'check_rc': True}, 0, 'foo 1.0.0-1', ''), (["pacman", "--query", "--groups"], {'check_rc': True}, 0, '', ''), (["pacman", "--sync", "--groups", "--groups"], {'check_rc': True}, 0, '', ''), (["pacman", "--query", "--upgrades"], {'check_rc': False}, 0, '', ''), (["pacman", "--query", "--explicit"], {'check_rc': True}, 0, 'foo 1.0.0-1', ''), (["pacman", "--query", "--deps"], {'check_rc': True}, 0, '', ''), ], False, ), ], ) def test_update_db(self, module_args, expected_calls, changed): args = {"update_cache": True} args.update(module_args) set_module_args(args) self.mock_run_command.side_effect = [ (rc, stdout, stderr) for expected_call, kwargs, rc, stdout, stderr in expected_calls ] with pytest.raises(AnsibleExitJson) as e: P = pacman.Pacman(pacman.setup_module()) P.run() self.mock_run_command.assert_has_calls([ mock.call(mock.ANY, expected_call, **kwargs) for expected_call, kwargs, rc, stdout, stderr in expected_calls ]) out = e.value.args[0] assert out["cache_updated"] == changed assert out["changed"] == changed @pytest.mark.parametrize( "check_mode_value, run_command_data, upgrade_extra_args", [ # just check (True, None, None), ( # for real False, { "args": ["pacman", "--sync", "--sysupgrade", "--quiet", "--noconfirm"], "return_value": [0, "stdout", "stderr"], }, None, ), ( # with extra args False, { "args": [ "pacman", "--sync", "--sysupgrade", "--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", source_is_URL=True, ) ], { "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 not out["changed"] assert "packages" in out assert "diff" not in out self.mock_run_command.call_count == 0 @pytest.mark.parametrize( "module_args, expected_packages, package_list_out, 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"], [Package("grep", "grep")], { "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"], [Package("grep", "grep"), Package("gawk", "gawk")], { "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"], [Package("grep", "grep")], { "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"], [Package("grep", "grep")], { "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"], [Package("grep", "grep")], { "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"], [Package("sudo", "sudo")], { "calls": [ mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--sync", "--print-format", "%n %v", "sudo", ], check_rc=False, ), ], "side_effect": [(0, "sudo version", "")], }, AnsibleExitJson, ), ( # Install pkgs: one regular, one already installed, one file URL and one https URL { "name": [ "sudo", "grep", "./somepackage-12.3-x86_64.pkg.tar.zst", "http://example.com/otherpkg-1.2-x86_64.pkg.tar.zst", ], "state": "present", }, ["otherpkg", "somepackage", "sudo"], [ Package("sudo", "sudo"), Package("grep", "grep"), Package("somepackage", "./somepackage-12.3-x86_64.pkg.tar.zst", source_is_URL=True), Package( "otherpkg", "http://example.com/otherpkg-1.2-x86_64.pkg.tar.zst", source_is_URL=True, ), ], { "calls": [ mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--sync", "--print-format", "%n %v", "sudo", ], check_rc=False, ), mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--upgrade", "--print-format", "%n %v", "./somepackage-12.3-x86_64.pkg.tar.zst", "http://example.com/otherpkg-1.2-x86_64.pkg.tar.zst", ], check_rc=False, ), mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--sync", "sudo", ], check_rc=False, ), mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--upgrade", "./somepackage-12.3-x86_64.pkg.tar.zst", "http://example.com/otherpkg-1.2-x86_64.pkg.tar.zst", ], check_rc=False, ), ], "side_effect": [ (0, "sudo version", ""), (0, "somepackage 12.3\notherpkg 1.2", ""), (0, "", ""), (0, "", ""), ], }, AnsibleExitJson, ), ( # install pkg, extra_args {"name": ["sudo"], "state": "present", "extra_args": "--some --thing else"}, ["sudo"], [Package("sudo", "sudo")], { "calls": [ mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--some", "--thing", "else", "--sync", "--print-format", "%n %v", "sudo", ], check_rc=False, ), mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--some", "--thing", "else", "--sync", "sudo", ], check_rc=False, ), ], "side_effect": [(0, "sudo version", ""), (0, "", "")], }, AnsibleExitJson, ), ( # latest pkg: Check mode {"_ansible_check_mode": True, "name": ["sqlite"], "state": "latest"}, ["sqlite"], [Package("sqlite", "sqlite")], { "calls": [ mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--sync", "--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"], [Package("sqlite", "sqlite")], { "calls": [ mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--sync", "--print-format", "%n %v", "sqlite", ], check_rc=False, ), mock.call( mock.ANY, [ "pacman", "--noconfirm", "--noprogressbar", "--needed", "--sync", "sqlite", ], check_rc=False, ), ], "side_effect": [(0, "sqlite new-version", ""), (0, "", "")], }, AnsibleExitJson, ), ], ) def test_op_packages( self, mock_valid_inventory, mock_package_list, module_args, expected_packages, package_list_out, run_command_data, raises, ): set_module_args(module_args) self.mock_run_command.side_effect = run_command_data["side_effect"] mock_package_list.return_value = package_list_out 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 "packages" in out assert "diff" in out else: assert out["stdout"] == "stdout" assert out["stderr"] == "stderr"