blob: c9cac603a8f23b5367d1f6e64bbcc03ee88960c9 [file] [log] [blame]
Alex Savatieievd48994d2018-12-13 12:13:00 +01001"""Model Comparer:
2- yaml parser
3- class tree comparison
4"""
5import itertools
Alex Savatieievd48994d2018-12-13 12:13:00 +01006import os
Alex3ebc5632019-04-18 16:47:18 -05007
Alex3bc95f62020-03-05 17:00:04 -06008from functools import reduce
9
Alex3ebc5632019-04-18 16:47:18 -050010from cfg_checker.common import logger, logger_cli
Alex3ebc5632019-04-18 16:47:18 -050011
Alex Savatieievd48994d2018-12-13 12:13:00 +010012import yaml
13
Alex Savatieievd48994d2018-12-13 12:13:00 +010014
Alex3ebc5632019-04-18 16:47:18 -050015def get_element(element_path, input_data):
Alex Savatieiev4f149d02019-02-28 17:15:29 -060016 paths = element_path.split(":")
17 data = input_data
18 for i in range(0, len(paths)):
19 data = data[paths[i]]
20 return data
21
22
Alex3ebc5632019-04-18 16:47:18 -050023def pop_element(element_path, input_data):
Alex Savatieiev4f149d02019-02-28 17:15:29 -060024 paths = element_path.split(":")
25 data = input_data
26 # Search for last dict
27 for i in range(0, len(paths)-1):
28 data = data[paths[i]]
29 # pop the actual element
30 return data.pop(paths[-1])
31
32
Alex Savatieievd48994d2018-12-13 12:13:00 +010033class ModelComparer(object):
34 """Collection of functions to compare model data.
35 """
Alex Savatieiev4f149d02019-02-28 17:15:29 -060036 # key order is important
37 _model_parts = {
38 "01_nodes": "nodes",
39 "02_system": "classes:system",
40 "03_cluster": "classes:cluster",
41 "04_other": "classes"
42 }
Alex3ebc5632019-04-18 16:47:18 -050043
Alex Savatieievd48994d2018-12-13 12:13:00 +010044 models = {}
Alex Savatieiev06ab17d2019-02-26 18:40:48 -060045 models_path = "/srv/salt/reclass"
46 model_name_1 = "source"
47 model_path_1 = os.path.join(models_path, model_name_1)
48 model_name_2 = "target"
49 model_path_2 = os.path.join(models_path, model_name_1)
Alex Savatieievd48994d2018-12-13 12:13:00 +010050
51 @staticmethod
52 def load_yaml_class(fname):
53 """Loads a yaml from the file and forms a tree item
54
55 Arguments:
56 fname {string} -- full path to the yaml file
57 """
58 _yaml = {}
59 try:
60 _size = 0
61 with open(fname, 'r') as f:
Alexb8af13a2019-04-16 18:38:12 -050062 _yaml = yaml.load(f, Loader=yaml.FullLoader)
Alex Savatieievd48994d2018-12-13 12:13:00 +010063 _size = f.tell()
64 # TODO: do smth with the data
65 if not _yaml:
Alex1839bbf2019-08-22 17:17:21 -050066 # logger.warning("WARN: empty file '{}'".format(fname))
Alex Savatieievd48994d2018-12-13 12:13:00 +010067 _yaml = {}
68 else:
Alexc4f59622021-08-27 13:42:00 -050069 logger.debug("... loaded YAML '{}' ({}b)".format(fname, _size))
Alex Savatieievd48994d2018-12-13 12:13:00 +010070 return _yaml
71 except yaml.YAMLError as exc:
72 logger_cli.error(exc)
73 except IOError as e:
74 logger_cli.error(
75 "Error loading file '{}': {}".format(fname, e.message)
76 )
77 raise Exception("CRITICAL: Failed to load YAML data: {}".format(
Alex Savatieiev36b938d2019-01-21 11:01:18 +010078 e.message + e.strerror
Alex Savatieievd48994d2018-12-13 12:13:00 +010079 ))
80
81 def load_model_tree(self, name, root_path="/srv/salt/reclass"):
82 """Walks supplied path for the YAML filed and loads the tree
83
84 Arguments:
85 root_folder_path {string} -- Path to Model's root folder. Optional
86 """
Alex Savatieiev42b89fa2019-03-07 18:45:26 -060087 logger_cli.info("# Loading reclass tree from '{}'".format(root_path))
Alex Savatieievd48994d2018-12-13 12:13:00 +010088 # prepare the file tree to walk
89 raw_tree = {}
90 # Credits to Andrew Clark@MIT. Original code is here:
91 # http://code.activestate.com/recipes/577879-create-a-nested-dictionary-from-oswalk/
92 root_path = root_path.rstrip(os.sep)
93 start = root_path.rfind(os.sep) + 1
94 root_key = root_path.rsplit(os.sep, 1)[1]
95 # Look Ma! I am walking the file tree with no recursion!
96 for path, dirs, files in os.walk(root_path):
97 # if this is a hidden folder, ignore it
Alex Savatieiev06ab17d2019-02-26 18:40:48 -060098 _folders_list = path[start:].split(os.sep)
99 if any(item.startswith(".") for item in _folders_list):
Alex Savatieievd48994d2018-12-13 12:13:00 +0100100 continue
101 # cut absolute part of the path and split folder names
102 folders = path[start:].split(os.sep)
103 subdir = {}
104 # create generator of files that are not hidden
Alex Savatieiev36b938d2019-01-21 11:01:18 +0100105 _exts = ('.yml', '.yaml')
Alex Savatieiev06ab17d2019-02-26 18:40:48 -0600106 _subfiles = (_fl for _fl in files
107 if _fl.endswith(_exts) and not _fl.startswith('.'))
Alex Savatieievd48994d2018-12-13 12:13:00 +0100108 for _file in _subfiles:
109 # cut file extension. All reclass files are '.yml'
110 _subnode = _file
111 # load all YAML class data into the tree
112 subdir[_subnode] = self.load_yaml_class(
113 os.path.join(path, _file)
114 )
Alex Savatieiev36b938d2019-01-21 11:01:18 +0100115 try:
116 # Save original filepath, just in case
117 subdir[_subnode]["_source"] = os.path.join(
118 path[start:],
119 _file
120 )
121 except Exception:
122 logger.warning(
123 "Non-yaml file detected: {}".format(_file)
124 )
Alex Savatieievd48994d2018-12-13 12:13:00 +0100125 # creating dict structure out of folder list. Pure python magic
126 parent = reduce(dict.get, folders[:-1], raw_tree)
127 parent[folders[-1]] = subdir
Alex3ebc5632019-04-18 16:47:18 -0500128
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600129 self.models[name] = {}
130 # Brake in according to pathes
131 _parts = self._model_parts.keys()
132 _parts = sorted(_parts)
133 for ii in range(0, len(_parts)):
134 self.models[name][_parts[ii]] = pop_element(
135 self._model_parts[_parts[ii]],
136 raw_tree[root_key]
137 )
Alex3ebc5632019-04-18 16:47:18 -0500138
Alex Savatieievd48994d2018-12-13 12:13:00 +0100139 # save it as a single data object
Alex Savatieiev3db12a72019-03-22 16:32:31 -0500140 self.models[name]["rc_diffs"] = raw_tree[root_key]
Alex Savatieievd48994d2018-12-13 12:13:00 +0100141 return True
142
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600143 def find_changes(self, dict1, dict2, path=""):
144 _report = {}
145 for k in dict1.keys():
146 # yamls might load values as non-str types
147 if not isinstance(k, str):
148 _new_path = path + ":" + str(k)
149 else:
150 _new_path = path + ":" + k
151 # ignore _source key
152 if k == "_source":
153 continue
Alexe9908f72020-05-19 16:04:53 -0500154 # ignore secrets and other secure stuff
Alex1839bbf2019-08-22 17:17:21 -0500155 if isinstance(k, str) and k == "secrets.yml":
156 continue
157 if isinstance(k, str) and k.find("_password") > 0:
158 continue
Alexe9908f72020-05-19 16:04:53 -0500159 if isinstance(k, str) and k.find("_key") > 0:
160 continue
161 if isinstance(k, str) and k.find("_token") > 0:
162 continue
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600163 # check if this is an env name cluster entry
164 if dict2 is not None and \
165 k == self.model_name_1 and \
166 self.model_name_2 in dict2.keys():
167 k1 = self.model_name_1
168 k2 = self.model_name_2
169 if type(dict1[k1]) is dict:
170 if path == "":
171 _new_path = k1
172 _child_report = self.find_changes(
173 dict1[k1],
174 dict2[k2],
175 _new_path
176 )
177 _report.update(_child_report)
178 elif dict2 is None or k not in dict2:
179 # no key in dict2
180 _report[_new_path] = {
181 "type": "value",
182 "raw_values": [dict1[k], "N/A"],
183 "str_values": [
184 "{}".format(dict1[k]),
185 "n/a"
186 ]
187 }
188 logger.info(
189 "{}: {}, {}".format(_new_path, dict1[k], "N/A")
190 )
191 else:
192 if type(dict1[k]) is dict:
193 if path == "":
194 _new_path = k
195 _child_report = self.find_changes(
196 dict1[k],
197 dict2[k],
198 _new_path
199 )
200 _report.update(_child_report)
201 elif type(dict1[k]) is list and type(dict2[k]) is list:
202 # use ifilterfalse to compare lists of dicts
203 try:
204 _removed = list(
Alex3bc95f62020-03-05 17:00:04 -0600205 itertools.filterfalse(
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600206 lambda x: x in dict2[k],
207 dict1[k]
208 )
209 )
210 _added = list(
Alex3bc95f62020-03-05 17:00:04 -0600211 itertools.filterfalse(
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600212 lambda x: x in dict1[k],
213 dict2[k]
214 )
215 )
216 except TypeError as e:
217 # debug routine,
218 # should not happen, due to list check above
219 logger.error(
220 "Caught lambda type mismatch: {}".format(
221 e.message
222 )
223 )
224 logger_cli.warning(
225 "Types mismatch for correct compare: "
226 "{}, {}".format(
227 type(dict1[k]),
228 type(dict2[k])
229 )
230 )
231 _removed = None
232 _added = None
233 _original = ["= {}".format(item) for item in dict1[k]]
234 if _removed or _added:
235 _removed_str_lst = ["- {}".format(item)
236 for item in _removed]
Alex3ebc5632019-04-18 16:47:18 -0500237 _added_str_lst = ["+ {}".format(i) for i in _added]
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600238 _report[_new_path] = {
239 "type": "list",
240 "raw_values": [
241 dict1[k],
242 _removed_str_lst + _added_str_lst
243 ],
244 "str_values": [
245 "{}".format('\n'.join(_original)),
246 "{}\n{}".format(
247 '\n'.join(_removed_str_lst),
248 '\n'.join(_added_str_lst)
249 )
250 ]
251 }
252 logger.info(
253 "{}:\n"
254 "{} original items total".format(
255 _new_path,
256 len(dict1[k])
257 )
258 )
259 if _removed:
260 logger.info(
261 "{}".format('\n'.join(_removed_str_lst))
262 )
263 if _added:
264 logger.info(
265 "{}".format('\n'.join(_added_str_lst))
266 )
267 else:
268 # in case of type mismatch
269 # considering it as not equal
270 d1 = dict1
271 d2 = dict2
272 val1 = d1[k] if isinstance(d1, dict) else d1
273 val2 = d2[k] if isinstance(d2, dict) else d2
274 try:
275 match = val1 == val2
276 except TypeError as e:
277 logger.warning(
278 "One of the values is not a dict: "
Alex3bc95f62020-03-05 17:00:04 -0600279 "{}, {}; {}".format(
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600280 str(dict1),
Alex3bc95f62020-03-05 17:00:04 -0600281 str(dict2),
282 e.message
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600283 ))
284 match = False
285 if not match:
286 _report[_new_path] = {
287 "type": "value",
288 "raw_values": [val1, val2],
289 "str_values": [
290 "{}".format(val1),
291 "{}".format(val2)
292 ]
293 }
294 logger.info("{}: {}, {}".format(
295 _new_path,
296 val1,
297 val2
298 ))
299 return _report
300
Alex Savatieievd48994d2018-12-13 12:13:00 +0100301 def generate_model_report_tree(self):
Alex Savatieiev0137dad2019-01-25 16:18:42 +0100302 """Use two loaded models to generate comparison table with
Alex Savatieievd48994d2018-12-13 12:13:00 +0100303 values are groupped by YAML files
304 """
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600305 # We are to cut both models into logical pieces
306 # nodes, will not be equal most of the time
307 # system, must be pretty much the same or we in trouble
308 # cluster, will be the most curious part for comparison
309 # other, all of the rest
Alex Savatieiev36b938d2019-01-21 11:01:18 +0100310
Alex Savatieiev4f149d02019-02-28 17:15:29 -0600311 _diff_report = {}
312 for _key in self._model_parts.keys():
313 # tmp report for keys
314 _tmp_diffs = self.find_changes(
315 self.models[self.model_name_1][_key],
316 self.models[self.model_name_2][_key]
317 )
318 # prettify the report
319 for key in _tmp_diffs.keys():
320 # break the key in two parts
321 _ext = ".yml"
322 if ".yaml" in key:
323 _ext = ".yaml"
324 _split = key.split(_ext)
325 _file_path = _split[0]
326 _param_path = "none"
327 if len(_split) > 1:
328 _param_path = _split[1]
329 _tmp_diffs[key].update({
330 "class_file": _file_path + _ext,
331 "param": _param_path,
332 })
333 _diff_report[_key[3:]] = {
334 "path": self._model_parts[_key],
335 "diffs": _tmp_diffs
336 }
337
338 _diff_report["diff_names"] = [self.model_name_1, self.model_name_2]
339 return _diff_report