1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3#
4# (c) 2015, Patrick F. Marques <patrickfmarques@gmail.com>
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_floating_ip
15short_description: Manage DigitalOcean Floating IPs
16description:
17     - Create/delete/assign a floating IP.
18author: "Patrick Marques (@pmarques)"
19options:
20  state:
21    description:
22     - Indicate desired state of the target.
23    default: present
24    choices: ['present', 'absent']
25    type: str
26  ip:
27    description:
28     - Public IP address of the Floating IP. Used to remove an IP
29    type: str
30    aliases: ['id']
31  region:
32    description:
33     - The region that the Floating IP is reserved to.
34    type: str
35  droplet_id:
36    description:
37     - The Droplet that the Floating IP has been assigned to.
38    type: str
39  oauth_token:
40    description:
41     - DigitalOcean OAuth token.
42    required: true
43    type: str
44  timeout:
45    description:
46      - Floating IP creation timeout.
47    type: int
48    default: 30
49  validate_certs:
50    description:
51      - If set to C(no), the SSL certificates will not be validated.
52      - This should only set to C(no) used on personally controlled sites using self-signed certificates.
53    type: bool
54    default: true
55notes:
56  - Version 2 of DigitalOcean API is used.
57requirements:
58  - "python >= 2.6"
59"""
60
61
62EXAMPLES = r"""
63- name: "Create a Floating IP in region lon1"
64  community.digitalocean.digital_ocean_floating_ip:
65    state: present
66    region: lon1
67
68- name: "Create a Floating IP assigned to Droplet ID 123456"
69  community.digitalocean.digital_ocean_floating_ip:
70    state: present
71    droplet_id: 123456
72
73- name: "Delete a Floating IP with ip 1.2.3.4"
74  community.digitalocean.digital_ocean_floating_ip:
75    state: absent
76    ip: "1.2.3.4"
77
78"""
79
80
81RETURN = r"""
82# Digital Ocean API info https://developers.digitalocean.com/documentation/v2/#floating-ips
83data:
84    description: a DigitalOcean Floating IP resource
85    returned: success and no resource constraint
86    type: dict
87    sample: {
88      "action": {
89        "id": 68212728,
90        "status": "in-progress",
91        "type": "assign_ip",
92        "started_at": "2015-10-15T17:45:44Z",
93        "completed_at": null,
94        "resource_id": 758603823,
95        "resource_type": "floating_ip",
96        "region": {
97          "name": "New York 3",
98          "slug": "nyc3",
99          "sizes": [
100            "512mb",
101            "1gb",
102            "2gb",
103            "4gb",
104            "8gb",
105            "16gb",
106            "32gb",
107            "48gb",
108            "64gb"
109          ],
110          "features": [
111            "private_networking",
112            "backups",
113            "ipv6",
114            "metadata"
115          ],
116          "available": true
117        },
118        "region_slug": "nyc3"
119      }
120    }
121"""
122
123import json
124import time
125
126from ansible.module_utils.basic import AnsibleModule
127from ansible.module_utils.basic import env_fallback
128from ansible.module_utils.urls import fetch_url
129
130
131class Response(object):
132    def __init__(self, resp, info):
133        self.body = None
134        if resp:
135            self.body = resp.read()
136        self.info = info
137
138    @property
139    def json(self):
140        if not self.body:
141            if "body" in self.info:
142                return json.loads(self.info["body"])
143            return None
144        try:
145            return json.loads(self.body)
146        except ValueError:
147            return None
148
149    @property
150    def status_code(self):
151        return self.info["status"]
152
153
154class Rest(object):
155    def __init__(self, module, headers):
156        self.module = module
157        self.headers = headers
158        self.baseurl = "https://api.digitalocean.com/v2"
159
160    def _url_builder(self, path):
161        if path[0] == "/":
162            path = path[1:]
163        return "%s/%s" % (self.baseurl, path)
164
165    def send(self, method, path, data=None, headers=None):
166        url = self._url_builder(path)
167        data = self.module.jsonify(data)
168        timeout = self.module.params["timeout"]
169
170        resp, info = fetch_url(
171            self.module,
172            url,
173            data=data,
174            headers=self.headers,
175            method=method,
176            timeout=timeout,
177        )
178
179        # Exceptions in fetch_url may result in a status -1, the ensures a
180        if info["status"] == -1:
181            self.module.fail_json(msg=info["msg"])
182
183        return Response(resp, info)
184
185    def get(self, path, data=None, headers=None):
186        return self.send("GET", path, data, headers)
187
188    def put(self, path, data=None, headers=None):
189        return self.send("PUT", path, data, headers)
190
191    def post(self, path, data=None, headers=None):
192        return self.send("POST", path, data, headers)
193
194    def delete(self, path, data=None, headers=None):
195        return self.send("DELETE", path, data, headers)
196
197
198def wait_action(module, rest, ip, action_id, timeout=10):
199    end_time = time.time() + 10
200    while time.time() < end_time:
201        response = rest.get("floating_ips/{0}/actions/{1}".format(ip, action_id))
202        status_code = response.status_code
203        status = response.json["action"]["status"]
204        # TODO: check status_code == 200?
205        if status == "completed":
206            return True
207        elif status == "errored":
208            module.fail_json(
209                msg="Floating ip action error [ip: {0}: action: {1}]".format(
210                    ip, action_id
211                ),
212                data=json,
213            )
214
215    module.fail_json(
216        msg="Floating ip action timeout [ip: {0}: action: {1}]".format(ip, action_id),
217        data=json,
218    )
219
220
221def core(module):
222    api_token = module.params["oauth_token"]
223    state = module.params["state"]
224    ip = module.params["ip"]
225    droplet_id = module.params["droplet_id"]
226
227    rest = Rest(
228        module,
229        {
230            "Authorization": "Bearer {0}".format(api_token),
231            "Content-type": "application/json",
232        },
233    )
234
235    if state in ("present"):
236        if droplet_id is not None and module.params["ip"] is not None:
237            # Lets try to associate the ip to the specified droplet
238            associate_floating_ips(module, rest)
239        else:
240            create_floating_ips(module, rest)
241
242    elif state in ("absent"):
243        response = rest.delete("floating_ips/{0}".format(ip))
244        status_code = response.status_code
245        json_data = response.json
246        if status_code == 204:
247            module.exit_json(changed=True)
248        elif status_code == 404:
249            module.exit_json(changed=False)
250        else:
251            module.exit_json(changed=False, data=json_data)
252
253
254def get_floating_ip_details(module, rest):
255    ip = module.params["ip"]
256
257    response = rest.get("floating_ips/{0}".format(ip))
258    status_code = response.status_code
259    json_data = response.json
260    if status_code == 200:
261        return json_data["floating_ip"]
262    else:
263        module.fail_json(
264            msg="Error assigning floating ip [{0}: {1}]".format(
265                status_code, json_data["message"]
266            ),
267            region=module.params["region"],
268        )
269
270
271def assign_floating_id_to_droplet(module, rest):
272    ip = module.params["ip"]
273
274    payload = {
275        "type": "assign",
276        "droplet_id": module.params["droplet_id"],
277    }
278
279    response = rest.post("floating_ips/{0}/actions".format(ip), data=payload)
280    status_code = response.status_code
281    json_data = response.json
282    if status_code == 201:
283        wait_action(module, rest, ip, json_data["action"]["id"])
284
285        module.exit_json(changed=True, data=json_data)
286    else:
287        module.fail_json(
288            msg="Error creating floating ip [{0}: {1}]".format(
289                status_code, json_data["message"]
290            ),
291            region=module.params["region"],
292        )
293
294
295def associate_floating_ips(module, rest):
296    floating_ip = get_floating_ip_details(module, rest)
297    droplet = floating_ip["droplet"]
298
299    # TODO: If already assigned to a droplet verify if is one of the specified as valid
300    if droplet is not None and str(droplet["id"]) in [module.params["droplet_id"]]:
301        module.exit_json(changed=False)
302    else:
303        assign_floating_id_to_droplet(module, rest)
304
305
306def create_floating_ips(module, rest):
307    payload = {}
308
309    if module.params["region"] is not None:
310        payload["region"] = module.params["region"]
311    if module.params["droplet_id"] is not None:
312        payload["droplet_id"] = module.params["droplet_id"]
313
314    # Get existing floating IPs
315    response = rest.get("floating_ips/")
316    status_code = response.status_code
317    json_data = response.json
318
319    # Exit unchanged if any of them are assigned to this Droplet already
320    if status_code == 200:
321        floating_ips = json_data.get("floating_ips", [])
322        if len(floating_ips) != 0:
323            for floating_ip in floating_ips:
324                droplet = floating_ip.get("droplet", None)
325                if droplet is not None:
326                    droplet_id = droplet.get("id", None)
327                    if droplet_id is not None:
328                        if str(droplet_id) == module.params["droplet_id"]:
329                            ip = floating_ip.get("ip", None)
330                            if ip is not None:
331                                module.exit_json(
332                                    changed=False, data={"floating_ip": ip}
333                                )
334                            else:
335                                module.fail_json(
336                                    changed=False,
337                                    msg="Unexpected error querying floating ip",
338                                )
339
340    response = rest.post("floating_ips", data=payload)
341    status_code = response.status_code
342    json_data = response.json
343    if status_code == 202:
344        module.exit_json(changed=True, data=json_data)
345    else:
346        module.fail_json(
347            msg="Error creating floating ip [{0}: {1}]".format(
348                status_code, json_data["message"]
349            ),
350            region=module.params["region"],
351        )
352
353
354def main():
355    module = AnsibleModule(
356        argument_spec=dict(
357            state=dict(choices=["present", "absent"], default="present"),
358            ip=dict(aliases=["id"], required=False),
359            region=dict(required=False),
360            droplet_id=dict(required=False),
361            oauth_token=dict(
362                no_log=True,
363                # Support environment variable for DigitalOcean OAuth Token
364                fallback=(
365                    env_fallback,
366                    ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"],
367                ),
368                required=True,
369            ),
370            validate_certs=dict(type="bool", default=True),
371            timeout=dict(type="int", default=30),
372        ),
373        required_if=[("state", "delete", ["ip"])],
374        mutually_exclusive=[["region", "droplet_id"]],
375    )
376
377    core(module)
378
379
380if __name__ == "__main__":
381    main()
382