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