Skip to content

Commit f89e9dc

Browse files
authored
Merge pull request #68 from networktocode/mzb-netbox-keeper-disaggregation
Onboarding process refactor
2 parents d38343d + 1eeea6f commit f89e9dc

33 files changed

+2010
-1038
lines changed

development/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ RUN pip install --upgrade pip\
1313
# -------------------------------------------------------------------------------------
1414
# Install NetBox
1515
# -------------------------------------------------------------------------------------
16+
# Remove redis==3.4.1 from the requirements.txt file as a workaround to #4910
17+
# https://github.com/netbox-community/netbox/issues/4910, required for version 2.8.8 and earlier
1618
RUN git clone --single-branch --branch ${netbox_ver} https://github.com/netbox-community/netbox.git /opt/netbox/ && \
1719
cd /opt/netbox/ && \
20+
sed -i '/^redis\=\=/d' /opt/netbox/requirements.txt && \
1821
pip install -r /opt/netbox/requirements.txt
1922

2023
# Make the django-debug-toolbar always visible when DEBUG is enabled,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Example of custom onboarding class.
2+
3+
(c) 2020 Network To Code
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
Unless required by applicable law or agreed to in writing, software
9+
distributed under the License is distributed on an "AS IS" BASIS,
10+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
See the License for the specific language governing permissions and
12+
limitations under the License.
13+
"""
14+
15+
from netbox_onboarding.netbox_keeper import NetboxKeeper
16+
from netbox_onboarding.onboarding.onboarding import Onboarding
17+
18+
19+
class MyOnboardingClass(Onboarding):
20+
"""Custom onboarding class example.
21+
22+
Main purpose of this class is to access and modify the onboarding_kwargs.
23+
By accessing the onboarding kwargs, user gains ability to modify
24+
onboarding parameters before the objects are created in NetBox.
25+
26+
This class adds the get_device_role method that does the static
27+
string comparison and returns the device role.
28+
"""
29+
30+
def run(self, onboarding_kwargs):
31+
"""Ensures network device."""
32+
# Access hostname from onboarding_kwargs and get device role automatically
33+
device_new_role = self.get_device_role(hostname=onboarding_kwargs["netdev_hostname"])
34+
35+
# Update the device role in onboarding kwargs dictionary
36+
onboarding_kwargs["netdev_nb_role_slug"] = device_new_role
37+
38+
nb_k = NetboxKeeper(**onboarding_kwargs)
39+
nb_k.ensure_device()
40+
41+
self.created_device = nb_k.device
42+
43+
@staticmethod
44+
def get_device_role(hostname):
45+
"""Returns the device role based on hostname data.
46+
47+
This is a static analysis of hostname string content only
48+
"""
49+
hostname_lower = hostname.lower()
50+
if ("rtr" in hostname_lower) or ("router" in hostname_lower):
51+
role = "router"
52+
elif ("sw" in hostname_lower) or ("switch" in hostname_lower):
53+
role = "switch"
54+
elif ("fw" in hostname_lower) or ("firewall" in hostname_lower):
55+
role = "firewall"
56+
elif "dc" in hostname_lower:
57+
role = "datacenter"
58+
else:
59+
role = "generic"
60+
61+
return role
62+
63+
64+
class OnboardingDriverExtensions:
65+
"""This is an example of a custom onboarding driver extension.
66+
67+
This extension sets the onboarding_class to MyOnboardingClass,
68+
which is an example class of how to access and modify the device
69+
role automatically through the onboarding process.
70+
"""
71+
72+
def __init__(self, napalm_device):
73+
"""Inits the class."""
74+
self.napalm_device = napalm_device
75+
self.onboarding_class = MyOnboardingClass
76+
self.ext_result = None
77+
78+
def get_onboarding_class(self):
79+
"""Return onboarding class for IOS driver.
80+
81+
Currently supported is Standalone Onboarding Process
82+
83+
Result of this method is used by the OnboardingManager to
84+
initiate the instance of the onboarding class.
85+
"""
86+
return self.onboarding_class
87+
88+
def get_ext_result(self):
89+
"""This method is used to store any object as a return value.
90+
91+
Result of this method is passed to the onboarding class as
92+
driver_addon_result argument.
93+
94+
:return: Any()
95+
"""
96+
return self.ext_result

netbox_onboarding/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class OnboardingConfig(PluginConfig):
4040
"default_device_status": "active",
4141
"create_management_interface_if_missing": True,
4242
"platform_map": {},
43+
"onboarding_extensions_map": {"ios": "netbox_onboarding.onboarding_extensions.ios",},
4344
}
4445
caching_config = {}
4546

netbox_onboarding/choices.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ class OnboardingStatusChoices(ChoiceSet):
2222
STATUS_PENDING = "pending"
2323
STATUS_RUNNING = "running"
2424
STATUS_SUCCEEDED = "succeeded"
25+
STATUS_SKIPPED = "skipped"
2526

2627
CHOICES = (
2728
(STATUS_FAILED, "failed"),
2829
(STATUS_PENDING, "pending"),
2930
(STATUS_RUNNING, "running"),
3031
(STATUS_SUCCEEDED, "succeeded"),
32+
(STATUS_SKIPPED, "skipped"),
3133
)
3234

3335

netbox_onboarding/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Constants for netbox_onboarding plugin."""
2-
NETMIKO_TO_NAPALM = {
2+
3+
NETMIKO_TO_NAPALM_STATIC = {
34
"cisco_ios": "ios",
45
"cisco_nxos": "nxos_ssh",
56
"arista_eos": "eos",

netbox_onboarding/exceptions.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Exceptions.
2+
3+
(c) 2020 Network To Code
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
Unless required by applicable law or agreed to in writing, software
9+
distributed under the License is distributed on an "AS IS" BASIS,
10+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
See the License for the specific language governing permissions and
12+
limitations under the License.
13+
"""
14+
15+
16+
class OnboardException(Exception):
17+
"""A failure occurred during the onboarding process.
18+
19+
The exception includes a reason "slug" as defined below as well as a humanized message.
20+
"""
21+
22+
REASONS = (
23+
"fail-config", # config provided is not valid
24+
"fail-connect", # device is unreachable at IP:PORT
25+
"fail-execute", # unable to execute device/API command
26+
"fail-login", # bad username/password
27+
"fail-dns", # failed to get IP address from name resolution
28+
"fail-general", # other error
29+
)
30+
31+
def __init__(self, reason, message, **kwargs):
32+
"""Exception Init."""
33+
super(OnboardException, self).__init__(kwargs)
34+
self.reason = reason
35+
self.message = message
36+
37+
def __str__(self):
38+
"""Exception __str__."""
39+
return f"{self.__class__.__name__}: {self.reason}: {self.message}"

netbox_onboarding/filters.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ class OnboardingTaskFilter(NameSlugSearchFilterSet):
2626

2727
q = django_filters.CharFilter(method="search", label="Search",)
2828

29-
site_id = django_filters.ModelMultipleChoiceFilter(queryset=Site.objects.all(), label="Site (ID)",)
30-
3129
site = django_filters.ModelMultipleChoiceFilter(
3230
field_name="site__slug", queryset=Site.objects.all(), to_field_name="slug", label="Site (slug)",
3331
)
@@ -46,7 +44,7 @@ class Meta: # noqa: D106 "Missing docstring in public nested class"
4644
model = OnboardingTask
4745
fields = ["id", "site", "site_id", "platform", "role", "status", "failed_reason"]
4846

49-
def search(self, queryset, name, value):
47+
def search(self, queryset, name, value): # pylint: disable=unused-argument, no-self-use
5048
"""Perform the filtered search."""
5149
if not value.strip():
5250
return queryset
@@ -55,7 +53,7 @@ def search(self, queryset, name, value):
5553
| Q(ip_address__icontains=value)
5654
| Q(site__name__icontains=value)
5755
| Q(platform__name__icontains=value)
58-
| Q(device__icontains=value)
56+
| Q(created_device__name__icontains=value)
5957
| Q(status__icontains=value)
6058
| Q(failed_reason__icontains=value)
6159
| Q(message__icontains=value)

netbox_onboarding/forms.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
"""
1414

1515
from django import forms
16+
from django.db import transaction
1617
from django_rq import get_queue
1718

18-
from utilities.forms import BootstrapMixin
19+
from utilities.forms import BootstrapMixin, CSVModelForm
1920
from dcim.models import Site, Platform, DeviceRole, DeviceType
20-
from extras.forms import CustomFieldModelCSVForm
2121

2222
from .models import OnboardingTask
2323
from .choices import OnboardingStatusChoices, OnboardingFailChoices
@@ -33,7 +33,7 @@ class OnboardingTaskForm(BootstrapMixin, forms.ModelForm):
3333
required=True, label="IP address", help_text="IP Address/DNS Name of the device to onboard"
3434
)
3535

36-
site = forms.ModelChoiceField(required=True, queryset=Site.objects.all(), to_field_name="slug")
36+
site = forms.ModelChoiceField(required=True, queryset=Site.objects.all())
3737

3838
username = forms.CharField(required=False, help_text="Device username (will not be stored in database)")
3939
password = forms.CharField(
@@ -106,7 +106,7 @@ class Meta: # noqa: D106 "Missing docstring in public nested class"
106106
fields = ["q", "site", "platform", "status", "failed_reason"]
107107

108108

109-
class OnboardingTaskFeedCSVForm(CustomFieldModelCSVForm):
109+
class OnboardingTaskFeedCSVForm(CSVModelForm):
110110
"""Form for entering CSV to bulk-import OnboardingTask entries."""
111111

112112
site = forms.ModelChoiceField(
@@ -149,12 +149,21 @@ class OnboardingTaskFeedCSVForm(CustomFieldModelCSVForm):
149149

150150
class Meta: # noqa: D106 "Missing docstring in public nested class"
151151
model = OnboardingTask
152-
fields = OnboardingTask.csv_headers
152+
fields = [
153+
"site",
154+
"ip_address",
155+
"port",
156+
"timeout",
157+
"platform",
158+
"role",
159+
]
153160

154161
def save(self, commit=True, **kwargs):
155162
"""Save the model, and add it and the associated credentials to the onboarding worker queue."""
156163
model = super().save(commit=commit, **kwargs)
157164
if commit:
158165
credentials = Credentials(self.data.get("username"), self.data.get("password"), self.data.get("secret"))
159-
get_queue("default").enqueue("netbox_onboarding.worker.onboard_device", model.pk, credentials)
166+
transaction.on_commit(
167+
lambda: get_queue("default").enqueue("netbox_onboarding.worker.onboard_device", model.pk, credentials)
168+
)
160169
return model
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 2.2.10 on 2020-08-21 11:05
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("netbox_onboarding", "0001_initial"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="OnboardingDevice",
16+
fields=[
17+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)),
18+
("enabled", models.BooleanField(default=True)),
19+
("device", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="dcim.Device")),
20+
],
21+
),
22+
]

