1#!/usr/bin/python
2# (c) 2016, Tomas Karasek <tom.to.the.k@gmail.com>
3# (c) 2016, Matt Baldwin <baldwin@stackpointcloud.com>
4# (c) 2016, Thibaud Morel l'Horset <teebes@gmail.com>
5#
6# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
7
8from __future__ import absolute_import, division, print_function
9__metaclass__ = type
10
11
12ANSIBLE_METADATA = {'metadata_version': '1.1',
13                    'status': ['preview'],
14                    'supported_by': 'community'}
15
16DOCUMENTATION = '''
17---
18module: packet_device
19
20short_description: Manage a bare metal server in the Packet Host.
21
22description:
23    - Manage a bare metal server in the Packet Host (a "device" in the API terms).
24    - When the machine is created it can optionally wait for public IP address, or for active state.
25    - This module has a dependency on packet >= 1.0.
26    - API is documented at U(https://www.packet.net/developers/api/devices).
27
28version_added: "2.3"
29
30author:
31    - Tomas Karasek (@t0mk) <tom.to.the.k@gmail.com>
32    - Matt Baldwin (@baldwinSPC) <baldwin@stackpointcloud.com>
33    - Thibaud Morel l'Horset (@teebes) <teebes@gmail.com>
34
35options:
36  auth_token:
37    description:
38      - Packet api token. You can also supply it in env var C(PACKET_API_TOKEN).
39
40  count:
41    description:
42      - The number of devices to create. Count number can be included in hostname via the %d string formatter.
43    default: 1
44
45  count_offset:
46    description:
47      - From which number to start the count.
48    default: 1
49
50  device_ids:
51    description:
52      - List of device IDs on which to operate.
53
54  facility:
55    description:
56      - Facility slug for device creation. See Packet API for current list - U(https://www.packet.net/developers/api/facilities/).
57
58  features:
59    description:
60      - Dict with "features" for device creation. See Packet API docs for details.
61
62  hostnames:
63    description:
64      - A hostname of a device, or a list of hostnames.
65      - If given string or one-item list, you can use the C("%d") Python string format to expand numbers from I(count).
66      - If only one hostname, it might be expanded to list if I(count)>1.
67    aliases: [name]
68
69  locked:
70    description:
71      - Whether to lock a created device.
72    default: false
73    version_added: "2.4"
74    aliases: [lock]
75    type: bool
76
77  operating_system:
78    description:
79      - OS slug for device creation. See Packet API for current list - U(https://www.packet.net/developers/api/operatingsystems/).
80
81  plan:
82    description:
83      - Plan slug for device creation. See Packet API for current list - U(https://www.packet.net/developers/api/plans/).
84
85  project_id:
86    description:
87      - ID of project of the device.
88    required: true
89
90  state:
91    description:
92      - Desired state of the device.
93      - If set to C(present) (the default), the module call will return immediately after the device-creating HTTP request successfully returns.
94      - If set to C(active), the module call will block until all the specified devices are in state active due to the Packet API, or until I(wait_timeout).
95    choices: [present, absent, active, inactive, rebooted]
96    default: present
97
98  user_data:
99    description:
100      - Userdata blob made available to the machine
101
102  wait_for_public_IPv:
103    description:
104      - Whether to wait for the instance to be assigned a public IPv4/IPv6 address.
105      - If set to 4, it will wait until IPv4 is assigned to the instance.
106      - If set to 6, wait until public IPv6 is assigned to the instance.
107    choices: [4,6]
108    version_added: "2.4"
109
110  wait_timeout:
111    description:
112      - How long (seconds) to wait either for automatic IP address assignment, or for the device to reach the C(active) I(state).
113      - If I(wait_for_public_IPv) is set and I(state) is C(active), the module will wait for both events consequently, applying the timeout twice.
114    default: 900
115  ipxe_script_url:
116    description:
117      - URL of custom iPXE script for provisioning.
118      - More about custom iPXE for Packet devices at U(https://help.packet.net/technical/infrastructure/custom-ipxe).
119    version_added: "2.4"
120  always_pxe:
121    description:
122      - Persist PXE as the first boot option.
123      - Normally, the PXE process happens only on the first boot. Set this arg to have your device continuously boot to iPXE.
124    default: false
125    version_added: "2.4"
126    type: bool
127
128
129requirements:
130     - "packet-python >= 1.35"
131
132notes:
133     - Doesn't support check mode.
134
135'''
136
137EXAMPLES = '''
138# All the examples assume that you have your Packet api token in env var PACKET_API_TOKEN.
139# You can also pass it to the auth_token parameter of the module instead.
140
141# Creating devices
142
143- name: create 1 device
144  hosts: localhost
145  tasks:
146  - packet_device:
147      project_id: 89b497ee-5afc-420a-8fb5-56984898f4df
148      hostnames: myserver
149      operating_system: ubuntu_16_04
150      plan: baremetal_0
151      facility: sjc1
152
153# Create the same device and wait until it is in state "active", (when it's
154# ready for other API operations). Fail if the devices in not "active" in
155# 10 minutes.
156
157- name: create device and wait up to 10 minutes for active state
158  hosts: localhost
159  tasks:
160  - packet_device:
161      project_id: 89b497ee-5afc-420a-8fb5-56984898f4df
162      hostnames: myserver
163      operating_system: ubuntu_16_04
164      plan: baremetal_0
165      facility: sjc1
166      state: active
167      wait_timeout: 600
168
169- name: create 3 ubuntu devices called server-01, server-02 and server-03
170  hosts: localhost
171  tasks:
172  - packet_device:
173      project_id: 89b497ee-5afc-420a-8fb5-56984898f4df
174      hostnames: server-%02d
175      count: 3
176      operating_system: ubuntu_16_04
177      plan: baremetal_0
178      facility: sjc1
179
180- name: Create 3 coreos devices with userdata, wait until they get IPs and then wait for SSH
181  hosts: localhost
182  tasks:
183  - name: create 3 devices and register their facts
184    packet_device:
185      hostnames: [coreos-one, coreos-two, coreos-three]
186      operating_system: coreos_stable
187      plan: baremetal_0
188      facility: ewr1
189      locked: true
190      project_id: 89b497ee-5afc-420a-8fb5-56984898f4df
191      wait_for_public_IPv: 4
192      user_data: |
193        #cloud-config
194        ssh_authorized_keys:
195          - {{ lookup('file', 'my_packet_sshkey') }}
196        coreos:
197          etcd:
198            discovery: https://discovery.etcd.io/6a28e078895c5ec737174db2419bb2f3
199            addr: $private_ipv4:4001
200            peer-addr: $private_ipv4:7001
201          fleet:
202            public-ip: $private_ipv4
203          units:
204            - name: etcd.service
205              command: start
206            - name: fleet.service
207              command: start
208    register: newhosts
209
210  - name: wait for ssh
211    wait_for:
212      delay: 1
213      host: "{{ item.public_ipv4 }}"
214      port: 22
215      state: started
216      timeout: 500
217    with_items: "{{ newhosts.devices }}"
218
219
220# Other states of devices
221
222- name: remove 3 devices by uuid
223  hosts: localhost
224  tasks:
225  - packet_device:
226      project_id: 89b497ee-5afc-420a-8fb5-56984898f4df
227      state: absent
228      device_ids:
229        - 1fb4faf8-a638-4ac7-8f47-86fe514c30d8
230        - 2eb4faf8-a638-4ac7-8f47-86fe514c3043
231        - 6bb4faf8-a638-4ac7-8f47-86fe514c301f
232'''
233
234RETURN = '''
235changed:
236    description: True if a device was altered in any way (created, modified or removed)
237    type: bool
238    sample: True
239    returned: success
240
241devices:
242    description: Information about each device that was processed
243    type: list
244    sample: '[{"hostname": "my-server.com", "id": "2a5122b9-c323-4d5c-b53c-9ad3f54273e7",
245               "public_ipv4": "147.229.15.12", "private-ipv4": "10.0.15.12",
246               "tags": [], "locked": false, "state": "provisioning",
247               "public_ipv6": ""2604:1380:2:5200::3"}]'
248    returned: success
249'''  # NOQA
250
251
252import os
253import re
254import time
255import uuid
256import traceback
257
258from ansible.module_utils.basic import AnsibleModule
259from ansible.module_utils._text import to_native
260
261HAS_PACKET_SDK = True
262try:
263    import packet
264except ImportError:
265    HAS_PACKET_SDK = False
266
267from ansible.module_utils.basic import AnsibleModule
268
269
270NAME_RE = r'({0}|{0}{1}*{0})'.format(r'[a-zA-Z0-9]', r'[a-zA-Z0-9\-]')
271HOSTNAME_RE = r'({0}\.)*{0}$'.format(NAME_RE)
272MAX_DEVICES = 100
273
274PACKET_DEVICE_STATES = (
275    'queued',
276    'provisioning',
277    'failed',
278    'powering_on',
279    'active',
280    'powering_off',
281    'inactive',
282    'rebooting',
283)
284
285PACKET_API_TOKEN_ENV_VAR = "PACKET_API_TOKEN"
286
287
288ALLOWED_STATES = ['absent', 'active', 'inactive', 'rebooted', 'present']
289
290
291def serialize_device(device):
292    """
293    Standard representation for a device as returned by various tasks::
294
295        {
296            'id': 'device_id'
297            'hostname': 'device_hostname',
298            'tags': [],
299            'locked': false,
300            'state': 'provisioning',
301            'ip_addresses': [
302                {
303                    "address": "147.75.194.227",
304                    "address_family": 4,
305                    "public": true
306                },
307                {
308                    "address": "2604:1380:2:5200::3",
309                    "address_family": 6,
310                    "public": true
311                },
312                {
313                    "address": "10.100.11.129",
314                    "address_family": 4,
315                    "public": false
316                }
317            ],
318            "private_ipv4": "10.100.11.129",
319            "public_ipv4": "147.75.194.227",
320            "public_ipv6": "2604:1380:2:5200::3",
321        }
322
323    """
324    device_data = {}
325    device_data['id'] = device.id
326    device_data['hostname'] = device.hostname
327    device_data['tags'] = device.tags
328    device_data['locked'] = device.locked
329    device_data['state'] = device.state
330    device_data['ip_addresses'] = [
331        {
332            'address': addr_data['address'],
333            'address_family': addr_data['address_family'],
334            'public': addr_data['public'],
335        }
336        for addr_data in device.ip_addresses
337    ]
338    # Also include each IPs as a key for easier lookup in roles.
339    # Key names:
340    # - public_ipv4
341    # - public_ipv6
342    # - private_ipv4
343    # - private_ipv6 (if there is one)
344    for ipdata in device_data['ip_addresses']:
345        if ipdata['public']:
346            if ipdata['address_family'] == 6:
347                device_data['public_ipv6'] = ipdata['address']
348            elif ipdata['address_family'] == 4:
349                device_data['public_ipv4'] = ipdata['address']
350        elif not ipdata['public']:
351            if ipdata['address_family'] == 6:
352                # Packet doesn't give public ipv6 yet, but maybe one
353                # day they will
354                device_data['private_ipv6'] = ipdata['address']
355            elif ipdata['address_family'] == 4:
356                device_data['private_ipv4'] = ipdata['address']
357    return device_data
358
359
360def is_valid_hostname(hostname):
361    return re.match(HOSTNAME_RE, hostname) is not None
362
363
364def is_valid_uuid(myuuid):
365    try:
366        val = uuid.UUID(myuuid, version=4)
367    except ValueError:
368        return False
369    return str(val) == myuuid
370
371
372def listify_string_name_or_id(s):
373    if ',' in s:
374        return s.split(',')
375    else:
376        return [s]
377
378
379def get_hostname_list(module):
380    # hostname is a list-typed param, so I guess it should return list
381    # (and it does, in Ansible 2.2.1) but in order to be defensive,
382    # I keep here the code to convert an eventual string to list
383    hostnames = module.params.get('hostnames')
384    count = module.params.get('count')
385    count_offset = module.params.get('count_offset')
386    if isinstance(hostnames, str):
387        hostnames = listify_string_name_or_id(hostnames)
388    if not isinstance(hostnames, list):
389        raise Exception("name %s is not convertible to list" % hostnames)
390
391    # at this point, hostnames is a list
392    hostnames = [h.strip() for h in hostnames]
393
394    if (len(hostnames) > 1) and (count > 1):
395        _msg = ("If you set count>1, you should only specify one hostname "
396                "with the %d formatter, not a list of hostnames.")
397        raise Exception(_msg)
398
399    if (len(hostnames) == 1) and (count > 0):
400        hostname_spec = hostnames[0]
401        count_range = range(count_offset, count_offset + count)
402        if re.search(r"%\d{0,2}d", hostname_spec):
403            hostnames = [hostname_spec % i for i in count_range]
404        elif count > 1:
405            hostname_spec = '%s%%02d' % hostname_spec
406            hostnames = [hostname_spec % i for i in count_range]
407
408    for hn in hostnames:
409        if not is_valid_hostname(hn):
410            raise Exception("Hostname '%s' does not seem to be valid" % hn)
411
412    if len(hostnames) > MAX_DEVICES:
413        raise Exception("You specified too many hostnames, max is %d" %
414                        MAX_DEVICES)
415    return hostnames
416
417
418def get_device_id_list(module):
419    device_ids = module.params.get('device_ids')
420
421    if isinstance(device_ids, str):
422        device_ids = listify_string_name_or_id(device_ids)
423
424    device_ids = [di.strip() for di in device_ids]
425
426    for di in device_ids:
427        if not is_valid_uuid(di):
428            raise Exception("Device ID '%s' does not seem to be valid" % di)
429
430    if len(device_ids) > MAX_DEVICES:
431        raise Exception("You specified too many devices, max is %d" %
432                        MAX_DEVICES)
433    return device_ids
434
435
436def create_single_device(module, packet_conn, hostname):
437
438    for param in ('hostnames', 'operating_system', 'plan'):
439        if not module.params.get(param):
440            raise Exception("%s parameter is required for new device."
441                            % param)
442    project_id = module.params.get('project_id')
443    plan = module.params.get('plan')
444    user_data = module.params.get('user_data')
445    facility = module.params.get('facility')
446    operating_system = module.params.get('operating_system')
447    locked = module.params.get('locked')
448    ipxe_script_url = module.params.get('ipxe_script_url')
449    always_pxe = module.params.get('always_pxe')
450    if operating_system != 'custom_ipxe':
451        for param in ('ipxe_script_url', 'always_pxe'):
452            if module.params.get(param):
453                raise Exception('%s parameter is not valid for non custom_ipxe operating_system.' % param)
454
455    device = packet_conn.create_device(
456        project_id=project_id,
457        hostname=hostname,
458        plan=plan,
459        facility=facility,
460        operating_system=operating_system,
461        userdata=user_data,
462        locked=locked)
463    return device
464
465
466def refresh_device_list(module, packet_conn, devices):
467    device_ids = [d.id for d in devices]
468    new_device_list = get_existing_devices(module, packet_conn)
469    return [d for d in new_device_list if d.id in device_ids]
470
471
472def wait_for_devices_active(module, packet_conn, watched_devices):
473    wait_timeout = module.params.get('wait_timeout')
474    wait_timeout = time.time() + wait_timeout
475    refreshed = watched_devices
476    while wait_timeout > time.time():
477        refreshed = refresh_device_list(module, packet_conn, watched_devices)
478        if all(d.state == 'active' for d in refreshed):
479            return refreshed
480        time.sleep(5)
481    raise Exception("Waiting for state \"active\" timed out for devices: %s"
482                    % [d.hostname for d in refreshed if d.state != "active"])
483
484
485def wait_for_public_IPv(module, packet_conn, created_devices):
486
487    def has_public_ip(addr_list, ip_v):
488        return any([a['public'] and a['address_family'] == ip_v and
489                    a['address'] for a in addr_list])
490
491    def all_have_public_ip(ds, ip_v):
492        return all([has_public_ip(d.ip_addresses, ip_v) for d in ds])
493
494    address_family = module.params.get('wait_for_public_IPv')
495
496    wait_timeout = module.params.get('wait_timeout')
497    wait_timeout = time.time() + wait_timeout
498    while wait_timeout > time.time():
499        refreshed = refresh_device_list(module, packet_conn, created_devices)
500        if all_have_public_ip(refreshed, address_family):
501            return refreshed
502        time.sleep(5)
503
504    raise Exception("Waiting for IPv%d address timed out. Hostnames: %s"
505                    % (address_family, [d.hostname for d in created_devices]))
506
507
508def get_existing_devices(module, packet_conn):
509    project_id = module.params.get('project_id')
510    return packet_conn.list_devices(
511        project_id, params={
512            'per_page': MAX_DEVICES})
513
514
515def get_specified_device_identifiers(module):
516    if module.params.get('device_ids'):
517        device_id_list = get_device_id_list(module)
518        return {'ids': device_id_list, 'hostnames': []}
519    elif module.params.get('hostnames'):
520        hostname_list = get_hostname_list(module)
521        return {'hostnames': hostname_list, 'ids': []}
522
523
524def act_on_devices(module, packet_conn, target_state):
525    specified_identifiers = get_specified_device_identifiers(module)
526    existing_devices = get_existing_devices(module, packet_conn)
527    changed = False
528    create_hostnames = []
529    if target_state in ['present', 'active', 'rebooted']:
530        # states where we might create non-existing specified devices
531        existing_devices_names = [ed.hostname for ed in existing_devices]
532        create_hostnames = [hn for hn in specified_identifiers['hostnames']
533                            if hn not in existing_devices_names]
534
535    process_devices = [d for d in existing_devices
536                       if (d.id in specified_identifiers['ids']) or
537                       (d.hostname in specified_identifiers['hostnames'])]
538
539    if target_state != 'present':
540        _absent_state_map = {}
541        for s in PACKET_DEVICE_STATES:
542            _absent_state_map[s] = packet.Device.delete
543
544        state_map = {
545            'absent': _absent_state_map,
546            'active': {'inactive': packet.Device.power_on,
547                       'provisioning': None, 'rebooting': None
548                       },
549            'inactive': {'active': packet.Device.power_off},
550            'rebooted': {'active': packet.Device.reboot,
551                         'inactive': packet.Device.power_on,
552                         'provisioning': None, 'rebooting': None
553                         },
554        }
555
556        # First do non-creation actions, it might be faster
557        for d in process_devices:
558            if d.state == target_state:
559                continue
560            if d.state in state_map[target_state]:
561                api_operation = state_map[target_state].get(d.state)
562                if api_operation is not None:
563                    api_operation(d)
564                    changed = True
565            else:
566                _msg = (
567                    "I don't know how to process existing device %s from state %s "
568                    "to state %s" %
569                    (d.hostname, d.state, target_state))
570                raise Exception(_msg)
571
572    # At last create missing devices
573    created_devices = []
574    if create_hostnames:
575        created_devices = [create_single_device(module, packet_conn, n)
576                           for n in create_hostnames]
577        if module.params.get('wait_for_public_IPv'):
578            created_devices = wait_for_public_IPv(
579                module, packet_conn, created_devices)
580        changed = True
581
582    processed_devices = created_devices + process_devices
583    if target_state == 'active':
584        processed_devices = wait_for_devices_active(
585            module, packet_conn, processed_devices)
586
587    return {
588        'changed': changed,
589        'devices': [serialize_device(d) for d in processed_devices]
590    }
591
592
593def main():
594    module = AnsibleModule(
595        argument_spec=dict(
596            auth_token=dict(default=os.environ.get(PACKET_API_TOKEN_ENV_VAR),
597                            no_log=True),
598            count=dict(type='int', default=1),
599            count_offset=dict(type='int', default=1),
600            device_ids=dict(type='list'),
601            facility=dict(),
602            features=dict(type='dict'),
603            hostnames=dict(type='list', aliases=['name']),
604            locked=dict(type='bool', default=False, aliases=['lock']),
605            operating_system=dict(),
606            plan=dict(),
607            project_id=dict(required=True),
608            state=dict(choices=ALLOWED_STATES, default='present'),
609            user_data=dict(default=None),
610            wait_for_public_IPv=dict(type='int', choices=[4, 6]),
611            wait_timeout=dict(type='int', default=900),
612            ipxe_script_url=dict(default=''),
613            always_pxe=dict(type='bool', default=False),
614        ),
615        required_one_of=[('device_ids', 'hostnames',)],
616        mutually_exclusive=[
617            ('hostnames', 'device_ids'),
618            ('count', 'device_ids'),
619            ('count_offset', 'device_ids'),
620        ]
621    )
622
623    if not HAS_PACKET_SDK:
624        module.fail_json(msg='packet required for this module')
625
626    if not module.params.get('auth_token'):
627        _fail_msg = ("if Packet API token is not in environment variable %s, "
628                     "the auth_token parameter is required" %
629                     PACKET_API_TOKEN_ENV_VAR)
630        module.fail_json(msg=_fail_msg)
631
632    auth_token = module.params.get('auth_token')
633
634    packet_conn = packet.Manager(auth_token=auth_token)
635
636    state = module.params.get('state')
637
638    try:
639        module.exit_json(**act_on_devices(module, packet_conn, state))
640    except Exception as e:
641        module.fail_json(msg='failed to set device state %s, error: %s' %
642                         (state, to_native(e)), exception=traceback.format_exc())
643
644
645if __name__ == '__main__':
646    main()
647