diff --git a/changelogs/fragments/4956-pacman-install-reason.yaml b/changelogs/fragments/4956-pacman-install-reason.yaml new file mode 100644 index 0000000000..e22c56e7bc --- /dev/null +++ b/changelogs/fragments/4956-pacman-install-reason.yaml @@ -0,0 +1,2 @@ +minor_changes: + - pacman - added parameters ``reason`` and ``reason_for`` to set/change the install reason of packages (https://github.com/ansible-collections/community.general/pull/4956). diff --git a/plugins/modules/packaging/os/pacman.py b/plugins/modules/packaging/os/pacman.py index 65f6f92747..1d764c6f0f 100644 --- a/plugins/modules/packaging/os/pacman.py +++ b/plugins/modules/packaging/os/pacman.py @@ -104,6 +104,22 @@ options: default: type: str + reason: + description: + - The install reason to set for the packages. + choices: [ dependency, explicit ] + type: str + version_added: 5.4.0 + + reason_for: + description: + - Set the install reason for C(all) packages or only for C(new) packages. + - In case of C(state=latest) already installed packages which will be updated to a newer version are not counted as C(new). + default: new + choices: [ all, new ] + type: str + version_added: 5.4.0 + notes: - When used with a C(loop:) each package will be processed individually, it is much more efficient to pass the list directly to the I(name) option. @@ -223,6 +239,20 @@ EXAMPLES = """ name: baz state: absent force: yes + +- name: Install foo as dependency and leave reason untouched if already installed + community.general.pacman: + name: foo + state: present + reason: dependency + reason_for: new + +- name: Run the equivalent of "pacman -S --asexplicit", mark foo as explicit and install it if not present + community.general.pacman: + name: foo + state: present + reason: explicit + reason_for: all """ import shlex @@ -331,7 +361,14 @@ class Pacman(object): def install_packages(self, pkgs): pkgs_to_install = [] pkgs_to_install_from_url = [] + pkgs_to_set_reason = [] for p in pkgs: + if self.m.params["reason"] and ( + p.name not in self.inventory["pkg_reasons"] + or self.m.params["reason_for"] == "all" + and self.inventory["pkg_reasons"][p.name] != self.m.params["reason"] + ): + pkgs_to_set_reason.append(p.name) if p.source_is_URL: # URL packages bypass the latest / upgradable_pkgs test # They go through the dry-run to let pacman decide if they will be installed @@ -344,7 +381,7 @@ class Pacman(object): ): pkgs_to_install.append(p) - if len(pkgs_to_install) == 0 and len(pkgs_to_install_from_url) == 0: + if len(pkgs_to_install) == 0 and len(pkgs_to_install_from_url) == 0 and len(pkgs_to_set_reason) == 0: self.exit_params["packages"] = [] self.add_exit_infos("package(s) already installed") return @@ -377,8 +414,13 @@ class Pacman(object): continue 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)) + before.append("%s-%s-%s" % (name, self.inventory["installed_pkgs"][name], self.inventory["pkg_reasons"][name])) + if name in pkgs_to_set_reason: + after.append("%s-%s-%s" % (name, version, self.m.params["reason"])) + elif name in self.inventory["pkg_reasons"]: + after.append("%s-%s-%s" % (name, version, self.inventory["pkg_reasons"][name])) + else: + after.append("%s-%s" % (name, version)) to_be_installed.append(name) return (to_be_installed, before, after) @@ -398,7 +440,7 @@ class Pacman(object): before.extend(b) after.extend(a) - if len(installed_pkgs) == 0: + if len(installed_pkgs) == 0 and len(pkgs_to_set_reason) == 0: # This can happen with URL packages if pacman decides there's nothing to do self.exit_params["packages"] = [] self.add_exit_infos("package(s) already installed") @@ -411,9 +453,11 @@ class Pacman(object): "after": "\n".join(sorted(after)) + "\n" if after else "", } + changed_reason_pkgs = [p for p in pkgs_to_set_reason if p not in installed_pkgs] + if self.m.check_mode: - self.add_exit_infos("Would have installed %d packages" % len(installed_pkgs)) - self.exit_params["packages"] = sorted(installed_pkgs) + self.add_exit_infos("Would have installed %d packages" % (len(installed_pkgs) + len(changed_reason_pkgs))) + self.exit_params["packages"] = sorted(installed_pkgs + changed_reason_pkgs) return # actually do it @@ -430,8 +474,22 @@ class Pacman(object): if pkgs_to_install_from_url: _install_packages_for_real("--upgrade", pkgs_to_install_from_url) - self.exit_params["packages"] = installed_pkgs - self.add_exit_infos("Installed %d package(s)" % len(installed_pkgs)) + # set reason + if pkgs_to_set_reason: + cmd = [self.pacman_path, "--noconfirm", "--database"] + if self.m.params["reason"] == "dependency": + cmd.append("--asdeps") + else: + cmd.append("--asexplicit") + cmd.extend(pkgs_to_set_reason) + + rc, stdout, stderr = self.m.run_command(cmd, check_rc=False) + if rc != 0: + self.fail("Failed to install package(s)", cmd=cmd, stdout=stdout, stderr=stderr) + self.add_exit_infos(stdout=stdout, stderr=stderr) + + self.exit_params["packages"] = sorted(installed_pkgs + changed_reason_pkgs) + self.add_exit_infos("Installed %d package(s)" % (len(installed_pkgs) + len(changed_reason_pkgs))) def remove_packages(self, pkgs): # filter out pkgs that are already absent @@ -631,6 +689,7 @@ class Pacman(object): "available_pkgs": {pkgname: version}, "available_groups": {groupname: set(pkgnames)}, "upgradable_pkgs": {pkgname: (current_version,latest_version)}, + "pkg_reasons": {pkgname: reason}, } Fails the module if a package requested for install cannot be found @@ -723,12 +782,31 @@ class Pacman(object): rc=rc, ) + pkg_reasons = {} + dummy, stdout, dummy = self.m.run_command([self.pacman_path, "--query", "--explicit"], 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 = l.split()[0] + pkg_reasons[pkg] = "explicit" + dummy, stdout, dummy = self.m.run_command([self.pacman_path, "--query", "--deps"], 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 = l.split()[0] + pkg_reasons[pkg] = "dependency" + return dict( installed_pkgs=installed_pkgs, installed_groups=installed_groups, available_pkgs=available_pkgs, available_groups=available_groups, upgradable_pkgs=upgradable_pkgs, + pkg_reasons=pkg_reasons, ) @@ -749,6 +827,8 @@ def setup_module(): upgrade_extra_args=dict(type="str", default=""), update_cache=dict(type="bool"), update_cache_extra_args=dict(type="str", default=""), + reason=dict(type="str", choices=["explicit", "dependency"]), + reason_for=dict(type="str", default="new", choices=["new", "all"]), ), required_one_of=[["name", "update_cache", "upgrade"]], mutually_exclusive=[["name", "upgrade"]], diff --git a/tests/integration/targets/pacman/tasks/main.yml b/tests/integration/targets/pacman/tasks/main.yml index 4a34307c48..cabf9b7c40 100644 --- a/tests/integration/targets/pacman/tasks/main.yml +++ b/tests/integration/targets/pacman/tasks/main.yml @@ -12,3 +12,4 @@ - include: 'remove_nosave.yml' - include: 'update_cache.yml' - include: 'locally_installed_package.yml' + - include: 'reason.yml' diff --git a/tests/integration/targets/pacman/tasks/reason.yml b/tests/integration/targets/pacman/tasks/reason.yml new file mode 100644 index 0000000000..51b2bf36ba --- /dev/null +++ b/tests/integration/targets/pacman/tasks/reason.yml @@ -0,0 +1,97 @@ +--- +- vars: + reg_pkg: ed + url_pkg: lemon + file_pkg: hdparm + file_pkg_path: /tmp/pkg.zst + extra_pkg: core/sdparm + extra_pkg_outfmt: sdparm + block: + - name: Make sure that test packages are not installed + pacman: + name: + - '{{reg_pkg}}' + - '{{url_pkg}}' + - '{{file_pkg}}' + - '{{extra_pkg}}' + state: absent + + - name: Get URL for {{url_pkg}} + command: + cmd: pacman --sync --print-format "%l" {{url_pkg}} + register: url_pkg_url + + - name: Get URL for {{file_pkg}} + command: + cmd: pacman --sync --print-format "%l" {{file_pkg}} + register: file_pkg_url + - name: Download {{file_pkg}} pkg + get_url: + url: '{{file_pkg_url.stdout}}' + dest: '{{file_pkg_path}}' + + - name: Install packages from mixed sources as dependency (check mode) + pacman: + name: + - '{{reg_pkg}}' + - '{{url_pkg_url.stdout}}' + - '{{file_pkg_path}}' + reason: dependency + check_mode: True + register: install_1 + + - name: Install packages from mixed sources as explicit + pacman: + name: + - '{{reg_pkg}}' + - '{{url_pkg_url.stdout}}' + - '{{file_pkg_path}}' + reason: explicit + register: install_2 + + - name: Install packages from mixed sources with new packages being installed as dependency - (idempotency) + pacman: + name: + - '{{reg_pkg}}' + - '{{url_pkg_url.stdout}}' + - '{{file_pkg_path}}' + reason: dependency + register: install_3 + + - name: Install new package with already installed packages from mixed sources as dependency + pacman: + name: + - '{{reg_pkg}}' + - '{{url_pkg_url.stdout}}' + - '{{file_pkg_path}}' + - '{{extra_pkg}}' + reason: dependency + register: install_4 + + - name: Set install reason for all packages to dependency + pacman: + name: + - '{{reg_pkg}}' + - '{{url_pkg_url.stdout}}' + - '{{file_pkg_path}}' + - '{{extra_pkg}}' + reason: dependency + reason_for: all + register: install_5 + + - assert: + that: + - install_1 is changed + - install_1.msg == 'Would have installed 3 packages' + - install_1.packages|sort() == [reg_pkg, url_pkg, file_pkg]|sort() + - install_2 is changed + - install_2.msg == 'Installed 3 package(s)' + - install_2.packages|sort() == [reg_pkg, url_pkg, file_pkg]|sort() + - install_3 is not changed + - install_3.msg == 'package(s) already installed' + - install_4 is changed + - install_4.msg == 'Installed 1 package(s)' + - install_4.packages == [extra_pkg_outfmt] + - install_5 is changed + - install_5.msg == 'Installed 3 package(s)' + - install_5.packages|sort() == [reg_pkg, url_pkg, file_pkg]|sort() diff --git a/tests/unit/plugins/modules/packaging/os/test_pacman.py b/tests/unit/plugins/modules/packaging/os/test_pacman.py index bbc8f3435c..ae2da6a48f 100644 --- a/tests/unit/plugins/modules/packaging/os/test_pacman.py +++ b/tests/unit/plugins/modules/packaging/os/test_pacman.py @@ -100,6 +100,19 @@ valid_inventory = { "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 = { @@ -108,6 +121,7 @@ empty_inventory = { "installed_groups": {}, "available_groups": {}, "upgradable_pkgs": {}, + "pkg_reasons": {}, } @@ -255,6 +269,27 @@ class TestPacman: """, "", ), + ( # 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, ), @@ -272,6 +307,8 @@ class TestPacman: "", "warning: config file /etc/pacman.conf, line 34: directive 'TotalDownload' in section 'options' not recognized.", ), + (0, "", ""), + (0, "", ""), ], None, ), @@ -288,6 +325,8 @@ class TestPacman: "partial\npkg\\nlist", "some warning", ), + (0, "", ""), + (0, "", ""), ], AnsibleFailJson, ), @@ -375,6 +414,8 @@ class TestPacman: (["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, ), @@ -843,7 +884,7 @@ class TestPacman: ], "state": "present", }, - ["sudo", "somepackage", "otherpkg"], + ["otherpkg", "somepackage", "sudo"], [ Package("sudo", "sudo"), Package("grep", "grep"),