From 3ebbcbadcf04a437eb6bc11a64362a2907a1fe8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kar=C3=A1sek?= Date: Tue, 3 Jan 2017 18:10:17 +0200 Subject: [PATCH] 2 modules for Packet host: packet_device and packet_sshkey (#19005) * Added 2 modules for Packet Host: packet_device and packet_sshkey * Fixed comments from @mmlb * Fixed comments from @gundalow * Fix typos pointed by @gundalow * Mention new Packet modules in the CHANGELOG.md --- CHANGELOG.md | 3 + lib/ansible/modules/cloud/packet/__init__.py | 0 .../modules/cloud/packet/packet_device.py | 566 ++++++++++++++++++ .../modules/cloud/packet/packet_sshkey.py | 274 +++++++++ 4 files changed, 843 insertions(+) create mode 100644 lib/ansible/modules/cloud/packet/__init__.py create mode 100644 lib/ansible/modules/cloud/packet/packet_device.py create mode 100644 lib/ansible/modules/cloud/packet/packet_sshkey.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d3aeac9a4..e54f29150e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,9 @@ Ansible Changes By Release * infini_vol - omapi_host - openwrt_init +- packet: + * packet_device + * packet_sshkey - windows: * win_say diff --git a/lib/ansible/modules/cloud/packet/__init__.py b/lib/ansible/modules/cloud/packet/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/ansible/modules/cloud/packet/packet_device.py b/lib/ansible/modules/cloud/packet/packet_device.py new file mode 100644 index 00000000000..7a6501013d0 --- /dev/null +++ b/lib/ansible/modules/cloud/packet/packet_device.py @@ -0,0 +1,566 @@ +#!/usr/bin/python +# (c) 2016, Tomas Karasek +# (c) 2016, Matt Baldwin +# (c) 2016, Thibaud Morel l'Horset +# +# 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: packet_device + +short_description: create, destroy, start, stop, and reboot a Packet Host machine. + +description: + - create, destroy, update, start, stop, and reboot a Packet Host machine. When the machine is created it can optionally wait for it to have an IP address before returning. This module has a dependency on packet >= 1.0. + - API is documented at U(https://www.packet.net/help/api/#page:devices,header:devices-devices-post). + +version_added: 2.3 + +author: Tomas Karasek , Matt Baldwin , Thibaud Morel l'Horset + +options: + auth_token: + description: + - Packet api token. You can also supply it in env var C(PACKET_API_TOKEN). + + count: + description: + - The number of devices to create. Count number can be included in hostname via the %d string formatter. + + count_offset: + description: + - From which number to start the count. + + device_ids: + description: + - List of device IDs on which to operate. + + facility: + description: + - Facility slug for device creation. As of 2016, it should be one of [ewr1, sjc1, ams1, nrt1]. + + features: + description: + - Dict with "features" for device creation. See Packet API docs for details. + + hostnames: + description: + - A hostname of a device, or a list of hostnames. + - If given string or one-item list, you can use the C("%d") Python string format to expand numbers from count. + - If only one hostname, it might be expanded to list if count>1. + aliases: [name] + + lock: + description: + - Whether to lock a created device. + default: false + + operating_system: + description: + - OS slug for device creation. See Packet docs or API for current list. + + plan: + description: + - Plan slug for device creation. See Packet docs or API for current list. + + project_id: + description: + - ID of project of the device. + required: true + + state: + description: + - Desired state of the device. + choices: [present, absent, active, inactive, rebooted] + default: 'present' + + user_data: + description: + - Userdata blob made available to the machine + required: false + default: None + + wait: + description: + - Whether to wait for the instance to be assigned IP address before returning. + required: false + default: False + type: bool + + wait_timeout: + description: + - How long to wait for IP address of new devices before quitting. In seconds. + default: 60 + +requirements: + - packet-python + - "python >= 2.6" +''' + +EXAMPLES = ''' +# All the examples assume that you have your Packet api token in env var PACKET_API_TOKEN. +# You can also pass it to the auth_token parameter of the module instead. + +# Creating devices + +- name: create 1 device + hosts: localhost + tasks: + - packet_device: + project_id: 89b497ee-5afc-420a-8fb5-56984898f4df + hostnames: myserver + operating_system: ubuntu_16_04 + plan: baremetal_0 + facility: sjc1 + +- name: create 3 ubuntu devices called server-01, server-02 and server-03 + hosts: localhost + tasks: + - packet_device: + project_id: 89b497ee-5afc-420a-8fb5-56984898f4df + hostnames: server-%02d + count: 3 + operating_system: ubuntu_16_04 + plan: baremetal_0 + facility: sjc1 + +- name: Create 3 coreos devices with userdata, wait until they get IPs and then wait for SSH + hosts: localhost + tasks: + - name: create 3 devices and register their facts + packet_device: + hostnames: [coreos-one, coreos-two, coreos-three] + operating_system: coreos_stable + plan: baremetal_0 + facility: ewr1 + locked: true + project_id: 89b497ee-5afc-420a-8fb5-56984898f4df + user_data: | + #cloud-config + ssh_authorized_keys: + - ssh-dss AAAAB3NzaC1kc3MAAACBAIfNT5S0ncP4BBJBYNhNPxFF9lqVhfPeu6SM1LoCocxqDc1AT3zFRi8hjIf6TLZ2AA4FYbcAWxLMhiBxZRVldT9GdBXile78kAK5z3bKTwq152DCqpxwwbaTIggLFhsU8wrfBsPWnDuAxZ0h7mmrCjoLIE3CNLDA/NmV3iB8xMThAAAAFQCStcesSgR1adPORzBxTr7hug92LwAAAIBOProm3Gk+HWedLyE8IfofLaOeRnbBRHAOL4z0SexKkVOnQ/LGN/uDIIPGGBDYTvXgKZT+jbHeulRJ2jKgfSpGKN4JxFQ8uzVH492jEiiUJtT72Ss1dCV4PmyERVIw+f54itihV3z/t25dWgowhb0int8iC/OY3cGodlmYb3wdcQAAAIBuLbB45djZXzUkOTzzcRDIRfhaxo5WipbtEM2B1fuBt2gyrvksPpH/LK6xTjdIIb0CxPu4OCxwJG0aOz5kJoRnOWIXQGhH7VowrJhsqhIc8gN9ErbO5ea8b1L76MNcAotmBDeTUiPw01IJ8MdDxfmcsCslJKgoRKSmQpCwXQtN2g== tomk@hp2 + coreos: + etcd: + discovery: https://discovery.etcd.io/6a28e078895c5ec737174db2419bb2f3 + addr: $private_ipv4:4001 + peer-addr: $private_ipv4:7001 + fleet: + public-ip: $private_ipv4 + units: + - name: etcd.service + command: start + - name: fleet.service + command: start + register: newhosts + + - name: wait for ssh + wait_for: + delay: 1 + host: "{{ item.public_ipv4 }}" + port: 22 + state: started + timeout: 500 + with_items: "{{ newhosts.devices }}" + + +# Other states of devices + +- name: remove 3 devices by uuid + hosts: localhost + tasks: + - packet_device: + project_id: 89b497ee-5afc-420a-8fb5-56984898f4df + state: absent + device_ids: + - 1fb4faf8-a638-4ac7-8f47-86fe514c30d8 + - 2eb4faf8-a638-4ac7-8f47-86fe514c3043 + - 6bb4faf8-a638-4ac7-8f47-86fe514c301f +''' + +RETURN = ''' +changed: + description: True if a device was altered in any way (created, modified or removed) + type: bool + sample: True + returned: always +devices: + description: Information about each device that was processed + type: array + sample: '[{"hostname": "my-server.com", "id": "server-id", "public-ipv4": "147.229.15.12", "private-ipv4": "10.0.15.12", "public-ipv6": ""2604:1380:2:5200::3"}]' + returned: always +''' + + +import os +import time +import uuid +import re + +from ansible.module_utils.basic import AnsibleModule + +HAS_PACKET_SDK = True + +try: + import packet +except ImportError: + HAS_PACKET_SDK = False + + +NAME_RE = '({0}|{0}{1}*{0})'.format('[a-zA-Z0-9]','[a-zA-Z0-9\-]') +HOSTNAME_RE = '({0}\.)*{0}$'.format(NAME_RE) +MAX_DEVICES = 100 + +PACKET_DEVICE_STATES = ( + 'queued', + 'provisioning', + 'failed', + 'powering_on', + 'active', + 'powering_off', + 'inactive', + 'rebooting', +) + +PACKET_API_TOKEN_ENV_VAR = "PACKET_API_TOKEN" + + +ALLOWED_STATES = ['absent', 'active', 'inactive', 'rebooted', 'present'] + + +def serialize_device(device): + """ + Standard represenation for a device as returned by various tasks:: + + { + 'id': 'device_id' + 'hostname': 'device_hostname', + 'tags': [], + 'locked': false, + 'ip_addresses': [ + { + "address": "147.75.194.227", + "address_family": 4, + "public": true + }, + { + "address": "2604:1380:2:5200::3", + "address_family": 6, + "public": true + }, + { + "address": "10.100.11.129", + "address_family": 4, + "public": false + } + ], + "private_ipv4": "10.100.11.129", + "public_ipv4": "147.75.194.227", + "public_ipv6": "2604:1380:2:5200::3", + } + + """ + device_data = {} + device_data['id'] = device.id + device_data['hostname'] = device.hostname + device_data['tags'] = device.tags + device_data['locked'] = device.locked + device_data['ip_addresses'] = [ + { + 'address': addr_data['address'], + 'address_family': addr_data['address_family'], + 'public': addr_data['public'], + } + for addr_data in device.ip_addresses + ] + # Also include each IPs as a key for easier lookup in roles. + # Key names: + # - public_ipv4 + # - public_ipv6 + # - private_ipv4 + # - private_ipv6 (if there is one) + for ipdata in device_data['ip_addresses']: + if ipdata['public']: + if ipdata['address_family'] == 6: + device_data['public_ipv6'] = ipdata['address'] + elif ipdata['address_family'] == 4: + device_data['public_ipv4'] = ipdata['address'] + elif not ipdata['public']: + if ipdata['address_family'] == 6: + # Packet doesn't give public ipv6 yet, but maybe one + # day they will + device_data['private_ipv6'] = ipdata['address'] + elif ipdata['address_family'] == 4: + device_data['private_ipv4'] = ipdata['address'] + return device_data + + +def is_valid_hostname(hostname): + return re.match(HOSTNAME_RE, hostname) is not None + + +def is_valid_uuid(myuuid): + try: + val = uuid.UUID(myuuid, version=4) + except ValueError: + return False + return str(val) == myuuid + + +def listify_string_name_or_id(s): + if ',' in s: + return [i for i in s.split(',')] + else: + return [s] + + +def get_hostname_list(module): + # hostname is a list-typed param, so I guess it should return list + # (and it does, in Ansbile 2.2.1) but in order to be defensive, + # I keep here the code to convert an eventual string to list + hostnames = module.params.get('hostnames') + count = module.params.get('count') + count_offset = module.params.get('count_offset') + if isinstance(hostnames, str): + hostnames = listify_string_name_or_id(hostnames) + if not isinstance(hostnames, list): + raise Exception("name %s is not convertible to list" % hostnames) + + # at this point, hostnames is a list + hostnames = [h.strip() for h in hostnames] + + if (len(hostnames) > 1) and (count > 1): + _msg = ("If you set count>1, you should only specify one hostname " + "with the %d formatter, not a list of hostnames.") + raise Exception(_msg) + + if (len(hostnames) == 1) and (count > 0): + hostname_spec = hostnames[0] + count_range = range(count_offset, count_offset + count) + if re.search("%\d{0,2}d", hostname_spec): + hostnames = [hostname_spec % i for i in count_range] + elif count > 1: + hostname_spec = '%s%%02d' % hostname_spec + hostnames = [hostname_spec % i for i in count_range] + + for hn in hostnames: + if not is_valid_hostname(hn): + raise Exception("Hostname '%s' does not seem to be valid" % hn) + + if len(hostnames) > MAX_DEVICES: + raise Exception("You specified too many devices, max is %d" % + MAX_DEVICES) + return hostnames + + +def get_device_id_list(module): + device_ids = module.params.get('device_ids') + + if isinstance(device_ids, str): + device_ids = listify_string_name_or_id(device_ids) + + device_ids = [di.strip() for di in device_ids] + + for di in device_ids: + if not is_valid_uuid(di): + raise Exception("Device ID '%s' does not seem to be valid" % di) + + if len(device_ids) > MAX_DEVICES: + raise Exception("You specified too many devices, max is %d" % + MAX_DEVICES) + return device_ids + + +def create_single_device(module, packet_conn, hostname): + + for param in ('hostnames', 'operating_system', 'plan'): + if not module.params.get(param): + raise Exception("%s parameter is required for new device." + % param) + project_id = module.params.get('project_id') + plan = module.params.get('plan') + user_data = module.params.get('user_data') + facility = module.params.get('facility') + locked = module.params.get('lock') + operating_system = module.params.get('operating_system') + locked = module.params.get('locked') + + device = packet_conn.create_device( + project_id=project_id, + hostname=hostname, + plan=plan, + facility=facility, + operating_system=operating_system, + userdata=user_data, + locked=locked) + return device + + +def wait_for_ips(module, packet_conn, created_devices): + + def has_public_ip(addr_list): + return any([a['public'] and (len(a['address']) > 0) for a in addr_list]) + + def all_have_public_ip(ds): + return all([has_public_ip(d.ip_addresses) for d in ds]) + + def refresh_created_devices(ids_of_created_devices, module, packet_conn): + new_device_list = get_existing_devices(module, packet_conn) + return [d for d in new_device_list if d.id in ids_of_created_devices] + + created_ids = [d.id for d in created_devices] + wait_timeout = module.params.get('wait_timeout') + wait_timeout = time.time() + wait_timeout + while wait_timeout > time.time(): + refreshed = refresh_created_devices(created_ids, module, + packet_conn) + if all_have_public_ip(refreshed): + return refreshed + time.sleep(5) + + raise Exception("Waiting for IP assignment timed out. Hostnames: %s" + % [d.hostname for d in created_devices]) + + +def get_existing_devices(module, packet_conn): + project_id = module.params.get('project_id') + return packet_conn.list_devices(project_id, params={'per_page': MAX_DEVICES}) + + +def get_specified_device_identifiers(module): + if module.params.get('device_ids'): + device_id_list = get_device_id_list(module) + return {'ids': device_id_list, 'hostnames': []} + elif module.params.get('hostnames'): + hostname_list = get_hostname_list(module) + return {'hostnames': hostname_list, 'ids': []} + + +def act_on_devices(target_state, module, packet_conn): + specified_identifiers = get_specified_device_identifiers(module) + existing_devices = get_existing_devices(module, packet_conn) + changed = False + create_hostnames = [] + if target_state in ['present', 'active', 'rebooted']: + # states where we might create non-existing specified devices + existing_devices_names = [ed.hostname for ed in existing_devices] + create_hostnames = [hn for hn in specified_identifiers['hostnames'] + if hn not in existing_devices_names] + + process_devices = [d for d in existing_devices + if (d.id in specified_identifiers['ids']) or + (d.hostname in specified_identifiers['hostnames'])] + + if target_state != 'present': + _absent_state_map = {} + for s in PACKET_DEVICE_STATES: + _absent_state_map[s] = packet.Device.delete + + state_map = { + 'absent': _absent_state_map, + 'active': {'inactive': packet.Device.power_on}, + 'inactive': {'active': packet.Device.power_off}, + 'rebooted': {'active': packet.Device.reboot, + 'inactive': packet.Device.power_on}, + } + + # First do non-creation actions, it might be faster + for d in process_devices: + if d.state in state_map[target_state]: + api_operation = state_map[target_state].get(d.state) + try: + api_operation(d) + changed = True + except Exception as e: + _msg = ("while trying to make device %s, id %s %s, from state %s, " + "with api call by %s got error: %s" % + (d.hostname, d.id, target_state, d.state, api_operation, e)) + raise Exception(_msg) + else: + _msg = ("I don't know how to process existing device %s from state %s " + "to state %s" % (d.hostname, d.state, target_state)) + raise Exception(_msg) + + # At last create missing devices + created_devices = [] + if create_hostnames: + created_devices = [create_single_device(module, packet_conn, n) + for n in create_hostnames] + if module.params.get('wait'): + created_devices = wait_for_ips(module, packet_conn, created_devices) + changed = True + + processed_devices = created_devices + process_devices + + return { + 'changed': changed, + 'devices': [serialize_device(d) for d in processed_devices] + } + + +def main(): + module = AnsibleModule( + argument_spec=dict( + auth_token=dict(default=os.environ.get(PACKET_API_TOKEN_ENV_VAR), + no_log=True), + count=dict(type='int', default=1), + count_offset=dict(type='int', default=1), + device_ids=dict(type='list'), + facility=dict(default='ewr1'), + features=dict(type='dict'), + hostnames=dict(type='list', aliases=['name']), + locked=dict(type='bool', default=False), + operating_system=dict(), + plan=dict(), + project_id=dict(required=True), + state=dict(choices=ALLOWED_STATES, default='present'), + user_data=dict(default=None), + wait=dict(type='bool', default=False), + wait_timeout=dict(type='int', default=60), + + ), + required_one_of=[('device_ids','hostnames',)], + mutually_exclusive=[ + ('hostnames', 'device_ids'), + ('count', 'device_ids'), + ('count_offset', 'device_ids'), + ] + ) + + if not HAS_PACKET_SDK: + module.fail_json(msg='packet required for this module') + + if not module.params.get('auth_token'): + _fail_msg = ( "if Packet API token is not in environment variable %s, " + "the auth_token parameter is required" % + PACKET_API_TOKEN_ENV_VAR) + module.fail_json(msg=_fail_msg) + + auth_token = module.params.get('auth_token') + + packet_conn = packet.Manager(auth_token=auth_token) + + state = module.params.get('state') + + try: + module.exit_json(**act_on_devices(state, module, packet_conn)) + except Exception as e: + module.fail_json(msg='failed to set machine state %s, error: %s' % (state,str(e))) + + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/cloud/packet/packet_sshkey.py b/lib/ansible/modules/cloud/packet/packet_sshkey.py new file mode 100644 index 00000000000..db822648698 --- /dev/null +++ b/lib/ansible/modules/cloud/packet/packet_sshkey.py @@ -0,0 +1,274 @@ +#!/usr/bin/python +# Copyright 2016 Tomas Karasek +# +# 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: packet_sshkey +short_description: Create/delete an SSH key in Packet host. +description: + - Create/delete an SSH key in Packet host. + - API is documented at U(https://www.packet.net/help/api/#page:ssh-keys,header:ssh-keys-ssh-keys-post). +version_added: "2.3" +author: "Tomas Karasek " +options: + state: + description: + - Indicate desired state of the target. + default: present + choices: ['present', 'absent'] + auth_token: + description: + - Packet api token. You can also supply it in env var C(PACKET_API_TOKEN). + label: + description: + - Label for the key. If you keep it empty, it will be read from key string. + id: + description: + - UUID of the key which you want to remove. + fingerprint: + description: + - Fingerprint of the key which you want to remove. + key: + description: + - Public Key string ({type} {base64 encoded key} {description}). + key_file: + description: + - File with the public key. + +requirements: + - "python >= 2.6" + - packet-python + +''' + +EXAMPLES = ''' +# All the examples assume that you have your Packet API token in env var PACKET_API_TOKEN. +# You can also pass the api token in module param auth_token. + +- name: create sshkey from string + hosts: localhost + tasks: + packet_sshkey: + key: ssh-dss AAAAB3NzaC1kc3MAAACBAIfNT5S0ncP4BBJBYNhNPxFF9lqVhfPeu6SM1LoCocxqDc1AT3zFRi8hjIf6TLZ2AA4FYbcAWxLMhiBxZRVldT9GdBXile78kAK5z3bKTwq152DCqpxwwbaTIggLFhsU8wrfBsPWnDuAxZ0h7mmrCjoLIE3CNLDA/NmV3iB8xMThAAAAFQCStcesSgR1adPORzBxTr7hug92LwAAAIBOProm3Gk+HWedLyE8IfofLaOeRnbBRHAOL4z0SexKkVOnQ/LGN/uDIIPGGBDYTvXgKZT+jbHeulRJ2jKgfSpGKN4JxFQ8uzVH492jEiiUJtT72Ss1dCV4PmyERVIw+f54itihV3z/t25dWgowhb0int8iC/OY3cGodlmYb3wdcQAAAIBuLbB45djZXzUkOTzzcRDIRfhaxo5WipbtEM2B1fuBt2gyrvksPpH/LK6xTjdIIb0CxPu4OCxwJG0aOz5kJoRnOWIXQGhH7VowrJhsqhIc8gN9ErbO5ea8b1L76MNcAotmBDeTUiPw01IJ8MdDxfmcsCslJKgoRKSmQpCwXQtN2g== tomk@hp2 + +- name: create sshkey from file + hosts: localhost + tasks: + packet_sshkey: + label: key from file + key_file: ~/ff.pub + +- name: remove sshkey by id + hosts: localhost + tasks: + packet_sshkey: + state: absent + id: eef49903-7a09-4ca1-af67-4087c29ab5b6 +''' + +RETURN = ''' +changed: + description: True if a sshkey was created or removed. + type: bool + sample: True + returned: always +sshkeys: + description: Information about sshkeys that were createe/removed. + type: array + sample: [ + { + "fingerprint": "5c:93:74:7c:ed:07:17:62:28:75:79:23:d6:08:93:46", + "id": "41d61bd8-3342-428b-a09c-e67bdd18a9b7", + "key": "ssh-dss AAAAB3NzaC1kc3MAAACBAIfNT5S0ncP4BBJBYNhNPxFF9lqVhfPeu6SM1LoCocxqDc1AT3zFRi8hjIf6TLZ2AA4FYbcAWxLMhiBxZRVldT9GdBXile78kAK5z3bKTwq152DCqpxwwbaTIggLFhsU8wrfBsPWnDuAxZ0h7mmrCjoLIE3CNLDA/NmV3iB8xMThAAAAFQCStcesSgR1adPORzBxTr7hug92LwAAAIBOProm3Gk+HWedLyE8IfofLaOeRnbBRHAOL4z0SexKkVOnQ/LGN/uDIIPGGBDYTvXgKZT+jbHeulRJ2jKgfSpGKN4JxFQ8uzVH492jEiiUJtT72Ss1dCV4PmyERVIw+f54itihV3z/t25dWgowhb0int8iC/OY3cGodlmYb3wdcQAAAIBuLbB45djZXzUkOTzzcRDIRfhaxo5WipbtEM2B1fuBt2gyrvksPpH/LK6xTjdIIb0CxPu4OCxwJG0aOz5kJoRnOWIXQGhH7VowrJhsqhIc8gN9ErbO5ea8b1L76MNcAotmBDeTUiPw01IJ8MdDxfmcsCslJKgoRKSmQpCwXQtN2g== tomk@hp2", + "label": "mynewkey33" + } + ] + returned: always +''' + +import os +import uuid + +from ansible.module_utils.basic import AnsibleModule + +HAS_PACKET_SDK = True + + +try: + import packet +except ImportError: + HAS_PACKET_SDK = False + + +PACKET_API_TOKEN_ENV_VAR = "PACKET_API_TOKEN" + + +def serialize_sshkey(sshkey): + sshkey_data = {} + copy_keys = ['id', 'key', 'label','fingerprint'] + for name in copy_keys: + sshkey_data[name] = getattr(sshkey, name) + return sshkey_data + + +def is_valid_uuid(myuuid): + try: + val = uuid.UUID(myuuid, version=4) + except ValueError: + return False + return str(val) == myuuid + + +def load_key_string(key_str): + ret_dict = {} + key_str = key_str.strip() + ret_dict['key'] = key_str + cut_key = key_str.split() + if len(cut_key) in [2,3]: + if len(cut_key) == 3: + ret_dict['label'] = cut_key[2] + else: + raise Exception("Public key %s is in wrong format" % key_str) + return ret_dict + + +def get_sshkey_selector(module): + key_id = module.params.get('id') + if key_id: + if not is_valid_uuid(key_id): + raise Exception("sshkey ID %s is not valid UUID" % key_id) + selecting_fields = ['label', 'fingerprint', 'id', 'key'] + select_dict = {} + for f in selecting_fields: + if module.params.get(f) is not None: + select_dict[f] = module.params.get(f) + + if module.params.get('key_file'): + with open(module.params.get('key_file')) as _file: + loaded_key = load_key_string(_file.read()) + select_dict['key'] = loaded_key['key'] + if module.params.get('label') is None: + if loaded_key.get('label'): + select_dict['label'] = loaded_key['label'] + + def selector(k): + if 'key' in select_dict: + # if key string is specified, compare only the key strings + return k.key == select_dict['key'] + else: + # if key string not specified, all the fields must match + return all([select_dict[f] == getattr(k,f) for f in select_dict]) + return selector + + +def act_on_sshkeys(target_state, module, packet_conn): + selector = get_sshkey_selector(module) + existing_sshkeys = packet_conn.list_ssh_keys() + matching_sshkeys = filter(selector, existing_sshkeys) + changed = False + if target_state == 'present': + if matching_sshkeys == []: + # there is no key matching the fields from module call + # => create the key, label and + newkey = {} + if module.params.get('key_file'): + with open(module.params.get('key_file')) as f: + newkey = load_key_string(f.read()) + if module.params.get('key'): + newkey = load_key_string(module.params.get('key')) + if module.params.get('label'): + newkey['label'] = module.params.get('label') + for param in ('label', 'key'): + if param not in newkey: + _msg=("If you want to ensure a key is present, you must " + "supply both a label and a key string, either in " + "module params, or in a key file. %s is missing" + % param) + raise Exception(_msg) + matching_sshkeys = [] + new_key_response = packet_conn.create_ssh_key( + newkey['label'], newkey['key']) + changed = True + + matching_sshkeys.append(new_key_response) + else: + # state is 'absent' => delete mathcing keys + for k in matching_sshkeys: + try: + k.delete() + changed = True + except Exception as e: + _msg = ("while trying to remove sshkey %s, id %s %s, " + "got error: %s" % + (k.label, k.id, target_state, e)) + raise Exception(_msg) + + return { + 'changed': changed, + 'sshkeys': [serialize_sshkey(k) for k in matching_sshkeys] + } + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state = dict(choices=['present', 'absent'], default='present'), + auth_token=dict(default=os.environ.get(PACKET_API_TOKEN_ENV_VAR), + no_log=True), + label=dict(type='str', aliases=['name'], default=None), + id=dict(type='str', default=None), + fingerprint=dict(type='str', default=None), + key=dict(type='str', default=None, no_log=True), + key_file=dict(type='path', default=None), + ), + mutually_exclusive=[ + ('label', 'id'), + ('label', 'fingerprint'), + ('id', 'fingerprint'), + ('key', 'fingerprint'), + ('key', 'id'), + ('key_file', 'key'), + ] + ) + + if not HAS_PACKET_SDK: + module.fail_json(msg='packet required for this module') + + if not module.params.get('auth_token'): + _fail_msg = ( "if Packet API token is not in environment variable %s, " + "the auth_token parameter is required" % + PACKET_API_TOKEN_ENV_VAR) + module.fail_json(msg=_fail_msg) + + auth_token = module.params.get('auth_token') + + packet_conn = packet.Manager(auth_token=auth_token) + + state = module.params.get('state') + + if state in ['present','absent']: + try: + module.exit_json(**act_on_sshkeys(state, module, packet_conn)) + except Exception as e: + module.fail_json(msg='failed to set sshkey state: %s' % str(e)) + else: + module.fail_json(msg='%s is not a valid state for this module' % state) + + +if __name__ == '__main__': + main()