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