1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# (c) 2013, Patrick Callahan <pmc@patrickcallahan.com> 5# based on 6# openbsd_pkg 7# (c) 2013 8# Patrik Lundin <patrik.lundin.swe@gmail.com> 9# 10# yum 11# (c) 2012, Red Hat, Inc 12# Written by Seth Vidal <skvidal at fedoraproject.org> 13# 14# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 15 16from __future__ import absolute_import, division, print_function 17__metaclass__ = type 18 19 20ANSIBLE_METADATA = {'metadata_version': '1.1', 21 'status': ['preview'], 22 'supported_by': 'community'} 23 24 25DOCUMENTATION = ''' 26--- 27module: zypper 28author: 29 - "Patrick Callahan (@dirtyharrycallahan)" 30 - "Alexander Gubin (@alxgu)" 31 - "Thomas O'Donnell (@andytom)" 32 - "Robin Roth (@robinro)" 33 - "Andrii Radyk (@AnderEnder)" 34version_added: "1.2" 35short_description: Manage packages on SUSE and openSUSE 36description: 37 - Manage packages on SUSE and openSUSE using the zypper and rpm tools. 38options: 39 name: 40 description: 41 - Package name C(name) or package specifier or a list of either. 42 - Can include a version like C(name=1.0), C(name>3.4) or C(name<=2.7). If a version is given, C(oldpackage) is implied and zypper is allowed to 43 update the package within the version range given. 44 - You can also pass a url or a local path to a rpm file. 45 - When using state=latest, this can be '*', which updates all installed packages. 46 required: true 47 aliases: [ 'pkg' ] 48 state: 49 description: 50 - C(present) will make sure the package is installed. 51 C(latest) will make sure the latest version of the package is installed. 52 C(absent) will make sure the specified package is not installed. 53 C(dist-upgrade) will make sure the latest version of all installed packages from all enabled repositories is installed. 54 - When using C(dist-upgrade), I(name) should be C('*'). 55 required: false 56 choices: [ present, latest, absent, dist-upgrade ] 57 default: "present" 58 type: 59 description: 60 - The type of package to be operated on. 61 required: false 62 choices: [ package, patch, pattern, product, srcpackage, application ] 63 default: "package" 64 version_added: "2.0" 65 extra_args_precommand: 66 version_added: "2.6" 67 required: false 68 description: 69 - Add additional global target options to C(zypper). 70 - Options should be supplied in a single line as if given in the command line. 71 disable_gpg_check: 72 description: 73 - Whether to disable to GPG signature checking of the package 74 signature being installed. Has an effect only if state is 75 I(present) or I(latest). 76 required: false 77 default: "no" 78 type: bool 79 disable_recommends: 80 version_added: "1.8" 81 description: 82 - Corresponds to the C(--no-recommends) option for I(zypper). Default behavior (C(yes)) modifies zypper's default behavior; C(no) does 83 install recommended packages. 84 required: false 85 default: "yes" 86 type: bool 87 force: 88 version_added: "2.2" 89 description: 90 - Adds C(--force) option to I(zypper). Allows to downgrade packages and change vendor or architecture. 91 required: false 92 default: "no" 93 type: bool 94 force_resolution: 95 version_added: "2.10" 96 description: 97 - Adds C(--force-resolution) option to I(zypper). Allows to (un)install packages with conflicting requirements (resolver will choose a solution). 98 required: false 99 default: "no" 100 type: bool 101 update_cache: 102 version_added: "2.2" 103 description: 104 - Run the equivalent of C(zypper refresh) before the operation. Disabled in check mode. 105 required: false 106 default: "no" 107 type: bool 108 aliases: [ "refresh" ] 109 oldpackage: 110 version_added: "2.2" 111 description: 112 - Adds C(--oldpackage) option to I(zypper). Allows to downgrade packages with less side-effects than force. This is implied as soon as a 113 version is specified as part of the package name. 114 required: false 115 default: "no" 116 type: bool 117 extra_args: 118 version_added: "2.4" 119 required: false 120 description: 121 - Add additional options to C(zypper) command. 122 - Options should be supplied in a single line as if given in the command line. 123notes: 124 - When used with a `loop:` each package will be processed individually, 125 it is much more efficient to pass the list directly to the `name` option. 126# informational: requirements for nodes 127requirements: 128 - "zypper >= 1.0 # included in openSUSE >= 11.1 or SUSE Linux Enterprise Server/Desktop >= 11.0" 129 - python-xml 130 - rpm 131''' 132 133EXAMPLES = ''' 134# Install "nmap" 135- zypper: 136 name: nmap 137 state: present 138 139# Install apache2 with recommended packages 140- zypper: 141 name: apache2 142 state: present 143 disable_recommends: no 144 145# Apply a given patch 146- zypper: 147 name: openSUSE-2016-128 148 state: present 149 type: patch 150 151# Remove the "nmap" package 152- zypper: 153 name: nmap 154 state: absent 155 156# Install the nginx rpm from a remote repo 157- zypper: 158 name: 'http://nginx.org/packages/sles/12/x86_64/RPMS/nginx-1.8.0-1.sles12.ngx.x86_64.rpm' 159 state: present 160 161# Install local rpm file 162- zypper: 163 name: /tmp/fancy-software.rpm 164 state: present 165 166# Update all packages 167- zypper: 168 name: '*' 169 state: latest 170 171# Apply all available patches 172- zypper: 173 name: '*' 174 state: latest 175 type: patch 176 177# Perform a dist-upgrade with additional arguments 178- zypper: 179 name: '*' 180 state: dist-upgrade 181 extra_args: '--no-allow-vendor-change --allow-arch-change' 182 183# Refresh repositories and update package "openssl" 184- zypper: 185 name: openssl 186 state: present 187 update_cache: yes 188 189# Install specific version (possible comparisons: <, >, <=, >=, =) 190- zypper: 191 name: 'docker>=1.10' 192 state: present 193 194# Wait 20 seconds to acquire the lock before failing 195- zypper: 196 name: mosh 197 state: present 198 environment: 199 ZYPP_LOCK_TIMEOUT: 20 200''' 201 202import xml 203import re 204from xml.dom.minidom import parseString as parseXML 205from ansible.module_utils.six import iteritems 206from ansible.module_utils._text import to_native 207 208# import module snippets 209from ansible.module_utils.basic import AnsibleModule 210 211 212class Package: 213 def __init__(self, name, prefix, version): 214 self.name = name 215 self.prefix = prefix 216 self.version = version 217 self.shouldinstall = (prefix == '+') 218 219 def __str__(self): 220 return self.prefix + self.name + self.version 221 222 223def split_name_version(name): 224 """splits of the package name and desired version 225 226 example formats: 227 - docker>=1.10 228 - apache=2.4 229 230 Allowed version specifiers: <, >, <=, >=, = 231 Allowed version format: [0-9.-]* 232 233 Also allows a prefix indicating remove "-", "~" or install "+" 234 """ 235 236 prefix = '' 237 if name[0] in ['-', '~', '+']: 238 prefix = name[0] 239 name = name[1:] 240 if prefix == '~': 241 prefix = '-' 242 243 version_check = re.compile('^(.*?)((?:<|>|<=|>=|=)[0-9.-]*)?$') 244 try: 245 reres = version_check.match(name) 246 name, version = reres.groups() 247 if version is None: 248 version = '' 249 return prefix, name, version 250 except Exception: 251 return prefix, name, '' 252 253 254def get_want_state(names, remove=False): 255 packages = [] 256 urls = [] 257 for name in names: 258 if '://' in name or name.endswith('.rpm'): 259 urls.append(name) 260 else: 261 prefix, pname, version = split_name_version(name) 262 if prefix not in ['-', '+']: 263 if remove: 264 prefix = '-' 265 else: 266 prefix = '+' 267 packages.append(Package(pname, prefix, version)) 268 return packages, urls 269 270 271def get_installed_state(m, packages): 272 "get installed state of packages" 273 274 cmd = get_cmd(m, 'search') 275 cmd.extend(['--match-exact', '--details', '--installed-only']) 276 cmd.extend([p.name for p in packages]) 277 return parse_zypper_xml(m, cmd, fail_not_found=False)[0] 278 279 280def parse_zypper_xml(m, cmd, fail_not_found=True, packages=None): 281 rc, stdout, stderr = m.run_command(cmd, check_rc=False) 282 283 try: 284 dom = parseXML(stdout) 285 except xml.parsers.expat.ExpatError as exc: 286 m.fail_json(msg="Failed to parse zypper xml output: %s" % to_native(exc), 287 rc=rc, stdout=stdout, stderr=stderr, cmd=cmd) 288 289 if rc == 104: 290 # exit code 104 is ZYPPER_EXIT_INF_CAP_NOT_FOUND (no packages found) 291 if fail_not_found: 292 errmsg = dom.getElementsByTagName('message')[-1].childNodes[0].data 293 m.fail_json(msg=errmsg, rc=rc, stdout=stdout, stderr=stderr, cmd=cmd) 294 else: 295 return {}, rc, stdout, stderr 296 elif rc in [0, 106, 103]: 297 # zypper exit codes 298 # 0: success 299 # 106: signature verification failed 300 # 103: zypper was upgraded, run same command again 301 if packages is None: 302 firstrun = True 303 packages = {} 304 solvable_list = dom.getElementsByTagName('solvable') 305 for solvable in solvable_list: 306 name = solvable.getAttribute('name') 307 packages[name] = {} 308 packages[name]['version'] = solvable.getAttribute('edition') 309 packages[name]['oldversion'] = solvable.getAttribute('edition-old') 310 status = solvable.getAttribute('status') 311 packages[name]['installed'] = status == "installed" 312 packages[name]['group'] = solvable.parentNode.nodeName 313 if rc == 103 and firstrun: 314 # if this was the first run and it failed with 103 315 # run zypper again with the same command to complete update 316 return parse_zypper_xml(m, cmd, fail_not_found=fail_not_found, packages=packages) 317 318 return packages, rc, stdout, stderr 319 m.fail_json(msg='Zypper run command failed with return code %s.' % rc, rc=rc, stdout=stdout, stderr=stderr, cmd=cmd) 320 321 322def get_cmd(m, subcommand): 323 "puts together the basic zypper command arguments with those passed to the module" 324 is_install = subcommand in ['install', 'update', 'patch', 'dist-upgrade'] 325 is_refresh = subcommand == 'refresh' 326 cmd = ['/usr/bin/zypper', '--quiet', '--non-interactive', '--xmlout'] 327 if m.params['extra_args_precommand']: 328 args_list = m.params['extra_args_precommand'].split() 329 cmd.extend(args_list) 330 # add global options before zypper command 331 if (is_install or is_refresh) and m.params['disable_gpg_check']: 332 cmd.append('--no-gpg-checks') 333 334 if subcommand == 'search': 335 cmd.append('--disable-repositories') 336 337 cmd.append(subcommand) 338 if subcommand not in ['patch', 'dist-upgrade'] and not is_refresh: 339 cmd.extend(['--type', m.params['type']]) 340 if m.check_mode and subcommand != 'search': 341 cmd.append('--dry-run') 342 if is_install: 343 cmd.append('--auto-agree-with-licenses') 344 if m.params['disable_recommends']: 345 cmd.append('--no-recommends') 346 if m.params['force']: 347 cmd.append('--force') 348 if m.params['force_resolution']: 349 cmd.append('--force-resolution') 350 if m.params['oldpackage']: 351 cmd.append('--oldpackage') 352 if m.params['extra_args']: 353 args_list = m.params['extra_args'].split(' ') 354 cmd.extend(args_list) 355 356 return cmd 357 358 359def set_diff(m, retvals, result): 360 # TODO: if there is only one package, set before/after to version numbers 361 packages = {'installed': [], 'removed': [], 'upgraded': []} 362 if result: 363 for p in result: 364 group = result[p]['group'] 365 if group == 'to-upgrade': 366 versions = ' (' + result[p]['oldversion'] + ' => ' + result[p]['version'] + ')' 367 packages['upgraded'].append(p + versions) 368 elif group == 'to-install': 369 packages['installed'].append(p) 370 elif group == 'to-remove': 371 packages['removed'].append(p) 372 373 output = '' 374 for state in packages: 375 if packages[state]: 376 output += state + ': ' + ', '.join(packages[state]) + '\n' 377 if 'diff' not in retvals: 378 retvals['diff'] = {} 379 if 'prepared' not in retvals['diff']: 380 retvals['diff']['prepared'] = output 381 else: 382 retvals['diff']['prepared'] += '\n' + output 383 384 385def package_present(m, name, want_latest): 386 "install and update (if want_latest) the packages in name_install, while removing the packages in name_remove" 387 retvals = {'rc': 0, 'stdout': '', 'stderr': ''} 388 packages, urls = get_want_state(name) 389 390 # add oldpackage flag when a version is given to allow downgrades 391 if any(p.version for p in packages): 392 m.params['oldpackage'] = True 393 394 if not want_latest: 395 # for state=present: filter out already installed packages 396 # if a version is given leave the package in to let zypper handle the version 397 # resolution 398 packageswithoutversion = [p for p in packages if not p.version] 399 prerun_state = get_installed_state(m, packageswithoutversion) 400 # generate lists of packages to install or remove 401 packages = [p for p in packages if p.shouldinstall != (p.name in prerun_state)] 402 403 if not packages and not urls: 404 # nothing to install/remove and nothing to update 405 return None, retvals 406 407 # zypper install also updates packages 408 cmd = get_cmd(m, 'install') 409 cmd.append('--') 410 cmd.extend(urls) 411 # pass packages to zypper 412 # allow for + or - prefixes in install/remove lists 413 # also add version specifier if given 414 # do this in one zypper run to allow for dependency-resolution 415 # for example "-exim postfix" runs without removing packages depending on mailserver 416 cmd.extend([str(p) for p in packages]) 417 418 retvals['cmd'] = cmd 419 result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd) 420 421 return result, retvals 422 423 424def package_update_all(m): 425 "run update or patch on all available packages" 426 427 retvals = {'rc': 0, 'stdout': '', 'stderr': ''} 428 if m.params['type'] == 'patch': 429 cmdname = 'patch' 430 elif m.params['state'] == 'dist-upgrade': 431 cmdname = 'dist-upgrade' 432 else: 433 cmdname = 'update' 434 435 cmd = get_cmd(m, cmdname) 436 retvals['cmd'] = cmd 437 result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd) 438 return result, retvals 439 440 441def package_absent(m, name): 442 "remove the packages in name" 443 retvals = {'rc': 0, 'stdout': '', 'stderr': ''} 444 # Get package state 445 packages, urls = get_want_state(name, remove=True) 446 if any(p.prefix == '+' for p in packages): 447 m.fail_json(msg="Can not combine '+' prefix with state=remove/absent.") 448 if urls: 449 m.fail_json(msg="Can not remove via URL.") 450 if m.params['type'] == 'patch': 451 m.fail_json(msg="Can not remove patches.") 452 prerun_state = get_installed_state(m, packages) 453 packages = [p for p in packages if p.name in prerun_state] 454 455 if not packages: 456 return None, retvals 457 458 cmd = get_cmd(m, 'remove') 459 cmd.extend([p.name + p.version for p in packages]) 460 461 retvals['cmd'] = cmd 462 result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd) 463 return result, retvals 464 465 466def repo_refresh(m): 467 "update the repositories" 468 retvals = {'rc': 0, 'stdout': '', 'stderr': ''} 469 470 cmd = get_cmd(m, 'refresh') 471 472 retvals['cmd'] = cmd 473 result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd) 474 475 return retvals 476 477# =========================================== 478# Main control flow 479 480 481def main(): 482 module = AnsibleModule( 483 argument_spec=dict( 484 name=dict(required=True, aliases=['pkg'], type='list'), 485 state=dict(required=False, default='present', choices=['absent', 'installed', 'latest', 'present', 'removed', 'dist-upgrade']), 486 type=dict(required=False, default='package', choices=['package', 'patch', 'pattern', 'product', 'srcpackage', 'application']), 487 extra_args_precommand=dict(required=False, default=None), 488 disable_gpg_check=dict(required=False, default='no', type='bool'), 489 disable_recommends=dict(required=False, default='yes', type='bool'), 490 force=dict(required=False, default='no', type='bool'), 491 force_resolution=dict(required=False, default='no', type='bool'), 492 update_cache=dict(required=False, aliases=['refresh'], default='no', type='bool'), 493 oldpackage=dict(required=False, default='no', type='bool'), 494 extra_args=dict(required=False, default=None), 495 ), 496 supports_check_mode=True 497 ) 498 499 name = module.params['name'] 500 state = module.params['state'] 501 update_cache = module.params['update_cache'] 502 503 # remove empty strings from package list 504 name = list(filter(None, name)) 505 506 # Refresh repositories 507 if update_cache and not module.check_mode: 508 retvals = repo_refresh(module) 509 510 if retvals['rc'] != 0: 511 module.fail_json(msg="Zypper refresh run failed.", **retvals) 512 513 # Perform requested action 514 if name == ['*'] and state in ['latest', 'dist-upgrade']: 515 packages_changed, retvals = package_update_all(module) 516 elif name != ['*'] and state == 'dist-upgrade': 517 module.fail_json(msg="Can not dist-upgrade specific packages.") 518 else: 519 if state in ['absent', 'removed']: 520 packages_changed, retvals = package_absent(module, name) 521 elif state in ['installed', 'present', 'latest']: 522 packages_changed, retvals = package_present(module, name, state == 'latest') 523 524 retvals['changed'] = retvals['rc'] == 0 and bool(packages_changed) 525 526 if module._diff: 527 set_diff(module, retvals, packages_changed) 528 529 if retvals['rc'] != 0: 530 module.fail_json(msg="Zypper run failed.", **retvals) 531 532 if not retvals['changed']: 533 del retvals['stdout'] 534 del retvals['stderr'] 535 536 module.exit_json(name=name, state=state, update_cache=update_cache, **retvals) 537 538 539if __name__ == "__main__": 540 main() 541