Skip to content

Commit

Permalink
Custom pollers (#136)
Browse files Browse the repository at this point in the history
* custom pollers

* fix existing tests

* tests

* poller connect-disconnect

* exclude custom poller form test from nb 3.7
  • Loading branch information
amyasnikov authored Nov 19, 2024
1 parent 4e69071 commit 69361dd
Show file tree
Hide file tree
Showing 28 changed files with 412 additions and 117 deletions.
8 changes: 6 additions & 2 deletions docs/entities/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ This field defines [Serializer](serializers.md) for Command output.
## Parameters
This block contains type-specific parameters.

### Type:CLI
### Type: CLI
#### CLI Command
This field must contain text string which is going to be sent to device when polling occurs.

### Type:NETCONF
### Type: NETCONF
#### RPC
This field must contain an XML RPC which is going to be sent to device via Netconf.

Expand Down Expand Up @@ -80,3 +80,7 @@ Example:
}
}
```

### Type: Custom

This type has been introduced especially for [Custom Pollers](../features/custom_pollers.md) support. For this type you can define arbitrary parameters (in a form of JSON object) and then use them inside your custom poller.
73 changes: 73 additions & 0 deletions docs/features/custom_pollers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# User-defined Pollers

Validity is able to perform device polling via custom user-defined pollers. This feature may be useful when:

* existing polling methods must be adjusted to work with specific network equipment (e.g. slightly modify `netmiko` to interact with some ancient switch);
* some completely new polling method must be introduced (e.g. gNMI-based).

## Defining custom Poller

To define your own Poller, two steps must be performed:

* Inherit from `CustomPoller` class to implement your custom polling logic
* Fill out `PollerInfo` structure with Poller meta info

### Implementing Poller class

Here is the minimal viable example of a custom poller class. It uses `scrapli` library to connect to devices via SSH.

```python
from scrapli import Scrapli
from validity.pollers import CustomPoller
from validity.models import Command


class ScrapliPoller(CustomPoller):
driver_factory = Scrapli
host_param_name = 'host' # Scrapli expects "host" param containing ip address of the device
driver_connect_method = 'open' # This driver method (if defined) will be called to open the connection.
driver_disconnect_method = 'close' # This driver method (if defined) will be called to gracefully close the connection.

def poll_one_command(self, driver, command) -> str:
"""
Arguments:
driver - object returned by calling driver_factory, usually represents connection to a particular device
command - Django model instance of the Command
Returns:
A string containing particular command execution result
"""
resp = driver.send_command(command.parameters["cli_command"])
return resp.result
```

!!! note
Be aware that every poller class instance is usually responsible for interaction with multiple devices. Hence, do not use poller fields for storing device-specific parameters.


### Filling PollerInfo

Poller Info is required to tell Validity about your custom poller.
Here is the example of the plugin settings:

```python
# configuration.py

from validity.settings import PollerInfo
from my_awesome_poller import ScrapliPoller

