1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# (c) 2013, Matthias Vogelgesang <matthias.vogelgesang@gmail.com>
5# (c) 2014, Justin Lecher <jlec@gentoo.org>
6#
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
12
13DOCUMENTATION = '''
14---
15module: zypper_repository
16author: "Matthias Vogelgesang (@matze)"
17short_description: Add and remove Zypper repositories
18description:
19    - Add or remove Zypper repositories on SUSE and openSUSE
20options:
21    name:
22        description:
23            - A name for the repository. Not required when adding repofiles.
24        type: str
25    repo:
26        description:
27            - URI of the repository or .repo file. Required when state=present.
28        type: str
29    state:
30        description:
31            - A source string state.
32        choices: [ "absent", "present" ]
33        default: "present"
34        type: str
35    description:
36        description:
37            - A description of the repository
38        type: str
39    disable_gpg_check:
40        description:
41            - Whether to disable GPG signature checking of
42              all packages. Has an effect only if state is
43              I(present).
44            - Needs zypper version >= 1.6.2.
45        type: bool
46        default: no
47    autorefresh:
48        description:
49            - Enable autorefresh of the repository.
50        type: bool
51        default: yes
52        aliases: [ "refresh" ]
53    priority:
54        description:
55            - Set priority of repository. Packages will always be installed
56              from the repository with the smallest priority number.
57            - Needs zypper version >= 1.12.25.
58        type: int
59    overwrite_multiple:
60        description:
61            - Overwrite multiple repository entries, if repositories with both name and
62              URL already exist.
63        type: bool
64        default: no
65    auto_import_keys:
66        description:
67            - Automatically import the gpg signing key of the new or changed repository.
68            - Has an effect only if state is I(present). Has no effect on existing (unchanged) repositories or in combination with I(absent).
69            - Implies runrefresh.
70            - Only works with C(.repo) files if `name` is given explicitly.
71        type: bool
72        default: no
73    runrefresh:
74        description:
75            - Refresh the package list of the given repository.
76            - Can be used with repo=* to refresh all repositories.
77        type: bool
78        default: no
79    enabled:
80        description:
81            - Set repository to enabled (or disabled).
82        type: bool
83        default: yes
84
85
86requirements:
87    - "zypper >= 1.0  # included in openSUSE >= 11.1 or SUSE Linux Enterprise Server/Desktop >= 11.0"
88    - python-xml
89'''
90
91EXAMPLES = '''
92- name: Add NVIDIA repository for graphics drivers
93  community.general.zypper_repository:
94    name: nvidia-repo
95    repo: 'ftp://download.nvidia.com/opensuse/12.2'
96    state: present
97
98- name: Remove NVIDIA repository
99  community.general.zypper_repository:
100    name: nvidia-repo
101    repo: 'ftp://download.nvidia.com/opensuse/12.2'
102    state: absent
103
104- name: Add python development repository
105  community.general.zypper_repository:
106    repo: 'http://download.opensuse.org/repositories/devel:/languages:/python/SLE_11_SP3/devel:languages:python.repo'
107
108- name: Refresh all repos
109  community.general.zypper_repository:
110    repo: '*'
111    runrefresh: yes
112
113- name: Add a repo and add its gpg key
114  community.general.zypper_repository:
115    repo: 'http://download.opensuse.org/repositories/systemsmanagement/openSUSE_Leap_42.1/'
116    auto_import_keys: yes
117
118- name: Force refresh of a repository
119  community.general.zypper_repository:
120    repo: 'http://my_internal_ci_repo/repo'
121    name: my_ci_repo
122    state: present
123    runrefresh: yes
124'''
125
126import traceback
127
128XML_IMP_ERR = None
129try:
130    from xml.dom.minidom import parseString as parseXML
131    HAS_XML = True
132except ImportError:
133    XML_IMP_ERR = traceback.format_exc()
134    HAS_XML = False
135
136from distutils.version import LooseVersion
137
138from ansible.module_utils.basic import AnsibleModule, missing_required_lib
139
140from ansible.module_utils.urls import fetch_url
141from ansible.module_utils.common.text.converters import to_text
142from ansible.module_utils.six.moves import configparser, StringIO
143from io import open
144
145REPO_OPTS = ['alias', 'name', 'priority', 'enabled', 'autorefresh', 'gpgcheck']
146
147
148def _get_cmd(module, *args):
149    """Combines the non-interactive zypper command with arguments/subcommands"""
150    cmd = [module.get_bin_path('zypper', required=True), '--quiet', '--non-interactive']
151    cmd.extend(args)
152
153    return cmd
154
155
156def _parse_repos(module):
157    """parses the output of zypper --xmlout repos and return a parse repo dictionary"""
158    cmd = _get_cmd(module, '--xmlout', 'repos')
159
160    if not HAS_XML:
161        module.fail_json(msg=missing_required_lib("python-xml"), exception=XML_IMP_ERR)
162    rc, stdout, stderr = module.run_command(cmd, check_rc=False)
163    if rc == 0:
164        repos = []
165        dom = parseXML(stdout)
166        repo_list = dom.getElementsByTagName('repo')
167        for repo in repo_list:
168            opts = {}
169            for o in REPO_OPTS:
170                opts[o] = repo.getAttribute(o)
171            opts['url'] = repo.getElementsByTagName('url')[0].firstChild.data
172            # A repo can be uniquely identified by an alias + url
173            repos.append(opts)
174        return repos
175    # exit code 6 is ZYPPER_EXIT_NO_REPOS (no repositories defined)
176    elif rc == 6:
177        return []
178    else:
179        module.fail_json(msg='Failed to execute "%s"' % " ".join(cmd), rc=rc, stdout=stdout, stderr=stderr)
180
181
182def _repo_changes(module, realrepo, repocmp):
183    "Check whether the 2 given repos have different settings."
184    for k in repocmp:
185        if repocmp[k] and k not in realrepo:
186            return True
187
188    for k, v in realrepo.items():
189        if k in repocmp and repocmp[k]:
190            valold = str(repocmp[k] or "")
191            valnew = v or ""
192            if k == "url":
193                if '$releasever' in valold or '$releasever' in valnew:
194                    cmd = ['rpm', '-q', '--qf', '%{version}', '-f', '/etc/os-release']
195                    rc, stdout, stderr = module.run_command(cmd, check_rc=True)
196                    valnew = valnew.replace('$releasever', stdout)
197                    valold = valold.replace('$releasever', stdout)
198                if '$basearch' in valold or '$basearch' in valnew:
199                    cmd = ['rpm', '-q', '--qf', '%{arch}', '-f', '/etc/os-release']
200                    rc, stdout, stderr = module.run_command(cmd, check_rc=True)
201                    valnew = valnew.replace('$basearch', stdout)
202                    valold = valold.replace('$basearch', stdout)
203                valold, valnew = valold.rstrip("/"), valnew.rstrip("/")
204            if valold != valnew:
205                return True
206    return False
207
208
209def repo_exists(module, repodata, overwrite_multiple):
210    """Check whether the repository already exists.
211
212        returns (exists, mod, old_repos)
213            exists: whether a matching (name, URL) repo exists
214            mod: whether there are changes compared to the existing repo
215            old_repos: list of matching repos
216    """
217    existing_repos = _parse_repos(module)
218
219    # look for repos that have matching alias or url to the one searched
220    repos = []
221    for kw in ['alias', 'url']:
222        name = repodata[kw]
223        for oldr in existing_repos:
224            if repodata[kw] == oldr[kw] and oldr not in repos:
225                repos.append(oldr)
226
227    if len(repos) == 0:
228        # Repo does not exist yet
229        return (False, False, None)
230    elif len(repos) == 1:
231        # Found an existing repo, look for changes
232        has_changes = _repo_changes(module, repos[0], repodata)
233        return (True, has_changes, repos)
234    elif len(repos) >= 2:
235        if overwrite_multiple:
236            # Found two repos and want to overwrite_multiple
237            return (True, True, repos)
238        else:
239            errmsg = 'More than one repo matched "%s": "%s".' % (name, repos)
240            errmsg += ' Use overwrite_multiple to allow more than one repo to be overwritten'
241            module.fail_json(msg=errmsg)
242
243
244def addmodify_repo(module, repodata, old_repos, zypper_version, warnings):
245    "Adds the repo, removes old repos before, that would conflict."
246    repo = repodata['url']
247    cmd = _get_cmd(module, 'addrepo', '--check')
248    if repodata['name']:
249        cmd.extend(['--name', repodata['name']])
250
251    # priority on addrepo available since 1.12.25
252    # https://github.com/openSUSE/zypper/blob/b9b3cb6db76c47dc4c47e26f6a4d2d4a0d12b06d/package/zypper.changes#L327-L336
253    if repodata['priority']:
254        if zypper_version >= LooseVersion('1.12.25'):
255            cmd.extend(['--priority', str(repodata['priority'])])
256        else:
257            warnings.append("Setting priority only available for zypper >= 1.12.25. Ignoring priority argument.")
258
259    if repodata['enabled'] == '0':
260        cmd.append('--disable')
261
262    # gpgcheck available since 1.6.2
263    # https://github.com/openSUSE/zypper/blob/b9b3cb6db76c47dc4c47e26f6a4d2d4a0d12b06d/package/zypper.changes#L2446-L2449
264    # the default changed in the past, so don't assume a default here and show warning for old zypper versions
265    if zypper_version >= LooseVersion('1.6.2'):
266        if repodata['gpgcheck'] == '1':
267            cmd.append('--gpgcheck')
268        else:
269            cmd.append('--no-gpgcheck')
270    else:
271        warnings.append("Enabling/disabling gpgcheck only available for zypper >= 1.6.2. Using zypper default value.")
272
273    if repodata['autorefresh'] == '1':
274        cmd.append('--refresh')
275
276    cmd.append(repo)
277
278    if not repo.endswith('.repo'):
279        cmd.append(repodata['alias'])
280
281    if old_repos is not None:
282        for oldrepo in old_repos:
283            remove_repo(module, oldrepo['url'])
284
285    rc, stdout, stderr = module.run_command(cmd, check_rc=False)
286    return rc, stdout, stderr
287
288
289def remove_repo(module, repo):
290    "Removes the repo."
291    cmd = _get_cmd(module, 'removerepo', repo)
292
293    rc, stdout, stderr = module.run_command(cmd, check_rc=True)
294    return rc, stdout, stderr
295
296
297def get_zypper_version(module):
298    rc, stdout, stderr = module.run_command([module.get_bin_path('zypper', required=True), '--version'])
299    if rc != 0 or not stdout.startswith('zypper '):
300        return LooseVersion('1.0')
301    return LooseVersion(stdout.split()[1])
302
303
304def runrefreshrepo(module, auto_import_keys=False, shortname=None):
305    "Forces zypper to refresh repo metadata."
306    if auto_import_keys:
307        cmd = _get_cmd(module, '--gpg-auto-import-keys', 'refresh', '--force')
308    else:
309        cmd = _get_cmd(module, 'refresh', '--force')
310    if shortname is not None:
311        cmd.extend(['-r', shortname])
312
313    rc, stdout, stderr = module.run_command(cmd, check_rc=True)
314    return rc, stdout, stderr
315
316
317def main():
318    module = AnsibleModule(
319        argument_spec=dict(
320            name=dict(required=False),
321            repo=dict(required=False),
322            state=dict(choices=['present', 'absent'], default='present'),
323            runrefresh=dict(required=False, default=False, type='bool'),
324            description=dict(required=False),
325            disable_gpg_check=dict(required=False, default=False, type='bool'),
326            autorefresh=dict(required=False, default=True, type='bool', aliases=['refresh']),
327            priority=dict(required=False, type='int'),
328            enabled=dict(required=False, default=True, type='bool'),
329            overwrite_multiple=dict(required=False, default=False, type='bool'),
330            auto_import_keys=dict(required=False, default=False, type='bool'),
331        ),
332        supports_check_mode=False,
333        required_one_of=[['state', 'runrefresh']],
334    )
335
336    repo = module.params['repo']
337    alias = module.params['name']
338    state = module.params['state']
339    overwrite_multiple = module.params['overwrite_multiple']
340    auto_import_keys = module.params['auto_import_keys']
341    runrefresh = module.params['runrefresh']
342
343    zypper_version = get_zypper_version(module)
344    warnings = []  # collect warning messages for final output
345
346    repodata = {
347        'url': repo,
348        'alias': alias,
349        'name': module.params['description'],
350        'priority': module.params['priority'],
351    }
352    # rewrite bools in the language that zypper lr -x provides for easier comparison
353    if module.params['enabled']:
354        repodata['enabled'] = '1'
355    else:
356        repodata['enabled'] = '0'
357    if module.params['disable_gpg_check']:
358        repodata['gpgcheck'] = '0'
359    else:
360        repodata['gpgcheck'] = '1'
361    if module.params['autorefresh']:
362        repodata['autorefresh'] = '1'
363    else:
364        repodata['autorefresh'] = '0'
365
366    def exit_unchanged():
367        module.exit_json(changed=False, repodata=repodata, state=state)
368
369    # Check run-time module parameters
370    if repo == '*' or alias == '*':
371        if runrefresh:
372            runrefreshrepo(module, auto_import_keys)
373            module.exit_json(changed=False, runrefresh=True)
374        else:
375            module.fail_json(msg='repo=* can only be used with the runrefresh option.')
376
377    if state == 'present' and not repo:
378        module.fail_json(msg='Module option state=present requires repo')
379    if state == 'absent' and not repo and not alias:
380        module.fail_json(msg='Alias or repo parameter required when state=absent')
381
382    if repo and repo.endswith('.repo'):
383        if alias:
384            module.fail_json(msg='Incompatible option: \'name\'. Do not use name when adding .repo files')
385    else:
386        if not alias and state == "present":
387            module.fail_json(msg='Name required when adding non-repo files.')
388
389    # Download / Open and parse .repo file to ensure idempotency
390    if repo and repo.endswith('.repo'):
391        if repo.startswith(('http://', 'https://')):
392            response, info = fetch_url(module=module, url=repo, force=True)
393            if not response or info['status'] != 200:
394                module.fail_json(msg='Error downloading .repo file from provided URL')
395            repofile_text = to_text(response.read(), errors='surrogate_or_strict')
396        else:
397            try:
398                with open(repo, encoding='utf-8') as file:
399                    repofile_text = file.read()
400            except IOError:
401                module.fail_json(msg='Error opening .repo file from provided path')
402
403        repofile = configparser.ConfigParser()
404        try:
405            repofile.readfp(StringIO(repofile_text))
406        except configparser.Error:
407            module.fail_json(msg='Invalid format, .repo file could not be parsed')
408
409        # No support for .repo file with zero or more than one repository
410        if len(repofile.sections()) != 1:
411            err = "Invalid format, .repo file contains %s repositories, expected 1" % len(repofile.sections())
412            module.fail_json(msg=err)
413
414        section = repofile.sections()[0]
415        repofile_items = dict(repofile.items(section))
416        # Only proceed if at least baseurl is available
417        if 'baseurl' not in repofile_items:
418            module.fail_json(msg='No baseurl found in .repo file')
419
420        # Set alias (name) and url based on values from .repo file
421        alias = section
422        repodata['alias'] = section
423        repodata['url'] = repofile_items['baseurl']
424
425        # If gpgkey is part of the .repo file, auto import key
426        if 'gpgkey' in repofile_items:
427            auto_import_keys = True
428
429        # Map additional values, if available
430        if 'name' in repofile_items:
431            repodata['name'] = repofile_items['name']
432        if 'enabled' in repofile_items:
433            repodata['enabled'] = repofile_items['enabled']
434        if 'autorefresh' in repofile_items:
435            repodata['autorefresh'] = repofile_items['autorefresh']
436        if 'gpgcheck' in repofile_items:
437            repodata['gpgcheck'] = repofile_items['gpgcheck']
438
439    exists, mod, old_repos = repo_exists(module, repodata, overwrite_multiple)
440
441    if alias:
442        shortname = alias
443    else:
444        shortname = repo
445
446    if state == 'present':
447        if exists and not mod:
448            if runrefresh:
449                runrefreshrepo(module, auto_import_keys, shortname)
450            exit_unchanged()
451        rc, stdout, stderr = addmodify_repo(module, repodata, old_repos, zypper_version, warnings)
452        if rc == 0 and (runrefresh or auto_import_keys):
453            runrefreshrepo(module, auto_import_keys, shortname)
454    elif state == 'absent':
455        if not exists:
456            exit_unchanged()
457        rc, stdout, stderr = remove_repo(module, shortname)
458
459    if rc == 0:
460        module.exit_json(changed=True, repodata=repodata, state=state, warnings=warnings)
461    else:
462        module.fail_json(msg="Zypper failed with rc %s" % rc, rc=rc, stdout=stdout, stderr=stderr, repodata=repodata, state=state, warnings=warnings)
463
464
465if __name__ == '__main__':
466    main()
467