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