PLUGIN_SETTINGS = {
'validity': {
'custom_pollers' : [
PollerInfo(klass=ScrapliPoller, name='scrapli', color='pink', command_types=['CLI'])
]
}
}
```

PollerInfo parameters:

* **klass** - class inherited from `CustomPoller`
* **name** - system name of the poller, must contain lowercase letters only
* **verbose_name** - optional verbose name of the poller. Will be used in NetBox GUI
* **color** - badge color used for "Connection Type" field in the GUI
* **command_types** - list of acceptable [Command](../entities/commands.md) types for this kind of Poller. Available choices are `CLI`, `netconf`, `json_api` and `custom`
2 changes: 1 addition & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
deepdiff>=6.2.0,<7
dimi >=1.2.0,< 2
dimi >=1.3.0,< 2
django-bootstrap5 >=24.2,<25
dulwich # Core NetBox "optional" requirement
jq>=1.4.0,<2
Expand Down
2 changes: 1 addition & 1 deletion requirements/docs.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
mkdocs==1.6.1
mkdocs-include-markdown-plugin==4.0.4
mkdocs-include-markdown-plugin==7.0.0
mkdocs-material==9.5.34
9 changes: 6 additions & 3 deletions validity/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Annotated

from core.api.nested_serializers import (
NestedDataFileSerializer as _NestedDataFileSerializer,
)
Expand Down Expand Up @@ -27,7 +29,7 @@
from rest_framework.reverse import reverse
from tenancy.models import Tenant

from validity import config, models
from validity import config, di, models
from validity.choices import ExplanationVerbosityChoices
from validity.netbox_changes import NestedTenantSerializer
from .helpers import (
Expand Down Expand Up @@ -366,8 +368,9 @@ class Meta:
)
brief_fields = ("id", "url", "display", "name")

def validate(self, data):
models.Poller.validate_commands(data["connection_type"], data["commands"])
@di.inject
def validate(self, data, command_types: Annotated[dict[str, list[str]], "PollerChoices.command_types"]):
models.Poller.validate_commands(data["commands"], command_types, data["connection_type"])
return super().validate(data)


Expand Down
25 changes: 7 additions & 18 deletions validity/choices.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Optional, TypeVar
from typing import Optional, TypeVar

from django.db.models import IntegerChoices, TextChoices
from django.db.models.enums import ChoicesMeta
Expand Down Expand Up @@ -39,9 +39,13 @@ def colors(self):

class MemberMixin:
@classmethod
def member(cls: type[_Type], value: Any) -> Optional[_Type]:
def member(cls: type[_Type], value: str) -> Optional[_Type]:
return cls._value2member_map_.get(value) # type: ignore

@classmethod
def contains(cls, value: str) -> bool:
return value in cls._value2member_map_


class BoolOperationChoices(TextChoices, metaclass=ColoredChoiceMeta):
OR = "OR", _("OR"), "purple"
Expand Down Expand Up @@ -94,10 +98,6 @@ class DeviceGroupByChoices(MemberMixin, TextChoices):
SITE = "device__site__slug", _("Site")
TEST = "test__name", _("Test")

@classmethod
def contains(cls, value: str) -> bool:
return value in cls._value2member_map_

def viewname(self) -> str:
view_prefixes = {self.TENANT: "tenancy:", self.TEST: "plugins:validity:compliance"}
default_prefix = "dcim:"
Expand All @@ -109,22 +109,11 @@ def pk_field(self):
return "__".join(pk_path)


class ConnectionTypeChoices(TextChoices, metaclass=ColoredChoiceMeta):
netmiko = "netmiko", "netmiko", "blue"
requests = "requests", "requests", "info"
scrapli_netconf = "scrapli_netconf", "scrapli_netconf", "orange"

__command_types__ = {"netmiko": "CLI", "scrapli_netconf": "netconf", "requests": "json_api"}

@property
def acceptable_command_type(self) -> "CommandTypeChoices":
return CommandTypeChoices[self.__command_types__[self.name]]


class CommandTypeChoices(TextChoices, metaclass=ColoredChoiceMeta):
CLI = "CLI", "CLI", "blue"
netconf = "netconf", "orange"
json_api = "json_api", "JSON API", "info"
custom = "custom", _("Custom"), "gray"


class ExplanationVerbosityChoices(IntegerChoices):
Expand Down
23 changes: 15 additions & 8 deletions validity/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
from rq.job import Job

from validity import di
from validity.choices import ConnectionTypeChoices
from validity.pollers import NetmikoPoller, RequestsPoller, ScrapliNetconfPoller
from validity.settings import ValiditySettings
from validity.settings import PollerInfo, ValiditySettings
from validity.utils.misc import null_request


Expand All @@ -25,12 +24,20 @@ def validity_settings(django_settings: Annotated[LazySettings, django_settings])


@di.dependency(scope=Singleton)
def poller_map():
return {
ConnectionTypeChoices.netmiko: NetmikoPoller,
ConnectionTypeChoices.requests: RequestsPoller,
ConnectionTypeChoices.scrapli_netconf: ScrapliNetconfPoller,
}
def pollers_info(custom_pollers: Annotated[list[PollerInfo], "validity_settings.custom_pollers"]) -> list[PollerInfo]:
return [
PollerInfo(klass=NetmikoPoller, name="netmiko", verbose_name="netmiko", color="blue", command_types=["CLI"]),
PollerInfo(
klass=RequestsPoller, name="requests", verbose_name="requests", color="info", command_types=["json_api"]
),
PollerInfo(
klass=ScrapliNetconfPoller,
name="scrapli_netconf",
verbose_name="scrapli_netconf",
color="orange",
command_types=["netconf"],
),
] + custom_pollers


import validity.pollers.factory # noqa
Expand Down
12 changes: 6 additions & 6 deletions validity/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, JSONField

from validity import choices, models
from validity import choices, di, models
from validity.api.helpers import SubformValidationMixin
from ..utils.misc import LazyIterator
from .mixins import PollerCleanMixin


Expand Down Expand Up @@ -77,7 +78,7 @@ def __init__(self, *args, headers=None, **kwargs):
self.base_fields = base_fields
super().__init__(*args, headers=headers, **kwargs)

def save(self, commit=True) -> choices.Any:
def save(self, commit=True):
if (_global := self.cleaned_data.get("global")) is not None:
self.instance._global = _global
return super().save(commit)
Expand Down Expand Up @@ -186,7 +187,9 @@ class Meta:


class PollerImportForm(PollerCleanMixin, NetBoxModelImportForm):
connection_type = CSVChoiceField(choices=choices.ConnectionTypeChoices.choices, help_text=_("Connection Type"))
connection_type = CSVChoiceField(
choices=LazyIterator(lambda: di["PollerChoices"].choices), help_text=_("Connection Type")
)
commands = CSVModelMultipleChoiceField(
queryset=models.Command.objects.all(),
to_field_name="label",
Expand All @@ -201,9 +204,6 @@ class PollerImportForm(PollerCleanMixin, NetBoxModelImportForm):
required=False,
)

def full_clean(self) -> None:
return super().full_clean()

class Meta:
model = models.Poller
fields = ("name", "connection_type", "commands", "public_credentials", "private_credentials")
6 changes: 3 additions & 3 deletions validity/forms/filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@
from utilities.forms.fields import DynamicModelMultipleChoiceField
from utilities.forms.widgets import DateTimePicker

from validity import models
from validity import di, models
from validity.choices import (
BoolOperationChoices,
CommandTypeChoices,
ConnectionTypeChoices,
DeviceGroupByChoices,
DynamicPairsChoices,
ExtractionMethodChoices,
SeverityChoices,
)
from validity.netbox_changes import FieldSet
from validity.utils.misc import LazyIterator
from .fields import PlaceholderChoiceField
from .mixins import AddM2MPlaceholderFormMixin, ExcludeMixin

Expand Down Expand Up @@ -175,7 +175,7 @@ class PollerFilterForm(NetBoxModelFilterSetForm):
model = models.Poller
name = CharField(required=False)
connection_type = PlaceholderChoiceField(
required=False, label=_("Connection Type"), choices=ConnectionTypeChoices.choices
required=False, label=_("Connection Type"), choices=LazyIterator(lambda: di["PollerChoices"].choices)
)


Expand Down
9 changes: 5 additions & 4 deletions validity/forms/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
from extras.models import Tag
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import HTMXSelect

from validity import models
from validity.choices import ConnectionTypeChoices, ExplanationVerbosityChoices
from validity import di, models
from validity.choices import ExplanationVerbosityChoices
from validity.netbox_changes import FieldSet
from validity.utils.misc import LazyIterator
from .fields import DynamicModelChoicePropertyField, DynamicModelMultipleChoicePropertyField
from .mixins import PollerCleanMixin, SubformMixin
from .widgets import PrettyJSONWidget
Expand Down Expand Up @@ -137,7 +137,8 @@ class Meta:

class PollerForm(PollerCleanMixin, NetBoxModelForm):
connection_type = ChoiceField(
choices=add_blank_choice(ConnectionTypeChoices.choices), widget=Select(attrs={"id": "connection_type_select"})
choices=LazyIterator([(None, "---------")], lambda: di["PollerChoices"].choices),
widget=Select(attrs={"id": "connection_type_select"}),
)
commands = DynamicModelMultipleChoiceField(queryset=models.Command.objects.all())

Expand Down
9 changes: 5 additions & 4 deletions validity/forms/mixins.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import json
from typing import Literal, Sequence
from typing import Annotated, Literal, Sequence

from utilities.forms import get_field_value
from utilities.forms.fields import DynamicModelMultipleChoiceField

from validity.models import Poller
from validity import di, models
from validity.netbox_changes import FieldSet


Expand All @@ -26,9 +26,10 @@ def __init__(self, *args, exclude: Sequence[str] = (), **kwargs) -> None:


class PollerCleanMixin:
def clean(self):
@di.inject
def clean(self, command_types: Annotated[dict[str, list[str]], "PollerChoices.command_types"]):
connection_type = self.cleaned_data.get("connection_type") or get_field_value(self, "connection_type")
Poller.validate_commands(connection_type, self.cleaned_data.get("commands", []))
models.Poller.validate_commands(self.cleaned_data.get("commands", []), command_types, connection_type)
return super().clean()


Expand Down
34 changes: 34 additions & 0 deletions validity/model_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import TYPE_CHECKING, Collection

from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _


if TYPE_CHECKING:
from validity.models import Command


def only_one_config_command(commands: Collection["Command"]) -> None:
config_commands_count = sum(1 for cmd in commands if cmd.retrieves_config)
if config_commands_count > 1:
raise ValidationError(
{
"commands": _("No more than 1 command to retrieve config is allowed, but %(cnt)s were specified")
% {"cnt": config_commands_count}
}
)


def commands_with_appropriate_type(
commands: Collection["Command"], command_types: dict[str, list[str]], connection_type: str
):
acceptable_command_types = command_types.get(connection_type, [])
if invalid_cmds := [cmd.label for cmd in commands if cmd.type not in acceptable_command_types]:
raise ValidationError(
{
"commands": _(
"The following commands have inappropriate type and cannot be bound to this Poller: %(cmds)s"
)
% {"cmds": ", ".join(label for label in invalid_cmds)}
}
)
Loading

0 comments on commit 69361dd

Please sign in to comment.