diff --git a/README.md b/README.md index 27e2031d..5da4532e 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ The plugin behavior can be controlled with the following list of settings - `create_manufacturer_if_missing` boolean (default True), If True, a new manufacturer object will be created if the manufacturer discovered by Napalm is do not match an existing manufacturer, this option is only valid if `create_device_type_if_missing` is True as well. - `create_device_role_if_missing` boolean (default True), If True, a new device role object will be created if the device role provided was not provided as part of the onboarding and if the `default_device_role` do not already exist. - `create_management_interface_if_missing` boolean (default True), If True, add management interface and IP address to the device. If False no management interfaces will be created, nor will the IP address be added to Nautobot, while the device will still get added. -- `default_device_status` string (default "Active"), status assigned to a new device by default (must be lowercase). +- `default_device_status` string (default "Active"), status assigned to a new device by default. +- `default_ip_status` string (default "Active"), status assigned to a new device management IP. - `default_device_role` string (default "network") - `default_device_role_color` string (default FF0000), color assigned to the device role if it needs to be created. - `default_management_interface` string (default "PLACEHOLDER"), name of the management interface that will be created, if one can't be identified on the device. diff --git a/nautobot_device_onboarding/__init__.py b/nautobot_device_onboarding/__init__.py index 6bf51cdb..e2b4d2af 100644 --- a/nautobot_device_onboarding/__init__.py +++ b/nautobot_device_onboarding/__init__.py @@ -35,10 +35,11 @@ class OnboardingConfig(PluginConfig): "create_device_type_if_missing": True, "create_device_role_if_missing": True, "default_device_role": "network", - "default_device_role_color": "FF0000", + "default_device_role_color": "ff0000", "default_management_interface": "PLACEHOLDER", "default_management_prefix_length": 0, "default_device_status": "Active", + "default_ip_status": "Active", "create_management_interface_if_missing": True, "skip_device_type_on_update": False, "skip_manufacturer_on_update": False, diff --git a/nautobot_device_onboarding/nautobot_keeper.py b/nautobot_device_onboarding/nautobot_keeper.py index 704d7c74..5668b5aa 100644 --- a/nautobot_device_onboarding/nautobot_keeper.py +++ b/nautobot_device_onboarding/nautobot_keeper.py @@ -17,12 +17,14 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.utils.text import slugify from nautobot.dcim.choices import InterfaceTypeChoices from nautobot.dcim.models import Manufacturer, Device, Interface, DeviceType, DeviceRole from nautobot.dcim.models import Platform from nautobot.dcim.models import Site from nautobot.extras.models import Status +from nautobot.extras.models.customfields import CustomField from nautobot.ipam.models import IPAddress from .constants import NETMIKO_TO_NAPALM_STATIC @@ -33,6 +35,21 @@ PLUGIN_SETTINGS = settings.PLUGINS_CONFIG["nautobot_device_onboarding"] +def ensure_default_cf(obj, model): + """Update objects's default custom fields.""" + for cf in CustomField.objects.get_for_model(model): + if (cf.default is not None) and (cf.name not in obj.cf): + obj.cf[cf.name] = cf.default + + try: + obj.validated_save() + except ValidationError as err: + raise OnboardException( + reason="fail-general", + message=f"ERROR: {obj} validation error: {err.messages}", + ) + + def object_match(obj, search_array): """Used to search models for multiple criteria. @@ -192,6 +209,7 @@ def ensure_device_manufacturer( except Manufacturer.DoesNotExist: if create_manufacturer: self.nb_manufacturer = Manufacturer.objects.create(name=self.netdev_vendor, slug=nb_manufacturer_slug) + ensure_default_cf(obj=self.nb_manufacturer, model=Manufacturer) else: raise OnboardException( reason="fail-config", message=f"ERROR manufacturer not found: {self.netdev_vendor}" @@ -264,6 +282,7 @@ def ensure_device_type( model=nb_device_type_slug.upper(), manufacturer=self.nb_manufacturer, ) + ensure_default_cf(obj=self.nb_device_type, model=DeviceType) else: raise OnboardException( reason="fail-config", message=f"ERROR device type not found: {self.netdev_model}" @@ -292,6 +311,7 @@ def ensure_device_role( color=self.netdev_nb_role_color, vm_role=False, ) + ensure_default_cf(obj=self.nb_device_role, model=DeviceRole) else: raise OnboardException( reason="fail-config", message=f"ERROR device role not found: {self.netdev_nb_role_slug}" @@ -344,6 +364,7 @@ def ensure_device_platform(self, create_platform_if_missing=PLUGIN_SETTINGS["cre slug=self.netdev_nb_platform_slug, napalm_driver=netmiko_to_napalm[self.netdev_netmiko_device_type], ) + ensure_default_cf(obj=self.nb_platform, model=Platform) else: raise OnboardException( reason="fail-general", @@ -407,6 +428,7 @@ def ensure_device_instance(self, default_status=PLUGIN_SETTINGS["default_device_ try: self.device, created = Device.objects.update_or_create(**lookup_args) + ensure_default_cf(obj=self.device, model=Device) if created: logger.info("CREATED device: %s", self.netdev_hostname) @@ -425,14 +447,31 @@ def ensure_interface(self): self.nb_mgmt_ifname, _ = Interface.objects.get_or_create( name=self.netdev_mgmt_ifname, device=self.device, defaults=dict(type=InterfaceTypeChoices.TYPE_OTHER) ) + ensure_default_cf(obj=self.nb_mgmt_ifname, model=Interface) def ensure_primary_ip(self): """Ensure mgmt_ipaddr exists in IPAM, has the device interface, and is assigned as the primary IP address.""" # see if the primary IP address exists in IPAM if self.netdev_mgmt_ip_address and self.netdev_mgmt_pflen: + ct = ContentType.objects.get_for_model(IPAddress) # pylint: disable=invalid-name + default_status_name = PLUGIN_SETTINGS["default_ip_status"] + try: + ip_status = Status.objects.get(content_types__in=[ct], name=default_status_name) + except Status.DoesNotExist: + raise OnboardException( + reason="fail-general", + message=f"ERROR could not find existing IP Address status: {default_status_name}", + ) + except Status.MultipleObjectsReturned: + raise OnboardException( + reason="fail-general", + message=f"ERROR multiple IP Address status using same name: {default_status_name}", + ) + self.nb_primary_ip, created = IPAddress.objects.get_or_create( - address=f"{self.netdev_mgmt_ip_address}/{self.netdev_mgmt_pflen}" + address=f"{self.netdev_mgmt_ip_address}/{self.netdev_mgmt_pflen}", defaults={"status": ip_status} ) + ensure_default_cf(obj=self.nb_primary_ip, model=IPAddress) if created or self.nb_primary_ip not in self.nb_mgmt_ifname.ip_addresses.all(): logger.info("ASSIGN: IP address %s to %s", self.nb_primary_ip.address, self.nb_mgmt_ifname.name) diff --git a/nautobot_device_onboarding/tests/test_nautobot_keeper.py b/nautobot_device_onboarding/tests/test_nautobot_keeper.py index a6ac4255..67e333fb 100644 --- a/nautobot_device_onboarding/tests/test_nautobot_keeper.py +++ b/nautobot_device_onboarding/tests/test_nautobot_keeper.py @@ -18,7 +18,8 @@ from nautobot.dcim.choices import InterfaceTypeChoices from nautobot.dcim.models import Site, Manufacturer, DeviceType, DeviceRole, Device, Interface, Platform from nautobot.ipam.models import IPAddress -from nautobot.extras.models import Status +from nautobot.extras.choices import CustomFieldTypeChoices +from nautobot.extras.models import CustomField, Status from nautobot_device_onboarding.exceptions import OnboardException from nautobot_device_onboarding.nautobot_keeper import NautobotKeeper @@ -32,6 +33,57 @@ class NautobotKeeperTestCase(TestCase): def setUp(self): """Create a superuser and token for API calls.""" self.site1 = Site.objects.create(name="USWEST", slug="uswest") + DATA = ( + { + "field_type": CustomFieldTypeChoices.TYPE_TEXT, + "field_name": "cf_manufacturer", + "default_value": "Foobar!", + "model": Manufacturer, + }, + { + "field_type": CustomFieldTypeChoices.TYPE_INTEGER, + "field_name": "cf_devicetype", + "default_value": 5, + "model": DeviceType, + }, + { + "field_type": CustomFieldTypeChoices.TYPE_INTEGER, + "field_name": "cf_devicerole", + "default_value": 10, + "model": DeviceRole, + }, + { + "field_type": CustomFieldTypeChoices.TYPE_BOOLEAN, + "field_name": "cf_platform", + "default_value": True, + "model": Platform, + }, + { + "field_type": CustomFieldTypeChoices.TYPE_BOOLEAN, + "field_name": "cf_device", + "default_value": False, + "model": Device, + }, + { + "field_type": CustomFieldTypeChoices.TYPE_DATE, + "field_name": "cf_interface", + "default_value": "2016-06-23", + "model": Interface, + }, + { + "field_type": CustomFieldTypeChoices.TYPE_URL, + "field_name": "cf_ipaddress", + "default_value": "http://example.com/", + "model": IPAddress, + }, + ) + + for data in DATA: + # Create a custom field + cf = CustomField.objects.create( + type=data["field_type"], name=data["field_name"], default=data["default_value"], required=False + ) + cf.content_types.set([ContentType.objects.get_for_model(data["model"])]) def test_ensure_device_manufacturer_strict_missing(self): """Verify ensure_device_manufacturer function when Manufacturer object is not present.""" @@ -473,3 +525,32 @@ def test_platform_map(self): Platform.objects.get(name=PLUGIN_SETTINGS["platform_map"]["cisco_ios"]).name, slugify(PLUGIN_SETTINGS["platform_map"]["cisco_ios"]), ) + + def test_ensure_custom_fields(self): + """Verify objects inherit default custom fields.""" + onboarding_kwargs = { + "netdev_hostname": "sw1", + "netdev_nb_role_slug": "switch", + "netdev_vendor": "Cisco", + "netdev_model": "c2960", + "netdev_nb_site_slug": self.site1.slug, + "netdev_netmiko_device_type": "cisco_ios", + "netdev_serial_number": "123456", + "netdev_mgmt_ip_address": "192.0.2.15", + "netdev_mgmt_ifname": "Management0", + "netdev_mgmt_pflen": 24, + "netdev_nb_role_color": "ff0000", + } + + nbk = NautobotKeeper(**onboarding_kwargs) + nbk.ensure_device() + + device = Device.objects.get(name="sw1") + + self.assertEqual(device.cf["cf_device"], False) + self.assertEqual(device.platform.cf["cf_platform"], True) + self.assertEqual(device.device_type.cf["cf_devicetype"], 5) + self.assertEqual(device.device_role.cf["cf_devicerole"], 10) + self.assertEqual(device.device_type.manufacturer.cf["cf_manufacturer"], "Foobar!") + self.assertEqual(device.interfaces.get(name="Management0").cf["cf_interface"], "2016-06-23") + self.assertEqual(device.primary_ip.cf["cf_ipaddress"], "http://example.com/")