From 373d9613a1c577a3f2e4ecb9a5f6ed643a472998 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Tue, 21 Jan 2020 20:12:41 +0100 Subject: [PATCH 01/21] Improve vcenter host physical nic link speed logic (#39) * Improved handling of physical NIC link speed detection to fix #38 * Fixed enabled bool and adjusted formatting for #38 * Fix vCenter link speed call for #38 --- run.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/run.py b/run.py index be3916d..a6feeb1 100644 --- a/run.py +++ b/run.py @@ -424,7 +424,22 @@ def get_objects(self, vc_obj_type): "Collecting info for physical interface '%s'.", nic_name ) - pnic_up = pnic.spec.linkSpeed + # Try multiple methods of finding link speed + link_speed = pnic.spec.linkSpeed + if link_speed is None: + try: + link_speed = "{}Mbps ".format( + pnic.validLinkSpecification[0].speedMb + ) + except IndexError: + log.debug( + "No link speed detected for physical " + "interface '%s'.", nic_name + ) + else: + link_speed = "{}Mbps ".format( + pnic.spec.linkSpeed.speedMb + ) results["interfaces"].append( { "device": {"name": obj_name}, @@ -435,15 +450,10 @@ def get_objects(self, vc_obj_type): "name": nic_name, # Capitalized to match NetBox format "mac_address": pnic.mac.upper(), - "description": ( - "{}Mbps Physical Interface".format( - pnic.spec.linkSpeed.speedMb - ) if pnic_up - else "{}Mbps Physical Interface".format( - pnic.validLinkSpecification[0].speedMb - ) + "description": "{}Physical Interface".format( + link_speed ), - "enabled": bool(pnic_up), + "enabled": bool(link_speed), "tags": self.tags }) # Virtual Interfaces From c96345efc29d4f0a72e14ec2aab2ff9d60780f9c Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Tue, 21 Jan 2020 21:03:40 +0100 Subject: [PATCH 02/21] Improve compare_dicts function and cleanup optimizations (#40) * Cleanup dictionary comparision recursion match logic * Removed individual logging for virtual interfaces during cleanup func --- run.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/run.py b/run.py index a6feeb1..4ac912d 100644 --- a/run.py +++ b/run.py @@ -83,7 +83,7 @@ def compare_dicts(dict1, dict2, dict1_name="d1", dict2_name="d2", path=""): "%s and %s contain dictionary. Evaluating.", dict1_path, dict2_path ) - compare_dicts( + result = compare_dicts( dict1[key], dict2[key], dict1_name, dict2_name, path="[{}]".format(key) ) @@ -112,12 +112,12 @@ def compare_dicts(dict1, dict2, dict1_name="d1", dict2_name="d2", path=""): dict1_path, dict1[key], dict2_path, dict2[key] ) result = False - if not result: - log.debug( - "%s and %s values do not match.", dict1_path, dict2_path - ) - else: + if result: log.debug("%s and %s values match.", dict1_path, dict2_path) + else: + log.debug("%s and %s values do not match.", dict1_path, dict2_path) + return result + log.debug("Final dictionary compare result: %s", result) return result def format_ip(ip_addr): @@ -1220,22 +1220,22 @@ def remove_all(self): query=query )["results"] query_key = self.obj_map[nb_obj_type]["key"] + # NetBox virtual interfaces do not currently support filtering + # by tags. Therefore we collect all virtual interfaces and + # filter them post collection. + if nb_obj_type == "virtual_interfaces": + log.debug("Collected %s virtual interfaces pre-filtering.") + nb_objects = [ + obj for obj in nb_objects if self.vc_tag in obj["tags"] + ] + log.debug( + "Filtered to %s virtual interfaces with '%s' tag.", + len(nb_objects), self.vc_tag + ) log.info( "Deleting %s NetBox %s objects.", len(nb_objects), nb_obj_type ) for obj in nb_objects: - # NetBox virtual interfaces do not currently support filtering - # by tags. Therefore we accidentally collect all virtual - # virtual interfaces in our query so we need to make suer we - # only delete the relevant ones by checking tags - if nb_obj_type == "virtual_interfaces" \ - and self.vc_tag not in obj["tags"]: - log.debug( - "NetBox %s '%s' object does not contain '%s' tag. " - "Skipping deletion.", nb_obj_type, obj[query_key], - self.vc_tag - ) - continue log.info( "Deleting NetBox %s '%s' object.", nb_obj_type, obj[query_key] From 3a65dcb9d07b3b613420da849574d81488fe0767 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Thu, 30 Jan 2020 18:46:33 +0100 Subject: [PATCH 03/21] Adjusted banned asset tags to and improve error handling (#44) * Adjusted request debugs for #41 * Fixed formatting error * Handle blank strings in asset tags for #41 --- run.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/run.py b/run.py index 4ac912d..398c6e6 100644 --- a/run.py +++ b/run.py @@ -386,7 +386,7 @@ def get_objects(self, vc_obj_type): serial_number = None # Asset Tag if "AssetTag" in hw_idents.keys(): - banned_tags = ["Default string", "Unknown", " "] + banned_tags = ["Default string", "Unknown", " ", ""] asset_tag = truncate(hw_idents["AssetTag"], max_len=50) for btag in banned_tags: if btag.lower() in hw_idents["AssetTag"].lower(): @@ -728,11 +728,14 @@ def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): query if query else "", "{}/".format(nb_id) if nb_id else "" ) - log.debug("Sending %s to '%s'", req_type.upper(), url) + log.debug( + "Sending %s to '%s' with data '%s'.", req_type.upper(), url, data + ) req = getattr(self.nb_session, req_type)( url, json=data, timeout=10, verify=(not settings.NB_INSECURE_TLS) ) # Parse status + log.debug("Received HTTP Status %s.", req.status_code) if req.status_code == 200: log.debug( "NetBox %s request OK; returned %s status.", req_type.upper(), @@ -763,7 +766,7 @@ def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): "exist or the data sent is not acceptable.", nb_obj_type ) log.debug( - "NetBox %s status reason: %s", req.status_code, req.json() + "NetBox %s status reason: %s", req.status_code, req.text ) elif req_type == "put": log.warning( @@ -772,7 +775,7 @@ def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): req.status_code ) log.debug( - "NetBox %s status reason: %s", req.status_code, req.json() + "NetBox %s status reason: %s", req.status_code, req.text ) else: raise SystemExit( @@ -799,7 +802,7 @@ def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): "Well this in unexpected. Please report this. " "%s request received %s status with body '%s' and response " "'%s'.", - req_type.upper(), req.status_code, data, req.json() + req_type.upper(), req.status_code, data, req.text ) ) return result From 3f59a798c0e9bb900a9eacfbb3bc37b5882e84a7 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sat, 1 Feb 2020 18:54:40 +0100 Subject: [PATCH 04/21] Feature enhancement: Support unique credentials for vCenter hosts (#47) * Updated to support per host credentials for #43 * Updated build workflow as E1101 is expected with migration --- .github/workflows/build.yml | 2 +- run.py | 54 ++++++++++++++++++++++++++++++------- settings.example.py | 7 +++-- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 86bf1e0..e54ed9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - name: Lint with pylint run: | pip install pylint - pylint --disable=C0302,E0611,C0103,R0914,R1702,R0912,R0915,R0913 run.py + pylint --disable=C0302,E0611,E1101,C0103,R0914,R1702,R0912,R0915,R0913 run.py - name: Verify app compiles run: | python run.py --help diff --git a/run.py b/run.py index 398c6e6..aa4c30b 100644 --- a/run.py +++ b/run.py @@ -33,7 +33,7 @@ def main(): for vc_host in settings.VC_HOSTS: try: start_time = datetime.now() - nb = NetBoxHandler(vc_host["HOST"], vc_host["PORT"]) + nb = NetBoxHandler(vc_conn=vc_host) if args.cleanup: nb.remove_all() log.info( @@ -61,6 +61,7 @@ def main(): log.debug("Connection error details: %s", err) continue + def compare_dicts(dict1, dict2, dict1_name="d1", dict2_name="d2", path=""): """ Compares the key value pairs of two dictionaries and match boolean. @@ -120,6 +121,7 @@ def compare_dicts(dict1, dict2, dict1_name="d1", dict2_name="d2", path=""): log.debug("Final dictionary compare result: %s", result) return result + def format_ip(ip_addr): """ Formats IPv4 addresses to IP with CIDR standard notation. @@ -168,6 +170,38 @@ def format_tag(tag): tag = truncate(tag, max_len=100) return tag +def format_vcenter_conn(conn): + """ + Formats :param conn: into the expected connection string for vCenter. + + This supports the use of per-host credentials without breaking previous + deployments during upgrade. + + :param conn: vCenter host connection details provided in settings.py + :type conn: dict + :returns: A dictionary containing the host details and credentials + :rtype: dict + """ + try: + if bool(conn["USER"] != "" and conn["PASS"] != ""): + log.debug( + "Host specific vCenter credentials provided for '%s'.", + conn["HOST"] + ) + else: + log.debug( + "Host specific vCenter credentials are not defined for '%s'.", + conn["HOST"] + ) + conn["USER"], conn["PASS"] = settings.VC_USER, settings.VC_PASS + except KeyError: + log.debug( + "Host specific vCenter credential key missing for '%s'. Falling " + "back to global.", conn["HOST"] + ) + conn["USER"], conn["PASS"] = settings.VC_USER, settings.VC_PASS + return conn + def truncate(text="", max_len=50): """Ensure a string complies to the maximum length specified.""" return text if len(text) < max_len else text[:max_len] @@ -207,10 +241,12 @@ def verify_ip(ip_addr): class vCenterHandler: """Handles vCenter connection state and object data collection""" - def __init__(self, vc_host, vc_port): + def __init__(self, vc_conn): self.vc_session = None # Used to hold vCenter session state - self.vc_host = vc_host - self.vc_port = vc_port + self.vc_host = vc_conn["HOST"] + self.vc_port = vc_conn["PORT"] + self.vc_user = vc_conn["USER"] + self.vc_pass = vc_conn["PASS"] self.tags = ["Synced", "vCenter", format_tag(self.vc_host)] def authenticate(self): @@ -223,8 +259,8 @@ def authenticate(self): vc_instance = SmartConnectNoSSL( host=self.vc_host, port=self.vc_port, - user=settings.VC_USER, - pwd=settings.VC_PASS, + user=self.vc_user, + pwd=self.vc_pass, ) atexit.register(Disconnect, vc_instance) self.vc_session = vc_instance.RetrieveContent() @@ -591,7 +627,7 @@ def get_objects(self, vc_obj_type): class NetBoxHandler: """Handles NetBox connection state and interaction with API""" - def __init__(self, vc_host, vc_port): + def __init__(self, vc_conn): self.header = {"Authorization": "Token {}".format(settings.NB_API_KEY)} self.nb_api_url = "http{}://{}{}/api/".format( ("s" if not settings.NB_DISABLE_TLS else ""), settings.NB_FQDN, @@ -701,8 +737,8 @@ def __init__(self, vc_host, vc_port): } # Create an instance of the vCenter host for use in tagging functions # Strip to hostname if a fqdn was provided - self.vc_tag = format_tag(vc_host) - self.vc = vCenterHandler(vc_host=vc_host, vc_port=vc_port) + self.vc_tag = format_tag(vc_conn["HOST"]) + self.vc = vCenterHandler(format_vcenter_conn(vc_conn)) def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): """ diff --git a/settings.example.py b/settings.example.py index d99c34e..ee29ba0 100644 --- a/settings.example.py +++ b/settings.example.py @@ -9,13 +9,12 @@ # vCenter Settings VC_HOSTS = [ - # Hostname (FQDN or IP) and port used to access vCenter cluster + # Hostname (FQDN or IP), Port, User, and Password for each vCenter instance + # The USER argument supports SSO with @domain.tld suffix # You can add more vCenter instances by duplicating the line below and # updating the values - {"HOST": "vcenter1.example.com", "PORT": 443}, + {"HOST": "vcenter1.example.com", "PORT": 443, "USER": "", "PASS": ""}, ] -VC_USER = "" # User account to authenticate to vCenter, supports SSO with @domain.tld suffix -VC_PASS = "" # Password for the account defined in VC_USER # NetBox Settings NB_API_KEY = "" # NetBox API Key From e61f36f518c416e31fc63919825d6aeba53cd3e2 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sat, 1 Feb 2020 19:01:33 +0100 Subject: [PATCH 05/21] Fix formatting object relationships to NetBox standard (#48) * Fix #45 by applying NB format rules to interfaces * Adjusted additional formatting misses --- run.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/run.py b/run.py index aa4c30b..4eebda7 100644 --- a/run.py +++ b/run.py @@ -478,7 +478,9 @@ def get_objects(self, vc_obj_type): ) results["interfaces"].append( { - "device": {"name": obj_name}, + "device": { + "name": truncate(obj_name, max_len=64) + }, # Interface speed is placed in the description # as it is irrelevant to making connections and # an error prone mapping process @@ -501,7 +503,9 @@ def get_objects(self, vc_obj_type): ) results["interfaces"].append( { - "device": {"name": obj_name}, + "device": { + "name": truncate(obj_name, max_len=64) + }, "type": 0, # 0 = Virtual "name": nic_name, "mac_address": vnic.spec.mac.upper(), @@ -523,7 +527,7 @@ def get_objects(self, vc_obj_type): "tenant": None, # Collected from prefix "interface": { "device": { - "name": obj_name + "name": truncate(obj_name, max_len=64) }, "name": nic_name, }, @@ -578,7 +582,9 @@ def get_objects(self, vc_obj_type): ) results["virtual_interfaces"].append( { - "virtual_machine": {"name": obj_name}, + "virtual_machine": { + "name": truncate(obj_name, max_len=64) + }, "type": 0, # 0 = Virtual "name": nic_name, "mac_address": nic.macAddress.upper(), @@ -605,7 +611,9 @@ def get_objects(self, vc_obj_type): "tenant": None, "interface": { "virtual_machine": { - "name": obj_name + "name": truncate( + obj_name, max_len=64 + ) }, "name": nic_name, }, From 61f00d5e46bc8cd0b800d654c97c735b3cbaa0b1 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Tue, 4 Feb 2020 19:39:13 +0100 Subject: [PATCH 06/21] Added in support for NetBox API v2.7 changes to fix #25 and #50 --- run.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/run.py b/run.py index 4eebda7..2d35302 100644 --- a/run.py +++ b/run.py @@ -241,7 +241,8 @@ def verify_ip(ip_addr): class vCenterHandler: """Handles vCenter connection state and object data collection""" - def __init__(self, vc_conn): + def __init__(self, vc_conn, nb_api_version): + self.nb_api_version = nb_api_version self.vc_session = None # Used to hold vCenter session state self.vc_host = vc_conn["HOST"] self.vc_port = vc_conn["PORT"] @@ -301,6 +302,25 @@ def create_view(self, vc_obj_type): True # Should we recurively look into view ) + def _format_value(self, key, value): + """ + Formats object values depending on the NetBox API version. + + Prior to NetBox API v2.7 NetBox used integers for status and type + fields. We use the version of NetBox API to determine whether we need + to return integers or named strings. + """ + if self.nb_api_version > 2.6: + if key == "status": + translation = {0: "offline", 1: "active"} + elif key == "type": + translation = {0: "virtual", 32767: "other"} + result = translation[value] + else: + result = value + return result + + def get_objects(self, vc_obj_type): """ Collects vCenter objects of type and returns NetBox formated objects. @@ -442,7 +462,9 @@ def get_objects(self, vc_obj_type): "serial": serial_number, "asset_tag": asset_tag, "cluster": {"name": cluster}, - "status": ( # 0 = Offline / 1 = Active + "status": self._format_value( + "status", + # 0 = Offline / 1 = Active 1 if obj.summary.runtime.connectionState == \ "connected" else 0 @@ -484,7 +506,7 @@ def get_objects(self, vc_obj_type): # Interface speed is placed in the description # as it is irrelevant to making connections and # an error prone mapping process - "type": 32767, # 32767 = Other + "type": self._format_value("type", 32767), "name": nic_name, # Capitalized to match NetBox format "mac_address": pnic.mac.upper(), @@ -506,7 +528,7 @@ def get_objects(self, vc_obj_type): "device": { "name": truncate(obj_name, max_len=64) }, - "type": 0, # 0 = Virtual + "type": self._format_value("type", 0), "name": nic_name, "mac_address": vnic.spec.mac.upper(), "mtu": vnic.spec.mtu, @@ -556,8 +578,13 @@ def get_objects(self, vc_obj_type): results["virtual_machines"].append( { "name": truncate(obj_name, max_len=64), - "status": 1 if obj.runtime.powerState == "poweredOn" - else 0, + "status": self._format_value( + "status", + int( + 1 if obj.runtime.powerState == "poweredOn" + else 0 + ) + ), "cluster": {"name": cluster}, "role": {"name": "Server"}, "platform": platform, @@ -585,7 +612,7 @@ def get_objects(self, vc_obj_type): "virtual_machine": { "name": truncate(obj_name, max_len=64) }, - "type": 0, # 0 = Virtual + "type": self._format_value("type", 0), "name": nic_name, "mac_address": nic.macAddress.upper(), "enabled": nic.connected, @@ -641,7 +668,7 @@ def __init__(self, vc_conn): ("s" if not settings.NB_DISABLE_TLS else ""), settings.NB_FQDN, (":{}".format(settings.NB_PORT) if settings.NB_PORT != 443 else "") ) - self.nb_session = None + self.nb_session = self._create_nb_session() # NetBox object type relationships when working in the API self.obj_map = { "cluster_groups": { @@ -746,7 +773,25 @@ def __init__(self, vc_conn): # Create an instance of the vCenter host for use in tagging functions # Strip to hostname if a fqdn was provided self.vc_tag = format_tag(vc_conn["HOST"]) - self.vc = vCenterHandler(format_vcenter_conn(vc_conn)) + self.vc = vCenterHandler( + format_vcenter_conn(vc_conn), nb_api_version=self._get_api_version() + ) + + def _create_nb_session(self): + """Creates a session with NetBox.""" + session = requests.Session() + session.headers.update(self.header) + log.info("Created new HTTP Session for NetBox.") + return session + + def _get_api_version(self): + """Determines the current NetBox API Version""" + with self.nb_session.get( + self.nb_api_url, timeout=10, + verify=(not settings.NB_INSECURE_TLS)) as resp: + result = float(resp.headers["API-Version"]) + log.info("Detected NetBox API v%s.", result) + return result def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): """ @@ -758,11 +803,6 @@ def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): query: String used to filter results when using GET method nb_id: Integer used when working with a single NetBox object """ - # If an existing session is not already found then create it - # The goal here is session re-use without TCP handshake on every request - if not self.nb_session: - self.nb_session = requests.Session() - self.nb_session.headers.update(self.header) result = None # Generate URL url = "{}{}/{}/{}{}".format( From 81850feaf9ff387ffebf7195f83104893f722487 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Tue, 4 Feb 2020 22:15:50 +0100 Subject: [PATCH 07/21] Fixed cleanup requests for IP defined vCenter hosts for #51 --- run.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/run.py b/run.py index 4eebda7..5259fd4 100644 --- a/run.py +++ b/run.py @@ -1260,7 +1260,8 @@ def remove_all(self): ) query = "?tag={}".format( # vCenter site is a global dependency so we change the query - "vcenter" if nb_obj_type == "sites" else self.vc_tag + "vcenter" if nb_obj_type == "sites" + else format_slug(self.vc_tag) ) nb_objects = self.request( req_type="get", nb_obj_type=nb_obj_type, From 906d8048717079ed2370464cb97670eae0e53feb Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Tue, 4 Feb 2020 22:21:51 +0100 Subject: [PATCH 08/21] Add primary ip to synced NetBox hosts and virtual machines for #46 (#54) * Created functions for setting primary IP on NetBox hosts and VMs for #46 * Added primary ip comparison information message --- run.py | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/run.py b/run.py index 5259fd4..752ec84 100644 --- a/run.py +++ b/run.py @@ -47,6 +47,7 @@ def main(): nb.sync_objects(vc_obj_type="clusters") nb.sync_objects(vc_obj_type="hosts") nb.sync_objects(vc_obj_type="virtual_machines") + nb.set_primary_ips() log.info( "Completed sync with vCenter instance '%s'! Total " "execution time %s.", vc_host["HOST"], @@ -748,6 +749,29 @@ def __init__(self, vc_conn): self.vc_tag = format_tag(vc_conn["HOST"]) self.vc = vCenterHandler(format_vcenter_conn(vc_conn)) + def get_primary_ip(self, nb_obj_type, nb_id): + """Collects the primary IP of a NetBox device or virtual machine.""" + query_key = str( + "device_id" if nb_obj_type == "devices" else "virtual_machine_id" + ) + req = self.request( + req_type="get", nb_obj_type="ip_addresses", + query="?{}={}".format(query_key, nb_id) + ) + log.debug("Found %s child IP addresses.", req["count"]) + if req["count"] > 0: + result = { + "address": req["results"][0]["address"], + "id": req["results"][0]["id"] + } + log.debug( + "Selected %s (ID: %s) as primary IP.", result["address"], + result["id"] + ) + else: + result = None + return result + def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): """ HTTP requests and exception handler for NetBox @@ -943,6 +967,74 @@ def obj_exists(self, nb_obj_type, vc_data): req_type="post", nb_obj_type=nb_obj_type, data=vc_data ) + def set_primary_ips(self): + """Sets the Primary IP of vCenter hosts and Virtual Machines.""" + for nb_obj_type in ("devices", "virtual_machines"): + log.info( + "Collecting NetBox %s objects to set Primary IPs.", + nb_obj_type[:-1].replace("_", " ") + ) + obj_key = self.obj_map[nb_obj_type]["key"] + # Collect all parent objects that support Primary IPs + parents = self.request( + req_type="get", nb_obj_type=nb_obj_type, + query="?tag={}".format(format_slug(self.vc_tag)) + ) + log.info("Collected %s NetBox objects.", parents["count"]) + for nb_obj in parents["results"]: + update_object = False + parent_id = nb_obj["id"] + nb_obj_name = nb_obj[obj_key] + new_pri_ip = self.get_primary_ip(nb_obj_type, parent_id) + # Skip the rest if we don't have a usable IP + if new_pri_ip is None: + log.info( + "No usable IPs were found for NetBox '%s' object. " + "Moving on.", + nb_obj_name + ) + continue + if nb_obj["primary_ip"] is None: + log.info( + "No existing Primary IP found for NetBox '%s' object.", + nb_obj_name + ) + update_object = True + else: + log.info( + "Primary IP already set for NetBox '%s' object. " + "Comparing.", + nb_obj_name + ) + old_pri_ip = nb_obj["primary_ip"]["id"] + if old_pri_ip != new_pri_ip["id"]: + log.info( + "Existing Primary IP does not match latest check. " + "Requesting update." + ) + update_object = True + else: + log.info( + "Existing Primary IP matches latest check. Moving " + "on." + ) + if update_object: + log.info( + "Setting NetBox '%s' object primary IP to %s.", + nb_obj_name, new_pri_ip["address"] + ) + ip_version = str( + "primary_ip{}".format( + ip_network( + new_pri_ip["address"], strict=False + ).version + )) + data = {ip_version: new_pri_ip["id"]} + self.request( + req_type="patch", nb_obj_type=nb_obj_type, + nb_id=parent_id, data=data + ) + def sync_objects(self, vc_obj_type): """ Collects objects from vCenter and syncs them to NetBox. From 287c8063326a957a8004267ca05cceac41ddbc62 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Wed, 5 Feb 2020 00:44:04 +0100 Subject: [PATCH 09/21] Allow sites to be modified for #49 --- run.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/run.py b/run.py index 3166d63..b004e64 100644 --- a/run.py +++ b/run.py @@ -113,7 +113,11 @@ def compare_dicts(dict1, dict2, dict1_name="d1", dict2_name="d2", path=""): "Mismatch: %s value is '%s' while %s value is '%s'.", dict1_path, dict1[key], dict2_path, dict2[key] ) - result = False + # Allow the modification of device sites by ignoring the value + if "site" in path and key == "name": + log.debug("Site mismatch is allowed. Moving on.") + else: + result = False if result: log.debug("%s and %s values match.", dict1_path, dict2_path) else: From 5d8b529d3a39675d6816b9e4a15f5de039597c7d Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Wed, 5 Feb 2020 08:04:07 +0100 Subject: [PATCH 10/21] Converted PUT methods to PATCH in support of #49 and #37 --- run.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/run.py b/run.py index b004e64..961eb82 100644 --- a/run.py +++ b/run.py @@ -880,7 +880,7 @@ def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): log.debug( "NetBox %s status reason: %s", req.status_code, req.text ) - elif req_type == "put": + elif req_type == "patch": log.warning( "NetBox failed to modify %s object with status %s. The " "data sent may not be acceptable.", nb_obj_type, @@ -967,7 +967,7 @@ def obj_exists(self, nb_obj_type, vc_data): nb_obj_type, vc_data[query_key] ) self.request( - req_type="put", nb_obj_type=nb_obj_type, data=vc_data, + req_type="patch", nb_obj_type=nb_obj_type, data=vc_data, nb_id=nb_data["id"] ) elif compare_dicts( @@ -990,8 +990,16 @@ def obj_exists(self, nb_obj_type, vc_data): vc_data["tags"] = list( set(vc_data["tags"] + nb_data["tags"]) ) + # Remove site from existing NetBox host objects to allow for + # user modifications + if nb_obj_type == "devices": + del vc_data["site"] + log.debug( + "Removed site from %s object before sending update " + "to NetBox.", vc_data[query_key] + ) self.request( - req_type="put", nb_obj_type=nb_obj_type, data=vc_data, + req_type="patch", nb_obj_type=nb_obj_type, data=vc_data, nb_id=nb_data["id"] ) elif req["count"] > 1: From 320e7604711f4c696fa288eddb37416826d0dda2 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sun, 9 Feb 2020 18:43:04 +0100 Subject: [PATCH 11/21] Fixed an error in manufacturer template --- netbox_templates.py | 506 ++++++++++++++++++++++++++++++++++++++++++++ run.py | 301 ++++++++++++-------------- 2 files changed, 638 insertions(+), 169 deletions(-) create mode 100644 netbox_templates.py diff --git a/netbox_templates.py b/netbox_templates.py new file mode 100644 index 0000000..6e0ff9c --- /dev/null +++ b/netbox_templates.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""A collection of NetBox object templates""" + + +def remove_empty_fields(obj): + """ + Removes empty fields from NetBox objects. + + This ensures NetBox objects do not return invalid None values in fields. + :param obj: A NetBox formatted object + :type obj: dict + """ + return {k: v for k, v in obj.items() if v is not None} + + +def format_slug(text): + """ + Format string to comply to NetBox slug acceptable pattern and max length. + + NetBox slug pattern: ^[-a-zA-Z0-9_]+$ + NetBox slug max length: 50 characters + """ + allowed_chars = ( + "abcdefghijklmnopqrstuvxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" # Alphabet + "01234567890" # Numbers + "_-" # Symbols + ) + # Replace seperators with dash + seperators = [" ", ",", "."] + for sep in seperators: + text = text.replace(sep, "-") + # Strip unacceptable characters + text = "".join([c for c in text if c in allowed_chars]) + # Enforce max length + return truncate(text, max_len=50).lower() + + +def truncate(text="", max_len=50): + """Ensure a string complies to the maximum length specified.""" + return text if len(text) < max_len else text[:max_len] + + +class Templates: + """NetBox object templates""" + def __init__(self, api_version): + """ + Required parameters for the NetBox class + + :param api_version: NetBox API version objects must be formatted to + :type api_version: float + """ + self.api_version = api_version + + def cluster(self, name, ctype, group=None, tags=None): + """ + Template for NetBox clusters at /virtualization/clusters/ + + :param name: Name of the cluster group + :type name: str + :param ctype: Name of NetBox cluster type object + :type ctype: str + :param group: Name of NetBox cluster group object + :type group: str, optional + :param tags: Tags to apply to the object + :type tags: list, optional + """ + obj = { + "name": truncate(name, max_len=100), + "type": {"name": ctype}, + "group": {"name": truncate(group, max_len=50)} if group else None, + "tags": tags, + } + return remove_empty_fields(obj) + + def cluster_group(self, name, slug=None): + """ + Template for NetBox cluster groups at /virtualization/cluster-groups/ + + :param name: Name of the cluster group + :type name: str + :param slug: Unique slug for cluster group. A slug will be derived from :param name: if no slug is provided. + :type slug: str, optional + """ + obj = { + "name": truncate(name, max_len=50), + "slug": slug if slug else format_slug(name) + } + return remove_empty_fields(obj) + + def device(self, name, device_role, device_type, display_name=None, + platform=None, site=None, serial=None, asset_tag=None, + cluster=None, status=None, tags=None): + """ + Template for NetBox devices at /dcim/devices/ + + :param name: Hostname of the device + :type name: str + :param device_role: Name of device role + :type device_role: str + :param device_type: Model name of device type + :type device_type: str + :param display_name: Friendly name for device + :type display_name: str, opt + :param platform: Platform running on the device + :type platform: str, opt + :param site: Site where the device resides + :type site: str, opt + :param serial: Serial number of the device + :type serial: str, opt + :param asset_tag: Asset tag of the device + :type asset_tag: str, opt + :param cluster: Cluster the device belongs to + :type cluster: str, opt + :param status: NetBox IP address status in NB API v2.6 format + :type status: int + :param tags: Tags to apply to the object + :type tags: list, optional + """ + obj = { + "name": name, + "device_role": {"name": device_role}, + "device_type": {"model": device_type}, + "display_name": display_name, + "platform": {"name": platform} if platform else None, + "site": {"name": site} if site else None, + "serial": truncate(serial, max_len=50) if serial else None, + "asset_tag": truncate(asset_tag, max_len=50) if asset_tag else None, + "cluster": { + "name": truncate(cluster, max_len=100) + } if cluster else None, + "status": self._version_dependent( + nb_obj_type="devices", + key="status", + value=status + ), + "tags": tags, + } + return remove_empty_fields(obj) + + def device_interface(self, device, name, itype=None, enabled=None, mtu=None, + mac_address=None, mgmt_only=None, description=None, + cable=None, mode=None, untagged_vlan=None, + tagged_vlans=None, tags=None): + """ + Template for NetBox device interfaces at /dcim/interfaces/ + + :param device: Name of parent device the interface belongs to + :type device: str + :param name: Name of the physical interface + :type name: str + :param itype: Type of interface `0` if Virtual else `32767` for Other + :type itype: str, optional + :param enabled: `True` if the interface is up else `False` + :type enabled: bool,optional + :param mtu: The configured MTU for the interface + :type mtu: int,optional + :param mac_address: The MAC address of the interface + :type mac_address: str, optional + :param mgmt_only: `True` if interface is only for out of band else `False` + :type mgmt_only: bool, optional + :param description: Description for the interface + :type description: str, optional + :param cable: NetBox cable object ID of the interface is attached to + :type cable: int, optional + :param mode: `100` if access, `200` if tagged, or `300 if` tagged for all vlans + :type mode: int, optional + :param untagged_vlan: NetBox VLAN object id of untagged vlan + :type untagged_vlan: int, optional + :param tagged_vlans: List of NetBox VLAN object ids for tagged VLANs + :type tagged_vlans: str, optional + :param tags: Tags to apply to the object + :type tags: list, optional + """ + obj = { + "device": {"name": device}, + "name": name, + "type": self._version_dependent( + nb_obj_type="interfaces", + key="type", + value=itype + ) if itype else None, + "enabled": enabled, + "mtu": mtu, + "mac_address": mac_address.upper() if mac_address else None, + "mgmt_only": mgmt_only, + "description": description, + "cable": cable, + "mode": mode, + "untagged_vlan": untagged_vlan, + "tagged_vlans": tagged_vlans, + "tags": tags, + } + return remove_empty_fields(obj) + + def device_type(self, manufacturer, model, slug=None, part_number=None, + tags=None): + """ + Template for NetBox device types at /dcim/device-types/ + + :param manufacturer: Name of NetBox manufacturer object + :type manufacturer: str + :param model: Name of NetBox model object + :type model: str + :param slug: Unique slug for manufacturer. A slug will be derived from :param name: if no slug is provided. + :type slug: str, optional + :param part_number: Unique partner number for the device + :type part_number: str, optional + :param tags: Tags to apply to the object + :type tags: list, optional + """ + obj = { + "manufacturer": {"name": manufacturer}, + "model": truncate(model, max_len=50), + "slug": slug if slug else format_slug(model), + "part_number": truncate( + part_number, max_len=50 + ) if part_number else None, + "tags": tags + } + return remove_empty_fields(obj) + + def ip_address(self, address, description=None, device=None, dns_name=None, + interface=None, status=1, tags=None, tenant=None, + virtual_machine=None, vrf=None): + """ + Template for NetBox IP addresses at /ipam/ip-addresses/ + + :param address: IP address + :type address: str + :param description: A description of the IP address purpose + :type description: str, optional + :param device: The device which the IP and its interface are attached to + :type device: str, optional + :param dns_name: FQDN pointed to the IP address + :type dns_name: str, optional + :param interface: Name of the parent interface IP is configured on + :type interface: str, optional + :param status: `1` if active, `0` if deprecated + :type status: int + :param tags: Tags to apply to the object + :type tags: list, optional + :param tenant: The tenant the IP address belongs to + :type tenant: str, optional + :param virtual_machine: Name of the NetBox VM object the IP is configured on + :type virtual_machine: str, optional + :param vrf: Virtual Routing and Forwarding instance for the IP + :type vrf: str, optional + """ + # Validate user did not try to provide a parent device and VM + if bool(device and virtual_machine): + raise ValueError( + "Values provided for both parent device and virtual machine " + "but they are exclusive to each other." + ) + obj = { + "address": address, + "description": description, + "dns_name": dns_name, + "status": self._version_dependent( + nb_obj_type="ip_addresses", + key="status", + value=status + ), + "tags": tags, + "tenant": tenant, + "vrf": vrf + } + if interface and bool(device or virtual_machine): + obj["interface"] = {"name": interface} + if device: + obj["interface"] = { + **obj["interface"], **{"device": {"name": device}} + } + elif virtual_machine: + obj["interface"] = { + **obj["interface"], + **{"virtual_machine": { + "name": truncate(virtual_machine, max_len=64) + }} + } + return remove_empty_fields(obj) + + def manufacturer(self, name, slug=None): + """ + Template for NetBox manufacturers at /dcim/manufacturers + + :param name: Name of the manufacturer + :type name: str + :param slug: Unique slug for manufacturer. A slug will be derived from :param name: if no slug is provided. + :type slug: str, optional + """ + obj = { + "name": truncate(name, max_len=50), + "slug": slug if slug else format_slug(name) + } + return remove_empty_fields(obj) + + def _version_dependent(self, nb_obj_type, key, value): + """ + Formats object values depending on the NetBox API version. + + Prior to NetBox API v2.7 NetBox used integers for multiple choice + fields. We use the version of NetBox API to determine whether we need + to return integers or named strings. + + :param nb_obj_type: NetBox object type, must match keys in self.obj_map + :type nb_obj_type: str + :param key: The dictionary key to check against + :type key: str + :param value: Value to the provided key in NetBox 2.6 or less format + :return: NetBox API version safe value + :rtype: str + """ + obj_map = { + "circuits": { + "status": { + 0: "deprovisioning", + 1: "active", + 2: "planned", + 3: "provisioning", + 4: "offline", + 5: "decomissioned" + }}, + "devices": { + "status": { + 0: "offline", + 1: "active", + 2: "planned", + 3: "staged", + 4: "failed", + 5: "inventory", + 6: "decomissioning" + }}, + "interfaces": { + "type": { + 0: "virtual", + 32767: "other" + }, + "mode": { + 100: "access", + 200: "tagged", + 300: "tagged-all", + }}, + "ip_addresses": { + "role": { + 10: "loopback", + 20: "secondary", + 30: "anycast", + 40: "vip", + 41: "vrrp", + 42: "hsrp", + 43: "glbp", + 44: "carp" + }, + "status": { + # Zero should be offline but does not exist in NetBox v2.7 + # so we remap it to deprecated + 0: "deprecated", + 1: "active", + 2: "reserved", + 3: "deprecated", + 5: "dhcp" + }, + "type": { + 0: "virtual", + 32767: "other" + }}, + "prefixes": { + "status": { + 0: "container", + 1: "active", + 2: "reserved", + 3: "deprecated" + }}, + "sites": { + "status": { + 1: "active", + 2: "planned", + 4: "retired" + }}, + "vlans": { + "status": { + 1: "active", + 2: "reserved", + 3: "deprecated" + }}, + "virtual_machines": { + "status": { + 0: "offline", + 1: "active", + 3: "staged" + } + }} + # isinstance is used as a safety check. If a string is passed we'll + # assume someone passed a value for API v2.7 and return the result. + if isinstance(value, int) and self.api_version > 2.6: + result = obj_map[nb_obj_type][key][value] + else: + result = value + return result + + def virtual_machine(self, name, cluster, status=None, role=None, + tenant=None, platform=None, primary_ip4=None, + primary_ip6=None, vcpus=None, memory=None, disk=None, + comments=None, local_context_data=None, tags=None): + """ + Template for NetBox virtual machines at /virtualization/virtual-machines/ + + :param name: Name of the virtual machine + :type name: str + :param cluster: Name of the cluster the virtual machine resides on + :type cluster: str + :param status: `0` if offline, `1` if active, `3` if staged + :type status: int, optional + :param role: Name of NetBox role object + :type role: str, optional + :param tenant: Name of NetBox tenant object + :type tenant: str, optional + :param platform: Name of NetBox platform object + :type platform: str, optional + :param primary_ip4: NetBox IP address object ID + :type primary_ip4: int, optional + :param primary_ip6: NetBox IP address object ID + :type primary_ip6: int, optional + :param vcpus: Quantity of virtual CPUs assigned to VM + :type vcpus: int, optional + :param memory: Quantity of RAM assigned to VM in MB + :type memory: int, optional + :param disk: Quantity of disk space assigned to VM in GB + :type disk: str, optional + :param comments: Comments regarding the VM + :type comments: str, optional + :param local_context_data: Additional context data regarding the VM + :type local_context_data: dict, optional + :param tags: Tags to apply to the object + :type tags: list, optional + """ + obj = { + "name": name, + "cluster": {"name": cluster}, + "status": self._version_dependent( + nb_obj_type="virtual_machines", + key="status", + value=status + ), + "role": {"name": role} if role else None, + "tenant": {"name": tenant} if tenant else None, + "platform": platform, + "primary_ip4": primary_ip4, + "primary_ip6": primary_ip6, + "vcpus": vcpus, + "memory": memory, + "disk": disk, + "comments": comments, + "local_context_data": local_context_data, + "tags": tags + } + return remove_empty_fields(obj) + + def vm_interface(self, virtual_machine, name, itype=0, enabled=None, + mtu=None, mac_address=None, description=None, mode=None, + untagged_vlan=None, tagged_vlans=None, tags=None): + """ + Template for NetBox virtual machine interfaces at /virtualization/interfaces/ + + :param virtual_machine: Name of parent virtual machine the interface belongs to + :type virtual_machine: str + :param name: Name of the physical interface + :type name: str + :param itype: Type of interface `0` if Virtual else `32767` for Other + :type itype: str, optional + :param enabled: `True` if the interface is up else `False` + :type enabled: bool,optional + :param mtu: The configured MTU for the interface + :type mtu: int,optional + :param mac_address: The MAC address of the interface + :type mac_address: str, optional + :param description: Description for the interface + :itype description: str, optional + :param mode: `100` if access, `200` if tagged, or `300 if` tagged for all vlans + :itype mode: int, optional + :param untagged_vlan: NetBox VLAN object id of untagged vlan + :type untagged_vlan: int, optional + :param tagged_vlans: List of NetBox VLAN object ids for tagged VLANs + :type tagged_vlans: str, optional + :param tags: Tags to apply to the object + :type tags: list, optional + """ + obj = { + "virtual_machine": {"name": truncate(virtual_machine, max_len=64)}, + "name": name, + "itype": self._version_dependent( + nb_obj_type="interfaces", + key="type", + value=itype + ), + "enabled": enabled, + "mtu": mtu, + "mac_address": mac_address.upper() if mac_address else None, + "description": description, + "mode": mode, + "untagged_vlan": untagged_vlan, + "tagged_vlans": tagged_vlans, + "tags": tags, + } + return remove_empty_fields(obj) diff --git a/run.py b/run.py index 961eb82..3d33ff8 100644 --- a/run.py +++ b/run.py @@ -11,6 +11,7 @@ from pyVmomi import vim import settings from logger import log +from netbox_templates import Templates def main(): @@ -351,6 +352,8 @@ def get_objects(self, vc_obj_type): ] } results = {} + # Setup use of NetBox templates + nbt = Templates(api_version=self.nb_api_version) # Initalize keys expected to be returned for nb_obj_type in obj_type_map[vc_obj_type]: results.setdefault(nb_obj_type, []) @@ -364,67 +367,57 @@ def get_objects(self, vc_obj_type): vc_obj_type, obj_name ) if vc_obj_type == "datacenters": - results["cluster_groups"].append( - { - "name": truncate(obj_name, max_len=50), - "slug": format_slug(obj_name), - }) + results["cluster_groups"].append(nbt.cluster_group( + name=obj_name + )) elif vc_obj_type == "clusters": - cluster_group = truncate(obj.parent.parent.name, max_len=50) - results["clusters"].append( - { - "name": truncate(obj_name, max_len=100), - "type": {"name": "VMware ESXi"}, - "group": {"name": cluster_group}, - "tags": self.tags - }) + results["clusters"].append(nbt.cluster( + name=obj_name, + ctype="VMware ESXi", + group=obj.parent.parent.name, + tags=self.tags + )) elif vc_obj_type == "hosts": obj_manuf_name = truncate( obj.summary.hardware.vendor, max_len=50 ) + obj_model = truncate(obj.summary.hardware.model, max_len=50) # NetBox Manufacturers and Device Types are susceptible to # duplication as they are parents to multiple objects # To avoid unnecessary querying we check to make sure they # haven't already been collected - duplicate = {"manufacturers": False, "device_types": False} - if obj_manuf_name in \ - [res["name"] for res in results["manufacturers"]]: - duplicate["manufacturers"] = True - log.debug( - "Manufacturers object already exists. Skipping." - ) - if not duplicate["manufacturers"]: + if not obj_manuf_name in [ + res["name"] for res in results["manufacturers"]]: log.debug( "Collecting info to create NetBox manufacturers " "object." ) - results["manufacturers"].append( - { - "name": obj_manuf_name, - "slug": format_slug(obj_manuf_name) - }) - obj_model = truncate(obj.summary.hardware.model, max_len=50) - if obj_model in \ - [res["model"] for res in results["device_types"]]: - duplicate["device_types"] = True + results["manufacturers"].append(nbt.manufacturer( + name=obj_manuf_name + )) + else: log.debug( - "Device Types object already exists. Skipping." + "Manufacturers object '%s' already exists. " + "Skipping.", obj_manuf_name ) - if not duplicate["device_types"]: + if not obj_model in [ + res["model"] for res in + results["device_types"]]: log.debug( "Collecting info to create NetBox device_types " "object." ) - results["device_types"].append( - { - "manufacturer": { - "name": obj_manuf_name - }, - "model": obj_model, - "slug": format_slug(obj_model), - "part_number": obj_model, - "tags": self.tags - }) + results["device_types"].append(nbt.device_type( + manufacturer=obj_manuf_name, + model=obj_model, + part_number=obj_model, + tags=self.tags + )) + else: + log.debug( + "Device Type object '%s' already exists. " + "Skipping.", obj_model + ) log.debug( "Collecting info to create NetBox devices object." ) @@ -438,44 +431,39 @@ def get_objects(self, vc_obj_type): # Serial Number if "EnclosureSerialNumberTag" in hw_idents.keys(): serial_number = hw_idents["EnclosureSerialNumberTag"] - serial_number = truncate(serial_number, max_len=50) elif "ServiceTag" in hw_idents.keys() \ and " " not in hw_idents["ServiceTag"]: serial_number = hw_idents["ServiceTag"] - serial_number = truncate(serial_number, max_len=50) else: serial_number = None # Asset Tag if "AssetTag" in hw_idents.keys(): + asset_tag = hw_idents["AssetTag"].lower() banned_tags = ["Default string", "Unknown", " ", ""] - asset_tag = truncate(hw_idents["AssetTag"], max_len=50) - for btag in banned_tags: - if btag.lower() in hw_idents["AssetTag"].lower(): - log.debug("Banned asset tag string. Nulling.") - asset_tag = None - break + banned_tags = [t.lower() for t in banned_tags] + if asset_tag in banned_tags: + log.debug("Banned asset tag string. Nulling.") + asset_tag = None else: + log.debug( + "No asset tag detected for device '%s'.", obj_name + ) asset_tag = None - cluster = truncate(obj.parent.name, max_len=100) - results["devices"].append( - { - "name": truncate(obj_name, max_len=64), - "device_type": {"model": obj_model}, - "device_role": {"name": "Server"}, - "platform": {"name": "VMware ESXi"}, - "site": {"name": "vCenter"}, - "serial": serial_number, - "asset_tag": asset_tag, - "cluster": {"name": cluster}, - "status": self._format_value( - "status", - # 0 = Offline / 1 = Active - 1 if obj.summary.runtime.connectionState == \ - "connected" - else 0 - ), - "tags": self.tags - }) + results["devices"].append(nbt.device( + name=truncate(obj_name, max_len=64), + device_role="Server", + device_type=obj_model, + platform="VMware ESXi", + site="vCenter", + serial=serial_number, + asset_tag=asset_tag, + cluster=obj.parent.name, + status=int( + 1 if obj.summary.runtime.connectionState == + "connected" else 0 + ), + tags=self.tags + )) # Iterable object types # Physical Interfaces log.debug( @@ -503,24 +491,20 @@ def get_objects(self, vc_obj_type): link_speed = "{}Mbps ".format( pnic.spec.linkSpeed.speedMb ) - results["interfaces"].append( - { - "device": { - "name": truncate(obj_name, max_len=64) - }, - # Interface speed is placed in the description - # as it is irrelevant to making connections and - # an error prone mapping process - "type": self._format_value("type", 32767), - "name": nic_name, - # Capitalized to match NetBox format - "mac_address": pnic.mac.upper(), - "description": "{}Physical Interface".format( - link_speed - ), - "enabled": bool(link_speed), - "tags": self.tags - }) + results["interfaces"].append(nbt.device_interface( + device=truncate(obj_name, max_len=64), + name=nic_name, + itype=32767, # Other + mac_address=pnic.mac, + # Interface speed is placed in the description as it + # is irrelevant to making connections and an error + # prone mapping process + description="{}Physical Interface".format( + link_speed + ), + enabled=bool(link_speed), + tags=self.tags, + )) # Virtual Interfaces for vnic in obj.config.network.vnic: nic_name = truncate(vnic.device, max_len=64) @@ -528,38 +512,28 @@ def get_objects(self, vc_obj_type): "Collecting info for virtual interface '%s'.", nic_name ) - results["interfaces"].append( - { - "device": { - "name": truncate(obj_name, max_len=64) - }, - "type": self._format_value("type", 0), - "name": nic_name, - "mac_address": vnic.spec.mac.upper(), - "mtu": vnic.spec.mtu, - "tags": self.tags - }) + results["interfaces"].append(nbt.device_interface( + device=truncate(obj_name, max_len=64), + name=nic_name, + itype=0, # Virtual + mac_address=vnic.spec.mac, + mtu=vnic.spec.mtu, + tags=self.tags, + )) # IP Addresses ip_addr = vnic.spec.ip.ipAddress log.debug( "Collecting info for IP Address '%s'.", ip_addr ) - results["ip_addresses"].append( - { - "address": "{}/{}".format( - ip_addr, vnic.spec.ip.subnetMask - ), - "vrf": None, # Collected from prefix - "tenant": None, # Collected from prefix - "interface": { - "device": { - "name": truncate(obj_name, max_len=64) - }, - "name": nic_name, - }, - "tags": self.tags - }) + results["ip_addresses"].append(nbt.ip_address( + address="{}/{}".format( + ip_addr, vnic.spec.ip.subnetMask + ), + device=truncate(obj_name, max_len=64), + interface=nic_name, + tags=self.tags, + )) elif vc_obj_type == "virtual_machines": log.info( "Collecting info about vCenter %s '%s' object.", @@ -580,28 +554,23 @@ def get_objects(self, vc_obj_type): platform = {"name": "Linux"} elif "windows" in vm_family: platform = {"name": "Windows"} - results["virtual_machines"].append( - { - "name": truncate(obj_name, max_len=64), - "status": self._format_value( - "status", - int( - 1 if obj.runtime.powerState == "poweredOn" - else 0 - ) - ), - "cluster": {"name": cluster}, - "role": {"name": "Server"}, - "platform": platform, - "memory": obj.config.hardware.memoryMB, - "disk": int(sum([ - comp.capacityInKB for comp in - obj.config.hardware.device - if isinstance(comp, vim.vm.device.VirtualDisk) - ]) / 1024 / 1024), # Kilobytes to Gigabytes - "vcpus": obj.config.hardware.numCPU, - "tags": self.tags - }) + results["virtual_machines"].append(nbt.virtual_machine( + name=truncate(obj_name, max_len=64), + cluster=cluster, + status=int( + 1 if obj.runtime.powerState == "poweredOn" else 0 + ), + role="Server", + platform=platform, + memory=obj.config.hardware.memoryMB, + disk=int(sum([ + comp.capacityInKB for comp in + obj.config.hardware.device + if isinstance(comp, vim.vm.device.VirtualDisk) + ]) / 1024 / 1024), # Kilobytes to Gigabytes + vcpus=obj.config.hardware.numCPU, + tags=self.tags + )) # If VMware Tools is not detected then we cannot reliably # collect interfaces and IP addresses if vm_family: @@ -613,44 +582,31 @@ def get_objects(self, vc_obj_type): nic_name ) results["virtual_interfaces"].append( - { - "virtual_machine": { - "name": truncate(obj_name, max_len=64) - }, - "type": self._format_value("type", 0), - "name": nic_name, - "mac_address": nic.macAddress.upper(), - "enabled": nic.connected, - "tags": self.tags - }) + nbt.vm_interface( + virtual_machine=obj_name, + itype=0, + name=nic_name, + mac_address=nic.macAddress, + enabled=nic.connected, + tags=self.tags + )) # IP Addresses if nic.ipConfig is not None: for ip in nic.ipConfig.ipAddress: - ip_addr = ip.ipAddress + ip_addr = "{}/{}".format( + ip.ipAddress, ip.prefixLength + ) log.debug( "Collecting info for IP Address '%s'.", ip_addr ) results["ip_addresses"].append( - { - "address": "{}/{}".format( - ip_addr, ip.prefixLength - ), - # VRF and Tenant are initialized - # to be later collected through a - # prefix search - "vrf": None, - "tenant": None, - "interface": { - "virtual_machine": { - "name": truncate( - obj_name, max_len=64 - ) - }, - "name": nic_name, - }, - "tags": self.tags - }) + nbt.ip_address( + address=ip_addr, + virtual_machine=obj_name, + interface=nic_name, + tags=self.tags + )) except AttributeError: log.warning( "Unable to collect necessary data for vCenter %s '%s'" @@ -668,12 +624,12 @@ def get_objects(self, vc_obj_type): class NetBoxHandler: """Handles NetBox connection state and interaction with API""" def __init__(self, vc_conn): - self.header = {"Authorization": "Token {}".format(settings.NB_API_KEY)} self.nb_api_url = "http{}://{}{}/api/".format( ("s" if not settings.NB_DISABLE_TLS else ""), settings.NB_FQDN, (":{}".format(settings.NB_PORT) if settings.NB_PORT != 443 else "") ) self.nb_session = self._create_nb_session() + self.nb_api_version = self._get_api_version() # NetBox object type relationships when working in the API self.obj_map = { "cluster_groups": { @@ -783,9 +739,16 @@ def __init__(self, vc_conn): ) def _create_nb_session(self): - """Creates a session with NetBox.""" + """ + Creates a session with NetBox + + :return: `True` if session created else `False` + :rtype: bool + """ + header = {"Authorization": "Token {}".format(settings.NB_API_KEY)} session = requests.Session() - session.headers.update(self.header) + session.headers.update(header) + self.nb_session = session log.info("Created new HTTP Session for NetBox.") return session From 1f2b7f24ecfec31546e40ca89ec84fc03543ab8b Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sun, 9 Feb 2020 19:37:33 +0100 Subject: [PATCH 12/21] Moved templates to dedicated directory --- run.py | 2 +- templates/__init__.py | 1 + netbox_templates.py => templates/netbox.py | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 templates/__init__.py rename netbox_templates.py => templates/netbox.py (98%) diff --git a/run.py b/run.py index 3d33ff8..71991c6 100644 --- a/run.py +++ b/run.py @@ -11,7 +11,7 @@ from pyVmomi import vim import settings from logger import log -from netbox_templates import Templates +from templates.netbox import Templates def main(): diff --git a/templates/__init__.py b/templates/__init__.py new file mode 100644 index 0000000..ecdd1ef --- /dev/null +++ b/templates/__init__.py @@ -0,0 +1 @@ +from .netbox import Templates diff --git a/netbox_templates.py b/templates/netbox.py similarity index 98% rename from netbox_templates.py rename to templates/netbox.py index 6e0ff9c..46fad55 100644 --- a/netbox_templates.py +++ b/templates/netbox.py @@ -78,7 +78,7 @@ def cluster_group(self, name, slug=None): :param name: Name of the cluster group :type name: str - :param slug: Unique slug for cluster group. A slug will be derived from :param name: if no slug is provided. + :param slug: Unique slug for cluster group. :type slug: str, optional """ obj = { @@ -201,7 +201,7 @@ def device_type(self, manufacturer, model, slug=None, part_number=None, :type manufacturer: str :param model: Name of NetBox model object :type model: str - :param slug: Unique slug for manufacturer. A slug will be derived from :param name: if no slug is provided. + :param slug: Unique slug for manufacturer. :type slug: str, optional :param part_number: Unique partner number for the device :type part_number: str, optional @@ -286,7 +286,7 @@ def manufacturer(self, name, slug=None): :param name: Name of the manufacturer :type name: str - :param slug: Unique slug for manufacturer. A slug will be derived from :param name: if no slug is provided. + :param slug: Unique slug for manufacturer. :type slug: str, optional """ obj = { From 78592e21e446f8700148226a9680f9ecda2bbc6f Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sun, 9 Feb 2020 19:37:45 +0100 Subject: [PATCH 13/21] Updated git workflow --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e54ed9f..b464c4c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,7 @@ jobs: run: | pip install pylint pylint --disable=C0302,E0611,E1101,C0103,R0914,R1702,R0912,R0915,R0913 run.py + pylint --disable=R0201,R0913,R0914 templates/netbox.py - name: Verify app compiles run: | python run.py --help From 9ae57adc66c33182d49f68e7b395e75d9fb873b1 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sun, 9 Feb 2020 21:11:39 +0100 Subject: [PATCH 14/21] Implement PTR lookup functionality to populate DNS name field on IP address objects (#61) * Added first stage of DNS lookups * Added async capable PTR lookups for #42 as an optional feature * Updated requirements.txt to support aiodns for #42 * Removed unused variable for linting --- requirements.txt | 5 +++ run.py | 90 +++++++++++++++++++++++++++++++++++++++++++++ settings.example.py | 3 ++ 3 files changed, 98 insertions(+) diff --git a/requirements.txt b/requirements.txt index 54a43fd..4b2a43f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,12 @@ +aiodns==2.0.0 certifi==2019.11.28 +cffi==1.14.0 chardet==3.0.4 idna==2.8 +pycares==3.1.1 +pycparser==2.19 pyvmomi==6.7.3 requests==2.22.0 six==1.13.0 +typing==3.7.4.1 urllib3==1.25.7 diff --git a/run.py b/run.py index 71991c6..2cef70f 100644 --- a/run.py +++ b/run.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """Exports vCenter objects and imports them into Netbox via Python3""" +import asyncio import atexit from socket import gaierror from datetime import date, datetime from ipaddress import ip_network import argparse +import aiodns import requests from pyVim.connect import SmartConnectNoSSL, Disconnect from pyVmomi import vim @@ -49,6 +51,9 @@ def main(): nb.sync_objects(vc_obj_type="hosts") nb.sync_objects(vc_obj_type="virtual_machines") nb.set_primary_ips() + # Optional tasks + if settings.POPULATE_DNS_NAME: + nb.set_dns_names() log.info( "Completed sync with vCenter instance '%s'! Total " "execution time %s.", vc_host["HOST"], @@ -208,6 +213,52 @@ def format_vcenter_conn(conn): conn["USER"], conn["PASS"] = settings.VC_USER, settings.VC_PASS return conn +def queue_dns_lookups(ips): + """ + Queue handler for reverse DNS lokups. + + :param ips: A list of IP addresses to queue for PTR lookup. + :type ips: list + :return: IP addresses and their respective PTR record + :rtype: dict + """ + loop = asyncio.get_event_loop() + resolver = aiodns.DNSResolver(loop=loop) + if settings.CUSTOM_DNS_SERVERS and settings.DNS_SERVERS: + resolver.nameservers = settings.DNS_SERVERS + queue = asyncio.gather(*(reverse_lookup(resolver, ip) for ip in ips)) + results = loop.run_until_complete(queue) + return results + +async def reverse_lookup(resolver, ip): + """ + Queries for PTR record of the IP provided with async support. + + :param resolver: aiodns resolver instance set to the asyncio loop + :type resolver: aiodns.DNSResolver + :param ip: IP address to request PTR record for. + :type ip: str + :return: IP Address and its PTR record + :rtype: tuple + """ + result = (ip, "") + allowed_chars = "abcdefghijklmnopqrstuvwxyz0123456789-." + log.info("Requesting PTR record for %s.", ip) + try: + resp = await resolver.gethostbyaddr(ip) + # Make sure records comply to NetBox and DNS expected format + if all([bool(c.lower() in allowed_chars) for c in resp.name]): + result = (ip, resp.name.lower()) + log.debug("PTR record for %s is '%s'.", ip, result[1]) + else: + log.debug( + "Invalid characters detected in PTR record '%s'. Nulling.", + resp.name + ) + except aiodns.error.DNSError as err: + log.info("Unable to find record for %s: %s", ip, err.args[1]) + return result + def truncate(text="", max_len=50): """Ensure a string complies to the maximum length specified.""" return text if len(text) < max_len else text[:max_len] @@ -1050,6 +1101,45 @@ def set_primary_ips(self): nb_id=parent_id, data=data ) + def set_dns_names(self): + """ + Performs a reverse DNS lookup on IP addresses and populates DMS name. + """ + log.info("Collecting NetBox IP address objects to set DNS Names.") + # Grab all the IPs from NetBox related tagged from the vCenter host + ip_objs = self.request( + req_type="get", nb_obj_type="ip_addresses", + query="?tag={}".format(format_slug(self.vc_tag)) + )["results"] + log.info("Collected %s NetBox IP address objects.", ip_objs) + # We take the IP address objects and make a map of relevant details to + # compare against and use later + nb_objs = {} + for obj in ip_objs: + ip = obj["address"].split("/")[0] + nb_objs[ip] = { + "id": obj["id"], + "dns_name": obj["dns_name"] + } + ips = [ip["address"].split("/")[0] for ip in ip_objs] + ptrs = queue_dns_lookups(ips) + # Having collected the IP address objects from NetBox already we can + # avoid individual checks for updates by comparing the objects and ptrs + log.info("Comparing latest PTR records against existing NetBox data.") + for ip, ptr in ptrs: + if ptr != nb_objs[ip]["dns_name"]: + log.info( + "Mismatch! The latest PTR for '%s' is '%s' while NetBox " + "has '%s'. Requesting update.", ip, ptr, + nb_objs[ip]["dns_name"] + ) + self.request( + req_type="patch", nb_obj_type="ip_addresses", + nb_id=nb_objs[ip]["id"], data={"dns_name": ptr} + ) + else: + log.info("NetBox has the latest PTR for '%s'. Moving on.", ip) + def sync_objects(self, vc_obj_type): """ Collects objects from vCenter and syncs them to NetBox. diff --git a/settings.example.py b/settings.example.py index ee29ba0..91e4766 100644 --- a/settings.example.py +++ b/settings.example.py @@ -6,6 +6,9 @@ LOG_FILE = True # Places all logs in a rotating file if True IPV4_ALLOWED = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8"] # IPv4 networks eligible to be synced to NetBox IPV6_ALLOWED = ["fe80::/10"] # IPv6 networks eligible to be synced to NetBox +POPULATE_DNS_NAME = True # Perform reverse DNS lookup on all eligible IP addresses and populate DNS name field in NetBox +CUSTOM_DNS_SERVERS = False # Use custom DNS servers defined below +DNS_SERVERS = ["192.168.1.11", "192.168.1.12"] # [optional] List of DNS servers to query for PTR records # vCenter Settings VC_HOSTS = [ From 7e11ff53f2488ffdc30fcc98c3596446afbe2942 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sun, 9 Feb 2020 21:24:20 +0100 Subject: [PATCH 15/21] Began conversion to Sphinx formatting and function reordering --- run.py | 142 +++++++++++++++++++++++++------------------- templates/netbox.py | 6 +- 2 files changed, 86 insertions(+), 62 deletions(-) diff --git a/run.py b/run.py index 2cef70f..c575833 100644 --- a/run.py +++ b/run.py @@ -16,66 +16,24 @@ from templates.netbox import Templates -def main(): - """Main function to run if script is called directly""" - parser = argparse.ArgumentParser() - parser.add_argument( - "-c", "--cleanup", action="store_true", - help="Remove all vCenter synced objects which support tagging. This " - "is helpful if you want to start fresh or stop using this script." - ) - parser.add_argument( - "-v", "--verbose", action="store_true", - help="Enable verbose output. This overrides the log level in the " - "settings file. Intended for debugging purposes only." - ) - args = parser.parse_args() - if args.verbose: - log.setLevel("DEBUG") - log.debug("Log level has been overriden by the --verbose argument.") - for vc_host in settings.VC_HOSTS: - try: - start_time = datetime.now() - nb = NetBoxHandler(vc_conn=vc_host) - if args.cleanup: - nb.remove_all() - log.info( - "Completed removal of vCenter instance '%s' objects. Total " - "execution time %s.", - vc_host["HOST"], (datetime.now() - start_time) - ) - else: - nb.verify_dependencies() - nb.sync_objects(vc_obj_type="datacenters") - nb.sync_objects(vc_obj_type="clusters") - nb.sync_objects(vc_obj_type="hosts") - nb.sync_objects(vc_obj_type="virtual_machines") - nb.set_primary_ips() - # Optional tasks - if settings.POPULATE_DNS_NAME: - nb.set_dns_names() - log.info( - "Completed sync with vCenter instance '%s'! Total " - "execution time %s.", vc_host["HOST"], - (datetime.now() - start_time) - ) - except (ConnectionError, requests.exceptions.ConnectionError, - requests.exceptions.ReadTimeout) as err: - log.warning( - "Critical connection error occurred. Skipping sync with '%s'.", - vc_host["HOST"] - ) - log.debug("Connection error details: %s", err) - continue - - def compare_dicts(dict1, dict2, dict1_name="d1", dict2_name="d2", path=""): """ - Compares the key value pairs of two dictionaries and match boolean. + Compares the key value pairs of two dictionaries returns whether they match. dict1 keys and values are compared against dict2. dict2 may have keys and - values that dict1 does not care evaluate. - dict1_name and dict2_name allow you to overwrite dictionary name for logs. + values that dict1 does not evaluate against. + :param dict1: Primary dictionary to compare against :param dict2: + :type dict1: dict + :param dict2: Dictionary being compared to by :param dict1: + :type dict2: dict + :param dict1_name: Friendly name of :param dict1: for log messages + :type dict1_name: str + :param dict2_name: Friendly name of :param dict1: for log messages + :type dict2_name: str + :param path: Used to keep state of nested dictionary traversal + :type path: str + :return: `True` if :param dict2: matches all keys and values in :param dict2: else `False` + :rtype: bool """ # Setup paths to track key exploration. The path parameter is used to allow # recursive comparisions and track what's being compared. @@ -135,9 +93,12 @@ def compare_dicts(dict1, dict2, dict1_name="d1", dict2_name="d2", path=""): def format_ip(ip_addr): """ - Formats IPv4 addresses to IP with CIDR standard notation. + Formats IPv4 addresses and subnet to IP with CIDR standard notation. - ip_address expects IP and subnet mask, example: 192.168.0.0/255.255.255.0 + :param ip_addr: IP address with subnet; example `192.168.0.0/255.255.255.0` + :type ip_addr: str + :return: IP address with CIDR notation; example `192.168.0.0/24` + :rtype: str """ ip = ip_addr.split("/")[0] cidr = ip_network(ip_addr, strict=False).prefixlen @@ -145,12 +106,15 @@ def format_ip(ip_addr): log.debug("Converted '%s' to CIDR notation '%s'.", ip_addr, result) return result + def format_slug(text): """ Format string to comply to NetBox slug acceptable pattern and max length. - NetBox slug pattern: ^[-a-zA-Z0-9_]+$ - NetBox slug max length: 50 characters + :param text: Text to be formatted into an acceptable slug + :type text: str + :return: Slug of allowed characters [-a-zA-Z0-9_] with max length of 50 + :rtype: str """ allowed_chars = ( "abcdefghijklmnopqrstuvxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" # Alphabet @@ -213,6 +177,60 @@ def format_vcenter_conn(conn): conn["USER"], conn["PASS"] = settings.VC_USER, settings.VC_PASS return conn + +def main(): + """Main function ran when the script is called directly.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "-c", "--cleanup", action="store_true", + help="Remove all vCenter synced objects which support tagging. This " + "is helpful if you want to start fresh or stop using this script." + ) + parser.add_argument( + "-v", "--verbose", action="store_true", + help="Enable verbose output. This overrides the log level in the " + "settings file. Intended for debugging purposes only." + ) + args = parser.parse_args() + if args.verbose: + log.setLevel("DEBUG") + log.debug("Log level has been overriden by the --verbose argument.") + for vc_host in settings.VC_HOSTS: + try: + start_time = datetime.now() + nb = NetBoxHandler(vc_conn=vc_host) + if args.cleanup: + nb.remove_all() + log.info( + "Completed removal of vCenter instance '%s' objects. Total " + "execution time %s.", + vc_host["HOST"], (datetime.now() - start_time) + ) + else: + nb.verify_dependencies() + nb.sync_objects(vc_obj_type="datacenters") + nb.sync_objects(vc_obj_type="clusters") + nb.sync_objects(vc_obj_type="hosts") + nb.sync_objects(vc_obj_type="virtual_machines") + nb.set_primary_ips() + # Optional tasks + if settings.POPULATE_DNS_NAME: + nb.set_dns_names() + log.info( + "Completed sync with vCenter instance '%s'! Total " + "execution time %s.", vc_host["HOST"], + (datetime.now() - start_time) + ) + except (ConnectionError, requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout) as err: + log.warning( + "Critical connection error occurred. Skipping sync with '%s'.", + vc_host["HOST"] + ) + log.debug("Connection error details: %s", err) + continue + + def queue_dns_lookups(ips): """ Queue handler for reverse DNS lokups. @@ -230,6 +248,7 @@ def queue_dns_lookups(ips): results = loop.run_until_complete(queue) return results + async def reverse_lookup(resolver, ip): """ Queries for PTR record of the IP provided with async support. @@ -259,10 +278,12 @@ async def reverse_lookup(resolver, ip): log.info("Unable to find record for %s: %s", ip, err.args[1]) return result + def truncate(text="", max_len=50): """Ensure a string complies to the maximum length specified.""" return text if len(text) < max_len else text[:max_len] + def verify_ip(ip_addr): """ Verify input is expected format and checks against allowed networks. @@ -296,6 +317,7 @@ def verify_ip(ip_addr): log.debug("IP '%s' validation returned a %s status.", ip_addr, result) return result + class vCenterHandler: """Handles vCenter connection state and object data collection""" def __init__(self, vc_conn, nb_api_version): diff --git a/templates/netbox.py b/templates/netbox.py index 46fad55..fc6f09b 100644 --- a/templates/netbox.py +++ b/templates/netbox.py @@ -17,8 +17,10 @@ def format_slug(text): """ Format string to comply to NetBox slug acceptable pattern and max length. - NetBox slug pattern: ^[-a-zA-Z0-9_]+$ - NetBox slug max length: 50 characters + :param text: Text to be formatted into an acceptable slug + :type text: str + :return: Slug of allowed characters [-a-zA-Z0-9_] with max length of 50 + :rtype: str """ allowed_chars = ( "abcdefghijklmnopqrstuvxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" # Alphabet From 8f744c2fb26ea6a51691f2318130978d4b9ed9d4 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sun, 9 Feb 2020 22:05:52 +0100 Subject: [PATCH 16/21] Updated docstrings to sphinx format and cleaned up comments --- run.py | 126 +++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 83 insertions(+), 43 deletions(-) diff --git a/run.py b/run.py index c575833..b6518e5 100644 --- a/run.py +++ b/run.py @@ -130,11 +130,15 @@ def format_slug(text): # Enforce max length return truncate(text, max_len=50).lower() + def format_tag(tag): """ Format string to comply to NetBox tag format and max length. - NetBox tag max length: 100 characters + :param tag: The text which should be formatted + :type tag: str + :return: Tag which complies to the NetBox required tag format and max length + :rtype: str """ # If the tag presented is an IP address then no modifications are required try: @@ -145,6 +149,7 @@ def format_tag(tag): tag = truncate(tag, max_len=100) return tag + def format_vcenter_conn(conn): """ Formats :param conn: into the expected connection string for vCenter. @@ -280,7 +285,16 @@ async def reverse_lookup(resolver, ip): def truncate(text="", max_len=50): - """Ensure a string complies to the maximum length specified.""" + """ + Ensure a string complies to the maximum length specified. + + :param text: Text to be checked for length and truncated if necessary + :type text: str + :param max_len: Max length of the returned string + :type max_len: int, optional + :return: Text in :param text: truncated to :param max_len: if necessary + :rtype: str + """ return text if len(text) < max_len else text[:max_len] @@ -290,6 +304,10 @@ def verify_ip(ip_addr): Allowed networks can be defined in the settings IPV4_ALLOWED and IPV6_ALLOWED variables. + :param ip_addr: IP address to check for format and whether its within allowed networks + :type ip_addr: str + :return: `True` if valid IP and within the allowed networks else `False` + :rtype: bool """ result = False try: @@ -319,7 +337,14 @@ def verify_ip(ip_addr): class vCenterHandler: - """Handles vCenter connection state and object data collection""" + """ + Handles vCenter connection state and object data collection + + :param vc_conn: Connection details for a vCenter host defined in settings.py + :type vc_conn: dict + :param nb_api_version: NetBox API version that objects must conform to + :type nb_api_version: float + """ def __init__(self, vc_conn, nb_api_version): self.nb_api_version = nb_api_version self.vc_session = None # Used to hold vCenter session state @@ -330,7 +355,7 @@ def __init__(self, vc_conn, nb_api_version): self.tags = ["Synced", "vCenter", format_tag(self.vc_host)] def authenticate(self): - """Authenticate to vCenter""" + """Create a session to vCenter and authenticate against it""" log.info( "Attempting authentication to vCenter instance '%s'.", self.vc_host @@ -363,6 +388,8 @@ def create_view(self, vc_obj_type): Create a view scoped to the vCenter object type desired. This should be called before collecting data about vCenter object types. + :param vc_obj_type: vCenter object type to extract, must be key in vc_obj_views + :type vc_obj_type: str """ # Mapping of object type keywords to view types vc_obj_views = { @@ -381,32 +408,14 @@ def create_view(self, vc_obj_type): True # Should we recurively look into view ) - def _format_value(self, key, value): - """ - Formats object values depending on the NetBox API version. - - Prior to NetBox API v2.7 NetBox used integers for status and type - fields. We use the version of NetBox API to determine whether we need - to return integers or named strings. - """ - if self.nb_api_version > 2.6: - if key == "status": - translation = {0: "offline", 1: "active"} - elif key == "type": - translation = {0: "virtual", 32767: "other"} - result = translation[value] - else: - result = value - return result - - def get_objects(self, vc_obj_type): """ Collects vCenter objects of type and returns NetBox formated objects. - Returns dictionary of NetBox object types and corresponding list of - Netbox objects. Object format must be compliant to NetBox API POST - method (include all required fields). + :param vc_obj_type: vCenter object type to extract, must be key in obj_type_map + :type vc_obj_type: str + :return: Extracted vCenter objects of :param vc_obj_type: in NetBox format + :rtype: dict """ log.info( "Collecting vCenter %s objects.", @@ -695,7 +704,12 @@ def get_objects(self, vc_obj_type): return results class NetBoxHandler: - """Handles NetBox connection state and interaction with API""" + """ + Handles NetBox connection state and interaction with API + + :param vc_conn: Connection details for a vCenter host defined in settings.py + :type vc_conn: dict + """ def __init__(self, vc_conn): self.nb_api_url = "http{}://{}{}/api/".format( ("s" if not settings.NB_DISABLE_TLS else ""), settings.NB_FQDN, @@ -804,8 +818,6 @@ def __init__(self, vc_conn): "prune_pref": 7 }, } - # Create an instance of the vCenter host for use in tagging functions - # Strip to hostname if a fqdn was provided self.vc_tag = format_tag(vc_conn["HOST"]) self.vc = vCenterHandler( format_vcenter_conn(vc_conn), nb_api_version=self._get_api_version() @@ -826,7 +838,12 @@ def _create_nb_session(self): return session def _get_api_version(self): - """Determines the current NetBox API Version""" + """ + Determines the current NetBox API Version + + :return: NetBox API version + :rtype: float + """ with self.nb_session.get( self.nb_api_url, timeout=10, verify=(not settings.NB_INSECURE_TLS)) as resp: @@ -835,7 +852,16 @@ def _get_api_version(self): return result def get_primary_ip(self, nb_obj_type, nb_id): - """Collects the primary IP of a NetBox device or virtual machine.""" + """ + Collects the primary IP of a NetBox device or virtual machine. + + :param nb_obj_type: NetBox object type; must match key in self.obj_map + :type nb_obj_type: str + :param nb_id: NetBox object ID of parent object where IP is configured + :type nb_id: int + :return: Primary IP and ID of the requested NetBox device or virtual machine + :rtype: dict + """ query_key = str( "device_id" if nb_obj_type == "devices" else "virtual_machine_id" ) @@ -861,11 +887,18 @@ def request(self, req_type, nb_obj_type, data=None, query=None, nb_id=None): """ HTTP requests and exception handler for NetBox - req_type: HTTP Method - nb_obj_type: NetBox object type, must match keys in self.obj_map - data: Dictionary to be passed as request body. - query: String used to filter results when using GET method - nb_id: Integer used when working with a single NetBox object + :param req_type: HTTP method type (GET, POST, PUT, PATCH, DELETE) + :type req_type: str + :param nb_obj_type: NetBox object type, must match keys in self.obj_map + :type nb_obj_type: str + :param data: NetBox object key value pairs + :type data: dict, optional + :param query: Filter for GET method requests + :type query: str, optional + :param nb_id: NetBox Object ID used when modifying an existing object + :type nb_id: int, optional + :return: Netbox objects and their corresponding data + :rtype: dict """ result = None # Generate URL @@ -962,9 +995,10 @@ def obj_exists(self, nb_obj_type, vc_data): If object does not exist or does not match the vCenter object it will be created or updated. - nb_obj_type: String NetBox object type to query for and compare against - vc_data: Dictionary of vCenter object key value pairs pre-formatted for - NetBox + :param nb_obj_type: NetBox object type, must match keys in self.obj_map + :type nb_obj_type: str + :param vc_data: Extracted object of :param nb_obj_type: from vCenter + :type vc_data: dict """ # NetBox Device Types objects do not have names to query; we catch # and use the model instead @@ -1168,6 +1202,8 @@ def sync_objects(self, vc_obj_type): Some object types do not support tags so they will be a one-way sync meaning orphaned objects will not be removed from NetBox. + :param vc_obj_type: vCenter object type to extract, must be key in obj_type_map + :type vc_obj_type: str """ # Collect data from vCenter log.info( @@ -1232,9 +1268,10 @@ def prune_objects(self, vc_objects, vc_obj_type): If NetBox objects are not found in the supplied vc_objects data then they will go through a pruning process. - vc_objects: Dictionary of VC object types and list of their objects - vc_obj_type: The parent object type called during the synce. This is - used to determine whether special filtering needs to be applied. + :param vc_data: Nested dict of extracted vCenter objects sorted by NetBox object type keys + :type vc_objects: dict + :param vc_obj_type: vCenter object type to extract, must be key in obj_type_map + :type vc_obj_type: str """ # Determine qualifying object types based on object map nb_obj_types = [t for t in vc_objects if self.obj_map[t]["prune"]] @@ -1367,7 +1404,10 @@ def search_prefix(self, ip_addr): """ Queries Netbox for the parent prefix of any supplied IP address. - Returns dictionary of VRF and tenant values. + :param ip_addr: IP address + :type ip_addr: str + :return: The VRF and tenant name of the prefix containing :param ip_addr: + :rtype: dict """ result = {"tenant": None, "vrf": None} query = "?contains={}".format(ip_addr) From f0799b139222c49ce29f542735370879505f5eb5 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Mon, 10 Feb 2020 19:43:57 +0100 Subject: [PATCH 17/21] Added more debug logs for asset tags --- run.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/run.py b/run.py index b6518e5..d0caa6b 100644 --- a/run.py +++ b/run.py @@ -521,6 +521,10 @@ def get_objects(self, vc_obj_type): # Asset Tag if "AssetTag" in hw_idents.keys(): asset_tag = hw_idents["AssetTag"].lower() + log.debug( + "Received asset tag '%s' from vCenter.", + asset_tag + ) banned_tags = ["Default string", "Unknown", " ", ""] banned_tags = [t.lower() for t in banned_tags] if asset_tag in banned_tags: @@ -531,6 +535,7 @@ def get_objects(self, vc_obj_type): "No asset tag detected for device '%s'.", obj_name ) asset_tag = None + log.debug("Final decided asset tag: %s", asset_tag) results["devices"].append(nbt.device( name=truncate(obj_name, max_len=64), device_role="Server", From 26447bc41a65385a3d18b83707c71ffbac4a6165 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Mon, 10 Feb 2020 23:14:28 +0100 Subject: [PATCH 18/21] Improved banned asset tags for #41 and allowed them to be disabled --- run.py | 63 ++++++++++++++++++++++++++++++++------------- settings.example.py | 1 + 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/run.py b/run.py index d0caa6b..e70400e 100644 --- a/run.py +++ b/run.py @@ -183,6 +183,33 @@ def format_vcenter_conn(conn): return conn +def is_banned_asset_tag(text): + """ + Determines whether the text is a banned asset tag through various tests. + + :param text: Text to be checked against banned asset tags + :type text: str + :return: `True` if a banned asset tag else `False` + :rtype: bool + """ + # Is asset tag in banned list? + text = text.lower() + banned_tags = ["Default string", "Unknown", " ", ""] + banned_tags = [t.lower() for t in banned_tags] + if text in banned_tags: + result = True + # Does it exceed the max allowed length for NetBox asset tags? + elif len(text) > 50: + result = True + # Does asset tag contain all spaces? + elif text.replace(" ", "") == "": + result = True + # Apparently a "good" asset tag :) + else: + result = False + return result + + def main(): """Main function ran when the script is called directly.""" parser = argparse.ArgumentParser() @@ -519,23 +546,23 @@ def get_objects(self, vc_obj_type): else: serial_number = None # Asset Tag - if "AssetTag" in hw_idents.keys(): - asset_tag = hw_idents["AssetTag"].lower() - log.debug( - "Received asset tag '%s' from vCenter.", - asset_tag - ) - banned_tags = ["Default string", "Unknown", " ", ""] - banned_tags = [t.lower() for t in banned_tags] - if asset_tag in banned_tags: - log.debug("Banned asset tag string. Nulling.") - asset_tag = None - else: - log.debug( - "No asset tag detected for device '%s'.", obj_name - ) - asset_tag = None - log.debug("Final decided asset tag: %s", asset_tag) + asset_tag = None + if settings.ASSET_TAGS: + if "AssetTag" in hw_idents.keys(): + asset_tag = hw_idents["AssetTag"].lower() + log.debug( + "Received asset tag '%s' from vCenter.", + asset_tag + ) + if is_banned_asset_tag(asset_tag): + log.debug("Banned asset tag string. Nulling.") + else: + log.debug( + "No asset tag detected for device '%s'.", + obj_name + ) + log.debug("Final decided asset tag: %s", asset_tag) + # Create NetBox device results["devices"].append(nbt.device( name=truncate(obj_name, max_len=64), device_role="Server", @@ -825,7 +852,7 @@ def __init__(self, vc_conn): } self.vc_tag = format_tag(vc_conn["HOST"]) self.vc = vCenterHandler( - format_vcenter_conn(vc_conn), nb_api_version=self._get_api_version() + format_vcenter_conn(vc_conn), nb_api_version=self.nb_api_version ) def _create_nb_session(self): diff --git a/settings.example.py b/settings.example.py index 91e4766..18c8ce6 100644 --- a/settings.example.py +++ b/settings.example.py @@ -9,6 +9,7 @@ POPULATE_DNS_NAME = True # Perform reverse DNS lookup on all eligible IP addresses and populate DNS name field in NetBox CUSTOM_DNS_SERVERS = False # Use custom DNS servers defined below DNS_SERVERS = ["192.168.1.11", "192.168.1.12"] # [optional] List of DNS servers to query for PTR records +ASSET_TAGS = True # Attempt to collect asset tags from vCenter hosts # vCenter Settings VC_HOSTS = [ From b115af57c0b0ee02506345c42631eabbd5eb3e77 Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Tue, 11 Feb 2020 11:49:38 +0100 Subject: [PATCH 19/21] Updated settings.py format --- settings.example.py | 62 ++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/settings.example.py b/settings.example.py index 18c8ce6..b600dc2 100644 --- a/settings.example.py +++ b/settings.example.py @@ -1,30 +1,52 @@ #!/usr/bin/env python3 # Program Settings -LOG_LEVEL = "info" # Valid options are debug, info, warning, error, critical -LOG_CONSOLE = True # Logs to console if True, disables console logging if False -LOG_FILE = True # Places all logs in a rotating file if True -IPV4_ALLOWED = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8"] # IPv4 networks eligible to be synced to NetBox -IPV6_ALLOWED = ["fe80::/10"] # IPv6 networks eligible to be synced to NetBox -POPULATE_DNS_NAME = True # Perform reverse DNS lookup on all eligible IP addresses and populate DNS name field in NetBox -CUSTOM_DNS_SERVERS = False # Use custom DNS servers defined below -DNS_SERVERS = ["192.168.1.11", "192.168.1.12"] # [optional] List of DNS servers to query for PTR records -ASSET_TAGS = True # Attempt to collect asset tags from vCenter hosts +# Valid options are debug, info, warning, error, critical +LOG_LEVEL = "info" + # Logs to console if True, disables console logging if False +LOG_CONSOLE = True + # Places all logs in a rotating file if True +LOG_FILE = True +# IPv4 networks eligible to be synced to NetBox +IPV4_ALLOWED = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8"] + # IPv6 networks eligible to be synced to NetBox +IPV6_ALLOWED = ["fe80::/10"] + + +# Optional Settings +# Attempt to collect asset tags from vCenter hosts +ASSET_TAGS = True +# Perform reverse DNS lookup on all eligible IP addresses and populate DNS name field in NetBox +POPULATE_DNS_NAME = True +# Use custom DNS servers defined below for reverse DNS lookups +CUSTOM_DNS_SERVERS = False +# List of DNS servers to query for PTR records +DNS_SERVERS = ["192.168.1.11", "192.168.1.12"] +# Create a custom field for virtual machines to track the current host they reside on +TRACK_VM_HOST = False + # vCenter Settings +# Hostname (FQDN or IP), Port, User, and Password for each vCenter instance +# The USER argument supports SSO with @domain.tld suffix VC_HOSTS = [ - # Hostname (FQDN or IP), Port, User, and Password for each vCenter instance - # The USER argument supports SSO with @domain.tld suffix - # You can add more vCenter instances by duplicating the line below and - # updating the values + # You can add more vCenter instances by duplicating the line below {"HOST": "vcenter1.example.com", "PORT": 443, "USER": "", "PASS": ""}, ] + # NetBox Settings -NB_API_KEY = "" # NetBox API Key -NB_DISABLE_TLS = False # Disables SSL/TLS and uses HTTP for requests. Not ever recommended. -NB_FQDN = "netbox.example.com" # The fully qualified domain name to reach NetBox -NB_INSECURE_TLS = False # Leverage SSL/TLS but ignore certificate errors (ex. expired, untrusted) -NB_PORT = 443 # [optional] NetBox port to connect to if changed from the default -NB_PRUNE_ENABLED = True # Automatically orphan and delete objects if they are no longer in their source system -NB_PRUNE_DELAY_DAYS = 0 # How many days should we wait before pruning an orphaned object +# NetBox API Key +NB_API_KEY = "" +# Disables SSL/TLS and uses HTTP for requests. Not ever recommended. +NB_DISABLE_TLS = False +# The fully qualified domain name to reach NetBox +NB_FQDN = "netbox.example.com" +# Leverage SSL/TLS but ignore certificate errors (ex. expired, untrusted) +NB_INSECURE_TLS = False +# NetBox port to connect to +NB_PORT = 443 +# Automatically orphan and delete objects if they are no longer in their source system +NB_PRUNE_ENABLED = True +# How many days should to wait before pruning an orphaned object +NB_PRUNE_DELAY_DAYS = 0 From 3b4db8d2d39be1f9803d7769fc10663253479e5f Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Tue, 11 Feb 2020 14:22:34 +0100 Subject: [PATCH 20/21] Updated netbox template --- templates/netbox.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/templates/netbox.py b/templates/netbox.py index fc6f09b..d588f3c 100644 --- a/templates/netbox.py +++ b/templates/netbox.py @@ -17,10 +17,8 @@ def format_slug(text): """ Format string to comply to NetBox slug acceptable pattern and max length. - :param text: Text to be formatted into an acceptable slug - :type text: str - :return: Slug of allowed characters [-a-zA-Z0-9_] with max length of 50 - :rtype: str + NetBox slug pattern: ^[-a-zA-Z0-9_]+$ + NetBox slug max length: 50 characters """ allowed_chars = ( "abcdefghijklmnopqrstuvxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" # Alphabet @@ -355,9 +353,6 @@ def _version_dependent(self, nb_obj_type, key, value): 44: "carp" }, "status": { - # Zero should be offline but does not exist in NetBox v2.7 - # so we remap it to deprecated - 0: "deprecated", 1: "active", 2: "reserved", 3: "deprecated", From ed730e5140b7504cc354d5b00a759e610ebcaa0a Mon Sep 17 00:00:00 2001 From: Raymond Beaudoin <24757919+synackray@users.noreply.github.com> Date: Sun, 16 Feb 2020 00:16:55 +0100 Subject: [PATCH 21/21] Format fix for netbox template --- templates/netbox.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/netbox.py b/templates/netbox.py index d588f3c..77b2b3f 100644 --- a/templates/netbox.py +++ b/templates/netbox.py @@ -17,8 +17,10 @@ def format_slug(text): """ Format string to comply to NetBox slug acceptable pattern and max length. - NetBox slug pattern: ^[-a-zA-Z0-9_]+$ - NetBox slug max length: 50 characters + :param text: Text to be formatted into an acceptable slug + :type text: str + :return: Slug of allowed characters [-a-zA-Z0-9_] with max length of 50 + :rtype: str """ allowed_chars = ( "abcdefghijklmnopqrstuvxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" # Alphabet