diff --git a/lib/ansible/module_utils/net_tools/netbox/netbox_utils.py b/lib/ansible/module_utils/net_tools/netbox/netbox_utils.py index fa3441346f9..e9efceb8028 100644 --- a/lib/ansible/module_utils/net_tools/netbox/netbox_utils.py +++ b/lib/ansible/module_utils/net_tools/netbox/netbox_utils.py @@ -7,16 +7,27 @@ __metaclass__ = type API_APPS_ENDPOINTS = dict( circuits=[], - dcim=["device_roles", "device_types", "devices", "interfaces", "platforms", "racks", "sites"], + dcim=[ + "devices", + "device_roles", + "device_types", + "devices", + "interfaces", + "platforms", + "racks", + "regions", + "sites", + ], extras=[], - ipam=["ip_addresses", "prefixes", "vrfs"], + ipam=["ip_addresses", "prefixes", "roles", "vlans", "vlan_groups", "vrfs"], secrets=[], tenancy=["tenants", "tenant_groups"], - virtualization=["clusters"] + virtualization=["clusters"], ) QUERY_TYPES = dict( cluster="name", + devices="name", device_role="slug", device_type="slug", manufacturer="slug", @@ -28,17 +39,23 @@ QUERY_TYPES = dict( primary_ip6="address", rack="slug", region="slug", + role="slug", site="slug", - tenant="slug", + tenant="name", tenant_group="slug", - vrf="name" + time_zone="timezone", + vlan="name", + vlan_group="slug", + vrf="name", ) CONVERT_TO_ID = dict( cluster="clusters", + device="devices", device_role="device_roles", device_type="device_types", interface="interfaces", + lag="interfaces", nat_inside="ip_addresses", nat_outside="ip_addresses", platform="platforms", @@ -46,65 +63,193 @@ CONVERT_TO_ID = dict( primary_ip4="ip_addresses", primary_ip6="ip_addresses", rack="racks", + region="regions", + role="roles", site="sites", + tagged_vlans="vlans", tenant="tenants", tenant_group="tenant_groups", - vrf="vrfs" + untagged_vlan="vlans", + vlan="vlans", + vlan_group="vlan_groups", + vrf="vrfs", ) -FACE_ID = dict( - front=0, - rear=1 +FACE_ID = dict(front=0, rear=1) + +NO_DEFAULT_ID = set( + [ + "device", + "lag", + "primary_ip", + "primary_ip4", + "primary_ip6", + "role", + "vlan", + "vrf", + "nat_inside", + "nat_outside", + "region", + "untagged_vlan", + "tagged_vlans", + "tenant", + ] ) -NO_DEFAULT_ID = set([ - "primary_ip", - "primary_ip4", - "primary_ip6", - "vrf", - "nat_inside", - "nat_outside" -]) +DEVICE_STATUS = dict(offline=0, active=1, planned=2, staged=3, failed=4, inventory=5) -DEVICE_STATUS = dict( - offline=0, - active=1, - planned=2, - staged=3, - failed=4, - inventory=5 -) - -IP_ADDRESS_STATUS = dict( - active=1, - reserved=2, - deprecated=3, - dhcp=5 -) +IP_ADDRESS_STATUS = dict(active=1, reserved=2, deprecated=3, dhcp=5) IP_ADDRESS_ROLE = dict( - loopback=10, - secondary=20, - anycast=30, - vip=40, - vrrp=41, - hsrp=42, - glbp=43, - carp=44 + loopback=10, secondary=20, anycast=30, vip=40, vrrp=41, hsrp=42, glbp=43, carp=44 ) -PREFIX_STATUS = dict( - container=0, - active=1, - reserved=2, - deprecated=3 -) +PREFIX_STATUS = dict(container=0, active=1, reserved=2, deprecated=3) -VLAN_STATUS = dict( - active=1, - reserved=2, - deprecated=3 -) +VLAN_STATUS = dict(active=1, reserved=2, deprecated=3) + +SITE_STATUS = dict(active=1, planned=2, retired=4) + +INTF_FORM_FACTOR = { + "virtual": 0, + "link aggregation group (lag)": 200, + "100base-tx (10/100me)": 800, + "1000base-t (1ge)": 1000, + "10gbase-t (10ge)": 1150, + "10gbase-cx4 (10ge)": 1170, + "gbic (1ge)": 1050, + "sfp (1ge)": 1100, + "sfp+ (10ge)": 1200, + "xfp (10ge)": 1300, + "xenpak (10ge)": 1310, + "x2 (10ge)": 1320, + "sfp28 (25ge)": 1350, + "qsfp+ (40ge)": 1400, + "cfp (100ge)": 1500, + "cfp2 (100ge)": 1510, + "cfp2 (200ge)": 1650, + "cfp4 (100ge)": 1520, + "cisco cpak (100ge)": 1550, + "qsfp28 (100ge)": 1600, + "qsfp56 (200ge)": 1700, + "qsfp-dd (400ge)": 1750, + "ieee 802.11a": 2600, + "ieee 802.11b/g": 2610, + "ieee 802.11n": 2620, + "ieee 802.11ac": 2630, + "ieee 802.11ad": 2640, + "gsm": 2810, + "cdma": 2820, + "lte": 2830, + "oc-3/stm-1": 6100, + "oc-12/stm-4": 6200, + "oc-48/stm-16": 6300, + "oc-192/stm-64": 6400, + "oc-768/stm-256": 6500, + "oc-1920/stm-640": 6600, + "oc-3840/stm-1234": 6700, + "sfp (1gfc)": 3010, + "sfp (2gfc)": 3020, + "sfp (4gfc)": 3040, + "sfp+ (8gfc)": 3080, + "sfp+ (16gfc)": 3160, + "sfp28 (32gfc)": 3320, + "qsfp28 (128gfc)": 3400, + "t1 (1.544 mbps)": 4000, + "e1 (2.048 mbps)": 4010, + "t3 (45 mbps)": 4040, + "e3 (34 mbps)": 4050, + "cisco stackwise": 5000, + "cisco stackwise plus": 5050, + "cisco flexstack": 5100, + "cisco flexstack plus": 5150, + "juniper vcp": 5200, + "extreme summitstack": 5300, + "extreme summitstack-128": 5310, + "extreme summitstack-256": 5320, + "extreme summitstack-512": 5330, + "other": 32767, +} + +INTF_MODE = {"access": 100, "tagged": 200, "tagged all": 300} + +ALLOWED_QUERY_PARAMS = { + "interface": set(["name", "device"]), + "lag": set(["name"]), + "nat_inside": set(["vrf", "address"]), + "vlan": set(["name", "site", "vlan_group", "tenant"]), + "untagged_vlan": set(["name", "site", "vlan_group", "tenant"]), + "tagged_vlans": set(["name", "site", "vlan_group", "tenant"]), +} + +QUERY_PARAMS_IDS = set(["vrf", "site", "vlan_group", "tenant"]) + + +def _build_diff(before=None, after=None): + return {"before": before, "after": after} + + +def create_netbox_object(nb_endpoint, data, check_mode): + """Create a Netbox object. + :returns tuple(serialized_nb_obj, diff): tuple of the serialized created + Netbox object and the Ansible diff. + """ + if check_mode: + serialized_nb_obj = data + else: + serialized_nb_obj = nb_endpoint.create(data).serialize() + + diff = _build_diff(before={"state": "absent"}, after={"state": "present"}) + return serialized_nb_obj, diff + + +def delete_netbox_object(nb_obj, check_mode): + """Delete a Netbox object. + :returns tuple(serialized_nb_obj, diff): tuple of the serialized deleted + Netbox object and the Ansible diff. + """ + if not check_mode: + nb_obj.delete() + + diff = _build_diff(before={"state": "present"}, after={"state": "absent"}) + return nb_obj.serialize(), diff + + +def update_netbox_object(nb_obj, data, check_mode): + """Update a Netbox object. + :returns tuple(serialized_nb_obj, diff): tuple of the serialized updated + Netbox object and the Ansible diff. + """ + serialized_nb_obj = nb_obj.serialize() + updated_obj = serialized_nb_obj.copy() + updated_obj.update(data) + if serialized_nb_obj == updated_obj: + return serialized_nb_obj, None + else: + data_before, data_after = {}, {} + for key in data: + if serialized_nb_obj[key] != updated_obj[key]: + data_before[key] = serialized_nb_obj[key] + data_after[key] = updated_obj[key] + + if not check_mode: + nb_obj.update(data) + udpated_obj = nb_obj.serialize() + + diff = _build_diff(before=data_before, after=data_after) + return updated_obj, diff + + +def _get_query_param_id(nb, match, child): + endpoint = CONVERT_TO_ID[match] + app = find_app(endpoint) + nb_app = getattr(nb, app) + nb_endpoint = getattr(nb_app, endpoint) + result = nb_endpoint.get(**{QUERY_TYPES.get(match): child[match]}) + if result: + return result.id + else: + return child def find_app(endpoint): @@ -114,6 +259,28 @@ def find_app(endpoint): return nb_app +def build_query_params(nb, parent, module_data, child): + query_dict = dict() + query_params = ALLOWED_QUERY_PARAMS.get(parent) + matches = query_params.intersection(set(child.keys())) + for match in matches: + if match in QUERY_PARAMS_IDS: + value = _get_query_param_id(nb, match, child) + query_dict.update({match + "_id": value}) + else: + value = child.get(match) + query_dict.update({match: value}) + + if parent == "lag": + query_dict.update({"form_factor": 200}) + if isinstance(module_data["device"], int): + query_dict.update({"device_id": module_data["device"]}) + else: + query_dict.update({"device": module_data["device"]}) + + return query_dict + + def find_ids(nb, data): for k, v in data.items(): if k in CONVERT_TO_ID: @@ -123,38 +290,62 @@ def find_ids(nb, data): nb_app = getattr(nb, app) nb_endpoint = getattr(nb_app, endpoint) - if k == "interface": - query_id = nb_endpoint.get(**{"name": v["name"], "device": v["device"]}) - elif k == "nat_inside": - if v.get("vrf"): - vrf_id = nb.ipam.vrfs.get(**{"name": v["vrf"]}) - query_id = nb_endpoint.get(**{"address": v["address"], "vrf_id": vrf_id.id}) - else: - try: - query_id = nb_endpoint.get(**{"address": v["address"]}) - except ValueError: - return {"failed": "Multiple results found while searching for %s: %s - Specify a VRF within %s" % (k, v["address"], k)} - else: - query_id = nb_endpoint.get(**{QUERY_TYPES.get(k, "q"): search}) + if isinstance(v, dict): + query_params = build_query_params(nb, k, data, v) + query_id = nb_endpoint.get(**query_params) + + elif isinstance(v, list): + id_list = list() + for index in v: + norm_data = normalize_data(index) + temp_dict = build_query_params(nb, k, data, norm_data) + query_id = nb_endpoint.get(**temp_dict) + if query_id: + id_list.append(query_id.id) + else: + return ValueError("%s not found" % (index)) - if query_id: - data[k] = query_id.id - elif k in NO_DEFAULT_ID: - pass else: - data[k] = 1 + try: + query_id = nb_endpoint.get(**{QUERY_TYPES.get(k, "q"): search}) + except ValueError: + return ValueError( + "Multiple results found while searching for key: %s" % (k) + ) + + if isinstance(v, list): + data[k] = id_list + elif query_id: + data[k] = query_id.id + else: + raise ValueError("Could not resolve id of %s: %s" % (k, v)) return data def normalize_data(data): for k, v in data.items(): - data_type = QUERY_TYPES.get(k, "q") - if data_type == "slug": - if "-" in v: - data[k] = v.replace(" ", "").lower() - elif " " in v: - data[k] = v.replace(" ", "-").lower() - else: - data[k] = v.lower() + if isinstance(v, dict): + for subk, subv in v.items(): + sub_data_type = QUERY_TYPES.get(subk, "q") + if sub_data_type == "slug": + if "-" in subv: + data[k][subk] = subv.replace(" ", "").lower() + elif " " in subv: + data[k][subk] = subv.replace(" ", "-").lower() + else: + data[k][subk] = subv.lower() + else: + data_type = QUERY_TYPES.get(k, "q") + if data_type == "slug": + if "-" in v: + data[k] = v.replace(" ", "").lower() + elif " " in v: + data[k] = v.replace(" ", "-").lower() + else: + data[k] = v.lower() + elif data_type == "timezone": + if " " in v: + data[k] = v.replace(" ", "_") + return data diff --git a/lib/ansible/modules/net_tools/netbox/netbox_site.py b/lib/ansible/modules/net_tools/netbox/netbox_site.py new file mode 100644 index 00000000000..372aa3fb208 --- /dev/null +++ b/lib/ansible/modules/net_tools/netbox/netbox_site.py @@ -0,0 +1,348 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Mikhail Yohman (@fragmentedpacket) <mikhail.yohman@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {"metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: netbox_site +short_description: Creates or removes sites from Netbox +description: + - Creates or removes sites from Netbox +notes: + - Tags should be defined as a YAML list + - This should be ran with connection C(local) and hosts C(localhost) +author: + - Mikhail Yohman (@FragmentedPacket) +requirements: + - pynetbox +version_added: "2.8" +options: + netbox_url: + description: + - URL of the Netbox instance resolvable by Ansible control host + required: true + type: str + netbox_token: + description: + - The token created within Netbox to authorize API access + required: true + type: str + data: + description: + - Defines the site configuration + suboptions: + name: + description: + - Name of the site to be created + required: true + type: str + status: + description: + - Status of the site + choices: + - Active + - Planned + - Retired + type: str + region: + description: + - The region that the site should be associated with + type: str + tenant: + description: + - The tenant the site will be assigned to + type: str + facility: + description: + - Data center provider or facility, ex. Equinix NY7 + type: str + asn: + description: + - The ASN associated with the site + type: int + time_zone: + description: + - Timezone associated with the site, ex. America/Denver + type: str + description: + description: + - The description of the prefix + type: str + physical_address: + description: + - Physical address of site + type: str + shipping_address: + description: + - Shipping address of site + type: str + latitude: + description: + - Latitude in decimal format + type: int + longitude: + description: + - Longitude in decimal format + type: int + contact_name: + description: + - Name of contact for site + type: str + contact_phone: + description: + - Contact phone number for site + type: str + contact_email: + description: + - Contact email for site + type: str + comments: + description: + - Comments for the site. This can be markdown syntax + type: str + tags: + description: + - Any tags that the prefix may need to be associated with + type: list + custom_fields: + description: + - must exist in Netbox + type: dict + required: true + state: + description: + - Use C(present) or C(absent) for adding or removing. + choices: [ absent, present ] + default: present + type: str + validate_certs: + description: + - | + If C(no), SSL certificates will not be validated. + This should only be used on personally controlled sites using self-signed certificates. + default: "yes" + type: bool +""" + +EXAMPLES = r""" +- name: "Test Netbox site module" + connection: local + hosts: localhost + gather_facts: False + tasks: + - name: Create site within Netbox with only required information + netbox_site: + netbox_url: http://netbox.local + netbox_token: thisIsMyToken + data: + name: Test - Colorado + state: present + + - name: Delete site within netbox + netbox_site: + netbox_url: http://netbox.local + netbox_token: thisIsMyToken + data: + name: Test - Colorado + state: absent + + - name: Create site with all parameters + netbox_site: + netbox_url: http://netbox.local + netbox_token: thisIsMyToken + data: + name: Test - California + status: Planned + region: Test Region + tenant: Test Tenant + facility: EquinoxCA7 + asn: 65001 + time_zone: America/Los Angeles + description: This is a test description + physical_address: Hollywood, CA, 90210 + shipping_address: Hollywood, CA, 90210 + latitude: 10.100000 + longitude: 12.200000 + contact_name: Jenny + contact_phone: 867-5309 + contact_email: jenny@changednumber.com + comments: ### Placeholder + state: present +""" + +RETURN = r""" +site: + description: Serialized object as created or already existent within Netbox + returned: on creation + type: dict +msg: + description: Message indicating failure or info about what has been achieved + returned: always + type: str +""" + +import json +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.net_tools.netbox.netbox_utils import ( + find_ids, + normalize_data, + create_netbox_object, + delete_netbox_object, + update_netbox_object, + SITE_STATUS, +) +from ansible.module_utils.compat import ipaddress +from ansible.module_utils._text import to_text + + +PYNETBOX_IMP_ERR = None +try: + import pynetbox + HAS_PYNETBOX = True +except ImportError: + PYNETBOX_IMP_ERR = traceback.format_exc() + HAS_PYNETBOX = False + + +def main(): + """ + Main entry point for module execution + """ + argument_spec = dict( + netbox_url=dict(type="str", required=True), + netbox_token=dict(type="str", required=True, no_log=True), + data=dict(type="dict", required=True), + state=dict(required=False, default="present", choices=["present", "absent"]), + validate_certs=dict(type="bool", default=True) + ) + + global module + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + # Fail module if pynetbox is not installed + if not HAS_PYNETBOX: + module.fail_json(msg=missing_required_lib('pynetbox'), exception=PYNETBOX_IMP_ERR) + # Assign variables to be used with module + app = "dcim" + endpoint = "sites" + url = module.params["netbox_url"] + token = module.params["netbox_token"] + data = module.params["data"] + state = module.params["state"] + validate_certs = module.params["validate_certs"] + # Attempt to create Netbox API object + try: + nb = pynetbox.api(url, token=token, ssl_verify=validate_certs) + except Exception: + module.fail_json(msg="Failed to establish connection to Netbox API") + try: + nb_app = getattr(nb, app) + except AttributeError: + module.fail_json(msg="Incorrect application specified: %s" % (app)) + nb_endpoint = getattr(nb_app, endpoint) + norm_data = normalize_data(data) + try: + norm_data = _check_and_adapt_data(nb, norm_data) + + if "present" in state: + return module.exit_json( + **ensure_site_present(nb, nb_endpoint, norm_data) + ) + else: + return module.exit_json( + **ensure_site_absent(nb, nb_endpoint, norm_data) + ) + except pynetbox.RequestError as e: + return module.fail_json(msg=json.loads(e.error)) + except ValueError as e: + return module.fail_json(msg=str(e)) + except AttributeError as e: + return module.fail_json(msg=str(e)) + + +def _check_and_adapt_data(nb, data): + data = find_ids(nb, data) + + if data.get("status"): + data["status"] = SITE_STATUS.get(data["status"].lower()) + + if "-" in data["name"]: + site_slug = data["name"].replace(" ", "").lower() + elif " " in data["name"]: + site_slug = data["name"].replace(" ", "-").lower() + else: + site_slug = data["name"].lower() + + data["slug"] = site_slug + + return data + + +def ensure_site_present(nb, nb_endpoint, data): + """ + :returns dict(interface, msg, changed): dictionary resulting of the request, + where 'site' is the serialized interface fetched or newly created in Netbox + """ + + if not isinstance(data, dict): + changed = False + return {"msg": data, "changed": changed} + + nb_site = nb_endpoint.get(slug=data["slug"]) + result = dict() + if not nb_site: + site, diff = create_netbox_object(nb_endpoint, data, module.check_mode) + changed = True + msg = "Site %s created" % (data["name"]) + result["diff"] = diff + else: + site, diff = update_netbox_object(nb_site, data, module.check_mode) + if site is False: + module.fail_json( + msg="Request failed, couldn't update device: %s" % (data["name"]) + ) + if diff: + msg = "Site %s updated" % (data["name"]) + changed = True + result["diff"] = diff + else: + msg = "Site %s already exists" % (data["name"]) + changed = False + + result.update({"site": site, "msg": msg, "changed": changed}) + return result + + +def ensure_site_absent(nb, nb_endpoint, data): + """ + :returns dict(msg, changed) + """ + nb_site = nb_endpoint.get(slug=data["slug"]) + result = dict() + if nb_site: + dummy, diff = delete_netbox_object(nb_site, module.check_mode) + changed = True + msg = "Site %s deleted" % (data["name"]) + result["diff"] = diff + else: + msg = "Site %s already absent" % (data["name"]) + changed = False + + result.update({"msg": msg, "changed": changed}) + return result + + +if __name__ == "__main__": + main()