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