From 5d579e1e66b23171a80fb667c7024d653f6085ad Mon Sep 17 00:00:00 2001 From: Prasad Katti Date: Fri, 8 Dec 2017 12:34:46 -0800 Subject: [PATCH] [cloud] Port ec2_key module to boto3 (#33075) * port ec2_key to boto3 * update tests for ec2_key --- lib/ansible/modules/cloud/amazon/ec2_key.py | 273 +++++++++--------- .../targets/ec2_key/tasks/main.yml | 258 +++-------------- 2 files changed, 169 insertions(+), 362 deletions(-) diff --git a/lib/ansible/modules/cloud/amazon/ec2_key.py b/lib/ansible/modules/cloud/amazon/ec2_key.py index 1497d6d31cb..d7033ccb240 100644 --- a/lib/ansible/modules/cloud/amazon/ec2_key.py +++ b/lib/ansible/modules/cloud/amazon/ec2_key.py @@ -16,9 +16,9 @@ DOCUMENTATION = ''' --- module: ec2_key version_added: "1.5" -short_description: maintain an ec2 key pair. +short_description: create or delete an ec2 key pair description: - - maintains ec2 key pairs. This module has a dependency on python-boto >= 2.5 + - create or delete an ec2 key pair. options: name: description: @@ -38,27 +38,27 @@ options: description: - create or delete keypair required: false + choices: [ present, absent ] default: 'present' - aliases: [] wait: description: - - Wait for the specified action to complete before returning. + - Wait for the specified action to complete before returning. This option has no effect since version 2.5. required: false default: false - aliases: [] version_added: "1.6" wait_timeout: description: - - How long before wait gives up, in seconds + - How long before wait gives up, in seconds. This option has no effect since version 2.5. required: false default: 300 - aliases: [] version_added: "1.6" extends_documentation_fragment: - - aws - - ec2 -author: "Vincent Viallet (@zbal)" + - aws +requirements: [ boto3 ] +author: + - "Vincent Viallet (@zbal)" + - "Prasad Katti (@prasadkatti)" ''' EXAMPLES = ''' @@ -98,6 +98,11 @@ changed: returned: always type: bool sample: true +msg: + description: short message describing the action taken + returned: always + type: string + sample: key pair created key: description: details of the keypair (this is set to null when state is absent) returned: always @@ -122,151 +127,141 @@ key: -----END RSA PRIVATE KEY-----' ''' -import random -import string -import time +import uuid -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.ec2 import HAS_BOTO, ec2_argument_spec, ec2_connect +from ansible.module_utils.aws.core import AnsibleAWSModule +from ansible.module_utils.ec2 import ec2_argument_spec, get_aws_connection_info, boto3_conn from ansible.module_utils._text import to_bytes +try: + from botocore.exceptions import ClientError +except ImportError: + pass + + +def extract_key_data(key): + + data = { + 'name': key['KeyName'], + 'fingerprint': key['KeyFingerprint'] + } + if 'KeyMaterial' in key: + data['private_key'] = key['KeyMaterial'] + return data + + +def get_key_fingerprint(module, ec2_client, key_material): + ''' + EC2's fingerprints are non-trivial to generate, so push this key + to a temporary name and make ec2 calculate the fingerprint for us. + http://blog.jbrowne.com/?p=23 + https://forums.aws.amazon.com/thread.jspa?messageID=352828 + ''' + + # find an unused name + name_in_use = True + while name_in_use: + random_name = "ansible-" + str(uuid.uuid4()) + name_in_use = find_key_pair(module, ec2_client, random_name) + + temp_key = import_key_pair(module, ec2_client, random_name, key_material) + delete_key_pair(module, ec2_client, random_name, finish_task=False) + return temp_key['KeyFingerprint'] + + +def find_key_pair(module, ec2_client, name): + + try: + key = ec2_client.describe_key_pairs(KeyNames=[name])['KeyPairs'][0] + except ClientError as err: + if err.response['Error']['Code'] == "InvalidKeyPair.NotFound": + return None + module.fail_json_aws(err, msg="error finding keypair") + return key + + +def create_key_pair(module, ec2_client, name, key_material, force): + + key = find_key_pair(module, ec2_client, name) + if key: + if key_material and force: + new_fingerprint = get_key_fingerprint(module, ec2_client, key_material) + if key['KeyFingerprint'] != new_fingerprint: + if not module.check_mode: + delete_key_pair(module, ec2_client, name, finish_task=False) + key = import_key_pair(module, ec2_client, name, key_material) + key_data = extract_key_data(key) + module.exit_json(changed=True, key=key_data, msg="key pair updated") + key_data = extract_key_data(key) + module.exit_json(changed=False, key=key_data, msg="key pair already exists") + else: + # key doesn't exist, create it now + key_data = None + if not module.check_mode: + if key_material: + key = import_key_pair(module, ec2_client, name, key_material) + else: + try: + key = ec2_client.create_key_pair(KeyName=name) + except ClientError as err: + module.fail_json_aws(err, msg="error creating key") + key_data = extract_key_data(key) + module.exit_json(changed=True, key=key_data, msg="key pair created") + + +def import_key_pair(module, ec2_client, name, key_material): + + try: + key = ec2_client.import_key_pair(KeyName=name, PublicKeyMaterial=to_bytes(key_material)) + except ClientError as err: + module.fail_json_aws(err, msg="error importing key") + return key + + +def delete_key_pair(module, ec2_client, name, finish_task=True): + + key = find_key_pair(module, ec2_client, name) + if key: + if not module.check_mode: + try: + ec2_client.delete_key_pair(KeyName=name) + except ClientError as err: + module.fail_json_aws(err, msg="error deleting key") + if not finish_task: + return + module.exit_json(changed=True, key=None, msg="key deleted") + module.exit_json(key=None, msg="key did not exist") + def main(): + argument_spec = ec2_argument_spec() - argument_spec.update(dict( - name=dict(required=True), - key_material=dict(required=False), - force=dict(required=False, type='bool', default=True), - state=dict(default='present', choices=['present', 'absent']), - wait=dict(type='bool', default=False), - wait_timeout=dict(default=300), - ) - ) - module = AnsibleModule( - argument_spec=argument_spec, - supports_check_mode=True, + argument_spec.update( + dict( + name=dict(required=True), + key_material=dict(), + force=dict(type='bool', default=True), + state=dict(default='present', choices=['present', 'absent']), + wait=dict(type='bool', default=False), + wait_timeout=dict(default=300) + ) ) - if not HAS_BOTO: - module.fail_json(msg='boto required for this module') + module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True) + + region, ec2_url, aws_connect_params = get_aws_connection_info(module, boto3=True) + + ec2_client = boto3_conn(module, conn_type='client', resource='ec2', region=region, endpoint=ec2_url, **aws_connect_params) name = module.params['name'] state = module.params.get('state') key_material = module.params.get('key_material') force = module.params.get('force') - wait = module.params.get('wait') - wait_timeout = int(module.params.get('wait_timeout')) - changed = False - - ec2 = ec2_connect(module) - - # find the key if present - key = ec2.get_key_pair(name) - - # Ensure requested key is absent if state == 'absent': - if key: - '''found a match, delete it''' - if not module.check_mode: - try: - key.delete() - if wait: - start = time.time() - action_complete = False - while (time.time() - start) < wait_timeout: - if not ec2.get_key_pair(name): - action_complete = True - break - time.sleep(1) - if not action_complete: - module.fail_json(msg="timed out while waiting for the key to be removed") - except Exception as e: - module.fail_json(msg="Unable to delete key pair '%s' - %s" % (key, e)) - key = None - changed = True - - # Ensure requested key is present + delete_key_pair(module, ec2_client, name) elif state == 'present': - if key: - # existing key found - if key_material and force: - # EC2's fingerprints are non-trivial to generate, so push this key - # to a temporary name and make ec2 calculate the fingerprint for us. - # - # http://blog.jbrowne.com/?p=23 - # https://forums.aws.amazon.com/thread.jspa?messageID=352828 - - # find an unused name - test = 'empty' - while test: - randomchars = [random.choice(string.ascii_letters + string.digits) for x in range(0, 10)] - tmpkeyname = "ansible-" + ''.join(randomchars) - test = ec2.get_key_pair(tmpkeyname) - - # create tmp key - tmpkey = ec2.import_key_pair(tmpkeyname, to_bytes(key_material)) - # get tmp key fingerprint - tmpfingerprint = tmpkey.fingerprint - # delete tmp key - tmpkey.delete() - - if key.fingerprint != tmpfingerprint: - if not module.check_mode: - key.delete() - key = ec2.import_key_pair(name, to_bytes(key_material)) - - if wait: - start = time.time() - action_complete = False - while (time.time() - start) < wait_timeout: - if ec2.get_key_pair(name): - action_complete = True - break - time.sleep(1) - if not action_complete: - module.fail_json(msg="timed out while waiting for the key to be re-created") - - changed = True - - # if the key doesn't exist, create it now - else: - '''no match found, create it''' - if not module.check_mode: - if key_material: - '''We are providing the key, need to import''' - key = ec2.import_key_pair(name, to_bytes(key_material)) - else: - ''' - No material provided, let AWS handle the key creation and - retrieve the private key - ''' - key = ec2.create_key_pair(name) - - if wait: - start = time.time() - action_complete = False - while (time.time() - start) < wait_timeout: - if ec2.get_key_pair(name): - action_complete = True - break - time.sleep(1) - if not action_complete: - module.fail_json(msg="timed out while waiting for the key to be created") - - changed = True - - if key: - data = { - 'name': key.name, - 'fingerprint': key.fingerprint - } - if key.material: - data.update({'private_key': key.material}) - - module.exit_json(changed=changed, key=data) - else: - module.exit_json(changed=changed, key=None) + create_key_pair(module, ec2_client, name, key_material, force) if __name__ == '__main__': diff --git a/test/integration/targets/ec2_key/tasks/main.yml b/test/integration/targets/ec2_key/tasks/main.yml index 04aa2670741..c39bc5385d3 100644 --- a/test/integration/targets/ec2_key/tasks/main.yml +++ b/test/integration/targets/ec2_key/tasks/main.yml @@ -6,7 +6,7 @@ # - EC2_REGION -> AWS_REGION # # TODO - name: test 'validate_certs' parameter - +# TODO - name: test creating key pair with another_key_material with force=yes # ============================================================ # - include: ../../setup_ec2/tasks/common.yml module_name=ec2_key @@ -25,126 +25,7 @@ - 'result.msg == "missing required arguments: name"' # ============================================================ - - name: test with only name - ec2_key: - name={{ec2_key_name}} - register: result - ignore_errors: true - - - name: assert failure when called with only 'name' - assert: - that: - - 'result.failed' - - 'result.msg == "Either region or ec2_url must be specified"' - - # ============================================================ - - name: test invalid region parameter - ec2_key: - name={{ec2_key_name}} - region='asdf querty 1234' - register: result - ignore_errors: true - - - name: assert invalid region parameter - assert: - that: - - 'result.failed' - - 'result.msg.startswith("Region asdf querty 1234 does not seem to be available ")' - - # ============================================================ - - name: test valid region parameter - ec2_key: - name={{ec2_key_name}} - region={{ec2_region}} - register: result - ignore_errors: true - - - name: assert valid region parameter - assert: - that: - - 'result.failed' - - 'result.msg.startswith("No handler was ready to authenticate.")' - - # ============================================================ - - name: test environment variable EC2_REGION - ec2_key: - name={{ec2_key_name}} - environment: - EC2_REGION: '{{ec2_region}}' - register: result - ignore_errors: true - - - name: assert environment variable EC2_REGION - assert: - that: - - 'result.failed' - - 'result.msg.startswith("No handler was ready to authenticate.")' - - # ============================================================ - - name: test invalid ec2_url parameter - ec2_key: - name={{ec2_key_name}} - environment: - EC2_URL: bogus.example.com - register: result - ignore_errors: true - - - name: assert invalid ec2_url parameter - assert: - that: - - 'result.failed' - - 'result.msg.startswith("No handler was ready to authenticate.")' - - # ============================================================ - - name: test valid ec2_url parameter - ec2_key: - name={{ec2_key_name}} - environment: - EC2_URL: '{{ec2_url}}' - register: result - ignore_errors: true - - - name: assert valid ec2_url parameter - assert: - that: - - 'result.failed' - - 'result.msg.startswith("No handler was ready to authenticate.")' - - # ============================================================ - - name: test credentials from environment - ec2_key: - name={{ec2_key_name}} - environment: - EC2_REGION: '{{ec2_region}}' - EC2_ACCESS_KEY: bogus_access_key - EC2_SECRET_KEY: bogus_secret_key - register: result - ignore_errors: true - - - name: assert ec2_key with valid ec2_url - assert: - that: - - 'result.failed' - - '"EC2ResponseError: 401 Unauthorized" in result.module_stderr' - - # ============================================================ - - name: test credential parameters - ec2_key: - name={{ec2_key_name}} - ec2_region={{ec2_region}} - ec2_access_key=bogus_access_key - ec2_secret_key=bogus_secret_key - register: result - ignore_errors: true - - - name: assert credential parameters - assert: - that: - - 'result.failed' - - '"EC2ResponseError: 401 Unauthorized" in result.module_stderr' - - # ============================================================ - - name: test removing a non-existent keypair + - name: test removing a non-existent key pair ec2_key: name='{{ec2_key_name}}' ec2_region={{ec2_region}} @@ -154,8 +35,13 @@ state=absent register: result + - name: assert removing a non-existent key pair + assert: + that: + - 'not result.changed' + # ============================================================ - - name: test state=present without key_material + - name: test creating a new key pair ec2_key: name='{{ec2_key_name}}' ec2_region={{ec2_region}} @@ -165,7 +51,7 @@ state=present register: result - - name: assert state=present without key_material + - name: assert creating a new key pair assert: that: - 'result.changed' @@ -176,7 +62,7 @@ - 'result.key.name == "{{ec2_key_name}}"' # ============================================================ - - name: test state=absent without key_material + - name: test removing an existent key ec2_key: name='{{ec2_key_name}}' state=absent @@ -187,7 +73,7 @@ EC2_SECURITY_TOKEN: '{{security_token|default("")}}' register: result - - name: assert state=absent without key_material + - name: assert removing an existent key assert: that: - 'result.changed' @@ -213,86 +99,11 @@ - 'result.changed == True' - '"key" in result' - '"name" in result.key' - - 'result.key.name == "{{ec2_key_name}}"' - '"fingerprint" in result.key' - '"private_key" not in result.key' + - 'result.key.name == "{{ec2_key_name}}"' - 'result.key.fingerprint == "{{fingerprint}}"' - # ============================================================ - - name: test state=absent with key_material - ec2_key: - name='{{ec2_key_name}}' - key_material='{{key_material}}' - ec2_region='{{ec2_region}}' - ec2_access_key='{{ec2_access_key}}' - ec2_secret_key='{{ec2_secret_key}}' - security_token='{{security_token}}' - state=absent - register: result - - - name: assert state=absent with key_material - assert: - that: - - 'result.changed' - - '"key" in result' - - 'result.key == None' - - # ============================================================ - - name: test state=present with key_material with_files (expect changed=true) - ec2_key: - name='{{ec2_key_name}}' - state=present - key_material='{{ item }}' - with_file: '{{sshkey}}.pub' - environment: - EC2_REGION: '{{ec2_region}}' - EC2_ACCESS_KEY: '{{ec2_access_key}}' - EC2_SECRET_KEY: '{{ec2_secret_key}}' - EC2_SECURITY_TOKEN: '{{security_token|default("")}}' - register: result - - - name: assert state=present with key_material with_files (expect changed=true) - assert: - that: - - 'result.msg == "All items completed"' - - 'result.changed == True' - - '"results" in result' - - '"item" in result.results[0]' - - '"key" in result.results[0]' - - '"name" in result.results[0].key' - - 'result.results[0].key.name == "{{ec2_key_name}}"' - - '"fingerprint" in result.results[0].key' - - '"private_key" not in result.results[0].key' - - 'result.results[0].key.fingerprint == "{{fingerprint}}"' - - # ============================================================ - - name: test state=present with key_material with_files (expect changed=false) - ec2_key: - name='{{ec2_key_name}}' - state=present - key_material='{{ item }}' - with_file: '{{sshkey}}.pub' - environment: - EC2_REGION: '{{ec2_region}}' - EC2_ACCESS_KEY: '{{ec2_access_key}}' - EC2_SECRET_KEY: '{{ec2_secret_key}}' - EC2_SECURITY_TOKEN: '{{security_token|default("")}}' - register: result - - - name: assert state=present with key_material with_files (expect changed=false) - assert: - that: - - 'result.msg == "All items completed"' - - 'not result.changed' - - '"results" in result' - - '"item" in result.results[0]' - - '"key" in result.results[0]' - - '"name" in result.results[0].key' - - 'result.results[0].key.name == "{{ec2_key_name}}"' - - '"fingerprint" in result.results[0].key' - - '"private_key" not in result.results[0].key' - - 'result.results[0].key.fingerprint == "{{fingerprint}}"' - # ============================================================ - name: test force=no with another_key_material (expect changed=false) @@ -313,14 +124,35 @@ - 'result.key.fingerprint == "{{ fingerprint }}"' # ============================================================ - - name: test state=absent with key_material (expect changed=true) + + - name: test updating a key pair using another_key_material (expect changed=True) + ec2_key: + name: '{{ ec2_key_name }}' + ec2_region: '{{ ec2_region }}' + ec2_access_key: '{{ ec2_access_key }}' + ec2_secret_key: '{{ ec2_secret_key }}' + security_token: '{{ security_token }}' + key_material: '{{ another_key_material }}' + register: result + + - name: assert updating a key pair using another_key_material (expect changed=True) + assert: + that: + - 'result.changed' + - 'result.key.fingerprint != "{{ fingerprint }}"' + + # ============================================================ + + always: + + # ============================================================ + - name: test state=absent (expect changed=true) ec2_key: name='{{ec2_key_name}}' ec2_region='{{ec2_region}}' ec2_access_key='{{ec2_access_key}}' ec2_secret_key='{{ec2_secret_key}}' security_token='{{security_token}}' - key_material='{{key_material}}' state=absent register: result @@ -330,23 +162,3 @@ - 'result.changed' - '"key" in result' - 'result.key == None' - - always: - - # ============================================================ - - name: test state=absent (expect changed=false) - ec2_key: - name='{{ec2_key_name}}' - ec2_region='{{ec2_region}}' - ec2_access_key='{{ec2_access_key}}' - ec2_secret_key='{{ec2_secret_key}}' - security_token='{{security_token}}' - state=absent - register: result - - - name: assert state=absent with key_material (expect changed=false) - assert: - that: - - 'not result.changed' - - '"key" in result' - - 'result.key == None'