diff --git a/src/cnaas_nms/confpush/changescore.py b/src/cnaas_nms/confpush/changescore.py new file mode 100644 index 00000000..8e758894 --- /dev/null +++ b/src/cnaas_nms/confpush/changescore.py @@ -0,0 +1,70 @@ +import re + + +line_start = r"^[+-][ ]*" +line_start_remove = r"^-[ ]*" +DEFAULT_LINE_SCORE = 1.0 +# Stops looking after first match. Only searches a single line at a time. +change_patterns = [ + { + 'name': 'description', + 'regex': re.compile(str(line_start + r"description")), + 'modifier': 0.0 + }, + { + 'name': 'removed ip address', + 'regex': re.compile(str(line_start_remove + r".*(ip address).*")), + 'modifier': 10.0 + }, + { + 'name': 'removed vlan', + 'regex': re.compile(str(line_start_remove + r"vlan")), + 'modifier': 10.0 + }, + { + 'name': 'spanning-tree', + 'regex': re.compile(str(line_start + r"spanning-tree")), + 'modifier': 50.0 + }, + { + 'name': 'removed routing', + 'regex': re.compile(str(line_start_remove + r".*(routing|router).*")), + 'modifier': 50.0 + }, +] +# TODO: multiline patterns / block-aware config + + +def calculate_line_score(line: str): + for pattern in change_patterns: + if re.match(pattern['regex'], line): + return 1 * pattern['modifier'] + return DEFAULT_LINE_SCORE + + +def calculate_score(config: str, diff: str) -> float: + """Calculate a score based on how much and what configurations were + changed in the diff. + + Args: + config: the entire configuration of device after change + diff: the diff of what changed in the configuration + + Returns: + Calculated score, can be much higher than 100.0 + """ + config_lines = config.split('\n') + diff_lines = diff.split('\n') + changed_lines = 0 + total_line_score = 0.0 + for line in diff_lines: + if line.startswith('+') or line.startswith('-'): + changed_lines += 1 + total_line_score += calculate_line_score(line) + + changed_ratio = changed_lines / float(len(config_lines)) + + # Calculate score, 20% based on number of lines changed, 80% on individual + # line score with applied modifiers + + return (changed_ratio*100*0.2) + (total_line_score*0.8) diff --git a/src/cnaas_nms/confpush/nornir_helper.py b/src/cnaas_nms/confpush/nornir_helper.py index 1f51e8a3..a7b91190 100644 --- a/src/cnaas_nms/confpush/nornir_helper.py +++ b/src/cnaas_nms/confpush/nornir_helper.py @@ -10,6 +10,7 @@ @dataclass class NornirJobResult(JobResult): nrresult: Optional[MultiResult] = None + change_score: Optional[float] = None def cnaas_init(): diff --git a/src/cnaas_nms/confpush/sync_devices.py b/src/cnaas_nms/confpush/sync_devices.py index 70044022..225fe24b 100644 --- a/src/cnaas_nms/confpush/sync_devices.py +++ b/src/cnaas_nms/confpush/sync_devices.py @@ -1,5 +1,6 @@ from typing import Optional from ipaddress import IPv4Interface +from statistics import median import os import yaml @@ -11,6 +12,7 @@ import cnaas_nms.confpush.nornir_helper from cnaas_nms.db.session import sqla_session from cnaas_nms.confpush.get import get_uplinks, get_running_config_hash +from cnaas_nms.confpush.changescore import calculate_score from cnaas_nms.tools.log import get_logger from cnaas_nms.db.settings import get_settings from cnaas_nms.db.device import Device, DeviceState, DeviceType @@ -160,6 +162,12 @@ def push_sync_device(task, dry_run: bool = True, generate_only: bool = False): dry_run=dry_run ) + change_score = 0 + if task.results[1].diff: + config = task.results[1].host["config"] + diff = task.results[1].diff + task.host["change_score"] = calculate_score(config, diff) + def generate_only(hostname: str) -> (str, dict): """ @@ -240,6 +248,7 @@ def sync_devices(hostname: Optional[str] = None, device_type: Optional[str] = No failed_hosts = list(nrresult.failed_hosts.keys()) if not dry_run: + # set new config hash and mark device synchronized for key in nrresult.keys(): if key in failed_hosts: continue @@ -259,4 +268,23 @@ def sync_devices(hostname: Optional[str] = None, device_type: Optional[str] = No if nrresult.failed: logger.error("Not all devices were successfully synchronized") - return NornirJobResult(nrresult=nrresult) + total_change_score = 1 + change_scores = [] + # calculate change impact score + for host, results in nrresult.items(): + if host in failed_hosts: + total_change_score = 100 + break + change_scores.append(results[0].host["change_score"]) + logger.debug("Change score for host {}: {}".format( + host, results[0].host["change_score"])) + + if max(change_scores) > 1000: + # If some device has a score higher than this, disregard any averages + # and report max impact score + total_change_score = 100 + else: + total_change_score = max(min(int(median(change_scores) + 0.5), 100), 1) + logger.info("Change impact score: {}".format(total_change_score)) + # TODO: add field for change score in NornirJobResult object + return NornirJobResult(nrresult=nrresult, change_score=total_change_score) diff --git a/src/cnaas_nms/scheduler/jobtracker.py b/src/cnaas_nms/scheduler/jobtracker.py index 32df3952..249e417c 100644 --- a/src/cnaas_nms/scheduler/jobtracker.py +++ b/src/cnaas_nms/scheduler/jobtracker.py @@ -44,6 +44,9 @@ def finish_success(self, res: dict, next_job_id: Optional[str]): try: if isinstance(res, NornirJobResult) and isinstance(res.nrresult, AggregatedResult): self.result = nr_result_serialize(res.nrresult) + self.result['_totals'] = {'selected_devices': len(res.nrresult)} + if res.change_score: + self.result['_totals']['change_score'] = res.change_score elif isinstance(res, (StrJobResult, DictJobResult)): self.result = res.result else: diff --git a/test/integrationtests.py b/test/integrationtests.py index bad3d9c7..e243b888 100644 --- a/test/integrationtests.py +++ b/test/integrationtests.py @@ -102,13 +102,13 @@ def test_1_ztp(self): self.assertFalse(result_step2['eosaccess'][0]['failed'], "Could not reach device after ZTP") - def test_2_syncto(self): + def test_2_syncto_access(self): r = requests.post( f'{URL}/api/v1.0/device_syncto', json={"hostname": "eosaccess", "dry_run": True}, verify=TLS_VERIFY ) - self.assertEqual(r.status_code, 200, "Failed to do sync_to") + self.assertEqual(r.status_code, 200, "Failed to do sync_to access") def test_3_interfaces(self): r = requests.get( @@ -124,6 +124,14 @@ def test_3_interfaces(self): ) self.assertEqual(r.status_code, 200, "Failed to update interface") + def test_4_syncto_dist(self): + r = requests.post( + f'{URL}/api/v1.0/device_syncto', + json={"hostname": "eosdist", "dry_run": True}, + verify=TLS_VERIFY + ) + self.assertEqual(r.status_code, 200, "Failed to do sync_to dist") + if __name__ == '__main__': unittest.main()