Replacement for apt_repository.
1. Debian Squeeze is supported out of box now. 2. Repository type "deb" or "deb-src" should be explicitly specified. 3. If a source had beed added it must be possible to remove it. 4. PPA can be only used against Ubuntu hosts.
This commit is contained in:
parent
686a6f5557
commit
aed1f4156e
1 changed files with 288 additions and 98 deletions
|
@ -1,7 +1,8 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
# encoding: utf-8
|
||||
|
||||
# (c) 2012, Matt Wright <matt@nobien.net>
|
||||
# (c) 2013, Alexander Saltanov <asd@mokote.com>
|
||||
#
|
||||
# This file is part of Ansible
|
||||
#
|
||||
|
@ -17,137 +18,326 @@
|
|||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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"
|
||||
- Add or remove an APT repositories in Ubuntu and Debian.
|
||||
notes:
|
||||
- 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:
|
||||
description:
|
||||
- The repository name/value
|
||||
required: true
|
||||
default: null
|
||||
state:
|
||||
default: none
|
||||
description:
|
||||
- The repository state
|
||||
- A source string for the repository.
|
||||
state:
|
||||
required: false
|
||||
default: present
|
||||
choices: [ "present", "absent" ]
|
||||
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
|
||||
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 specified repository into sources.
|
||||
- apt_repository: repo='deb http://archive.canonical.com/ubuntu hardy partner'
|
||||
# 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 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
|
||||
HAVE_PYTHON_APT = 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
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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):
|
||||
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 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
|
||||
|
||||
def main():
|
||||
add_apt_repository = None
|
||||
|
||||
arg_spec = dict(
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
repo=dict(required=True),
|
||||
state=dict(default='present', choices=['present', 'absent'])
|
||||
state=dict(choices=['present', 'absent'], default='present'),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=True)
|
||||
|
||||
if not HAVE_PYAPT:
|
||||
module.fail_json(msg="Could not import python modules: apt, apt_pkg. Please install python-apt package.")
|
||||
|
||||
add_apt_repository = module.get_bin_path(ADD_APT_REPO, True)
|
||||
if check_cmd_needs_y():
|
||||
add_apt_repository += ' -y'
|
||||
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))
|
||||
|
||||
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
|
||||
#<<INCLUDE_ANSIBLE_MODULE_COMMON>>
|
||||
|
||||
|
|
Loading…
Reference in a new issue