add apt_key module

Pretty straightforward.  Give it a URL with an exported GPG key for signing an
Apt repository.  It downloads it and will install it using apt-key.  It's even
smart enough to tell if it's already there (i.e. actually tells you if it
changed or not).
This commit is contained in:
Jayson Vantuyl 2013-01-03 14:09:29 -08:00
parent f86974fdae
commit ad6373430b
3 changed files with 408 additions and 0 deletions

256
library/apt_key Normal file
View file

@ -0,0 +1,256 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
# (c) 2012, Jayson Vantuyl <jayson@aggressive.ly>
#
# 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_key
author: Jayson Vantuyl
version_added: 1.0
short_description: Add or remove an apt key
description:
- Add or remove an I(apt) key, optionally downloading it
notes:
- doesn't download the key unless it really needs it
- as a sanity check, downloaded key id must match the one specified
- best practice is to specify the key id and the url
options:
id:
required: false
default: none
description:
- identifier of key
url:
required: false
default: none
description:
- url to retrieve key from.
state:
required: false
choices: [ absent, present ]
default: present
description:
- used to specify if key is being added or revoked
examples:
- code: "apt_key: url=https://ftp-master.debian.org/keys/archive-key-6.0.asc state=present"
description: Add an Apt signing key, uses whichever key is at the URL
- code: "apt_key: id=473041FA url=https://ftp-master.debian.org/keys/archive-key-6.0.asc state=present"
description: Add an Apt signing key, will not download if present
- code: "apt_key: url=https://ftp-master.debian.org/keys/archive-key-6.0.asc state=absent"
description: Remove an Apt signing key, uses whichever key is at the URL
- code: "apt_key: id=473041FA state=absent"
description: Remove a Apt specific signing key
'''
from urllib2 import urlopen, URLError
from traceback import format_exc
from subprocess import Popen, PIPE, call
from re import compile as re_compile
from distutils.spawn import find_executable
from os import environ
from sys import exc_info
match_key = re_compile("^gpg:.*key ([0-9a-fA-F]+):.*$")
REQUIRED_EXECUTABLES=['gpg', 'grep', 'apt-key']
def find_missing_binaries():
return [missing for missing in REQUIRED_EXECUTABLES if not find_executable(missing)]
def get_key_ids(key_data):
p = Popen("gpg --list-only --import -", shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE)
(stdo, stde) = p.communicate(key_data)
if p.returncode > 0:
raise Exception("error running GPG to retrieve keys")
output = stdo + stde
for line in output.split('\n'):
match = match_key.match(line)
if match:
yield match.group(1)
def key_present(key_id):
return call("apt-key list | 2>&1 grep -q %s" % key_id, shell=True) == 0
def download_key(url):
if url is None:
raise Exception("Needed URL but none specified")
connection = urlopen(url)
if connection is None:
raise Exception("error connecting to download key from %r" % url)
return connection.read()
def add_key(key):
return call("apt-key add -", shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE)
(_, _) = p.communicate(key)
return p.returncode == 0
def remove_key(key_id):
return call('apt-key del %s' % key_id, shell=True) == 0
def return_values(tb=False):
if tb:
return {'exception': format_exc()}
else:
return {}
# use cues from the environment to mock out functions for testing
if 'ANSIBLE_TEST_APT_KEY' in environ:
orig_download_key = download_key
KEY_ADDED=0
KEY_REMOVED=0
KEY_DOWNLOADED=0
def download_key(url):
global KEY_DOWNLOADED
KEY_DOWNLOADED += 1
return orig_download_key(url)
def find_missing_binaries():
return []
def add_key(key):
global KEY_ADDED
KEY_ADDED += 1
return True
def remove_key(key_id):
global KEY_REMOVED
KEY_REMOVED += 1
return True
def return_values(tb=False):
extra = dict(
added=KEY_ADDED,
removed=KEY_REMOVED,
downloaded=KEY_DOWNLOADED
)
if tb:
extra['exception'] = format_exc()
return extra
if environ.get('ANSIBLE_TEST_APT_KEY') == 'none':
def key_present(key_id):
return False
else:
def key_present(key_id):
return key_id == environ['ANSIBLE_TEST_APT_KEY']
def main():
module = AnsibleModule(
argument_spec=dict(
id=dict(required=False, default=None),
url=dict(required=False),
state=dict(required=False, choices=['present', 'absent'], default='present')
)
)
expected_key_id = module.params['id']
url = module.params['url']
state = module.params['state']
changed = False
missing = find_missing_binaries()
if missing:
module.fail_json(msg="can't find needed binaries to run", missing=missing,
**return_values())
if state == 'present':
if expected_key_id and key_present(expected_key_id):
# key is present, nothing to do
pass
else:
# download key
try:
key = download_key(url)
(key_id,) = tuple(get_key_ids(key)) # TODO: support multiple key ids?
except Exception:
module.fail_json(
msg="error getting key id from url",
**return_values(True)
)
# sanity check downloaded key
if expected_key_id and key_id != expected_key_id:
module.fail_json(
msg="expected key id %s, got key id %s" % (expected_key_id, key_id),
**return_values()
)
# actually add key
if key_present(key_id):
changed=False
elif add_key(key):
changed=True
else:
module.fail_json(
msg="failed to add key id %s" % key_id,
**return_values()
)
elif state == 'absent':
# optionally download the key and get the id
if not expected_key_id:
try:
key = download_key(url)
(key_id,) = tuple(get_key_ids(key)) # TODO: support multiple key ids?
except Exception:
module.fail_json(
msg="error getting key id from url",
**return_values(True)
)
else:
key_id = expected_key_id
# actually remove key
if key_present(key_id):
if remove_key(key_id):
changed=True
else:
module.fail_json(msg="error removing key_id", **return_values(True))
else:
module.fail_json(
msg="unexpected state: %s" % state,
**return_values()
)
module.exit_json(changed=changed, **return_values())
# include magic from lib/ansible/module_common.py
#<<INCLUDE_ANSIBLE_MODULE_COMMON>>
main()

View file

@ -10,6 +10,7 @@ import os
import shutil
import time
import tempfile
import urllib2
from nose.plugins.skip import SkipTest
@ -288,3 +289,64 @@ class TestRunner(unittest.TestCase):
])
print result
assert result['changed'] == False
def test_apt_key(self):
try:
key_file = self._get_test_file("apt_key.gpg")
key_file_url = 'file://' + urllib2.quote(key_file)
key_id = '473041FA'
os.environ['ANSIBLE_TEST_APT_KEY'] = 'none'
# key missing, should download and add
result = self._run('apt_key', ['state=present', 'url=' + key_file_url])
assert 'failed' not in result
assert result['added'] == 1
assert result['downloaded'] == 1
assert result['removed'] == 0
assert result['changed']
os.environ["ANSIBLE_TEST_APT_KEY"] = key_id
# key missing, shouldn't download, no changes
result = self._run('apt_key', ['id=12345678', 'state=absent', 'url=' + key_file_url])
assert 'failed' not in result
assert result['added'] == 0
assert result['downloaded'] == 0
assert result['removed'] == 0
assert not result['changed']
# key missing, should download and fail sanity check, no changes
result = self._run('apt_key', ['id=12345678', 'state=present', 'url=' + key_file_url])
assert 'failed' in result
assert result['added'] == 0
assert result['downloaded'] == 1
assert result['removed'] == 0
# key present, shouldn't download, no changes
result = self._run('apt_key', ['id=' + key_id, 'state=present', 'url=' + key_file_url])
assert 'failed' not in result
assert result['added'] == 0
assert result['downloaded'] == 0
assert result['removed'] == 0
assert not result['changed']
# key present, should download to get key id
result = self._run('apt_key', ['state=present', 'url=' + key_file_url])
assert 'failed' not in result
assert result['added'] == 0
assert result['downloaded'] == 1
assert result['removed'] == 0
assert not result['changed']
# key present, should download to get key id and remove
result = self._run('apt_key', ['state=absent', 'url=' + key_file_url])
assert 'failed' not in result
assert result['added'] == 0
assert result['downloaded'] == 1
assert result['removed'] == 1
assert result['changed']
# key present, should remove but not download
result = self._run('apt_key', ['id=' + key_id, 'state=absent', 'url=' + key_file_url])
assert 'failed' not in result
assert result['added'] == 0
assert result['downloaded'] == 0
assert result['removed'] == 1
assert result['changed']
finally:
# always clean up the environment
os.environ.pop('ANSIBLE_TEST_APT_KEY', None)

90
test/apt_key.gpg Normal file
View file

@ -0,0 +1,90 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v1.4.10 (GNU/Linux)
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
hqsRKcw+Z9k=
=sw5O
-----END PGP PUBLIC KEY BLOCK-----