1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3# 4# Copyright: Ansible Project 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8 9__metaclass__ = type 10 11DOCUMENTATION = r""" 12--- 13module: digital_ocean_droplet 14short_description: Create and delete a DigitalOcean droplet 15description: 16 - Create and delete a droplet in DigitalOcean and optionally wait for it to be active. 17author: "Gurchet Rai (@gurch101)" 18options: 19 state: 20 description: 21 - Indicate desired state of the target. 22 - C(present) will create the named droplet; be mindful of the C(unique_name) parameter. 23 - C(absent) will delete the named droplet, if it exists. 24 - C(active) will create the named droplet (unless it exists) and ensure that it is powered on. 25 - C(inactive) will create the named droplet (unless it exists) and ensure that it is powered off. 26 default: present 27 choices: ['present', 'absent', 'active', 'inactive'] 28 type: str 29 id: 30 description: 31 - Numeric, the droplet id you want to operate on. 32 aliases: ['droplet_id'] 33 type: int 34 name: 35 description: 36 - String, this is the name of the droplet - must be formatted by hostname rules. 37 type: str 38 unique_name: 39 description: 40 - require unique hostnames. By default, DigitalOcean allows multiple hosts with the same name. Setting this to "yes" allows only one host 41 per name. Useful for idempotence. 42 default: False 43 type: bool 44 size: 45 description: 46 - This is the slug of the size you would like the droplet created with. 47 aliases: ['size_id'] 48 type: str 49 image: 50 description: 51 - This is the slug of the image you would like the droplet created with. 52 aliases: ['image_id'] 53 type: str 54 region: 55 description: 56 - This is the slug of the region you would like your server to be created in. 57 aliases: ['region_id'] 58 type: str 59 ssh_keys: 60 description: 61 - array of SSH key Fingerprint that you would like to be added to the server. 62 required: False 63 type: list 64 elements: str 65 private_networking: 66 description: 67 - add an additional, private network interface to droplet for inter-droplet communication. 68 default: False 69 type: bool 70 vpc_uuid: 71 description: 72 - A string specifying the UUID of the VPC to which the Droplet will be assigned. If excluded, Droplet will be 73 assigned to the account's default VPC for the region. 74 type: str 75 version_added: 0.1.0 76 user_data: 77 description: 78 - opaque blob of data which is made available to the droplet 79 required: False 80 type: str 81 ipv6: 82 description: 83 - enable IPv6 for your droplet. 84 required: False 85 default: False 86 type: bool 87 wait: 88 description: 89 - Wait for the droplet to be active before returning. If wait is "no" an ip_address may not be returned. 90 required: False 91 default: True 92 type: bool 93 wait_timeout: 94 description: 95 - How long before wait gives up, in seconds, when creating a droplet. 96 default: 120 97 type: int 98 backups: 99 description: 100 - indicates whether automated backups should be enabled. 101 required: False 102 default: False 103 type: bool 104 monitoring: 105 description: 106 - indicates whether to install the DigitalOcean agent for monitoring. 107 required: False 108 default: False 109 type: bool 110 tags: 111 description: 112 - List, A list of tag names as strings to apply to the Droplet after it is created. Tag names can either be existing or new tags. 113 required: False 114 type: list 115 elements: str 116 volumes: 117 description: 118 - List, A list including the unique string identifier for each Block Storage volume to be attached to the Droplet. 119 required: False 120 type: list 121 elements: str 122 oauth_token: 123 description: 124 - DigitalOcean OAuth token. Can be specified in C(DO_API_KEY), C(DO_API_TOKEN), or C(DO_OAUTH_TOKEN) environment variables 125 aliases: ['API_TOKEN'] 126 type: str 127 required: true 128 resize_disk: 129 description: 130 - Whether to increase disk size (only consulted if the C(unique_name) is C(True) and C(size) dictates an increase) 131 required: False 132 default: False 133 type: bool 134""" 135 136 137EXAMPLES = r""" 138- name: Create a new droplet 139 community.digitalocean.digital_ocean_droplet: 140 state: present 141 name: mydroplet 142 oauth_token: XXX 143 size: 2gb 144 region: sfo1 145 image: ubuntu-16-04-x64 146 wait_timeout: 500 147 ssh_keys: [ .... ] 148 register: my_droplet 149 150- debug: 151 msg: "ID is {{ my_droplet.data.droplet.id }}, IP is {{ my_droplet.data.ip_address }}" 152 153- name: Ensure a droplet is present 154 community.digitalocean.digital_ocean_droplet: 155 state: present 156 id: 123 157 name: mydroplet 158 oauth_token: XXX 159 size: 2gb 160 region: sfo1 161 image: ubuntu-16-04-x64 162 wait_timeout: 500 163 164- name: Ensure a droplet is present with SSH keys installed 165 community.digitalocean.digital_ocean_droplet: 166 state: present 167 id: 123 168 name: mydroplet 169 oauth_token: XXX 170 size: 2gb 171 region: sfo1 172 ssh_keys: ['1534404', '1784768'] 173 image: ubuntu-16-04-x64 174 wait_timeout: 500 175""" 176 177RETURN = r""" 178# Digital Ocean API info https://developers.digitalocean.com/documentation/v2/#droplets 179data: 180 description: a DigitalOcean Droplet 181 returned: changed 182 type: dict 183 sample: { 184 "ip_address": "104.248.118.172", 185 "ipv6_address": "2604:a880:400:d1::90a:6001", 186 "private_ipv4_address": "10.136.122.141", 187 "droplet": { 188 "id": 3164494, 189 "name": "example.com", 190 "memory": 512, 191 "vcpus": 1, 192 "disk": 20, 193 "locked": true, 194 "status": "new", 195 "kernel": { 196 "id": 2233, 197 "name": "Ubuntu 14.04 x64 vmlinuz-3.13.0-37-generic", 198 "version": "3.13.0-37-generic" 199 }, 200 "created_at": "2014-11-14T16:36:31Z", 201 "features": ["virtio"], 202 "backup_ids": [], 203 "snapshot_ids": [], 204 "image": {}, 205 "volume_ids": [], 206 "size": {}, 207 "size_slug": "512mb", 208 "networks": {}, 209 "region": {}, 210 "tags": ["web"] 211 } 212 } 213""" 214 215import time 216import json 217from ansible.module_utils.basic import AnsibleModule, env_fallback 218from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( 219 DigitalOceanHelper, 220) 221 222 223class DODroplet(object): 224 def __init__(self, module): 225 self.rest = DigitalOceanHelper(module) 226 self.module = module 227 self.wait = self.module.params.pop("wait", True) 228 self.wait_timeout = self.module.params.pop("wait_timeout", 120) 229 self.unique_name = self.module.params.pop("unique_name", False) 230 # pop the oauth token so we don't include it in the POST data 231 self.module.params.pop("oauth_token") 232 self.id = None 233 self.name = None 234 self.size = None 235 self.status = None 236 237 def get_by_id(self, droplet_id): 238 if not droplet_id: 239 return None 240 response = self.rest.get("droplets/{0}".format(droplet_id)) 241 json_data = response.json 242 if json_data is None: 243 self.module.fail_json( 244 changed=False, 245 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 246 ) 247 else: 248 if response.status_code == 200: 249 droplet = json_data.get("droplet", None) 250 if droplet is not None: 251 self.id = droplet.get("id", None) 252 self.name = droplet.get("name", None) 253 self.size = droplet.get("size_slug", None) 254 self.status = droplet.get("status", None) 255 return json_data 256 return None 257 258 def get_by_name(self, droplet_name): 259 if not droplet_name: 260 return None 261 page = 1 262 while page is not None: 263 response = self.rest.get("droplets?page={0}".format(page)) 264 json_data = response.json 265 if json_data is None: 266 self.module.fail_json( 267 changed=False, 268 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 269 ) 270 else: 271 if response.status_code == 200: 272 droplets = json_data.get("droplets", []) 273 for droplet in droplets: 274 if droplet.get("name", None) == droplet_name: 275 self.id = droplet.get("id", None) 276 self.name = droplet.get("name", None) 277 self.size = droplet.get("size_slug", None) 278 self.status = droplet.get("status", None) 279 return {"droplet": droplet} 280 if ( 281 "links" in json_data 282 and "pages" in json_data["links"] 283 and "next" in json_data["links"]["pages"] 284 ): 285 page += 1 286 else: 287 page = None 288 return None 289 290 def get_addresses(self, data): 291 """Expose IP addresses as their own property allowing users extend to additional tasks""" 292 _data = data 293 for k, v in data.items(): 294 setattr(self, k, v) 295 networks = _data["droplet"]["networks"] 296 for network in networks.get("v4", []): 297 if network["type"] == "public": 298 _data["ip_address"] = network["ip_address"] 299 else: 300 _data["private_ipv4_address"] = network["ip_address"] 301 for network in networks.get("v6", []): 302 if network["type"] == "public": 303 _data["ipv6_address"] = network["ip_address"] 304 else: 305 _data["private_ipv6_address"] = network["ip_address"] 306 return _data 307 308 def get_droplet(self): 309 json_data = self.get_by_id(self.module.params["id"]) 310 if not json_data and self.unique_name: 311 json_data = self.get_by_name(self.module.params["name"]) 312 return json_data 313 314 def resize_droplet(self, state): 315 """API reference: https://developers.digitalocean.com/documentation/v2/#resize-a-droplet (Must be powered off)""" 316 if self.status == "off": 317 response = self.rest.post( 318 "droplets/{0}/actions".format(self.id), 319 data={ 320 "type": "resize", 321 "disk": self.module.params["resize_disk"], 322 "size": self.module.params["size"], 323 }, 324 ) 325 json_data = response.json 326 if json_data is None: 327 self.module.fail_json( 328 changed=False, 329 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 330 ) 331 else: 332 if response.status_code == 201: 333 if state == "active": 334 self.ensure_power_on(self.id) 335 self.module.exit_json( 336 changed=True, 337 msg="Resized Droplet {0} ({1}) from {2} to {3}".format( 338 self.name, self.id, self.size, self.module.params["size"] 339 ), 340 ) 341 else: 342 self.module.fail_json( 343 msg="Resizing Droplet {0} ({1}) failed [HTTP {2}: {3}]".format( 344 self.name, 345 self.id, 346 response.status_code, 347 response.json.get("message", None), 348 ) 349 ) 350 else: 351 self.module.fail_json( 352 msg="Droplet must be off prior to resizing (https://developers.digitalocean.com/documentation/v2/#resize-a-droplet)" 353 ) 354 355 def create(self, state): 356 json_data = self.get_droplet() 357 droplet_data = None 358 if json_data is not None: 359 droplet = json_data.get("droplet", None) 360 if droplet is not None: 361 droplet_size = droplet.get("size_slug", None) 362 if droplet_size is not None: 363 if droplet_size != self.module.params["size"]: 364 self.resize_droplet(state) 365 droplet_data = self.get_addresses(json_data) 366 # If state is active or inactive, ensure requested and desired power states match 367 droplet = json_data.get("droplet", None) 368 if droplet is not None: 369 droplet_id = droplet.get("id", None) 370 droplet_status = droplet.get("status", None) 371 if droplet_id is not None and droplet_status is not None: 372 if state == "active" and droplet_status != "active": 373 power_on_json_data = self.ensure_power_on(droplet_id) 374 self.module.exit_json( 375 changed=True, data=self.get_addresses(power_on_json_data) 376 ) 377 elif state == "inactive" and droplet_status != "off": 378 power_off_json_data = self.ensure_power_off(droplet_id) 379 self.module.exit_json( 380 changed=True, data=self.get_addresses(power_off_json_data) 381 ) 382 else: 383 self.module.exit_json(changed=False, data=droplet_data) 384 if self.module.check_mode: 385 self.module.exit_json(changed=True) 386 request_params = dict(self.module.params) 387 del request_params["id"] 388 response = self.rest.post("droplets", data=request_params) 389 json_data = response.json 390 if json_data is None: 391 self.module.fail_json( 392 changed=False, 393 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 394 ) 395 else: 396 if response.status_code >= 400: 397 message = json_data.get( 398 "message", "Empty failure message from the DigitalOcean API!" 399 ) 400 self.module.fail_json(changed=False, msg=message) 401 droplet_data = json_data.get("droplet", None) 402 if droplet_data is not None: 403 droplet_id = droplet_data.get("id", None) 404 if droplet_id is not None: 405 if self.wait: 406 if state == "present" or state == "active": 407 json_data = self.ensure_power_on(droplet_id) 408 if state == "inactive": 409 json_data = self.ensure_power_off(droplet_id) 410 droplet_data = self.get_addresses(json_data) 411 else: 412 if state == "inactive": 413 response = self.rest.post( 414 "droplets/{0}/actions".format(droplet_id), 415 data={"type": "power_off"}, 416 ) 417 else: 418 self.module.fail_json( 419 changed=False, msg="Unexpected error, please file a bug" 420 ) 421 else: 422 self.module.fail_json( 423 changed=False, msg="Unexpected error, please file a bug" 424 ) 425 self.module.exit_json(changed=True, data=droplet_data) 426 427 def delete(self): 428 json_data = self.get_droplet() 429 if json_data: 430 if self.module.check_mode: 431 self.module.exit_json(changed=True) 432 response = self.rest.delete( 433 "droplets/{0}".format(json_data["droplet"]["id"]) 434 ) 435 json_data = response.json 436 if response.status_code == 204: 437 self.module.exit_json(changed=True, msg="Droplet deleted") 438 self.module.fail_json(changed=False, msg="Failed to delete droplet") 439 else: 440 self.module.exit_json(changed=False, msg="Droplet not found") 441 442 def ensure_power_on(self, droplet_id): 443 444 # Make sure Droplet is active first 445 end_time = time.monotonic() + self.wait_timeout 446 while time.monotonic() < end_time: 447 response = self.rest.get("droplets/{0}".format(droplet_id)) 448 json_data = response.json 449 if json_data is not None: 450 if response.status_code >= 400: 451 message = json_data.get( 452 "message", "Empty failure message from the DigitalOcean API!" 453 ) 454 self.module.fail_json(changed=False, msg=message) 455 else: 456 self.module.fail_json( 457 changed=False, 458 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 459 ) 460 461 droplet = json_data.get("droplet", None) 462 if droplet is None: 463 self.module.fail_json( 464 changed=False, 465 msg="Unexpected error, please file a bug (no droplet)", 466 ) 467 468 droplet_status = droplet.get("status", None) 469 if droplet_status is None: 470 self.module.fail_json( 471 changed=False, msg="Unexpected error, please file a bug (no status)" 472 ) 473 474 if droplet_status == "active": 475 break 476 477 time.sleep(min(10, end_time - time.monotonic())) 478 479 # Trigger power-on 480 response = self.rest.post( 481 "droplets/{0}/actions".format(droplet_id), data={"type": "power_on"} 482 ) 483 json_data = response.json 484 if json_data is not None: 485 if response.status_code >= 400: 486 message = json_data.get( 487 "message", "Empty failure message from the DigitalOcean API!" 488 ) 489 self.module.fail_json(changed=False, msg=message) 490 else: 491 self.module.fail_json( 492 changed=False, 493 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 494 ) 495 496 # Save the power-on action 497 action = json_data.get("action", None) 498 action_id = action.get("id", None) 499 if action is None or action_id is None: 500 self.module.fail_json( 501 changed=False, 502 msg="Unexpected error, please file a bug (no power-on action or id)", 503 ) 504 505 # Keep checking till it is done or times out 506 end_time = time.monotonic() + self.wait_timeout 507 while time.monotonic() < end_time: 508 response = self.rest.get( 509 "droplets/{0}/actions/{1}".format(droplet_id, action_id) 510 ) 511 json_data = response.json 512 if json_data is not None: 513 if response.status_code >= 400: 514 message = json_data.get( 515 "message", "Empty failure message from the DigitalOcean API!" 516 ) 517 self.module.fail_json(changed=False, msg=message) 518 else: 519 self.module.fail_json( 520 changed=False, 521 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 522 ) 523 524 action = json_data.get("action", None) 525 action_status = action.get("status", None) 526 if action is None or action_status is None: 527 self.module.fail_json( 528 changed=False, 529 msg="Unexpected error, please file a bug (no action or status)", 530 ) 531 532 if action_status == "errored": 533 self.module.fail_json( 534 changed=False, 535 msg="Error status on droplet power on action, please try again or contact DigitalOcean support", 536 ) 537 538 if action_status == "completed": 539 response = self.rest.get("droplets/{0}".format(droplet_id)) 540 json_data = response.json 541 if json_data is not None: 542 if response.status_code >= 400: 543 message = json_data.get( 544 "message", 545 "Empty failure message from the DigitalOcean API!", 546 ) 547 self.module.fail_json(changed=False, msg=message) 548 else: 549 self.module.fail_json( 550 changed=False, 551 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 552 ) 553 return json_data 554 555 time.sleep(min(10, end_time - time.monotonic())) 556 557 self.module.fail_json(msg="Wait for droplet powering on timeout") 558 559 def ensure_power_off(self, droplet_id): 560 561 # Make sure Droplet is active first 562 end_time = time.monotonic() + self.wait_timeout 563 while time.monotonic() < end_time: 564 response = self.rest.get("droplets/{0}".format(droplet_id)) 565 json_data = response.json 566 if json_data is not None: 567 if response.status_code >= 400: 568 message = json_data.get( 569 "message", "Empty failure message from the DigitalOcean API!" 570 ) 571 self.module.fail_json(changed=False, msg=message) 572 else: 573 self.module.fail_json( 574 changed=False, 575 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 576 ) 577 578 droplet = json_data.get("droplet", None) 579 if droplet is None: 580 self.module.fail_json( 581 changed=False, 582 msg="Unexpected error, please file a bug (no droplet)", 583 ) 584 585 droplet_status = droplet.get("status", None) 586 if droplet_status is None: 587 self.module.fail_json( 588 changed=False, msg="Unexpected error, please file a bug (no status)" 589 ) 590 591 if droplet_status == "active": 592 break 593 594 time.sleep(min(10, end_time - time.monotonic())) 595 596 # Trigger power-off 597 response = self.rest.post( 598 "droplets/{0}/actions".format(droplet_id), data={"type": "power_off"} 599 ) 600 json_data = response.json 601 if json_data is not None: 602 if response.status_code >= 400: 603 message = json_data.get( 604 "message", "Empty failure message from the DigitalOcean API!" 605 ) 606 self.module.fail_json(changed=False, msg=message) 607 else: 608 self.module.fail_json( 609 changed=False, 610 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 611 ) 612 613 # Save the power-off action 614 action = json_data.get("action", None) 615 action_id = action.get("id", None) 616 if action is None or action_id is None: 617 self.module.fail_json( 618 changed=False, 619 msg="Unexpected error, please file a bug (no power-off action or id)", 620 ) 621 622 # Keep checking till it is done or times out 623 end_time = time.monotonic() + self.wait_timeout 624 while time.monotonic() < end_time: 625 response = self.rest.get( 626 "droplets/{0}/actions/{1}".format(droplet_id, action_id) 627 ) 628 json_data = response.json 629 if json_data is not None: 630 if response.status_code >= 400: 631 message = json_data.get( 632 "message", "Empty failure message from the DigitalOcean API!" 633 ) 634 self.module.fail_json(changed=False, msg=message) 635 else: 636 self.module.fail_json( 637 changed=False, 638 msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds.", 639 ) 640 641 action = json_data.get("action", None) 642 action_status = action.get("status", None) 643 if action is None or action_status is None: 644 self.module.fail_json( 645 changed=False, 646 msg="Unexpected error, please file a bug (no action or status)", 647 ) 648 649 if action_status == "errored": 650 self.module.fail_json( 651 changed=False, 652 msg="Error status on droplet power off action, please try again or contact DigitalOcean support", 653 ) 654 655 if action_status == "completed": 656 response = self.rest.get("droplets/{0}".format(droplet_id)) 657 json_data = response.json 658 if response.status_code >= 400: 659 self.module.fail_json(changed=False, msg=json_data["message"]) 660 return json_data 661 662 time.sleep(min(10, end_time - time.monotonic())) 663 664 self.module.fail_json(msg="Wait for droplet powering off timeout") 665 666 667def core(module): 668 state = module.params.pop("state") 669 droplet = DODroplet(module) 670 if state == "present" or state == "active" or state == "inactive": 671 droplet.create(state) 672 elif state == "absent": 673 droplet.delete() 674 675 676def main(): 677 module = AnsibleModule( 678 argument_spec=dict( 679 state=dict( 680 choices=["present", "absent", "active", "inactive"], default="present" 681 ), 682 oauth_token=dict( 683 aliases=["API_TOKEN"], 684 no_log=True, 685 fallback=( 686 env_fallback, 687 ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"], 688 ), 689 required=True, 690 ), 691 name=dict(type="str"), 692 size=dict(aliases=["size_id"]), 693 image=dict(aliases=["image_id"]), 694 region=dict(aliases=["region_id"]), 695 ssh_keys=dict(type="list", elements="str", no_log=False), 696 private_networking=dict(type="bool", default=False), 697 vpc_uuid=dict(type="str"), 698 backups=dict(type="bool", default=False), 699 monitoring=dict(type="bool", default=False), 700 id=dict(aliases=["droplet_id"], type="int"), 701 user_data=dict(default=None), 702 ipv6=dict(type="bool", default=False), 703 volumes=dict(type="list", elements="str"), 704 tags=dict(type="list", elements="str"), 705 wait=dict(type="bool", default=True), 706 wait_timeout=dict(default=120, type="int"), 707 unique_name=dict(type="bool", default=False), 708 resize_disk=dict(type="bool", default=False), 709 ), 710 required_one_of=(["id", "name"],), 711 required_if=( 712 [ 713 ("state", "present", ["name", "size", "image", "region"]), 714 ("state", "active", ["name", "size", "image", "region"]), 715 ("state", "inactive", ["name", "size", "image", "region"]), 716 ] 717 ), 718 supports_check_mode=True, 719 ) 720 721 core(module) 722 723 724if __name__ == "__main__": 725 main() 726