mirror of
				https://github.com/ansible-collections/community.general.git
				synced 2024-09-14 20:13:21 +02:00 
			
		
		
		
	Also added some error handling to the fetch_url() call in the apt_repository module, so that failures to look up the PPA info are properly handled. Fixes #7322
		
			
				
	
	
		
			432 lines
		
	
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			432 lines
		
	
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #!/usr/bin/python
 | |
| # encoding: utf-8
 | |
| 
 | |
| # (c) 2012, Matt Wright <matt@nobien.net>
 | |
| # (c) 2013, Alexander Saltanov <asd@mokote.com>
 | |
| # (c) 2014, Rutger Spiertz <rutger@kumina.nl>
 | |
| #
 | |
| # This file is part of Ansible
 | |
| #
 | |
| # Ansible is free software: you can redistribute it and/or modify
 | |
| # it under the terms of the GNU General Public License as published by
 | |
| # the Free Software Foundation, either version 3 of the License, or
 | |
| # (at your option) any later version.
 | |
| #
 | |
| # Ansible is distributed in the hope that it will be useful,
 | |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | |
| # GNU General Public License for more details.
 | |
| #
 | |
| # You should have received a copy of the GNU General Public License
 | |
| # along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
 | |
| 
 | |
| 
 | |
| DOCUMENTATION = '''
 | |
| ---
 | |
| module: apt_repository
 | |
| short_description: Add and remove APT repositores
 | |
| description:
 | |
|     - Add or remove an APT repositories in Ubuntu and Debian.
 | |
| notes:
 | |
|     - This module works on Debian and Ubuntu and requires C(python-apt).
 | |
|     - 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.
 | |
|     mode:
 | |
|         required: false
 | |
|         default: 0644
 | |
|         description:
 | |
|             - The octal mode for newly created files in sources.list.d
 | |
|         version_added: "1.6"
 | |
|     update_cache:
 | |
|         description:
 | |
|             - Run the equivalent of C(apt-get update) when a change occurs.  Cache updates are run after making changes.
 | |
|         required: false
 | |
|         default: "yes"
 | |
|         choices: [ "yes", "no" ]
 | |
| author: Alexander Saltanov
 | |
| version_added: "0.7"
 | |
| requirements: [ python-apt ]
 | |
| '''
 | |
| 
 | |
| EXAMPLES = '''
 | |
| # 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
 | |
| 
 | |
| # 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 glob
 | |
| import os
 | |
| import re
 | |
| import tempfile
 | |
| 
 | |
| try:
 | |
|     import apt
 | |
|     import apt_pkg
 | |
|     import aptsources.distro as aptsources_distro
 | |
|     distro = aptsources_distro.get_distro()
 | |
|     HAVE_PYTHON_APT = True
 | |
| except ImportError:
 | |
|     distro = None
 | |
|     HAVE_PYTHON_APT = False
 | |
| 
 | |
| 
 | |
| VALID_SOURCE_TYPES = ('deb', 'deb-src')
 | |
| 
 | |
| def install_python_apt(module):
 | |
| 
 | |
|     if not module.check_mode:
 | |
|         apt_get_path = module.get_bin_path('apt-get')
 | |
|         if apt_get_path:
 | |
|             rc, so, se = module.run_command('%s update && %s install python-apt -y -q' % (apt_get_path, apt_get_path), use_unsafe_shell=True)
 | |
|             if rc == 0:
 | |
|                 global apt, apt_pkg, aptsources_distro, distro, HAVE_PYTHON_APT
 | |
|                 import apt
 | |
|                 import apt_pkg
 | |
|                 import aptsources.distro as aptsources_distro
 | |
|                 distro = aptsources_distro.get_distro()
 | |
|                 HAVE_PYTHON_APT = True
 | |
|             else:
 | |
|                 module.fail_json(msg="Failed to auto-install python-apt. Error was: '%s'" % se.strip())
 | |
| 
 | |
| 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 = self._apt_cfg_file('Dir::Etc::sourcelist')
 | |
| 
 | |
|         # read sources.list if it exists
 | |
|         if os.path.isfile(self.default_file):
 | |
|             self.load(self.default_file)
 | |
| 
 | |
|         # read sources.list.d
 | |
|         for file in glob.iglob('%s/*.list' % self._apt_cfg_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(self._apt_cfg_dir('Dir::Etc::sourceparts'), filename))
 | |
| 
 | |
|     def _suggest_filename(self, line):
 | |
|         def _cleanup_filename(s):
 | |
|             return '_'.join(re.sub('[^a-zA-Z0-9]', ' ', s).split())
 | |
|         def _strip_username_password(s):
 | |
|             if '@' in s:
 | |
|                 s = s.split('@', 1)
 | |
|                 s = s[-1]
 | |
|             return s
 | |
| 
 | |
|         # Drop options and protocols.
 | |
|         line = re.sub('\[[^\]]+\]', '', line)
 | |
|         line = re.sub('\w+://', '', line)
 | |
| 
 | |
|         # split line into valid keywords
 | |
|         parts = [part for part in line.split() if part not in VALID_SOURCE_TYPES]
 | |
| 
 | |
|         # Drop usernames and passwords
 | |
|         parts[0] = _strip_username_password(parts[0])
 | |
| 
 | |
|         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
 | |
| 
 | |
|     @staticmethod
 | |
|     def _apt_cfg_file(filespec):
 | |
|         '''
 | |
