Skip to content
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

Merged
merged 42 commits into from
Apr 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
ada0b89
Add jmespath dependency
chipn Feb 4, 2021
a47c0d8
Initial commit of validator classes
chipn Feb 3, 2021
796e597
Initial commit of validator tests
chipn Feb 3, 2021
52e6a2a
Support compiled jmespath expr for rhs
chipn Feb 5, 2021
19a4d60
Add ValidationResult class to context
chipn Feb 5, 2021
08afcf3
Add ansible example for validator plugins
chipn Feb 8, 2021
3511724
Exclude plugin examples from linting
chipn Feb 8, 2021
691c899
Integrate validator plugin support
chipn Feb 8, 2021
674a119
Update tests for validator plugin integration
chipn Feb 8, 2021
6e98b64
Fix pylint and yamllint errors
chipn Feb 8, 2021
63239c4
Fix pydocstyle errors
chipn Feb 8, 2021
65fc216
Update pytest task
chipn Feb 8, 2021
5407340
Rename validator dir config option
chipn Feb 9, 2021
6f08f68
Refactor to use importlib for validators
chipn Feb 8, 2021
483c539
Implement schema id check with default
chipn Feb 9, 2021
993e143
Initial commit of custom validator doc
chipn Feb 9, 2021
9ea75e3
Update test validators
chipn Feb 9, 2021
9b5a052
Add example mapping to validator doc
chipn Feb 9, 2021
5de08e0
Update pylint disable to use rule names
chipn Feb 9, 2021
c9d8b19
Add subclass to jmespathmodel
chipn Feb 9, 2021
ad5e5c6
Update operator to match comments
chipn Feb 10, 2021
3c961ee
Disable pylint rule at file level
chipn Feb 10, 2021
a4b57a5
Update tests and comments
chipn Feb 10, 2021
f3a4868
Add example of jmespathvalidation with compile
chipn Feb 10, 2021
856102b
Add additional test for jmespath compile
chipn Feb 10, 2021
5427077
Remove unused f-string
chipn Feb 12, 2021
db5109c
Refactor base classes for common use
chipn Feb 25, 2021
1459cc0
Disable pylint rule
chipn Feb 25, 2021
0a120a9
Refactor jsonschema to use BaseValidation class
chipn Feb 25, 2021
67c0cff
Refactor ansible cli for BaseValidation class
chipn Feb 25, 2021
220590b
Fix pydocstyle errors
chipn Feb 25, 2021
afc47b6
Update documentation for new base class usage
chipn Feb 25, 2021
ad31827
Update example validator
chipn Feb 25, 2021
0d5c573
Exclude examples dir from pylint due to dup-code
chipn Feb 25, 2021
e1dbdf9
Revert to class vars for jmespathmodelvalidation
chipn Feb 26, 2021
99eef97
Increase pylint min-similarity-lines
chipn Feb 26, 2021
abaf0a7
Revert exclude of examples for pylint
chipn Feb 26, 2021
622feea
Add link to custom validator doc in README
chipn Feb 26, 2021
01297dc
Update validate doc string
chipn Mar 1, 2021
7d557ef
Fix unaccepted change from rebase
chipn Mar 2, 2021
e264591
Update type annotations
chipn Mar 3, 2021
8e1dab7
Update duplicate validator error
chipn Mar 3, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ To run the schema validations, the command `schema-enforcer validate` can be run

```shell
bash$ schema-enforcer validate
schema-enforcer validate
schema-enforcer validate
ALL SCHEMA VALIDATION CHECKS PASSED
```

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

```yaml
bash$ cat chi-beijing-rt1/dns.yml
# jsonschema: schemas/dns_servers
# jsonschema: schemas/dns_servers
---
dns_servers:
- address: true
- address: "10.2.2.2"
```
```shell
bash$ test-schema validate
bash$ test-schema validate
FAIL | [ERROR] True is not of type 'string' [FILE] ./chi-beijing-rt1/dns.yml [PROPERTY] dns_servers:0:address
bash$ echo $?
1
Expand All @@ -160,7 +160,7 @@ When a structured data file fails schema validation, `schema-enforcer` exits wit
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.

```shell
bash$ cd examples/example2 && tree -L 2
bash$ cd examples/example2 && tree -L 2
.
├── README.md
├── hostvars
Expand Down Expand Up @@ -198,3 +198,4 @@ Detailed documentation can be found in the README.md files inside of the `docs/`
- [The `validate` command](docs/validate_command.md)
- [Mapping Structured Data Files to Schema Files](docs/mapping_schemas.md)
- [The `schema` command](docs/schema_command.md)
- [Implementing custom validators](docs/custom_validators.md)
158 changes: 158 additions & 0 deletions docs/custom_validators.md
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
Copy link
Contributor

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pinging myself to review

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
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option is lhs (left-hand side) and rhs (right-hand side).

Copy link
Contributor

Choose a reason for hiding this comment

The 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"
```
22 changes: 22 additions & 0 deletions examples/ansible3/host_vars/az_phx_pe01/base.yml
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"
22 changes: 22 additions & 0 deletions examples/ansible3/host_vars/az_phx_pe02/base.yml
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"
15 changes: 15 additions & 0 deletions examples/ansible3/inventory.yml
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"
2 changes: 2 additions & 0 deletions examples/ansible3/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tool.schema_enforcer]
ansible_inventory = "inventory.yml"
13 changes: 13 additions & 0 deletions examples/ansible3/validators/check_interfaces.py
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."""

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think pydocstyle requires no newline here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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"
16 changes: 15 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jsonref = "^0.2"
pydantic = "^1.6.1"
rich = "^9.5.1"
ansible = "^2.8.0"
jmespath = "^0.10.0"

[tool.poetry.dev-dependencies]
pytest = "^5.4.1"
Expand Down Expand Up @@ -75,6 +76,9 @@ notes = """,
XXX,
"""

[tool.pylint.SIMILARITIES]
min-similarity-lines = 15

[tool.pytest.ini_options]
testpaths = [
"tests"
Expand Down
4 changes: 3 additions & 1 deletion schema_enforcer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please can you make the same changes to the validate command , otherwise validators won't work for this command

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:

for schema_id, schema in schema_manager.iter_schemas():
if schema_id not in self.matches:
continue
schema.validate(self.get_content(), strict)
results = schema.get_results()
errs = itertools.chain(errs, results)
schema.clear_results()


for result in schema_obj.get_results():
result.instance_type = "HOST"
result.instance_hostname = host.name

Expand All @@ -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"))
Expand Down
1 change: 1 addition & 0 deletions schema_enforcer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Settings(BaseSettings): # pylint: disable=too-few-public-methods
main_directory: str = "schema"
definition_directory: str = "definitions"
schema_directory: str = "schemas"
validator_directory: str = "validators"
test_directory: str = "tests"

# Settings specific to the schema files
Expand Down
5 changes: 4 additions & 1 deletion schema_enforcer/instances/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ def validate(self, schema_manager, strict=False):
for schema_id, schema in schema_manager.iter_schemas():
if schema_id not in self.matches:
continue
errs = itertools.chain(errs, schema.validate(self.get_content(), strict))
schema.validate(self.get_content(), strict)
results = schema.get_results()
errs = itertools.chain(errs, results)
schema.clear_results()

return errs
Loading