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
11
12DOCUMENTATION = r"""
13---
14module: digital_ocean_block_storage
15short_description: Create/destroy or attach/detach Block Storage volumes in DigitalOcean
16description:
17     - Create/destroy Block Storage volume in DigitalOcean, or attach/detach Block Storage volume to a droplet.
18options:
19  command:
20    description:
21     - Which operation do you want to perform.
22    choices: ['create', 'attach']
23    required: true
24    type: str
25  state:
26    description:
27     - Indicate desired state of the target.
28    choices: ['present', 'absent']
29    required: true
30    type: str
31  block_size:
32    description:
33    - The size of the Block Storage volume in gigabytes.
34    - Required when I(command=create) and I(state=present).
35    - If snapshot_id is included, this will be ignored.
36    - If block_size > current size of the volume, the volume is resized.
37    type: int
38  volume_name:
39    description:
40    - The name of the Block Storage volume.
41    type: str
42    required: true
43  description:
44    description:
45    - Description of the Block Storage volume.
46    type: str
47  region:
48    description:
49    - The slug of the region where your Block Storage volume should be located in.
50    - If I(snapshot_id) is included, this will be ignored.
51    type: str
52  snapshot_id:
53    description:
54    - The snapshot id you would like the Block Storage volume created with.
55    - If included, I(region) and I(block_size) will be ignored and changed to C(null).
56    type: str
57  droplet_id:
58    description:
59    - The droplet id you want to operate on.
60    - Required when I(command=attach).
61    type: int
62extends_documentation_fragment:
63- community.digitalocean.digital_ocean.documentation
64
65notes:
66  - Two environment variables can be used, DO_API_KEY and DO_API_TOKEN.
67    They both refer to the v2 token.
68  - If snapshot_id is used, region and block_size will be ignored and changed to null.
69
70author:
71    - "Harnek Sidhu (@harneksidhu)"
72"""
73
74EXAMPLES = r"""
75- name: Create new Block Storage
76  community.digitalocean.digital_ocean_block_storage:
77    state: present
78    command: create
79    api_token: <TOKEN>
80    region: nyc1
81    block_size: 10
82    volume_name: nyc1-block-storage
83
84- name: Resize an existing Block Storage
85  community.digitalocean.digital_ocean_block_storage:
86    state: present
87    command: create
88    api_token: <TOKEN>
89    region: nyc1
90    block_size: 20
91    volume_name: nyc1-block-storage
92
93- name: Delete Block Storage
94  community.digitalocean.digital_ocean_block_storage:
95    state: absent
96    command: create
97    api_token: <TOKEN>
98    region: nyc1
99    volume_name: nyc1-block-storage
100
101- name: Attach Block Storage to a Droplet
102  community.digitalocean.digital_ocean_block_storage:
103    state: present
104    command: attach
105    api_token: <TOKEN>
106    volume_name: nyc1-block-storage
107    region: nyc1
108    droplet_id: <ID>
109
110- name: Detach Block Storage from a Droplet
111  community.digitalocean.digital_ocean_block_storage:
112    state: absent
113    command: attach
114    api_token: <TOKEN>
115    volume_name: nyc1-block-storage
116    region: nyc1
117    droplet_id: <ID>
118"""
119
120RETURN = r"""
121id:
122    description: Unique identifier of a Block Storage volume returned during creation.
123    returned: changed
124    type: str
125    sample: "69b25d9a-494c-12e6-a5af-001f53126b44"
126"""
127
128import time
129import traceback
130
131from ansible.module_utils.basic import AnsibleModule
132from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import (
133    DigitalOceanHelper,
134)
135
136
137class DOBlockStorageException(Exception):
138    pass
139
140
141class DOBlockStorage(object):
142    def __init__(self, module):
143        self.module = module
144        self.rest = DigitalOceanHelper(module)
145
146    def get_key_or_fail(self, k):
147        v = self.module.params[k]
148        if v is None:
149            self.module.fail_json(msg="Unable to load %s" % k)
150        return v
151
152    def poll_action_for_complete_status(self, action_id):
153        url = "actions/{0}".format(action_id)
154        end_time = time.time() + self.module.params["timeout"]
155        while time.time() < end_time:
156            time.sleep(2)
157            response = self.rest.get(url)
158            status = response.status_code
159            json = response.json
160            if status == 200:
161                if json["action"]["status"] == "completed":
162                    return True
163                elif json["action"]["status"] == "errored":
164                    raise DOBlockStorageException(json["message"])
165        raise DOBlockStorageException("Unable to reach api.digitalocean.com")
166
167    def get_block_storage_by_name(self, volume_name, region):
168        url = "volumes?name={0}&region={1}".format(volume_name, region)
169        resp = self.rest.get(url)
170        if resp.status_code != 200:
171            raise DOBlockStorageException(resp.json["message"])
172
173        volumes = resp.json["volumes"]
174        if not volumes:
175            return None
176
177        return volumes[0]
178
179    def get_attached_droplet_ID(self, volume_name, region):
180        volume = self.get_block_storage_by_name(volume_name, region)
181        if not volume or not volume["droplet_ids"]:
182            return None
183
184        return volume["droplet_ids"][0]
185
186    def attach_detach_block_storage(self, method, volume_name, region, droplet_id):
187        data = {
188            "type": method,
189            "volume_name": volume_name,
190            "region": region,
191            "droplet_id": droplet_id,
192        }
193        response = self.rest.post("volumes/actions", data=data)
194        status = response.status_code
195        json = response.json
196        if status == 202:
197            return self.poll_action_for_complete_status(json["action"]["id"])
198        elif status == 200:
199            return True
200        elif status == 404 and method == "detach":
201            return False  # Already detached
202        elif status == 422:
203            return False
204        else:
205            raise DOBlockStorageException(json["message"])
206
207    def resize_block_storage(self, volume_name, region, desired_size):
208        if not desired_size:
209            return False
210
211        volume = self.get_block_storage_by_name(volume_name, region)
212        if volume["size_gigabytes"] == desired_size:
213            return False
214
215        data = {
216            "type": "resize",
217            "size_gigabytes": desired_size,
218        }
219        resp = self.rest.post(
220            "volumes/{0}/actions".format(volume["id"]),
221            data=data,
222        )
223        if resp.status_code == 202:
224            return self.poll_action_for_complete_status(resp.json["action"]["id"])
225        else:
226            # we'd get status 422 if desired_size <= current volume size
227            raise DOBlockStorageException(resp.json["message"])
228
229    def create_block_storage(self):
230        volume_name = self.get_key_or_fail("volume_name")
231        snapshot_id = self.module.params["snapshot_id"]
232        if snapshot_id:
233            self.module.params["block_size"] = None
234            self.module.params["region"] = None
235            block_size = None
236            region = None
237        else:
238            block_size = self.get_key_or_fail("block_size")
239            region = self.get_key_or_fail("region")
240        description = self.module.params["description"]
241        data = {
242            "size_gigabytes": block_size,
243            "name": volume_name,
244            "description": description,
245            "region": region,
246            "snapshot_id": snapshot_id,
247        }
248        response = self.rest.post("volumes", data=data)
249        status = response.status_code
250        json = response.json
251        if status == 201:
252            self.module.exit_json(changed=True, id=json["volume"]["id"])
253        elif status == 409 and json["id"] == "conflict":
254            # The volume exists already, but it might not have the desired size
255            resized = self.resize_block_storage(volume_name, region, block_size)
256            self.module.exit_json(changed=resized)
257        else:
258            raise DOBlockStorageException(json["message"])
259
260    def delete_block_storage(self):
261        volume_name = self.get_key_or_fail("volume_name")
262        region = self.get_key_or_fail("region")
263        url = "volumes?name={0}&region={1}".format(volume_name, region)
264        attached_droplet_id = self.get_attached_droplet_ID(volume_name, region)
265        if attached_droplet_id is not None:
266            self.attach_detach_block_storage(
267                "detach", volume_name, region, attached_droplet_id
268            )
269        response = self.rest.delete(url)
270        status = response.status_code
271        json = response.json
272        if status == 204:
273            self.module.exit_json(changed=True)
274        elif status == 404:
275            self.module.exit_json(changed=False)
276        else:
277            raise DOBlockStorageException(json["message"])
278
279    def attach_block_storage(self):
280        volume_name = self.get_key_or_fail("volume_name")
281        region = self.get_key_or_fail("region")
282        droplet_id = self.get_key_or_fail("droplet_id")
283        attached_droplet_id = self.get_attached_droplet_ID(volume_name, region)
284        if attached_droplet_id is not None:
285            if attached_droplet_id == droplet_id:
286                self.module.exit_json(changed=False)
287            else:
288                self.attach_detach_block_storage(
289                    "detach", volume_name, region, attached_droplet_id
290                )
291        changed_status = self.attach_detach_block_storage(
292            "attach", volume_name, region, droplet_id
293        )
294        self.module.exit_json(changed=changed_status)
295
296    def detach_block_storage(self):
297        volume_name = self.get_key_or_fail("volume_name")
298        region = self.get_key_or_fail("region")
299        droplet_id = self.get_key_or_fail("droplet_id")
300        changed_status = self.attach_detach_block_storage(
301            "detach", volume_name, region, droplet_id
302        )
303        self.module.exit_json(changed=changed_status)
304
305
306def handle_request(module):
307    block_storage = DOBlockStorage(module)
308    command = module.params["command"]
309    state = module.params["state"]
310    if command == "create":
311        if state == "present":
312            block_storage.create_block_storage()
313        elif state == "absent":
314            block_storage.delete_block_storage()
315    elif command == "attach":
316        if state == "present":
317            block_storage.attach_block_storage()
318        elif state == "absent":
319            block_storage.detach_block_storage()
320
321
322def main():
323    argument_spec = DigitalOceanHelper.digital_ocean_argument_spec()
324    argument_spec.update(
325        state=dict(choices=["present", "absent"], required=True),
326        command=dict(choices=["create", "attach"], required=True),
327        block_size=dict(type="int", required=False),
328        volume_name=dict(type="str", required=True),
329        description=dict(type="str"),
330        region=dict(type="str", required=False),
331        snapshot_id=dict(type="str", required=False),
332        droplet_id=dict(type="int"),
333    )
334
335    module = AnsibleModule(argument_spec=argument_spec)
336
337    try:
338        handle_request(module)
339    except DOBlockStorageException as e:
340        module.fail_json(msg=str(e), exception=traceback.format_exc())
341    except KeyError as e:
342        module.fail_json(msg="Unable to load %s" % e, exception=traceback.format_exc())
343
344
345if __name__ == "__main__":
346    main()
347