Refactor nbhelper

Change-Id: I69d10d164fac3eb319e072447a520905880c31dd
diff --git a/scripts/base_edgeconfig.yaml b/scripts/base_edgeconfig.yaml
index 3cdcbf6..b462973 100644
--- a/scripts/base_edgeconfig.yaml
+++ b/scripts/base_edgeconfig.yaml
@@ -1,19 +1,8 @@
 ---
 # this is copied into every edgeconfig
 
-netprep_router: true
 netprep_netplan_file: "02-pronto"
 
-tftpd_files:
-  - "undionly.kpxe"
-
-vhosts:
-  - name: "default"
-    default_server: true
-    autoindex: true
-
-acme_username: "www-data"  # make independent of the acme role
-
 userlist:
   - username: terraform
     comment: "ONF Terraform User"
diff --git a/scripts/edgeconfig.py b/scripts/edgeconfig.py
index fc49606..7034f6c 100644
--- a/scripts/edgeconfig.py
+++ b/scripts/edgeconfig.py
@@ -14,7 +14,7 @@
 import nbhelper
 import os
 
-from ruamel import yaml
+from ruamel.yaml import YAML
 
 # main function that calls other functions
 if __name__ == "__main__":
@@ -32,46 +32,40 @@
     }
 
     args = nbhelper.initialize(extra_args)
-    tenant = nbhelper.NBTenant()
+    tenant = nbhelper.Tenant()
 
     # use base_config for additional items
-    base_yaml = yaml.safe_load(args.base_config.read())
+    yaml = YAML(typ="rt")
+    base_yaml = yaml.load(args.base_config.read())
 
-    dhcpd_subnets = []
     dhcpd_interfaces = []
 
-    # reverse zones aggregate across RFC1918 IP prefix
-    dns_reverse_zones = nbhelper.NBDNSReverseZones()
-    for prefix in tenant.get_prefixes().values():
+    # TODO
+    # dhcpd_if = dhcpd_subnet.dhcpd_interface
+    dns_forward_zones = nbhelper.service.dnsFowardZoneConfigGenerator()
+    dns_reverse_zones = nbhelper.service.dnsReverseZoneConfigGenerator()
+    dhcpd_subnets = nbhelper.service.dhcpSubnetConfigGenerator()
 
-        nbhelper.NBDNSForwardZone.get_fwd_zone(prefix)
-        dns_reverse_zones.add_prefix(prefix)
-        dhcpd_subnet = nbhelper.NBDHCPSubnet(prefix)
-        dhcpd_if = dhcpd_subnet.dhcpd_interface
-
-        if dhcpd_if and dhcpd_if not in dhcpd_interfaces:
-            dhcpd_interfaces.append(dhcpd_if)
-
-        dhcpd_subnets.append(dhcpd_subnet)
-
-    for device in tenant.get_devices():
+    for device in tenant.get_devices(device_types=["server", "router", "switch"]):
         output_yaml = base_yaml.copy()
 
         if (
-            isinstance(device, nbhelper.NBDevice)
+            isinstance(device, nbhelper.Device)
             and device.data.device_role.slug == "router"
         ) or (
-            isinstance(device, nbhelper.NBVirtualMachine)
+            isinstance(device, nbhelper.VirtualMachine)
             and device.data.role.slug == "router"
         ):
-            output_yaml["dns_forward_zones"] = nbhelper.NBDNSForwardZone.all_fwd_zones()
+            output_yaml["dns_forward_zones"] = dns_forward_zones
             output_yaml["dns_reverse_zones"] = dns_reverse_zones
             output_yaml["dhcpd_subnets"] = dhcpd_subnets
-            output_yaml["dhcpd_interfaces"] = dhcpd_interfaces
+            output_yaml["dhcpd_interfaces"] = list(device.internal_interfaces.keys())
             output_yaml["netprep_nftables"] = device.generate_nftables()
             output_yaml.update(device.generate_extra_config())
+            output_yaml = nbhelper.utils.apply_as_router(output_yaml)
 
         output_yaml["netprep_netplan"] = device.generate_netplan()
 
