CSIT-1131: Alerting
[csit.git] / resources / tools / presentation / specification_parser.py
1 # Copyright (c) 2018 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Specification
15
16 Parsing of the specification YAML file.
17 """
18
19
20 import logging
21 from yaml import load, YAMLError
22 from pprint import pformat
23
24 from errors import PresentationError
25 from utils import get_last_successful_build_number
26 from utils import get_last_completed_build_number
27
28
29 class Specification(object):
30     """Specification of Presentation and analytics layer.
31
32     - based on specification specified in the specification YAML file
33     - presentation and analytics layer is model driven
34     """
35
36     # Tags are used in specification YAML file and replaced while the file is
37     # parsed.
38     TAG_OPENER = "{"
39     TAG_CLOSER = "}"
40
41     def __init__(self, cfg_file):
42         """Initialization.
43
44         :param cfg_file: File handler for the specification YAML file.
45         :type cfg_file: BinaryIO
46         """
47         self._cfg_file = cfg_file
48         self._cfg_yaml = None
49
50         self._specification = {"environment": dict(),
51                                "configuration": dict(),
52                                "static": dict(),
53                                "input": dict(),
54                                "output": dict(),
55                                "tables": list(),
56                                "plots": list(),
57                                "files": list(),
58                                "cpta": dict()}
59
60     @property
61     def specification(self):
62         """Getter - specification.
63
64         :returns: Specification.
65         :rtype: dict
66         """
67         return self._specification
68
69     @property
70     def environment(self):
71         """Getter - environment.
72
73         :returns: Environment specification.
74         :rtype: dict
75         """
76         return self._specification["environment"]
77
78     @property
79     def configuration(self):
80         """Getter - configuration.
81
82         :returns: Configuration of PAL.
83         :rtype: dict
84         """
85         return self._specification["configuration"]
86
87     @property
88     def static(self):
89         """Getter - static content.
90
91         :returns: Static content specification.
92         :rtype: dict
93         """
94         return self._specification["static"]
95
96     @property
97     def mapping(self):
98         """Getter - Mapping.
99
100         :returns: Mapping of the old names of test cases to the new (actual)
101             one.
102         :rtype: dict
103         """
104         return self._specification["configuration"]["mapping"]
105
106     @property
107     def ignore(self):
108         """Getter - Ignore list.
109
110         :returns: List of ignored test cases.
111         :rtype: list
112         """
113         return self._specification["configuration"]["ignore"]
114
115     @property
116     def alerting(self):
117         """Getter - Alerting.
118
119         :returns: Specification of alerts.
120         :rtype: dict
121         """
122         return self._specification["configuration"]["alerting"]
123
124     @property
125     def input(self):
126         """Getter - specification - inputs.
127         - jobs and builds.
128
129         :returns: Inputs.
130         :rtype: dict
131         """
132         return self._specification["input"]
133
134     @property
135     def builds(self):
136         """Getter - builds defined in specification.
137
138         :returns: Builds defined in the specification.
139         :rtype: dict
140         """
141         return self.input["builds"]
142
143     @property
144     def output(self):
145         """Getter - specification - output formats and versions to be generated.
146         - formats: html, pdf
147         - versions: full, ...
148
149         :returns: Outputs to be generated.
150         :rtype: dict
151         """
152         return self._specification["output"]
153
154     @property
155     def tables(self):
156         """Getter - tables to be generated.
157
158         :returns: List of specifications of tables to be generated.
159         :rtype: list
160         """
161         return self._specification["tables"]
162
163     @property
164     def plots(self):
165         """Getter - plots to be generated.
166
167         :returns: List of specifications of plots to be generated.
168         :rtype: list
169         """
170         return self._specification["plots"]
171
172     @property
173     def files(self):
174         """Getter - files to be generated.
175
176         :returns: List of specifications of files to be generated.
177         :rtype: list
178         """
179         return self._specification["files"]
180
181     @property
182     def cpta(self):
183         """Getter - Continuous Performance Trending and Analysis to be
184         generated.
185
186         :returns: List of specifications of Continuous Performance Trending and
187         Analysis to be generated.
188         :rtype: list
189         """
190         return self._specification["cpta"]
191
192     def set_input_state(self, job, build_nr, state):
193         """Set the state of input
194
195         :param job:
196         :param build_nr:
197         :param state:
198         :return:
199         """
200
201         try:
202             for build in self._specification["input"]["builds"][job]:
203                 if build["build"] == build_nr:
204                     build["status"] = state
205                     break
206             else:
207                 raise PresentationError("Build '{}' is not defined for job '{}'"
208                                         " in specification file.".
209                                         format(build_nr, job))
210         except KeyError:
211             raise PresentationError("Job '{}' and build '{}' is not defined in "
212                                     "specification file.".format(job, build_nr))
213
214     def set_input_file_name(self, job, build_nr, file_name):
215         """Set the state of input
216
217         :param job:
218         :param build_nr:
219         :param file_name:
220         :return:
221         """
222
223         try:
224             for build in self._specification["input"]["builds"][job]:
225                 if build["build"] == build_nr:
226                     build["file-name"] = file_name
227                     break
228             else:
229                 raise PresentationError("Build '{}' is not defined for job '{}'"
230                                         " in specification file.".
231                                         format(build_nr, job))
232         except KeyError:
233             raise PresentationError("Job '{}' and build '{}' is not defined in "
234                                     "specification file.".format(job, build_nr))
235
236     def _get_build_number(self, job, build_type):
237         """Get the number of the job defined by its name:
238          - lastSuccessfulBuild
239          - lastCompletedBuild
240
241         :param job: Job name.
242         :param build_type: Build type:
243          - lastSuccessfulBuild
244          - lastCompletedBuild
245         :type job" str
246         :raises PresentationError: If it is not possible to get the build
247         number.
248         :returns: The build number.
249         :rtype: int
250         """
251
252         # defined as a range <start, end>
253         if build_type == "lastSuccessfulBuild":
254             # defined as a range <start, lastSuccessfulBuild>
255             ret_code, build_nr, _ = get_last_successful_build_number(
256                 self.environment["urls"]["URL[JENKINS,CSIT]"], job)
257         elif build_type == "lastCompletedBuild":
258             # defined as a range <start, lastCompletedBuild>
259             ret_code, build_nr, _ = get_last_completed_build_number(
260                 self.environment["urls"]["URL[JENKINS,CSIT]"], job)
261         else:
262             raise PresentationError("Not supported build type: '{0}'".
263                                     format(build_type))
264         if ret_code != 0:
265             raise PresentationError("Not possible to get the number of the "
266                                     "build number.")
267         try:
268             build_nr = int(build_nr)
269             return build_nr
270         except ValueError as err:
271             raise PresentationError("Not possible to get the number of the "
272                                     "build number.\nReason: {0}".format(err))
273
274     def _get_type_index(self, item_type):
275         """Get index of item type (environment, input, output, ...) in
276         specification YAML file.
277
278         :param item_type: Item type: Top level items in specification YAML file,
279         e.g.: environment, input, output.
280         :type item_type: str
281         :returns: Index of the given item type.
282         :rtype: int
283         """
284
285         index = 0
286         for item in self._cfg_yaml:
287             if item["type"] == item_type:
288                 return index
289             index += 1
290         return None
291
292     def _find_tag(self, text):
293         """Find the first tag in the given text. The tag is enclosed by the
294         TAG_OPENER and TAG_CLOSER.
295
296         :param text: Text to be searched.
297         :type text: str
298         :returns: The tag, or None if not found.
299         :rtype: str
300         """
301         try:
302             start = text.index(self.TAG_OPENER)
303             end = text.index(self.TAG_CLOSER, start + 1) + 1
304             return text[start:end]
305         except ValueError:
306             return None
307
308     def _replace_tags(self, data, src_data=None):
309         """Replace tag(s) in the data by their values.
310
311         :param data: The data where the tags will be replaced by their values.
312         :param src_data: Data where the tags are defined. It is dictionary where
313         the key is the tag and the value is the tag value. If not given, 'data'
314         is used instead.
315         :type data: str or dict
316         :type src_data: dict
317         :returns: Data with the tags replaced.
318         :rtype: str or dict
319         :raises: PresentationError if it is not possible to replace the tag or
320         the data is not the supported data type (str, dict).
321         """
322
323         if src_data is None:
324             src_data = data
325
326         if isinstance(data, str):
327             tag = self._find_tag(data)
328             if tag is not None:
329                 data = data.replace(tag, src_data[tag[1:-1]])
330
331         elif isinstance(data, dict):
332             counter = 0
333             for key, value in data.items():
334                 tag = self._find_tag(value)
335                 if tag is not None:
336                     try:
337                         data[key] = value.replace(tag, src_data[tag[1:-1]])
338                         counter += 1
339                     except KeyError:
340                         raise PresentationError("Not possible to replace the "
341                                                 "tag '{}'".format(tag))
342             if counter:
343                 self._replace_tags(data, src_data)
344         else:
345             raise PresentationError("Replace tags: Not supported data type.")
346
347         return data
348
349     def _parse_env(self):
350         """Parse environment specification in the specification YAML file.
351         """
352
353         logging.info("Parsing specification file: environment ...")
354
355         idx = self._get_type_index("environment")
356         if idx is None:
357             return None
358
359         try:
360             self._specification["environment"]["configuration"] = \
361                 self._cfg_yaml[idx]["configuration"]
362         except KeyError:
363             self._specification["environment"]["configuration"] = None
364
365         try:
366             self._specification["environment"]["paths"] = \
367                 self._replace_tags(self._cfg_yaml[idx]["paths"])
368         except KeyError:
369             self._specification["environment"]["paths"] = None
370
371         try:
372             self._specification["environment"]["urls"] = \
373                 self._replace_tags(self._cfg_yaml[idx]["urls"])
374         except KeyError:
375             self._specification["environment"]["urls"] = None
376
377         try:
378             self._specification["environment"]["make-dirs"] = \
379                 self._cfg_yaml[idx]["make-dirs"]
380         except KeyError:
381             self._specification["environment"]["make-dirs"] = None
382
383         try:
384             self._specification["environment"]["remove-dirs"] = \
385                 self._cfg_yaml[idx]["remove-dirs"]
386         except KeyError:
387             self._specification["environment"]["remove-dirs"] = None
388
389         try:
390             self._specification["environment"]["build-dirs"] = \
391                 self._cfg_yaml[idx]["build-dirs"]
392         except KeyError:
393             self._specification["environment"]["build-dirs"] = None
394
395         logging.info("Done.")
396
397     def _parse_configuration(self):
398         """Parse configuration of PAL in the specification YAML file.
399         """
400
401         logging.info("Parsing specification file: configuration ...")
402
403         idx = self._get_type_index("configuration")
404         if idx is None:
405             logging.warning("No configuration information in the specification "
406                             "file.")
407             return None
408
409         try:
410             self._specification["configuration"] = self._cfg_yaml[idx]
411
412         except KeyError:
413             raise PresentationError("No configuration defined.")
414
415         # Data sets: Replace ranges by lists
416         for set_name, data_set in self.configuration["data-sets"].items():
417             for job, builds in data_set.items():
418                 if builds:
419                     if isinstance(builds, dict):
420                         build_nr = builds.get("end", None)
421                         try:
422                             build_nr = int(build_nr)
423                         except ValueError:
424                             # defined as a range <start, build_type>
425                             build_nr = self._get_build_number(job, build_nr)
426                         builds = [x for x in range(builds["start"], build_nr+1)]
427                         self.configuration["data-sets"][set_name][job] = builds
428
429         # Mapping table:
430         mapping = None
431         mapping_file_name = self._specification["configuration"].\
432             get("mapping-file", None)
433         if mapping_file_name:
434             logging.debug("Mapping file: '{0}'".format(mapping_file_name))
435             try:
436                 with open(mapping_file_name, 'r') as mfile:
437                     mapping = load(mfile)
438                 logging.debug("Loaded mapping table:\n{0}".format(mapping))
439             except (YAMLError, IOError) as err:
440                 raise PresentationError(
441                     msg="An error occurred while parsing the mapping file "
442                         "'{0}'.".format(mapping_file_name),
443                     details=repr(err))
444         # Make sure everything is lowercase
445         if mapping:
446             self._specification["configuration"]["mapping"] = \
447                 {key.lower(): val.lower() for key, val in mapping.iteritems()}
448         else:
449             self._specification["configuration"]["mapping"] = dict()
450
451         # Ignore list:
452         ignore = None
453         ignore_list_name = self._specification["configuration"].\
454             get("ignore-list", None)
455         if ignore_list_name:
456             logging.debug("Ignore list file: '{0}'".format(ignore_list_name))
457             try:
458                 with open(ignore_list_name, 'r') as ifile:
459                     ignore = load(ifile)
460                 logging.debug("Loaded ignore list:\n{0}".format(ignore))
461             except (YAMLError, IOError) as err:
462                 raise PresentationError(
463                     msg="An error occurred while parsing the ignore list file "
464                         "'{0}'.".format(ignore_list_name),
465                     details=repr(err))
466         # Make sure everything is lowercase
467         if ignore:
468             self._specification["configuration"]["ignore"] = \
469                 [item.lower() for item in ignore]
470         else:
471             self._specification["configuration"]["ignore"] = list()
472
473         logging.info("Done.")
474
475     def _parse_input(self):
476         """Parse input specification in the specification YAML file.
477
478         :raises: PresentationError if there are no data to process.
479         """
480
481         logging.info("Parsing specification file: input ...")
482
483         idx = self._get_type_index("input")
484         if idx is None:
485             raise PresentationError("No data to process.")
486
487         try:
488             for key, value in self._cfg_yaml[idx]["general"].items():
489                 self._specification["input"][key] = value
490             self._specification["input"]["builds"] = dict()
491
492             for job, builds in self._cfg_yaml[idx]["builds"].items():
493                 if builds:
494                     if isinstance(builds, dict):
495                         build_nr = builds.get("end", None)
496                         try:
497                             build_nr = int(build_nr)
498                         except ValueError:
499                             # defined as a range <start, build_type>
500                             build_nr = self._get_build_number(job, build_nr)
501                         builds = [x for x in range(builds["start"], build_nr+1)]
502                     self._specification["input"]["builds"][job] = list()
503                     for build in builds:
504                         self._specification["input"]["builds"][job]. \
505                             append({"build": build, "status": None})
506
507                 else:
508                     logging.warning("No build is defined for the job '{}'. "
509                                     "Trying to continue without it.".
510                                     format(job))
511         except KeyError:
512             raise PresentationError("No data to process.")
513
514         logging.info("Done.")
515
516     def _parse_output(self):
517         """Parse output specification in the specification YAML file.
518
519         :raises: PresentationError if there is no output defined.
520         """
521
522         logging.info("Parsing specification file: output ...")
523
524         idx = self._get_type_index("output")
525         if idx is None:
526             raise PresentationError("No output defined.")
527
528         try:
529             self._specification["output"] = self._cfg_yaml[idx]
530         except (KeyError, IndexError):
531             raise PresentationError("No output defined.")
532
533         logging.info("Done.")
534
535     def _parse_static(self):
536         """Parse specification of the static content in the specification YAML
537         file.
538         """
539
540         logging.info("Parsing specification file: static content ...")
541
542         idx = self._get_type_index("static")
543         if idx is None:
544             logging.warning("No static content specified.")
545
546         for key, value in self._cfg_yaml[idx].items():
547             if isinstance(value, str):
548                 try:
549                     self._cfg_yaml[idx][key] = self._replace_tags(
550                         value, self._specification["environment"]["paths"])
551                 except KeyError:
552                     pass
553
554         self._specification["static"] = self._cfg_yaml[idx]
555
556         logging.info("Done.")
557
558     def _parse_elements(self):
559         """Parse elements (tables, plots) specification in the specification
560         YAML file.
561         """
562
563         logging.info("Parsing specification file: elements ...")
564
565         count = 1
566         for element in self._cfg_yaml:
567             try:
568                 element["output-file"] = self._replace_tags(
569                     element["output-file"],
570                     self._specification["environment"]["paths"])
571             except KeyError:
572                 pass
573
574             try:
575                 element["input-file"] = self._replace_tags(
576                     element["input-file"],
577                     self._specification["environment"]["paths"])
578             except KeyError:
579                 pass
580
581             # add data sets to the elements:
582             if isinstance(element.get("data", None), str):
583                 data_set = element["data"]
584                 try:
585                     element["data"] = self.configuration["data-sets"][data_set]
586                 except KeyError:
587                     raise PresentationError("Data set {0} is not defined in "
588                                             "the configuration section.".
589                                             format(data_set))
590
591             if element["type"] == "table":
592                 logging.info("  {:3d} Processing a table ...".format(count))
593                 try:
594                     element["template"] = self._replace_tags(
595                         element["template"],
596                         self._specification["environment"]["paths"])
597                 except KeyError:
598                     pass
599                 self._specification["tables"].append(element)
600                 count += 1
601
602             elif element["type"] == "plot":
603                 logging.info("  {:3d} Processing a plot ...".format(count))
604
605                 # Add layout to the plots:
606                 layout = element["layout"].get("layout", None)
607                 if layout is not None:
608                     element["layout"].pop("layout")
609                     try:
610                         for key, val in (self.configuration["plot-layouts"]
611                                          [layout].items()):
612                             element["layout"][key] = val
613                     except KeyError:
614                         raise PresentationError("Layout {0} is not defined in "
615                                                 "the configuration section.".
616                                                 format(layout))
617                 self._specification["plots"].append(element)
618                 count += 1
619
620             elif element["type"] == "file":
621                 logging.info("  {:3d} Processing a file ...".format(count))
622                 try:
623                     element["dir-tables"] = self._replace_tags(
624                         element["dir-tables"],
625                         self._specification["environment"]["paths"])
626                 except KeyError:
627                     pass
628                 self._specification["files"].append(element)
629                 count += 1
630
631             elif element["type"] == "cpta":
632                 logging.info("  {:3d} Processing Continuous Performance "
633                              "Trending and Analysis ...".format(count))
634
635                 for plot in element["plots"]:
636                     # Add layout to the plots:
637                     layout = plot.get("layout", None)
638                     if layout is not None:
639                         try:
640                             plot["layout"] = \
641                                 self.configuration["plot-layouts"][layout]
642                         except KeyError:
643                             raise PresentationError(
644                                 "Layout {0} is not defined in the "
645                                 "configuration section.".format(layout))
646                     # Add data sets:
647                     if isinstance(plot.get("data", None), str):
648                         data_set = plot["data"]
649                         try:
650                             plot["data"] = \
651                                 self.configuration["data-sets"][data_set]
652                         except KeyError:
653                             raise PresentationError(
654                                 "Data set {0} is not defined in "
655                                 "the configuration section.".
656                                 format(data_set))
657                 self._specification["cpta"] = element
658                 count += 1
659
660         logging.info("Done.")
661
662     def read_specification(self):
663         """Parse specification in the specification YAML file.
664
665         :raises: PresentationError if an error occurred while parsing the
666         specification file.
667         """
668         try:
669             self._cfg_yaml = load(self._cfg_file)
670         except YAMLError as err:
671             raise PresentationError(msg="An error occurred while parsing the "
672                                         "specification file.",
673                                     details=str(err))
674
675         self._parse_env()
676         self._parse_configuration()
677         self._parse_input()
678         self._parse_output()
679         self._parse_static()
680         self._parse_elements()
681
682         logging.debug("Specification: \n{}".
683                       format(pformat(self._specification)))