|         Wrapper for `apt_pkg` module for running with Python 2.5
 | |
|         '''
 | |
|         try:
 | |
|             result = apt_pkg.config.find_file(filespec)
 | |
|         except AttributeError:
 | |
|             result = apt_pkg.Config.FindFile(filespec)
 | |
|         return result
 | |
| 
 | |
|     @staticmethod
 | |
|     def _apt_cfg_dir(dirspec):
 | |
|         '''
 | |
|         Wrapper for `apt_pkg` module for running with Python 2.5
 | |
|         '''
 | |
|         try:
 | |
|             result = apt_pkg.config.find_dir(dirspec)
 | |
|         except AttributeError:
 | |
|             result = apt_pkg.Config.FindDir(dirspec)
 | |
|         return result
 | |
| 
 | |
|     def load(self, file):
 | |
|         group = []
 | |
|         f = open(file, 'r')
 | |
|         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)
 | |
| 
 | |
|                 # allow the user to override the default mode
 | |
|                 this_mode = module.params['mode']
 | |
|                 module.set_mode_if_different(tmp_path, this_mode, False)
 | |
| 
 | |
|                 f = os.fdopen(fd, 'w')
 | |
|                 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)
 | |
| 
 | |
|                     try:
 | |
|                         f.write(line)
 | |
|                     except IOError, 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)
 | |
| 
 | |
|     def dump(self):
 | |
|         return '\n'.join([str(i) for i in self])
 | |
| 
 | |
|     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):
 | |
| 
 | |
|     LP_API = 'https://launchpad.net/api/1.0/~%s/+archive/%s'
 | |
| 
 | |
|     def __init__(self, module, add_ppa_signing_keys_callback=None):
 | |
|         self.module = module
 | |
|         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 = self.LP_API % (owner_name, ppa_name)
 | |
| 
 | |
|         headers = dict(Accept='application/json')
 | |
|         response, info = fetch_url(self.module, lp_api, headers=headers)
 | |
|         if info['status'] != 200:
 | |
|             self.module.fail_json(msg="failed to fetch PPA information, error was: %s" % info['msg'])
 | |
|         return json.load(response)
 | |
| 
 | |
|     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', 'hkp://keyserver.ubuntu.com:80', 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)
 | |
| 
 | |
|     if module.check_mode:
 | |
|         return None
 | |
|     else:
 | |
|         return _run_command
 | |
| 
 | |
| 
 | |
| def main():
 | |
|     module = AnsibleModule(
 | |
|         argument_spec=dict(
 | |
|             repo=dict(required=True),
 | |
|             state=dict(choices=['present', 'absent'], default='present'),
 | |
|             mode=dict(required=False, default=0644),
 | |
|             update_cache = dict(aliases=['update-cache'], type='bool', default='yes'),
 | |
|             # this should not be needed, but exists as a failsafe
 | |
|             install_python_apt=dict(required=False, default="yes", type='bool'),
 | |
|         ),
 | |
|         supports_check_mode=True,
 | |
|     )
 | |
| 
 | |
|     params = module.params
 | |
|     if params['install_python_apt'] and not HAVE_PYTHON_APT and not module.check_mode:
 | |
|         install_python_apt(module)
 | |
| 
 | |
|     repo = module.params['repo']
 | |
|     state = module.params['state']
 | |
|     update_cache = module.params['update_cache']
 | |
|     sourceslist = None
 | |
| 
 | |
|     if HAVE_PYTHON_APT:
 | |
|         if isinstance(distro, aptsources_distro.UbuntuDistribution):
 | |
|             sourceslist = UbuntuSourcesList(module,
 | |
|                 add_ppa_signing_keys_callback=get_add_ppa_signing_key_callback(module))
 | |
|         elif HAVE_PYTHON_APT and \
 | |
|             isinstance(distro, aptsources_distro.DebianDistribution) or isinstance(distro, aptsources_distro.Distribution):
 | |
|             sourceslist = SourcesList()
 | |
|     else:
 | |
|         module.fail_json(msg='Module apt_repository supports only Debian and Ubuntu. ' + \
 | |
|                              'You may be seeing this because python-apt is not installed, but you requested that it not be auto-installed')
 | |
| 
 | |
|     sources_before = sourceslist.dump()
 | |
| 
 | |
|     try:
 | |
|         if state == 'present':
 | |
|             sourceslist.add_source(repo)
 | |
|         elif state == 'absent':
 | |
|             sourceslist.remove_source(repo)
 | |
|     except InvalidSource, err:
 | |
|         module.fail_json(msg='Invalid repository string: %s' % unicode(err))
 | |
| 
 | |
|     sources_after = sourceslist.dump()
 | |
|     changed = sources_before != sources_after
 | |
| 
 | |
|     if not module.check_mode and changed:
 | |
|         try:
 | |
|             sourceslist.save(module)
 | |
|             if update_cache:
 | |
|                 cache = apt.Cache()
 | |
|                 cache.update()
 | |
|         except OSError, err:
 | |
|             module.fail_json(msg=unicode(err))
 | |
| 
 | |
|     module.exit_json(changed=changed, repo=repo, state=state)
 | |
| 
 | |
| # import module snippets
 | |
| from ansible.module_utils.basic import *
 | |
| from ansible.module_utils.urls import *
 | |
| 
 | |
| main()
 |