-        with open("inventory/host_vars/%s.yaml" % device.name, "w") as f:
-            f.write(yaml.safe_dump(output_yaml, indent=2))
+        with open("inventory/host_vars/%s.yaml" % device.fullname, "w") as f:
+            # yaml.compact(seq_seq=False, seq_map=False)
+            yaml.dump(output_yaml, f)
diff --git a/scripts/nbhelper.py b/scripts/nbhelper.py
deleted file mode 100644
index d91108f..0000000
--- a/scripts/nbhelper.py
+++ /dev/null
@@ -1,1272 +0,0 @@
-#!/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
-
-from __future__ import absolute_import
-
-import re
-import sys
-import argparse
-import logging
-import netaddr
-import pynetbox
-import requests
-
-from ruamel import yaml
-
-# create shared logger
-logging.basicConfig()
-logger = logging.getLogger("nbh")
-
-# to dump YAML properly, using internal representers
-# see also:
-#  https://stackoverflow.com/questions/54378220/declare-data-type-to-ruamel-yaml-so-that-it-can-represen-serialize-it
-#  https://sourceforge.net/p/ruamel-yaml/code/ci/default/tree/representer.py
-
-ydump = yaml.YAML(typ="safe")
-ydump.representer.add_representer(
-    pynetbox.models.dcim.Devices, yaml.SafeRepresenter.represent_dict
-)
-ydump.representer.add_representer(
-    pynetbox.models.dcim.Interfaces, yaml.SafeRepresenter.represent_dict
-)
-ydump.representer.add_representer(
-    pynetbox.models.ipam.Prefixes, yaml.SafeRepresenter.represent_dict
-)
-ydump.representer.add_representer(
-    pynetbox.core.response.Record, yaml.SafeRepresenter.represent_dict
-)
-ydump.representer.add_representer(
-    pynetbox.models.ipam.IpAddresses, yaml.SafeRepresenter.represent_dict
-)
-ydump.representer.add_representer(
-    pynetbox.core.api.Api, yaml.SafeRepresenter.represent_none
-)
-
-netboxapi = None
-netbox_config = None
-netbox_version = None
-
-
-def initialize(extra_args):
-    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)
-
-
-class AttrDict(dict):
-    def __init__(self, *args, **kwargs):
-        super(AttrDict, self).__init__(*args, **kwargs)
-        self.__dict__ = self
-
-
-class NBTenant:
-    def __init__(self):
-        self.name = netbox_config["tenant_name"]
-        self.name_segments = netbox_config.get("prefix_segments", 1)
-        self.tenant = netboxapi.tenancy.tenants.get(name=self.name)
-
-        # NBTenant 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 netboxapi.dcim.devices.filter(tenant=self.tenant.slug):
-            device = NBDevice(device_data)
-            device.tenant = self
-            self.devices.append(device)
-
-        for vm_data in netboxapi.virtualization.virtual_machines.filter(
-            tenant=self.tenant.slug
-        ):
-            vm = NBVirtualMachine(vm_data)
-            vm.tenant = self
-            self.vms.append(vm)
-
-    def get_prefixes(self):
-        """Get the IP Prefixes owns by current tenant"""
-
-        if self.prefixes:
-            return self.prefixes
-
-        vrf = netboxapi.ipam.vrfs.get(tenant=self.tenant.slug)
-        for prefix_data in netboxapi.ipam.prefixes.filter(vrf_id=vrf.id):
-            if prefix_data.description:
-                self.prefixes[prefix_data.description] = NBPrefix(
-                    prefix_data, self.name_segments
-                )
-
-        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
-
-
-@yaml.yaml_object(ydump)
-class NBPrefix:
-
-    prefixes = {}
-
-    def __init__(self, data, name_segments):
-        self.data = data
-        self.name_segments = name_segments
-        self.domain_extension = check_name_dns(self.data.description)
-
-        logger.debug(
-            "Preix %s: domain_extension %s, data: %s",
-            self.data.prefix,
-            self.domain_extension,
-            dict(self.data),
-        )
-
-        # ip centric info
-        self.dhcp_range = None
-        self.reserved_ips = {}
-        self.aos = {}
-
-        # build item lists
-        self.build_prefix()
-        self.prefixes[self.data.prefix] = self
-
-    @classmethod
-    def all_prefixes(cls):
-        return cls.prefixes
-
-    @classmethod
-    def get_prefix(cls, prefix, name_segments=1):
-        if prefix in cls.prefixes:
-            return cls.prefixes[prefix]
-
-        data = netboxapi.ipam.prefixes.get(prefix=prefix)
-        if data:
-            return NBPrefix(data, name_segments)
-        else:
-            raise Exception("The prefix %s wasn't found in Netbox" % prefix)
-
-    def __repr__(self):
-        return str(self.data.prefix)
-
-    @classmethod
-    def to_yaml(cls, representer, node):
-        return representer.represent_dict(
-            {
-                "dhcp_range": node.dhcp_range,
-                "reserved_ips": node.reserved_ips,
-                "aos": node.aos,
-                "prefix_data": dict(node.prefix_data),
-            }
-        )
-
-    @classmethod
-    def all_reserved_by_ip(cls, ip_addr=""):
-        """
-        all_reserved_by_ip will return all reserved IP found in prefixes
-
-        We have the IP address marked as type 'Reserved' in Prefix,
-        This type of IP address is using to define a DHCP range
-        """
-
-        ret = list()
-
-        for prefix in cls.prefixes.values():
-            if ip_addr and ip_addr in prefix.aos.keys():
-                if prefix.reserved_ips:
-                    return list(prefix.reserved_ips.values())
-            else:
-                if prefix.reserved_ips:
-                    ret.extend(list(prefix.reserved_ips.values()))
-
-        return ret
-
-    def get_reserved_ips(self):
-        """
-        Get the reserved IP range (DHCP) in prefix
-
-        We have the IP address marked as type 'Reserved' in Prefix,
-        This type of IP address is using to define a DHCP range
-        """
-        if prefix.reserved_ips:
-            return list(prefix.reserved_ips.values())
-
-    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 parent(self):
-        """
-        Get the parent prefix to this prefix
-
-        FIXME: Doesn't handle multiple layers of prefixes, returns first found
-        """
-
-        # get all parents of this prefix (include self)
-        possible_parents = netboxapi.ipam.prefixes.filter(contains=self.data.prefix)
-
-        logger.debug(
-            "Prefix %s: possible parents %s", self.data.prefix, possible_parents
-        )
-
-        # filter out self, return first found
-        for pparent in possible_parents:
-            if pparent.prefix != self.data.prefix:
-                return NBPrefix.get_prefix(pparent.prefix, self.name_segments)
-
-        return None
-
-    def build_prefix(self):
-        """
-        find ip information for items (devices/vms, reserved_ips, dhcp_range) in prefix
-        """
-
-        ips = netboxapi.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 = str(ip.address)
-                continue
-
-            # reserved IPs
-            if ip.status.value == "reserved":
-
-                res = {}
-                res["name"] = ip.description.lower().split(" ")[0]
-                res["description"] = ip.description
-                res["ip4"] = str(netaddr.IPNetwork(ip.address))
-                res["custom_fields"] = ip.custom_fields
-
-                self.reserved_ips[str(ip)] = res
-                continue
-
-            # devices and VMs
-            if ip.assigned_object:  # can be null if not assigned to a device/vm
-                aotype = ip.assigned_object_type
-                if aotype == "dcim.interface":
-                    self.aos[str(ip)] = NBDevice.get_by_id(
-                        ip.assigned_object.device.id,
-                    )
-                elif aotype == "virtualization.vminterface":
-                    self.aos[str(ip)] = NBVirtualMachine.get_by_id(
-                        ip.assigned_object.virtual_machine.id,
-                    )
-                else:
-                    logger.error("IP %s has unknown device type: %s", ip, aotype)
-                    sys.exit(1)
-            else:
-                logger.warning("Unknown IP type %s, with attributes: %s", ip, dict(ip))
-
-
-@yaml.yaml_object(ydump)
-class NBAssignedObject:
-    """
-    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
-    """
-
-    objects = dict()
-
-    def __init__(self, data):
-        self.data = data
-
-        # The AssignedObject attributes
-        self.tenant = None
-        self.id = data.id
-        self.name = data.name
-        self.ips = dict()
-
-        # The NetBox objects related with this AssignedObject
-        self.services = None
-        self.interfaces = list()
-        self.mgmt_interfaces = list()
-        self.interfaces_by_ip = dict()
-
-        if self.__class__ == NBDevice:
-            self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
-            self.services = netboxapi.ipam.services.filter(device_id=self.id)
-            ip_addresses = netboxapi.ipam.ip_addresses.filter(device_id=self.id)
-        elif self.__class__ == NBVirtualMachine:
-            self.interfaces = netboxapi.virtualization.interfaces.filter(
-                virtual_machine_id=self.id
-            )
-            self.services = netboxapi.ipam.services.filter(virtual_machine_id=self.id)
-            ip_addresses = netboxapi.ipam.ip_addresses.filter(
-                virtual_machine_id=self.id
-            )
-
-        for ip in ip_addresses:
-            self.ips[ip.address] = ip
-            if ip.assigned_object and self.__class__ == NBDevice:
-                self.interfaces_by_ip[ip.address] = netboxapi.dcim.interfaces.get(
-                    ip.assigned_object_id
-                )
-            elif ip.assigned_object and self.__class__ == NBVirtualMachine:
-                self.interfaces_by_ip[
-                    ip.address
-                ] = netboxapi.virtualization.interfaces.get(ip.assigned_object_id)
-                self.interfaces_by_ip[ip.address].mgmt_only = False
-
-        logger.debug(
-            "%s id: %d, data: %s, ips: %s"
-            % (self.type, self.id, dict(self.data), self.ips)
-        )
-
-        self.netplan_config = dict()
-        self.extra_config = dict()
-
-    def __repr__(self):
-        return str(dict(self.data))
-
-    def dns_name(self, ip, prefix):
-        """
-        Returns the DNS name for the device at this IP in the prefix
-        """
-
-        def first_segment_suffix(split_name, suffixes, segments):
-            first_seg = "-".join([split_name[0], *suffixes])
-
-            if segments > 1:
-                name = ".".join([first_seg, *split_name[1:segments]])
-            else:
-                name = first_seg
-
-            return name
-
-        # clean/split the device name
-        name_split = clean_name_dns(self.data.name).split(".")
-
-        # always add interface suffix to mgmt interfaces
-        if self.interfaces_by_ip[ip].mgmt_only:
-            return first_segment_suffix(
-                name_split, [self.interfaces_by_ip[ip].name], prefix.name_segments
-            )
-
-        # find all IP's for this device in the prefix that aren't mgmt interfaces
-        prefix_ips = []
-        for s_ip in self.ips:
-            if s_ip in prefix.aos and not self.interfaces_by_ip[s_ip].mgmt_only:
-                prefix_ips.append(s_ip)
-
-        # name to use when only one IP address for device in a prefix
-        simple_name = ".".join(name_split[0 : prefix.name_segments])
-
-        # if more than one non-mgmt IP in prefix
-        if len(prefix_ips) > 1:
-
-            # use bare name if primary IP address
-            try:  # skip if no primary_ip.address
-                if ip == self.data.primary_ip.address:
-                    return simple_name
-            except AttributeError:
-                pass
-
-            # else, suffix with the interface name, and the last octet of IP address
-            return first_segment_suffix(
-                name_split,
-                [
-                    self.interfaces_by_ip[ip].name,
-                    str(netaddr.IPNetwork(ip).ip.words[3]),
-                ],
-                prefix.name_segments,
-            )
-
-        # simplest case - only one IP in prefix, return simple_name
-        return simple_name
-
-    def dns_cnames(self, ip):
-        """
-        returns a list of cnames for this object, based on IP matches
-        """
-
-        cnames = []
-
-        for service in self.services:
-
-            # if not assigned to any IP's, service is on all IPs
-            if not service.ipaddresses:
-                cnames.append(service.name)
-                continue
-
-            # If assigned to an IP, only create a CNAME on that IP
-            for service_ip in service.ipaddresses:
-                if ip == service_ip.address:
-                    cnames.append(service.name)
-
-        return cnames
-
-    def has_service(self, cidr_ip, port, protocol):
-        """
-        Return True if this AO has a service using specific port and protocol combination
-        """
-
-        if (
-            cidr_ip in self.interfaces_by_ip
-            and not self.interfaces_by_ip[cidr_ip].mgmt_only
-        ):
-            for service in self.services:
-                if service.port == port and service.protocol.value == protocol:
-                    return True
-
-        return False
-
-    def primary_iface(self):
-        """
-        Returns the interface data for the device that has the primary_ip
-        """
-
-        if self.data.primary_ip:
-            return self.interfaces_by_ip[self.data.primary_ip.address]
-
-        return None
-
-    @property
-    def type(self):
-        return "AssignedObject"
-
-    @classmethod
-    def get_by_id(cls, obj_id):
-        raise Exception("not implemented")
-
-    @classmethod
-    def all_objects(cls):
-        return cls.objects
-
-    @classmethod
-    def to_yaml(cls, representer, node):
-        return representer.represent_dict(
-            {
-                "data": node.data,
-                "services": node.services,
-                "ips": node.ips,
-                "interfaces_by_ip": node.interfaces_by_ip,
-            }
-        )
-
-    def generate_netplan(self):
-        """
-        Get the interface config of specific server belongs to this tenant
-        """
-
-        if self.netplan_config:
-            return self.netplan_config
-
-        if not self.data:
-            logger.error(
-                "{type} {name} doesn't have data yet.".format(
-                    type=self.type, name=self.name
-                )
-            )
-            sys.exit(1)
-
-        primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
-        primary_if = self.interfaces_by_ip[primary_ip] if primary_ip else None
-
-        self.netplan_config["ethernets"] = dict()
-
-        if (isinstance(self, NBDevice) and self.data.device_role.name == "Router") or (
-            isinstance(self, NBVirtualMachine) and self.data.role.name == "Router"
-        ):
-            for address, interface in self.interfaces_by_ip.items():
-                if interface.mgmt_only is True or str(interface.type) == "Virtual":
-                    continue
-
-                if not any(
-                    [
-                        p.check_ip_belonging(address)
-                        for p in self.tenant.prefixes.values()
-                    ]
-                ):
-                    continue
-
-                self.netplan_config["ethernets"].setdefault(interface.name, {})
-                self.netplan_config["ethernets"][interface.name].setdefault(
-                    "addresses", []
-                ).append(address)
-
-        elif isinstance(self, NBDevice) 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 physical_if in filter(
-                lambda i: str(i.type) != "Virtual"
-                and i != primary_if
-                and i.mgmt_only is False,
-                self.interfaces,
-            ):
-                self.netplan_config["ethernets"][physical_if.name] = {
-                    "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 virtual_if in filter(lambda i: str(i.type) == "Virtual", self.interfaces):
-            if "vlans" not in self.netplan_config:
-                self.netplan_config["vlans"] = dict()
-
-            if not virtual_if.tagged_vlans:
-                # If a virtual interface doesn't have tagged VLAN, skip
-                continue
-
-            # vlan_object_id is the "id" on netbox, it's different from known VLAN ID
-            vlan_object_id = virtual_if.tagged_vlans[0].id
-            vlan_object = netboxapi.ipam.vlans.get(vlan_object_id)
-            virtual_if_ips = netboxapi.ipam.ip_addresses.filter(
-                interface_id=virtual_if.id
-            )
-
-            routes = []
-            for ip in virtual_if_ips:
-                reserved_ips = NBPrefix.all_reserved_by_ip(str(ip))
-                for reserved_ip in reserved_ips:
-                    destination = reserved_ip["custom_fields"].get("rfc3442routes", "")
-                    if destination:
-                        for dest_ip in destination.split():
-                            new_route = {
-                                "to": dest_ip,
-                                "via": str(netaddr.IPNetwork(reserved_ip["ip4"]).ip),
-                                "metric": 100,
-                            }
-                            if new_route not in routes:
-                                routes.append(new_route)
-
-            self.netplan_config["vlans"][virtual_if.name] = {
-                "id": vlan_object.vid,
-                "link": virtual_if.label,
-                "addresses": [ip.address for ip in virtual_if_ips],
-            }
-
-            # Only the fabric virtual interface will need to route to other network segments
-            if routes and "fab" in virtual_if.name:
-                self.netplan_config["vlans"][virtual_if.name]["routes"] = routes
-
-        return self.netplan_config
-
-    def generate_nftables(self):
-
-        ret = dict()
-
-        primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
-        external_if = self.interfaces_by_ip[primary_ip] if primary_ip else None
-        internal_if = None
-
-        if external_if is None:
-            logger.error("The primary interface wasn't set for device %s", self.name)
-            sys.exit(1)
-
-        for intf in filter(
-            lambda i: str(i.type) != "Virtual" and i.mgmt_only is False,
-            self.interfaces_by_ip.values(),
-        ):
-            if intf.id != external_if.id:
-                internal_if = intf
-                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["allow_subnets"] = list()
-            ret["ue_routing"] = dict()
-            ret["ue_routing"]["ue_subnets"] = self.data.config_context["ue_subnets"]
-            for prefix in NBPrefix.all_prefixes().values():
-                if prefix.data.description:
-                    ret["allow_subnets"].append(prefix.data.prefix)
-
-                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 ip, device in prefix.aos.items():
-                        if device.name == self.name:
-                            ret["ue_routing"]["snat_addr"] = str(
-                                netaddr.IPNetwork(ip).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 ip, intf in self.interfaces_by_ip.items():
-                if ip != primary_ip and intf.mgmt_only == False:
-                    unbound_listen_ips.append(ip)
-
-            for prefix in NBPrefix.all_prefixes().values():
-                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 NBPrefix.all_prefixes().values():
-                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
-
-
-@yaml.yaml_object(ydump)
-class NBDevice(NBAssignedObject):
-    """
-    Wraps a single Netbox device
-    Also caches all known devices in a class variable (devs)
-    """
-
-    objects = dict()
-
-    def __init__(self, data):
-
-        super().__init__(data)
-        self.objects[self.id] = self
-
-    @property
-    def type(self):
-        return "NBDevice"
-
-    def get_interfaces(self):
-        if not self.interfaces:
-            self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
-
-        return self.interfaces
-
-    @classmethod
-    def get_by_id(cls, obj_id):
-        obj = cls.objects.get(obj_id, None)
-        obj = obj or NBDevice(netboxapi.dcim.devices.get(obj_id))
-
-        return obj
-
-
-@yaml.yaml_object(ydump)
-class NBVirtualMachine(NBAssignedObject):
-    """
-    VM equivalent of NBDevice
-    """
-
-    objects = dict()
-
-    def __init__(self, data):
-
-        super().__init__(data)
-        self.objects[self.id] = self
-
-    @property
-    def type(self):
-        return "NBVirtualMachine"
-
-    def get_interfaces(self):
-        if not self.interfaces:
-            self.interfaces = netboxapi.virtualization.interfaces.filter(
-                virtual_machine_id=self.id
-            )
-
-        return self.interfaces
-
-    @classmethod
-    def get_by_id(cls, obj_id):
-        obj = cls.objects.get(obj_id, None)
-        obj = obj or NBVirtualMachine(
-            netboxapi.virtualization.virtual_machines.get(obj_id)
-        )
-
-        return obj
-
-
-@yaml.yaml_object(ydump)
-class NBDNSForwardZone:
-
-    fwd_zones = {}
-
-    def __init__(self, prefix):
-
-        self.domain_extension = prefix.domain_extension
-
-        self.a_recs = {}
-        self.cname_recs = {}
-        self.srv_recs = {}
-        self.ns_recs = []
-        self.txt_recs = {}
-
-        if prefix.dhcp_range:
-            self.create_dhcp_fwd(prefix.dhcp_range)
-
-        for ip, ao in prefix.aos.items():
-            self.add_ao_records(prefix, ip, ao)
-
-        for ip, res in prefix.reserved_ips.items():
-            self.add_reserved(ip, res)
-
-        # reqquired for the add_fwd_cname function below
-        if callable(getattr(prefix, "parent")):
-            parent_prefix = prefix.parent()
-
-            if parent_prefix:
-                self.merge_parent_prefix(parent_prefix, prefix)
-
-        self.fwd_zones[self.domain_extension] = self
-
-    def __repr__(self):
-        return str(
-            {
-                "a": self.a_recs,
-                "cname": self.cname_recs,
-                "ns": self.ns_recs,
-                "srv": self.srv_recs,
-                "txt": self.txt_recs,
-            }
-        )
-
-    @classmethod
-    def add_fwd_cname(cls, cname, fqdn_dest):
-        """
-        Add an arbitrary CNAME (and possibly create the fwd zone if needed) pointing
-        at a FQDN destination name. It's used to support the per-IP "DNS name" field in NetBox
-        Note that the NS record
-        """
-
-        try:
-            fqdn_split = re.compile(r"([a-z]+)\.([a-z.]+)\.")
-            (short_name, extension) = fqdn_split.match(cname).groups()
-
-        except AttributeError:
-            logger.warning(
-                "Invalid DNS CNAME: '%s', must be in FQDN format: 'host.example.com.', ignored",
-                cname,
-            )
-            return
-
-        fake_prefix = AttrDict(
-            {
-                "domain_extension": extension,
-                "dhcp_range": None,
-                "aos": {},
-                "reserved_ips": {},
-                "parent": None,
-            }
-        )
-
-        fwd_zone = cls.get_fwd_zone(fake_prefix)
-
-        fwd_zone.cname_recs[short_name] = fqdn_dest
-
-    @classmethod
-    def get_fwd_zone(cls, prefix):
-        if prefix.domain_extension in cls.fwd_zones:
-            return cls.fwd_zones[prefix.domain_extension]
-
-        return NBDNSForwardZone(prefix)
-
-    @classmethod
-    def all_fwd_zones(cls):
-        return cls.fwd_zones
-
-    @classmethod
-    def to_yaml(cls, representer, node):
-        return representer.represent_dict(
-            {
-                "a": node.a_recs,
-                "cname": node.cname_recs,
-                "ns": node.ns_recs,
-                "srv": node.srv_recs,
-                "txt": node.txt_recs,
-            }
-        )
-
-    def fqdn(self, name):
-        return "%s.%s." % (name, self.domain_extension)
-
-    def create_dhcp_fwd(self, dhcp_range):
-
-        for ip in netaddr.IPNetwork(dhcp_range).iter_hosts():
-            self.a_recs["dhcp%03d" % (ip.words[3])] = str(ip)
-
-    def name_is_duplicate(self, name, target, record_type):
-        """
-        Returns True if name already exists in the zone as an A or CNAME
-        record, False otherwise
-        """
-
-        if name in self.a_recs:
-            logger.warning(
-                "Duplicate DNS record for name %s - A record to '%s', %s record to '%s'",
-                name,
-                self.a_recs[name],
-                record_type,
-                target,
-            )
-            return True
-
-        if name in self.cname_recs:
-            logger.warning(
-                "Duplicate DNS record for name %s - CNAME record to '%s', %s record to '%s'",
-                name,
-                self.cname_recs[name],
-                record_type,
-                target,
-            )
-            return True
-
-        return False
-
-    def add_ao_records(self, prefix, ip, ao):
-
-        name = ao.dns_name(ip, prefix)
-        target_ip = str(netaddr.IPNetwork(ip).ip)  # make bare IP, not CIDR format
-
-        # add A records
-        if not self.name_is_duplicate(name, target_ip, "A"):
-            self.a_recs[name] = target_ip
-
-        # add CNAME records that alias to this name
-        for cname in ao.dns_cnames(ip):
-            # check that it isn't a dupe
-            if not self.name_is_duplicate(cname, target_ip, "CNAME"):
-                self.cname_recs[cname] = self.fqdn(name)
-
-        # add NS records if this is a DNS server
-        if ao.has_service(ip, 53, "udp"):
-            self.ns_recs.append(self.fqdn(name))
-
-        # if a DNS name is set, add it as a CNAME
-        if ao.ips[ip]["dns_name"]:  # and ip == aos.data.primary_ip.address:
-            self.add_fwd_cname(ao.ips[ip]["dns_name"], self.fqdn(name))
-
-    def add_reserved(self, ip, res):
-
-        target_ip = str(netaddr.IPNetwork(ip).ip)  # make bare IP, not CIDR format
-
-        if not self.name_is_duplicate(res["name"], target_ip, "A"):
-            self.a_recs[res["name"]] = target_ip
-
-    def merge_parent_prefix(self, pprefix, prefix):
-
-        # only if no NS records exist already
-        if not self.ns_recs:
-            # scan parent prefix for services
-            for ip, ao in pprefix.aos.items():
-
-                # Create a DNS within this prefix pointing to out-of-prefix IP
-                # where DNS server is
-                name = ao.dns_name(ip, prefix)
-                target_ip = str(
-                    netaddr.IPNetwork(ip).ip
-                )  # make bare IP, not CIDR format
-
-                # add NS records if this is a DNS server
-                if ao.has_service(ip, 53, "udp"):
-                    self.a_recs[name] = target_ip
-                    self.ns_recs.append(self.fqdn(name))
-
-
-@yaml.yaml_object(ydump)
-class NBDNSReverseZones:
-    def __init__(self):
-
-        self.reverse_zones = {}
-
-    @classmethod
-    def to_yaml(cls, representer, node):
-        return representer.represent_dict(node.reverse_zones)
-
-    @classmethod
-    def canonicalize_rfc1918_prefix(cls, 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(str(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)
-
-    def add_prefix(self, prefix):
-
-        canonical_prefix = self.canonicalize_rfc1918_prefix(prefix)
-
-        if canonical_prefix in self.reverse_zones:
-            rzone = self.reverse_zones[canonical_prefix]
-        else:
-            rzone = {
-                "ns": [],
-                "ptr": {},
-            }
-
-        if prefix.dhcp_range:
-            # FIXME: doesn't check for duplicate entries
-            rzone["ptr"].update(self.create_dhcp_rev(prefix))
-
-        for ip, ao in prefix.aos.items():
-            target_ip = str(netaddr.IPNetwork(ip).ip)  # make bare IP, not CIDR format
-            ao_name = self.get_ao_name(ip, ao, prefix,)
-            rzone["ptr"][target_ip] = ao_name
-
-            # add NS records if this is a DNS server
-            if ao.has_service(ip, 53, "udp"):
-                rzone["ns"].append(ao_name)
-
-        parent_prefix = prefix.parent()
-
-        if parent_prefix:
-            self.merge_parent_prefix(rzone, parent_prefix)
-
-        self.reverse_zones[canonical_prefix] = rzone
-
-    def merge_parent_prefix(self, rzone, pprefix):
-
-        # parent items
-        p_ns = []
-
-        # scan parent prefix for services
-        for ip, ao in pprefix.aos.items():
-
-            ao_name = self.get_ao_name(ip, ao, pprefix,)
-
-            # add NS records if this is a DNS server
-            if ao.has_service(ip, 53, "udp"):
-                p_ns.append(ao_name)
-
-        # set DNS servers if none in rzone
-        if not rzone["ns"]:
-            rzone["ns"] = p_ns
-
-    def create_dhcp_rev(self, prefix):
-
-        dhcp_rzone = {}
-
-        for ip in netaddr.IPNetwork(prefix.dhcp_range).iter_hosts():
-            dhcp_rzone[str(ip)] = "dhcp%03d.%s." % (
-                ip.words[3],
-                prefix.domain_extension,
-            )
-
-        return dhcp_rzone
-
-    def get_ao_name(self, ip, ao, prefix):
-        short_name = ao.dns_name(ip, prefix)
-        return "%s.%s." % (short_name, prefix.domain_extension)
-
-
-@yaml.yaml_object(ydump)
-class NBDHCPSubnet:
-    def __init__(self, prefix):
-
-        self.domain_extension = prefix.domain_extension
-
-        self.subnet = None
-        self.range = None
-        self.first_ip = None
-        self.hosts = []
-        self.routers = []
-        self.dns_servers = []
-        self.dns_search = []
-        self.tftpd_server = None
-        self.ntp_servers = []
-        self.dhcpd_interface = None
-
-        self.add_prefix(prefix)
-
-        for ip, ao in prefix.aos.items():
-            self.add_ao(str(ip), ao, prefix)
-
-        parent_prefix = prefix.parent()
-
-        if parent_prefix:
-            self.merge_parent_prefix(parent_prefix)
-
-    def add_prefix(self, prefix):
-
-        self.subnet = str(prefix)
-
-        self.first_ip = str(netaddr.IPAddress(netaddr.IPNetwork(str(prefix)).first + 1))
-
-        self.dns_search = [prefix.domain_extension]
-
-        if prefix.dhcp_range:
-            self.range = prefix.dhcp_range
-
-        for ip, res in prefix.reserved_ips.items():
-            # routers are reserved IP's that start with 'router" in the IP description
-            if re.match("router", res["description"]):
-                router = {"ip": str(netaddr.IPNetwork(ip).ip)}
-
-                if (
-                    "rfc3442routes" in res["custom_fields"]
-                    and res["custom_fields"]["rfc3442routes"]
-                ):
-                    # split on whitespace
-                    router["rfc3442routes"] = re.split(
-                        r"\s+", res["custom_fields"]["rfc3442routes"]
-                    )
-
-                self.routers.append(router)
-
-        # set first IP to router if not set otherwise.
-        if not self.routers:
-            router = {"ip": self.first_ip}
-
-            self.routers.append(router)
-
-    def add_ao(self, ip, ao, prefix):
-
-        target_ip = str(netaddr.IPNetwork(ip).ip)  # make bare IP, not CIDR format
-
-        # find the DHCP interface if it's this IP
-        if target_ip == self.first_ip:
-            self.dhcpd_interface = ao.interfaces_by_ip[ip].name
-
-        name = ao.dns_name(ip, prefix)
-
-        # add only devices that have a macaddr for this IP
-        if ip in ao.interfaces_by_ip:
-
-            mac_addr = dict(ao.interfaces_by_ip[ip]).get("mac_address")
-
-            if mac_addr and mac_addr.strip():  # if exists and not blank
-                self.hosts.append(
-                    {"name": name, "ip_addr": target_ip, "mac_addr": mac_addr.lower()}
-                )
-
-        # add dns servers
-        if ao.has_service(ip, 53, "udp"):
-            self.dns_servers.append(target_ip)
-
-        # add tftp server
-        if ao.has_service(ip, 69, "udp"):
-            if not self.tftpd_server:
-                self.tftpd_server = target_ip
-            else:
-                logger.warning(
-                    "Duplicate TFTP servers in prefix, using first of %s and %s",
-                    self.tftpd_server,
-                    target_ip,
-                )
-
-        # add NTP servers
-        if ao.has_service(ip, 123, "udp"):
-            self.ntp_servers.append(target_ip)
-
-    def merge_parent_prefix(self, pprefix):
-
-        # parent items
-        p_dns_servers = []
-        p_tftpd_server = None
-        p_ntp_servers = []
-
-        # scan parent prefix for services
-        for ip, ao in pprefix.aos.items():
-
-            target_ip = str(netaddr.IPNetwork(ip).ip)
-
-            # add dns servers
-            if ao.has_service(ip, 53, "udp"):
-                p_dns_servers.append(target_ip)
-
-            # add tftp server
-            if ao.has_service(ip, 69, "udp"):
-                if not p_tftpd_server:
-                    p_tftpd_server = target_ip
-                else:
-                    logger.warning(
-                        "Duplicate TFTP servers in parent prefix, using first of %s and %s",
-                        p_tftpd_server,
-                        target_ip,
-                    )
-
-            # add NTP servers
-            if ao.has_service(ip, 123, "udp"):
-                p_ntp_servers.append(target_ip)
-
-        # merge if doesn't exist in prefix
-        if not self.dns_servers:
-            self.dns_servers = p_dns_servers
-
-        if not self.tftpd_server:
-            self.tftpd_server = p_tftpd_server
-
-        if not self.ntp_servers:
-            self.ntp_servers = p_ntp_servers
-
-    @classmethod
-    def to_yaml(cls, representer, node):
-        return representer.represent_dict(
-            {
-                "subnet": node.subnet,
-                "range": node.range,
-                "routers": node.routers,
-                "hosts": node.hosts,
-                "dns_servers": node.dns_servers,
-                "dns_search": node.dns_search,
-                "tftpd_server": node.tftpd_server,
-                "ntp_servers": node.ntp_servers,
-            }
-        )
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