1#!/usr/bin/python 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 update_cache: 95 version_added: "2.2" 96 description: 97 - Run the equivalent of C(zypper refresh) before the operation. Disabled in check mode. 98 required: false 99 default: "no" 100 type: bool 101 aliases: [ "refresh" ] 102 oldpackage: 103 version_added: "2.2" 104 description: 105 - Adds C(--oldpackage) option to I(zypper). Allows to downgrade packages with less side-effects than force. This is implied as soon as a 106 version is specified as part of the package name. 107 required: false 108 default: "no" 109 type: bool 110 extra_args: 111 version_added: "2.4" 112 required: false 113 description: 114 - Add additional options to C(zypper) command. 115 - Options should be supplied in a single line as if given in the command line. 116notes: 117 - When used with a `loop:` each package will be processed individually, 118 it is much more efficient to pass the list directly to the `name` option. 119# informational: requirements for nodes 120requirements: 121 - "zypper >= 1.0 # included in openSUSE >= 11.1 or SUSE Linux Enterprise Server/Desktop >= 11.0" 122 - python-xml 123 - rpm 124''' 125 126EXAMPLES = ''' 127# Install "nmap" 128- zypper: 129 name: nmap 130 state: present 131 132# Install apache2 with recommended packages 133- zypper: 134 name: apache2 135 state: present 136 disable_recommends: no 137 138# Apply a given patch 139- zypper: 140 name: openSUSE-2016-128 141 state: present 142 type: patch 143 144# Remove the "nmap" package 145- zypper: 146 name: nmap 147 state: absent 148 149# Install the nginx rpm from a remote repo 150- zypper: 151 name: 'http://nginx.org/packages/sles/12/x86_64/RPMS/nginx-1.8.0-1.sles12.ngx.x86_64.rpm' 152 state: present 153 154# Install local rpm file 155- zypper: 156 name: /tmp/fancy-software.rpm 157 state: present 158 159# Update all packages 160- zypper: 161 name: '*' 162 state: latest 163 164# Apply all available patches 165- zypper: 166 name: '*' 167 state: latest 168 type: patch 169 170# Perform a dist-upgrade with additional arguments 171- zypper: 172 name: '*' 173 state: dist-upgrade 174 extra_args: '--no-allow-vendor-change --allow-arch-change' 175 176# Refresh repositories and update package "openssl" 177- zypper: 178 name: openssl 179 state: present 180 update_cache: yes 181 182# Install specific version (possible comparisons: <, >, <=, >=, =) 183- zypper: 184 name: 'docker>=1.10' 185 state: present 186 187# Wait 20 seconds to acquire the lock before failing 188- zypper: 189 name: mosh 190 state: present 191 environment: 192 ZYPP_LOCK_TIMEOUT: 20 193''' 194 195import xml 196import re 197from xml.dom.minidom import parseString as parseXML 198from ansible.module_utils.six import iteritems 199from ansible.module_utils._text import to_native 200 201# import module snippets 202from ansible.module_utils.basic import AnsibleModule 203 204 205class Package: 206 def __init__(self, name, prefix, version): 207 self.name = name 208 self.prefix = prefix 209 self.version = version 210 self.shouldinstall = (prefix == '+') 211 212 def __str__(self): 213 return self.prefix + self.name + self.version 214 215 216def split_name_version(name): 217 """splits of the package name and desired version 218 219 example formats: 220 - docker>=1.10 221 - apache=2.4 222 223 Allowed version specifiers: <, >, <=, >=, = 224 Allowed version format: [0-9.-]* 225 226 Also allows a prefix indicating remove "-", "~" or install "+" 227 """ 228 229 prefix = '' 230 if name[0] in ['-', '~', '+']: 231 prefix = name[0] 232 name = name[1:] 233 if prefix == '~': 234 prefix = '-' 235 236 version_check = re.compile('^(.*?)((?:<|>|<=|>=|=)[0-9.-]*)?$') 237 try: 238 reres = version_check.match(name) 239 name, version = reres.groups() 240 if version is None: 241 version = '' 242 return prefix, name, version 243 except Exception: 244 return prefix, name, '' 245 246 247def get_want_state(names, remove=False): 248 packages = [] 249 urls = [] 250 for name in names: 251 if '://' in name or name.endswith('.rpm'): 252 urls.append(name) 253 else: 254 prefix, pname, version = split_name_version(name) 255 if prefix not in ['-', '+']: 256 if remove: 257 prefix = '-' 258 else: 259 prefix = '+' 260 packages.append(Package(pname, prefix, version)) 261 return packages, urls 262 263 264def get_installed_state(m, packages): 265 "get installed state of packages" 266 267 cmd = get_cmd(m, 'search') 268 cmd.extend(['--match-exact', '--details', '--installed-only']) 269 cmd.extend([p.name for p in packages]) 270 return parse_zypper_xml(m, cmd, fail_not_found=False)[0] 271 272 273def parse_zypper_xml(m, cmd, fail_not_found=True, packages=None): 274 rc, stdout, stderr = m.run_command(cmd, check_rc=False) 275 276 try: 277 dom = parseXML(stdout) 278 except xml.parsers.expat.ExpatError as exc: 279 m.fail_json(msg="Failed to parse zypper xml output: %s" % to_native(exc), 280 rc=rc, stdout=stdout, stderr=stderr, cmd=cmd) 281 282 if rc == 104: 283 # exit code 104 is ZYPPER_EXIT_INF_CAP_NOT_FOUND (no packages found) 284 if fail_not_found: 285 errmsg = dom.getElementsByTagName('message')[-1].childNodes[0].data 286 m.fail_json(msg=errmsg, rc=rc, stdout=stdout, stderr=stderr, cmd=cmd) 287 else: 288 return {}, rc, stdout, stderr 289 elif rc in [0, 106, 103]: 290 # zypper exit codes 291 # 0: success 292 # 106: signature verification failed 293 # 103: zypper was upgraded, run same command again 294 if packages is None: 295 firstrun = True 296 packages = {} 297 solvable_list = dom.getElementsByTagName('solvable') 298 for solvable in solvable_list: 299 name = solvable.getAttribute('name') 300 packages[name] = {} 301 packages[name]['version'] = solvable.getAttribute('edition') 302 packages[name]['oldversion'] = solvable.getAttribute('edition-old') 303 status = solvable.getAttribute('status') 304 packages[name]['installed'] = status == "installed" 305 packages[name]['group'] = solvable.parentNode.nodeName 306 if rc == 103 and firstrun: 307 # if this was the first run and it failed with 103 308 # run zypper again with the same command to complete update 309 return parse_zypper_xml(m, cmd, fail_not_found=fail_not_found, packages=packages) 310 311 return packages, rc, stdout, stderr 312 m.fail_json(msg='Zypper run command failed with return code %s.' % rc, rc=rc, stdout=stdout, stderr=stderr, cmd=cmd) 313 314 315def get_cmd(m, subcommand): 316 "puts together the basic zypper command arguments with those passed to the module" 317 is_install = subcommand in ['install', 'update', 'patch', 'dist-upgrade'] 318 is_refresh = subcommand == 'refresh' 319 cmd = ['/usr/bin/zypper', '--quiet', '--non-interactive', '--xmlout'] 320 if m.params['extra_args_precommand']: 321 args_list = m.params['extra_args_precommand'].split() 322 cmd.extend(args_list) 323 # add global options before zypper command 324 if (is_install or is_refresh) and m.params['disable_gpg_check']: 325 cmd.append('--no-gpg-checks') 326 327 if subcommand == 'search': 328 cmd.append('--disable-repositories') 329 330 cmd.append(subcommand) 331 if subcommand not in ['patch', 'dist-upgrade'] and not is_refresh: 332 cmd.extend(['--type', m.params['type']]) 333 if m.check_mode and subcommand != 'search': 334 cmd.append('--dry-run') 335 if is_install: 336 cmd.append('--auto-agree-with-licenses') 337 if m.params['disable_recommends']: 338 cmd.append('--no-recommends') 339 if m.params['force']: 340 cmd.append('--force') 341 if m.params['oldpackage']: 342 cmd.append('--oldpackage') 343 if m.params['extra_args']: 344 args_list = m.params['extra_args'].split(' ') 345 cmd.extend(args_list) 346 347 return cmd 348 349 350def set_diff(m, retvals, result): 351 # TODO: if there is only one package, set before/after to version numbers 352 packages = {'installed': [], 'removed': [], 'upgraded': []} 353 if result: 354 for p in result: 355 group = result[p]['group'] 356 if group == 'to-upgrade': 357 versions = ' (' + result[p]['oldversion'] + ' => ' + result[p]['version'] + ')' 358 packages['upgraded'].append(p + versions) 359 elif group == 'to-install': 360 packages['installed'].append(p) 361 elif group == 'to-remove': 362 packages['removed'].append(p) 363 364 output = '' 365 for state in packages: 366 if packages[state]: 367 output += state + ': ' + ', '.join(packages[state]) + '\n' 368 if 'diff' not in retvals: 369 retvals['diff'] = {} 370 if 'prepared' not in retvals['diff']: 371 retvals['diff']['prepared'] = output 372 else: 373 retvals['diff']['prepared'] += '\n' + output 374 375 376def package_present(m, name, want_latest): 377 "install and update (if want_latest) the packages in name_install, while removing the packages in name_remove" 378 retvals = {'rc': 0, 'stdout': '', 'stderr': ''} 379 packages, urls = get_want_state(name) 380 381 # add oldpackage flag when a version is given to allow downgrades 382 if any(p.version for p in packages): 383 m.params['oldpackage'] = True 384 385 if not want_latest: 386 # for state=present: filter out already installed packages 387 # if a version is given leave the package in to let zypper handle the version 388 # resolution 389 packageswithoutversion = [p for p in packages if not p.version] 390 prerun_state = get_installed_state(m, packageswithoutversion) 391 # generate lists of packages to install or remove 392 packages = [p for p in packages if p.shouldinstall != (p.name in prerun_state)] 393 394 if not packages and not urls: 395 # nothing to install/remove and nothing to update 396 return None, retvals 397 398 # zypper install also updates packages 399 cmd = get_cmd(m, 'install') 400 cmd.append('--') 401 cmd.extend(urls) 402 # pass packages to zypper 403 # allow for + or - prefixes in install/remove lists 404 # also add version specifier if given 405 # do this in one zypper run to allow for dependency-resolution 406 # for example "-exim postfix" runs without removing packages depending on mailserver 407 cmd.extend([str(p) for p in packages]) 408 409 retvals['cmd'] = cmd 410 result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd) 411 412 return result, retvals 413 414 415def package_update_all(m): 416 "run update or patch on all available packages" 417 418 retvals = {'rc': 0, 'stdout': '', 'stderr': ''} 419 if m.params['type'] == 'patch': 420 cmdname = 'patch' 421 elif m.params['state'] == 'dist-upgrade': 422 cmdname = 'dist-upgrade' 423 else: 424 cmdname = 'update' 425 426 cmd = get_cmd(m, cmdname) 427 retvals['cmd'] = cmd 428 result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd) 429 return result, retvals 430 431 432def package_absent(m, name): 433 "remove the packages in name" 434 retvals = {'rc': 0, 'stdout': '', 'stderr': ''} 435 # Get package state 436 packages, urls = get_want_state(name, remove=True) 437 if any(p.prefix == '+' for p in packages): 438 m.fail_json(msg="Can not combine '+' prefix with state=remove/absent.") 439 if urls: 440 m.fail_json(msg="Can not remove via URL.") 441 if m.params['type'] == 'patch': 442 m.fail_json(msg="Can not remove patches.") 443 prerun_state = get_installed_state(m, packages) 444 packages = [p for p in packages if p.name in prerun_state] 445 446 if not packages: 447 return None, retvals 448 449 cmd = get_cmd(m, 'remove') 450 cmd.extend([p.name + p.version for p in packages]) 451 452 retvals['cmd'] = cmd 453 result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd) 454 return result, retvals 455 456 457def repo_refresh(m): 458 "update the repositories" 459 retvals = {'rc': 0, 'stdout': '', 'stderr': ''} 460 461 cmd = get_cmd(m, 'refresh') 462 463 retvals['cmd'] = cmd 464 result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd) 465 466 return retvals 467 468# =========================================== 469# Main control flow 470 471 472def main(): 473 module = AnsibleModule( 474 argument_spec=dict( 475 name=dict(required=True, aliases=['pkg'], type='list'), 476 state=dict(required=False, default='present', choices=['absent', 'installed', 'latest', 'present', 'removed', 'dist-upgrade']), 477 type=dict(required=False, default='package', choices=['package', 'patch', 'pattern', 'product', 'srcpackage', 'application']), 478 extra_args_precommand=dict(required=False, default=None), 479 disable_gpg_check=dict(required=False, default='no', type='bool'), 480 disable_recommends=dict(required=False, default='yes', type='bool'), 481 force=dict(required=False, default='no', type='bool'), 482 update_cache=dict(required=False, aliases=['refresh'], default='no', type='bool'), 483 oldpackage=dict(required=False, default='no', type='bool'), 484 extra_args=dict(required=False, default=None), 485 ), 486 supports_check_mode=True 487 ) 488 489 name = module.params['name'] 490 state = module.params['state'] 491 update_cache = module.params['update_cache'] 492 493 # remove empty strings from package list 494 name = list(filter(None, name)) 495 496 # Refresh repositories 497 if update_cache and not module.check_mode: 498 retvals = repo_refresh(module) 499 500 if retvals['rc'] != 0: 501 module.fail_json(msg="Zypper refresh run failed.", **retvals) 502 503 # Perform requested action 504 if name == ['*'] and state in ['latest', 'dist-upgrade']: 505 packages_changed, retvals = package_update_all(module) 506 elif name != ['*'] and state == 'dist-upgrade': 507 module.fail_json(msg="Can not dist-upgrade specific packages.") 508 else: 509 if state in ['absent', 'removed']: 510 packages_changed, retvals = package_absent(module, name) 511 elif state in ['installed', 'present', 'latest']: 512 packages_changed, retvals = package_present(module, name, state == 'latest') 513 514 retvals['changed'] = retvals['rc'] == 0 and bool(packages_changed) 515 516 if module._diff: 517 set_diff(module, retvals, packages_changed) 518 519 if retvals['rc'] != 0: 520 module.fail_json(msg="Zypper run failed.", **retvals) 521 522 if not retvals['changed']: 523 del retvals['stdout'] 524 del retvals['stderr'] 525 526 module.exit_json(name=name, state=state, update_cache=update_cache, **retvals) 527 528 529if __name__ == "__main__": 530 main() 531