1#!/usr/bin/python 2# encoding: utf-8 3 4# Copyright: (c) 2012, Matt Wright <matt@nobien.net> 5# Copyright: (c) 2013, Alexander Saltanov <asd@mokote.com> 6# Copyright: (c) 2014, Rutger Spiertz <rutger@kumina.nl> 7 8# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 9 10from __future__ import absolute_import, division, print_function 11__metaclass__ = type 12 13 14DOCUMENTATION = ''' 15--- 16module: apt_repository 17short_description: Add and remove APT repositories 18description: 19 - Add or remove an APT repositories in Ubuntu and Debian. 20notes: 21 - This module works on Debian, Ubuntu and their derivatives. 22 - This module supports Debian Squeeze (version 6) as well as its successors. 23 - Supports C(check_mode). 24options: 25 repo: 26 description: 27 - A source string for the repository. 28 required: true 29 state: 30 description: 31 - A source string state. 32 choices: [ absent, present ] 33 default: "present" 34 mode: 35 description: 36 - The octal mode for newly created files in sources.list.d 37 default: '0644' 38 version_added: "1.6" 39 update_cache: 40 description: 41 - Run the equivalent of C(apt-get update) when a change occurs. Cache updates are run after making changes. 42 type: bool 43 default: "yes" 44 update_cache_retries: 45 description: 46 - Amount of retries if the cache update fails. Also see I(update_cache_retry_max_delay). 47 type: int 48 default: 5 49 version_added: '2.10' 50 update_cache_retry_max_delay: 51 description: 52 - Use an exponential backoff delay for each retry (see I(update_cache_retries)) up to this max delay in seconds. 53 type: int 54 default: 12 55 version_added: '2.10' 56 validate_certs: 57 description: 58 - If C(no), SSL certificates for the target repo will not be validated. This should only be used 59 on personally controlled sites using self-signed certificates. 60 type: bool 61 default: 'yes' 62 version_added: '1.8' 63 filename: 64 description: 65 - Sets the name of the source list file in sources.list.d. 66 Defaults to a file name based on the repository source url. 67 The .list extension will be automatically added. 68 version_added: '2.1' 69 codename: 70 description: 71 - Override the distribution codename to use for PPA repositories. 72 Should usually only be set when working with a PPA on 73 a non-Ubuntu target (for example, Debian or Mint). 74 version_added: '2.3' 75author: 76- Alexander Saltanov (@sashka) 77version_added: "0.7" 78requirements: 79 - python-apt (python 2) 80 - python3-apt (python 3) 81''' 82 83EXAMPLES = ''' 84- name: Add specified repository into sources list 85 ansible.builtin.apt_repository: 86 repo: deb http://archive.canonical.com/ubuntu hardy partner 87 state: present 88 89- name: Add specified repository into sources list using specified filename 90 ansible.builtin.apt_repository: 91 repo: deb http://dl.google.com/linux/chrome/deb/ stable main 92 state: present 93 filename: google-chrome 94 95- name: Add source repository into sources list 96 ansible.builtin.apt_repository: 97 repo: deb-src http://archive.canonical.com/ubuntu hardy partner 98 state: present 99 100- name: Remove specified repository from sources list 101 ansible.builtin.apt_repository: 102 repo: deb http://archive.canonical.com/ubuntu hardy partner 103 state: absent 104 105- name: Add nginx stable repository from PPA and install its signing key on Ubuntu target 106 ansible.builtin.apt_repository: 107 repo: ppa:nginx/stable 108 109- name: Add nginx stable repository from PPA and install its signing key on Debian target 110 ansible.builtin.apt_repository: 111 repo: 'ppa:nginx/stable' 112 codename: trusty 113''' 114 115RETURN = '''#''' 116 117import glob 118import json 119import os 120import re 121import sys 122import tempfile 123import copy 124import random 125import time 126 127try: 128 import apt 129 import apt_pkg 130 import aptsources.distro as aptsources_distro 131 distro = aptsources_distro.get_distro() 132 HAVE_PYTHON_APT = True 133except ImportError: 134 distro = None 135 HAVE_PYTHON_APT = False 136 137from ansible.module_utils.basic import AnsibleModule 138from ansible.module_utils._text import to_native 139from ansible.module_utils.urls import fetch_url 140 141 142if sys.version_info[0] < 3: 143 PYTHON_APT = 'python-apt' 144else: 145 PYTHON_APT = 'python3-apt' 146 147DEFAULT_SOURCES_PERM = 0o0644 148 149VALID_SOURCE_TYPES = ('deb', 'deb-src') 150 151 152def install_python_apt(module): 153 154 if not module.check_mode: 155 apt_get_path = module.get_bin_path('apt-get') 156 if apt_get_path: 157 rc, so, se = module.run_command([apt_get_path, 'update']) 158 if rc != 0: 159 module.fail_json(msg="Failed to auto-install %s. Error was: '%s'" % (PYTHON_APT, se.strip())) 160 rc, so, se = module.run_command([apt_get_path, 'install', PYTHON_APT, '-y', '-q']) 161 if rc == 0: 162 global apt, apt_pkg, aptsources_distro, distro, HAVE_PYTHON_APT 163 import apt 164 import apt_pkg 165 import aptsources.distro as aptsources_distro 166 distro = aptsources_distro.get_distro() 167 HAVE_PYTHON_APT = True 168 else: 169 module.fail_json(msg="Failed to auto-install %s. Error was: '%s'" % (PYTHON_APT, se.strip())) 170 else: 171 module.fail_json(msg="%s must be installed to use check mode" % PYTHON_APT) 172 173 174class InvalidSource(Exception): 175 pass 176 177 178# Simple version of aptsources.sourceslist.SourcesList. 179# No advanced logic and no backups inside. 180class SourcesList(object): 181 def __init__(self, module): 182 self.module = module 183 self.files = {} # group sources by file 184 # Repositories that we're adding -- used to implement mode param 185 self.new_repos = set() 186 self.default_file = self._apt_cfg_file('Dir::Etc::sourcelist') 187 188 # read sources.list if it exists 189 if os.path.isfile(self.default_file): 190 self.load(self.default_file) 191 192 # read sources.list.d 193 for file in glob.iglob('%s/*.list' % self._apt_cfg_dir('Dir::Etc::sourceparts')): 194 self.load(file) 195 196 def __iter__(self): 197 '''Simple iterator to go over all sources. Empty, non-source, and other not valid lines will be skipped.''' 198 for file, sources in self.files.items(): 199 for n, valid, enabled, source, comment in sources: 200 if valid: 201 yield file, n, enabled, source, comment 202 203 def _expand_path(self, filename): 204 if '/' in filename: 205 return filename 206 else: 207 return os.path.abspath(os.path.join(self._apt_cfg_dir('Dir::Etc::sourceparts'), filename)) 208 209 def _suggest_filename(self, line): 210 def _cleanup_filename(s): 211 filename = self.module.params['filename'] 212 if filename is not None: 213 return filename 214 return '_'.join(re.sub('[^a-zA-Z0-9]', ' ', s).split()) 215 216 def _strip_username_password(s): 217 if '@' in s: 218 s = s.split('@', 1) 219 s = s[-1] 220 return s 221 222 # Drop options and protocols. 223 line = re.sub(r'\[[^\]]+\]', '', line) 224 line = re.sub(r'\w+://', '', line) 225 226 # split line into valid keywords 227 parts = [part for part in line.split() if part not in VALID_SOURCE_TYPES] 228 229 # Drop usernames and passwords 230 parts[0] = _strip_username_password(parts[0]) 231 232 return '%s.list' % _cleanup_filename(' '.join(parts[:1])) 233 234 def _parse(self, line, raise_if_invalid_or_disabled=False): 235 valid = False 236 enabled = True 237 source = '' 238 comment = '' 239 240 line = line.strip() 241 if line.startswith('#'): 242 enabled = False 243 line = line[1:] 244 245 # Check for another "#" in the line and treat a part after it as a comment. 246 i = line.find('#') 247 if i > 0: 248 comment = line[i + 1:].strip() 249 line = line[:i] 250 251 # Split a source into substring to make sure that it is source spec. 252 # Duplicated whitespaces in a valid source spec will be removed. 253 source = line.strip() 254 if source: 255 chunks = source.split() 256 if chunks[0] in VALID_SOURCE_TYPES: 257 valid = True 258 source = ' '.join(chunks) 259 260 if raise_if_invalid_or_disabled and (not valid or not enabled): 261 raise InvalidSource(line) 262 263 return valid, enabled, source, comment 264 265 @staticmethod 266 def _apt_cfg_file(filespec): 267 ''' 268 Wrapper for `apt_pkg` module for running with Python 2.5 269 ''' 270 try: 271 result = apt_pkg.config.find_file(filespec) 272 except AttributeError: 273 result = apt_pkg.Config.FindFile(filespec) 274 return result 275 276 @staticmethod 277 def _apt_cfg_dir(dirspec): 278 ''' 279 Wrapper for `apt_pkg` module for running with Python 2.5 280 ''' 281 try: 282 result = apt_pkg.config.find_dir(dirspec) 283 except AttributeError: 284 result = apt_pkg.Config.FindDir(dirspec) 285 return result 286 287 def load(self, file): 288 group = [] 289 f = open(file, 'r') 290 for n, line in enumerate(f): 291 valid, enabled, source, comment = self._parse(line) 292 group.append((n, valid, enabled, source, comment)) 293 self.files[file] = group 294 295 def save(self): 296 for filename, sources in list(self.files.items()): 297 if sources: 298 d, fn = os.path.split(filename) 299 try: 300 os.makedirs(d) 301 except OSError as err: 302 if not os.path.isdir(d): 303 self.module.fail_json("Failed to create directory %s: %s" % (d, to_native(err))) 304 fd, tmp_path = tempfile.mkstemp(prefix=".%s-" % fn, dir=d) 305 306 f = os.fdopen(fd, 'w') 307 for n, valid, enabled, source, comment in sources: 308 chunks = [] 309 if not enabled: 310 chunks.append('# ') 311 chunks.append(source) 312 if comment: 313 chunks.append(' # ') 314 chunks.append(comment) 315 chunks.append('\n') 316 line = ''.join(chunks) 317 318 try: 319 f.write(line) 320 except IOError as err: 321 self.module.fail_json(msg="Failed to write to file %s: %s" % (tmp_path, to_native(err))) 322 self.module.atomic_move(tmp_path, filename) 323 324 # allow the user to override the default mode 325 if filename in self.new_repos: 326 this_mode = self.module.params.get('mode', DEFAULT_SOURCES_PERM) 327 self.module.set_mode_if_different(filename, this_mode, False) 328 else: 329 del self.files[filename] 330 if os.path.exists(filename): 331 os.remove(filename) 332 333 def dump(self): 334 dumpstruct = {} 335 for filename, sources in self.files.items(): 336 if sources: 337 lines = [] 338 for n, valid, enabled, source, comment in sources: 339 chunks = [] 340 if not enabled: 341 chunks.append('# ') 342 chunks.append(source) 343 if comment: 344 chunks.append(' # ') 345 chunks.append(comment) 346 chunks.append('\n') 347 lines.append(''.join(chunks)) 348 dumpstruct[filename] = ''.join(lines) 349 return dumpstruct 350 351 def _choice(self, new, old): 352 if new is None: 353 return old 354 return new 355 356 def modify(self, file, n, enabled=None, source=None, comment=None): 357 ''' 358 This function to be used with iterator, so we don't care of invalid sources. 359 If source, enabled, or comment is None, original value from line ``n`` will be preserved. 360 ''' 361 valid, enabled_old, source_old, comment_old = self.files[file][n][1:] 362 self.files[file][n] = (n, valid, self._choice(enabled, enabled_old), self._choice(source, source_old), self._choice(comment, comment_old)) 363 364 def _add_valid_source(self, source_new, comment_new, file): 365 # We'll try to reuse disabled source if we have it. 366 # If we have more than one entry, we will enable them all - no advanced logic, remember. 367 found = False 368 for filename, n, enabled, source, comment in self: 369 if source == source_new: 370 self.modify(filename, n, enabled=True) 371 found = True 372 373 if not found: 374 if file is None: 375 file = self.default_file 376 else: 377 file = self._expand_path(file) 378 379 if file not in self.files: 380 self.files[file] = [] 381 382 files = self.files[file] 383 files.append((len(files), True, True, source_new, comment_new)) 384 self.new_repos.add(file) 385 386 def add_source(self, line, comment='', file=None): 387 source = self._parse(line, raise_if_invalid_or_disabled=True)[2] 388 389 # Prefer separate files for new sources. 390 self._add_valid_source(source, comment, file=file or self._suggest_filename(source)) 391 392 def _remove_valid_source(self, source): 393 # If we have more than one entry, we will remove them all (not comment, remove!) 394 for filename, n, enabled, src, comment in self: 395 if source == src and enabled: 396 self.files[filename].pop(n) 397 398 def remove_source(self, line): 399 source = self._parse(line, raise_if_invalid_or_disabled=True)[2] 400 self._remove_valid_source(source) 401 402 403class UbuntuSourcesList(SourcesList): 404 405 LP_API = 'https://launchpad.net/api/1.0/~%s/+archive/%s' 406 407 def __init__(self, module, add_ppa_signing_keys_callback=None): 408 self.module = module 409 self.add_ppa_signing_keys_callback = add_ppa_signing_keys_callback 410 self.codename = module.params['codename'] or distro.codename 411 super(UbuntuSourcesList, self).__init__(module) 412 413 def _get_ppa_info(self, owner_name, ppa_name): 414 lp_api = self.LP_API % (owner_name, ppa_name) 415 416 headers = dict(Accept='application/json') 417 response, info = fetch_url(self.module, lp_api, headers=headers) 418 if info['status'] != 200: 419 self.module.fail_json(msg="failed to fetch PPA information, error was: %s" % info['msg']) 420 return json.loads(to_native(response.read())) 421 422 def _expand_ppa(self, path): 423 ppa = path.split(':')[1] 424 ppa_owner = ppa.split('/')[0] 425 try: 426 ppa_name = ppa.split('/')[1] 427 except IndexError: 428 ppa_name = 'ppa' 429 430 line = 'deb http://ppa.launchpad.net/%s/%s/ubuntu %s main' % (ppa_owner, ppa_name, self.codename) 431 return line, ppa_owner, ppa_name 432 433 def _key_already_exists(self, key_fingerprint): 434 rc, out, err = self.module.run_command('apt-key export %s' % key_fingerprint, check_rc=True) 435 return len(err) == 0 436 437 def add_source(self, line, comment='', file=None): 438 if line.startswith('ppa:'): 439 source, ppa_owner, ppa_name = self._expand_ppa(line) 440 441 if source in self.repos_urls: 442 # repository already exists 443 return 444 445 if self.add_ppa_signing_keys_callback is not None: 446 info = self._get_ppa_info(ppa_owner, ppa_name) 447 if not self._key_already_exists(info['signing_key_fingerprint']): 448 command = ['apt-key', 'adv', '--recv-keys', '--no-tty', '--keyserver', 'hkp://keyserver.ubuntu.com:80', info['signing_key_fingerprint']] 449 self.add_ppa_signing_keys_callback(command) 450 451 file = file or self._suggest_filename('%s_%s' % (line, self.codename)) 452 else: 453 source = self._parse(line, raise_if_invalid_or_disabled=True)[2] 454 file = file or self._suggest_filename(source) 455 self._add_valid_source(source, comment, file) 456 457 def remove_source(self, line): 458 if line.startswith('ppa:'): 459 source = self._expand_ppa(line)[0] 460 else: 461 source = self._parse(line, raise_if_invalid_or_disabled=True)[2] 462 self._remove_valid_source(source) 463 464 @property 465 def repos_urls(self): 466 _repositories = [] 467 for parsed_repos in self.files.values(): 468 for parsed_repo in parsed_repos: 469 valid = parsed_repo[1] 470 enabled = parsed_repo[2] 471 source_line = parsed_repo[3] 472 473 if not valid or not enabled: 474 continue 475 476 if source_line.startswith('ppa:'): 477 source, ppa_owner, ppa_name = self._expand_ppa(source_line) 478 _repositories.append(source) 479 else: 480 _repositories.append(source_line) 481 482 return _repositories 483 484 485def get_add_ppa_signing_key_callback(module): 486 def _run_command(command): 487 module.run_command(command, check_rc=True) 488 489 if module.check_mode: 490 return None 491 else: 492 return _run_command 493 494 495def revert_sources_list(sources_before, sources_after, sourceslist_before): 496 '''Revert the sourcelist files to their previous state.''' 497 498 # First remove any new files that were created: 499 for filename in set(sources_after.keys()).difference(sources_before.keys()): 500 if os.path.exists(filename): 501 os.remove(filename) 502 # Now revert the existing files to their former state: 503 sourceslist_before.save() 504 505 506def main(): 507 module = AnsibleModule( 508 argument_spec=dict( 509 repo=dict(type='str', required=True), 510 state=dict(type='str', default='present', choices=['absent', 'present']), 511 mode=dict(type='raw'), 512 update_cache=dict(type='bool', default=True, aliases=['update-cache']), 513 update_cache_retries=dict(type='int', default=5), 514 update_cache_retry_max_delay=dict(type='int', default=12), 515 filename=dict(type='str'), 516 # This should not be needed, but exists as a failsafe 517 install_python_apt=dict(type='bool', default=True), 518 validate_certs=dict(type='bool', default=True), 519 codename=dict(type='str'), 520 ), 521 supports_check_mode=True, 522 ) 523 524 params = module.params 525 repo = module.params['repo'] 526 state = module.params['state'] 527 update_cache = module.params['update_cache'] 528 # Note: mode is referenced in SourcesList class via the passed in module (self here) 529 530 sourceslist = None 531 532 if not HAVE_PYTHON_APT: 533 if params['install_python_apt']: 534 install_python_apt(module) 535 else: 536 module.fail_json(msg='%s is not installed, and install_python_apt is False' % PYTHON_APT) 537 538 if not repo: 539 module.fail_json(msg='Please set argument \'repo\' to a non-empty value') 540 541 if isinstance(distro, aptsources_distro.Distribution): 542 sourceslist = UbuntuSourcesList(module, add_ppa_signing_keys_callback=get_add_ppa_signing_key_callback(module)) 543 else: 544 module.fail_json(msg='Module apt_repository is not supported on target.') 545 546 sourceslist_before = copy.deepcopy(sourceslist) 547 sources_before = sourceslist.dump() 548 549 try: 550 if state == 'present': 551 sourceslist.add_source(repo) 552 elif state == 'absent': 553 sourceslist.remove_source(repo) 554 except InvalidSource as err: 555 module.fail_json(msg='Invalid repository string: %s' % to_native(err)) 556 557 sources_after = sourceslist.dump() 558 changed = sources_before != sources_after 559 560 if changed and module._diff: 561 diff = [] 562 for filename in set(sources_before.keys()).union(sources_after.keys()): 563 diff.append({'before': sources_before.get(filename, ''), 564 'after': sources_after.get(filename, ''), 565 'before_header': (filename, '/dev/null')[filename not in sources_before], 566 'after_header': (filename, '/dev/null')[filename not in sources_after]}) 567 else: 568 diff = {} 569 570 if changed and not module.check_mode: 571 try: 572 sourceslist.save() 573 if update_cache: 574 err = '' 575 update_cache_retries = module.params.get('update_cache_retries') 576 update_cache_retry_max_delay = module.params.get('update_cache_retry_max_delay') 577 randomize = random.randint(0, 1000) / 1000.0 578 579 for retry in range(update_cache_retries): 580 try: 581 cache = apt.Cache() 582 cache.update() 583 break 584 except apt.cache.FetchFailedException as e: 585 err = to_native(e) 586 587 # Use exponential backoff with a max fail count, plus a little bit of randomness 588 delay = 2 ** retry + randomize 589 if delay > update_cache_retry_max_delay: 590 delay = update_cache_retry_max_delay + randomize 591 time.sleep(delay) 592 else: 593 revert_sources_list(sources_before, sources_after, sourceslist_before) 594 module.fail_json(msg='Failed to update apt cache: %s' % (err if err else 'unknown reason')) 595 596 except (OSError, IOError) as err: 597 revert_sources_list(sources_before, sources_after, sourceslist_before) 598 module.fail_json(msg=to_native(err)) 599 600 module.exit_json(changed=changed, repo=repo, state=state, diff=diff) 601 602 603if __name__ == '__main__': 604 main() 605