diff --git a/library/packaging/apt_repository b/library/packaging/apt_repository index 46d3781f3d8..cd4ec25c86a 100644 --- a/library/packaging/apt_repository +++ b/library/packaging/apt_repository @@ -1,7 +1,8 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- +# encoding: utf-8 # (c) 2012, Matt Wright +# (c) 2013, Alexander Saltanov # # This file is part of Ansible # @@ -17,136 +18,325 @@ # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . -# -# Example: -# - name: add nginx repo -# action: apt_repository repo=ppa:nginx/stable state=present -# + DOCUMENTATION = ''' --- module: apt_repository -short_description: Manages apt repositores +short_description: Add and remove APT repositores description: - - Manages apt repositories (such as for Debian/Ubuntu). -version_added: "0.7" -options: - repo: - description: - - The repository name/value - required: true - default: null - state: - description: - - The repository state - required: false - default: present - choices: [ "present", "absent" ] + - Add or remove an APT repositories in Ubuntu and Debian. notes: - - If the repository is added, C(apt-get update) is invoked. - - This module works on Debian and Ubuntu only and requires C(add-apt-repository) be available on the destination server. To ensure this package is available use the M(apt) module and install the C(python-software-properties) package (or C(software-properties-common) in Ubuntu 13.04 or newer) before using this module. - - This module cannot be used on Debian Squeeze (Version 6) as there is no C(add-apt-repository) in C(python-software-properties) - - A bug in C(add-apt-repository) always adds C(deb) and C(deb-src) types for repositories (see the issue on Launchpad U(https://bugs.launchpad.net/ubuntu/+source/software-properties/+bug/987264)), if a repo doesn't have source information (eg MongoDB repo from 10gen) the system will fail while updating repositories. -author: Matt Wright + - This module works on Debian and Ubuntu and requires only C(python-apt) package. + - This module supports Debian Squeeze (version 6) as well as its successors. + - This module treats Debian and Ubuntu distributions separately. So PPA could be installed only on Ubuntu machines. +options: + repo: + required: true + default: none + description: + - A source string for the repository. + state: + required: false + choices: [ "absent", "present" ] + default: "present" + description: + - A source string state. +author: Alexander Saltanov +version_added: "1.2.1" requirements: [ python-apt ] ''' EXAMPLES = ''' -# Add nginx stable repository from PPA -- apt_repository: repo=ppa:nginx/stable +# Add specified repository into sources list. +apt_repository: repo='deb http://archive.canonical.com/ubuntu hardy partner' state=present + +# Add source repository into sources list. +apt_repository: repo='deb-src http://archive.canonical.com/ubuntu hardy partner' state=present -# Add specified repository into sources. -- apt_repository: repo='deb http://archive.canonical.com/ubuntu hardy partner' +# Remove specified repository from sources list. +apt_repository: repo='deb http://archive.canonical.com/ubuntu hardy partner' state=absent + +# On Ubuntu target: add nginx stable repository from PPA and install its signing key. +# On Debian target: adding PPA is not available, so it will fail immediately. +apt_repository: repo='ppa:nginx/stable' ''' -import platform +import glob +import json +import os +import re +import tempfile +import urllib2 try: - import apt import apt_pkg - HAVE_PYAPT = True + import aptsources.distro + distro = aptsources.distro.get_distro() + HAVE_PYTHON_APT = True except ImportError: - HAVE_PYAPT = False - -APT = "/usr/bin/apt-get" -ADD_APT_REPO = 'add-apt-repository' - -def check_cmd_needs_y(): - if platform.dist()[0] == 'debian' or float(platform.dist()[1]) >= 11.10: - return True - return False - -def repo_exists(module, repo): - configured = False - slist = apt_pkg.SourceList() - if not slist.read_main_list(): - module.fail_json(msg="Failed to parse sources.list") - for metaindex in slist.list: - if repo in metaindex.uri: - configured = True - return configured + HAVE_PYTHON_APT = False -def main(): - add_apt_repository = None - arg_spec = dict( - repo=dict(required=True), - state=dict(default='present', choices=['present', 'absent']) - ) +VALID_SOURCE_TYPES = ('deb', 'deb-src') + + +class InvalidSource(Exception): + pass + + +# Simple version of aptsources.sourceslist.SourcesList. +# No advanced logic and no backups inside. +class SourcesList(object): + def __init__(self): + self.files = {} # group sources by file + self.default_file = apt_pkg.config.find_file('Dir::Etc::sourcelist') + + # read sources.list + self.load(self.default_file) + + # read sources.list.d + for file in glob.iglob('%s/*.list' % apt_pkg.config.find_dir('Dir::Etc::sourceparts')): + self.load(file) + + def __iter__(self): + '''Simple iterator to go over all sources. Empty, non-source, and other not valid lines will be skipped.''' + for file, sources in self.files.items(): + for n, valid, enabled, source, comment in sources: + if valid: + yield file, n, enabled, source, comment + raise StopIteration + + def _expand_path(self, filename): + if '/' in filename: + return filename + else: + return os.path.abspath(os.path.join(apt_pkg.config.find_dir('Dir::Etc::sourceparts'), filename)) + + def _suggest_filename(self, line): + def _remove_protocol(s): + if '://' in s: + return s.split('://')[1] + else: + return s + + def _cleanup_filename(s): + return '_'.join(re.sub('[^a-zA-Z0-9]', ' ', s).split()) + + parts = [_remove_protocol(part) for part in line.split() if part not in VALID_SOURCE_TYPES] + return '%s.list' % _cleanup_filename(' '.join(parts[:1])) + + def _parse(self, line, raise_if_invalid_or_disabled=False): + valid = False + enabled = True + source = '' + comment = '' + + line = line.strip() + if line.startswith('#'): + enabled = False + line = line[1:] + + # Check for another "#" in the line and treat a part after it as a comment. + i = line.find('#') + if i > 0: + comment = line[i+1:].strip() + line = line[:i] + + # Split a source into substring to make sure that it is source spec. + # Duplicated whitespaces in a valid source spec will be removed. + source = line.strip() + if source: + chunks = source.split() + if chunks[0] in VALID_SOURCE_TYPES: + valid = True + source = ' '.join(chunks) + + if raise_if_invalid_or_disabled and (not valid or not enabled): + raise InvalidSource(line) + + return valid, enabled, source, comment + + def load(self, file): + group = [] + with open(file, 'r') as f: + for n, line in enumerate(f): + valid, enabled, source, comment = self._parse(line) + group.append((n, valid, enabled, source, comment)) + self.files[file] = group + + def save(self, module): + for filename, sources in self.files.items(): + if sources: + d, fn = os.path.split(filename) + fd, tmp_path = tempfile.mkstemp(prefix=".%s-" % fn, dir=d) + + with os.fdopen(fd, 'w') as f: + for n, valid, enabled, source, comment in sources: + chunks = [] + if not enabled: + chunks.append('# ') + chunks.append(source) + if comment: + chunks.append(' # ') + chunks.append(comment) + chunks.append('\n') + line = ''.join(chunks) - module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=True) + try: + f.write(line) + except IOError as err: + module.fail_json(msg="Failed to write to file %s: %s" % (tmp_path, unicode(err))) + module.atomic_move(tmp_path, filename) + else: + del self.files[filename] + if os.path.exists(filename): + os.remove(filename) - if not HAVE_PYAPT: - module.fail_json(msg="Could not import python modules: apt, apt_pkg. Please install python-apt package.") + def dump(self): + return '\n'.join([str(i) for i in self]) - add_apt_repository = module.get_bin_path(ADD_APT_REPO, True) - if check_cmd_needs_y(): - add_apt_repository += ' -y' + def modify(self, file, n, enabled=None, source=None, comment=None): + ''' + This function to be used with iterator, so we don't care of invalid sources. + If source, enabled, or comment is None, original value from line ``n`` will be preserved. + ''' + valid, enabled_old, source_old, comment_old = self.files[file][n][1:] + choice = lambda new, old: old if new is None else new + self.files[file][n] = (n, valid, choice(enabled, enabled_old), choice(source, source_old), choice(comment, comment_old)) + + def _add_valid_source(self, source_new, comment_new, file): + # We'll try to reuse disabled source if we have it. + # If we have more than one entry, we will enable them all - no advanced logic, remember. + found = False + for filename, n, enabled, source, comment in self: + if source == source_new: + self.modify(filename, n, enabled=True) + found = True + + if not found: + if file is None: + file = self.default_file + else: + file = self._expand_path(file) + + if file not in self.files: + self.files[file] = [] + + files = self.files[file] + files.append((len(files), True, True, source_new, comment_new)) + + def add_source(self, line, comment='', file=None): + source = self._parse(line, raise_if_invalid_or_disabled=True)[2] + + # Prefer separate files for new sources. + self._add_valid_source(source, comment, file=file or self._suggest_filename(source)) + + def _remove_valid_source(self, source): + # If we have more than one entry, we will remove them all (not comment, remove!) + for filename, n, enabled, src, comment in self: + if source == src and enabled: + self.files[filename].pop(n) + + def remove_source(self, line): + source = self._parse(line, raise_if_invalid_or_disabled=True)[2] + self._remove_valid_source(source) + + +class UbuntuSourcesList(SourcesList): + def __init__(self, add_ppa_signing_keys_callback=None): + self.add_ppa_signing_keys_callback = add_ppa_signing_keys_callback + super(UbuntuSourcesList, self).__init__() + + def _get_ppa_info(self, owner_name, ppa_name): + lp_api = 'https://launchpad.net/api/1.0/~%s/+archive/%s' % (owner_name, ppa_name) + connection = urllib2.urlopen(lp_api, timeout=30) + return json.loads(connection.read()) + + def _expand_ppa(self, path): + ppa = path.split(':')[1] + ppa_owner = ppa.split('/')[0] + try: + ppa_name = ppa.split('/')[1] + except IndexError: + ppa_name = 'ppa' + + line = 'deb http://ppa.launchpad.net/%s/%s/ubuntu %s main' % (ppa_owner, ppa_name, distro.codename) + return line, ppa_owner, ppa_name + + def add_source(self, line, comment='', file=None): + if line.startswith('ppa:'): + source, ppa_owner, ppa_name = self._expand_ppa(line) + + if self.add_ppa_signing_keys_callback is not None: + info = self._get_ppa_info(ppa_owner, ppa_name) + command = ['apt-key', 'adv', '--recv-keys', '--keyserver', 'keyserver.ubuntu.com', info['signing_key_fingerprint']] + self.add_ppa_signing_keys_callback(command) + + file = file or self._suggest_filename('%s_%s' % (line, distro.codename)) + else: + source = self._parse(line, raise_if_invalid_or_disabled=True)[2] + file = file or self._suggest_filename(source) + self._add_valid_source(source, comment, file) + + def remove_source(self, line): + if line.startswith('ppa:'): + source = self._expand_ppa(line)[0] + else: + source = self._parse(line, raise_if_invalid_or_disabled=True)[2] + self._remove_valid_source(source) + + +def get_add_ppa_signing_key_callback(module): + def _run_command(command): + module.run_command(command, check_rc=True) + + return _run_command if not module.check_mode else None + + +def main(): + module = AnsibleModule( + argument_spec=dict( + repo=dict(required=True), + state=dict(choices=['present', 'absent'], default='present'), + ), + supports_check_mode=True, + ) + + if not HAVE_PYTHON_APT: + module.fail_json(msg='Could not import python modules: apt_pkg. Please install python-apt package.') repo = module.params['repo'] state = module.params['state'] + sourceslist = None - repo_url = repo - if 'ppa:' in repo_url and not 'http://' in repo_url: - # looks like ppa:nginx/stable - repo_url = repo.split(':')[1] - elif len(repo_url.split(' ')) > 1: - # could be: - # http://myserver/path/to/repo free non-free - # deb http://myserver/path/to/repo free non-free - for i in repo_url.split(): - for prot in ['http', 'file', 'ftp']: - if prot in i: - repo_url = i - break - exists = repo_exists(module, repo_url) - - rc = 0 - out = '' - err = '' - if state == 'absent' and exists: - if module.check_mode: - module.exit_json(changed=True) - cmd = '%s "%s" --remove' % (add_apt_repository, repo) - rc, out, err = module.run_command(cmd) - elif state == 'present' and not exists: - if module.check_mode: - module.exit_json(changed=True) - cmd = '%s "%s"' % (add_apt_repository, repo) - rc, out, err = module.run_command(cmd) + if isinstance(distro, aptsources.distro.DebianDistribution): + sourceslist = SourcesList() + elif isinstance(distro, aptsources.distro.UbuntuDistribution): + sourceslist = UbuntuSourcesList(add_ppa_signing_keys_callback=get_add_ppa_signing_key_callback(module)) else: - module.exit_json(changed=False, repo=repo, state=state) + module.fail_json(msg='Module apt_repository supports only Debian and Ubuntu.') - if rc != 0: - module.fail_json(msg=err) - else: - changed = True + sources_before = sourceslist.dump() - if state == 'present' and changed: - rc, out, err = module.run_command('%s update' % APT) + try: + if state == 'present': + sourceslist.add_source(repo) + elif state == 'absent': + sourceslist.remove_source(repo) + except InvalidSource as err: + module.fail_json(msg='Invalid repository string: %s' % unicode(err)) - module.exit_json(changed=changed, repo=repo, state=state) + sources_after = sourceslist.dump() + changed = sources_before != sources_after + + if not module.check_mode and changed: + try: + sourceslist.save(module) + except OSError as err: + module.fail_json(msg=unicode(err)) + module.exit_json(changed=changed, repo=repo, state=state) # this is magic, see lib/ansible/module_common.py #<>