Skip to content

Commit d165177

Browse files
authored
Merge pull request #84 from networktocode/plugin
Business logic validator plugin implementation
2 parents 8b81d09 + 8e1dab7 commit d165177

File tree

25 files changed

+705
-19
lines changed

25 files changed

+705
-19
lines changed

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ To run the schema validations, the command `schema-enforcer validate` can be run
121121

122122
```shell
123123
bash$ schema-enforcer validate
124-
schema-enforcer validate
124+
schema-enforcer validate
125125
ALL SCHEMA VALIDATION CHECKS PASSED
126126
```
127127

@@ -140,14 +140,14 @@ If we modify one of the addresses in the `chi-beijing-rt1/dns.yml` file so that
140140

141141
```yaml
142142
bash$ cat chi-beijing-rt1/dns.yml
143-
# jsonschema: schemas/dns_servers
143+
# jsonschema: schemas/dns_servers
144144
---
145145
dns_servers:
146146
- address: true
147147
- address: "10.2.2.2"
148148
```
149149
```shell
150-
bash$ test-schema validate
150+
bash$ test-schema validate
151151
FAIL | [ERROR] True is not of type 'string' [FILE] ./chi-beijing-rt1/dns.yml [PROPERTY] dns_servers:0:address
152152
bash$ echo $?
153153
1
@@ -160,7 +160,7 @@ When a structured data file fails schema validation, `schema-enforcer` exits wit
160160
Schema enforcer will work with default settings, however, a `pyproject.toml` file can be placed at the root of the path in which `schema-enforcer` is run in order to override default settings or declare configuration for more advanced features. Inside of this `pyproject.toml` file, `tool.schema_enfocer` sections can be used to declare settings for schema enforcer. Take for example the `pyproject.toml` file in example 2.
161161

162162
```shell
163-
bash$ cd examples/example2 && tree -L 2
163+
bash$ cd examples/example2 && tree -L 2
164164
.
165165
├── README.md
166166
├── hostvars
@@ -198,3 +198,4 @@ Detailed documentation can be found in the README.md files inside of the `docs/`
198198
- [The `validate` command](docs/validate_command.md)
199199
- [Mapping Structured Data Files to Schema Files](docs/mapping_schemas.md)
200200
- [The `schema` command](docs/schema_command.md)
201+
- [Implementing custom validators](docs/custom_validators.md)

