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