From ada0b8916d45268e37d80c66324b68f3775b4d94 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 4 Feb 2021 09:31:13 -0700 Subject: [PATCH 01/42] Add jmespath dependency --- poetry.lock | 16 +++++++++++++++- pyproject.toml | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 6962904..157e3a2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -281,6 +281,14 @@ MarkupSafe = ">=0.23" [package.extras] i18n = ["Babel (>=0.8)"] +[[package]] +name = "jmespath" +version = "0.10.0" +description = "JSON Matching Expressions" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "jsonref" version = "0.2" @@ -712,7 +720,7 @@ testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pyt [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "e3b938e5ec45670a319811698f9d448d070508b91b89ac1b758a509ddc118d96" +content-hash = "8aa94287267973c7c07897ccdcc66fe93c3e433adaeb03e970b78cdfaae0ec9f" [metadata.files] ansible = [ @@ -771,11 +779,13 @@ cffi = [ {file = "cffi-1.14.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb"}, {file = "cffi-1.14.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d"}, {file = "cffi-1.14.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03"}, + {file = "cffi-1.14.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01"}, {file = "cffi-1.14.4-cp37-cp37m-win32.whl", hash = "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e"}, {file = "cffi-1.14.4-cp37-cp37m-win_amd64.whl", hash = "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35"}, {file = "cffi-1.14.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d"}, {file = "cffi-1.14.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b"}, {file = "cffi-1.14.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53"}, + {file = "cffi-1.14.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e"}, {file = "cffi-1.14.4-cp38-cp38-win32.whl", hash = "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d"}, {file = "cffi-1.14.4-cp38-cp38-win_amd64.whl", hash = "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375"}, {file = "cffi-1.14.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909"}, @@ -900,6 +910,10 @@ jinja2 = [ {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, ] +jmespath = [ + {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, + {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, +] jsonref = [ {file = "jsonref-0.2-py3-none-any.whl", hash = "sha256:b1e82fa0b62e2c2796a13e5401fe51790b248f6d9bf9d7212a3e31a3501b291f"}, {file = "jsonref-0.2.tar.gz", hash = "sha256:f3c45b121cf6257eafabdc3a8008763aed1cd7da06dbabc59a9e4d2a5e4e6697"}, diff --git a/pyproject.toml b/pyproject.toml index d48b7cc..9d73892 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From a47c0d8aa38ae38dac038d7cecf08bb31fca99d4 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 3 Feb 2021 13:15:38 -0700 Subject: [PATCH 02/42] Initial commit of validator classes --- schema_enforcer/schemas/validator.py | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 schema_enforcer/schemas/validator.py diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py new file mode 100644 index 0000000..35b44a3 --- /dev/null +++ b/schema_enforcer/schemas/validator.py @@ -0,0 +1,61 @@ +from pathlib import Path +import jmespath + + +class ValidationError(Exception): + pass + + +class ModelValidation: + """Base class for custom ModelValidation classes. A singleton of each subclass will be stored in validators. """ + + validators = [] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.validators.append(cls()) + + +class JmesPathModelValidation: + """Base class for custom JmesPathModelValidation classes. A singleton of each subclass will be stored in validators. """ + + validators = [] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.validators.append(cls()) + + @classmethod + def validate(cls, data: dict): + operators = { + "gt": lambda r, v: int(r) > int(v), + "gte": lambda r, v: int(r) >= int(v), + "eq": lambda r, v: r == v, + "lt": lambda r, v: int(r) < int(v), + "lte": lambda r, v: int(r) <= int(v), + "contains": lambda r, v: v in r, + } + result = jmespath.search(cls.left, data) + valid = True + if result: + if isinstance(result, list): + result = result[0] + valid = operators[cls.operator](result, cls.right) + if not valid: + raise ValidationError + + +def load(validator_path: str): + # Make base class and helper functions available to validation plugins without import + context = { + "ModelValidation": ModelValidation, + "JmesPathModelValidation": JmesPathModelValidation, + "ValidationError": ValidationError, + "jmes": jmespath.compile, + } + + validator_path = Path(validator_path).expanduser().resolve() + for filename in validator_path.glob("*.py"): + source = open(filename).read() + code = compile(source, filename, "exec") + exec(code, context) From 796e597ea67ad5be42e6d57d17d66d7357c2318d Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 3 Feb 2021 13:15:51 -0700 Subject: [PATCH 03/42] Initial commit of validator tests --- schema_enforcer/schemas/validator.py | 13 ++++- .../inventory/group_vars/all.yml | 15 ++++++ .../inventory/host_vars/az_phx_pe01/base.yml | 26 +++++++++ .../inventory/host_vars/az_phx_pe01/mpls.yml | 6 +++ .../inventory/host_vars/az_phx_pe01/ospf.yml | 13 +++++ .../inventory/host_vars/az_phx_pe01/vrf.yml | 5 ++ .../inventory/host_vars/az_phx_pe02/base.yml | 26 +++++++++ .../inventory/host_vars/az_phx_pe02/mpls.yml | 6 +++ .../inventory/host_vars/az_phx_pe02/ospf.yml | 13 +++++ .../inventory/host_vars/az_phx_pe02/vrf.yml | 5 ++ .../test_validators/inventory/inventory.yml | 14 +++++ .../validators/check_interfaces.py | 5 ++ .../test_validators/validators/check_peers.py | 32 +++++++++++ tests/test_schemas_validator.py | 53 +++++++++++++++++++ 14 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/test_validators/inventory/group_vars/all.yml create mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/base.yml create mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/mpls.yml create mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/ospf.yml create mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/vrf.yml create mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/base.yml create mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/mpls.yml create mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/ospf.yml create mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/vrf.yml create mode 100644 tests/fixtures/test_validators/inventory/inventory.yml create mode 100644 tests/fixtures/test_validators/validators/check_interfaces.py create mode 100644 tests/fixtures/test_validators/validators/check_peers.py create mode 100644 tests/test_schemas_validator.py diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index 35b44a3..0089a01 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -1,3 +1,6 @@ +""" +Classes for custom validator plugins +""" from pathlib import Path import jmespath @@ -7,7 +10,7 @@ class ValidationError(Exception): class ModelValidation: - """Base class for custom ModelValidation classes. A singleton of each subclass will be stored in validators. """ + """Base class for ModelValidation classes. A singleton of each subclass will be stored in validators. """ validators = [] @@ -17,7 +20,7 @@ def __init_subclass__(cls, **kwargs): class JmesPathModelValidation: - """Base class for custom JmesPathModelValidation classes. A singleton of each subclass will be stored in validators. """ + """Base class for JmesPathModelValidation classes. A singleton of each subclass will be stored in validators. """ validators = [] @@ -27,6 +30,9 @@ def __init_subclass__(cls, **kwargs): @classmethod def validate(cls, data: dict): + """ + Validate data using custom jmespath validator plugin + """ operators = { "gt": lambda r, v: int(r) > int(v), "gte": lambda r, v: int(r) >= int(v), @@ -46,6 +52,9 @@ def validate(cls, data: dict): def load(validator_path: str): + """ + Load all validator plugins from validator_path + """ # Make base class and helper functions available to validation plugins without import context = { "ModelValidation": ModelValidation, diff --git a/tests/fixtures/test_validators/inventory/group_vars/all.yml b/tests/fixtures/test_validators/inventory/group_vars/all.yml new file mode 100644 index 0000000..f99276a --- /dev/null +++ b/tests/fixtures/test_validators/inventory/group_vars/all.yml @@ -0,0 +1,15 @@ +core_as: 64599 +core_loopback_network: 192.168.1.0 +core_loopback_wildcard: 0.0.0.255 +core_bgp_password: cisco +vrf_global: + Blue: + rd_index: 1 + dot1q: 100 + ipv4_net: 10.100.100.0/24 + ipv6_net: 2001:db8:100::/64 + Green: + rd_index: 2 + dot1q: 200 + ipv4_net: 10.100.200.0/24 + ipv6_net: 2001:db8:200::/64 diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/base.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/base.yml new file mode 100644 index 0000000..01aaca2 --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/base.yml @@ -0,0 +1,26 @@ +hostname: az-phx-pe01 +pair_rtr: az-phx-pe02 +upstreams: [] +interfaces: + MgmtEth0/0/CPU0/0: + ipv4: 172.16.1.1 + ipv6: + peer: + peer_int: + Loopback0: + ipv4: 192.168.1.1 + ipv6: 2001:db8:1::1 + peer: + peer_int: + 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" diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/mpls.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/mpls.yml new file mode 100644 index 0000000..8fa4066 --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/mpls.yml @@ -0,0 +1,6 @@ +mpls: + interfaces: + GigabitEthernet0/0/0/0: + peer: az-phx-pe02 + GigabitEthernet0/0/0/1: + peer: co-den-p01 diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/ospf.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/ospf.yml new file mode 100644 index 0000000..d9b9cc1 --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/ospf.yml @@ -0,0 +1,13 @@ +ospf: + areas: + - id: 0 + interfaces: + GigabitEthernet0/0/0/0: + peer: az-phx-pe02 + passive: 'false' + cost: 1 + GigabitEthernet0/0/0/1: + peer: co-den-p01 + passive: 'false' + cost: 1 + Loopback0: diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/vrf.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/vrf.yml new file mode 100644 index 0000000..d597f5f --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/vrf.yml @@ -0,0 +1,5 @@ +vrfs: +- name: Blue + interfaces: [] +- name: Green + interfaces: [] diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/base.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/base.yml new file mode 100644 index 0000000..f6277ba --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/base.yml @@ -0,0 +1,26 @@ +hostname: az-phx-pe02 +pair_rtr: az-phx-pe01 +upstreams: [] +interfaces: + MgmtEth0/0/CPU0/0: + ipv4: 172.16.1.2 + ipv6: + peer: + peer_int: + Loopback0: + ipv4: 192.168.1.2 + ipv6: 2001:db8:1::2 + peer: + peer_int: + 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" diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/mpls.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/mpls.yml new file mode 100644 index 0000000..fe32f78 --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/mpls.yml @@ -0,0 +1,6 @@ +mpls: + interfaces: + GigabitEthernet0/0/0/0: + peer: az-phx-pe01 + GigabitEthernet0/0/0/1: + peer: co-den-p02 diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/ospf.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/ospf.yml new file mode 100644 index 0000000..0ceef0a --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/ospf.yml @@ -0,0 +1,13 @@ +ospf: + areas: + - id: 0 + interfaces: + GigabitEthernet0/0/0/0: + peer: az-phx-pe01 + passive: 'false' + cost: 1 + GigabitEthernet0/0/0/1: + peer: co-den-p02 + passive: 'false' + cost: 1 + Loopback0: diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/vrf.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/vrf.yml new file mode 100644 index 0000000..d597f5f --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/vrf.yml @@ -0,0 +1,5 @@ +vrfs: +- name: Blue + interfaces: [] +- name: Green + interfaces: [] diff --git a/tests/fixtures/test_validators/inventory/inventory.yml b/tests/fixtures/test_validators/inventory/inventory.yml new file mode 100644 index 0000000..4ebb4c9 --- /dev/null +++ b/tests/fixtures/test_validators/inventory/inventory.yml @@ -0,0 +1,14 @@ +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 diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py new file mode 100644 index 0000000..6a9ad3f --- /dev/null +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -0,0 +1,5 @@ +class CheckInterface(JmesPathModelValidation): # noqa: F821 + model = "interfaces" + left = "interfaces.*[@.type=='core'][] | length([?@])" + right = 2 + operator = "eq" diff --git a/tests/fixtures/test_validators/validators/check_peers.py b/tests/fixtures/test_validators/validators/check_peers.py new file mode 100644 index 0000000..4cdd655 --- /dev/null +++ b/tests/fixtures/test_validators/validators/check_peers.py @@ -0,0 +1,32 @@ +def ansible_hostname(hostname: str): + return hostname.replace("-", "_") + + +def normal_hostname(hostname: str): + return hostname.replace("_", "-") + + +class CheckPeers(ModelValidation): + """ + Validate that peer and peer_int are defined properly on both sides of a connection + + full Ansible host_vars + """ + + def validate(cls, data: dict): + for host in data: + for interface, int_cfg in data[host]["interfaces"].items(): + peer = int_cfg.get("peer", None) + if peer: + peer_int = int_cfg.get("peer_int", None) + if peer_int: + peer = ansible_hostname(peer) + # Only validate if peer exists in data + if peer in data: + peer_match = data[peer]["interfaces"][peer_int]["peer"] == normal_hostname(host) + peer_int_match = data[peer]["interfaces"][peer_int]["peer_int"] == interface + if not (peer_match and peer_int_match): + raise ValidationError + # If peer is defined, peer_int must also exist + else: + raise ValidationError diff --git a/tests/test_schemas_validator.py b/tests/test_schemas_validator.py new file mode 100644 index 0000000..b0e03c7 --- /dev/null +++ b/tests/test_schemas_validator.py @@ -0,0 +1,53 @@ +import os +import pytest +from schema_enforcer.ansible_inventory import AnsibleInventory +import schema_enforcer.schemas.validator as v + +FIXTURE_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "fixtures", "test_validators") + + +@pytest.fixture +def inventory(): + """ Fixture for Ansible inventory used in tests """ + inventory_dir = os.path.join(FIXTURE_DIR, "inventory") + + inventory = AnsibleInventory(inventory_dir) # pylint: disable=redefined-outer-name + return inventory + + +@pytest.fixture +def host_vars(inventory): # pylint: disable=redefined-outer-name + """ Fixture for providing Ansible host_vars as a consolidated dict """ + hosts = inventory.get_hosts_containing() + host_vars = dict() # pylint: disable=redefined-outer-name + for host in hosts: + hostname = host.get_vars()["inventory_hostname"] + host_vars[hostname] = inventory.get_host_vars(host) + return host_vars + + +def test_load(): + """ + Test that validator files are loaded and appended to base class validator list + """ + validator_path = os.path.join(FIXTURE_DIR, "validators") + v.load(validator_path) + assert v.JmesPathModelValidation.validators + + +def test_jmespathvalidation_pass(host_vars): + validate = getattr(v.JmesPathModelValidation.validators[0], "validate") + validate(host_vars["az_phx_pe01"]) + assert True + + +def test_jmespathvalidation_fail(host_vars): + validate = getattr(v.JmesPathModelValidation.validators[0], "validate") + with pytest.raises(v.ValidationError): + validate(host_vars["az_phx_pe02"]) + + +def test_modelvalidation_pass(host_vars): + validate = getattr(v.ModelValidation.validators[0], "validate") + validate(host_vars) + assert True From 52e6a2a4fbd439213a4ed1972a0ca2b6d85f7bef Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Fri, 5 Feb 2021 11:52:43 -0700 Subject: [PATCH 04/42] Support compiled jmespath expr for rhs --- schema_enforcer/schemas/validator.py | 13 ++++++++----- .../test_validators/validators/check_peers.py | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index 0089a01..e39506f 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -41,12 +41,15 @@ def validate(cls, data: dict): "lte": lambda r, v: int(r) <= int(v), "contains": lambda r, v: v in r, } - result = jmespath.search(cls.left, data) + lhs = jmespath.search(cls.left, data) valid = True - if result: - if isinstance(result, list): - result = result[0] - valid = operators[cls.operator](result, cls.right) + if lhs: + # Check rhs for compiled jmespath expression + if isinstance(cls.right, jmespath.parser.ParsedResult): + rhs = cls.right.search(data) + else: + rhs = cls.right + valid = operators[cls.operator](lhs, rhs) if not valid: raise ValidationError diff --git a/tests/fixtures/test_validators/validators/check_peers.py b/tests/fixtures/test_validators/validators/check_peers.py index 4cdd655..982e3c9 100644 --- a/tests/fixtures/test_validators/validators/check_peers.py +++ b/tests/fixtures/test_validators/validators/check_peers.py @@ -10,7 +10,7 @@ class CheckPeers(ModelValidation): """ Validate that peer and peer_int are defined properly on both sides of a connection - full Ansible host_vars + Requires full Ansible host_vars as data """ def validate(cls, data: dict): From 19a4d604c9db00d6bddb7ea14553e2d5c4183554 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Fri, 5 Feb 2021 12:43:33 -0700 Subject: [PATCH 05/42] Add ValidationResult class to context --- schema_enforcer/schemas/validator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index e39506f..d86c5ac 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -3,6 +3,7 @@ """ from pathlib import Path import jmespath +from schema_enforcer.validation import ValidationResult class ValidationError(Exception): @@ -63,6 +64,7 @@ def load(validator_path: str): "ModelValidation": ModelValidation, "JmesPathModelValidation": JmesPathModelValidation, "ValidationError": ValidationError, + "ValidationResult": ValidationResult, "jmes": jmespath.compile, } From 08afcf37c9e9c838f9d5127264bd4552336cb142 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 8 Feb 2021 08:55:45 -0700 Subject: [PATCH 06/42] Add ansible example for validator plugins --- .../ansible3/host_vars/az_phx_pe01/base.yml | 26 +++++++++++++++++++ .../ansible3/host_vars/az_phx_pe02/base.yml | 26 +++++++++++++++++++ examples/ansible3/inventory.yml | 14 ++++++++++ examples/ansible3/pyproject.toml | 2 ++ .../ansible3/validators/check_interfaces.py | 8 ++++++ 5 files changed, 76 insertions(+) create mode 100644 examples/ansible3/host_vars/az_phx_pe01/base.yml create mode 100644 examples/ansible3/host_vars/az_phx_pe02/base.yml create mode 100644 examples/ansible3/inventory.yml create mode 100644 examples/ansible3/pyproject.toml create mode 100644 examples/ansible3/validators/check_interfaces.py diff --git a/examples/ansible3/host_vars/az_phx_pe01/base.yml b/examples/ansible3/host_vars/az_phx_pe01/base.yml new file mode 100644 index 0000000..01aaca2 --- /dev/null +++ b/examples/ansible3/host_vars/az_phx_pe01/base.yml @@ -0,0 +1,26 @@ +hostname: az-phx-pe01 +pair_rtr: az-phx-pe02 +upstreams: [] +interfaces: + MgmtEth0/0/CPU0/0: + ipv4: 172.16.1.1 + ipv6: + peer: + peer_int: + Loopback0: + ipv4: 192.168.1.1 + ipv6: 2001:db8:1::1 + peer: + peer_int: + 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" diff --git a/examples/ansible3/host_vars/az_phx_pe02/base.yml b/examples/ansible3/host_vars/az_phx_pe02/base.yml new file mode 100644 index 0000000..f6277ba --- /dev/null +++ b/examples/ansible3/host_vars/az_phx_pe02/base.yml @@ -0,0 +1,26 @@ +hostname: az-phx-pe02 +pair_rtr: az-phx-pe01 +upstreams: [] +interfaces: + MgmtEth0/0/CPU0/0: + ipv4: 172.16.1.2 + ipv6: + peer: + peer_int: + Loopback0: + ipv4: 192.168.1.2 + ipv6: 2001:db8:1::2 + peer: + peer_int: + 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" diff --git a/examples/ansible3/inventory.yml b/examples/ansible3/inventory.yml new file mode 100644 index 0000000..4ebb4c9 --- /dev/null +++ b/examples/ansible3/inventory.yml @@ -0,0 +1,14 @@ +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 diff --git a/examples/ansible3/pyproject.toml b/examples/ansible3/pyproject.toml new file mode 100644 index 0000000..b4bd005 --- /dev/null +++ b/examples/ansible3/pyproject.toml @@ -0,0 +1,2 @@ +[tool.schema_enforcer] +ansible_inventory = "inventory.yml" \ No newline at end of file diff --git a/examples/ansible3/validators/check_interfaces.py b/examples/ansible3/validators/check_interfaces.py new file mode 100644 index 0000000..27495d5 --- /dev/null +++ b/examples/ansible3/validators/check_interfaces.py @@ -0,0 +1,8 @@ +class CheckInterface(JmesPathModelValidation): # noqa: F821 + top_level_properties = ["interfaces"] + id = "CheckInterface" + model = "interfaces" + left = "interfaces.*[@.type=='core'][] | length([?@])" + right = 2 + operator = "eq" + error = "Less than two core interfaces" From 351172445594d0537928a09c83bcfe8e9f998d11 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 8 Feb 2021 09:40:17 -0700 Subject: [PATCH 07/42] Exclude plugin examples from linting --- examples/ansible3/validators/check_interfaces.py | 4 +++- tests/fixtures/test_validators/validators/check_interfaces.py | 4 +++- tests/fixtures/test_validators/validators/check_peers.py | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/ansible3/validators/check_interfaces.py b/examples/ansible3/validators/check_interfaces.py index 27495d5..1a8974e 100644 --- a/examples/ansible3/validators/check_interfaces.py +++ b/examples/ansible3/validators/check_interfaces.py @@ -1,4 +1,6 @@ -class CheckInterface(JmesPathModelValidation): # noqa: F821 +# flake8: noqa +# pylint: skip-file +class CheckInterface(JmesPathModelValidation): top_level_properties = ["interfaces"] id = "CheckInterface" model = "interfaces" diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py index 6a9ad3f..53fa267 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces.py +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -1,4 +1,6 @@ -class CheckInterface(JmesPathModelValidation): # noqa: F821 +# flake8: noqa +# pylint: skip-file +class CheckInterface(JmesPathModelValidation): model = "interfaces" left = "interfaces.*[@.type=='core'][] | length([?@])" right = 2 diff --git a/tests/fixtures/test_validators/validators/check_peers.py b/tests/fixtures/test_validators/validators/check_peers.py index 982e3c9..398093c 100644 --- a/tests/fixtures/test_validators/validators/check_peers.py +++ b/tests/fixtures/test_validators/validators/check_peers.py @@ -1,3 +1,5 @@ +# flake8: noqa +# pylint: skip-file def ansible_hostname(hostname: str): return hostname.replace("-", "_") From 691c899b1fc95aff3052f13d71cbed45fbf84cfe Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 8 Feb 2021 10:15:30 -0700 Subject: [PATCH 08/42] Integrate validator plugin support --- schema_enforcer/config.py | 1 + schema_enforcer/schemas/manager.py | 9 +++++++++ schema_enforcer/schemas/validator.py | 19 ++++++++++++++----- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/schema_enforcer/config.py b/schema_enforcer/config.py index 8909241..4b1e38e 100644 --- a/schema_enforcer/config.py +++ b/schema_enforcer/config.py @@ -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" + plugin_directory: str = "validators" test_directory: str = "tests" # Settings specific to the schema files diff --git a/schema_enforcer/schemas/manager.py b/schema_enforcer/schemas/manager.py index 107b263..dee1966 100644 --- a/schema_enforcer/schemas/manager.py +++ b/schema_enforcer/schemas/manager.py @@ -13,6 +13,7 @@ from schema_enforcer.exceptions import SchemaNotDefined from schema_enforcer.utils import error, warn from schema_enforcer.schemas.jsonschema import JsonSchema +from schema_enforcer.schemas.validator import load_validators class SchemaManager: @@ -43,6 +44,14 @@ def __init__(self, config): schema = self.create_schema_from_file(root, filename) self.schemas[schema.get_id()] = schema + # Load plugins + full_plugin_dir = f"{config.plugin_directory}" + plugins = load_validators(full_plugin_dir) + for plugin in plugins: + # Use the class name as the schema id + schema_id = type(plugin).__name__ + self.schemas[schema_id] = plugin + def create_schema_from_file(self, root, filename): # pylint: disable=no-self-use """Create a new JsonSchema object for a given file. diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index d86c5ac..124b62d 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -1,13 +1,14 @@ """ Classes for custom validator plugins """ +# pylint: disable=E1101, R0903, W0122 from pathlib import Path import jmespath from schema_enforcer.validation import ValidationResult class ValidationError(Exception): - pass + """ Base exception for errors during validator """ class ModelValidation: @@ -30,7 +31,7 @@ def __init_subclass__(cls, **kwargs): cls.validators.append(cls()) @classmethod - def validate(cls, data: dict): + def validate(cls, data: dict, strict: bool): # pylint: disable=W0613 """ Validate data using custom jmespath validator plugin """ @@ -51,11 +52,14 @@ def validate(cls, data: dict): else: rhs = cls.right valid = operators[cls.operator](lhs, rhs) - if not valid: - raise ValidationError + if valid: + result = "PASS" + else: + result = "FAIL" + return [ValidationResult(result=result, schema_id=cls.id, message=cls.error)] -def load(validator_path: str): +def load_validators(validator_path: str): """ Load all validator plugins from validator_path """ @@ -73,3 +77,8 @@ def load(validator_path: str): source = open(filename).read() code = compile(source, filename, "exec") exec(code, context) +<<<<<<< HEAD +======= + + return ModelValidation.validators + JmesPathModelValidation.validators +>>>>>>> b44a529... Integrate validator plugin support From 674a119cc3fb1206eb693f54080a18dd07850c82 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 8 Feb 2021 10:22:55 -0700 Subject: [PATCH 09/42] Update tests for validator plugin integration --- tests/test_schemas_validator.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_schemas_validator.py b/tests/test_schemas_validator.py index b0e03c7..bd7fe57 100644 --- a/tests/test_schemas_validator.py +++ b/tests/test_schemas_validator.py @@ -1,3 +1,6 @@ +""" +Tests for validator plugin support +""" import os import pytest from schema_enforcer.ansible_inventory import AnsibleInventory @@ -31,23 +34,23 @@ def test_load(): Test that validator files are loaded and appended to base class validator list """ validator_path = os.path.join(FIXTURE_DIR, "validators") - v.load(validator_path) + v.load_validators(validator_path) assert v.JmesPathModelValidation.validators -def test_jmespathvalidation_pass(host_vars): +def test_jmespathvalidation_pass(host_vars): # pylint: disable=W0621 validate = getattr(v.JmesPathModelValidation.validators[0], "validate") - validate(host_vars["az_phx_pe01"]) - assert True + result = validate(host_vars["az_phx_pe01"], False) + assert result[0].result == "PASS" -def test_jmespathvalidation_fail(host_vars): +def test_jmespathvalidation_fail(host_vars): # pylint: disable=W0621 validate = getattr(v.JmesPathModelValidation.validators[0], "validate") - with pytest.raises(v.ValidationError): - validate(host_vars["az_phx_pe02"]) + result = validate(host_vars["az_phx_pe02"], False) + assert result[0].result == "FAIL" -def test_modelvalidation_pass(host_vars): +def test_modelvalidation_pass(host_vars): # pylint: disable=W0621 validate = getattr(v.ModelValidation.validators[0], "validate") validate(host_vars) assert True From 6e98b64821f93aebc041c5e15354df11615ee626 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 8 Feb 2021 10:23:06 -0700 Subject: [PATCH 10/42] Fix pylint and yamllint errors --- .../ansible3/host_vars/az_phx_pe01/base.yml | 30 ++++++++--------- .../ansible3/host_vars/az_phx_pe02/base.yml | 32 ++++++++----------- examples/ansible3/inventory.yml | 13 ++++---- .../inventory/group_vars/all.yml | 15 --------- .../inventory/host_vars/az_phx_pe01/base.yml | 30 ++++++++--------- .../inventory/host_vars/az_phx_pe01/mpls.yml | 6 ---- .../inventory/host_vars/az_phx_pe01/ospf.yml | 13 -------- .../inventory/host_vars/az_phx_pe01/vrf.yml | 5 --- .../inventory/host_vars/az_phx_pe02/base.yml | 32 ++++++++----------- .../inventory/host_vars/az_phx_pe02/mpls.yml | 6 ---- .../inventory/host_vars/az_phx_pe02/ospf.yml | 13 -------- .../inventory/host_vars/az_phx_pe02/vrf.yml | 5 --- .../test_validators/inventory/inventory.yml | 13 ++++---- .../validators/check_interfaces.py | 3 ++ 14 files changed, 71 insertions(+), 145 deletions(-) delete mode 100644 tests/fixtures/test_validators/inventory/group_vars/all.yml delete mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/mpls.yml delete mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/ospf.yml delete mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/vrf.yml delete mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/mpls.yml delete mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/ospf.yml delete mode 100644 tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/vrf.yml diff --git a/examples/ansible3/host_vars/az_phx_pe01/base.yml b/examples/ansible3/host_vars/az_phx_pe01/base.yml index 01aaca2..e8adf5e 100644 --- a/examples/ansible3/host_vars/az_phx_pe01/base.yml +++ b/examples/ansible3/host_vars/az_phx_pe01/base.yml @@ -1,26 +1,22 @@ -hostname: az-phx-pe01 -pair_rtr: az-phx-pe02 +--- +hostname: "az-phx-pe01" +pair_rtr: "az-phx-pe02" upstreams: [] interfaces: MgmtEth0/0/CPU0/0: - ipv4: 172.16.1.1 - ipv6: - peer: - peer_int: + ipv4: "172.16.1.1" Loopback0: - ipv4: 192.168.1.1 - ipv6: 2001:db8:1::1 - peer: - peer_int: + ipv4: "192.168.1.1" + ipv6: "2001:db8:1::1" GigabitEthernet0/0/0/0: - ipv4: 10.1.0.1 + ipv4: "10.1.0.1" ipv6: "2001:db8::" - peer: az-phx-pe02 - peer_int: GigabitEthernet0/0/0/0 + 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 + ipv4: "10.1.0.37" + ipv6: "2001:db8::12" + peer: "co-den-p01" + peer_int: "GigabitEthernet0/0/0/2" type: "core" diff --git a/examples/ansible3/host_vars/az_phx_pe02/base.yml b/examples/ansible3/host_vars/az_phx_pe02/base.yml index f6277ba..3cfbd09 100644 --- a/examples/ansible3/host_vars/az_phx_pe02/base.yml +++ b/examples/ansible3/host_vars/az_phx_pe02/base.yml @@ -1,26 +1,22 @@ -hostname: az-phx-pe02 -pair_rtr: az-phx-pe01 +--- +hostname: "az-phx-pe02" +pair_rtr: "az-phx-pe01" upstreams: [] interfaces: MgmtEth0/0/CPU0/0: - ipv4: 172.16.1.2 - ipv6: - peer: - peer_int: + ipv4: "172.16.1.2" Loopback0: - ipv4: 192.168.1.2 - ipv6: 2001:db8:1::2 - peer: - peer_int: + 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 + 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 + ipv4: "10.1.0.41" + ipv6: "2001:db8::14" + peer: "co-den-p02" + peer_int: "GigabitEthernet0/0/0/2" type: "access" diff --git a/examples/ansible3/inventory.yml b/examples/ansible3/inventory.yml index 4ebb4c9..072655b 100644 --- a/examples/ansible3/inventory.yml +++ b/examples/ansible3/inventory.yml @@ -1,14 +1,15 @@ +--- all: vars: - ansible_network_os: iosxr - ansible_user: cisco - ansible_password: cisco - ansible_connection: netconf + 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 + ansible_host: "172.16.1.1" az_phx_pe02: - ansible_host: 172.16.1.2 + ansible_host: "172.16.1.2" diff --git a/tests/fixtures/test_validators/inventory/group_vars/all.yml b/tests/fixtures/test_validators/inventory/group_vars/all.yml deleted file mode 100644 index f99276a..0000000 --- a/tests/fixtures/test_validators/inventory/group_vars/all.yml +++ /dev/null @@ -1,15 +0,0 @@ -core_as: 64599 -core_loopback_network: 192.168.1.0 -core_loopback_wildcard: 0.0.0.255 -core_bgp_password: cisco -vrf_global: - Blue: - rd_index: 1 - dot1q: 100 - ipv4_net: 10.100.100.0/24 - ipv6_net: 2001:db8:100::/64 - Green: - rd_index: 2 - dot1q: 200 - ipv4_net: 10.100.200.0/24 - ipv6_net: 2001:db8:200::/64 diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/base.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/base.yml index 01aaca2..e8adf5e 100644 --- a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/base.yml +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/base.yml @@ -1,26 +1,22 @@ -hostname: az-phx-pe01 -pair_rtr: az-phx-pe02 +--- +hostname: "az-phx-pe01" +pair_rtr: "az-phx-pe02" upstreams: [] interfaces: MgmtEth0/0/CPU0/0: - ipv4: 172.16.1.1 - ipv6: - peer: - peer_int: + ipv4: "172.16.1.1" Loopback0: - ipv4: 192.168.1.1 - ipv6: 2001:db8:1::1 - peer: - peer_int: + ipv4: "192.168.1.1" + ipv6: "2001:db8:1::1" GigabitEthernet0/0/0/0: - ipv4: 10.1.0.1 + ipv4: "10.1.0.1" ipv6: "2001:db8::" - peer: az-phx-pe02 - peer_int: GigabitEthernet0/0/0/0 + 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 + ipv4: "10.1.0.37" + ipv6: "2001:db8::12" + peer: "co-den-p01" + peer_int: "GigabitEthernet0/0/0/2" type: "core" diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/mpls.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/mpls.yml deleted file mode 100644 index 8fa4066..0000000 --- a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/mpls.yml +++ /dev/null @@ -1,6 +0,0 @@ -mpls: - interfaces: - GigabitEthernet0/0/0/0: - peer: az-phx-pe02 - GigabitEthernet0/0/0/1: - peer: co-den-p01 diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/ospf.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/ospf.yml deleted file mode 100644 index d9b9cc1..0000000 --- a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/ospf.yml +++ /dev/null @@ -1,13 +0,0 @@ -ospf: - areas: - - id: 0 - interfaces: - GigabitEthernet0/0/0/0: - peer: az-phx-pe02 - passive: 'false' - cost: 1 - GigabitEthernet0/0/0/1: - peer: co-den-p01 - passive: 'false' - cost: 1 - Loopback0: diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/vrf.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/vrf.yml deleted file mode 100644 index d597f5f..0000000 --- a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe01/vrf.yml +++ /dev/null @@ -1,5 +0,0 @@ -vrfs: -- name: Blue - interfaces: [] -- name: Green - interfaces: [] diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/base.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/base.yml index f6277ba..3cfbd09 100644 --- a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/base.yml +++ b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/base.yml @@ -1,26 +1,22 @@ -hostname: az-phx-pe02 -pair_rtr: az-phx-pe01 +--- +hostname: "az-phx-pe02" +pair_rtr: "az-phx-pe01" upstreams: [] interfaces: MgmtEth0/0/CPU0/0: - ipv4: 172.16.1.2 - ipv6: - peer: - peer_int: + ipv4: "172.16.1.2" Loopback0: - ipv4: 192.168.1.2 - ipv6: 2001:db8:1::2 - peer: - peer_int: + 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 + 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 + ipv4: "10.1.0.41" + ipv6: "2001:db8::14" + peer: "co-den-p02" + peer_int: "GigabitEthernet0/0/0/2" type: "access" diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/mpls.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/mpls.yml deleted file mode 100644 index fe32f78..0000000 --- a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/mpls.yml +++ /dev/null @@ -1,6 +0,0 @@ -mpls: - interfaces: - GigabitEthernet0/0/0/0: - peer: az-phx-pe01 - GigabitEthernet0/0/0/1: - peer: co-den-p02 diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/ospf.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/ospf.yml deleted file mode 100644 index 0ceef0a..0000000 --- a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/ospf.yml +++ /dev/null @@ -1,13 +0,0 @@ -ospf: - areas: - - id: 0 - interfaces: - GigabitEthernet0/0/0/0: - peer: az-phx-pe01 - passive: 'false' - cost: 1 - GigabitEthernet0/0/0/1: - peer: co-den-p02 - passive: 'false' - cost: 1 - Loopback0: diff --git a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/vrf.yml b/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/vrf.yml deleted file mode 100644 index d597f5f..0000000 --- a/tests/fixtures/test_validators/inventory/host_vars/az_phx_pe02/vrf.yml +++ /dev/null @@ -1,5 +0,0 @@ -vrfs: -- name: Blue - interfaces: [] -- name: Green - interfaces: [] diff --git a/tests/fixtures/test_validators/inventory/inventory.yml b/tests/fixtures/test_validators/inventory/inventory.yml index 4ebb4c9..072655b 100644 --- a/tests/fixtures/test_validators/inventory/inventory.yml +++ b/tests/fixtures/test_validators/inventory/inventory.yml @@ -1,14 +1,15 @@ +--- all: vars: - ansible_network_os: iosxr - ansible_user: cisco - ansible_password: cisco - ansible_connection: netconf + 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 + ansible_host: "172.16.1.1" az_phx_pe02: - ansible_host: 172.16.1.2 + ansible_host: "172.16.1.2" diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py index 53fa267..1a8974e 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces.py +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -1,7 +1,10 @@ # flake8: noqa # pylint: skip-file class CheckInterface(JmesPathModelValidation): + top_level_properties = ["interfaces"] + id = "CheckInterface" model = "interfaces" left = "interfaces.*[@.type=='core'][] | length([?@])" right = 2 operator = "eq" + error = "Less than two core interfaces" From 63239c40c5a9453208b86e0888a2c4c90e4e4059 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 8 Feb 2021 10:48:27 -0700 Subject: [PATCH 11/42] Fix pydocstyle errors --- .../ansible3/validators/check_interfaces.py | 3 +++ schema_enforcer/schemas/validator.py | 26 +++++++------------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/examples/ansible3/validators/check_interfaces.py b/examples/ansible3/validators/check_interfaces.py index 1a8974e..fd0fead 100644 --- a/examples/ansible3/validators/check_interfaces.py +++ b/examples/ansible3/validators/check_interfaces.py @@ -1,6 +1,9 @@ +"""Example validator plugin.""" # flake8: noqa # pylint: skip-file class CheckInterface(JmesPathModelValidation): + """Check that each device has at least two core uplinks.""" + top_level_properties = ["interfaces"] id = "CheckInterface" model = "interfaces" diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index 124b62d..d8f8b7d 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -1,6 +1,4 @@ -""" -Classes for custom validator plugins -""" +"""Classes for custom validator plugins.""" # pylint: disable=E1101, R0903, W0122 from pathlib import Path import jmespath @@ -8,33 +6,33 @@ class ValidationError(Exception): - """ Base exception for errors during validator """ + """Base exception for errors during validator.""" class ModelValidation: - """Base class for ModelValidation classes. A singleton of each subclass will be stored in validators. """ + """Base class for ModelValidation classes. A singleton of each subclass will be stored in validators.""" validators = [] def __init_subclass__(cls, **kwargs): + """Register singleton of each subclass.""" super().__init_subclass__(**kwargs) cls.validators.append(cls()) class JmesPathModelValidation: - """Base class for JmesPathModelValidation classes. A singleton of each subclass will be stored in validators. """ + """Base class for JmesPathModelValidation classes. A singleton of each subclass will be stored in validators.""" validators = [] def __init_subclass__(cls, **kwargs): + """Register singleton of each subclass.""" super().__init_subclass__(**kwargs) cls.validators.append(cls()) @classmethod def validate(cls, data: dict, strict: bool): # pylint: disable=W0613 - """ - Validate data using custom jmespath validator plugin - """ + """Validate data using custom jmespath validator plugin.""" operators = { "gt": lambda r, v: int(r) > int(v), "gte": lambda r, v: int(r) >= int(v), @@ -60,9 +58,7 @@ def validate(cls, data: dict, strict: bool): # pylint: disable=W0613 def load_validators(validator_path: str): - """ - Load all validator plugins from validator_path - """ + """Load all validator plugins from validator_path.""" # Make base class and helper functions available to validation plugins without import context = { "ModelValidation": ModelValidation, @@ -76,9 +72,5 @@ def load_validators(validator_path: str): for filename in validator_path.glob("*.py"): source = open(filename).read() code = compile(source, filename, "exec") - exec(code, context) -<<<<<<< HEAD -======= - + exec(code, context) # nosec return ModelValidation.validators + JmesPathModelValidation.validators ->>>>>>> b44a529... Integrate validator plugin support From 65fc21651d9a96692ff1fa3f69e7bbc804ac670a Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 8 Feb 2021 12:57:29 -0700 Subject: [PATCH 12/42] Update pytest task --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 5f28510..d7a952a 100644 --- a/tasks.py +++ b/tasks.py @@ -160,7 +160,7 @@ def pytest(context, name=NAME, image_ver=IMAGE_VER, local=INVOKE_LOCAL): # pty is set to true to properly run the docker commands due to the invocation process of docker # https://docs.pyinvoke.org/en/latest/api/runners.html - Search for pty for more information # Install python module - exec_cmd = 'find tests/ -name "*.py" -a -not -name "test_cli_ansible_not_exists.py" | xargs pytest -vv' + exec_cmd = 'find tests/ -name "test_*.py" -a -not -name "test_cli_ansible_not_exists.py" | xargs pytest -vv' run_cmd(context, exec_cmd, name, image_ver, local) From 540734015efd5cb544e07251171a90a8f5bbe284 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Tue, 9 Feb 2021 09:56:59 -0700 Subject: [PATCH 13/42] Rename validator dir config option --- schema_enforcer/config.py | 2 +- schema_enforcer/schemas/manager.py | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/schema_enforcer/config.py b/schema_enforcer/config.py index 4b1e38e..9c98c7c 100644 --- a/schema_enforcer/config.py +++ b/schema_enforcer/config.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): # pylint: disable=too-few-public-methods main_directory: str = "schema" definition_directory: str = "definitions" schema_directory: str = "schemas" - plugin_directory: str = "validators" + validator_directory: str = "validators" test_directory: str = "tests" # Settings specific to the schema files diff --git a/schema_enforcer/schemas/manager.py b/schema_enforcer/schemas/manager.py index dee1966..711c5ad 100644 --- a/schema_enforcer/schemas/manager.py +++ b/schema_enforcer/schemas/manager.py @@ -44,13 +44,9 @@ def __init__(self, config): schema = self.create_schema_from_file(root, filename) self.schemas[schema.get_id()] = schema - # Load plugins - full_plugin_dir = f"{config.plugin_directory}" - plugins = load_validators(full_plugin_dir) - for plugin in plugins: - # Use the class name as the schema id - schema_id = type(plugin).__name__ - self.schemas[schema_id] = plugin + # Load validators + full_validator_dir = f"{config.validator_directory}" + validators = load_validators(full_validator_dir) def create_schema_from_file(self, root, filename): # pylint: disable=no-self-use """Create a new JsonSchema object for a given file. From 6f08f6810caf0fcf6b49e15043798e2f76e13cd6 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 8 Feb 2021 14:32:54 -0700 Subject: [PATCH 14/42] Refactor to use importlib for validators --- .../ansible3/validators/check_interfaces.py | 3 + schema_enforcer/schemas/manager.py | 1 + schema_enforcer/schemas/validator.py | 67 +++++++++---------- .../validators/check_interfaces.py | 4 +- .../test_validators/validators/check_peers.py | 38 ++++++++++- tests/test_schemas_validator.py | 27 ++++---- 6 files changed, 86 insertions(+), 54 deletions(-) diff --git a/examples/ansible3/validators/check_interfaces.py b/examples/ansible3/validators/check_interfaces.py index fd0fead..9ce33a4 100644 --- a/examples/ansible3/validators/check_interfaces.py +++ b/examples/ansible3/validators/check_interfaces.py @@ -1,6 +1,9 @@ """Example validator plugin.""" # flake8: noqa # pylint: skip-file +from schema_enforcer.schemas.validator import JmesPathModelValidation + + class CheckInterface(JmesPathModelValidation): """Check that each device has at least two core uplinks.""" diff --git a/schema_enforcer/schemas/manager.py b/schema_enforcer/schemas/manager.py index 711c5ad..5c93904 100644 --- a/schema_enforcer/schemas/manager.py +++ b/schema_enforcer/schemas/manager.py @@ -47,6 +47,7 @@ def __init__(self, config): # Load validators full_validator_dir = f"{config.validator_directory}" validators = load_validators(full_validator_dir) + self.schemas.update(validators) def create_schema_from_file(self, root, filename): # pylint: disable=no-self-use """Create a new JsonSchema object for a given file. diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index d8f8b7d..6775759 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -1,37 +1,25 @@ """Classes for custom validator plugins.""" # pylint: disable=E1101, R0903, W0122 -from pathlib import Path +import pkgutil +import inspect +from typing import Iterable, Union import jmespath from schema_enforcer.validation import ValidationResult -class ValidationError(Exception): - """Base exception for errors during validator.""" - - class ModelValidation: - """Base class for ModelValidation classes. A singleton of each subclass will be stored in validators.""" - - validators = [] + """Base class for ModelValidation classes.""" - def __init_subclass__(cls, **kwargs): - """Register singleton of each subclass.""" - super().__init_subclass__(**kwargs) - cls.validators.append(cls()) + @classmethod + def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: + """Required function for custom validator.""" class JmesPathModelValidation: - """Base class for JmesPathModelValidation classes. A singleton of each subclass will be stored in validators.""" - - validators = [] - - def __init_subclass__(cls, **kwargs): - """Register singleton of each subclass.""" - super().__init_subclass__(**kwargs) - cls.validators.append(cls()) + """Base class for JmesPathModelValidation classes.""" @classmethod - def validate(cls, data: dict, strict: bool): # pylint: disable=W0613 + def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: # pylint: disable=W0613 """Validate data using custom jmespath validator plugin.""" operators = { "gt": lambda r, v: int(r) > int(v), @@ -57,20 +45,25 @@ def validate(cls, data: dict, strict: bool): # pylint: disable=W0613 return [ValidationResult(result=result, schema_id=cls.id, message=cls.error)] -def load_validators(validator_path: str): - """Load all validator plugins from validator_path.""" - # Make base class and helper functions available to validation plugins without import - context = { - "ModelValidation": ModelValidation, - "JmesPathModelValidation": JmesPathModelValidation, - "ValidationError": ValidationError, - "ValidationResult": ValidationResult, - "jmes": jmespath.compile, - } +def is_validator(obj) -> bool: + """Returns True if the object is a ModelValidation or JmesPathModelValidation subclass.""" + try: + return issubclass(obj, (JmesPathModelValidation, ModelValidation)) and obj not in ( + JmesPathModelValidation, + ModelValidation, + ) + except TypeError: + return False + - validator_path = Path(validator_path).expanduser().resolve() - for filename in validator_path.glob("*.py"): - source = open(filename).read() - code = compile(source, filename, "exec") - exec(code, context) # nosec - return ModelValidation.validators + JmesPathModelValidation.validators +def load_validators(validator_path: str) -> Iterable[Union[ModelValidation, JmesPathModelValidation]]: + """Load all validator plugins from validator_path.""" + validators = dict() + for importer, module_name, _ in pkgutil.iter_modules([validator_path]): + module = importer.find_module(module_name).load_module(module_name) + for name, cls in inspect.getmembers(module, is_validator): + if name in validators: + print(f"Duplicate validator name: {name}") + else: + validators[name] = cls + return validators diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py index 1a8974e..0c24cbe 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces.py +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -1,9 +1,11 @@ # flake8: noqa # pylint: skip-file +from schema_enforcer.schemas.validator import JmesPathModelValidation + + class CheckInterface(JmesPathModelValidation): top_level_properties = ["interfaces"] id = "CheckInterface" - model = "interfaces" left = "interfaces.*[@.type=='core'][] | length([?@])" right = 2 operator = "eq" diff --git a/tests/fixtures/test_validators/validators/check_peers.py b/tests/fixtures/test_validators/validators/check_peers.py index 398093c..079fc30 100644 --- a/tests/fixtures/test_validators/validators/check_peers.py +++ b/tests/fixtures/test_validators/validators/check_peers.py @@ -1,5 +1,10 @@ # flake8: noqa # pylint: skip-file +from typing import Iterable +from schema_enforcer.schemas.validator import ModelValidation +from schema_enforcer.validation import ValidationResult + + def ansible_hostname(hostname: str): return hostname.replace("-", "_") @@ -15,7 +20,11 @@ class CheckPeers(ModelValidation): Requires full Ansible host_vars as data """ - def validate(cls, data: dict): + id = "CheckPeers" + + @classmethod + def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: + results = list() for host in data: for interface, int_cfg in data[host]["interfaces"].items(): peer = int_cfg.get("peer", None) @@ -28,7 +37,30 @@ def validate(cls, data: dict): peer_match = data[peer]["interfaces"][peer_int]["peer"] == normal_hostname(host) peer_int_match = data[peer]["interfaces"][peer_int]["peer_int"] == interface if not (peer_match and peer_int_match): - raise ValidationError + results.append( + ValidationResult( + result="FAIL", + schema_id=cls.id, + message="Peer interfaces do not match", + instance_hostname=host, + instance_name=peer, + ) + ) + else: + results.append( + ValidationResult( + result="PASS", schema_id=cls.id, instance_hostname=host, instance_name=peer + ) + ) # If peer is defined, peer_int must also exist else: - raise ValidationError + results.append( + ValidationResult( + result="FAIL", + schema_id=cls.id, + message="Peer interface is not defined", + instance_hostname=host, + instance_name=peer, + ) + ) + return results diff --git a/tests/test_schemas_validator.py b/tests/test_schemas_validator.py index bd7fe57..b4084bc 100644 --- a/tests/test_schemas_validator.py +++ b/tests/test_schemas_validator.py @@ -29,28 +29,29 @@ def host_vars(inventory): # pylint: disable=redefined-outer-name return host_vars -def test_load(): +@pytest.fixture(scope="session") +def validators(): """ Test that validator files are loaded and appended to base class validator list """ validator_path = os.path.join(FIXTURE_DIR, "validators") - v.load_validators(validator_path) - assert v.JmesPathModelValidation.validators + return v.load_validators(validator_path) -def test_jmespathvalidation_pass(host_vars): # pylint: disable=W0621 - validate = getattr(v.JmesPathModelValidation.validators[0], "validate") +def test_jmespathvalidation_pass(host_vars, validators): # pylint: disable=W0621 + validate = getattr(validators["CheckInterface"], "validate") result = validate(host_vars["az_phx_pe01"], False) - assert result[0].result == "PASS" + assert result[0].passed() -def test_jmespathvalidation_fail(host_vars): # pylint: disable=W0621 - validate = getattr(v.JmesPathModelValidation.validators[0], "validate") +def test_jmespathvalidation_fail(host_vars, validators): # pylint: disable=W0621 + validate = getattr(validators["CheckInterface"], "validate") result = validate(host_vars["az_phx_pe02"], False) - assert result[0].result == "FAIL" + assert not result[0].passed() -def test_modelvalidation_pass(host_vars): # pylint: disable=W0621 - validate = getattr(v.ModelValidation.validators[0], "validate") - validate(host_vars) - assert True +def test_modelvalidation_pass(host_vars, validators): # pylint: disable=W0621 + validate = getattr(validators["CheckPeers"], "validate") + result = validate(host_vars, False) + assert result[0].passed() + assert result[1].passed() From 483c539a7aee36a51a34619ed55b5ac8d0128b0e Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Tue, 9 Feb 2021 11:00:04 -0700 Subject: [PATCH 15/42] Implement schema id check with default --- schema_enforcer/schemas/validator.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index 6775759..876a051 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -62,8 +62,11 @@ def load_validators(validator_path: str) -> Iterable[Union[ModelValidation, Jmes for importer, module_name, _ in pkgutil.iter_modules([validator_path]): module = importer.find_module(module_name).load_module(module_name) for name, cls in inspect.getmembers(module, is_validator): - if name in validators: - print(f"Duplicate validator name: {name}") + # Default to class name if id doesn't exist + if not hasattr(cls, "id"): + cls.id = name + if cls.id in validators: + print(f"Duplicate validator name: {cls.id}") else: - validators[name] = cls + validators[cls.id] = cls return validators From 993e1430d640c0e37caed48b86db773ad7f54254 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Tue, 9 Feb 2021 11:04:07 -0700 Subject: [PATCH 16/42] Initial commit of custom validator doc --- docs/custom_validators.md | 77 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/custom_validators.md diff --git a/docs/custom_validators.md b/docs/custom_validators.md new file mode 100644 index 0000000..218142e --- /dev/null +++ b/docs/custom_validators.md @@ -0,0 +1,77 @@ +# 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. + +## ModelValidation + +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 ModelValidation class to correctly register with schema-enforcer. +3. Provide a class method in your subclass with the following signature: +`def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]:` + + * 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. + +## JmesPathModelValidation + +Use this class for basic validation using jmespath 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 (not instance): + + * `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 + * `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 ModelValidation class. + +### Example: +``` +from schema_enforcer.schemas.validator import JmesPathModelValidation + + +class CheckInterface(JmesPathModelValidation): + top_level_properties = ["interfaces"] + id = "CheckInterface" + left = "interfaces.*[@.type=='core'][] | length([?@])" + right = 2 + operator = "eq" + error = "Less than two core interfaces" +``` + +## 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. From 9ea75e3da0fedbd1216da4df19b87335addc9a87 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Tue, 9 Feb 2021 12:57:38 -0700 Subject: [PATCH 17/42] Update test validators --- .../ansible3/validators/check_interfaces.py | 4 +- .../validators/check_interfaces.py | 7 +- .../test_validators/validators/check_peers.py | 77 +++++++++---------- 3 files changed, 43 insertions(+), 45 deletions(-) diff --git a/examples/ansible3/validators/check_interfaces.py b/examples/ansible3/validators/check_interfaces.py index 9ce33a4..483956d 100644 --- a/examples/ansible3/validators/check_interfaces.py +++ b/examples/ansible3/validators/check_interfaces.py @@ -1,10 +1,8 @@ """Example validator plugin.""" -# flake8: noqa -# pylint: skip-file from schema_enforcer.schemas.validator import JmesPathModelValidation -class CheckInterface(JmesPathModelValidation): +class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods """Check that each device has at least two core uplinks.""" top_level_properties = ["interfaces"] diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py index 0c24cbe..98547e0 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces.py +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -1,9 +1,10 @@ -# flake8: noqa -# pylint: skip-file +"""Test validator for JmesPathModelValidation class""" from schema_enforcer.schemas.validator import JmesPathModelValidation -class CheckInterface(JmesPathModelValidation): +class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods + """Test validator for JmesPathModelValidation class""" + top_level_properties = ["interfaces"] id = "CheckInterface" left = "interfaces.*[@.type=='core'][] | length([?@])" diff --git a/tests/fixtures/test_validators/validators/check_peers.py b/tests/fixtures/test_validators/validators/check_peers.py index 079fc30..55757f5 100644 --- a/tests/fixtures/test_validators/validators/check_peers.py +++ b/tests/fixtures/test_validators/validators/check_peers.py @@ -1,23 +1,24 @@ -# flake8: noqa -# pylint: skip-file +"""Test validator for ModelValidation class""" from typing import Iterable from schema_enforcer.schemas.validator import ModelValidation from schema_enforcer.validation import ValidationResult def ansible_hostname(hostname: str): + """Convert hostname to ansible format""" return hostname.replace("-", "_") def normal_hostname(hostname: str): + """Convert ansible hostname to normal format""" return hostname.replace("_", "-") -class CheckPeers(ModelValidation): +class CheckPeers(ModelValidation): # pylint: disable=too-few-public-methods """ Validate that peer and peer_int are defined properly on both sides of a connection - Requires full Ansible host_vars as data + Requires full Ansible host_vars as data which is currently unsupported in schema-enforcer """ id = "CheckPeers" @@ -27,40 +28,38 @@ def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: results = list() for host in data: for interface, int_cfg in data[host]["interfaces"].items(): - peer = int_cfg.get("peer", None) - if peer: - peer_int = int_cfg.get("peer_int", None) - if peer_int: - peer = ansible_hostname(peer) - # Only validate if peer exists in data - if peer in data: - peer_match = data[peer]["interfaces"][peer_int]["peer"] == normal_hostname(host) - peer_int_match = data[peer]["interfaces"][peer_int]["peer_int"] == interface - if not (peer_match and peer_int_match): - results.append( - ValidationResult( - result="FAIL", - schema_id=cls.id, - message="Peer interfaces do not match", - instance_hostname=host, - instance_name=peer, - ) - ) - else: - results.append( - ValidationResult( - result="PASS", schema_id=cls.id, instance_hostname=host, instance_name=peer - ) - ) - # If peer is defined, peer_int must also exist - else: - results.append( - ValidationResult( - result="FAIL", - schema_id=cls.id, - message="Peer interface is not defined", - instance_hostname=host, - instance_name=peer, - ) + if "peer" not in int_cfg: + continue + peer = int_cfg["peer"] + if "peer_int" not in int_cfg: + results.append( + ValidationResult( + result="FAIL", + schema_id=cls.id, + message="Peer interface is not defined", + instance_hostname=host, + instance_name=peer, ) + ) + continue + peer_int = int_cfg["peer_int"] + peer = ansible_hostname(peer) + if peer not in data: + continue + peer_match = data[peer]["interfaces"][peer_int]["peer"] == normal_hostname(host) + peer_int_match = data[peer]["interfaces"][peer_int]["peer_int"] == interface + if peer_match and peer_int_match: + results.append( + ValidationResult(result="PASS", schema_id=cls.id, instance_hostname=host, instance_name=peer) + ) + else: + results.append( + ValidationResult( + result="FAIL", + schema_id=cls.id, + message="Peer information does not match.", + instance_hostname=host, + instance_name=peer, + ) + ) return results From 9b5a0524e4ce3915ce474fc7373318567a887747 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Tue, 9 Feb 2021 13:50:20 -0700 Subject: [PATCH 18/42] Add example mapping to validator doc --- docs/custom_validators.md | 52 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/custom_validators.md b/docs/custom_validators.md index 218142e..12953c5 100644 --- a/docs/custom_validators.md +++ b/docs/custom_validators.md @@ -21,11 +21,11 @@ the following criteria: 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. +by providing a class-level `id` variable. ## JmesPathModelValidation -Use this class for basic validation using jmespath expressions to query specific values in your data. In order to work correctly, your Python script must meet +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. @@ -75,3 +75,51 @@ Custom validators are run with `schema-enforcer validate` and `schema-enforcer a 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. + +### Example - top_level_properties + +The CheckInterface validator has a top_level_properties of "interfaces": + +``` +class CheckInterface(JmesPathModelValidation): + 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" +``` + + + From 5de08e096824dc9d47462d07851718110b1c1d54 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Tue, 9 Feb 2021 13:51:13 -0700 Subject: [PATCH 19/42] Update pylint disable to use rule names --- schema_enforcer/schemas/validator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index 876a051..af862e7 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -1,5 +1,5 @@ """Classes for custom validator plugins.""" -# pylint: disable=E1101, R0903, W0122 +# pylint: disable=no-member, too-few-public-methods import pkgutil import inspect from typing import Iterable, Union From c9d8b199150423e1a4015ac9d7616f199f986e88 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Tue, 9 Feb 2021 13:53:01 -0700 Subject: [PATCH 20/42] Add subclass to jmespathmodel --- schema_enforcer/schemas/validator.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index af862e7..ef99b09 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -2,7 +2,7 @@ # pylint: disable=no-member, too-few-public-methods import pkgutil import inspect -from typing import Iterable, Union +from typing import Iterable import jmespath from schema_enforcer.validation import ValidationResult @@ -15,7 +15,7 @@ def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: """Required function for custom validator.""" -class JmesPathModelValidation: +class JmesPathModelValidation(ModelValidation): """Base class for JmesPathModelValidation classes.""" @classmethod @@ -48,15 +48,12 @@ def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: # py def is_validator(obj) -> bool: """Returns True if the object is a ModelValidation or JmesPathModelValidation subclass.""" try: - return issubclass(obj, (JmesPathModelValidation, ModelValidation)) and obj not in ( - JmesPathModelValidation, - ModelValidation, - ) + return issubclass(obj, ModelValidation) and obj not in (JmesPathModelValidation, ModelValidation,) except TypeError: return False -def load_validators(validator_path: str) -> Iterable[Union[ModelValidation, JmesPathModelValidation]]: +def load_validators(validator_path: str) -> Iterable[ModelValidation]: """Load all validator plugins from validator_path.""" validators = dict() for importer, module_name, _ in pkgutil.iter_modules([validator_path]): From ad5e5c6745c1a93b4a441a713f56b8332d4ecd0a Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 10 Feb 2021 09:38:00 -0700 Subject: [PATCH 21/42] Update operator to match comments --- examples/ansible3/validators/check_interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ansible3/validators/check_interfaces.py b/examples/ansible3/validators/check_interfaces.py index 483956d..6863b18 100644 --- a/examples/ansible3/validators/check_interfaces.py +++ b/examples/ansible3/validators/check_interfaces.py @@ -10,5 +10,5 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public model = "interfaces" left = "interfaces.*[@.type=='core'][] | length([?@])" right = 2 - operator = "eq" + operator = "gte" error = "Less than two core interfaces" From 3c961ee28d87e118810c6bcc286563863e4aeb12 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 10 Feb 2021 09:38:12 -0700 Subject: [PATCH 22/42] Disable pylint rule at file level --- tests/test_schemas_validator.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_schemas_validator.py b/tests/test_schemas_validator.py index b4084bc..18da20c 100644 --- a/tests/test_schemas_validator.py +++ b/tests/test_schemas_validator.py @@ -1,6 +1,7 @@ """ Tests for validator plugin support """ +# pylint: disable=redefined-outer-name import os import pytest from schema_enforcer.ansible_inventory import AnsibleInventory @@ -14,15 +15,15 @@ def inventory(): """ Fixture for Ansible inventory used in tests """ inventory_dir = os.path.join(FIXTURE_DIR, "inventory") - inventory = AnsibleInventory(inventory_dir) # pylint: disable=redefined-outer-name + inventory = AnsibleInventory(inventory_dir) return inventory @pytest.fixture -def host_vars(inventory): # pylint: disable=redefined-outer-name +def host_vars(inventory): """ Fixture for providing Ansible host_vars as a consolidated dict """ hosts = inventory.get_hosts_containing() - host_vars = dict() # pylint: disable=redefined-outer-name + host_vars = dict() for host in hosts: hostname = host.get_vars()["inventory_hostname"] host_vars[hostname] = inventory.get_host_vars(host) @@ -38,19 +39,19 @@ def validators(): return v.load_validators(validator_path) -def test_jmespathvalidation_pass(host_vars, validators): # pylint: disable=W0621 +def test_jmespathvalidation_pass(host_vars, validators): validate = getattr(validators["CheckInterface"], "validate") result = validate(host_vars["az_phx_pe01"], False) assert result[0].passed() -def test_jmespathvalidation_fail(host_vars, validators): # pylint: disable=W0621 +def test_jmespathvalidation_fail(host_vars, validators): validate = getattr(validators["CheckInterface"], "validate") result = validate(host_vars["az_phx_pe02"], False) assert not result[0].passed() -def test_modelvalidation_pass(host_vars, validators): # pylint: disable=W0621 +def test_modelvalidation_pass(host_vars, validators): validate = getattr(validators["CheckPeers"], "validate") result = validate(host_vars, False) assert result[0].passed() From a4b57a5ff460292b2d1e721a61281d2922d46922 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 10 Feb 2021 10:18:01 -0700 Subject: [PATCH 23/42] Update tests and comments --- .../inventory/host_vars/co_den_p01/base.yml | 19 ++++++ .../test_validators/inventory/inventory.yml | 4 ++ .../validators/check_interfaces.py | 2 +- tests/test_schemas_validator.py | 67 ++++++++++++++++--- 4 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/test_validators/inventory/host_vars/co_den_p01/base.yml diff --git a/tests/fixtures/test_validators/inventory/host_vars/co_den_p01/base.yml b/tests/fixtures/test_validators/inventory/host_vars/co_den_p01/base.yml new file mode 100644 index 0000000..65ec1a7 --- /dev/null +++ b/tests/fixtures/test_validators/inventory/host_vars/co_den_p01/base.yml @@ -0,0 +1,19 @@ +--- +hostname: "co-den-p01" +pair_rtr: "co-den-p02" +interfaces: + MgmtEth0/0/CPU0/0: + ipv4: "172.16.1.5" + Loopback0: + ipv4: "192.168.1.5" + ipv6: "2001:db8:1::5" + GigabitEthernet0/0/0/2: + ipv4: "10.1.0.38" + ipv6: "2001:db8::13" + peer: "ut-slc-pe01" + peer_int: "GigabitEthernet0/0/0/2" + GigabitEthernet0/0/0/3: + ipv4: "10.1.0.45" + ipv6: "2001:db8::16" + peer: "ut-slc-pe01" + peer_int: "GigabitEthernet0/0/0/1" diff --git a/tests/fixtures/test_validators/inventory/inventory.yml b/tests/fixtures/test_validators/inventory/inventory.yml index 072655b..55f9820 100644 --- a/tests/fixtures/test_validators/inventory/inventory.yml +++ b/tests/fixtures/test_validators/inventory/inventory.yml @@ -13,3 +13,7 @@ all: ansible_host: "172.16.1.1" az_phx_pe02: ansible_host: "172.16.1.2" + p_rtrs: + hosts: + co_den_p01: + ansible_host: "172.16.1.3" diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py index 98547e0..3b119d4 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces.py +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -9,5 +9,5 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public id = "CheckInterface" left = "interfaces.*[@.type=='core'][] | length([?@])" right = 2 - operator = "eq" + operator = "gte" error = "Less than two core interfaces" diff --git a/tests/test_schemas_validator.py b/tests/test_schemas_validator.py index 18da20c..f0c0762 100644 --- a/tests/test_schemas_validator.py +++ b/tests/test_schemas_validator.py @@ -1,6 +1,4 @@ -""" -Tests for validator plugin support -""" +"""Tests for validator plugin support.""" # pylint: disable=redefined-outer-name import os import pytest @@ -12,7 +10,7 @@ @pytest.fixture def inventory(): - """ Fixture for Ansible inventory used in tests """ + """Fixture for Ansible inventory used in tests.""" inventory_dir = os.path.join(FIXTURE_DIR, "inventory") inventory = AnsibleInventory(inventory_dir) @@ -21,7 +19,7 @@ def inventory(): @pytest.fixture def host_vars(inventory): - """ Fixture for providing Ansible host_vars as a consolidated dict """ + """Fixture for providing Ansible host_vars as a consolidated dict.""" hosts = inventory.get_hosts_containing() host_vars = dict() for host in hosts: @@ -32,27 +30,78 @@ def host_vars(inventory): @pytest.fixture(scope="session") def validators(): - """ - Test that validator files are loaded and appended to base class validator list - """ + """Test that validator files are loaded and appended to base class validator list.""" validator_path = os.path.join(FIXTURE_DIR, "validators") return v.load_validators(validator_path) def test_jmespathvalidation_pass(host_vars, validators): + """ + Validator: "interfaces.*[@.type=='core'][] | length([?@])" gte 2 + Test expected to pass for az_phx_pe01 with two core interfaces: + interfaces: + GigabitEthernet0/0/0/0: + type: "core" + GigabitEthernet0/0/0/1: + type: "core" + """ validate = getattr(validators["CheckInterface"], "validate") result = validate(host_vars["az_phx_pe01"], False) assert result[0].passed() def test_jmespathvalidation_fail(host_vars, validators): + """ + Validator: "interfaces.*[@.type=='core'][] | length([?@])" gte 2 + Test expected to fail for az_phx_pe02 with one core interface: + interfaces: + GigabitEthernet0/0/0/0: + type: "core" + GigabitEthernet0/0/0/1: + type: "access" + """ validate = getattr(validators["CheckInterface"], "validate") result = validate(host_vars["az_phx_pe02"], False) assert not result[0].passed() def test_modelvalidation_pass(host_vars, validators): + """ + Validator: Checks that peer and peer_int match between peers + Test expected to pass for az_phx_pe01/az_phx_pe02: + + az_phx_pe01: + GigabitEthernet0/0/0/0: + peer: "az-phx-pe02" + peer_int: "GigabitEthernet0/0/0/0" + + az_phx_pe02: + GigabitEthernet0/0/0/0: + peer: "az-phx-pe01" + peer_int: "GigabitEthernet0/0/0/0" + """ validate = getattr(validators["CheckPeers"], "validate") result = validate(host_vars, False) assert result[0].passed() - assert result[1].passed() + assert result[2].passed() + + +def test_modelvalidation_fail(host_vars, validators): + """ + Validator: Checks that peer and peer_int match between peers + + Test expected to fail for az_phx_pe01/co_den_p01: + + az_phx_pe01: + GigabitEthernet0/0/0/1: + peer: "co-den-p01" + peer_int: "GigabitEthernet0/0/0/2" + + co_den_p01: + GigabitEthernet0/0/0/2: + peer: ut-slc-pe01 + peer_int: GigabitEthernet0/0/0/2 + """ + validate = getattr(validators["CheckPeers"], "validate") + result = validate(host_vars, False) + assert not result[1].passed() From f3a486850ff9d2c78baa40894046b97561142eb8 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 10 Feb 2021 10:47:01 -0700 Subject: [PATCH 24/42] Add example of jmespathvalidation with compile --- docs/custom_validators.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/custom_validators.md b/docs/custom_validators.md index 12953c5..aecbae5 100644 --- a/docs/custom_validators.md +++ b/docs/custom_validators.md @@ -54,7 +54,9 @@ The class provides the following operators for basic use cases: If you require additional logic or need to compare other types, use the ModelValidation class. -### Example: +### Examples: + +#### Basic ``` from schema_enforcer.schemas.validator import JmesPathModelValidation @@ -68,6 +70,23 @@ class CheckInterface(JmesPathModelValidation): error = "Less than two core interfaces" ``` +#### With compiled jmespath expression +``` +import jmespath +from schema_enforcer.schemas.validator import JmesPathModelValidation + + +class CheckInterfaceIPv4(JmesPathModelValidation): + top_level_properties = ["interfaces"] + id = "CheckInterfaceIPv4" + left = "interfaces.*[@.type=='core'][] | length([?@])" + # Schema-enforcer will check if right is a compiled jmespath expression and execute + # search against your data before comparing left and right + right = jmespath.compile("interfaces.* | length([?@.type=='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. From 856102bd3ed0234fc3e5615c021f9b35c71e9913 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 10 Feb 2021 10:47:24 -0700 Subject: [PATCH 25/42] Add additional test for jmespath compile --- .../inventory/host_vars/co_den_p01/base.yml | 2 +- .../validators/check_interfaces_ipv4.py | 14 +++++++ tests/test_schemas_validator.py | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/test_validators/validators/check_interfaces_ipv4.py diff --git a/tests/fixtures/test_validators/inventory/host_vars/co_den_p01/base.yml b/tests/fixtures/test_validators/inventory/host_vars/co_den_p01/base.yml index 65ec1a7..d9d2692 100644 --- a/tests/fixtures/test_validators/inventory/host_vars/co_den_p01/base.yml +++ b/tests/fixtures/test_validators/inventory/host_vars/co_den_p01/base.yml @@ -13,7 +13,7 @@ interfaces: peer: "ut-slc-pe01" peer_int: "GigabitEthernet0/0/0/2" GigabitEthernet0/0/0/3: - ipv4: "10.1.0.45" ipv6: "2001:db8::16" peer: "ut-slc-pe01" peer_int: "GigabitEthernet0/0/0/1" + type: "core" diff --git a/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py b/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py new file mode 100644 index 0000000..da6338e --- /dev/null +++ b/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py @@ -0,0 +1,14 @@ +"""Test validator for JmesPathModelValidation class""" +import jmespath +from schema_enforcer.schemas.validator import JmesPathModelValidation + + +class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-public-methods + """Test validator for JmesPathModelValidation class""" + + top_level_properties = ["interfaces"] + id = "CheckInterfaceIPv4" + left = "interfaces.*[@.type=='core'][] | length([?@])" + right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") + operator = "eq" + error = "All core interfaces do not have IPv4 addresses" diff --git a/tests/test_schemas_validator.py b/tests/test_schemas_validator.py index f0c0762..d7070bf 100644 --- a/tests/test_schemas_validator.py +++ b/tests/test_schemas_validator.py @@ -65,6 +65,43 @@ def test_jmespathvalidation_fail(host_vars, validators): assert not result[0].passed() +def test_jmespathvalidation_with_compile_pass(host_vars, validators): + """ + Validator: "interfaces.*[@.type=='core'][] | length([?@])" eq jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") + Test expected to pass for az_phx_pe01 where all core interfaces have IPv4 addresses: + 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" + """ + validate = getattr(validators["CheckInterfaceIPv4"], "validate") + result = validate(host_vars["az_phx_pe01"], False) + assert result[0].passed() + + +def test_jmespathvalidation_with_compile_fail(host_vars, validators): + """ + Validator: "interfaces.*[@.type=='core'][] | length([?@])" eq jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") + Test expected to fail for co_den_p01 where core interface is missing an IPv4 addresses: + GigabitEthernet0/0/0/3: + ipv6: "2001:db8::16" + peer: "ut-slc-pe01" + peer_int: "GigabitEthernet0/0/0/1" + type: "core" + """ + validate = getattr(validators["CheckInterfaceIPv4"], "validate") + result = validate(host_vars["co_den_p01"], False) + assert not result[0].passed() + + def test_modelvalidation_pass(host_vars, validators): """ Validator: Checks that peer and peer_int match between peers From 54270779ebd6a9bece18fd568f8e621c6a66616d Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Fri, 12 Feb 2021 08:33:09 -0700 Subject: [PATCH 26/42] Remove unused f-string --- schema_enforcer/schemas/manager.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/schema_enforcer/schemas/manager.py b/schema_enforcer/schemas/manager.py index 5c93904..c44f8fa 100644 --- a/schema_enforcer/schemas/manager.py +++ b/schema_enforcer/schemas/manager.py @@ -45,8 +45,7 @@ def __init__(self, config): self.schemas[schema.get_id()] = schema # Load validators - full_validator_dir = f"{config.validator_directory}" - validators = load_validators(full_validator_dir) + validators = load_validators(config.validator_directory) self.schemas.update(validators) def create_schema_from_file(self, root, filename): # pylint: disable=no-self-use From db5109c235dfdd29651216d4d5508370e0504e35 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 25 Feb 2021 10:32:48 -0700 Subject: [PATCH 27/42] Refactor base classes for common use --- schema_enforcer/schemas/validator.py | 57 ++++++++++++------- .../validators/check_interfaces.py | 14 +++-- .../validators/check_interfaces_ipv4.py | 14 +++-- .../test_validators/validators/check_peers.py | 33 ++--------- tests/test_schemas_validator.py | 35 ++++++++---- 5 files changed, 81 insertions(+), 72 deletions(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index ef99b09..c816068 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -7,19 +7,37 @@ from schema_enforcer.validation import ValidationResult -class ModelValidation: - """Base class for ModelValidation classes.""" +class BaseValidation: + """Base class for Validation classes.""" - @classmethod - def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: + def __init__(self): + self._results = [] + + def add_validation_error(self, message): + self._results.append(ValidationResult(result="FAIL", schema_id=self.id, message=message)) + + def add_validation_pass(self): + self._results.append(ValidationResult(result="PASS", schema_id=self.id)) + + def get_results(self): + """Return all validation results for this validator.""" + if not self._results: + self._results.append(ValidationResult(result="PASS", schema_id=self.id)) + + return self._results + + def clear_results(self): + self._results = [] + + def validate(self, data: dict, strict: bool): """Required function for custom validator.""" + raise NotImplementedError -class JmesPathModelValidation(ModelValidation): +class JmesPathModelValidation(BaseValidation): """Base class for JmesPathModelValidation classes.""" - @classmethod - def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: # pylint: disable=W0613 + def validate(self, data: dict, strict: bool): # pylint: disable=W0613 """Validate data using custom jmespath validator plugin.""" operators = { "gt": lambda r, v: int(r) > int(v), @@ -29,31 +47,28 @@ def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: # py "lte": lambda r, v: int(r) <= int(v), "contains": lambda r, v: v in r, } - lhs = jmespath.search(cls.left, data) + lhs = jmespath.search(self.left, data) valid = True if lhs: # Check rhs for compiled jmespath expression - if isinstance(cls.right, jmespath.parser.ParsedResult): - rhs = cls.right.search(data) + if isinstance(self.right, jmespath.parser.ParsedResult): + rhs = self.right.search(data) else: - rhs = cls.right - valid = operators[cls.operator](lhs, rhs) - if valid: - result = "PASS" - else: - result = "FAIL" - return [ValidationResult(result=result, schema_id=cls.id, message=cls.error)] + rhs = self.right + valid = operators[self.operator](lhs, rhs) + if not valid: + self.add_validation_error(self.error) def is_validator(obj) -> bool: - """Returns True if the object is a ModelValidation or JmesPathModelValidation subclass.""" + """Returns True if the object is a BaseValidation or JmesPathModelValidation subclass.""" try: - return issubclass(obj, ModelValidation) and obj not in (JmesPathModelValidation, ModelValidation,) + return issubclass(obj, BaseValidation) and obj not in (JmesPathModelValidation, BaseValidation) except TypeError: return False -def load_validators(validator_path: str) -> Iterable[ModelValidation]: +def load_validators(validator_path: str) -> Iterable[BaseValidation]: """Load all validator plugins from validator_path.""" validators = dict() for importer, module_name, _ in pkgutil.iter_modules([validator_path]): @@ -65,5 +80,5 @@ def load_validators(validator_path: str) -> Iterable[ModelValidation]: if cls.id in validators: print(f"Duplicate validator name: {cls.id}") else: - validators[cls.id] = cls + validators[cls.id] = cls() return validators diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py index 3b119d4..ea7faf9 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces.py +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -5,9 +5,11 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods """Test validator for JmesPathModelValidation class""" - top_level_properties = ["interfaces"] - id = "CheckInterface" - left = "interfaces.*[@.type=='core'][] | length([?@])" - right = 2 - operator = "gte" - error = "Less than two core interfaces" + def __init__(self): + super().__init__() + self.top_level_properties = ["interfaces"] + self.id = "CheckInterface" + self.left = "interfaces.*[@.type=='core'][] | length([?@])" + self.right = 2 + self.operator = "gte" + self.error = "Less than two core interfaces" diff --git a/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py b/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py index da6338e..5d63328 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py +++ b/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py @@ -6,9 +6,11 @@ class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-public-methods """Test validator for JmesPathModelValidation class""" - top_level_properties = ["interfaces"] - id = "CheckInterfaceIPv4" - left = "interfaces.*[@.type=='core'][] | length([?@])" - right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") - operator = "eq" - error = "All core interfaces do not have IPv4 addresses" + def __init__(self): + super().__init__() + self.top_level_properties = ["interfaces"] + self.id = "CheckInterfaceIPv4" + self.left = "interfaces.*[@.type=='core'][] | length([?@])" + self.right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") + self.operator = "eq" + self.error = "All core interfaces do not have IPv4 addresses" diff --git a/tests/fixtures/test_validators/validators/check_peers.py b/tests/fixtures/test_validators/validators/check_peers.py index 55757f5..9c2fb9c 100644 --- a/tests/fixtures/test_validators/validators/check_peers.py +++ b/tests/fixtures/test_validators/validators/check_peers.py @@ -1,6 +1,6 @@ """Test validator for ModelValidation class""" from typing import Iterable -from schema_enforcer.schemas.validator import ModelValidation +from schema_enforcer.schemas.validator import BaseValidation from schema_enforcer.validation import ValidationResult @@ -14,7 +14,7 @@ def normal_hostname(hostname: str): return hostname.replace("_", "-") -class CheckPeers(ModelValidation): # pylint: disable=too-few-public-methods +class CheckPeers(BaseValidation): # pylint: disable=too-few-public-methods """ Validate that peer and peer_int are defined properly on both sides of a connection @@ -23,24 +23,14 @@ class CheckPeers(ModelValidation): # pylint: disable=too-few-public-methods id = "CheckPeers" - @classmethod - def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: - results = list() + def validate(self, data: dict, strict: bool) -> Iterable[ValidationResult]: for host in data: for interface, int_cfg in data[host]["interfaces"].items(): if "peer" not in int_cfg: continue peer = int_cfg["peer"] if "peer_int" not in int_cfg: - results.append( - ValidationResult( - result="FAIL", - schema_id=cls.id, - message="Peer interface is not defined", - instance_hostname=host, - instance_name=peer, - ) - ) + self.add_validation_error("Peer interface is not defined") continue peer_int = int_cfg["peer_int"] peer = ansible_hostname(peer) @@ -49,17 +39,6 @@ def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]: peer_match = data[peer]["interfaces"][peer_int]["peer"] == normal_hostname(host) peer_int_match = data[peer]["interfaces"][peer_int]["peer_int"] == interface if peer_match and peer_int_match: - results.append( - ValidationResult(result="PASS", schema_id=cls.id, instance_hostname=host, instance_name=peer) - ) + self.add_validation_pass() else: - results.append( - ValidationResult( - result="FAIL", - schema_id=cls.id, - message="Peer information does not match.", - instance_hostname=host, - instance_name=peer, - ) - ) - return results + self.add_validation_error("Peer information does not match.") diff --git a/tests/test_schemas_validator.py b/tests/test_schemas_validator.py index d7070bf..cac4be4 100644 --- a/tests/test_schemas_validator.py +++ b/tests/test_schemas_validator.py @@ -45,9 +45,11 @@ def test_jmespathvalidation_pass(host_vars, validators): GigabitEthernet0/0/0/1: type: "core" """ - validate = getattr(validators["CheckInterface"], "validate") - result = validate(host_vars["az_phx_pe01"], False) + validator = validators["CheckInterface"] + validator.validate(host_vars["az_phx_pe01"], False) + result = validator.get_results() assert result[0].passed() + validator.clear_results() def test_jmespathvalidation_fail(host_vars, validators): @@ -60,9 +62,11 @@ def test_jmespathvalidation_fail(host_vars, validators): GigabitEthernet0/0/0/1: type: "access" """ - validate = getattr(validators["CheckInterface"], "validate") - result = validate(host_vars["az_phx_pe02"], False) + validator = validators["CheckInterface"] + validator.validate(host_vars["az_phx_pe02"], False) + result = validator.get_results() assert not result[0].passed() + validator.clear_results() def test_jmespathvalidation_with_compile_pass(host_vars, validators): @@ -82,9 +86,11 @@ def test_jmespathvalidation_with_compile_pass(host_vars, validators): peer_int: "GigabitEthernet0/0/0/2" type: "core" """ - validate = getattr(validators["CheckInterfaceIPv4"], "validate") - result = validate(host_vars["az_phx_pe01"], False) + validator = validators["CheckInterfaceIPv4"] + validator.validate(host_vars["az_phx_pe01"], False) + result = validator.get_results() assert result[0].passed() + validator.clear_results() def test_jmespathvalidation_with_compile_fail(host_vars, validators): @@ -97,9 +103,11 @@ def test_jmespathvalidation_with_compile_fail(host_vars, validators): peer_int: "GigabitEthernet0/0/0/1" type: "core" """ - validate = getattr(validators["CheckInterfaceIPv4"], "validate") - result = validate(host_vars["co_den_p01"], False) + validator = validators["CheckInterfaceIPv4"] + validator.validate(host_vars["co_den_p01"], False) + result = validator.get_results() assert not result[0].passed() + validator.clear_results() def test_modelvalidation_pass(host_vars, validators): @@ -117,10 +125,12 @@ def test_modelvalidation_pass(host_vars, validators): peer: "az-phx-pe01" peer_int: "GigabitEthernet0/0/0/0" """ - validate = getattr(validators["CheckPeers"], "validate") - result = validate(host_vars, False) + validator = validators["CheckPeers"] + validator.validate(host_vars, False) + result = validator.get_results() assert result[0].passed() assert result[2].passed() + validator.clear_results() def test_modelvalidation_fail(host_vars, validators): @@ -139,6 +149,7 @@ def test_modelvalidation_fail(host_vars, validators): peer: ut-slc-pe01 peer_int: GigabitEthernet0/0/0/2 """ - validate = getattr(validators["CheckPeers"], "validate") - result = validate(host_vars, False) + validator = validators["CheckPeers"] + validator.validate(host_vars, False) + result = validator.get_results() assert not result[1].passed() From 1459cc06b6c967b11f82c8e71eefad83fdce7887 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 25 Feb 2021 13:31:27 -0700 Subject: [PATCH 28/42] Disable pylint rule --- tests/fixtures/test_validators/validators/check_interfaces.py | 2 +- .../test_validators/validators/check_interfaces_ipv4.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py index ea7faf9..5456140 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces.py +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -8,7 +8,7 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public def __init__(self): super().__init__() self.top_level_properties = ["interfaces"] - self.id = "CheckInterface" + self.id = "CheckInterface" # pylint: disable=invalid-name self.left = "interfaces.*[@.type=='core'][] | length([?@])" self.right = 2 self.operator = "gte" diff --git a/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py b/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py index 5d63328..820f32a 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py +++ b/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py @@ -9,7 +9,7 @@ class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-pu def __init__(self): super().__init__() self.top_level_properties = ["interfaces"] - self.id = "CheckInterfaceIPv4" + self.id = "CheckInterfaceIPv4" # pylint: disable=invalid-name self.left = "interfaces.*[@.type=='core'][] | length([?@])" self.right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") self.operator = "eq" From 0a120a9c68fd5a41008029d5c3c90df8cb6259b2 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 25 Feb 2021 13:32:00 -0700 Subject: [PATCH 29/42] Refactor jsonschema to use BaseValidation class --- schema_enforcer/instances/file.py | 5 ++++- schema_enforcer/schemas/jsonschema.py | 13 ++++++------- tests/test_jsonschema.py | 16 ++++++++++++---- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/schema_enforcer/instances/file.py b/schema_enforcer/instances/file.py index 83e7185..483910a 100644 --- a/schema_enforcer/instances/file.py +++ b/schema_enforcer/instances/file.py @@ -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 diff --git a/schema_enforcer/schemas/jsonschema.py b/schema_enforcer/schemas/jsonschema.py index 48e94cd..227ecf0 100644 --- a/schema_enforcer/schemas/jsonschema.py +++ b/schema_enforcer/schemas/jsonschema.py @@ -3,6 +3,7 @@ import pkgutil import json from jsonschema import Draft7Validator # pylint: disable=import-self +from schema_enforcer.schemas.validator import BaseValidation from schema_enforcer.validation import ValidationResult, RESULT_FAIL, RESULT_PASS # TODO do we need to catch a possible exception here ? @@ -10,7 +11,7 @@ v7schema = json.loads(v7data.decode("utf-8")) -class JsonSchema: +class JsonSchema(BaseValidation): """class to manage jsonschema type schemas.""" schematype = "jsonchema" @@ -23,6 +24,7 @@ def __init__(self, schema, filename, root): filename (string): Name of the schema file on the filesystem. root (string): Absolute path to the directory where the schema file is located. """ + super().__init__() self.filename = filename self.root = root self.data = schema @@ -56,14 +58,11 @@ def validate(self, data, strict=False): for err in validator.iter_errors(data): has_error = True - yield ValidationResult( - schema_id=self.id, result=RESULT_FAIL, message=err.message, absolute_path=list(err.absolute_path) - ) + self.add_validation_error(err.message, absolute_path=list(err.absolute_path)) if not has_error: - yield ValidationResult( - schema_id=self.id, result=RESULT_PASS, - ) + self.add_validation_pass() + return self.get_results() def validate_to_dict(self, data, strict=False): """Return a list of ValidationResult objects. diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 60d5f30..255d859 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -71,28 +71,36 @@ def test_validate(schema_instance, valid_instance_data, invalid_instance_data, s Args: schema_instance (JsonSchema): Instance of JsonSchema class """ - validation_results = list(schema_instance.validate(data=valid_instance_data)) + schema_instance.validate(data=valid_instance_data) + validation_results = schema_instance.get_results() assert len(validation_results) == 1 assert validation_results[0].schema_id == LOADED_SCHEMA_DATA.get("$id") assert validation_results[0].result == RESULT_PASS assert validation_results[0].message is None + schema_instance.clear_results() - validation_results = list(schema_instance.validate(data=invalid_instance_data)) + schema_instance.validate(data=invalid_instance_data) + validation_results = schema_instance.get_results() assert len(validation_results) == 1 assert validation_results[0].schema_id == LOADED_SCHEMA_DATA.get("$id") assert validation_results[0].result == RESULT_FAIL assert validation_results[0].message == "True is not of type 'string'" assert validation_results[0].absolute_path == ["dns_servers", "0", "address"] + schema_instance.clear_results() - validation_results = list(schema_instance.validate(data=strict_invalid_instance_data, strict=False)) + schema_instance.validate(data=strict_invalid_instance_data, strict=False) + validation_results = schema_instance.get_results() assert validation_results[0].result == RESULT_PASS + schema_instance.clear_results() - validation_results = list(schema_instance.validate(data=strict_invalid_instance_data, strict=True)) + schema_instance.validate(data=strict_invalid_instance_data, strict=True) + validation_results = schema_instance.get_results() assert validation_results[0].result == RESULT_FAIL assert ( validation_results[0].message == "Additional properties are not allowed ('fun_extr_attribute' was unexpected)" ) + schema_instance.clear_results() @staticmethod def test_validate_to_dict(schema_instance, valid_instance_data): From 67c0cff7402eac2889d8c809aa1bead12125af31 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 25 Feb 2021 13:51:41 -0700 Subject: [PATCH 30/42] Refactor ansible cli for BaseValidation class --- schema_enforcer/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/schema_enforcer/cli.py b/schema_enforcer/cli.py index 2ebbbe2..3eba5b1 100644 --- a/schema_enforcer/cli.py +++ b/schema_enforcer/cli.py @@ -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) + 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")) From 220590b907e57545f645e37b78b0b9d0d96877e4 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 25 Feb 2021 13:51:54 -0700 Subject: [PATCH 31/42] Fix pydocstyle errors --- schema_enforcer/schemas/validator.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index c816068..4b26ffa 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -11,15 +11,27 @@ class BaseValidation: """Base class for Validation classes.""" def __init__(self): - self._results = [] + """Base init for all validation classes.""" + self._results: Iterable[ValidationResult] = [] + + 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 + """ + self._results.append(ValidationResult(result="FAIL", schema_id=self.id, message=message, **kwargs)) - def add_validation_error(self, message): - self._results.append(ValidationResult(result="FAIL", schema_id=self.id, message=message)) + def add_validation_pass(self, **kwargs): + """Add validator pass to results. - def add_validation_pass(self): - self._results.append(ValidationResult(result="PASS", schema_id=self.id)) + Args: + kwargs (optional): additional arguments to add to ValidationResult when required + """ + self._results.append(ValidationResult(result="PASS", schema_id=self.id, **kwargs)) - def get_results(self): + def get_results(self) -> Iterable[ValidationResult]: """Return all validation results for this validator.""" if not self._results: self._results.append(ValidationResult(result="PASS", schema_id=self.id)) @@ -27,6 +39,7 @@ def get_results(self): return self._results def clear_results(self): + """Reset results for validator instance.""" self._results = [] def validate(self, data: dict, strict: bool): From afc47b6b8f0f61d181f84d33d261b862f5e42ba7 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 25 Feb 2021 15:16:02 -0700 Subject: [PATCH 32/42] Update documentation for new base class usage --- docs/custom_validators.md | 73 ++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/docs/custom_validators.md b/docs/custom_validators.md index aecbae5..ccd2391 100644 --- a/docs/custom_validators.md +++ b/docs/custom_validators.md @@ -6,15 +6,16 @@ load your plugins from the `validator_directory` and run them against your host 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. -## ModelValidation +## 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 ModelValidation class to correctly register with schema-enforcer. -3. Provide a class method in your subclass with the following signature: -`def validate(cls, data: dict, strict: bool) -> Iterable[ValidationResult]:` +2. Include a subclass of the BaseValidation class to correctly register with schema-enforcer. +3. Ensure you call `super().__init__()` in your class `__init__`. +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 @@ -23,6 +24,25 @@ the following criteria: 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 @@ -30,7 +50,8 @@ 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 (not instance): +3. Ensure you call `super().__init__()` in your class `__init__`. +4. Provide the following instance 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) @@ -60,14 +81,15 @@ If you require additional logic or need to compare other types, use the ModelVal ``` from schema_enforcer.schemas.validator import JmesPathModelValidation - -class CheckInterface(JmesPathModelValidation): - top_level_properties = ["interfaces"] - id = "CheckInterface" - left = "interfaces.*[@.type=='core'][] | length([?@])" - right = 2 - operator = "eq" - error = "Less than two core interfaces" +class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods + def __init__(self): + super().__init__() + self.top_level_properties = ["interfaces"] + self.id = "CheckInterface" # pylint: disable=invalid-name + self.left = "interfaces.*[@.type=='core'][] | length([?@])" + self.right = 2 + self.operator = "gte" + self.error = "Less than two core interfaces" ``` #### With compiled jmespath expression @@ -76,15 +98,16 @@ import jmespath from schema_enforcer.schemas.validator import JmesPathModelValidation -class CheckInterfaceIPv4(JmesPathModelValidation): - top_level_properties = ["interfaces"] - id = "CheckInterfaceIPv4" - left = "interfaces.*[@.type=='core'][] | length([?@])" - # Schema-enforcer will check if right is a compiled jmespath expression and execute - # search against your data before comparing left and right - right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") - operator = "eq" - error = "All core interfaces do not have IPv4 addresses" +class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-public-methods + def __init__(self): + super().__init__() + self.top_level_properties = ["interfaces"] + self.id = "CheckInterfaceIPv4" # pylint: disable=invalid-name + self.left = "interfaces.*[@.type=='core'][] | length([?@])" + self.right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") + self.operator = "eq" + self.error = "All core interfaces do not have IPv4 addresses" + ``` ## Running validators @@ -100,8 +123,10 @@ for more details. The CheckInterface validator has a top_level_properties of "interfaces": ``` -class CheckInterface(JmesPathModelValidation): - top_level_properties = ["interfaces"] +class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods + def __init__(self): + super().__init__() + self.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: From ad3182710d2fd74da4fd2c0160d74db4cf9ed59c Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 25 Feb 2021 15:54:06 -0700 Subject: [PATCH 33/42] Update example validator --- .../ansible3/validators/check_interfaces.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/examples/ansible3/validators/check_interfaces.py b/examples/ansible3/validators/check_interfaces.py index 6863b18..0357819 100644 --- a/examples/ansible3/validators/check_interfaces.py +++ b/examples/ansible3/validators/check_interfaces.py @@ -3,12 +3,14 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods - """Check that each device has at least two core uplinks.""" + """Check that each device has more than one core uplink.""" - top_level_properties = ["interfaces"] - id = "CheckInterface" - model = "interfaces" - left = "interfaces.*[@.type=='core'][] | length([?@])" - right = 2 - operator = "gte" - error = "Less than two core interfaces" + def __init__(self): + """Initialize vars required for jmespath validation.""" + super().__init__() + self.top_level_properties = ["interfaces"] + self.id = "CheckInterface" # pylint: disable=invalid-name + self.left = "interfaces.*[@.type=='core'][] | length([?@])" + self.right = 2 + self.operator = "gte" + self.error = "Less than two core interfaces" From 0d5c5734773382ebfef8f6c47231ffea3a6b269c Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Thu, 25 Feb 2021 15:54:29 -0700 Subject: [PATCH 34/42] Exclude examples dir from pylint due to dup-code --- tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index d7a952a..e2fddad 100644 --- a/tasks.py +++ b/tasks.py @@ -226,7 +226,8 @@ def pylint(context, name=NAME, image_ver=IMAGE_VER, local=INVOKE_LOCAL): """ # pty is set to true to properly run the docker commands due to the invocation process of docker # https://docs.pyinvoke.org/en/latest/api/runners.html - Search for pty for more information - exec_cmd = 'find . -name "*.py" | xargs pylint' + # Examples directory excluded due to pylint duplicate-code errors + exec_cmd = 'find . -name "*.py" -not -path "./examples/*" | xargs pylint' run_cmd(context, exec_cmd, name, image_ver, local) From e1dbdf93e65cd12ce9fed6f74aebf5ebae31cb07 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Fri, 26 Feb 2021 15:49:24 -0700 Subject: [PATCH 35/42] Revert to class vars for jmespathmodelvalidation --- docs/custom_validators.md | 43 +++++++------------ .../ansible3/validators/check_interfaces.py | 15 +++---- .../validators/check_interfaces.py | 14 +++--- .../validators/check_interfaces_ipv4.py | 14 +++--- 4 files changed, 34 insertions(+), 52 deletions(-) diff --git a/docs/custom_validators.md b/docs/custom_validators.md index ccd2391..96c3900 100644 --- a/docs/custom_validators.md +++ b/docs/custom_validators.md @@ -13,7 +13,7 @@ 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__`. +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):` @@ -50,8 +50,7 @@ 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. Ensure you call `super().__init__()` in your class `__init__`. -4. Provide the following instance level variables: +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) @@ -73,7 +72,7 @@ The class provides the following operators for basic use cases: "contains": right in left, ``` -If you require additional logic or need to compare other types, use the ModelValidation class. +If you require additional logic or need to compare other types, use the BaseValidation class and create your own validate method. ### Examples: @@ -82,14 +81,12 @@ If you require additional logic or need to compare other types, use the ModelVal from schema_enforcer.schemas.validator import JmesPathModelValidation class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods - def __init__(self): - super().__init__() - self.top_level_properties = ["interfaces"] - self.id = "CheckInterface" # pylint: disable=invalid-name - self.left = "interfaces.*[@.type=='core'][] | length([?@])" - self.right = 2 - self.operator = "gte" - self.error = "Less than two core interfaces" + 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" ``` #### With compiled jmespath expression @@ -99,15 +96,12 @@ from schema_enforcer.schemas.validator import JmesPathModelValidation class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-public-methods - def __init__(self): - super().__init__() - self.top_level_properties = ["interfaces"] - self.id = "CheckInterfaceIPv4" # pylint: disable=invalid-name - self.left = "interfaces.*[@.type=='core'][] | length([?@])" - self.right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") - self.operator = "eq" - self.error = "All core interfaces do not have IPv4 addresses" - + top_level_properties = ["interfaces"] + id = "CheckInterfaceIPv4" # pylint: disable=invalid-name + left = "interfaces.*[@.type=='core'][] | length([?@])" + right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") + operator = "eq" + error = "All core interfaces do not have IPv4 addresses" ``` ## Running validators @@ -124,9 +118,7 @@ The CheckInterface validator has a top_level_properties of "interfaces": ``` class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods - def __init__(self): - super().__init__() - self.top_level_properties = ["interfaces"] + 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: @@ -164,6 +156,3 @@ schema_enforcer_automap_default: false schema_enforcer_schema_ids: - "CheckInterface" ``` - - - diff --git a/examples/ansible3/validators/check_interfaces.py b/examples/ansible3/validators/check_interfaces.py index 0357819..2c69fbf 100644 --- a/examples/ansible3/validators/check_interfaces.py +++ b/examples/ansible3/validators/check_interfaces.py @@ -5,12 +5,9 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods """Check that each device has more than one core uplink.""" - def __init__(self): - """Initialize vars required for jmespath validation.""" - super().__init__() - self.top_level_properties = ["interfaces"] - self.id = "CheckInterface" # pylint: disable=invalid-name - self.left = "interfaces.*[@.type=='core'][] | length([?@])" - self.right = 2 - self.operator = "gte" - self.error = "Less than two core interfaces" + 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" diff --git a/tests/fixtures/test_validators/validators/check_interfaces.py b/tests/fixtures/test_validators/validators/check_interfaces.py index 5456140..960a9db 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces.py +++ b/tests/fixtures/test_validators/validators/check_interfaces.py @@ -5,11 +5,9 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods """Test validator for JmesPathModelValidation class""" - def __init__(self): - super().__init__() - self.top_level_properties = ["interfaces"] - self.id = "CheckInterface" # pylint: disable=invalid-name - self.left = "interfaces.*[@.type=='core'][] | length([?@])" - self.right = 2 - self.operator = "gte" - self.error = "Less than two core interfaces" + 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" diff --git a/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py b/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py index 820f32a..37348b6 100644 --- a/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py +++ b/tests/fixtures/test_validators/validators/check_interfaces_ipv4.py @@ -6,11 +6,9 @@ class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-public-methods """Test validator for JmesPathModelValidation class""" - def __init__(self): - super().__init__() - self.top_level_properties = ["interfaces"] - self.id = "CheckInterfaceIPv4" # pylint: disable=invalid-name - self.left = "interfaces.*[@.type=='core'][] | length([?@])" - self.right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") - self.operator = "eq" - self.error = "All core interfaces do not have IPv4 addresses" + top_level_properties = ["interfaces"] + id = "CheckInterfaceIPv4" # pylint: disable=invalid-name + left = "interfaces.*[@.type=='core'][] | length([?@])" + right = jmespath.compile("interfaces.* | length([?@.type=='core'][].ipv4)") + operator = "eq" + error = "All core interfaces do not have IPv4 addresses" From 99eef97b0b703f419f0dca2700cd9cef3388cea6 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Fri, 26 Feb 2021 15:55:38 -0700 Subject: [PATCH 36/42] Increase pylint min-similarity-lines --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9d73892..b5a332d 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,9 @@ notes = """, XXX, """ +[tool.pylint.SIMILARITIES] +min-similarity-lines = 15 + [tool.pytest.ini_options] testpaths = [ "tests" From abaf0a700d5af1b11fb22d020c9ac016cdd20d6e Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Fri, 26 Feb 2021 15:55:52 -0700 Subject: [PATCH 37/42] Revert exclude of examples for pylint --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index e2fddad..9087e51 100644 --- a/tasks.py +++ b/tasks.py @@ -227,7 +227,7 @@ def pylint(context, name=NAME, image_ver=IMAGE_VER, local=INVOKE_LOCAL): # pty is set to true to properly run the docker commands due to the invocation process of docker # https://docs.pyinvoke.org/en/latest/api/runners.html - Search for pty for more information # Examples directory excluded due to pylint duplicate-code errors - exec_cmd = 'find . -name "*.py" -not -path "./examples/*" | xargs pylint' + exec_cmd = 'find . -name "*.py" | xargs pylint' run_cmd(context, exec_cmd, name, image_ver, local) From 622feeacd8dd46677e6070b2288a80bda0a661c1 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Fri, 26 Feb 2021 16:00:02 -0700 Subject: [PATCH 38/42] Add link to custom validator doc in README --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8964887..704af9d 100755 --- a/README.md +++ b/README.md @@ -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 ``` @@ -140,14 +140,18 @@ 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" +<<<<<<< HEAD ``` ```shell -bash$ test-schema validate +bash$ test-schema validate +======= +bash$ test-schema validate +>>>>>>> a07ffe0... Add link to custom validator doc in README FAIL | [ERROR] True is not of type 'string' [FILE] ./chi-beijing-rt1/dns.yml [PROPERTY] dns_servers:0:address bash$ echo $? 1 @@ -160,7 +164,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 @@ -198,3 +202,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) From 01297dc59eb09c59d3d98aab51b6346f9c2739ed Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Mon, 1 Mar 2021 15:28:35 -0700 Subject: [PATCH 39/42] Update validate doc string --- schema_enforcer/schemas/validator.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index 4b26ffa..fd4c4ad 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -43,7 +43,17 @@ def clear_results(self): self._results = [] def validate(self, data: dict, strict: bool): - """Required function for custom validator.""" + """Required function for custom validator. + + Args: + data (dict): variables to be validated by validator + strict (bool): true when --strict cli option is used to request strict validation (if provided) + + Returns: + None + + Use add_validation_error and add_validation_pass to report results. + """ raise NotImplementedError From 7d557ef0d14dac8a26993f3ce0c7f871068db5c3 Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Tue, 2 Mar 2021 08:26:17 -0700 Subject: [PATCH 40/42] Fix unaccepted change from rebase --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 704af9d..29c768a 100755 --- a/README.md +++ b/README.md @@ -145,13 +145,9 @@ bash$ cat chi-beijing-rt1/dns.yml dns_servers: - address: true - address: "10.2.2.2" -<<<<<<< HEAD ``` ```shell bash$ test-schema validate -======= -bash$ test-schema validate ->>>>>>> a07ffe0... Add link to custom validator doc in README FAIL | [ERROR] True is not of type 'string' [FILE] ./chi-beijing-rt1/dns.yml [PROPERTY] dns_servers:0:address bash$ echo $? 1 From e264591d27b808cdf8872d94135e6939d7996c9e Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 3 Mar 2021 09:29:40 -0700 Subject: [PATCH 41/42] Update type annotations --- schema_enforcer/schemas/validator.py | 9 +++++---- tests/fixtures/test_validators/validators/check_peers.py | 4 +--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index fd4c4ad..cd62443 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -1,8 +1,9 @@ """Classes for custom validator plugins.""" # pylint: disable=no-member, too-few-public-methods +# See PEP585 (https://www.python.org/dev/peps/pep-0585/) +from __future__ import annotations import pkgutil import inspect -from typing import Iterable import jmespath from schema_enforcer.validation import ValidationResult @@ -12,7 +13,7 @@ class BaseValidation: def __init__(self): """Base init for all validation classes.""" - self._results: Iterable[ValidationResult] = [] + self._results: list[ValidationResult] = [] def add_validation_error(self, message: str, **kwargs): """Add validator error to results. @@ -31,7 +32,7 @@ def add_validation_pass(self, **kwargs): """ self._results.append(ValidationResult(result="PASS", schema_id=self.id, **kwargs)) - def get_results(self) -> Iterable[ValidationResult]: + def get_results(self) -> list[ValidationResult]: """Return all validation results for this validator.""" if not self._results: self._results.append(ValidationResult(result="PASS", schema_id=self.id)) @@ -91,7 +92,7 @@ def is_validator(obj) -> bool: return False -def load_validators(validator_path: str) -> Iterable[BaseValidation]: +def load_validators(validator_path: str) -> dict[str, BaseValidation]: """Load all validator plugins from validator_path.""" validators = dict() for importer, module_name, _ in pkgutil.iter_modules([validator_path]): diff --git a/tests/fixtures/test_validators/validators/check_peers.py b/tests/fixtures/test_validators/validators/check_peers.py index 9c2fb9c..ccea521 100644 --- a/tests/fixtures/test_validators/validators/check_peers.py +++ b/tests/fixtures/test_validators/validators/check_peers.py @@ -1,7 +1,5 @@ """Test validator for ModelValidation class""" -from typing import Iterable from schema_enforcer.schemas.validator import BaseValidation -from schema_enforcer.validation import ValidationResult def ansible_hostname(hostname: str): @@ -23,7 +21,7 @@ class CheckPeers(BaseValidation): # pylint: disable=too-few-public-methods id = "CheckPeers" - def validate(self, data: dict, strict: bool) -> Iterable[ValidationResult]: + def validate(self, data: dict, strict: bool): for host in data: for interface, int_cfg in data[host]["interfaces"].items(): if "peer" not in int_cfg: From 8e1dab775109006e13dd506288e121542d8e69be Mon Sep 17 00:00:00 2001 From: Chip Nielsen Date: Wed, 3 Mar 2021 09:38:56 -0700 Subject: [PATCH 42/42] Update duplicate validator error --- schema_enforcer/schemas/validator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/schema_enforcer/schemas/validator.py b/schema_enforcer/schemas/validator.py index cd62443..7e4337e 100644 --- a/schema_enforcer/schemas/validator.py +++ b/schema_enforcer/schemas/validator.py @@ -102,7 +102,9 @@ def load_validators(validator_path: str) -> dict[str, BaseValidation]: if not hasattr(cls, "id"): cls.id = name if cls.id in validators: - print(f"Duplicate validator name: {cls.id}") + print( + f"Unable to load the validator {cls.id}, there is already a validator with the same name ({name})." + ) else: validators[cls.id] = cls() return validators