| """Model Comparer: |
| - yaml parser |
| - class tree comparison |
| """ |
| import itertools |
| import os |
| import yaml |
| |
| from cfg_checker.reports import reporter |
| from cfg_checker.common import logger, logger_cli |
| |
| |
| def get_element(element_path, input_data): |
| paths = element_path.split(":") |
| data = input_data |
| for i in range(0, len(paths)): |
| data = data[paths[i]] |
| return data |
| |
| |
| def pop_element(element_path, input_data): |
| paths = element_path.split(":") |
| data = input_data |
| # Search for last dict |
| for i in range(0, len(paths)-1): |
| data = data[paths[i]] |
| # pop the actual element |
| return data.pop(paths[-1]) |
| |
| |
| class ModelComparer(object): |
| """Collection of functions to compare model data. |
| """ |
| # key order is important |
| _model_parts = { |
| "01_nodes": "nodes", |
| "02_system": "classes:system", |
| "03_cluster": "classes:cluster", |
| "04_other": "classes" |
| } |
| |
| models = {} |
| models_path = "/srv/salt/reclass" |
| model_name_1 = "source" |
| model_path_1 = os.path.join(models_path, model_name_1) |
| model_name_2 = "target" |
| model_path_2 = os.path.join(models_path, model_name_1) |
| |
| @staticmethod |
| def load_yaml_class(fname): |
| """Loads a yaml from the file and forms a tree item |
| |
| Arguments: |
| fname {string} -- full path to the yaml file |
| """ |
| _yaml = {} |
| try: |
| _size = 0 |
| with open(fname, 'r') as f: |
| _yaml = yaml.load(f, Loader=yaml.FullLoader) |
| _size = f.tell() |
| # TODO: do smth with the data |
| if not _yaml: |
| logger_cli.warning("WARN: empty file '{}'".format(fname)) |
| _yaml = {} |
| else: |
| logger.debug("...loaded YAML '{}' ({}b)".format(fname, _size)) |
| return _yaml |
| except yaml.YAMLError as exc: |
| logger_cli.error(exc) |
| except IOError as e: |
| logger_cli.error( |
| "Error loading file '{}': {}".format(fname, e.message) |
| ) |
| raise Exception("CRITICAL: Failed to load YAML data: {}".format( |
| e.message + e.strerror |
| )) |
| |
| def load_model_tree(self, name, root_path="/srv/salt/reclass"): |
| """Walks supplied path for the YAML filed and loads the tree |
| |
| Arguments: |
| root_folder_path {string} -- Path to Model's root folder. Optional |
| """ |
| logger_cli.info("# Loading reclass tree from '{}'".format(root_path)) |
| # prepare the file tree to walk |
| raw_tree = {} |
| # Credits to Andrew Clark@MIT. Original code is here: |
| # http://code.activestate.com/recipes/577879-create-a-nested-dictionary-from-oswalk/ |
| root_path = root_path.rstrip(os.sep) |
| start = root_path.rfind(os.sep) + 1 |
| root_key = root_path.rsplit(os.sep, 1)[1] |
| # Look Ma! I am walking the file tree with no recursion! |
| for path, dirs, files in os.walk(root_path): |
| # if this is a hidden folder, ignore it |
| _folders_list = path[start:].split(os.sep) |
| if any(item.startswith(".") for item in _folders_list): |
| continue |
| # cut absolute part of the path and split folder names |
| folders = path[start:].split(os.sep) |
| subdir = {} |
| # create generator of files that are not hidden |
| _exts = ('.yml', '.yaml') |
| _subfiles = (_fl for _fl in files |
| if _fl.endswith(_exts) and not _fl.startswith('.')) |
| for _file in _subfiles: |
| # cut file extension. All reclass files are '.yml' |
| _subnode = _file |
| # load all YAML class data into the tree |
| subdir[_subnode] = self.load_yaml_class( |
| os.path.join(path, _file) |
| ) |
| try: |
| # Save original filepath, just in case |
| subdir[_subnode]["_source"] = os.path.join( |
| path[start:], |
| _file |
| ) |
| except Exception: |
| logger.warning( |
| "Non-yaml file detected: {}".format(_file) |
| ) |
| # creating dict structure out of folder list. Pure python magic |
| parent = reduce(dict.get, folders[:-1], raw_tree) |
| parent[folders[-1]] = subdir |
| |
| self.models[name] = {} |
| # Brake in according to pathes |
| _parts = self._model_parts.keys() |
| _parts = sorted(_parts) |
| for ii in range(0, len(_parts)): |
| self.models[name][_parts[ii]] = pop_element( |
| self._model_parts[_parts[ii]], |
| raw_tree[root_key] |
| ) |
| |
| # save it as a single data object |
| self.models[name]["rc_diffs"] = raw_tree[root_key] |
| return True |
| |
| def find_changes(self, dict1, dict2, path=""): |
| _report = {} |
| for k in dict1.keys(): |
| # yamls might load values as non-str types |
| if not isinstance(k, str): |
| _new_path = path + ":" + str(k) |
| else: |
| _new_path = path + ":" + k |
| # ignore _source key |
| if k == "_source": |
| continue |
| # check if this is an env name cluster entry |
| if dict2 is not None and \ |
| k == self.model_name_1 and \ |
| self.model_name_2 in dict2.keys(): |
| k1 = self.model_name_1 |
| k2 = self.model_name_2 |
| if type(dict1[k1]) is dict: |
| if path == "": |
| _new_path = k1 |
| _child_report = self.find_changes( |
| dict1[k1], |
| dict2[k2], |
| _new_path |
| ) |
| _report.update(_child_report) |
| elif dict2 is None or k not in dict2: |
| # no key in dict2 |
| _report[_new_path] = { |
| "type": "value", |
| "raw_values": [dict1[k], "N/A"], |
| "str_values": [ |
| "{}".format(dict1[k]), |
| "n/a" |
| ] |
| } |
| logger.info( |
| "{}: {}, {}".format(_new_path, dict1[k], "N/A") |
| ) |
| else: |
| if type(dict1[k]) is dict: |
| if path == "": |
| _new_path = k |
| _child_report = self.find_changes( |
| dict1[k], |
| dict2[k], |
| _new_path |
| ) |
| _report.update(_child_report) |
| elif type(dict1[k]) is list and type(dict2[k]) is list: |
| # use ifilterfalse to compare lists of dicts |
| try: |
| _removed = list( |
| itertools.ifilterfalse( |
| lambda x: x in dict2[k], |
| dict1[k] |
| ) |
| ) |
| _added = list( |
| itertools.ifilterfalse( |
| lambda x: x in dict1[k], |
| dict2[k] |
| ) |
| ) |
| except TypeError as e: |
| # debug routine, |
| # should not happen, due to list check above |
| logger.error( |
| "Caught lambda type mismatch: {}".format( |
| e.message |
| ) |
| ) |
| logger_cli.warning( |
| "Types mismatch for correct compare: " |
| "{}, {}".format( |
| type(dict1[k]), |
| type(dict2[k]) |
| ) |
| ) |
| _removed = None |
| _added = None |
| _original = ["= {}".format(item) for item in dict1[k]] |
| if _removed or _added: |
| _removed_str_lst = ["- {}".format(item) |
| for item in _removed] |
| _added_str_lst = ["+ {}".format(item) |
| for item in _added] |
| _report[_new_path] = { |
| "type": "list", |
| "raw_values": [ |
| dict1[k], |
| _removed_str_lst + _added_str_lst |
| ], |
| "str_values": [ |
| "{}".format('\n'.join(_original)), |
| "{}\n{}".format( |
| '\n'.join(_removed_str_lst), |
| '\n'.join(_added_str_lst) |
| ) |
| ] |
| } |
| logger.info( |
| "{}:\n" |
| "{} original items total".format( |
| _new_path, |
| len(dict1[k]) |
| ) |
| ) |
| if _removed: |
| logger.info( |
| "{}".format('\n'.join(_removed_str_lst)) |
| ) |
| if _added: |
| logger.info( |
| "{}".format('\n'.join(_added_str_lst)) |
| ) |
| else: |
| # in case of type mismatch |
| # considering it as not equal |
| d1 = dict1 |
| d2 = dict2 |
| val1 = d1[k] if isinstance(d1, dict) else d1 |
| val2 = d2[k] if isinstance(d2, dict) else d2 |
| try: |
| match = val1 == val2 |
| except TypeError as e: |
| logger.warning( |
| "One of the values is not a dict: " |
| "{}, {}".format( |
| str(dict1), |
| str(dict2) |
| )) |
| match = False |
| if not match: |
| _report[_new_path] = { |
| "type": "value", |
| "raw_values": [val1, val2], |
| "str_values": [ |
| "{}".format(val1), |
| "{}".format(val2) |
| ] |
| } |
| logger.info("{}: {}, {}".format( |
| _new_path, |
| val1, |
| val2 |
| )) |
| return _report |
| |
| |
| def generate_model_report_tree(self): |
| """Use two loaded models to generate comparison table with |
| values are groupped by YAML files |
| """ |
| # We are to cut both models into logical pieces |
| # nodes, will not be equal most of the time |
| # system, must be pretty much the same or we in trouble |
| # cluster, will be the most curious part for comparison |
| # other, all of the rest |
| |
| _diff_report = {} |
| for _key in self._model_parts.keys(): |
| # tmp report for keys |
| _tmp_diffs = self.find_changes( |
| self.models[self.model_name_1][_key], |
| self.models[self.model_name_2][_key] |
| ) |
| # prettify the report |
| for key in _tmp_diffs.keys(): |
| # break the key in two parts |
| _ext = ".yml" |
| if ".yaml" in key: |
| _ext = ".yaml" |
| _split = key.split(_ext) |
| _file_path = _split[0] |
| _param_path = "none" |
| if len(_split) > 1: |
| _param_path = _split[1] |
| _tmp_diffs[key].update({ |
| "class_file": _file_path + _ext, |
| "param": _param_path, |
| }) |
| _diff_report[_key[3:]] = { |
| "path": self._model_parts[_key], |
| "diffs": _tmp_diffs |
| } |
| |
| _diff_report["diff_names"] = [self.model_name_1, self.model_name_2] |
| return _diff_report |
| |
| def compare_models(self): |
| # Do actual compare using model names from the class |
| self.load_model_tree( |
| self.model_name_1, |
| self.model_path_1 |
| ) |
| self.load_model_tree( |
| self.model_name_2, |
| self.model_path_2 |
| ) |
| # Models should have similar structure to be compared |
| # classes/system |
| # classes/cluster |
| # nodes |
| |
| diffs = self.generate_model_report_tree() |
| |
| report_file = \ |
| self.model_name_1 + "-vs-" + self.model_name_2 + ".html" |
| # HTML report class is post-callable |
| report = reporter.ReportToFile( |
| reporter.HTMLModelCompare(), |
| report_file |
| ) |
| logger_cli.info("...generating report to {}".format(report_file)) |
| # report will have tabs for each of the comparable entities in diffs |
| report({ |
| "nodes": {}, |
| "rc_diffs": diffs, |
| }) |
| # with open("./gen_tree.json", "w+") as _out: |
| # _out.write(json.dumps(mComparer.generate_model_report_tree)) |
| |
| return |