From 9862129ab09c8a99628e7081c412eba4a7c3786c Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Sun, 13 Mar 2022 09:54:50 +0000 Subject: [PATCH] Initial commit --- .gitignore | 3 + README.md | 17 +++++ example.yaml | 53 ++++++++++++++ schema.yaml | 30 ++++++++ validator/__init__.py | 77 ++++++++++++++++++++ validator/bondethernet.py | 44 +++++++++++ validator/bridgedomain.py | 15 ++++ validator/interface.py | 149 ++++++++++++++++++++++++++++++++++++++ validator/loopback.py | 15 ++++ vppcfg | 48 ++++++++++++ 10 files changed, 451 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 example.yaml create mode 100644 schema.yaml create mode 100644 validator/__init__.py create mode 100644 validator/bondethernet.py create mode 100644 validator/bridgedomain.py create mode 100644 validator/interface.py create mode 100644 validator/loopback.py create mode 100755 vppcfg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..545fd19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce23922 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +## Design + +### Validators + +Validators are functions which are passed the fully formed YAML configuration and are meant to +check it for syntax and semantic validity. A validator has a unique name and takes the (yaml) +configuration as the only argument. Validators are expected to return a tuple of (bool,[string]) +where the boolean signals success (False meaning the validator rejected the configuration file, +True meaning it is known to be correct), and a list of zero or more strings which contain messages +meant for human consumption. They can contain INFO, WARN or ERROR messages, and are meant to help +the caller understand why the validator rejected the configuration. + +Validators can be disabled with the --skip-validator <name> [<name>] flag. It is not +advised to skip validators. The purpose of the validators is to ensure the configuration is sane +and semantically correct. + +Validators can be registered as follows: diff --git a/example.yaml b/example.yaml new file mode 100644 index 0000000..ec57028 --- /dev/null +++ b/example.yaml @@ -0,0 +1,53 @@ +bondethernets: + BondEthernet0: + description: "Infra: xsw0.lab.ipng.ch LACP" + interfaces: [ GigabitEthernet2/0/0, GigabitEthernet2/0/1 ] + +interfaces: + GigabitEthernet1/0/0: + description: "Infra: nikhef-core-1.nl.switch.coloclue.net e1/34" + lcp: e0-0 + addresses: [ 94.142.244.85/24, 2A02:898::146:1/64 ] + sub-interfaces: + 100: + description: "Cust: hvn0.nlams0.ipng.ch" + addresses: [ 94.142.241.185/29, 2a02:898:146::1/64 ] + 101: + description: "Infra: L2 for FrysIX AS112" + + GigabitEthernet2/0/0: + description: "Infra: LAG to xsw0" + + GigabitEthernet2/0/1: + description: "Infra: LAG to xsw1" + + BondEthernet0: + description: "Bond, James Bond!" + mac: 00:01:02:03:04:05 + sub-interfaces: + 200: + encapsulation: + dot1q: 1000 + 201: + encapsulation: + dot1ad: 1000 + 202: + encapsulation: + dot1q: 1000 + inner-dot1q: 1000 + 203: + encapsulation: + dot1ad: 1000 + inner-dot1q: 1000 + 300: + encapsulation: + dot1ad: 1000 + dot1q: 1000 + 301: + encapsulation: + inner-dot1q: 1000 + 302: + encapsulation: + dot1ad: 1000 + dot1q: 1000 + inner-dot1q: 1000 diff --git a/schema.yaml b/schema.yaml new file mode 100644 index 0000000..a0f8ced --- /dev/null +++ b/schema.yaml @@ -0,0 +1,30 @@ +interfaces: map(include('interface'),key=str(matches='.*GigabitEthernet[0-9]+/[0-9]+/[0-9]+|BondEthernet[0-9]+')) +bondethernets: map(include('bondethernet'),key=str(matches='BondEthernet[0-9]+')) +--- +bondethernet: + description: str(exclude='\'"',required=False) + interfaces: list(str(matches='.*GigabitEthernet[0-9]+/[0-9]+/[0-9]+')) +--- +interface: + description: str(exclude='\'"',required=False) + lcp: str(max=8,matches='[a-z]+[a-z0-9-]{,7}',required=False) + mac: mac(required=False) + addresses: list(include('v4'),include('v6'),min=1,max=6,required=False) + sub-interfaces: map(include('sub-interface'),key=int(min=1,max=4294967295),required=False) +--- +v4: str(matches='[0-9\.]+/[0-9]+') +--- +v6: str(matches='[0-9a-f:]+/[0-9]+',ignore_case=True) +--- +sub-interface: + description: str(exclude='\'"',required=False) + lcp: str(max=8,matches='[a-z]+[a-z0-9-]{,7}',required=False) + addresses: list(ip(),required=False) + encapsulation: include('encapsulation',required=False) +--- +encapsulation: + dot1q: int(min=1,max=4095,required=False) + dot1ad: int(min=1,max=4095,required=False) + inner-dot1q: int(min=1,max=4095,required=False) + exact-match: bool(required=False) + diff --git a/validator/__init__.py b/validator/__init__.py new file mode 100644 index 0000000..6ceb744 --- /dev/null +++ b/validator/__init__.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import ( + absolute_import, + division, + print_function, +) + +import logging +try: + import yamale +except ImportError: + print("ERROR: install yamale manually: sudo pip install yamale") + sys.exit(-2) +from validator.loopback import loopback +from validator.bondethernet import bondethernet +from validator.interface import interface +from validator.bridgedomain import bridgedomain + +class NullHandler(logging.Handler): + def emit(self, record): + pass + + +class Validator(object): + def __init__(self, args): + self.logger = logging.getLogger('vppcfg.validator') + self.logger.addHandler(NullHandler()) + + self.args = args + + def validate(self, yaml): + ret_rv = True + ret_msgs = [] + if self.args.schema: + try: + self.logger.info("Validating against schema %s" % self.args.schema) + schema = yamale.make_schema(self.args.schema) + data = yamale.make_data(content=str(yaml)) + yamale.validate(schema, data) + self.logger.debug("Schema correctly validated by yamale") + except ValueError as e: + ret_rv = False + for result in e.results: + for error in result.errors: + ret_msgs.extend(['yamale: %s' % error]) + return ret_rv, ret_msgs + else: + self.logger.warning("Schema validation disabled") + + self.logger.debug("Validating Semantics...") + + rv, msgs = bondethernet(self.args, yaml) + if msgs: + ret_msgs.extend(msgs) + if not rv: + ret_rv = False + + rv, msgs = interface(self.args, yaml) + if msgs: + ret_msgs.extend(msgs) + if not rv: + ret_rv = False + + rv, msgs = loopback(self.args, yaml) + if msgs: + ret_msgs.extend(msgs) + if not rv: + ret_rv = False + + rv, msgs = bridgedomain(self.args, yaml) + if msgs: + ret_msgs.extend(msgs) + if not rv: + ret_rv = False + + return ret_rv, ret_msgs diff --git a/validator/bondethernet.py b/validator/bondethernet.py new file mode 100644 index 0000000..56cc0a8 --- /dev/null +++ b/validator/bondethernet.py @@ -0,0 +1,44 @@ +import logging +import validator.interface as interface + +class NullHandler(logging.Handler): + def emit(self, record): + pass + + +def exists(yaml, ifname): + """ Return True if the BondEthernet exists """ + try: + if ifname in yaml['bondethernets']: + return True + except: + pass + return False + +def bondethernet(args, yaml): + result = True + msgs = [] + logger = logging.getLogger('vppcfg.validator') + logger.addHandler(NullHandler()) + + if not 'bondethernets' in yaml: + return result, msgs + + for ifname, iface in yaml['bondethernets'].items(): + logger.debug("bondethernet %s: %s" % (ifname, iface)) + for member in iface['interfaces']: + if not interface.exists(yaml, member): + msgs.append("bondethernet %s member %s doesn't exist" % (ifname, member)) + result = False + continue + + if interface.has_sub(yaml, member): + msgs.append("bondethernet %s member %s has sub-interface(s)" % (ifname, member)) + result = False + if interface.has_lcp(yaml, member): + msgs.append("bondethernet %s member %s has an LCP" % (ifname, member)) + result = False + if interface.has_address(yaml, member): + msgs.append("bondethernet %s member %s has address(es)" % (ifname, member)) + result = False + return result, msgs diff --git a/validator/bridgedomain.py b/validator/bridgedomain.py new file mode 100644 index 0000000..fb3b3b1 --- /dev/null +++ b/validator/bridgedomain.py @@ -0,0 +1,15 @@ +import logging + +class NullHandler(logging.Handler): + def emit(self, record): + pass + + +def bridgedomain(args, yaml): + result = True + msgs = [] + logger = logging.getLogger('vppcfg.validator') + logger.addHandler(NullHandler()) + + logger.debug("Validating bridgedomains...") + return result, msgs diff --git a/validator/interface.py b/validator/interface.py new file mode 100644 index 0000000..40cee48 --- /dev/null +++ b/validator/interface.py @@ -0,0 +1,149 @@ +import logging +import validator.bondethernet as bondethernet + +class NullHandler(logging.Handler): + def emit(self, record): + pass + +def has_sub(yaml, ifname): + """ Returns True if this interface has sub-interfaces """ + if not 'interfaces' in yaml: + return False + + if ifname in yaml['interfaces']: + iface = yaml['interfaces'][ifname] + if 'sub-interfaces' in iface and len(iface['sub-interfaces']) > 0: + return True + return False + +def has_address(yaml, ifname): + """ Returns True if this interface or sub-interface has one or more addresses""" + if not 'interfaces' in yaml: + return False + + if '.' in ifname: + ifname, subid = ifname.split('.') + subid = int(subid) + try: + if len(yaml['interfaces'][ifname]['sub-interfaces'][subid]['addresses']) > 0: + return True + except: + pass + return False + + try: + if len(yaml['interfaces'][ifname]['addresses']) > 0: + return True + except: + pass + return False + +def is_bond_member(yaml, ifname): + """ Returns True if this interface is a member of a BondEthernet """ + if not 'bondethernets' in yaml: + return False + + for bond, iface in yaml['bondethernets'].items(): + if not 'interfaces' in iface: + continue + if ifname in iface['interfaces']: + return True + return False + +def has_lcp(yaml, ifname): + """ Returns True if this interface or sub-interface has an LCP""" + if not 'interfaces' in yaml: + return False + + if '.' in ifname: + ifname, subid = ifname.split('.') + subid = int(subid) + try: + if len(yaml['interfaces'][ifname]['sub-interfaces'][subid]['lcp']) > 0: + return True + except: + pass + return False + + try: + if len(yaml['interfaces'][ifname]['lcp']) > 0: + return True + except: + pass + return False + +def exists(yaml, ifname): + """ Returns true if ifname exists as a phy or sub-int """ + try: + if ifname in yaml['interfaces']: + return True + if '.' in ifname: + ifname, subid = ifname.split('.') + subid = int(subid) + if subid in yaml['interfaces'][ifname]['sub-interfaces']: + return True + except: + pass + return False + +def valid_encapsulation(yaml, sub_ifname): + try: + ifname, subid = sub_ifname.split('.') + subid = int(subid) + sub_iface = yaml['interfaces'][ifname]['sub-interfaces'][subid] + except: + return False + + if not 'encapsulation' in sub_iface: + return True + + encap = sub_iface['encapsulation'] + if 'dot1ad' in encap and 'dot1q' in encap: + return False + if 'inner-dot1q' in encap and not ('dot1ad' in encap or 'dot1q' in encap): + return False + + return True + + +def interface(args, yaml): + result = True + msgs = [] + logger = logging.getLogger('vppcfg.validator') + logger.addHandler(NullHandler()) + + if not 'interfaces' in yaml: + return result, msgs + + for ifname, iface in yaml['interfaces'].items(): + logger.debug("interface %s" % iface) + if ifname.startswith("BondEthernet") and not bondethernet.exists(yaml, ifname): + msgs.append("interface %s does not exist in bondethernets" % ifname) + result = False + continue + + iface_lcp = has_lcp(yaml, ifname) + iface_address = has_address(yaml, ifname) + + if iface_address and not iface_lcp: + msgs.append("interface %s has adddress(es) but no LCP" % ifname) + result = False + + if has_sub(yaml, ifname): + for sub_id, sub_iface in yaml['interfaces'][ifname]['sub-interfaces'].items(): + sub_ifname = "%s.%d" % (ifname, sub_id) + logger.debug("sub-interface %s" % sub_iface) + if has_lcp(yaml, sub_ifname): + if not iface_lcp: + msgs.append("sub-interface %s has LCP but %s does not have LCP" % (sub_ifname, ifname)) + result = False + if has_address(yaml, sub_ifname): + ## The sub_iface lcp is not required: it can be derived from the iface_lcp, which has to be set + if not iface_lcp: + msgs.append("sub-interface %s has address(es) but %s does not have LCP" % (sub_ifname, ifname)) + result = False + if not valid_encapsulation(yaml, sub_ifname): + msgs.append("sub-interface %s has invalid encapsulation" % (sub_ifname)) + result = False + + return result, msgs diff --git a/validator/loopback.py b/validator/loopback.py new file mode 100644 index 0000000..4f0966f --- /dev/null +++ b/validator/loopback.py @@ -0,0 +1,15 @@ +import logging + +class NullHandler(logging.Handler): + def emit(self, record): + pass + + +def loopback(args, yaml): + result = True + msgs = [] + logger = logging.getLogger('vppcfg.validator') + logger.addHandler(NullHandler()) + + logger.debug("Validating loopbacks...") + return result, msgs diff --git a/vppcfg b/vppcfg new file mode 100755 index 0000000..4fe8c9e --- /dev/null +++ b/vppcfg @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +import yaml +import logging +from validator import Validator + +try: + import argparse +except ImportError: + print("ERROR: install argparse manually: sudo pip install argparse") + sys.exit(-2) + + +def main(): + parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument('-c', dest='config', type=str, help="""YAML configuration file for VPP""") + parser.add_argument('-s', dest='schema', type=str, default='./schema.yaml', help="""YAML schema validation""") + parser.add_argument('-d', dest='debug', action='store_true', help="""Enable debug, default False""") + + args = parser.parse_args() + if args.debug: + print("Arguments:", args) + level = logging.DEBUG + else: + level = logging.INFO + logging.basicConfig(format='[%(levelname)-8s] %(name)s.%(funcName)s: %(message)s', level=level) + + try: + with open(args.config, "r") as f: + logging.info("Loading configfile %s" % args.config) + cfg = yaml.load(f, Loader = yaml.FullLoader) + logging.debug("Config: %s" % cfg) + except: + logging.error("Couldn't read config from %s" % args.config) + sys.exit(-1) + + v = Validator(args) + rv, msgs = v.validate(cfg) + if not rv: + for m in msgs: + logging.error(m) + else: + logging.info("Configuration validated successfully") + +if __name__ == "__main__": + main()