diff --git a/library/apt_key b/library/apt_key new file mode 100644 index 0000000000..dcc4d12717 --- /dev/null +++ b/library/apt_key @@ -0,0 +1,256 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2012, Michael DeHaan +# (c) 2012, Jayson Vantuyl +# +# 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 . + +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 +#<> +main() diff --git a/test/TestRunner.py b/test/TestRunner.py index 3b2c940260..9526dbbdee 100644 --- a/test/TestRunner.py +++ b/test/TestRunner.py @@ -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) diff --git a/test/apt_key.gpg b/test/apt_key.gpg new file mode 100644 index 0000000000..7af1d3c997 --- /dev/null +++ b/test/apt_key.gpg @@ -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-----