Python3: resources and libraries
[csit.git] / resources / tools / presentation / generator_alerts.py
1 # Copyright (c) 2019 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 import smtplib
15 import logging
16
17 from email.mime.text import MIMEText
18 from email.mime.multipart import MIMEMultipart
19 from os.path import isdir
20 from collections import OrderedDict
21
22 from utils import get_last_completed_build_number
23 from errors import PresentationError
24
25
26 class AlertingError(PresentationError):
27     """Exception(s) raised by the alerting module.
28
29     When raising this exception, put this information to the message in this
30     order:
31      - short description of the encountered problem (parameter msg),
32      - relevant messages if there are any collected, e.g., from caught
33        exception (optional parameter details),
34      - relevant data if there are any collected (optional parameter details).
35     """
36
37     def __init__(self, msg, details='', level="CRITICAL"):
38         """Sets the exception message and the level.
39
40         :param msg: Short description of the encountered problem.
41         :param details: Relevant messages if there are any collected, e.g.,
42             from caught exception (optional parameter details), or relevant data
43             if there are any collected (optional parameter details).
44         :param level: Level of the error, possible choices are: "DEBUG", "INFO",
45             "WARNING", "ERROR" and "CRITICAL".
46         :type msg: str
47         :type details: str
48         :type level: str
49         """
50
51         super(AlertingError, self).__init__(
52             "Alerting: {0}".format(msg), details, level)
53
54     def __repr__(self):
55         return (
56             "AlertingError(msg={msg!r},details={dets!r},level={level!r})".
57             format(msg=self._msg, dets=self._details, level=self._level))
58
59
60 class Alerting:
61     """Class implementing the alerting mechanism.
62     """
63
64     def __init__(self, spec):
65         """Initialization.
66
67         :param spec: The CPTA specification.
68         :type spec: Specification
69         """
70
71         # Implemented alerts:
72         self._ALERTS = ("failed-tests", )
73
74         self._spec = spec
75
76         try:
77             self._spec_alert = spec.alerting
78         except KeyError as err:
79             raise  AlertingError("Alerting is not configured, skipped.",
80                                  repr(err),
81                                  "WARNING")
82
83         self._path_failed_tests = spec.environment["paths"]["DIR[STATIC,VPP]"]
84
85         # Verify and validate input specification:
86         self.configs = self._spec_alert.get("configurations", None)
87         if not self.configs:
88             raise AlertingError("No alert configuration is specified.")
89         for config_type, config_data in self.configs.iteritems():
90             if config_type == "email":
91                 if not config_data.get("server", None):
92                     raise AlertingError("Parameter 'server' is missing.")
93                 if not config_data.get("address-to", None):
94                     raise AlertingError("Parameter 'address-to' (recipient) is "
95                                         "missing.")
96                 if not config_data.get("address-from", None):
97                     raise AlertingError("Parameter 'address-from' (sender) is "
98                                         "missing.")
99             elif config_type == "jenkins":
100                 if not isdir(config_data.get("output-dir", "")):
101                     raise AlertingError("Parameter 'output-dir' is "
102                                         "missing or it is not a directory.")
103                 if not config_data.get("output-file", None):
104                     raise AlertingError("Parameter 'output-file' is missing.")
105             else:
106                 raise AlertingError("Alert of type '{0}' is not implemented.".
107                                     format(config_type))
108
109         self.alerts = self._spec_alert.get("alerts", None)
110         if not self.alerts:
111             raise AlertingError("No alert is specified.")
112         for alert, alert_data in self.alerts.iteritems():
113             if not alert_data.get("title", None):
114                 raise AlertingError("Parameter 'title' is missing.")
115             if not alert_data.get("type", None) in self._ALERTS:
116                 raise AlertingError("Parameter 'failed-tests' is missing or "
117                                     "incorrect.")
118             if not alert_data.get("way", None) in self.configs.keys():
119                 raise AlertingError("Parameter 'way' is missing or incorrect.")
120             if not alert_data.get("include", None):
121                 raise AlertingError("Parameter 'include' is missing or the "
122                                     "list is empty.")
123
124     def __str__(self):
125         """Return string with human readable description of the alert.
126
127         :returns: Readable description.
128         :rtype: str
129         """
130         return "configs={configs}, alerts={alerts}".format(
131             configs=self.configs, alerts=self.alerts)
132
133     def __repr__(self):
134         """Return string executable as Python constructor call.
135
136         :returns: Executable constructor call.
137         :rtype: str
138         """
139         return "Alerting(spec={spec})".format(
140             spec=self._spec)
141
142     def generate_alerts(self):
143         """Generate alert(s) using specified way(s).
144         """
145
146         for alert, alert_data in self.alerts.iteritems():
147             if alert_data["way"] == "jenkins":
148                 self._generate_email_body(alert_data)
149             else:
150                 raise AlertingError("Alert with way '{0}' is not implemented.".
151                                     format(alert_data["way"]))
152
153     @staticmethod
154     def _send_email(server, addr_from, addr_to, subject, text=None, html=None):
155         """Send an email using predefined configuration.
156
157         :param server: SMTP server used to send email.
158         :param addr_from: Sender address.
159         :param addr_to: Recipient address(es).
160         :param subject: Subject of the email.
161         :param text: Message in the ASCII text format.
162         :param html: Message in the HTML format.
163         :type server: str
164         :type addr_from: str
165         :type addr_to: list
166         :type subject: str
167         :type text: str
168         :type html: str
169         """
170
171         if not text and not html:
172             raise AlertingError("No text/data to send.")
173
174         msg = MIMEMultipart('alternative')
175         msg['Subject'] = subject
176         msg['From'] = addr_from
177         msg['To'] = ", ".join(addr_to)
178
179         if text:
180             msg.attach(MIMEText(text, 'plain'))
181         if html:
182             msg.attach(MIMEText(html, 'html'))
183
184         smtp_server = None
185         try:
186             logging.info("Trying to send alert '{0}' ...".format(subject))
187             logging.debug("SMTP Server: {0}".format(server))
188             logging.debug("From: {0}".format(addr_from))
189             logging.debug("To: {0}".format(", ".join(addr_to)))
190             logging.debug("Message: {0}".format(msg.as_string()))
191             smtp_server = smtplib.SMTP(server)
192             smtp_server.sendmail(addr_from, addr_to, msg.as_string())
193         except smtplib.SMTPException as err:
194             raise AlertingError("Not possible to send the alert via email.",
195                                 str(err))
196         finally:
197             if smtp_server:
198                 smtp_server.quit()
199
200     def _get_compressed_failed_tests(self, alert, test_set, sort=True):
201         """Return the dictionary with compressed faild tests. The compression is
202         done by grouping the tests from the same area but with different NICs,
203         frame sizes and number of processor cores.
204
205         For example, the failed tests:
206           10ge2p1x520-64b-1c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
207           10ge2p1x520-64b-2c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
208           10ge2p1x520-64b-4c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
209           10ge2p1x520-imix-1c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
210           10ge2p1x520-imix-2c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
211           10ge2p1x520-imix-4c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
212
213         will be represented as:
214           ethip4udp-ip4scale4000-udpsrcscale15-nat44 \
215           (10ge2p1x520, 64b, imix, 1c, 2c, 4c)
216
217         Structure of returned data:
218
219         {
220             "trimmed_TC_name_1": {
221                 "nics": [],
222                 "framesizes": [],
223                 "cores": []
224             }
225             ...
226             "trimmed_TC_name_N": {
227                 "nics": [],
228                 "framesizes": [],
229                 "cores": []
230             }
231         }
232
233         :param alert: Files are created for this alert.
234         :param test_set: Specifies which set of tests will be included in the
235             result. Its name is the same as the name of file with failed tests.
236         :param sort: If True, the failed tests are sorted alphabetically.
237         :type alert: dict
238         :type test_set: str
239         :type sort: bool
240         :returns: CSIT build number, VPP version, Number of passed tests,
241             Number of failed tests, Compressed failed tests.
242         :rtype: tuple(str, str, int, int, OrderedDict)
243         """
244
245         directory = self.configs[alert["way"]]["output-dir"]
246         failed_tests = OrderedDict()
247         file_path = "{0}/{1}.txt".format(directory, test_set)
248         version = ""
249         try:
250             with open(file_path, 'r') as f_txt:
251                 for idx, line in enumerate(f_txt):
252                     if idx == 0:
253                         build = line[:-1]
254                         continue
255                     if idx == 1:
256                         version = line[:-1]
257                         continue
258                     if idx == 2:
259                         passed = line[:-1]
260                         continue
261                     if idx == 3:
262                         failed = line[:-1]
263                         continue
264                     try:
265                         test = line[:-1].split('-')
266                         nic = test[0]
267                         framesize = test[1]
268                         cores = test[2]
269                         name = '-'.join(test[3:-1])
270                     except IndexError:
271                         continue
272                     if failed_tests.get(name, None) is None:
273                         failed_tests[name] = dict(nics=list(),
274                                                   framesizes=list(),
275                                                   cores=list())
276                     if nic not in failed_tests[name]["nics"]:
277                         failed_tests[name]["nics"].append(nic)
278                     if framesize not in failed_tests[name]["framesizes"]:
279                         failed_tests[name]["framesizes"].append(framesize)
280                     if cores not in failed_tests[name]["cores"]:
281                         failed_tests[name]["cores"].append(cores)
282         except IOError:
283             logging.error("No such file or directory: {file}".
284                           format(file=file_path))
285             return None, None, None, None, None
286         if sort:
287             sorted_failed_tests = OrderedDict()
288             keys = [k for k in failed_tests.keys()]
289             keys.sort()
290             for key in keys:
291                 sorted_failed_tests[key] = failed_tests[key]
292             return build, version, passed, failed, sorted_failed_tests
293         else:
294             return build, version, passed, failed, failed_tests
295
296     def _generate_email_body(self, alert):
297         """Create the file which is used in the generated alert.
298
299         :param alert: Files are created for this alert.
300         :type alert: dict
301         """
302
303         if alert["type"] != "failed-tests":
304             raise AlertingError("Alert of type '{0}' is not implemented.".
305                                 format(alert["type"]))
306
307         config = self.configs[alert["way"]]
308
309         text = ""
310         for idx, test_set in enumerate(alert.get("include", [])):
311             build, version, passed, failed, failed_tests = \
312                 self._get_compressed_failed_tests(alert, test_set)
313             if build is None:
314                 ret_code, build_nr, _ = get_last_completed_build_number(
315                     self._spec.environment["urls"]["URL[JENKINS,CSIT]"],
316                     alert["urls"][idx].split('/')[-1])
317                 if ret_code != 0:
318                     build_nr = ''
319                 text += "\n\nNo input data available for '{set}'. See CSIT " \
320                         "build {link}/{build} for more information.\n".\
321                     format(set='-'.join(test_set.split('-')[-2:]),
322                            link=alert["urls"][idx],
323                            build=build_nr)
324                 continue
325             text += ("\n\n{topo}-{arch}, "
326                      "{failed} tests failed, "
327                      "{passed} tests passed, "
328                      "CSIT build: {link}/{build}, "
329                      "VPP version: {version}\n\n".
330                      format(topo=test_set.split('-')[-2],
331                             arch=test_set.split('-')[-1],
332                             failed=failed,
333                             passed=passed,
334                             link=alert["urls"][idx],
335                             build=build,
336                             version=version))
337             regression_hdr = ("\n\n{topo}-{arch}, "
338                               "CSIT build: {link}/{build}, "
339                               "VPP version: {version}\n\n"
340                               .format(topo=test_set.split('-')[-2],
341                                       arch=test_set.split('-')[-1],
342                                       link=alert["urls"][idx],
343                                       build=build,
344                                       version=version
345                                       ))
346             max_len_name = 0
347             max_len_nics = 0
348             max_len_framesizes = 0
349             max_len_cores = 0
350             for name, params in failed_tests.items():
351                 failed_tests[name]["nics"] = ",".join(sorted(params["nics"]))
352                 failed_tests[name]["framesizes"] = \
353                     ",".join(sorted(params["framesizes"]))
354                 failed_tests[name]["cores"] = ",".join(sorted(params["cores"]))
355                 if len(name) > max_len_name:
356                     max_len_name = len(name)
357                 if len(failed_tests[name]["nics"]) > max_len_nics:
358                     max_len_nics = len(failed_tests[name]["nics"])
359                 if len(failed_tests[name]["framesizes"]) > max_len_framesizes:
360                     max_len_framesizes = len(failed_tests[name]["framesizes"])
361                 if len(failed_tests[name]["cores"]) > max_len_cores:
362                     max_len_cores = len(failed_tests[name]["cores"])
363
364             for name, params in failed_tests.items():
365                 text += "{name}  {nics}  {frames}  {cores}\n".format(
366                     name=name + " " * (max_len_name - len(name)),
367                     nics=params["nics"] +
368                         " " * (max_len_nics - len(params["nics"])),
369                     frames=params["framesizes"] + " " *
370                         (max_len_framesizes - len(params["framesizes"])),
371                     cores=params["cores"] +
372                         " " * (max_len_cores - len(params["cores"])))
373
374             # Add list of regressions:
375             file_name = "{0}/cpta-regressions-{1}.txt".\
376                 format(config["output-dir"], alert["urls"][idx].split('/')[-1])
377             try:
378                 with open(file_name, 'r') as txt_file:
379                     file_content = txt_file.read()
380                     reg_file_name = "{dir}/trending-regressions.txt". \
381                         format(dir=config["output-dir"])
382                     with open(reg_file_name, 'a+') as reg_file:
383                         reg_file.write(regression_hdr)
384                         if file_content:
385                             reg_file.write(file_content)
386                         else:
387                             reg_file.write("No regressions")
388             except IOError as err:
389                 logging.warning(repr(err))
390
391             # Add list of progressions:
392             file_name = "{0}/cpta-progressions-{1}.txt".\
393                 format(config["output-dir"], alert["urls"][idx].split('/')[-1])
394             try:
395                 with open(file_name, 'r') as txt_file:
396                     file_content = txt_file.read()
397                     pro_file_name = "{dir}/trending-progressions.txt". \
398                         format(dir=config["output-dir"])
399                     with open(pro_file_name, 'a+') as pro_file:
400                         pro_file.write(regression_hdr)
401                         if file_content:
402                             pro_file.write(file_content)
403                         else:
404                             pro_file.write("No progressions")
405             except IOError as err:
406                 logging.warning(repr(err))
407
408         text += "\nFor detailed information visit: {url}\n".\
409             format(url=alert["url-details"])
410         file_name = "{0}/{1}".format(config["output-dir"],
411                                      config["output-file"])
412         logging.info("Writing the file '{0}.txt' ...".format(file_name))
413
414         try:
415             with open("{0}.txt".format(file_name), 'w') as txt_file:
416                 txt_file.write(text)
417         except IOError:
418             logging.error("Not possible to write the file '{0}.txt'.".
419                           format(file_name))