docs/custom_validators.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Implementing custom validators
2+
3+
With custom validators, you can implement business logic in Python. Schema-enforcer will automatically
4+
load your plugins from the `validator_directory` and run them against your host data.
5+
6+
The validator plugin provides two base classes: ModelValidation and JmesPathModelValidation. The former can be used
7+
when you want to implement all logic and the latter can be used as a shortcut for jmespath validation.
8+
9+
## BaseValidation
10+
11+
Use this class to implement arbitrary validation logic in Python. In order to work correctly, your Python script must meet
12+
the following criteria:
13+
14+
1. Exist in the `validator_directory` dir.
15+
2. Include a subclass of the BaseValidation class to correctly register with schema-enforcer.
16+
3. Ensure you call `super().__init__()` in your class `__init__` if you override.
17+
4. Provide a class method in your subclass with the following signature:
18+
`def validate(data: dict, strict: bool):`
19+
20+
* Data is a dictionary of variables on a per-host basis.
21+
* Strict is set to true when the strict flag is set via the CLI. You can use this to offer strict validation behavior
22+
or ignore it if not needed.
23+
24+
The name of your class will be used as the schema-id for mapping purposes. You can override the default schema ID
25+
by providing a class-level `id` variable.
26+
27+
Helper functions are provided to add pass/fail results:
28+
29+
```
30+
def add_validation_error(self, message: str, **kwargs):
31+
"""Add validator error to results.
32+
Args:
33+
message (str): error message
34+
kwargs (optional): additional arguments to add to ValidationResult when required
35+
"""
36+
37+
def add_validation_pass(self, **kwargs):
38+
"""Add validator pass to results.
39+
Args:
40+
kwargs (optional): additional arguments to add to ValidationResult when required
41+
"""
42+
```
43+
In most cases, you will not need to provide kwargs. However, if you find a use case that requires updating other fields
44+
in the ValidationResult, you can send the key/value pairs to update the result directly. This is for advanced users only.
45+
46+
## JmesPathModelValidation
47+
48+
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
49+
the following criteria:
50+
51+
1. Exist in the `validator_directory` dir.
52+
2. Include a subclass of the JmesPathModelValidation class to correctly register with schema-enforcer.
53+
3. Provide the following class level variables:
54+
55+
* `top_level_properties`: Field for mapping of validator to data
56+
* `id`: Schema ID to use for reporting purposes (optional - defaults to class name)
57+
* `left`: Jmespath expression to query your host data
58+
* `right`: Value or a compiled jmespath expression
59+
* `operator`: Operator to use for comparison between left and right hand side of expression
60+
* `error`: Message to report when validation fails
61+
62+
### Supported operators:
63+
64+
The class provides the following operators for basic use cases:
65+
66+
```
67+
"gt": int(left) > int(right),
68+
"gte": int(left) >= int(right),
69+
"eq": left == right,
70+
"lt": int(left) < int(right),
71+
"lte": int(left) <= int(right),
72+
"contains": right in left,
73+
```
74+
75+
If you require additional logic or need to compare other types, use the BaseValidation class and create your own validate method.
76+
77+
### Examples:
78+
79+
#### Basic
80+
```
81+
from schema_enforcer.schemas.validator import JmesPathModelValidation
82+
83+
class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods
84+
top_level_properties = ["interfaces"]
85+
id = "CheckInterface" # pylint: disable=invalid-name
86+
left = "interfaces.*[@.type=='core'][] | length([?@])"
87+
right = 2
88+
operator = "gte"
89+
error = "Less than two core interfaces"
90+
```
91+
92+
#### With compiled jmespath expression
93+
```
94+
import jmespath
95+
from schema_enforcer.schemas.validator import JmesPathModelValidation
96+
97+
98+
class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-public-methods
99+
top_level_properties = ["interfaces"]
100+
id = "CheckInterfaceIPv4" # pylint: disable=invalid-name
101+
left = "interfaces.*[@.type=='core'][] | length([?@])"
102+
right = jmespath.compile("interfaces.* | length([[email protected]=='core'][].ipv4)")
103+
operator = "eq"
104+
error = "All core interfaces do not have IPv4 addresses"
105+
```
106+
107+
## Running validators
108+
109+
Custom validators are run with `schema-enforcer validate` and `schema-enforcer ansible` commands.
110+
111+
You map validators to keys in your data with `top_level_properties` in your subclass or with `schema_enforcer_schema_ids`
112+
in your data. Schema-enforcer uses the same process to map custom validators and schemas. Refer to the "Mapping Schemas" documentation
113+
for more details.
114+
115+
### Example - top_level_properties
116+
117+
The CheckInterface validator has a top_level_properties of "interfaces":
118+
119+
```
120+
class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods
121+
top_level_properties = ["interfaces"]
122+
```
123+
124+
With automapping enabled, this validator will apply to any host with a top-level `interfaces` key in the Ansible host_vars data:
125+
126+
```
127+
---
128+
hostname: "az-phx-pe01"
129+
pair_rtr: "az-phx-pe02"
130+
interfaces:
131+
MgmtEth0/0/CPU0/0:
132+
ipv4: "172.16.1.1"
133+
Loopback0:
134+
ipv4: "192.168.1.1"
135+
ipv6: "2001:db8:1::1"
136+
GigabitEthernet0/0/0/0:
137+
ipv4: "10.1.0.1"
138+
ipv6: "2001:db8::"
139+
peer: "az-phx-pe02"
140+
peer_int: "GigabitEthernet0/0/0/0"
141+
type: "core"
142+
GigabitEthernet0/0/0/1:
143+
ipv4: "10.1.0.37"
144+
ipv6: "2001:db8::12"
145+
peer: "co-den-p01"
146+
peer_int: "GigabitEthernet0/0/0/2"
147+
type: "core"
148+
```
149+
150+
### Example - manual mapping
151+
152+
Alternatively, you can manually map a validator in your Ansible host vars or other data files.
153+
154+
```
155+
schema_enforcer_automap_default: false
156+
schema_enforcer_schema_ids:
157+
- "CheckInterface"
158+
```
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
hostname: "az-phx-pe01"
3+
pair_rtr: "az-phx-pe02"
4+
upstreams: []
5+
interfaces:
6+
MgmtEth0/0/CPU0/0:
7+
ipv4: "172.16.1.1"
8+
Loopback0:
9+
ipv4: "192.168.1.1"
10+
ipv6: "2001:db8:1::1"
11+
GigabitEthernet0/0/0/0:
12+
ipv4: "10.1.0.1"
13+
ipv6: "2001:db8::"
14+
peer: "az-phx-pe02"
15+
peer_int: "GigabitEthernet0/0/0/0"
16+
type: "core"
17+
GigabitEthernet0/0/0/1:
18+
ipv4: "10.1.0.37"
19+
ipv6: "2001:db8::12"
20+
peer: "co-den-p01"
21+
peer_int: "GigabitEthernet0/0/0/2"
22+
type: "core"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
hostname: "az-phx-pe02"
3+
pair_rtr: "az-phx-pe01"
4+
upstreams: []
5+
interfaces:
6+
MgmtEth0/0/CPU0/0:
7+
ipv4: "172.16.1.2"
8+
Loopback0:
9+
ipv4: "192.168.1.2"
10+
ipv6: "2001:db8:1::2"
11+
GigabitEthernet0/0/0/0:
12+
ipv4: "10.1.0.2"
13+
ipv6: "2001:db8::1"
14+
peer: "az-phx-pe01"
15+
peer_int: "GigabitEthernet0/0/0/0"
16+
type: "core"
17+
GigabitEthernet0/0/0/1:
18+
ipv4: "10.1.0.41"
19+
ipv6: "2001:db8::14"
20+
peer: "co-den-p02"
21+
peer_int: "GigabitEthernet0/0/0/2"
22+
type: "access"

