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