Refactor nbhelper
Change-Id: I69d10d164fac3eb319e072447a520905880c31dd
diff --git a/scripts/nbhelper/__init__.py b/scripts/nbhelper/__init__.py
new file mode 100644
index 0000000..7755623
--- /dev/null
+++ b/scripts/nbhelper/__init__.py
@@ -0,0 +1,8 @@
+from .utils import initialize
+from .tenant import Tenant
+from .device import Device, VirtualMachine
+from .service import (
+ dhcpSubnetConfigGenerator,
+ dnsFowardZoneConfigGenerator,
+ dnsReverseZoneConfigGenerator,
+)
diff --git a/scripts/nbhelper/container.py b/scripts/nbhelper/container.py
new file mode 100644
index 0000000..6e0cfcf
--- /dev/null
+++ b/scripts/nbhelper/container.py
@@ -0,0 +1,291 @@
+import netaddr
+
+
+class Singleton(type):
+ _instances = {}
+
+ def __call__(cls, *args, **kwargs):
+ if cls not in cls._instances:
+ cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
+ return cls._instances[cls]
+
+
+class Container(object):
+ def __init__(self):
+ self.instances = dict()
+
+ def add(self, instance_id, instance):
+ if instance_id in self.instances:
+ raise Exception()
+ self.instances[instance_id] = instance
+
+ def get(self, instance_id):
+ if instance_id not in self.instances:
+ return instance_id
+ return self.instances[instance_id]
+
+
+class AssignedObjectContainer(Container):
+ # AssignedObjectContainer is the parent class
+ # which is with the shared function of Devices/VMs Container
+
+ def all(self):
+ return self.instances.values()
+
+ def getDNSServer(self):
+ for instance in self.instances.values():
+ if "dns" in list(map(str, instance.services)):
+ return instance
+
+ def getDHCPServer(self):
+ for instance in self.instances.values():
+ if "tftp" in list(map(str, instance.services)):
+ return instance
+
+ def getNTPServer(self):
+ for instance in self.instances.values():
+ if "ntp" in list(map(str, instance.services)):
+ return instance
+
+ def getRouters(self):
+ """ Get a list of Devices/VMs which type is Router """
+
+ ret = list()
+ for instance in self.instances.values():
+ if instance.role == "router":
+ ret.append(instance)
+
+ return ret
+
+ def getRouterIPforPrefix(self, prefix):
+ """ Get the first found IP address of exist routers as string """
+
+ for router in self.getRouters():
+ for interface in router.interfaces.values():
+
+ # The mgmt-only interface will not act as gateway
+ if interface["mgmtOnly"]:
+ continue
+
+ for address in interface["addresses"]:
+ if netaddr.IPNetwork(address).ip in netaddr.IPNetwork(
+ prefix.subnet
+ ):
+ return str(netaddr.IPNetwork(address).ip)
+
+
+class DeviceContainer(AssignedObjectContainer, metaclass=Singleton):
+ # DeviceContainer holds all devices fetch from Netbox, device_id as key
+ pass
+
+
+class VirtualMachineContainer(AssignedObjectContainer, metaclass=Singleton):
+ # DeviceContainer holds all devices fetch from Netbox, vm_id as key
+ pass
+
+
+class PrefixContainer(Container, metaclass=Singleton):
+ # PrefixContainer holds all prefixes fetch from Netbox, prefix(str) as key
+
+ def get(self, instance_id, name_segments=1):
+ return super().get(instance_id)
+
+ def all(self):
+ return self.instances.values()
+
+ def all_reserved_ips(self, ip_addr=""):
+ ret = list()
+
+ for prefix in self.instances.values():
+ if ip_addr and netaddr.IPNetwork(ip_addr).ip in netaddr.IPNetwork(
+ prefix.subnet
+ ):
+ if prefix.reserved_ips:
+ return list(prefix.reserved_ips.values())
+ else:
+ if prefix.reserved_ips:
+ ret.extend(list(prefix.reserved_ips.values()))
+
+ return ret
+
+
+class ServiceInfoContainer(Container, metaclass=Singleton):
+ # ServiceInfoContainer holds hosts DNS/DHCP information in Tenant networks
+
+ def __init__(self):
+ super().__init__()
+ self.initialized = False
+
+ def all(self):
+ return self.instances.items()
+
+ def initialize(self):
+ if self.initialized:
+ return
+
+ deviceContainer = DeviceContainer()
+ vmContainer = VirtualMachineContainer()
+ prefixContainer = PrefixContainer()
+
+ for prefix in prefixContainer.all():
+
+ subnet = netaddr.IPNetwork(prefix.subnet)
+ domain = prefix.data.description or ""
+
+ if not domain:
+ continue
+
+ # If prefix has set the Router IP, use this value as defualt,
+ # otherwise find router in this subnet and get its IP address
+ routes = (
+ prefix.routes
+ or deviceContainer.getRouterIPforPrefix(prefix)
+ or vmContainer.getRouterIPforPrefix(prefix)
+ or ""
+ )
+
+ self.instances[prefix.data.description] = {
+ "domain": domain,
+ "subnet": prefix.subnet,
+ "router": routes,
+ "dhcprange": prefix.dhcp_range or "",
+ "hosts": dict(),
+ "dnsServer": None,
+ "ntpServer": None,
+ "dhcpServer": None,
+ }
+
+ # Find the service IP address for this network
+ serviceMap = {
+ "dnsServer": deviceContainer.getDNSServer()
+ or vmContainer.getDNSServer(),
+ "ntpServer": deviceContainer.getNTPServer()
+ or vmContainer.getNTPServer(),
+ "dhcpServer": deviceContainer.getDHCPServer()
+ or vmContainer.getDHCPServer(),
+ }
+
+ # Loop through the device's IP, and set IP to dataset
+ for service, device in serviceMap.items():
+ for interface in device.interfaces.values():
+ for address in interface["addresses"]:
+ address = netaddr.IPNetwork(address).ip
+ if address in subnet:
+ self.instances[domain][service] = {
+ "name": device.name,
+ "address": str(address),
+ }
+ else:
+ for neighbor in prefix.neighbor:
+ neighborSubnet = netaddr.IPNetwork(neighbor.subnet)
+ if address in neighborSubnet:
+ self.instances[domain][service] = {
+ "name": device.name,
+ "address": str(address),
+ }
+ break
+
+ # A dict to check if the device exists in this domain (prefix)
+ deviceInDomain = dict()
+
+ # Gather all Devices/VMs in this tenant, build IP/Hostname map
+ for device in list(deviceContainer.all()) + list(vmContainer.all()):
+
+ # Iterate the interface owned by device
+ for intfName, interface in device.interfaces.items():
+
+ # Iterate the address with each interface
+ for address in interface["addresses"]:
+
+ # Extract the IP address from interface's IP (w/o netmask)
+ address = netaddr.IPNetwork(address).ip
+
+ # Only record the IP address in current subnet,
+ # Skip if the mac_address is blank
+ if address in subnet:
+
+ # deviceInDomain store the interface information like:
+ # {"mgmtserver1": {
+ # "non-mgmt-counter": 1,
+ # "interface": {
+ # "bmc": {
+ # "mgmtOnly": True,
+ # "macaddr": "ca:fe:ba:be:00:00",
+ # "ipaddr": [IPAddress("10.32.4.1")]
+ # }
+ # "eno1": {
+ # "mgmtOnly": False,
+ # "macaddr": "ca:fe:ba:be:11:11",
+ # "ipaddr": [IPAddress("10.32.4.129"), IPAddress("10.32.4.130")]
+ # }
+ # }
+ # "mgmtswitch1": ...
+ # }
+
+ deviceInDomain.setdefault(device.name, dict())
+ deviceInDomain[device.name].setdefault(
+ "non-mgmt-counter", 0
+ )
+ deviceInDomain[device.name].setdefault("interfaces", dict())
+
+ # Set up a interface structure in deviceInDomain[device.name]
+ deviceInDomain[device.name]["interfaces"].setdefault(
+ intfName, dict()
+ )
+ interfaceDict = deviceInDomain[device.name]["interfaces"][
+ intfName
+ ]
+ interfaceDict.setdefault("mgmtOnly", False)
+
+ # Use interface["mac_address"] as the default value, but if the mac_address
+ # is None, that means we are dealing with a virtual interfaces
+ # so we can get the linked interface's mac_address instead
+
+ interfaceDict.setdefault(
+ "mac_address", interface["mac_address"] or
+ device.interfaces[interface["instance"].label]["mac_address"]
+ )
+ interfaceDict.setdefault("ip_addresses", list())
+ interfaceDict["ip_addresses"].append(address)
+
+ # If the interface is mgmtOnly, set the attribute to True
+ # Otherwise, increase the non-mgmt-counter, the counter uses to
+ # find out how many interfaces of this device has IPs on subnet
+ if interface["mgmtOnly"]:
+ interfaceDict["mgmtOnly"] = True
+ else:
+ deviceInDomain[device.name]["non-mgmt-counter"] += 1
+
+ for deviceName, data in deviceInDomain.items():
+
+ nonMgmtCounter = data["non-mgmt-counter"]
+ for intfName, interface in data["interfaces"].items():
+
+ # If current interface doesn't have mac address set, skip
+ if not interface["mac_address"]:
+ continue
+
+ # In the default situation, hostname is deviceName
+ hostname_list = [deviceName]
+
+ # If the condition is -
+ # 1. multiple interfaces show on this subnet
+ # 2. the interface is a management-only interface
+ # then add interface name into hostname
+ if nonMgmtCounter > 1 or interface["mgmtOnly"]:
+ hostname_list.append(intfName)
+
+ # Iterate the IP address owns by current interface,
+ # if the interface has multiple IP addresses,
+ # add last digit to hostname for identifiability
+ for address in interface["ip_addresses"]:
+ hostname = hostname_list.copy()
+ if len(interface["ip_addresses"]) > 1:
+ hostname.append(str(address.words[-1]))
+
+ self.instances[domain]["hosts"][str(address)] = {
+ "hostname": "-".join(hostname),
+ "macaddr": interface["mac_address"].lower(),
+ }
+
+ self.initialized = True
diff --git a/scripts/nbhelper/device.py b/scripts/nbhelper/device.py
new file mode 100644
index 0000000..a88740b
--- /dev/null
+++ b/scripts/nbhelper/device.py
@@ -0,0 +1,441 @@
+#!/usr/bin/env python3
+
+# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+# device.py
+#
+
+import netaddr
+
+from .utils import logger, clean_name_dns
+from .network import Prefix
+from .container import DeviceContainer, VirtualMachineContainer, PrefixContainer
+
+
+class AssignedObject:
+ """
+ Assigned Object is either a Device or Virtual Machine, which function
+ nearly identically in the NetBox data model.
+
+ This parent class holds common functions for those two child classes
+
+ An assignedObject (device or VM) should have following attributes:
+ - self.data: contains the original copy of data from NetBox
+ - self.id: Device ID or VM ID
+ - self.interfaces: A dictionary contains interfaces belong to this AO
+ the interface dictionary looks like:
+
+ {
+ "eno1": {
+ "address": ["192.168.0.1/24", "192.168.0.2/24"],
+ "instance": <interface_instance>,
+ "isPrimary": True,
+ "mgmtOnly": False,
+ "isVirtual": False
+ }
+ }
+ """
+
+ objects = dict()
+
+ def __init__(self, data):
+ from .utils import netboxapi, netbox_config
+
+ self.data = data
+ self.nbapi = netboxapi
+
+ # The AssignedObject attributes
+ self.id = self.data.id
+ self.tenant = None
+ self.primary_ip = None
+
+ # In Netbox, we use FQDN as the Device name, but in the script,
+ # we use the first segment to be the name of device.
+ # For example, if the device named "mgmtserver1.stage1.menlo" on Netbox,
+ # then we will have "mgmtserver1" as name.
+ self.fullname = self.data.name
+ self.name = self.fullname.split(".")[0]
+
+ # The device role which can be ["server", "router", "switch", ...]
+ self.role = None
+
+ # The NetBox objects related with this AssignedObject
+ self.interfaces = dict()
+ self.services = None
+
+ # Generated configuration for ansible playbooks
+ self.netplan_config = dict()
+ self.extra_config = dict()
+
+ if self.__class__ == Device:
+ self.role = self.data.device_role.slug
+ self.services = self.nbapi.ipam.services.filter(device_id=self.id)
+ interfaces = self.nbapi.dcim.interfaces.filter(device_id=self.id)
+ ip_addresses = self.nbapi.ipam.ip_addresses.filter(device_id=self.id)
+ elif self.__class__ == VirtualMachine:
+ self.role = self.data.role.slug
+ self.services = self.nbapi.ipam.services.filter(virtual_machine_id=self.id)
+ interfaces = self.nbapi.virtualization.interfaces.filter(
+ virtual_machine_id=self.id
+ )
+ ip_addresses = self.nbapi.ipam.ip_addresses.filter(
+ virtual_machine_id=self.id
+ )
+
+ self.primary_ip = self.data.primary_ip
+
+ for interface in interfaces:
+ # The Device's interface structure is different from VM's interface
+ # VM interface doesn't have mgmt_only and type, Therefore,
+ # the default value of mgmtOnly is False, isVirtual is True
+
+ self.interfaces[interface.name] = {
+ "addresses": list(),
+ "mac_address": interface.mac_address,
+ "instance": interface,
+ "isPrimary": False,
+ "mgmtOnly": getattr(interface, "mgmt_only", False),
+ "isVirtual": interface.type.value == "virtual"
+ if hasattr(interface, "type")
+ else True,
+ }
+
+ for address in ip_addresses:
+ interface = self.interfaces[address.assigned_object.name]
+ interface["addresses"].append(address.address)
+
+ # ipam.ip_addresses doesn't have primary tag,
+ # the primary tag is only available is only in the Device.
+ # So we need to compare address to check which one is primary ip
+ if address.address == self.primary_ip.address:
+ interface["isPrimary"] = True
+
+ # mgmt_only = False is a hack for VirtualMachine type
+ if self.__class__ == VirtualMachine:
+ interface["instance"].mgmt_only = False
+
+ def __repr__(self):
+ return str(dict(self.data))
+
+ @property
+ def type(self):
+ return "AssignedObject"
+
+ @property
+ def internal_interfaces(self):
+ """
+ The function internal_interfaces
+ """
+
+ ret = dict()
+ for intfName, interface in self.interfaces.items():
+ if (
+ not interface["isPrimary"]
+ and not interface["mgmtOnly"]
+ and interface["addresses"]
+ ):
+ ret[intfName] = interface
+
+ return ret
+
+ def generate_netplan(self):
+ """
+ Get the interface config of specific server belongs to this tenant
+ """
+
+ if self.netplan_config:
+ return self.netplan_config
+
+ primary_if = None
+ for interface in self.interfaces.values():
+ if interface["isPrimary"] is True:
+ primary_if = interface["instance"]
+
+ if primary_if is None:
+ logger.error("The primary interface wasn't set for device %s", self.name)
+ return dict()
+
+ # Initialize the part of "ethernets" configuration
+ self.netplan_config["ethernets"] = dict()
+
+ # If the current selected device is a Router
+ if (isinstance(self, Device) and self.data.device_role.name == "Router") or (
+ isinstance(self, VirtualMachine) and self.data.role.name == "Router"
+ ):
+ for intfName, interface in self.interfaces.items():
+ if interface["mgmtOnly"] or interface["isVirtual"]:
+ continue
+
+ # Check if this address is public IP address (e.g. "8.8.8.8" on eth0)
+ isExternalAddress = True
+ for prefix in PrefixContainer().all():
+ for address in interface["addresses"]:
+ if address in netaddr.IPSet([prefix.subnet]):
+ isExternalAddress = False
+
+ # If this interface has the public IP address, netplan shouldn't include it
+ if isExternalAddress:
+ continue
+
+ self.netplan_config["ethernets"].setdefault(intfName, {})
+ self.netplan_config["ethernets"][intfName].setdefault(
+ "addresses", []
+ ).append(address)
+
+ # If the current selected device is a Server
+ elif isinstance(self, Device) and self.data.device_role.name == "Server":
+ if primary_if:
+ self.netplan_config["ethernets"][primary_if.name] = {
+ "dhcp4": "yes",
+ "dhcp4-overrides": {"route-metric": 100},
+ }
+
+ for intfName, interface in self.interfaces.items():
+ if (
+ not interface["isVirtual"]
+ and intfName != primary_if.name
+ and not interface["mgmtOnly"]
+ and interface["addresses"]
+ ):
+ self.netplan_config["ethernets"][intfName] = {
+ "dhcp4": "yes",
+ "dhcp4-overrides": {"route-metric": 200},
+ }
+
+ else:
+ # Exclude the device type which is not Router and Server
+ return None
+
+ # Get interfaces own by AssignedObject and is virtual (VLAN interface)
+ for intfName, interface in self.interfaces.items():
+
+ # If the interface is not a virtual interface or
+ # the interface doesn't have VLAN tagged, skip this interface
+ if not interface["isVirtual"] or not interface["instance"].tagged_vlans:
+ continue
+
+ if "vlans" not in self.netplan_config:
+ self.netplan_config["vlans"] = dict()
+
+ vlan_object_id = interface["instance"].tagged_vlans[0].id
+ vlan_object = self.nbapi.ipam.vlans.get(vlan_object_id)
+
+ routes = list()
+ for address in interface["addresses"]:
+
+ for reserved_ip in PrefixContainer().all_reserved_ips(address):
+
+ destination = reserved_ip["custom_fields"].get("rfc3442routes", "")
+ if not destination:
+ continue
+
+ # If interface address is in destination subnet, we don't need this route
+ if netaddr.IPNetwork(address).ip in netaddr.IPNetwork(destination):
+ continue
+
+ for dest_addr in destination.split():
+ new_route = {
+ "to": dest_addr,
+ "via": str(netaddr.IPNetwork(reserved_ip["ip4"]).ip),
+ "metric": 100,
+ }
+
+ if new_route not in routes:
+ routes.append(new_route)
+
+ self.netplan_config["vlans"][intfName] = {
+ "id": vlan_object.vid,
+ "link": interface["instance"].label,
+ "addresses": interface["addresses"],
+ }
+
+ # Only the fabric virtual interface will need to route to other network segments
+ if routes and "fab" in intfName:
+ self.netplan_config["vlans"][intfName]["routes"] = routes
+
+ return self.netplan_config
+
+ def generate_nftables(self):
+
+ ret = dict()
+
+ internal_if = None
+ external_if = None
+
+ # Use isPrimary == True as the identifier to select external interface
+ for interface in self.interfaces.values():
+ if interface["isPrimary"] is True:
+ external_if = interface["instance"]
+
+ if external_if is None:
+ logger.error("The primary interface wasn't set for device %s", self.name)
+ sys.exit(1)
+
+ for interface in self.interfaces.values():
+ # If "isVirtual" set to False and "mgmtOnly" set to False
+ if (
+ not interface["isVirtual"]
+ and not interface["mgmtOnly"]
+ and interface["instance"] is not external_if
+ ):
+ internal_if = interface["instance"]
+ break
+
+ ret["external_if"] = external_if.name
+ ret["internal_if"] = internal_if.name
+
+ if self.services:
+ ret["services"] = list()
+
+ for service in self.services:
+ ret["services"].append(
+ {
+ "name": service.name,
+ "protocol": service.protocol.value,
+ "port": service.port,
+ }
+ )
+
+ # Only management server needs to be configured the whitelist netrange of internal interface
+ if self.data.device_role.name == "Router":
+
+ ret["interface_subnets"] = dict()
+ ret["ue_routing"] = dict()
+ ret["ue_routing"]["ue_subnets"] = self.data.config_context["ue_subnets"]
+
+ # Create the interface_subnets in the configuration
+ # It's using the interface as the key to list IP addresses
+ for intfName, interface in self.interfaces.items():
+ if interface["mgmtOnly"]:
+ continue
+
+ for address in interface["addresses"]:
+ for prefix in PrefixContainer().all():
+ intfAddr = netaddr.IPNetwork(address).ip
+
+ # If interface IP doesn't belong to this prefix, skip
+ if intfAddr not in netaddr.IPNetwork(prefix.subnet):
+ continue
+
+ # If prefix is a parent prefix (parent prefix won't config domain name)
+ # skip to add in interface_subnets
+ if not prefix.data.description:
+ continue
+
+ ret["interface_subnets"].setdefault(intfName, list())
+
+ if prefix.subnet not in ret["interface_subnets"][intfName]:
+ ret["interface_subnets"][intfName].append(prefix.subnet)
+ for neighbor in prefix.neighbor:
+ if neighbor.subnet not in ret["interface_subnets"][intfName]:
+ ret["interface_subnets"][intfName].append(neighbor.subnet)
+
+ for prefix in PrefixContainer().all():
+
+ if "fab" in prefix.data.description:
+ ret["ue_routing"].setdefault("src_subnets", [])
+ ret["ue_routing"]["src_subnets"].append(prefix.data.prefix)
+
+ if (
+ not ret["ue_routing"].get("snat_addr")
+ and "fab" in prefix.data.description
+ ):
+ for interface in self.interfaces.values():
+ for address in interface["addresses"]:
+ if address in netaddr.IPSet([prefix.subnet]):
+ ret["ue_routing"]["snat_addr"] = str(
+ netaddr.IPNetwork(address).ip
+ )
+ break
+
+ return ret
+
+ def generate_extra_config(self):
+ """
+ Generate the extra configs which need in management server configuration
+ This function should only be called when the device role is "Router"
+ """
+
+ if self.extra_config:
+ return self.extra_config
+
+ primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
+
+ service_names = list(map(lambda x: x.name, self.services))
+
+ if "dns" in service_names:
+ unbound_listen_ips = []
+ unbound_allow_ips = []
+
+ for interface in self.interfaces.values():
+ if not interface["isPrimary"] and not interface["mgmtOnly"]:
+ for address in interface["addresses"]:
+ unbound_listen_ips.append(address)
+
+ for prefix in PrefixContainer().all():
+ if prefix.data.description:
+ unbound_allow_ips.append(prefix.data.prefix)
+
+ if unbound_listen_ips:
+ self.extra_config["unbound_listen_ips"] = unbound_listen_ips
+
+ if unbound_allow_ips:
+ self.extra_config["unbound_allow_ips"] = unbound_allow_ips
+
+ if "ntp" in service_names:
+ ntp_client_allow = []
+
+ for prefix in PrefixContainer().all():
+ if prefix.data.description:
+ ntp_client_allow.append(prefix.data.prefix)
+
+ if ntp_client_allow:
+ self.extra_config["ntp_client_allow"] = ntp_client_allow
+
+ return self.extra_config
+
+
+class Device(AssignedObject):
+ """
+ Wraps a single Netbox device
+ Also caches all known devices in a class variable (devs)
+ """
+
+ def __init__(self, data):
+
+ super().__init__(data)
+ DeviceContainer().add(self.id, self)
+
+ @property
+ def type(self):
+ return "Device"
+
+ def get_interfaces(self):
+ if not self.interfaces:
+ self.interfaces = self.nbapi.dcim.interfaces.filter(device_id=self.id)
+
+ return self.interfaces
+
+
+class VirtualMachine(AssignedObject):
+ """
+ VM equivalent of Device
+ """
+
+ def __init__(self, data):
+
+ super().__init__(data)
+ VirtualMachineContainer().add(self.id, self)
+
+ @property
+ def type(self):
+ return "VirtualMachine"
+
+ def get_interfaces(self):
+ if not self.interfaces:
+ self.interfaces = self.nbapi.virtualization.interfaces.filter(
+ virtual_machine_id=self.id
+ )
+
+ return self.interfaces
diff --git a/scripts/nbhelper/network.py b/scripts/nbhelper/network.py
new file mode 100644
index 0000000..5520d48
--- /dev/null
+++ b/scripts/nbhelper/network.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+
+# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+# nbhelper.py
+# Helper functions for building YAML output from Netbox API calls
+
+import netaddr
+
+from .utils import logger, check_name_dns
+from .container import PrefixContainer
+from .container import DeviceContainer, VirtualMachineContainer
+
+
+class Prefix:
+ def __init__(self, data, name_segments):
+ from .utils import netboxapi
+
+ self.data = data
+ self.name_segments = name_segments
+ self.nbapi = netboxapi
+
+ if self.data.description:
+ self.domain_extension = check_name_dns(self.data.description)
+
+ # ip centric info
+ self.subnet = self.data.prefix
+ self.dhcp_range = list()
+ self.routes = list()
+ self.reserved_ips = {}
+
+ self.neighbor = list()
+
+ # build item lists
+ self.build_prefix()
+
+ # Find the neighbor relationship in prefix level
+ self.find_neighbor()
+
+ PrefixContainer().add(self.data.prefix, self)
+
+ def __repr__(self):
+ return str(self.data.prefix)
+
+ def check_ip_belonging(self, ip):
+ """
+ Check if an IP address is belonging to this prefix
+ """
+ return ip in netaddr.IPSet([self.data.prefix])
+
+ def find_neighbor(self):
+
+ parent_prefixes = list()
+ for prefix in PrefixContainer().all():
+ if not prefix.data.description:
+ parent_prefixes.append(prefix)
+
+ for prefix in PrefixContainer().all():
+ if not prefix.data.description:
+ continue
+
+ targetSubnet = netaddr.IPNetwork(prefix.subnet)
+ mySubnet = netaddr.IPNetwork(self.subnet)
+
+ for parent in parent_prefixes:
+ parentSubnet = netaddr.IPNetwork(parent.subnet)
+ if mySubnet in parentSubnet and targetSubnet in parentSubnet:
+ if prefix not in self.neighbor:
+ self.neighbor.append(prefix)
+ if self not in prefix.neighbor:
+ prefix.neighbor.append(self)
+
+
+ def build_prefix(self):
+ """
+ find ip information for items (devices/vms, reserved_ips, dhcp_range) in prefix
+ """
+ ips = self.nbapi.ipam.ip_addresses.filter(parent=self.data.prefix)
+
+ for ip in sorted(ips, key=lambda k: k["address"]):
+
+ logger.debug("prefix_item ip: %s, data: %s", ip, dict(ip))
+
+ # if it's a DHCP range, add that range to the dev list as prefix_dhcp
+ if ip.status.value == "dhcp":
+ self.dhcp_range.append(str(ip.address))
+
+ # reserved IPs
+ if ip.status.value == "reserved":
+ self.reserved_ips[str(ip)] = {
+ "name": ip.description.lower().split(" ")[0],
+ "description": ip.description,
+ "ip4": str(ip.address),
+ "custom_fields": ip.custom_fields,
+ }
+ self.routes.append(
+ {
+ "ip": str(ip.address),
+ "rfc3442routes": [ip.custom_fields.get("rfc3442routes")],
+ }
+ )
diff --git a/scripts/nbhelper/service.py b/scripts/nbhelper/service.py
new file mode 100644
index 0000000..d84f0cb
--- /dev/null
+++ b/scripts/nbhelper/service.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+
+# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+# service.py
+#
+
+import re
+import netaddr
+
+from .utils import logger, AttrDict
+from .container import ServiceInfoContainer
+
+
+def getIPaddress(addressWithMask):
+ return str(netaddr.IPNetwork(addressWithMask).ip)
+
+
+def dhcpSubnetConfigGenerator():
+ dhcpSubnetConfigs = list()
+
+ serviceInfoContainer = ServiceInfoContainer()
+ serviceInfoContainer.initialize()
+
+ for domainName, domain in serviceInfoContainer.all():
+ subnetConfig = {
+ "dns_search": [domainName],
+ "dns_servers": [domain["dnsServer"]["address"]],
+ "ntp_servers": [domain["ntpServer"]["address"]],
+ "tftp_servers": domain["dhcpServer"]["address"],
+ "range": domain["dhcprange"],
+ "subnet": domain["subnet"],
+ "routers": [{"ip": domain["router"]}],
+ "hosts": list(),
+ }
+
+ for address, host in domain["hosts"].items():
+ subnetConfig["hosts"].append(
+ {
+ "ip_addr": getIPaddress(address),
+ "mac_addr": host["macaddr"],
+ "name": host["hostname"],
+ }
+ )
+
+ subnetConfig["hosts"] = sorted(
+ subnetConfig["hosts"], key=lambda x: int(x["ip_addr"].split(".")[-1])
+ )
+ dhcpSubnetConfigs.append(subnetConfig)
+
+ return dhcpSubnetConfigs
+
+
+def dnsFowardZoneConfigGenerator():
+ def getDomainNameByIP(ip_address):
+ """
+ getDomainNameByIP will return the corresponding domain name of an IP address
+ In the ntpServer, dhcpServer, dnsServer we only have the IP addresses of them,
+ But we can use the dhcp subnet configuration to find the FQDN
+ """
+ dhcpSubnetConfigs = dhcpSubnetConfigGenerator()
+
+ for domain in dhcpSubnetConfigs:
+ domainName = domain["dns_search"][0]
+ for host in domain["hosts"]:
+ if ip_address == host["ip_addr"]:
+ return f"{host['name']}.{domainName}."
+
+
+ dnsForwardZoneConfigs = dict()
+
+ serviceInfoContainer = ServiceInfoContainer()
+ serviceInfoContainer.initialize()
+
+ for domainName, domain in serviceInfoContainer.all():
+ forwardZoneConfig = {
+ "cname": dict(),
+ "a": dict(),
+ "ns": list(),
+ "srv": dict(),
+ "txt": dict(),
+ }
+
+ # Get the services set to this Tenant network
+ ntpServer = domain["ntpServer"] or None
+ dhcpServer = domain["dhcpServer"] or None
+ dnsServer = domain["dnsServer"] or None
+
+ # If service exists, set the FQDN to CNAME records
+ if ntpServer:
+ forwardZoneConfig["cname"]["dns"] = getDomainNameByIP(ntpServer["address"])
+ if dhcpServer:
+ forwardZoneConfig["cname"]["tftp"] = getDomainNameByIP(dhcpServer["address"])
+ if dnsServer:
+ forwardZoneConfig["cname"]["dns"] = getDomainNameByIP(dnsServer["address"])
+ forwardZoneConfig["ns"].append(getDomainNameByIP(dnsServer["address"]))
+
+ for address, host in domain["hosts"].items():
+ # Add exist IP address into dnsReverseZoneConfigs,
+ hostname = host["hostname"]
+ forwardZoneConfig["a"][hostname] = address
+
+ for address in netaddr.IPSet(domain["dhcprange"]):
+ # If address exists in ServiceInfoContainer's host dictionary,
+ # Use the pre-generated hostname as A record's name
+ hostname = "dhcp%03d" % address.words[-1]
+ forwardZoneConfig["a"][hostname] = str(address)
+
+ dnsForwardZoneConfigs[domainName] = forwardZoneConfig
+
+ return dnsForwardZoneConfigs
+
+
+def dnsReverseZoneConfigGenerator():
+ def canonicalize_rfc1918_prefix(prefix):
+ """
+ RFC1918 prefixes need to be expanded to their widest canonical range to
+ group all reverse lookup domains together for reverse DNS with NSD/Unbound.
+ """
+
+ pnet = netaddr.IPNetwork(prefix)
+ (o1, o2, o3, o4) = pnet.network.words # Split ipv4 octets
+ cidr_plen = pnet.prefixlen
+
+ if o1 == 10:
+ o2 = o3 = o4 = 0
+ cidr_plen = 8
+ elif (o1 == 172 and o2 >= 16 and o2 <= 31) or (o1 == 192 and o2 == 168):
+ o3 = o4 = 0
+ cidr_plen = 16
+
+ return "%s/%d" % (".".join(map(str, [o1, o2, o3, o4])), cidr_plen)
+
+ serviceInfoContainer = ServiceInfoContainer()
+ serviceInfoContainer.initialize()
+
+ # dnsReverseZoneConfigs contains all reverse zone records.
+ dnsReverseZoneConfigs = dict()
+ widedomain = None
+
+ for domainName, domain in serviceInfoContainer.all():
+
+ # Expand network range to group all tenant domains
+ widedomain = widedomain or canonicalize_rfc1918_prefix(domain["subnet"])
+
+ # Create the basic structure of reverse zone config
+ # {"10.0.0.0/8": {"ns": list(), "ptr": dict()}}
+ dnsReverseZoneConfigs.setdefault(widedomain, dict())
+ dnsReverseZoneConfigs[widedomain].setdefault("ns", list())
+ dnsReverseZoneConfigs[widedomain].setdefault("ptr", dict())
+
+ # Get the DNS services set to this Tenant network
+ dnsServer = domain["dnsServer"]["name"] if domain["dnsServer"] else None
+
+ # If service exists, set the FQDN to CNAME records
+ if dnsServer:
+ dnsReverseZoneConfigs[widedomain]["ns"].append(f"{domainName}.{dnsServer}.")
+
+ for address, host in domain["hosts"].items():
+ # Add exist IP address into dnsReverseZoneConfigs,
+ hostname = host["hostname"]
+ dnsReverseZoneConfigs[widedomain]["ptr"][
+ address
+ ] = f"{hostname}.{domainName}."
+
+ for address in netaddr.IPSet(domain["dhcprange"]):
+ # Add DHCP range IP address into dnsReverseZoneConfigs,
+ # Use the pre-generated hostname as A record's name
+ hostname = "dhcp%03d" % address.words[3]
+ dnsReverseZoneConfigs[widedomain]["ptr"][
+ str(address)
+ ] = f"{hostname}.{domainName}."
+
+ dnsReverseZoneConfigs[widedomain]["ptr"] = dict(
+ sorted(
+ dnsReverseZoneConfigs[widedomain]["ptr"].items(),
+ key=lambda x: (int(x[0].split(".")[-2]), int(x[0].split(".")[-1])),
+ )
+ )
+
+ return dnsReverseZoneConfigs
diff --git a/scripts/nbhelper/tenant.py b/scripts/nbhelper/tenant.py
new file mode 100644
index 0000000..4d529a7
--- /dev/null
+++ b/scripts/nbhelper/tenant.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+
+# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+# tenant.py
+# The tenant abstract object of Netbox Object - Tenant
+
+from .utils import logger, netboxapi
+from .device import Device, VirtualMachine
+from .network import Prefix
+
+
+class Tenant:
+ def __init__(self):
+
+ from .utils import netboxapi, netbox_config
+
+ self.nbapi = netboxapi
+ self.name = netbox_config["tenant_name"]
+ self.name_segments = netbox_config.get("prefix_segments", 1)
+
+ self.tenant = self.nbapi.tenancy.tenants.get(name=self.name)
+
+ # Tenant only keep the resources which owns by it
+ self.devices = list()
+ self.vms = list()
+ self.prefixes = dict()
+
+ # Get the Device and Virtual Machines from Netbox API
+ for device_data in self.nbapi.dcim.devices.filter(tenant=self.tenant.slug):
+ device = Device(device_data)
+ device.tenant = self
+ self.devices.append(device)
+
+ for vm_data in self.nbapi.virtualization.virtual_machines.filter(
+ tenant=self.tenant.slug
+ ):
+ vm = VirtualMachine(vm_data)
+ vm.tenant = self
+ self.vms.append(vm)
+
+ vrf = self.nbapi.ipam.vrfs.get(tenant=self.tenant.slug)
+ for prefix_data in self.nbapi.ipam.prefixes.filter(vrf_id=vrf.id):
+ prefix = Prefix(prefix_data, self.name_segments)
+ if prefix_data.description:
+ self.prefixes[prefix_data.prefix] = prefix
+
+ def get_prefixes(self):
+ """Get the IP Prefixes owns by current tenant"""
+
+ return self.prefixes
+
+ def get_device_by_name(self, name):
+ """
+ Find the device or VM which belongs to this Tenant,
+ If the name wasn't specified, return the management server
+ """
+
+ for machine in self.devices + self.vms:
+ if name and machine.name == name:
+ return machine
+ elif machine.data["device_role"]["name"] == "Router":
+ return machine
+
+ ret_msg = (
+ "The name '%s' wasn't found in this tenant, "
+ + "or can't found any Router in this tenant"
+ )
+
+ logger.error(ret_msg, name)
+ sys.exit(1)
+
+ def get_devices(self, device_types=["server", "router"]):
+ """
+ Get all devices (Router + Server) belong to this Tenant
+ """
+
+ if not device_types:
+ return self.devices + self.vms
+
+ ret = []
+
+ for machine in self.devices:
+ if machine.data.device_role.slug in device_types:
+ ret.append(machine)
+
+ for vm in self.vms:
+ if vm.data.role.slug in device_types:
+ ret.append(vm)
+
+ return ret
diff --git a/scripts/nbhelper/utils.py b/scripts/nbhelper/utils.py
new file mode 100644
index 0000000..5614ba9
--- /dev/null
+++ b/scripts/nbhelper/utils.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+
+# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+# utils.py
+# The utility functions shared among nbhelper objects
+
+import re
+import logging
+import argparse
+import pynetbox
+import requests
+
+from ruamel import yaml
+
+# Initialize module-level variables
+netboxapi = None
+netbox_config = None
+netbox_version = None
+
+# create shared logger
+logging.basicConfig()
+logger = logging.getLogger("nbh")
+
+
+def initialize(extra_args):
+ """
+ Initialize the NBHelper module, the extra_args is required, it contains the
+ NetBox API url, API token, and the name of site
+ """
+ global netboxapi, netbox_config, netbox_version
+
+ args = parse_cli_args(extra_args)
+ netbox_config = yaml.safe_load(args.settings.read())
+
+ for require_args in ["api_endpoint", "token", "tenant_name"]:
+ if not netbox_config.get(require_args):
+ logger.error("The require argument: %s was not set. Stop." % require_args)
+ sys.exit(1)
+
+ netboxapi = pynetbox.api(
+ netbox_config["api_endpoint"], token=netbox_config["token"], threading=True,
+ )
+
+ if not netbox_config.get("validate_certs", True):
+ session = requests.Session()
+ session.verify = False
+ netboxapi.http_session = session
+
+ netbox_version = netboxapi.version
+
+ return args
+
+
+def parse_cli_args(extra_args={}):
+ """
+ parse CLI arguments. Can add extra arguments with a option:kwargs dict
+ """
+
+ parser = argparse.ArgumentParser(description="Netbox")
+
+ # Positional args
+ parser.add_argument(
+ "settings",
+ type=argparse.FileType("r"),
+ help="YAML Ansible inventory file w/NetBox API token",
+ )
+
+ parser.add_argument(
+ "--debug", action="store_true", help="Print additional debugging information"
+ )
+
+ for ename, ekwargs in extra_args.items():
+ parser.add_argument(ename, **ekwargs)
+
+ args = parser.parse_args()
+ log_level = logging.DEBUG if args.debug else logging.INFO
+ logger.setLevel(log_level)
+
+ return args
+
+
+def check_name_dns(name):
+
+ badchars = re.search("[^a-z0-9.-]", name.lower(), re.ASCII)
+
+ if badchars:
+ logger.error(
+ "DNS name '%s' has one or more invalid characters: '%s'",
+ name,
+ badchars.group(0),
+ )
+ sys.exit(1)
+
+ return name.lower()
+
+
+def clean_name_dns(name):
+ return re.sub("[^a-z0-9.-]", "-", name.lower(), 0, re.ASCII)
+
+
+def apply_as_router(output_yaml):
+ """ Add the router-specific value for output structure """
+
+ output_yaml.setdefault("netprep_router", True)
+ output_yaml.setdefault("tftpd_files", ["undionly.kpxe"])
+ output_yaml.setdefault(
+ "vhosts", [{"name": "default", "default_server": True, "autoindex": True}]
+ )
+ output_yaml.setdefault("acme_username", "www-data")
+
+ return output_yaml
+
+
+class AttrDict(dict):
+ def __init__(self, *args, **kwargs):
+ super(AttrDict, self).__init__(*args, **kwargs)
+ self.__dict__ = self