examples/ansible3/inventory.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
all:
3+
vars:
4+
ansible_network_os: "iosxr"
5+
ansible_user: "cisco"
6+
ansible_password: "cisco"
7+
ansible_connection: "netconf"
8+
ansible_netconf_ssh_config: true
9+
children:
10+
pe_rtrs:
11+
hosts:
12+
az_phx_pe01:
13+
ansible_host: "172.16.1.1"
14+
az_phx_pe02:
15+
ansible_host: "172.16.1.2"

examples/ansible3/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tool.schema_enforcer]
2+
ansible_inventory = "inventory.yml"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Example validator plugin."""
2+
from schema_enforcer.schemas.validator import JmesPathModelValidation
3+
4+
5+
class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods
6+
"""Check that each device has more than one core uplink."""
7+
8+
top_level_properties = ["interfaces"]
9+
id = "CheckInterface" # pylint: disable=invalid-name
10+
left = "interfaces.*[@.type=='core'][] | length([?@])"
11+
right = 2
12+
operator = "gte"
13+
error = "Less than two core interfaces"

poetry.lock

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jsonref = "^0.2"
2525
pydantic = "^1.6.1"
2626
rich = "^9.5.1"
2727
ansible = "^2.8.0"
28+
jmespath = "^0.10.0"
2829

2930
[tool.poetry.dev-dependencies]
3031
pytest = "^5.4.1"
@@ -75,6 +76,9 @@ notes = """,
7576
XXX,
7677
"""
7778

79+
[tool.pylint.SIMILARITIES]
80+
min-similarity-lines = 15
81+
7882
[tool.pytest.ini_options]
7983
testpaths = [
8084
"tests"

schema_enforcer/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,9 @@ def ansible(
303303
data = hostvars
304304

305305
# Validate host vars against schema
306-
for result in schema_obj.validate(data=data, strict=strict):
306+
schema_obj.validate(data=data, strict=strict)
307307

308+
for result in schema_obj.get_results():
308309
result.instance_type = "HOST"
309310
result.instance_hostname = host.name
310311

@@ -314,6 +315,7 @@ def ansible(
314315

315316
elif result.passed() and show_pass:
316317
result.print()
318+
schema_obj.clear_results()
317319

318320
if not error_exists:
319321
print(colored("ALL SCHEMA VALIDATION CHECKS PASSED", "green"))

0 commit comments

Comments
 (0)