blob: 84e10a65359d5de72c24569b397720c636d21c55 [file] [log] [blame]
Alex0989ecf2022-03-29 13:43:21 -05001# Author: Alex Savatieiev (osavatieiev@mirantis.com; a.savex@gmail.com)
2# Copyright 2019-2022 Mirantis, Inc.
Alex Savatieievd48994d2018-12-13 12:13:00 +01003"""Model Comparer:
4- yaml parser
5- class tree comparison
6"""
7import itertools
Alex Savatieievd48994d2018-12-13 12:13:00 +01008import os
Alex3ebc5632019-04-18 16:47:18 -05009
Alex3bc95f62020-03-05 17:00:04 -060010from functools import reduce
11
Alex3ebc5632019-04-18 16:47:18 -050012from cfg_checker.common import logger, logger_cli
Alex3ebc5632019-04-18 16:47:18 -050013
Alex Savatieievd48994d2018-12-13 12:13:00 +010014import yaml
15
Alex Savatieievd48994d2018-12-13 12:13:00 +010016
Alex3ebc5632019-04-18 16:47:18 -050017def get_element(element_path, input_data):
Alex Savatieiev4f149d02019-02-28 17:15:29 -060018 paths = element_path.split(":")
19 data = input_data
20 for i in range(0, len(paths)):
21 data = data[paths[i]]
22 return data
23
24
Alex3ebc5632019-04-18 16:47:18 -050025def pop_element(element_path, input_data):
Alex Savatieiev4f149d02019-02-28 17:15:29 -060026 paths = element_path.split(":")
27 data = input_data
28 # Search for last dict
29 for i in range(0, len(paths)-1):
30 data = data[paths[i]]
31 # pop the actual element
32 return data.pop(paths[-1])
33
34
Alex Savatieievd48994d2018-12-13 12:13:00 +010035class ModelComparer(object):
36 """Collection of functions to compare model data.
37 """
Alex Savatieiev4f149d02019-02-28 17:15:29 -060038 # key order is important
39 _model_parts = {
40 "01_nodes": "nodes",
41 "02_system": "classes:system",
42 "03_cluster": "classes:cluster",
43 "04_other": "classes"
44 }
Alex3ebc5632019-04-18 16:47:18 -050045
Alex Savatieievd48994d2018-12-13 12:13:00 +010046 models = {}
Alex Savatieiev06ab17d2019-02-26 18:40:48 -060047 models_path = "/srv/salt/reclass"
48 model_name_1 = "source"
49 model_path_1 = os.path.join(models_path, model_name_1)
50 model_name_2 = "target"
51 model_path_2 = os.path.join(models_path, model_name_1)
Alex Savatieievd48994d2018-12-13 12:13:00 +010052
53 @staticmethod
54 def load_yaml_class(fname):
55 """Loads a yaml from the file and forms a tree item
56
57 Arguments:
58 fname {string} -- full path to the yaml file
59 """
60 _yaml = {}
61 try:
62 _size = 0
63 with open(fname, 'r') as f:
Alexb8af13a2019-04-16 18:38:12 -050064 _yaml = yaml.load(f, Loader=yaml.FullLoader)
Alex Savatieievd48994d2018-12-13 12:13:00 +010065 _size = f.tell()
66 # TODO: do smth with the data
67 if not _yaml:
Alex1839bbf2019-08-22 17:17:21 -050068 # logger.warning("WARN: empty file '{}'".format(fname))
Alex Savatieievd48994d2018-12-13 12:13:00 +010069 _yaml = {}
70 else:
Alexc4f59622021-08-27 13:42:00 -050071 logger.debug("... loaded YAML '{}' ({}b)".format(fname, _size))
Alex Savatieievd48994d2018-12-13 12:13:00 +010072 return _yaml
73 except yaml.YAMLError as exc:
74 logger_cli.error(exc)
75 except IOError as e:
76 logger_cli.error(
77 "Error loading file '{}': {}".format(fname, e.message)
78 )
79 raise Exception("CRITICAL: Failed to load YAML data: {}".format(
Alex Savatieiev36b938d2019-01-21 11:01:18 +010080 e.message + e.strerror
Alex Savatieievd48994d2018-12-13 12:13:00 +010081 ))
82
83 def load_model_tree(self, name, root_path="/srv/salt/reclass"):
84 """Walks supplied path for the YAML filed and loads the tree
85
86 Arguments:
87 root_folder_path {string} -- Path to Model's root folder. Optional
88 """
Alex Savatieiev42b89fa2019-03-07 18:45:26 -060089 logger_cli.info("# Loading reclass tree from '{}'".format(root_path))
Alex Savatieievd48994d2018-12-13 12:13:00 +010090 # prepare the file tree to walk
91 raw_tree = {}
92 # Credits to Andrew Clark@MIT. Original code is here:
93 # http://code.activestate.com/recipes/577879-create-a-nested-dictionary-from-oswalk/
94 root_path = root_path.rstrip(os.sep)
95 start = root_path.rfind(os.sep) + 1
96 root_key = root_path.rsplit(os.sep, 1)[1]
97 # Look Ma! I am walking the file tree with no recursion!
98 for path, dirs, files in os.walk(root_path):
99 # if this is a hidden folder, ignore it
Alex Savatieiev06ab17d2019-02-26 18:40:48 -0600100 _folders_list = path[start:].split(os.sep)
101 if any(item.startswith(".") for item in _folders_list):
Alex Savatieievd48994d2018-12-13 12:13:00 +0100102 continue
103 # cut absolute part of the path and split folder names
104 folders = path[start:].split(os.sep)
105 subdir = {}
106 # create generator of files that are not hidden
Alex Savatieiev36b938d2019-01-21 11:01:18 +0100107 _exts = ('.yml', '.yaml')
Alex Savatieiev06ab17d2019-02-26 18:40:48 -0600108 _subfiles = (_fl for _fl in files
109 if _fl.endswith(_exts) and not _fl.startswith('.'))
Alex Savatieievd48994d2018-12-13 12:13:00 +0100110 for _file in _subfiles:
111 # cut file extension. All reclass files are '.yml'
112 _subnode = _file
113 # load all YAML class data into the tree
114 subdir[_subnode] = self.load_yaml_class(
115 os.path.join(path, _file)
116 )
Alex Savatieiev36b938d2019-01-21 11:01:18 +0100117 try:
118 # Save original filepath, just in case
119 subdir[_subnode]["_source"] = os.path.join(
120 path[start:],
121 _file
122 )
123 except Exception:
124 logger.warning(
125 "Non-yaml file detected: {}".format(_file)
126 )
Alex Savatieievd48994d2018-12-13 12:13:00 +0100127 # creating dict structure out of folder list. Pure python magic
128 parent = reduce(dict.get, folders[:-1], raw_tree)
129 parent[folders[-1]] = subdir
Alex3ebc5632019-04-18 16:47:18 -0500130
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600131 self.models[name] = {}
132 # Brake in according to pathes
133 _parts = self._model_parts.keys()
134 _parts = sorted(_parts)
135 for ii in range(0, len(_parts)):
136 self.models[name][_parts[ii]] = pop_element(
137 self._model_parts[_parts[ii]],
138 raw_tree[root_key]
139 )
Alex3ebc5632019-04-18 16:47:18 -0500140
Alex Savatieievd48994d2018-12-13 12:13:00 +0100141 # save it as a single data object
Alex Savatieiev3db12a72019-03-22 16:32:31 -0500142 self.models[name]["rc_diffs"] = raw_tree[root_key]
Alex Savatieievd48994d2018-12-13 12:13:00 +0100143 return True
144
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600145 def find_changes(self, dict1, dict2, path=""):
146 _report = {}
147 for k in dict1.keys():
148 # yamls might load values as non-str types
149 if not isinstance(k, str):
150 _new_path = path + ":" + str(k)
151 else:
152 _new_path = path + ":" + k
153 # ignore _source key
154 if k == "_source":
155 continue
Alexe9908f72020-05-19 16:04:53 -0500156 # ignore secrets and other secure stuff
Alex1839bbf2019-08-22 17:17:21 -0500157 if isinstance(k, str) and k == "secrets.yml":
158 continue
159 if isinstance(k, str) and k.find("_password") > 0:
160 continue
Alexe9908f72020-05-19 16:04:53 -0500161 if isinstance(k, str) and k.find("_key") > 0:
162 continue
163 if isinstance(k, str) and k.find("_token") > 0:
164 continue
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600165 # check if this is an env name cluster entry
166 if dict2 is not None and \
167 k == self.model_name_1 and \
168 self.model_name_2 in dict2.keys():
169 k1 = self.model_name_1
170 k2 = self.model_name_2
171 if type(dict1[k1]) is dict:
172 if path == "":
173 _new_path = k1
174 _child_report = self.find_changes(
175 dict1[k1],
176 dict2[k2],
177 _new_path
178 )
179 _report.update(_child_report)
180 elif dict2 is None or k not in dict2:
181 # no key in dict2
182 _report[_new_path] = {
183 "type": "value",
184 "raw_values": [dict1[k], "N/A"],
185 "str_values": [
186 "{}".format(dict1[k]),
187 "n/a"
188 ]
189 }
190 logger.info(
191 "{}: {}, {}".format(_new_path, dict1[k], "N/A")
192 )
193 else:
194 if type(dict1[k]) is dict:
195 if path == "":
196 _new_path = k
197 _child_report = self.find_changes(
198 dict1[k],
199 dict2[k],
200 _new_path
201 )
202 _report.update(_child_report)
203 elif type(dict1[k]) is list and type(dict2[k]) is list:
204 # use ifilterfalse to compare lists of dicts
205 try:
206 _removed = list(
Alex3bc95f62020-03-05 17:00:04 -0600207 itertools.filterfalse(
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600208 lambda x: x in dict2[k],
209 dict1[k]
210 )
211 )
212 _added = list(
Alex3bc95f62020-03-05 17:00:04 -0600213 itertools.filterfalse(
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600214 lambda x: x in dict1[k],
215 dict2[k]
216 )
217 )
218 except TypeError as e:
219 # debug routine,
220 # should not happen, due to list check above
221 logger.error(
222 "Caught lambda type mismatch: {}".format(
223 e.message
224 )
225 )
226 logger_cli.warning(
227 "Types mismatch for correct compare: "
228 "{}, {}".format(
229 type(dict1[k]),
230 type(dict2[k])
231 )
232 )
233 _removed = None
234 _added = None
235 _original = ["= {}".format(item) for item in dict1[k]]
236 if _removed or _added:
237 _removed_str_lst = ["- {}".format(item)
238 for item in _removed]
Alex3ebc5632019-04-18 16:47:18 -0500239 _added_str_lst = ["+ {}".format(i) for i in _added]
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600240 _report[_new_path] = {
241 "type": "list",
242 "raw_values": [
243 dict1[k],
244 _removed_str_lst + _added_str_lst
245 ],
246 "str_values": [
247 "{}".format('\n'.join(_original)),
248 "{}\n{}".format(
249 '\n'.join(_removed_str_lst),
250 '\n'.join(_added_str_lst)
251 )
252 ]
253 }
254 logger.info(
255 "{}:\n"
256 "{} original items total".format(
257 _new_path,
258 len(dict1[k])
259 )
260 )
261 if _removed:
262 logger.info(
263 "{}".format('\n'.join(_removed_str_lst))
264 )
265 if _added:
266 logger.info(
267 "{}".format('\n'.join(_added_str_lst))
268 )
269 else:
270 # in case of type mismatch
271 # considering it as not equal
272 d1 = dict1
273 d2 = dict2
274 val1 = d1[k] if isinstance(d1, dict) else d1
275 val2 = d2[k] if isinstance(d2, dict) else d2
276 try:
277 match = val1 == val2
278 except TypeError as e:
279 logger.warning(
280 "One of the values is not a dict: "
Alex3bc95f62020-03-05 17:00:04 -0600281 "{}, {}; {}".format(
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600282 str(dict1),
Alex3bc95f62020-03-05 17:00:04 -0600283 str(dict2),
284 e.message
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600285 ))
286 match = False
287 if not match:
288 _report[_new_path] = {
289 "type": "value",
290 "raw_values": [val1, val2],
291 "str_values": [
292 "{}".format(val1),
293 "{}".format(val2)
294 ]
295 }
296 logger.info("{}: {}, {}".format(
297 _new_path,
298 val1,
299 val2
300 ))
301 return _report
302
Alex Savatieievd48994d2018-12-13 12:13:00 +0100303 def generate_model_report_tree(self):
Alex Savatieiev0137dad2019-01-25 16:18:42 +0100304 """Use two loaded models to generate comparison table with
Alex Savatieievd48994d2018-12-13 12:13:00 +0100305 values are groupped by YAML files
306 """
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600307 # We are to cut both models into logical pieces
308 # nodes, will not be equal most of the time
309 # system, must be pretty much the same or we in trouble
310 # cluster, will be the most curious part for comparison
311 # other, all of the rest
Alex Savatieiev36b938d2019-01-21 11:01:18 +0100312
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600313 _diff_report = {}
314 for _key in self._model_parts.keys():
315 # tmp report for keys
316 _tmp_diffs = self.find_changes(
317 self.models[self.model_name_1][_key],
318 self.models[self.model_name_2][_key]
319 )
320 # prettify the report
321 for key in _tmp_diffs.keys():
322 # break the key in two parts
323 _ext = ".yml"
324 if ".yaml" in key:
325 _ext = ".yaml"
326 _split = key.split(_ext)
327 _file_path = _split[0]
328 _param_path = "none"
329 if len(_split) > 1:
330 _param_path = _split[1]
331 _tmp_diffs[key].update({
332 "class_file": _file_path + _ext,
333 "param": _param_path,
334 })
335 _diff_report[_key[3:]] = {
336 "path": self._model_parts[_key],
337 "diffs": _tmp_diffs
338 }
339
340 _diff_report["diff_names"] = [self.model_name_1, self.model_name_2]
341 return _diff_report