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