-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Business logic validator plugin implementation #84
Changes from all commits
ada0b89
a47c0d8
796e597
52e6a2a
19a4d60
08afcf3
3511724
691c899
674a119
6e98b64
63239c4
65fc216
5407340
6f08f68
483c539
993e143
9ea75e3
9b5a052
5de08e0
c9d8b19
ad5e5c6
3c961ee
a4b57a5
f3a4868
856102b
5427077
db5109c
1459cc0
0a120a9
67c0cff
220590b
afc47b6
ad31827
0d5c573
e1dbdf9
99eef97
abaf0a7
622feea
01297dc
7d557ef
e264591
8e1dab7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
# Implementing custom validators | ||
|
||
With custom validators, you can implement business logic in Python. Schema-enforcer will automatically | ||
load your plugins from the `validator_directory` and run them against your host data. | ||
|
||
The validator plugin provides two base classes: ModelValidation and JmesPathModelValidation. The former can be used | ||
when you want to implement all logic and the latter can be used as a shortcut for jmespath validation. | ||
|
||
## BaseValidation | ||
|
||
Use this class to implement arbitrary validation logic in Python. In order to work correctly, your Python script must meet | ||
the following criteria: | ||
|
||
1. Exist in the `validator_directory` dir. | ||
2. Include a subclass of the BaseValidation class to correctly register with schema-enforcer. | ||
3. Ensure you call `super().__init__()` in your class `__init__` if you override. | ||
4. Provide a class method in your subclass with the following signature: | ||
`def validate(data: dict, strict: bool):` | ||
|
||
* Data is a dictionary of variables on a per-host basis. | ||
* Strict is set to true when the strict flag is set via the CLI. You can use this to offer strict validation behavior | ||
or ignore it if not needed. | ||
|
||
The name of your class will be used as the schema-id for mapping purposes. You can override the default schema ID | ||
by providing a class-level `id` variable. | ||
|
||
Helper functions are provided to add pass/fail results: | ||
|
||
``` | ||
def add_validation_error(self, message: str, **kwargs): | ||
"""Add validator error to results. | ||
Args: | ||
message (str): error message | ||
kwargs (optional): additional arguments to add to ValidationResult when required | ||
""" | ||
|
||
def add_validation_pass(self, **kwargs): | ||
"""Add validator pass to results. | ||
Args: | ||
kwargs (optional): additional arguments to add to ValidationResult when required | ||
""" | ||
``` | ||
In most cases, you will not need to provide kwargs. However, if you find a use case that requires updating other fields | ||
in the ValidationResult, you can send the key/value pairs to update the result directly. This is for advanced users only. | ||
|
||
## JmesPathModelValidation | ||
|
||
Use this class for basic validation using [jmespath](https://jmespath.org/) expressions to query specific values in your data. In order to work correctly, your Python script must meet | ||
the following criteria: | ||
|
||
1. Exist in the `validator_directory` dir. | ||
2. Include a subclass of the JmesPathModelValidation class to correctly register with schema-enforcer. | ||
3. Provide the following class level variables: | ||
|
||
* `top_level_properties`: Field for mapping of validator to data | ||
* `id`: Schema ID to use for reporting purposes (optional - defaults to class name) | ||
* `left`: Jmespath expression to query your host data | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am the one who indicated left/right, but I did so out of lack of imagination. Just want to make sure we are all good with the name here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another option is lhs (left-hand side) and rhs (right-hand side). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. left / right sounds good to me |
||
* `right`: Value or a compiled jmespath expression | ||
* `operator`: Operator to use for comparison between left and right hand side of expression | ||
* `error`: Message to report when validation fails | ||
|
||
### Supported operators: | ||
|
||
The class provides the following operators for basic use cases: | ||
|
||
``` | ||
"gt": int(left) > int(right), | ||
"gte": int(left) >= int(right), | ||
"eq": left == right, | ||
"lt": int(left) < int(right), | ||
"lte": int(left) <= int(right), | ||
"contains": right in left, | ||
``` | ||
|
||
If you require additional logic or need to compare other types, use the BaseValidation class and create your own validate method. | ||
|
||
### Examples: | ||
|
||
#### Basic | ||
``` | ||
from schema_enforcer.schemas.validator import JmesPathModelValidation | ||
|
||
class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods | ||
top_level_properties = ["interfaces"] | ||
id = "CheckInterface" # pylint: disable=invalid-name | ||
left = "interfaces.*[@.type=='core'][] | length([?@])" | ||
right = 2 | ||
operator = "gte" | ||
error = "Less than two core interfaces" | ||
``` | ||
chipn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
#### With compiled jmespath expression | ||
``` | ||
import jmespath | ||
from schema_enforcer.schemas.validator import JmesPathModelValidation | ||
|
||
|
||
class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-public-methods | ||
top_level_properties = ["interfaces"] | ||
id = "CheckInterfaceIPv4" # pylint: disable=invalid-name | ||
left = "interfaces.*[@.type=='core'][] | length([?@])" | ||
right = jmespath.compile("interfaces.* | length([[email protected]=='core'][].ipv4)") | ||
operator = "eq" | ||
error = "All core interfaces do not have IPv4 addresses" | ||
``` | ||
|
||
## Running validators | ||
|
||
Custom validators are run with `schema-enforcer validate` and `schema-enforcer ansible` commands. | ||
|
||
You map validators to keys in your data with `top_level_properties` in your subclass or with `schema_enforcer_schema_ids` | ||
in your data. Schema-enforcer uses the same process to map custom validators and schemas. Refer to the "Mapping Schemas" documentation | ||
for more details. | ||
chipn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
### Example - top_level_properties | ||
|
||
The CheckInterface validator has a top_level_properties of "interfaces": | ||
|
||
``` | ||
class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods | ||
top_level_properties = ["interfaces"] | ||
``` | ||
|
||
With automapping enabled, this validator will apply to any host with a top-level `interfaces` key in the Ansible host_vars data: | ||
|
||
``` | ||
--- | ||
hostname: "az-phx-pe01" | ||
pair_rtr: "az-phx-pe02" | ||
interfaces: | ||
MgmtEth0/0/CPU0/0: | ||
ipv4: "172.16.1.1" | ||
Loopback0: | ||
ipv4: "192.168.1.1" | ||
ipv6: "2001:db8:1::1" | ||
GigabitEthernet0/0/0/0: | ||
ipv4: "10.1.0.1" | ||
ipv6: "2001:db8::" | ||
peer: "az-phx-pe02" | ||
peer_int: "GigabitEthernet0/0/0/0" | ||
type: "core" | ||
GigabitEthernet0/0/0/1: | ||
ipv4: "10.1.0.37" | ||
ipv6: "2001:db8::12" | ||
peer: "co-den-p01" | ||
peer_int: "GigabitEthernet0/0/0/2" | ||
type: "core" | ||
``` | ||
|
||
### Example - manual mapping | ||
|
||
Alternatively, you can manually map a validator in your Ansible host vars or other data files. | ||
|
||
``` | ||
schema_enforcer_automap_default: false | ||
schema_enforcer_schema_ids: | ||
- "CheckInterface" | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
--- | ||
hostname: "az-phx-pe01" | ||
pair_rtr: "az-phx-pe02" | ||
upstreams: [] | ||
interfaces: | ||
MgmtEth0/0/CPU0/0: | ||
ipv4: "172.16.1.1" | ||
Loopback0: | ||
ipv4: "192.168.1.1" | ||
ipv6: "2001:db8:1::1" | ||
GigabitEthernet0/0/0/0: | ||
ipv4: "10.1.0.1" | ||
ipv6: "2001:db8::" | ||
peer: "az-phx-pe02" | ||
peer_int: "GigabitEthernet0/0/0/0" | ||
type: "core" | ||
GigabitEthernet0/0/0/1: | ||
ipv4: "10.1.0.37" | ||
ipv6: "2001:db8::12" | ||
peer: "co-den-p01" | ||
peer_int: "GigabitEthernet0/0/0/2" | ||
type: "core" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
--- | ||
hostname: "az-phx-pe02" | ||
pair_rtr: "az-phx-pe01" | ||
upstreams: [] | ||
interfaces: | ||
MgmtEth0/0/CPU0/0: | ||
ipv4: "172.16.1.2" | ||
Loopback0: | ||
ipv4: "192.168.1.2" | ||
ipv6: "2001:db8:1::2" | ||
GigabitEthernet0/0/0/0: | ||
ipv4: "10.1.0.2" | ||
ipv6: "2001:db8::1" | ||
peer: "az-phx-pe01" | ||
peer_int: "GigabitEthernet0/0/0/0" | ||
type: "core" | ||
GigabitEthernet0/0/0/1: | ||
ipv4: "10.1.0.41" | ||
ipv6: "2001:db8::14" | ||
peer: "co-den-p02" | ||
peer_int: "GigabitEthernet0/0/0/2" | ||
type: "access" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
--- | ||
all: | ||
vars: | ||
ansible_network_os: "iosxr" | ||
ansible_user: "cisco" | ||
ansible_password: "cisco" | ||
ansible_connection: "netconf" | ||
ansible_netconf_ssh_config: true | ||
children: | ||
pe_rtrs: | ||
hosts: | ||
az_phx_pe01: | ||
ansible_host: "172.16.1.1" | ||
az_phx_pe02: | ||
ansible_host: "172.16.1.2" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
[tool.schema_enforcer] | ||
ansible_inventory = "inventory.yml" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
"""Example validator plugin.""" | ||
from schema_enforcer.schemas.validator import JmesPathModelValidation | ||
|
||
|
||
class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods | ||
"""Check that each device has more than one core uplink.""" | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think pydocstyle requires no newline here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No newline after the comment? I vaguely remember that pydocstyle complained when it isn't in a single line or you miss the period at the end. This validator is also in the test directory and pydocstyle is passing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ack. That's interesting -- I've seen pydocstyle flag when a function has space between the docstring and the beginning of the logic executed by the function. It may just be on multiline docstrings though.. IDK. If pydocstyle isn't complaining I'm good :). |
||
top_level_properties = ["interfaces"] | ||
id = "CheckInterface" # pylint: disable=invalid-name | ||
left = "interfaces.*[@.type=='core'][] | length([?@])" | ||
right = 2 | ||
operator = "gte" | ||
error = "Less than two core interfaces" |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -303,8 +303,9 @@ def ansible( | |||||||||||||||
data = hostvars | ||||||||||||||||
|
||||||||||||||||
# Validate host vars against schema | ||||||||||||||||
for result in schema_obj.validate(data=data, strict=strict): | ||||||||||||||||
schema_obj.validate(data=data, strict=strict) | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please can you make the same changes to the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The validate CLI command calls validate on the InstanceFile object. The InstanceFile validate method was refactored for the new approach: schema-enforcer/schema_enforcer/instances/file.py Lines 128 to 134 in 7d557ef
|
||||||||||||||||
|
||||||||||||||||
for result in schema_obj.get_results(): | ||||||||||||||||
result.instance_type = "HOST" | ||||||||||||||||
result.instance_hostname = host.name | ||||||||||||||||
|
||||||||||||||||
|
@@ -314,6 +315,7 @@ def ansible( | |||||||||||||||
|
||||||||||||||||
elif result.passed() and show_pass: | ||||||||||||||||
result.print() | ||||||||||||||||
schema_obj.clear_results() | ||||||||||||||||
|
||||||||||||||||
if not error_exists: | ||||||||||||||||
print(colored("ALL SCHEMA VALIDATION CHECKS PASSED", "green")) | ||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An intro of what's meant by "business logic" might be valuable here to set proper context?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pinging myself to review