netbox_onboarding/models.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@
1111
See the License for the specific language governing permissions and
1212
limitations under the License.
1313
"""
14+
from django.db.models.signals import post_save
15+
from django.dispatch import receiver
1416
from django.db import models
17+
from django.urls import reverse
18+
from dcim.models import Device
1519
from .choices import OnboardingStatusChoices, OnboardingFailChoices
20+
from .release import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_29
1621

1722

1823
class OnboardingTask(models.Model):
@@ -47,18 +52,73 @@ class OnboardingTask(models.Model):
4752

4853
created_on = models.DateTimeField(auto_now_add=True)
4954

50-
csv_headers = [
51-
"site",
52-
"ip_address",
53-
"port",
54-
"timeout",
55-
"platform",
56-
"role",
57-
]
58-
5955
class Meta: # noqa: D106 "missing docstring in public nested class"
6056
ordering = ["created_on"]
6157

6258
def __str__(self):
6359
"""String representation of an OnboardingTask."""
6460
return f"{self.site} : {self.ip_address}"
61+
62+
def get_absolute_url(self):
63+
"""Provide absolute URL to an OnboardingTask."""
64+
return reverse("plugins:netbox_onboarding:onboardingtask", kwargs={"pk": self.pk})
65+
66+
if NETBOX_RELEASE_CURRENT >= NETBOX_RELEASE_29:
67+
from utilities.querysets import RestrictedQuerySet # pylint: disable=no-name-in-module, import-outside-toplevel
68+
69+
objects = RestrictedQuerySet.as_manager()
70+
71+
72+
class OnboardingDevice(models.Model):
73+
"""The status of each Onboarded Device is tracked in the OnboardingDevice table."""
74+
75+
device = models.OneToOneField(to="dcim.Device", on_delete=models.CASCADE)
76+
enabled = models.BooleanField(default=True, help_text="Whether (re)onboarding of this device is permitted")
77+
78+
@property
79+
def last_check_attempt_date(self):
80+
"""Date of last onboarding attempt for a device."""
81+
try:
82+
return OnboardingTask.objects.filter(created_device=self.device).latest("created_on").created_on
83+
except ValueError:
84+
return "unknown"
85+
86+
@property
87+
def last_check_successful_date(self):
88+
"""Date of last successful onboarding for a device."""
89+
try:
90+
return (
91+
OnboardingTask.objects.filter(
92+
created_device=self.device, status=OnboardingStatusChoices.STATUS_SUCCEEDED
93+
)
94+
.latest("created_on")
95+
.created_on
96+
)
97+
except ValueError:
98+
return "unknown"
99+
100+
@property
101+
def status(self):
102+
"""Last onboarding status."""
103+
try:
104+
return OnboardingTask.objects.filter(created_device=self.device).latest("created_on").status
105+
except ValueError:
106+
return "unknown"
107+
108+
@property
109+
def last_ot(self):
110+
"""Last onboarding task."""
111+
try:
112+
return OnboardingTask.objects.filter(created_device=self.device).latest("created_on")
113+
except ValueError:
114+
return None
115+
116+
117+
@receiver(post_save, sender=Device)
118+
def init_onboarding_for_new_device(sender, instance, created, **kwargs): # pylint: disable=unused-argument
119+
"""Register to create a OnboardingDevice object for each new Device Object using Django Signal.
120+
121+
https://docs.djangoproject.com/en/3.0/ref/signals/#post-save
122+
"""
123+
if created:
124+
OnboardingDevice.objects.create(device=instance)

0 commit comments

Comments
 (0)