1""" 2DigitalOcean Cloud Module 3========================= 4 5The DigitalOcean cloud module is used to control access to the DigitalOcean VPS system. 6 7Use of this module requires a requires a ``personal_access_token``, an ``ssh_key_file``, 8and at least one SSH key name in ``ssh_key_names``. More ``ssh_key_names`` can be added 9by separating each key with a comma. The ``personal_access_token`` can be found in the 10DigitalOcean web interface in the "Apps & API" section. The SSH key name can be found 11under the "SSH Keys" section. 12 13.. code-block:: yaml 14 15 # Note: This example is for /etc/salt/cloud.providers or any file in the 16 # /etc/salt/cloud.providers.d/ directory. 17 18 my-digital-ocean-config: 19 personal_access_token: xxx 20 ssh_key_file: /path/to/ssh/key/file 21 ssh_key_names: my-key-name,my-key-name-2 22 driver: digitalocean 23 24:depends: requests 25""" 26 27import decimal 28import logging 29import os 30import pprint 31import time 32 33import salt.config as config 34import salt.utils.cloud 35import salt.utils.files 36import salt.utils.json 37import salt.utils.stringutils 38from salt.exceptions import ( 39 SaltCloudConfigError, 40 SaltCloudExecutionFailure, 41 SaltCloudExecutionTimeout, 42 SaltCloudNotFound, 43 SaltCloudSystemExit, 44 SaltInvocationError, 45) 46 47try: 48 import requests 49 50 HAS_REQUESTS = True 51except ImportError: 52 HAS_REQUESTS = False 53 54# Get logging started 55log = logging.getLogger(__name__) 56 57__virtualname__ = "digitalocean" 58__virtual_aliases__ = ("digital_ocean", "do") 59 60 61# Only load in this module if the DIGITALOCEAN configurations are in place 62def __virtual__(): 63 """ 64 Check for DigitalOcean configurations 65 """ 66 if get_configured_provider() is False: 67 return False 68 69 if get_dependencies() is False: 70 return False 71 72 return __virtualname__ 73 74 75def _get_active_provider_name(): 76 try: 77 return __active_provider_name__.value() 78 except AttributeError: 79 return __active_provider_name__ 80 81 82def get_configured_provider(): 83 """ 84 Return the first configured instance. 85 """ 86 return config.is_provider_configured( 87 opts=__opts__, 88 provider=_get_active_provider_name() or __virtualname__, 89 aliases=__virtual_aliases__, 90 required_keys=("personal_access_token",), 91 ) 92 93 94def get_dependencies(): 95 """ 96 Warn if dependencies aren't met. 97 """ 98 return config.check_driver_dependencies(__virtualname__, {"requests": HAS_REQUESTS}) 99 100 101def avail_locations(call=None): 102 """ 103 Return a dict of all available VM locations on the cloud provider with 104 relevant data 105 """ 106 if call == "action": 107 raise SaltCloudSystemExit( 108 "The avail_locations function must be called with " 109 "-f or --function, or with the --list-locations option" 110 ) 111 112 items = query(method="regions") 113 ret = {} 114 for region in items["regions"]: 115 ret[region["name"]] = {} 116 for item in region.keys(): 117 ret[region["name"]][item] = str(region[item]) 118 119 return ret 120 121 122def avail_images(call=None): 123 """ 124 Return a list of the images that are on the provider 125 """ 126 if call == "action": 127 raise SaltCloudSystemExit( 128 "The avail_images function must be called with " 129 "-f or --function, or with the --list-images option" 130 ) 131 132 fetch = True 133 page = 1 134 ret = {} 135 136 while fetch: 137 items = query(method="images", command="?page=" + str(page) + "&per_page=200") 138 139 for image in items["images"]: 140 ret[image["name"]] = {} 141 for item in image.keys(): 142 ret[image["name"]][item] = image[item] 143 144 page += 1 145 try: 146 fetch = "next" in items["links"]["pages"] 147 except KeyError: 148 fetch = False 149 150 return ret 151 152 153def avail_sizes(call=None): 154 """ 155 Return a list of the image sizes that are on the provider 156 """ 157 if call == "action": 158 raise SaltCloudSystemExit( 159 "The avail_sizes function must be called with " 160 "-f or --function, or with the --list-sizes option" 161 ) 162 163 items = query(method="sizes", command="?per_page=100") 164 ret = {} 165 for size in items["sizes"]: 166 ret[size["slug"]] = {} 167 for item in size.keys(): 168 ret[size["slug"]][item] = str(size[item]) 169 170 return ret 171 172 173def list_nodes(call=None): 174 """ 175 Return a list of the VMs that are on the provider 176 """ 177 if call == "action": 178 raise SaltCloudSystemExit( 179 "The list_nodes function must be called with -f or --function." 180 ) 181 return _list_nodes() 182 183 184def list_nodes_full(call=None, for_output=True): 185 """ 186 Return a list of the VMs that are on the provider 187 """ 188 if call == "action": 189 raise SaltCloudSystemExit( 190 "The list_nodes_full function must be called with -f or --function." 191 ) 192 return _list_nodes(full=True, for_output=for_output) 193 194 195def list_nodes_select(call=None): 196 """ 197 Return a list of the VMs that are on the provider, with select fields 198 """ 199 return salt.utils.cloud.list_nodes_select( 200 list_nodes_full("function"), 201 __opts__["query.selection"], 202 call, 203 ) 204 205 206def get_image(vm_): 207 """ 208 Return the image object to use 209 """ 210 images = avail_images() 211 vm_image = config.get_cloud_config_value( 212 "image", vm_, __opts__, search_global=False 213 ) 214 if not isinstance(vm_image, str): 215 vm_image = str(vm_image) 216 217 for image in images: 218 if vm_image in ( 219 images[image]["name"], 220 images[image]["slug"], 221 images[image]["id"], 222 ): 223 if images[image]["slug"] is not None: 224 return images[image]["slug"] 225 return int(images[image]["id"]) 226 raise SaltCloudNotFound( 227 "The specified image, '{}', could not be found.".format(vm_image) 228 ) 229 230 231def get_size(vm_): 232 """ 233 Return the VM's size. Used by create_node(). 234 """ 235 sizes = avail_sizes() 236 vm_size = str( 237 config.get_cloud_config_value("size", vm_, __opts__, search_global=False) 238 ) 239 for size in sizes: 240 if vm_size.lower() == sizes[size]["slug"]: 241 return sizes[size]["slug"] 242 raise SaltCloudNotFound( 243 "The specified size, '{}', could not be found.".format(vm_size) 244 ) 245 246 247def get_location(vm_): 248 """ 249 Return the VM's location 250 """ 251 locations = avail_locations() 252 vm_location = str( 253 config.get_cloud_config_value("location", vm_, __opts__, search_global=False) 254 ) 255 256 for location in locations: 257 if vm_location in (locations[location]["name"], locations[location]["slug"]): 258 return locations[location]["slug"] 259 raise SaltCloudNotFound( 260 "The specified location, '{}', could not be found.".format(vm_location) 261 ) 262 263 264def create_node(args): 265 """ 266 Create a node 267 """ 268 node = query(method="droplets", args=args, http_method="post") 269 return node 270 271 272def create(vm_): 273 """ 274 Create a single VM from a data dict 275 """ 276 try: 277 # Check for required profile parameters before sending any API calls. 278 if ( 279 vm_["profile"] 280 and config.is_profile_configured( 281 __opts__, 282 _get_active_provider_name() or "digitalocean", 283 vm_["profile"], 284 vm_=vm_, 285 ) 286 is False 287 ): 288 return False 289 except AttributeError: 290 pass 291 292 __utils__["cloud.fire_event"]( 293 "event", 294 "starting create", 295 "salt/cloud/{}/creating".format(vm_["name"]), 296 args=__utils__["cloud.filter_event"]( 297 "creating", vm_, ["name", "profile", "provider", "driver"] 298 ), 299 sock_dir=__opts__["sock_dir"], 300 transport=__opts__["transport"], 301 ) 302 303 log.info("Creating Cloud VM %s", vm_["name"]) 304 305 kwargs = { 306 "name": vm_["name"], 307 "size": get_size(vm_), 308 "image": get_image(vm_), 309 "region": get_location(vm_), 310 "ssh_keys": [], 311 "tags": [], 312 } 313 314 # backwards compat 315 ssh_key_name = config.get_cloud_config_value( 316 "ssh_key_name", vm_, __opts__, search_global=False 317 ) 318 319 if ssh_key_name: 320 kwargs["ssh_keys"].append(get_keyid(ssh_key_name)) 321 322 ssh_key_names = config.get_cloud_config_value( 323 "ssh_key_names", vm_, __opts__, search_global=False, default=False 324 ) 325 326 if ssh_key_names: 327 for key in ssh_key_names.split(","): 328 kwargs["ssh_keys"].append(get_keyid(key)) 329 330 key_filename = config.get_cloud_config_value( 331 "ssh_key_file", vm_, __opts__, search_global=False, default=None 332 ) 333 334 if key_filename is not None and not os.path.isfile(key_filename): 335 raise SaltCloudConfigError( 336 "The defined key_filename '{}' does not exist".format(key_filename) 337 ) 338 339 if not __opts__.get("ssh_agent", False) and key_filename is None: 340 raise SaltCloudConfigError( 341 "The DigitalOcean driver requires an ssh_key_file and an ssh_key_name " 342 "because it does not supply a root password upon building the server." 343 ) 344 345 ssh_interface = config.get_cloud_config_value( 346 "ssh_interface", vm_, __opts__, search_global=False, default="public" 347 ) 348 349 if ssh_interface in ["private", "public"]: 350 log.info("ssh_interface: Setting interface for ssh to %s", ssh_interface) 351 kwargs["ssh_interface"] = ssh_interface 352 else: 353 raise SaltCloudConfigError( 354 "The DigitalOcean driver requires ssh_interface to be defined as 'public'" 355 " or 'private'." 356 ) 357 358 private_networking = config.get_cloud_config_value( 359 "private_networking", 360 vm_, 361 __opts__, 362 search_global=False, 363 default=None, 364 ) 365 366 if private_networking is not None: 367 if not isinstance(private_networking, bool): 368 raise SaltCloudConfigError( 369 "'private_networking' should be a boolean value." 370 ) 371 kwargs["private_networking"] = private_networking 372 373 if not private_networking and ssh_interface == "private": 374 raise SaltCloudConfigError( 375 "The DigitalOcean driver requires ssh_interface if defined as 'private' " 376 "then private_networking should be set as 'True'." 377 ) 378 379 backups_enabled = config.get_cloud_config_value( 380 "backups_enabled", 381 vm_, 382 __opts__, 383 search_global=False, 384 default=None, 385 ) 386 387 if backups_enabled is not None: 388 if not isinstance(backups_enabled, bool): 389 raise SaltCloudConfigError("'backups_enabled' should be a boolean value.") 390 kwargs["backups"] = backups_enabled 391 392 ipv6 = config.get_cloud_config_value( 393 "ipv6", 394 vm_, 395 __opts__, 396 search_global=False, 397 default=None, 398 ) 399 400 if ipv6 is not None: 401 if not isinstance(ipv6, bool): 402 raise SaltCloudConfigError("'ipv6' should be a boolean value.") 403 kwargs["ipv6"] = ipv6 404 405 monitoring = config.get_cloud_config_value( 406 "monitoring", 407 vm_, 408 __opts__, 409 search_global=False, 410 default=None, 411 ) 412 413 if monitoring is not None: 414 if not isinstance(monitoring, bool): 415 raise SaltCloudConfigError("'monitoring' should be a boolean value.") 416 kwargs["monitoring"] = monitoring 417 418 kwargs["tags"] = config.get_cloud_config_value( 419 "tags", vm_, __opts__, search_global=False, default=False 420 ) 421 422 userdata_file = config.get_cloud_config_value( 423 "userdata_file", vm_, __opts__, search_global=False, default=None 424 ) 425 if userdata_file is not None: 426 try: 427 with salt.utils.files.fopen(userdata_file, "r") as fp_: 428 kwargs["user_data"] = salt.utils.cloud.userdata_template( 429 __opts__, vm_, salt.utils.stringutils.to_unicode(fp_.read()) 430 ) 431 except Exception as exc: # pylint: disable=broad-except 432 log.exception("Failed to read userdata from %s: %s", userdata_file, exc) 433 434 create_dns_record = config.get_cloud_config_value( 435 "create_dns_record", 436 vm_, 437 __opts__, 438 search_global=False, 439 default=None, 440 ) 441 442 if create_dns_record: 443 log.info("create_dns_record: will attempt to write DNS records") 444 default_dns_domain = None 445 dns_domain_name = vm_["name"].split(".") 446 if len(dns_domain_name) > 2: 447 log.debug( 448 "create_dns_record: inferring default dns_hostname, dns_domain from" 449 " minion name as FQDN" 450 ) 451 default_dns_hostname = ".".join(dns_domain_name[:-2]) 452 default_dns_domain = ".".join(dns_domain_name[-2:]) 453 else: 454 log.debug("create_dns_record: can't infer dns_domain from %s", vm_["name"]) 455 default_dns_hostname = dns_domain_name[0] 456 457 dns_hostname = config.get_cloud_config_value( 458 "dns_hostname", 459 vm_, 460 __opts__, 461 search_global=False, 462 default=default_dns_hostname, 463 ) 464 dns_domain = config.get_cloud_config_value( 465 "dns_domain", 466 vm_, 467 __opts__, 468 search_global=False, 469 default=default_dns_domain, 470 ) 471 if dns_hostname and dns_domain: 472 log.info( 473 'create_dns_record: using dns_hostname="%s", dns_domain="%s"', 474 dns_hostname, 475 dns_domain, 476 ) 477 __add_dns_addr__ = lambda t, d: post_dns_record( 478 dns_domain=dns_domain, name=dns_hostname, record_type=t, record_data=d 479 ) 480 481 log.debug("create_dns_record: %s", __add_dns_addr__) 482 else: 483 log.error( 484 "create_dns_record: could not determine dns_hostname and/or dns_domain" 485 ) 486 raise SaltCloudConfigError( 487 "'create_dns_record' must be a dict specifying \"domain\" " 488 'and "hostname" or the minion name must be an FQDN.' 489 ) 490 491 __utils__["cloud.fire_event"]( 492 "event", 493 "requesting instance", 494 "salt/cloud/{}/requesting".format(vm_["name"]), 495 args=__utils__["cloud.filter_event"]("requesting", kwargs, list(kwargs)), 496 sock_dir=__opts__["sock_dir"], 497 transport=__opts__["transport"], 498 ) 499 500 try: 501 ret = create_node(kwargs) 502 except Exception as exc: # pylint: disable=broad-except 503 log.error( 504 "Error creating %s on DIGITALOCEAN\n\n" 505 "The following exception was thrown when trying to " 506 "run the initial deployment: %s", 507 vm_["name"], 508 exc, 509 # Show the traceback if the debug logging level is enabled 510 exc_info_on_loglevel=logging.DEBUG, 511 ) 512 return False 513 514 def __query_node_data(vm_name): 515 data = show_instance(vm_name, "action") 516 if not data: 517 # Trigger an error in the wait_for_ip function 518 return False 519 if data["networks"].get("v4"): 520 for network in data["networks"]["v4"]: 521 if network["type"] == "public": 522 return data 523 return False 524 525 try: 526 data = salt.utils.cloud.wait_for_ip( 527 __query_node_data, 528 update_args=(vm_["name"],), 529 timeout=config.get_cloud_config_value( 530 "wait_for_ip_timeout", vm_, __opts__, default=10 * 60 531 ), 532 interval=config.get_cloud_config_value( 533 "wait_for_ip_interval", vm_, __opts__, default=10 534 ), 535 ) 536 except (SaltCloudExecutionTimeout, SaltCloudExecutionFailure) as exc: 537 try: 538 # It might be already up, let's destroy it! 539 destroy(vm_["name"]) 540 except SaltCloudSystemExit: 541 pass 542 finally: 543 raise SaltCloudSystemExit(str(exc)) 544 545 if not vm_.get("ssh_host"): 546 vm_["ssh_host"] = None 547 548 # add DNS records, set ssh_host, default to first found IP, preferring IPv4 for ssh bootstrap script target 549 addr_families, dns_arec_types = (("v4", "v6"), ("A", "AAAA")) 550 arec_map = dict(list(zip(addr_families, dns_arec_types))) 551 for facing, addr_family, ip_address in [ 552 (net["type"], family, net["ip_address"]) 553 for family in addr_families 554 for net in data["networks"][family] 555 ]: 556 log.info('found %s IP%s interface for "%s"', facing, addr_family, ip_address) 557 dns_rec_type = arec_map[addr_family] 558 if facing == "public": 559 if create_dns_record: 560 __add_dns_addr__(dns_rec_type, ip_address) 561 if facing == ssh_interface: 562 if not vm_["ssh_host"]: 563 vm_["ssh_host"] = ip_address 564 565 if vm_["ssh_host"] is None: 566 raise SaltCloudSystemExit( 567 "No suitable IP addresses found for ssh minion bootstrapping: {}".format( 568 repr(data["networks"]) 569 ) 570 ) 571 572 log.debug( 573 "Found public IP address to use for ssh minion bootstrapping: %s", 574 vm_["ssh_host"], 575 ) 576 577 vm_["key_filename"] = key_filename 578 ret = __utils__["cloud.bootstrap"](vm_, __opts__) 579 ret.update(data) 580 581 log.info("Created Cloud VM '%s'", vm_["name"]) 582 log.debug("'%s' VM creation details:\n%s", vm_["name"], pprint.pformat(data)) 583 584 __utils__["cloud.fire_event"]( 585 "event", 586 "created instance", 587 "salt/cloud/{}/created".format(vm_["name"]), 588 args=__utils__["cloud.filter_event"]( 589 "created", vm_, ["name", "profile", "provider", "driver"] 590 ), 591 sock_dir=__opts__["sock_dir"], 592 transport=__opts__["transport"], 593 ) 594 595 return ret 596 597 598def query( 599 method="droplets", droplet_id=None, command=None, args=None, http_method="get" 600): 601 """ 602 Make a web call to DigitalOcean 603 """ 604 base_path = str( 605 config.get_cloud_config_value( 606 "api_root", 607 get_configured_provider(), 608 __opts__, 609 search_global=False, 610 default="https://api.digitalocean.com/v2", 611 ) 612 ) 613 614 path = "{}/{}/".format(base_path, method) 615 616 if droplet_id: 617 path += "{}/".format(droplet_id) 618 619 if command: 620 path += command 621 622 if not isinstance(args, dict): 623 args = {} 624 625 personal_access_token = config.get_cloud_config_value( 626 "personal_access_token", 627 get_configured_provider(), 628 __opts__, 629 search_global=False, 630 ) 631 632 data = salt.utils.json.dumps(args) 633 634 requester = getattr(requests, http_method) 635 request = requester( 636 path, 637 data=data, 638 headers={ 639 "Authorization": "Bearer " + personal_access_token, 640 "Content-Type": "application/json", 641 }, 642 ) 643 if request.status_code > 299: 644 raise SaltCloudSystemExit( 645 "An error occurred while querying DigitalOcean. HTTP Code: {} " 646 "Error: '{}'".format( 647 request.status_code, 648 # request.read() 649 request.text, 650 ) 651 ) 652 653 log.debug(request.url) 654 655 # success without data 656 if request.status_code == 204: 657 return True 658 659 content = request.text 660 661 result = salt.utils.json.loads(content) 662 if result.get("status", "").lower() == "error": 663 raise SaltCloudSystemExit(pprint.pformat(result.get("error_message", {}))) 664 665 return result 666 667 668def script(vm_): 669 """ 670 Return the script deployment object 671 """ 672 deploy_script = salt.utils.cloud.os_script( 673 config.get_cloud_config_value("script", vm_, __opts__), 674 vm_, 675 __opts__, 676 salt.utils.cloud.salt_config_to_yaml( 677 salt.utils.cloud.minion_config(__opts__, vm_) 678 ), 679 ) 680 return deploy_script 681 682 683def show_instance(name, call=None): 684 """ 685 Show the details from DigitalOcean concerning a droplet 686 """ 687 if call != "action": 688 raise SaltCloudSystemExit( 689 "The show_instance action must be called with -a or --action." 690 ) 691 node = _get_node(name) 692 __utils__["cloud.cache_node"](node, _get_active_provider_name(), __opts__) 693 return node 694 695 696def _get_node(name): 697 attempts = 10 698 while attempts >= 0: 699 try: 700 return list_nodes_full(for_output=False)[name] 701 except KeyError: 702 attempts -= 1 703 log.debug( 704 "Failed to get the data for node '%s'. Remaining attempts: %s", 705 name, 706 attempts, 707 ) 708 # Just a little delay between attempts... 709 time.sleep(0.5) 710 return {} 711 712 713def list_keypairs(call=None): 714 """ 715 Return a dict of all available VM locations on the cloud provider with 716 relevant data 717 """ 718 if call != "function": 719 log.error("The list_keypairs function must be called with -f or --function.") 720 return False 721 722 fetch = True 723 page = 1 724 ret = {} 725 726 while fetch: 727 items = query( 728 method="account/keys", 729 command="?page=" + str(page) + "&per_page=100", 730 ) 731 732 for key_pair in items["ssh_keys"]: 733 name = key_pair["name"] 734 if name in ret: 735 raise SaltCloudSystemExit( 736 "A duplicate key pair name, '{}', was found in DigitalOcean's " 737 "key pair list. Please change the key name stored by DigitalOcean. " 738 "Be sure to adjust the value of 'ssh_key_file' in your cloud " 739 "profile or provider configuration, if necessary.".format(name) 740 ) 741 ret[name] = {} 742 for item in key_pair.keys(): 743 ret[name][item] = str(key_pair[item]) 744 745 page += 1 746 try: 747 fetch = "next" in items["links"]["pages"] 748 except KeyError: 749 fetch = False 750 751 return ret 752 753 754def show_keypair(kwargs=None, call=None): 755 """ 756 Show the details of an SSH keypair 757 """ 758 if call != "function": 759 log.error("The show_keypair function must be called with -f or --function.") 760 return False 761 762 if not kwargs: 763 kwargs = {} 764 765 if "keyname" not in kwargs: 766 log.error("A keyname is required.") 767 return False 768 769 keypairs = list_keypairs(call="function") 770 keyid = keypairs[kwargs["keyname"]]["id"] 771 log.debug("Key ID is %s", keyid) 772 773 details = query(method="account/keys", command=keyid) 774 775 return details 776 777 778def import_keypair(kwargs=None, call=None): 779 """ 780 Upload public key to cloud provider. 781 Similar to EC2 import_keypair. 782 783 .. versionadded:: 2016.11.0 784 785 kwargs 786 file(mandatory): public key file-name 787 keyname(mandatory): public key name in the provider 788 """ 789 with salt.utils.files.fopen(kwargs["file"], "r") as public_key_filename: 790 public_key_content = salt.utils.stringutils.to_unicode( 791 public_key_filename.read() 792 ) 793 794 digitalocean_kwargs = {"name": kwargs["keyname"], "public_key": public_key_content} 795 796 created_result = create_key(digitalocean_kwargs, call=call) 797 return created_result 798 799 800def create_key(kwargs=None, call=None): 801 """ 802 Upload a public key 803 """ 804 if call != "function": 805 log.error("The create_key function must be called with -f or --function.") 806 return False 807 808 try: 809 result = query( 810 method="account", 811 command="keys", 812 args={"name": kwargs["name"], "public_key": kwargs["public_key"]}, 813 http_method="post", 814 ) 815 except KeyError: 816 log.info("`name` and `public_key` arguments must be specified") 817 return False 818 819 return result 820 821 822def remove_key(kwargs=None, call=None): 823 """ 824 Delete public key 825 """ 826 if call != "function": 827 log.error("The create_key function must be called with -f or --function.") 828 return False 829 830 try: 831 result = query( 832 method="account", command="keys/" + kwargs["id"], http_method="delete" 833 ) 834 except KeyError: 835 log.info("`id` argument must be specified") 836 return False 837 838 return result 839 840 841def get_keyid(keyname): 842 """ 843 Return the ID of the keyname 844 """ 845 if not keyname: 846 return None 847 keypairs = list_keypairs(call="function") 848 keyid = keypairs[keyname]["id"] 849 if keyid: 850 return keyid 851 raise SaltCloudNotFound("The specified ssh key could not be found.") 852 853 854def destroy(name, call=None): 855 """ 856 Destroy a node. Will check termination protection and warn if enabled. 857 858 CLI Example: 859 860 .. code-block:: bash 861 862 salt-cloud --destroy mymachine 863 """ 864 if call == "function": 865 raise SaltCloudSystemExit( 866 "The destroy action must be called with -d, --destroy, -a or --action." 867 ) 868 869 __utils__["cloud.fire_event"]( 870 "event", 871 "destroying instance", 872 "salt/cloud/{}/destroying".format(name), 873 args={"name": name}, 874 sock_dir=__opts__["sock_dir"], 875 transport=__opts__["transport"], 876 ) 877 878 data = show_instance(name, call="action") 879 node = query(method="droplets", droplet_id=data["id"], http_method="delete") 880 881 ## This is all terribly optomistic: 882 # vm_ = get_vm_config(name=name) 883 # delete_dns_record = config.get_cloud_config_value( 884 # 'delete_dns_record', vm_, __opts__, search_global=False, default=None, 885 # ) 886 # TODO: when _vm config data can be made available, we should honor the configuration settings, 887 # but until then, we should assume stale DNS records are bad, and default behavior should be to 888 # delete them if we can. When this is resolved, also resolve the comments a couple of lines below. 889 delete_dns_record = True 890 891 if not isinstance(delete_dns_record, bool): 892 raise SaltCloudConfigError("'delete_dns_record' should be a boolean value.") 893 # When the "to do" a few lines up is resolved, remove these lines and use the if/else logic below. 894 log.debug("Deleting DNS records for %s.", name) 895 destroy_dns_records(name) 896 897 # Until the "to do" from line 754 is taken care of, we don't need this logic. 898 # if delete_dns_record: 899 # log.debug('Deleting DNS records for %s.', name) 900 # destroy_dns_records(name) 901 # else: 902 # log.debug('delete_dns_record : %s', delete_dns_record) 903 # for line in pprint.pformat(dir()).splitlines(): 904 # log.debug('delete context: %s', line) 905 906 __utils__["cloud.fire_event"]( 907 "event", 908 "destroyed instance", 909 "salt/cloud/{}/destroyed".format(name), 910 args={"name": name}, 911 sock_dir=__opts__["sock_dir"], 912 transport=__opts__["transport"], 913 ) 914 915 if __opts__.get("update_cachedir", False) is True: 916 __utils__["cloud.delete_minion_cachedir"]( 917 name, _get_active_provider_name().split(":")[0], __opts__ 918 ) 919 920 return node 921 922 923def post_dns_record(**kwargs): 924 """ 925 Creates a DNS record for the given name if the domain is managed with DO. 926 """ 927 if "kwargs" in kwargs: # flatten kwargs if called via salt-cloud -f 928 f_kwargs = kwargs["kwargs"] 929 del kwargs["kwargs"] 930 kwargs.update(f_kwargs) 931 mandatory_kwargs = ("dns_domain", "name", "record_type", "record_data") 932 for i in mandatory_kwargs: 933 if kwargs[i]: 934 pass 935 else: 936 error = '{}="{}" ## all mandatory args must be provided: {}'.format( 937 i, kwargs[i], mandatory_kwargs 938 ) 939 raise SaltInvocationError(error) 940 941 domain = query(method="domains", droplet_id=kwargs["dns_domain"]) 942 943 if domain: 944 result = query( 945 method="domains", 946 droplet_id=kwargs["dns_domain"], 947 command="records", 948 args={ 949 "type": kwargs["record_type"], 950 "name": kwargs["name"], 951 "data": kwargs["record_data"], 952 }, 953 http_method="post", 954 ) 955 return result 956 957 return False 958 959 960def destroy_dns_records(fqdn): 961 """ 962 Deletes DNS records for the given hostname if the domain is managed with DO. 963 """ 964 domain = ".".join(fqdn.split(".")[-2:]) 965 hostname = ".".join(fqdn.split(".")[:-2]) 966 # TODO: remove this when the todo on 754 is available 967 try: 968 response = query(method="domains", droplet_id=domain, command="records") 969 except SaltCloudSystemExit: 970 log.debug("Failed to find domains.") 971 return False 972 log.debug("found DNS records: %s", pprint.pformat(response)) 973 records = response["domain_records"] 974 975 if records: 976 record_ids = [r["id"] for r in records if r["name"].decode() == hostname] 977 log.debug("deleting DNS record IDs: %s", record_ids) 978 for id_ in record_ids: 979 try: 980 log.info("deleting DNS record %s", id_) 981 ret = query( 982 method="domains", 983 droplet_id=domain, 984 command="records/{}".format(id_), 985 http_method="delete", 986 ) 987 except SaltCloudSystemExit: 988 log.error( 989 "failed to delete DNS domain %s record ID %s.", domain, hostname 990 ) 991 log.debug("DNS deletion REST call returned: %s", pprint.pformat(ret)) 992 993 return False 994 995 996def show_pricing(kwargs=None, call=None): 997 """ 998 Show pricing for a particular profile. This is only an estimate, based on 999 unofficial pricing sources. 1000 1001 .. versionadded:: 2015.8.0 1002 1003 CLI Examples: 1004 1005 .. code-block:: bash 1006 1007 salt-cloud -f show_pricing my-digitalocean-config profile=my-profile 1008 """ 1009 profile = __opts__["profiles"].get(kwargs["profile"], {}) 1010 if not profile: 1011 return {"Error": "The requested profile was not found"} 1012 1013 # Make sure the profile belongs to DigitalOcean 1014 provider = profile.get("provider", "0:0") 1015 comps = provider.split(":") 1016 if len(comps) < 2 or comps[1] != "digitalocean": 1017 return {"Error": "The requested profile does not belong to DigitalOcean"} 1018 1019 raw = {} 1020 ret = {} 1021 sizes = avail_sizes() 1022 ret["per_hour"] = decimal.Decimal(sizes[profile["size"]]["price_hourly"]) 1023 1024 ret["per_day"] = ret["per_hour"] * 24 1025 ret["per_week"] = ret["per_day"] * 7 1026 ret["per_month"] = decimal.Decimal(sizes[profile["size"]]["price_monthly"]) 1027 ret["per_year"] = ret["per_week"] * 52 1028 1029 if kwargs.get("raw", False): 1030 ret["_raw"] = raw 1031 1032 return {profile["profile"]: ret} 1033 1034 1035def list_floating_ips(call=None): 1036 """ 1037 Return a list of the floating ips that are on the provider 1038 1039 .. versionadded:: 2016.3.0 1040 1041 CLI Examples: 1042 1043 .. code-block:: bash 1044 1045 salt-cloud -f list_floating_ips my-digitalocean-config 1046 """ 1047 if call == "action": 1048 raise SaltCloudSystemExit( 1049 "The list_floating_ips function must be called with " 1050 "-f or --function, or with the --list-floating-ips option" 1051 ) 1052 1053 fetch = True 1054 page = 1 1055 ret = {} 1056 1057 while fetch: 1058 items = query( 1059 method="floating_ips", 1060 command="?page=" + str(page) + "&per_page=200", 1061 ) 1062 1063 for floating_ip in items["floating_ips"]: 1064 ret[floating_ip["ip"]] = {} 1065 for item in floating_ip.keys(): 1066 ret[floating_ip["ip"]][item] = floating_ip[item] 1067 1068 page += 1 1069 try: 1070 fetch = "next" in items["links"]["pages"] 1071 except KeyError: 1072 fetch = False 1073 1074 return ret 1075 1076 1077def show_floating_ip(kwargs=None, call=None): 1078 """ 1079 Show the details of a floating IP 1080 1081 .. versionadded:: 2016.3.0 1082 1083 CLI Examples: 1084 1085 .. code-block:: bash 1086 1087 salt-cloud -f show_floating_ip my-digitalocean-config floating_ip='45.55.96.47' 1088 """ 1089 if call != "function": 1090 log.error("The show_floating_ip function must be called with -f or --function.") 1091 return False 1092 1093 if not kwargs: 1094 kwargs = {} 1095 1096 if "floating_ip" not in kwargs: 1097 log.error("A floating IP is required.") 1098 return False 1099 1100 floating_ip = kwargs["floating_ip"] 1101 log.debug("Floating ip is %s", floating_ip) 1102 1103 details = query(method="floating_ips", command=floating_ip) 1104 1105 return details 1106 1107 1108def create_floating_ip(kwargs=None, call=None): 1109 """ 1110 Create a new floating IP 1111 1112 .. versionadded:: 2016.3.0 1113 1114 CLI Examples: 1115 1116 .. code-block:: bash 1117 1118 salt-cloud -f create_floating_ip my-digitalocean-config region='NYC2' 1119 1120 salt-cloud -f create_floating_ip my-digitalocean-config droplet_id='1234567' 1121 """ 1122 if call != "function": 1123 log.error( 1124 "The create_floating_ip function must be called with -f or --function." 1125 ) 1126 return False 1127 1128 if not kwargs: 1129 kwargs = {} 1130 1131 if "droplet_id" in kwargs: 1132 result = query( 1133 method="floating_ips", 1134 args={"droplet_id": kwargs["droplet_id"]}, 1135 http_method="post", 1136 ) 1137 1138 return result 1139 1140 elif "region" in kwargs: 1141 result = query( 1142 method="floating_ips", args={"region": kwargs["region"]}, http_method="post" 1143 ) 1144 1145 return result 1146 1147 else: 1148 log.error("A droplet_id or region is required.") 1149 return False 1150 1151 1152def delete_floating_ip(kwargs=None, call=None): 1153 """ 1154 Delete a floating IP 1155 1156 .. versionadded:: 2016.3.0 1157 1158 CLI Examples: 1159 1160 .. code-block:: bash 1161 1162 salt-cloud -f delete_floating_ip my-digitalocean-config floating_ip='45.55.96.47' 1163 """ 1164 if call != "function": 1165 log.error( 1166 "The delete_floating_ip function must be called with -f or --function." 1167 ) 1168 return False 1169 1170 if not kwargs: 1171 kwargs = {} 1172 1173 if "floating_ip" not in kwargs: 1174 log.error("A floating IP is required.") 1175 return False 1176 1177 floating_ip = kwargs["floating_ip"] 1178 log.debug("Floating ip is %s", kwargs["floating_ip"]) 1179 1180 result = query(method="floating_ips", command=floating_ip, http_method="delete") 1181 1182 return result 1183 1184 1185def assign_floating_ip(kwargs=None, call=None): 1186 """ 1187 Assign a floating IP 1188 1189 .. versionadded:: 2016.3.0 1190 1191 CLI Examples: 1192 1193 .. code-block:: bash 1194 1195 salt-cloud -f assign_floating_ip my-digitalocean-config droplet_id=1234567 floating_ip='45.55.96.47' 1196 """ 1197 if call != "function": 1198 log.error( 1199 "The assign_floating_ip function must be called with -f or --function." 1200 ) 1201 return False 1202 1203 if not kwargs: 1204 kwargs = {} 1205 1206 if "floating_ip" and "droplet_id" not in kwargs: 1207 log.error("A floating IP and droplet_id is required.") 1208 return False 1209 1210 result = query( 1211 method="floating_ips", 1212 command=kwargs["floating_ip"] + "/actions", 1213 args={"droplet_id": kwargs["droplet_id"], "type": "assign"}, 1214 http_method="post", 1215 ) 1216 1217 return result 1218 1219 1220def unassign_floating_ip(kwargs=None, call=None): 1221 """ 1222 Unassign a floating IP 1223 1224 .. versionadded:: 2016.3.0 1225 1226 CLI Examples: 1227 1228 .. code-block:: bash 1229 1230 salt-cloud -f unassign_floating_ip my-digitalocean-config floating_ip='45.55.96.47' 1231 """ 1232 if call != "function": 1233 log.error( 1234 "The inassign_floating_ip function must be called with -f or --function." 1235 ) 1236 return False 1237 1238 if not kwargs: 1239 kwargs = {} 1240 1241 if "floating_ip" not in kwargs: 1242 log.error("A floating IP is required.") 1243 return False 1244 1245 result = query( 1246 method="floating_ips", 1247 command=kwargs["floating_ip"] + "/actions", 1248 args={"type": "unassign"}, 1249 http_method="post", 1250 ) 1251 1252 return result 1253 1254 1255def _list_nodes(full=False, for_output=False): 1256 """ 1257 Helper function to format and parse node data. 1258 """ 1259 fetch = True 1260 page = 1 1261 ret = {} 1262 1263 while fetch: 1264 items = query(method="droplets", command="?page=" + str(page) + "&per_page=200") 1265 for node in items["droplets"]: 1266 name = node["name"] 1267 ret[name] = {} 1268 if full: 1269 ret[name] = _get_full_output(node, for_output=for_output) 1270 else: 1271 public_ips, private_ips = _get_ips(node["networks"]) 1272 ret[name] = { 1273 "id": node["id"], 1274 "image": node["image"]["name"], 1275 "name": name, 1276 "private_ips": private_ips, 1277 "public_ips": public_ips, 1278 "size": node["size_slug"], 1279 "state": str(node["status"]), 1280 } 1281 1282 page += 1 1283 try: 1284 fetch = "next" in items["links"]["pages"] 1285 except KeyError: 1286 fetch = False 1287 1288 return ret 1289 1290 1291def reboot(name, call=None): 1292 """ 1293 Reboot a droplet in DigitalOcean. 1294 1295 .. versionadded:: 2015.8.8 1296 1297 name 1298 The name of the droplet to restart. 1299 1300 CLI Example: 1301 1302 .. code-block:: bash 1303 1304 salt-cloud -a reboot droplet_name 1305 """ 1306 if call != "action": 1307 raise SaltCloudSystemExit( 1308 "The reboot action must be called with -a or --action." 1309 ) 1310 1311 data = show_instance(name, call="action") 1312 if data.get("status") == "off": 1313 return { 1314 "success": True, 1315 "action": "stop", 1316 "status": "off", 1317 "msg": "Machine is already off.", 1318 } 1319 1320 ret = query( 1321 droplet_id=data["id"], 1322 command="actions", 1323 args={"type": "reboot"}, 1324 http_method="post", 1325 ) 1326 1327 return { 1328 "success": True, 1329 "action": ret["action"]["type"], 1330 "state": ret["action"]["status"], 1331 } 1332 1333 1334def start(name, call=None): 1335 """ 1336 Start a droplet in DigitalOcean. 1337 1338 .. versionadded:: 2015.8.8 1339 1340 name 1341 The name of the droplet to start. 1342 1343 CLI Example: 1344 1345 .. code-block:: bash 1346 1347 salt-cloud -a start droplet_name 1348 """ 1349 if call != "action": 1350 raise SaltCloudSystemExit( 1351 "The start action must be called with -a or --action." 1352 ) 1353 1354 data = show_instance(name, call="action") 1355 if data.get("status") == "active": 1356 return { 1357 "success": True, 1358 "action": "start", 1359 "status": "active", 1360 "msg": "Machine is already running.", 1361 } 1362 1363 ret = query( 1364 droplet_id=data["id"], 1365 command="actions", 1366 args={"type": "power_on"}, 1367 http_method="post", 1368 ) 1369 1370 return { 1371 "success": True, 1372 "action": ret["action"]["type"], 1373 "state": ret["action"]["status"], 1374 } 1375 1376 1377def stop(name, call=None): 1378 """ 1379 Stop a droplet in DigitalOcean. 1380 1381 .. versionadded:: 2015.8.8 1382 1383 name 1384 The name of the droplet to stop. 1385 1386 CLI Example: 1387 1388 .. code-block:: bash 1389 1390 salt-cloud -a stop droplet_name 1391 """ 1392 if call != "action": 1393 raise SaltCloudSystemExit("The stop action must be called with -a or --action.") 1394 1395 data = show_instance(name, call="action") 1396 if data.get("status") == "off": 1397 return { 1398 "success": True, 1399 "action": "stop", 1400 "status": "off", 1401 "msg": "Machine is already off.", 1402 } 1403 1404 ret = query( 1405 droplet_id=data["id"], 1406 command="actions", 1407 args={"type": "shutdown"}, 1408 http_method="post", 1409 ) 1410 1411 return { 1412 "success": True, 1413 "action": ret["action"]["type"], 1414 "state": ret["action"]["status"], 1415 } 1416 1417 1418def _get_full_output(node, for_output=False): 1419 """ 1420 Helper function for _list_nodes to loop through all node information. 1421 Returns a dictionary containing the full information of a node. 1422 """ 1423 ret = {} 1424 for item in node.keys(): 1425 value = node[item] 1426 if value is not None and for_output: 1427 value = str(value) 1428 ret[item] = value 1429 return ret 1430 1431 1432def _get_ips(networks): 1433 """ 1434 Helper function for list_nodes. Returns public and private ip lists based on a 1435 given network dictionary. 1436 """ 1437 v4s = networks.get("v4") 1438 v6s = networks.get("v6") 1439 public_ips = [] 1440 private_ips = [] 1441 1442 if v4s: 1443 for item in v4s: 1444 ip_type = item.get("type") 1445 ip_address = item.get("ip_address") 1446 if ip_type == "public": 1447 public_ips.append(ip_address) 1448 if ip_type == "private": 1449 private_ips.append(ip_address) 1450 1451 if v6s: 1452 for item in v6s: 1453 ip_type = item.get("type") 1454 ip_address = item.get("ip_address") 1455 if ip_type == "public": 1456 public_ips.append(ip_address) 1457 if ip_type == "private": 1458 private_ips.append(ip_address) 1459 1460 return public_ips, private_ips 1461