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