Wei-Yu Chen | e0877d0 | 2021-07-12 23:12:50 +0800 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | |
| 3 | # SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org> |
| 4 | # SPDX-License-Identifier: Apache-2.0 |
| 5 | |
| 6 | # tenant_validator.py |
| 7 | # Grab the data from Tenant and check if the information is invalidate |
| 8 | |
Wei-Yu Chen | b43fc32 | 2021-07-21 14:50:16 +0800 | [diff] [blame^] | 9 | from __future__ import absolute_import |
| 10 | |
Wei-Yu Chen | e0877d0 | 2021-07-12 23:12:50 +0800 | [diff] [blame] | 11 | import re |
Wei-Yu Chen | b43fc32 | 2021-07-21 14:50:16 +0800 | [diff] [blame^] | 12 | import sys |
Wei-Yu Chen | e0877d0 | 2021-07-12 23:12:50 +0800 | [diff] [blame] | 13 | import yaml |
| 14 | import logging |
| 15 | import argparse |
Wei-Yu Chen | e0877d0 | 2021-07-12 23:12:50 +0800 | [diff] [blame] | 16 | import pynetbox |
| 17 | import requests |
| 18 | import netaddr |
| 19 | |
| 20 | |
| 21 | logging.basicConfig() |
| 22 | logger = logging.getLogger("TenentValidator") |
| 23 | |
| 24 | # The Global variable shared with different netbox api caller function |
| 25 | netboxapi = None |
| 26 | netbox_config = None |
| 27 | misconfs = list() |
| 28 | |
| 29 | # The consistent variable should be same in every devices, prefixes of the deployment |
| 30 | site_name = None |
| 31 | deployment_name = None |
| 32 | |
| 33 | # A Regex rule to identify if device name is a valid domain |
| 34 | fqdn_regex = re.compile( |
Wei-Yu Chen | b43fc32 | 2021-07-21 14:50:16 +0800 | [diff] [blame^] | 35 | r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*" |
| 36 | + r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" |
Wei-Yu Chen | e0877d0 | 2021-07-12 23:12:50 +0800 | [diff] [blame] | 37 | ) |
| 38 | |
| 39 | |
| 40 | class Configuration(object): |
| 41 | |
| 42 | mapping_dict = { |
| 43 | logging.ERROR: logger.error, |
| 44 | logging.WARN: logger.warn, |
| 45 | logging.INFO: logger.info, |
| 46 | } |
| 47 | |
| 48 | def __init__(self, obj, message, level=logging.ERROR): |
| 49 | self.level = level |
| 50 | self.obj = obj |
| 51 | self.message = message |
| 52 | |
| 53 | # self.mapping_dict[level](self) |
| 54 | |
| 55 | def __repr__(self): |
| 56 | return "[%s] object %s: %s\nRef. %s" % ( |
| 57 | "/".join(self.obj.url.split("/")[-4:-2]), |
| 58 | self.obj, |
| 59 | self.message, |
| 60 | self.obj.url.replace("10.76.28.11", "netbox.infra.onlab.us").replace( |
| 61 | "api/", "" |
| 62 | ), |
| 63 | ) |
| 64 | |
| 65 | |
| 66 | def get_object_type(obj): |
| 67 | return ("/".join(obj.url.split("/")[-4:-2]),) |
| 68 | |
| 69 | |
| 70 | def validate_vlans(vlans=list()): |
| 71 | global misconfs |
| 72 | |
| 73 | for vlan in vlans: |
| 74 | if not vlan.group: |
| 75 | misconfs.append(Configuration(vlan, "VLAN group isn't set")) |
| 76 | if not vlan.site: |
| 77 | misconfs.append(Configuration(vlan, "VLAN site isn't set")) |
| 78 | if not vlan.tenant: |
| 79 | misconfs.append(Configuration(vlan, "VLAN tenant isn't set")) |
| 80 | |
| 81 | |
| 82 | def validate_prefixes(prefixes=list()): |
| 83 | global misconfs |
| 84 | |
| 85 | tenant_dict = dict() |
| 86 | |
| 87 | for prefix in prefixes: |
| 88 | netmask = prefix.prefix.split("/")[-1] |
| 89 | |
| 90 | if not re.search(fqdn_regex, prefix.description): |
| 91 | misconfs.append(Configuration(prefix, "Description (FQDN) is invalid")) |
| 92 | else: |
| 93 | device = netboxapi.dcim.devices.filter(tenant_id=prefix.tenant.id)[0] |
| 94 | if device: |
| 95 | sitename = device.name.split(".")[-1] |
| 96 | if sitename not in prefix.description: |
| 97 | misconfs.append( |
| 98 | Configuration(prefix, "Site name should in the prefix FQDN") |
| 99 | ) |
| 100 | |
| 101 | if not prefix.tenant: |
| 102 | misconfs.append(Configuration(prefix, "Tenant isn't set")) |
| 103 | else: |
| 104 | tenant_dict.setdefault(prefix.tenant, dict()) |
| 105 | tenant_dict[prefix.tenant].setdefault(netmask, list()) |
| 106 | tenant_dict[prefix.tenant][netmask].append(prefix) |
| 107 | |
| 108 | # Assume prefixes in different tenants don't have the intersection |
| 109 | # So parent prefix only check if it has a DHCP sub-prefix in same tenant |
| 110 | for prefixes_by_tenant in tenant_dict.values(): |
| 111 | for netmask, prefixes_by_netmask in prefixes_by_tenant.items(): |
| 112 | for prefix in prefixes_by_netmask: |
| 113 | if int(netmask) < 26: |
| 114 | children = [ |
| 115 | child |
| 116 | for key, value in prefixes_by_tenant.items() |
| 117 | if int(key) > int(netmask) |
| 118 | for child in value |
| 119 | ] |
| 120 | |
| 121 | children = [ |
| 122 | child |
| 123 | for child in children |
| 124 | if str( |
| 125 | netaddr.IPNetwork( |
| 126 | child.prefix.split("/")[0] + "/" + netmask |
| 127 | ).cidr |
| 128 | ) |
| 129 | == prefix.prefix |
| 130 | ] |
| 131 | if children: |
| 132 | continue |
| 133 | |
| 134 | dhcp_addr = list( |
| 135 | filter( |
| 136 | lambda ip: ip.status.value == "dhcp", |
| 137 | netboxapi.ipam.ip_addresses.filter(parent=prefix.prefix), |
| 138 | ) |
| 139 | ) |
| 140 | |
| 141 | if not dhcp_addr: |
| 142 | misconfs.append( |
| 143 | Configuration(prefix, "DHCP subnet not found in prefix") |
| 144 | ) |
| 145 | continue |
| 146 | |
| 147 | if len(dhcp_addr) > 1: |
| 148 | misconfs.append( |
| 149 | Configuration( |
| 150 | prefix, "Prefix should have exact 1 DHCP subnet" |
| 151 | ) |
| 152 | ) |
| 153 | continue |
| 154 | |
| 155 | dhcp_range = dhcp_addr[0].address |
| 156 | ip_addrs = netboxapi.ipam.ip_addresses.filter(parent=dhcp_range) |
| 157 | ip_addrs = list(filter(lambda ip: ip != dhcp_addr[0], ip_addrs)) |
| 158 | if ip_addrs: |
| 159 | misconfs.append( |
| 160 | Configuration( |
| 161 | prefix, |
| 162 | "DHCP range %s contains other IP addresses: %s" |
| 163 | % (dhcp_range, ip_addrs), |
| 164 | ) |
| 165 | ) |
| 166 | |
| 167 | |
| 168 | def validate_ip_addresses(ip_addresses=list()): |
| 169 | global misconfs |
| 170 | prefix_dict = dict() |
| 171 | |
| 172 | for ip_address in ip_addresses: |
| 173 | |
| 174 | if netaddr.IPNetwork(ip_address.address).network.is_private(): |
| 175 | if not ip_address.vrf: |
| 176 | misconfs.append( |
| 177 | Configuration(ip_address, "VRF isn't set for this IP address") |
| 178 | ) |
| 179 | else: |
| 180 | prefix = str(netaddr.IPNetwork(ip_address.address).cidr) |
| 181 | if prefix not in prefix_dict: |
| 182 | prefix_dict[prefix] = netboxapi.ipam.prefixes.get(prefix=prefix) |
| 183 | |
| 184 | if prefix_dict[prefix] and prefix_dict[prefix].vrf != ip_address.vrf: |
| 185 | misconfs.append( |
| 186 | Configuration(ip_address, "VRF isn't match with Prefix") |
| 187 | ) |
| 188 | |
| 189 | |
| 190 | def validate_interfaces(interfaces=list()): |
| 191 | global misconfs |
| 192 | |
| 193 | for interface in interfaces: |
| 194 | count_ips = 0 |
| 195 | |
| 196 | if get_object_type(interface) == "virtualization/interfaces": |
| 197 | count_ips = len( |
| 198 | netboxapi.ipam.ip_addresses.filter(vminterface_id=interface.id) |
| 199 | ) |
| 200 | elif get_object_type(interface) == "dcim/interfaces": |
| 201 | count_ips = interface.count_ipaddresses |
| 202 | |
| 203 | if count_ips > 0 and not interface.mac_address: |
| 204 | misconfs.append( |
| 205 | Configuration( |
| 206 | interface, |
| 207 | "VM Interface has IP address assigned but mac address isn't set", |
| 208 | ) |
| 209 | ) |
| 210 | |
| 211 | if ( |
| 212 | get_object_type(interface) == "dcim/interfaces" |
| 213 | and interface.type.value == "virtaul" |
| 214 | and (not interface.mgmt_only or not interface.tagged_vlans) |
| 215 | ): |
| 216 | misconfs.append( |
| 217 | Configuration( |
| 218 | interface, |
| 219 | "Virtual interface should be management only or VLAN interface", |
| 220 | ) |
| 221 | ) |
| 222 | |
| 223 | if interface.tagged_vlans: |
| 224 | if len(interface.tagged_vlans) > 1: |
| 225 | misconfs.append( |
| 226 | Configuration(interface, "Virtual Interface has multiple VLANs set") |
| 227 | ) |
| 228 | elif str(interface.tagged_vlans[0].vid) not in interface.name: |
| 229 | misconfs.append( |
| 230 | Configuration( |
| 231 | interface, "Virtual Interface name not match to VLAN ID" |
| 232 | ) |
| 233 | ) |
| 234 | |
| 235 | |
| 236 | def validate_vrfs(vrfs=list()): |
| 237 | global misconfs |
| 238 | |
| 239 | for vrf in vrfs: |
| 240 | if not vrf.enforce_unique: |
| 241 | misconfs.append( |
| 242 | Configuration(vrf, "VRF doesn't have enforce_unique set as True") |
| 243 | ) |
| 244 | |
| 245 | if not vrf.tenant: |
| 246 | misconfs.append(Configuration(vrf, "VRF doesn't have tenant set")) |
| 247 | |
| 248 | |
| 249 | def validate_machines(machines=list()): |
| 250 | global misconfs |
| 251 | |
| 252 | tenant_info = dict() |
| 253 | |
| 254 | for machine in machines: |
| 255 | if not re.search(fqdn_regex, machine.name): |
| 256 | misconfs.append( |
| 257 | Configuration( |
| 258 | machine, "Device/VM FQDN name %s is invalid" % machine.name |
| 259 | ) |
| 260 | ) |
| 261 | |
| 262 | if not machine.tenant: |
| 263 | misconfs.append(Configuration(machine, "Device/VM doesn't have tenant set")) |
| 264 | |
| 265 | segments = machine.name.split(".") |
| 266 | if len(segments) != 3: |
| 267 | misconfs.append( |
| 268 | Configuration( |
| 269 | machine, |
| 270 | "Device/VM FQDN should have 3 segments, found %d" % len(segments), |
| 271 | ) |
| 272 | ) |
| 273 | elif machine.tenant: |
| 274 | if machine.tenant not in tenant_info: |
| 275 | tenant_info.setdefault( |
| 276 | machine.tenant, {"deployment": segments[1], "site": segments[2]} |
| 277 | ) |
| 278 | else: |
| 279 | deployment = tenant_info[machine.tenant]["deployment"] |
| 280 | site = tenant_info[machine.tenant]["site"] |
| 281 | if deployment != segments[1] or site != segments[2]: |
| 282 | misconfs.append( |
| 283 | Configuration( |
| 284 | machine, |
| 285 | "Deployment or Site name is not consistent with other Device/VM", |
| 286 | ) |
| 287 | ) |
| 288 | |
| 289 | if ( |
| 290 | ( |
| 291 | machine.__class__.__name__ == "Devices" |
| 292 | and machine.device_role.name in ["Router", "Switch", "Server"] |
| 293 | ) |
| 294 | or machine.__class__.__name__ == "VirtualMachines" |
| 295 | ) and not machine.primary_ip: |
| 296 | misconfs.append( |
| 297 | Configuration(machine, "Primary IP must be set for this Device/VM") |
| 298 | ) |
| 299 | |
| 300 | |
| 301 | def validate_tenants(tenants=list()): |
| 302 | global misconfs |
| 303 | |
| 304 | for tenant in tenants: |
| 305 | if tenant.device_count == 0: |
| 306 | misconfs.append(Configuration(tenant, "0 device was found for this tenant")) |
| 307 | if tenant.prefix_count == 0: |
| 308 | misconfs.append(Configuration(tenant, "0 prefix was found for this tenant")) |
| 309 | if tenant.vrf_count == 0: |
| 310 | misconfs.append(Configuration(tenant, "0 vrf was found for this tenant")) |
| 311 | |
| 312 | devices = list(netboxapi.dcim.devices.filter(tenant_id=tenant.id)) |
| 313 | mgmtserver = list(filter(lambda d: d.device_role.name == "Router", devices)) |
| 314 | if len(mgmtserver) != 1: |
| 315 | misconfs.append( |
| 316 | Configuration(tenant, "Tenant must have exact 1 Router (mgmtserver)") |
| 317 | ) |
| 318 | |
| 319 | |
| 320 | def get_objects(tenant_name=""): |
| 321 | return_dict = dict() |
| 322 | |
| 323 | tenants = list(netboxapi.tenancy.tenants.filter(name=tenant_name)) |
| 324 | if len(tenants) == 0: |
| 325 | logger.critical("Tenant name %s wasn't found in Netbox" % tenant_name) |
| 326 | sys.exit(1) |
| 327 | tenant_id = None if len(tenants) != 1 else tenants[0].id |
| 328 | |
| 329 | # If the tenant_id is None, then Netbox API will return all objects by default |
| 330 | devices = list(netboxapi.dcim.devices.filter(tenant_id=tenant_id)) |
| 331 | virtual_machines = list( |
| 332 | netboxapi.virtualization.virtual_machines.filter(tenant_id=tenant_id) |
| 333 | ) |
| 334 | |
| 335 | physical_interfaces = list() |
| 336 | for device in devices: |
| 337 | physical_interfaces.extend( |
| 338 | list(netboxapi.dcim.interfaces.filter(device_id=device.id)) |
| 339 | ) |
| 340 | |
| 341 | virtual_interfaces = list() |
| 342 | for virtual_machine in virtual_machines: |
| 343 | virtual_interfaces.extend( |
| 344 | list( |
| 345 | netboxapi.virtualization.interfaces.filter( |
| 346 | virtual_machine_id=virtual_machine.id |
| 347 | ) |
| 348 | ) |
| 349 | ) |
| 350 | |
| 351 | vrfs = list(netboxapi.ipam.vrfs.filter(tenant_id=tenant_id)) |
| 352 | vlans = list(netboxapi.ipam.vlans.filter(tenant_id=tenant_id)) |
| 353 | ip_addresses = list(netboxapi.ipam.ip_addresses.filter(tenant_id=tenant_id)) |
| 354 | |
| 355 | prefixes = list() |
| 356 | for vrf in vrfs: |
| 357 | prefixes.extend(list(netboxapi.ipam.prefixes.filter(vrf_id=vrf.id))) |
| 358 | |
| 359 | return_dict = { |
| 360 | "tenants": tenants, |
| 361 | "devices": devices, |
| 362 | "physical_interfaces": physical_interfaces, |
| 363 | "virtual_machines": virtual_machines, |
| 364 | "virtual_interfaces": virtual_interfaces, |
| 365 | "vrfs": vrfs, |
| 366 | "prefixes": prefixes, |
| 367 | "vlans": vlans, |
| 368 | "ip_addresses": ip_addresses, |
| 369 | } |
| 370 | |
| 371 | return return_dict |
| 372 | |
| 373 | |
| 374 | if __name__ == "__main__": |
| 375 | |
| 376 | parser = argparse.ArgumentParser(description="Netbox Tenant Validator") |
| 377 | parser.add_argument( |
| 378 | "settings", |
| 379 | type=argparse.FileType("r"), |
| 380 | help="YAML Ansible inventory file w/NetBox API token", |
| 381 | ) |
| 382 | |
| 383 | args = parser.parse_args() |
| 384 | netbox_config = yaml.safe_load(args.settings.read()) |
| 385 | netboxapi = pynetbox.api( |
| 386 | netbox_config["api_endpoint"], token=netbox_config["token"], threading=True |
| 387 | ) |
| 388 | |
| 389 | if not netbox_config.get("validate_certs", False): |
| 390 | session = requests.Session() |
| 391 | session.verify = False |
| 392 | netboxapi.http_session = session |
| 393 | |
| 394 | mapping_func = { |
| 395 | "tenants": validate_tenants, |
| 396 | "devices": validate_machines, |
| 397 | "physical_interfaces": validate_interfaces, |
| 398 | "virtual_machines": validate_machines, |
| 399 | "virtual_interfaces": validate_interfaces, |
| 400 | "vrfs": validate_vrfs, |
| 401 | "vlans": validate_vlans, |
| 402 | "prefixes": validate_prefixes, |
| 403 | "ip_addresses": validate_ip_addresses, |
| 404 | } |
| 405 | |
| 406 | netbox_data = get_objects(netbox_config.get("tenant_name", "")) |
| 407 | |
| 408 | for key, validate_func in mapping_func.items(): |
| 409 | if netbox_data[key]: |
| 410 | validate_func(netbox_data[key]) |
| 411 | |
| 412 | if not misconfs: |
| 413 | print("All checks passed.") |
| 414 | else: |
| 415 | for misconf in misconfs: |
| 416 | print(misconf) |