1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# Copyright: (c) 2012, Afterburn <https://github.com/afterburn> 5# Copyright: (c) 2013, Aaron Bull Schaefer <aaron@elasticdog.com> 6# Copyright: (c) 2015, Indrajit Raychaudhuri <irc+code@indrajit.com> 7# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 8 9from __future__ import absolute_import, division, print_function 10__metaclass__ = type 11 12DOCUMENTATION = ''' 13--- 14module: pacman 15short_description: Manage packages with I(pacman) 16description: 17 - Manage packages with the I(pacman) package manager, which is used by Arch Linux and its variants. 18author: 19 - Indrajit Raychaudhuri (@indrajitr) 20 - Aaron Bull Schaefer (@elasticdog) <aaron@elasticdog.com> 21 - Maxime de Roucy (@tchernomax) 22options: 23 name: 24 description: 25 - Name or list of names of the package(s) or file(s) to install, upgrade, or remove. 26 Can't be used in combination with C(upgrade). 27 aliases: [ package, pkg ] 28 type: list 29 elements: str 30 31 state: 32 description: 33 - Whether to install (C(present) or C(installed), C(latest)), or remove (C(absent) or C(removed)) a package. 34 - C(present) and C(installed) will simply ensure that a desired package is installed. 35 - C(latest) will update the specified package if it is not of the latest available version. 36 - C(absent) and C(removed) will remove the specified package. 37 default: present 38 choices: [ absent, installed, latest, present, removed ] 39 type: str 40 41 force: 42 description: 43 - When removing package, force remove package, without any checks. 44 Same as `extra_args="--nodeps --nodeps"`. 45 When update_cache, force redownload repo databases. 46 Same as `update_cache_extra_args="--refresh --refresh"`. 47 default: no 48 type: bool 49 50 executable: 51 description: 52 - Name of binary to use. This can either be C(pacman) or a pacman compatible AUR helper. 53 - Beware that AUR helpers might behave unexpectedly and are therefore not recommended. 54 default: pacman 55 type: str 56 version_added: 3.1.0 57 58 extra_args: 59 description: 60 - Additional option to pass to pacman when enforcing C(state). 61 default: 62 type: str 63 64 update_cache: 65 description: 66 - Whether or not to refresh the master package lists. 67 - This can be run as part of a package installation or as a separate step. 68 - Alias C(update-cache) has been deprecated and will be removed in community.general 5.0.0. 69 default: no 70 type: bool 71 aliases: [ update-cache ] 72 73 update_cache_extra_args: 74 description: 75 - Additional option to pass to pacman when enforcing C(update_cache). 76 default: 77 type: str 78 79 upgrade: 80 description: 81 - Whether or not to upgrade the whole system. 82 Can't be used in combination with C(name). 83 default: no 84 type: bool 85 86 upgrade_extra_args: 87 description: 88 - Additional option to pass to pacman when enforcing C(upgrade). 89 default: 90 type: str 91 92notes: 93 - When used with a C(loop:) each package will be processed individually, 94 it is much more efficient to pass the list directly to the I(name) option. 95 - To use an AUR helper (I(executable) option), a few extra setup steps might be required beforehand. 96 For example, a dedicated build user with permissions to install packages could be necessary. 97''' 98 99RETURN = ''' 100packages: 101 description: a list of packages that have been changed 102 returned: when upgrade is set to yes 103 type: list 104 sample: [ package, other-package ] 105''' 106 107EXAMPLES = ''' 108- name: Install package foo from repo 109 community.general.pacman: 110 name: foo 111 state: present 112 113- name: Install package bar from file 114 community.general.pacman: 115 name: ~/bar-1.0-1-any.pkg.tar.xz 116 state: present 117 118- name: Install package foo from repo and bar from file 119 community.general.pacman: 120 name: 121 - foo 122 - ~/bar-1.0-1-any.pkg.tar.xz 123 state: present 124 125- name: Install package from AUR using a Pacman compatible AUR helper 126 community.general.pacman: 127 name: foo 128 state: present 129 executable: yay 130 extra_args: --builddir /var/cache/yay 131 132- name: Upgrade package foo 133 community.general.pacman: 134 name: foo 135 state: latest 136 update_cache: yes 137 138- name: Remove packages foo and bar 139 community.general.pacman: 140 name: 141 - foo 142 - bar 143 state: absent 144 145- name: Recursively remove package baz 146 community.general.pacman: 147 name: baz 148 state: absent 149 extra_args: --recursive 150 151- name: Run the equivalent of "pacman -Sy" as a separate step 152 community.general.pacman: 153 update_cache: yes 154 155- name: Run the equivalent of "pacman -Su" as a separate step 156 community.general.pacman: 157 upgrade: yes 158 159- name: Run the equivalent of "pacman -Syu" as a separate step 160 community.general.pacman: 161 update_cache: yes 162 upgrade: yes 163 164- name: Run the equivalent of "pacman -Rdd", force remove package baz 165 community.general.pacman: 166 name: baz 167 state: absent 168 force: yes 169''' 170 171import re 172 173from ansible.module_utils.basic import AnsibleModule 174 175 176def get_version(pacman_output): 177 """Take pacman -Q or pacman -S output and get the Version""" 178 fields = pacman_output.split() 179 if len(fields) == 2: 180 return fields[1] 181 return None 182 183 184def get_name(module, pacman_output): 185 """Take pacman -Q or pacman -S output and get the package name""" 186 fields = pacman_output.split() 187 if len(fields) == 2: 188 return fields[0] 189 module.fail_json(msg="get_name: fail to retrieve package name from pacman output") 190 191 192def query_package(module, pacman_path, name, state="present"): 193 """Query the package status in both the local system and the repository. Returns a boolean to indicate if the package is installed, a second 194 boolean to indicate if the package is up-to-date and a third boolean to indicate whether online information were available 195 """ 196 if state == "present": 197 lcmd = "%s --query %s" % (pacman_path, name) 198 lrc, lstdout, lstderr = module.run_command(lcmd, check_rc=False) 199 if lrc != 0: 200 # package is not installed locally 201 return False, False, False 202 else: 203 # a non-zero exit code doesn't always mean the package is installed 204 # for example, if the package name queried is "provided" by another package 205 installed_name = get_name(module, lstdout) 206 if installed_name != name: 207 return False, False, False 208 209 # get the version installed locally (if any) 210 lversion = get_version(lstdout) 211 212 rcmd = "%s --sync --print-format \"%%n %%v\" %s" % (pacman_path, name) 213 rrc, rstdout, rstderr = module.run_command(rcmd, check_rc=False) 214 # get the version in the repository 215 rversion = get_version(rstdout) 216 217 if rrc == 0: 218 # Return True to indicate that the package is installed locally, and the result of the version number comparison 219 # to determine if the package is up-to-date. 220 return True, (lversion == rversion), False 221 222 # package is installed but cannot fetch remote Version. Last True stands for the error 223 return True, True, True 224 225 226def update_package_db(module, pacman_path): 227 if module.params['force']: 228 module.params["update_cache_extra_args"] += " --refresh --refresh" 229 230 cmd = "%s --sync --refresh %s" % (pacman_path, module.params["update_cache_extra_args"]) 231 rc, stdout, stderr = module.run_command(cmd, check_rc=False) 232 233 if rc == 0: 234 return True 235 else: 236 module.fail_json(msg="could not update package db") 237 238 239def upgrade(module, pacman_path): 240 cmdupgrade = "%s --sync --sysupgrade --quiet --noconfirm %s" % (pacman_path, module.params["upgrade_extra_args"]) 241 cmdneedrefresh = "%s --query --upgrades" % (pacman_path) 242 rc, stdout, stderr = module.run_command(cmdneedrefresh, check_rc=False) 243 data = stdout.split('\n') 244 data.remove('') 245 packages = [] 246 diff = { 247 'before': '', 248 'after': '', 249 } 250 251 if rc == 0: 252 # Match lines of `pacman -Qu` output of the form: 253 # (package name) (before version-release) -> (after version-release) 254 # e.g., "ansible 2.7.1-1 -> 2.7.2-1" 255 regex = re.compile(r'([\w+\-.@]+) (\S+-\S+) -> (\S+-\S+)') 256 for p in data: 257 if '[ignored]' not in p: 258 m = regex.search(p) 259 packages.append(m.group(1)) 260 if module._diff: 261 diff['before'] += "%s-%s\n" % (m.group(1), m.group(2)) 262 diff['after'] += "%s-%s\n" % (m.group(1), m.group(3)) 263 if module.check_mode: 264 if packages: 265 module.exit_json(changed=True, msg="%s package(s) would be upgraded" % (len(data)), packages=packages, diff=diff) 266 else: 267 module.exit_json(changed=False, msg='Nothing to upgrade', packages=packages) 268 rc, stdout, stderr = module.run_command(cmdupgrade, check_rc=False) 269 if rc == 0: 270 if packages: 271 module.exit_json(changed=True, msg='System upgraded', packages=packages, diff=diff) 272 else: 273 module.exit_json(changed=False, msg='Nothing to upgrade', packages=packages) 274 else: 275 module.fail_json(msg="Could not upgrade") 276 else: 277 module.exit_json(changed=False, msg='Nothing to upgrade', packages=packages) 278 279 280def remove_packages(module, pacman_path, packages): 281 data = [] 282 diff = { 283 'before': '', 284 'after': '', 285 } 286 287 if module.params["force"]: 288 module.params["extra_args"] += " --nodeps --nodeps" 289 290 remove_c = 0 291 # Using a for loop in case of error, we can report the package that failed 292 for package in packages: 293 # Query the package first, to see if we even need to remove 294 installed, updated, unknown = query_package(module, pacman_path, package) 295 if not installed: 296 continue 297 298 cmd = "%s --remove --noconfirm --noprogressbar %s %s" % (pacman_path, module.params["extra_args"], package) 299 rc, stdout, stderr = module.run_command(cmd, check_rc=False) 300 301 if rc != 0: 302 module.fail_json(msg="failed to remove %s" % (package)) 303 304 if module._diff: 305 d = stdout.split('\n')[2].split(' ')[2:] 306 for i, pkg in enumerate(d): 307 d[i] = re.sub('-[0-9].*$', '', d[i].split('/')[-1]) 308 diff['before'] += "%s\n" % pkg 309 data.append('\n'.join(d)) 310 311 remove_c += 1 312 313 if remove_c > 0: 314 module.exit_json(changed=True, msg="removed %s package(s)" % remove_c, diff=diff) 315 316 module.exit_json(changed=False, msg="package(s) already absent") 317 318 319def install_packages(module, pacman_path, state, packages, package_files): 320 install_c = 0 321 package_err = [] 322 message = "" 323 data = [] 324 diff = { 325 'before': '', 326 'after': '', 327 } 328 329 to_install_repos = [] 330 to_install_files = [] 331 for i, package in enumerate(packages): 332 # if the package is installed and state == present or state == latest and is up-to-date then skip 333 installed, updated, latestError = query_package(module, pacman_path, package) 334 if latestError and state == 'latest': 335 package_err.append(package) 336 337 if installed and (state == 'present' or (state == 'latest' and updated)): 338 continue 339 340 if package_files[i]: 341 to_install_files.append(package_files[i]) 342 else: 343 to_install_repos.append(package) 344 345 if to_install_repos: 346 cmd = "%s --sync --noconfirm --noprogressbar --needed %s %s" % (pacman_path, module.params["extra_args"], " ".join(to_install_repos)) 347 rc, stdout, stderr = module.run_command(cmd, check_rc=False) 348 349 if rc != 0: 350 module.fail_json(msg="failed to install %s: %s" % (" ".join(to_install_repos), stderr)) 351 352 # As we pass `--needed` to pacman returns a single line of ` there is nothing to do` if no change is performed. 353 # The check for > 3 is here because we pick the 4th line in normal operation. 354 if len(stdout.split('\n')) > 3: 355 data = stdout.split('\n')[3].split(' ')[2:] 356 data = [i for i in data if i != ''] 357 for i, pkg in enumerate(data): 358 data[i] = re.sub('-[0-9].*$', '', data[i].split('/')[-1]) 359 if module._diff: 360 diff['after'] += "%s\n" % pkg 361 362 install_c += len(to_install_repos) 363 364 if to_install_files: 365 cmd = "%s --upgrade --noconfirm --noprogressbar --needed %s %s" % (pacman_path, module.params["extra_args"], " ".join(to_install_files)) 366 rc, stdout, stderr = module.run_command(cmd, check_rc=False) 367 368 if rc != 0: 369 module.fail_json(msg="failed to install %s: %s" % (" ".join(to_install_files), stderr)) 370 371 # As we pass `--needed` to pacman returns a single line of ` there is nothing to do` if no change is performed. 372 # The check for > 3 is here because we pick the 4th line in normal operation. 373 if len(stdout.split('\n')) > 3: 374 data = stdout.split('\n')[3].split(' ')[2:] 375 data = [i for i in data if i != ''] 376 for i, pkg in enumerate(data): 377 data[i] = re.sub('-[0-9].*$', '', data[i].split('/')[-1]) 378 if module._diff: 379 diff['after'] += "%s\n" % pkg 380 381 install_c += len(to_install_files) 382 383 if state == 'latest' and len(package_err) > 0: 384 message = "But could not ensure 'latest' state for %s package(s) as remote version could not be fetched." % (package_err) 385 386 if install_c > 0: 387 module.exit_json(changed=True, msg="installed %s package(s). %s" % (install_c, message), diff=diff) 388 389 module.exit_json(changed=False, msg="package(s) already installed. %s" % (message), diff=diff) 390 391 392def check_packages(module, pacman_path, packages, state): 393 would_be_changed = [] 394 diff = { 395 'before': '', 396 'after': '', 397 'before_header': '', 398 'after_header': '' 399 } 400 401 for package in packages: 402 installed, updated, unknown = query_package(module, pacman_path, package) 403 if ((state in ["present", "latest"] and not installed) or 404 (state == "absent" and installed) or 405 (state == "latest" and not updated)): 406 would_be_changed.append(package) 407 if would_be_changed: 408 if state == "absent": 409 state = "removed" 410 411 if module._diff and (state == 'removed'): 412 diff['before_header'] = 'removed' 413 diff['before'] = '\n'.join(would_be_changed) + '\n' 414 elif module._diff and ((state == 'present') or (state == 'latest')): 415 diff['after_header'] = 'installed' 416 diff['after'] = '\n'.join(would_be_changed) + '\n' 417 418 module.exit_json(changed=True, msg="%s package(s) would be %s" % ( 419 len(would_be_changed), state), diff=diff) 420 else: 421 module.exit_json(changed=False, msg="package(s) already %s" % state, diff=diff) 422 423 424def expand_package_groups(module, pacman_path, pkgs): 425 expanded = [] 426 427 __, stdout, __ = module.run_command([pacman_path, "--sync", "--groups", "--quiet"], check_rc=True) 428 available_groups = stdout.splitlines() 429 430 for pkg in pkgs: 431 if pkg: # avoid empty strings 432 if pkg in available_groups: 433 # A group was found matching the package name: expand it 434 cmd = [pacman_path, "--sync", "--groups", "--quiet", pkg] 435 rc, stdout, stderr = module.run_command(cmd, check_rc=True) 436 expanded.extend([name.strip() for name in stdout.splitlines()]) 437 else: 438 expanded.append(pkg) 439 440 return expanded 441 442 443def main(): 444 module = AnsibleModule( 445 argument_spec=dict( 446 name=dict(type='list', elements='str', aliases=['pkg', 'package']), 447 state=dict(type='str', default='present', choices=['present', 'installed', 'latest', 'absent', 'removed']), 448 force=dict(type='bool', default=False), 449 executable=dict(type='str', default='pacman'), 450 extra_args=dict(type='str', default=''), 451 upgrade=dict(type='bool', default=False), 452 upgrade_extra_args=dict(type='str', default=''), 453 update_cache=dict( 454 type='bool', default=False, aliases=['update-cache'], 455 deprecated_aliases=[dict(name='update-cache', version='5.0.0', collection_name='community.general')]), 456 update_cache_extra_args=dict(type='str', default=''), 457 ), 458 required_one_of=[['name', 'update_cache', 'upgrade']], 459 mutually_exclusive=[['name', 'upgrade']], 460 supports_check_mode=True, 461 ) 462 463 module.run_command_environ_update = dict(LC_ALL='C') 464 465 p = module.params 466 467 # find pacman binary 468 pacman_path = module.get_bin_path(p['executable'], True) 469 470 # normalize the state parameter 471 if p['state'] in ['present', 'installed']: 472 p['state'] = 'present' 473 elif p['state'] in ['absent', 'removed']: 474 p['state'] = 'absent' 475 476 if p["update_cache"] and not module.check_mode: 477 update_package_db(module, pacman_path) 478 if not (p['name'] or p['upgrade']): 479 module.exit_json(changed=True, msg='Updated the package master lists') 480 481 if p['update_cache'] and module.check_mode and not (p['name'] or p['upgrade']): 482 module.exit_json(changed=True, msg='Would have updated the package cache') 483 484 if p['upgrade']: 485 upgrade(module, pacman_path) 486 487 if p['name']: 488 pkgs = expand_package_groups(module, pacman_path, p['name']) 489 490 pkg_files = [] 491 for i, pkg in enumerate(pkgs): 492 if not pkg: # avoid empty strings 493 continue 494 elif re.match(r".*\.pkg\.tar(\.(gz|bz2|xz|lrz|lzo|Z|zst))?$", pkg): 495 # The package given is a filename, extract the raw pkg name from 496 # it and store the filename 497 pkg_files.append(pkg) 498 pkgs[i] = re.sub(r'-[0-9].*$', '', pkgs[i].split('/')[-1]) 499 else: 500 pkg_files.append(None) 501 502 if module.check_mode: 503 check_packages(module, pacman_path, pkgs, p['state']) 504 505 if p['state'] in ['present', 'latest']: 506 install_packages(module, pacman_path, p['state'], pkgs, pkg_files) 507 elif p['state'] == 'absent': 508 remove_packages(module, pacman_path, pkgs) 509 else: 510 module.exit_json(changed=False, msg="No package specified to work on.") 511 512 513if __name__ == "__main__": 514 main() 515