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