1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3
4# Copyright: (c) 2018, Mikhail Yohman (@FragmentedPacket) <mikhail.yohman@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__metaclass__ = type
9
10ANSIBLE_METADATA = {'metadata_version': '1.1',
11                    'status': ['preview'],
12                    'supported_by': 'community'}
13
14DOCUMENTATION = r'''
15---
16module: netbox_ip_address
17short_description: Creates or removes IP addresses from Netbox
18description:
19  - Creates or removes IP addresses from Netbox
20notes:
21  - Tags should be defined as a YAML list
22  - This should be ran with connection C(local) and hosts C(localhost)
23author:
24  - Mikhail Yohman (@FragmentedPacket)
25  - Anthony Ruhier (@Anthony25)
26requirements:
27  - pynetbox
28version_added: '2.8'
29options:
30  netbox_url:
31    description:
32      - URL of the Netbox instance resolvable by Ansible control host
33    required: true
34  netbox_token:
35    description:
36      - The token created within Netbox to authorize API access
37    required: true
38  data:
39    description:
40      - Defines the IP address configuration
41    suboptions:
42      family:
43        description:
44          - Specifies with address family the IP address belongs to
45        choices:
46          - 4
47          - 6
48      address:
49        description:
50          - Required if state is C(present)
51      prefix:
52        description:
53          - |
54            With state C(present), if an interface is given, it will ensure
55            that an IP inside this prefix (and vrf, if given) is attached
56            to this interface. Otherwise, it will get the next available IP
57            of this prefix and attach it.
58            With state C(new), it will force to get the next available IP in
59            this prefix. If an interface is given, it will also force to attach
60            it.
61            Required if state is C(present) or C(new) when no address is given.
62            Unused if an address is specified.
63      vrf:
64        description:
65          - VRF that IP address is associated with
66      tenant:
67        description:
68          - The tenant that the device will be assigned to
69      status:
70        description:
71          - The status of the IP address
72        choices:
73          - Active
74          - Reserved
75          - Deprecated
76          - DHCP
77      role:
78        description:
79          - The role of the IP address
80        choices:
81          - Loopback
82          - Secondary
83          - Anycast
84          - VIP
85          - VRRP
86          - HSRP
87          - GLBP
88          - CARP
89      interface:
90        description:
91          - |
92            The name and device of the interface that the IP address should be assigned to
93            Required if state is C(present) and a prefix specified.
94      description:
95        description:
96          - The description of the interface
97      nat_inside:
98        description:
99          - The inside IP address this IP is assigned to
100      tags:
101        description:
102          - Any tags that the IP address may need to be associated with
103      custom_fields:
104        description:
105          - must exist in Netbox
106    required: true
107  state:
108    description:
109      - |
110        Use C(present), C(new) or C(absent) for adding, force adding or removing.
111        C(present) will check if the IP is already created, and return it if
112        true. C(new) will force to create it anyway (useful for anycasts, for
113        example).
114    choices: [ absent, new, present ]
115    default: present
116  validate_certs:
117    description:
118      - If C(no), SSL certificates will not be validated. This should only be used on personally controlled sites using self-signed certificates.
119    default: 'yes'
120    type: bool
121'''
122
123EXAMPLES = r'''
124- name: "Test Netbox IP address module"
125  connection: local
126  hosts: localhost
127  gather_facts: False
128
129  tasks:
130    - name: Create IP address within Netbox with only required information
131      netbox_ip_address:
132        netbox_url: http://netbox.local
133        netbox_token: thisIsMyToken
134        data:
135          address: 192.168.1.10
136        state: present
137    - name: Force to create (even if it already exists) the IP
138      netbox_ip_address:
139        netbox_url: http://netbox.local
140        netbox_token: thisIsMyToken
141        data:
142          address: 192.168.1.10
143        state: new
144    - name: Get a new available IP inside 192.168.1.0/24
145      netbox_ip_address:
146        netbox_url: http://netbox.local
147        netbox_token: thisIsMyToken
148        data:
149          prefix: 192.168.1.0/24
150        state: new
151    - name: Delete IP address within netbox
152      netbox_ip_address:
153        netbox_url: http://netbox.local
154        netbox_token: thisIsMyToken
155        data:
156          address: 192.168.1.10
157        state: absent
158    - name: Create IP address with several specified options
159      netbox_ip_address:
160        netbox_url: http://netbox.local
161        netbox_token: thisIsMyToken
162        data:
163          family: 4
164          address: 192.168.1.20
165          vrf: Test
166          tenant: Test Tenant
167          status: Reserved
168          role: Loopback
169          description: Test description
170          tags:
171            - Schnozzberry
172        state: present
173    - name: Create IP address and assign a nat_inside IP
174      netbox_ip_address:
175        netbox_url: http://netbox.local
176        netbox_token: thisIsMyToken
177        data:
178          family: 4
179          address: 192.168.1.30
180          vrf: Test
181          nat_inside:
182            address: 192.168.1.20
183            vrf: Test
184          interface:
185            name: GigabitEthernet1
186            device: test100
187    - name: Ensure that an IP inside 192.168.1.0/24 is attached to GigabitEthernet1
188      netbox_ip_address:
189        netbox_url: http://netbox.local
190        netbox_token: thisIsMyToken
191        data:
192          prefix: 192.168.1.0/24
193          vrf: Test
194          interface:
195            name: GigabitEthernet1
196            device: test100
197        state: present
198    - name: Attach a new available IP of 192.168.1.0/24 to GigabitEthernet1
199      netbox_ip_address:
200        netbox_url: http://netbox.local
201        netbox_token: thisIsMyToken
202        data:
203          prefix: 192.168.1.0/24
204          vrf: Test
205          interface:
206            name: GigabitEthernet1
207            device: test100
208        state: new
209'''
210
211RETURN = r'''
212ip_address:
213  description: Serialized object as created or already existent within Netbox
214  returned: on creation
215  type: dict
216msg:
217  description: Message indicating failure or info about what has been achieved
218  returned: always
219  type: str
220'''
221
222import json
223import traceback
224
225from ansible.module_utils.basic import AnsibleModule, missing_required_lib
226from ansible.module_utils.net_tools.netbox.netbox_utils import (
227    find_ids,
228    normalize_data,
229    create_netbox_object,
230    delete_netbox_object,
231    update_netbox_object,
232    IP_ADDRESS_ROLE,
233    IP_ADDRESS_STATUS
234)
235from ansible.module_utils.compat import ipaddress
236from ansible.module_utils._text import to_text
237
238
239PYNETBOX_IMP_ERR = None
240try:
241    import pynetbox
242    HAS_PYNETBOX = True
243except ImportError:
244    PYNETBOX_IMP_ERR = traceback.format_exc()
245    HAS_PYNETBOX = False
246
247
248def main():
249    '''
250    Main entry point for module execution
251    '''
252    argument_spec = dict(
253        netbox_url=dict(type="str", required=True),
254        netbox_token=dict(type="str", required=True, no_log=True),
255        data=dict(type="dict", required=True),
256        state=dict(required=False, default='present', choices=['present', 'absent', 'new']),
257        validate_certs=dict(type="bool", default=True)
258    )
259
260    global module
261    module = AnsibleModule(argument_spec=argument_spec,
262                           supports_check_mode=True)
263
264    # Fail module if pynetbox is not installed
265    if not HAS_PYNETBOX:
266        module.fail_json(msg=missing_required_lib('pynetbox'), exception=PYNETBOX_IMP_ERR)
267
268    # Assign variables to be used with module
269    changed = False
270    app = 'ipam'
271    endpoint = 'ip_addresses'
272    url = module.params["netbox_url"]
273    token = module.params["netbox_token"]
274    data = module.params["data"]
275    state = module.params["state"]
276    validate_certs = module.params["validate_certs"]
277
278    # Attempt to create Netbox API object
279    try:
280        nb = pynetbox.api(url, token=token, ssl_verify=validate_certs)
281    except Exception:
282        module.fail_json(msg="Failed to establish connection to Netbox API")
283    try:
284        nb_app = getattr(nb, app)
285    except AttributeError:
286        module.fail_json(msg="Incorrect application specified: %s" % (app))
287
288    nb_endpoint = getattr(nb_app, endpoint)
289    norm_data = normalize_data(data)
290    try:
291        norm_data = _check_and_adapt_data(nb, norm_data)
292        if state in ("new", "present"):
293            return _handle_state_new_present(
294                module, state, nb_app, nb_endpoint, norm_data
295            )
296        elif state == "absent":
297            return module.exit_json(
298                **ensure_ip_address_absent(nb_endpoint, norm_data)
299            )
300        else:
301            return module.fail_json(msg="Invalid state %s" % state)
302    except pynetbox.RequestError as e:
303        return module.fail_json(msg=json.loads(e.error))
304    except ValueError as e:
305        return module.fail_json(msg=str(e))
306
307
308def _check_and_adapt_data(nb, data):
309    data = find_ids(nb, data)
310
311    if data.get("vrf") and not isinstance(data["vrf"], int):
312        raise ValueError(
313            "%s does not exist - Please create VRF" % (data["vrf"])
314        )
315    if data.get("status"):
316        data["status"] = IP_ADDRESS_STATUS.get(data["status"].lower())
317    if data.get("role"):
318        data["role"] = IP_ADDRESS_ROLE.get(data["role"].lower())
319
320    return data
321
322
323def _handle_state_new_present(module, state, nb_app, nb_endpoint, data):
324    if data.get("address"):
325        if state == "present":
326            return module.exit_json(
327                **ensure_ip_address_present(nb_endpoint, data)
328            )
329        elif state == "new":
330            return module.exit_json(
331                **create_ip_address(nb_endpoint, data)
332            )
333    else:
334        if state == "present":
335            return module.exit_json(
336                **ensure_ip_in_prefix_present_on_netif(
337                    nb_app, nb_endpoint, data
338                )
339            )
340        elif state == "new":
341            return module.exit_json(
342                **get_new_available_ip_address(nb_app, data)
343            )
344
345
346def ensure_ip_address_present(nb_endpoint, data):
347    """
348    :returns dict(ip_address, msg, changed): dictionary resulting of the request,
349    where 'ip_address' is the serialized ip fetched or newly created in Netbox
350    """
351    if not isinstance(data, dict):
352        changed = False
353        return {"msg": data, "changed": changed}
354
355    try:
356        nb_addr = _search_ip(nb_endpoint, data)
357    except ValueError:
358        return _error_multiple_ip_results(data)
359
360    result = {}
361    if not nb_addr:
362        return create_ip_address(nb_endpoint, data)
363    else:
364        ip_addr, diff = update_netbox_object(nb_addr, data, module.check_mode)
365        if ip_addr is False:
366            module.fail_json(
367                msg="Request failed, couldn't update IP: %s" % (data["address"])
368            )
369        if diff:
370            msg = "IP Address %s updated" % (data["address"])
371            changed = True
372            result["diff"] = diff
373        else:
374            ip_addr = nb_addr.serialize()
375            changed = False
376            msg = "IP Address %s already exists" % (data["address"])
377
378        return {"ip_address": ip_addr, "msg": msg, "changed": changed}
379
380
381def _search_ip(nb_endpoint, data):
382    get_query_params = {"address": data["address"]}
383    if data.get("vrf"):
384        get_query_params["vrf_id"] = data["vrf"]
385
386    ip_addr = nb_endpoint.get(**get_query_params)
387    return ip_addr
388
389
390def _error_multiple_ip_results(data):
391    changed = False
392    if "vrf" in data:
393        return {"msg": "Returned more than result", "changed": changed}
394    else:
395        return {
396            "msg": "Returned more than one result - Try specifying VRF.",
397            "changed": changed
398        }
399
400
401def create_ip_address(nb_endpoint, data):
402    if not isinstance(data, dict):
403        changed = False
404        return {"msg": data, "changed": changed}
405
406    ip_addr, diff = create_netbox_object(nb_endpoint, data, module.check_mode)
407    changed = True
408    msg = "IP Addresses %s created" % (data["address"])
409
410    return {"ip_address": ip_addr, "msg": msg, "changed": changed, "diff": diff}
411
412
413def ensure_ip_in_prefix_present_on_netif(nb_app, nb_endpoint, data):
414    """
415    :returns dict(ip_address, msg, changed): dictionary resulting of the request,
416    where 'ip_address' is the serialized ip fetched or newly created in Netbox
417    """
418    if not isinstance(data, dict):
419        changed = False
420        return {"msg": data, "changed": changed}
421
422    if not data.get("interface") or not data.get("prefix"):
423        raise ValueError("A prefix and interface are required")
424
425    get_query_params = {
426        "interface_id": data["interface"], "parent": data["prefix"],
427    }
428    if data.get("vrf"):
429        get_query_params["vrf_id"] = data["vrf"]
430
431    attached_ips = nb_endpoint.filter(**get_query_params)
432    if attached_ips:
433        ip_addr = attached_ips[-1].serialize()
434        changed = False
435        msg = "IP Address %s already attached" % (ip_addr["address"])
436
437        return {"ip_address": ip_addr, "msg": msg, "changed": changed}
438    else:
439        return get_new_available_ip_address(nb_app, data)
440
441
442def get_new_available_ip_address(nb_app, data):
443    prefix_query = {"prefix": data["prefix"]}
444    if data.get("vrf"):
445        prefix_query["vrf_id"] = data["vrf"]
446
447    result = {}
448    prefix = nb_app.prefixes.get(**prefix_query)
449    if not prefix:
450        changed = False
451        msg = "%s does not exist - please create first" % (data["prefix"])
452        return {"msg": msg, "changed": changed}
453    elif prefix.available_ips.list():
454        ip_addr, diff = create_netbox_object(prefix.available_ips, data, module.check_mode)
455        changed = True
456        msg = "IP Addresses %s created" % (ip_addr["address"])
457        result["diff"] = diff
458    else:
459        changed = False
460        msg = "No available IPs available within %s" % (data['prefix'])
461        return {"msg": msg, "changed": changed}
462
463    result.update({"ip_address": ip_addr, "msg": msg, "changed": changed})
464    return result
465
466
467def _get_prefix_id(nb_app, prefix, vrf_id=None):
468    ipaddr_prefix = ipaddress.ip_network(prefix)
469    network = to_text(ipaddr_prefix.network_address)
470    mask = ipaddr_prefix.prefixlen
471
472    prefix_query_params = {
473        "prefix": network,
474        "mask_length": mask
475    }
476    if vrf_id:
477        prefix_query_params["vrf_id"] = vrf_id
478
479    prefix_id = nb_app.prefixes.get(prefix_query_params)
480    if not prefix_id:
481        if vrf_id:
482            raise ValueError("Prefix %s does not exist in VRF %s - Please create it" % (prefix, vrf_id))
483        else:
484            raise ValueError("Prefix %s does not exist - Please create it" % (prefix))
485
486    return prefix_id
487
488
489def ensure_ip_address_absent(nb_endpoint, data):
490    """
491    :returns dict(msg, changed)
492    """
493    if not isinstance(data, dict):
494        changed = False
495        return {"msg": data, "changed": changed}
496
497    try:
498        ip_addr = _search_ip(nb_endpoint, data)
499    except ValueError:
500        return _error_multiple_ip_results(data)
501
502    result = {}
503    if ip_addr:
504        dummy, diff = delete_netbox_object(ip_addr, module.check_mode)
505        changed = True
506        msg = "IP Address %s deleted" % (data["address"])
507        result["diff"] = diff
508    else:
509        changed = False
510        msg = "IP Address %s already absent" % (data["address"])
511
512    result.update({"msg": msg, "changed": changed})
513    return result
514
515
516if __name__ == "__main__":
517    main()
518