ce5d8035e959c1de55edea7e4232d83e38f19279
[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 execute_command
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(object):
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         try:
75             self._spec = spec.alerting
76         except KeyError as err:
77             raise  AlertingError("Alerting is not configured, skipped.",
78                                  repr(err),
79                                  "WARNING")
80
81         self._path_failed_tests = spec.environment["paths"]["DIR[STATIC,VPP]"]
82
83         # Verify and validate input specification:
84         self.configs = self._spec.get("configurations", None)
85         if not self.configs:
86             raise AlertingError("No alert configuration is specified.")
87         for config_type, config_data in self.configs.iteritems():
88             if config_type == "email":
89                 if not config_data.get("server", None):
90                     raise AlertingError("Parameter 'server' is missing.")
91                 if not config_data.get("address-to", None):
92                     raise AlertingError("Parameter 'address-to' (recipient) is "
93                                         "missing.")
94                 if not config_data.get("address-from", None):
95                     raise AlertingError("Parameter 'address-from' (sender) is "
96                                         "missing.")
97             elif config_type == "jenkins":
98                 if not isdir(config_data.get("output-dir", "")):
99                     raise AlertingError("Parameter 'output-dir' is "
100                                         "missing or it is not a directory.")
101                 if not config_data.get("output-file", None):
102                     raise AlertingError("Parameter 'output-file' is missing.")
103             else:
104                 raise AlertingError("Alert of type '{0}' is not implemented.".
105                                     format(config_type))
106
107         self.alerts = self._spec.get("alerts", None)
108         if not self.alerts:
109             raise AlertingError("No alert is specified.")
110         for alert, alert_data in self.alerts.iteritems():
111             if not alert_data.get("title", None):
112                 raise AlertingError("Parameter 'title' is missing.")
113             if not alert_data.get("type", None) in self._ALERTS:
114                 raise AlertingError("Parameter 'failed-tests' is missing or "
115                                     "incorrect.")
116             if not alert_data.get("way", None) in self.configs.keys():
117                 raise AlertingError("Parameter 'way' is missing or incorrect.")
118             if not alert_data.get("include", None):
119                 raise AlertingError("Parameter 'include' is missing or the "
120                                     "list is empty.")
121
122     def __str__(self):
123         """Return string with human readable description of the alert.
124
125         :returns: Readable description.
126         :rtype: str
127         """
128         return "configs={configs}, alerts={alerts}".format(
129             configs=self.configs, alerts=self.alerts)
130
131     def __repr__(self):
132         """Return string executable as Python constructor call.
133
134         :returns: Executable constructor call.
135         :rtype: str
136         """
137         return "Alerting(spec={spec})".format(
138             spec=self._spec)
139
140     def generate_alerts(self):
141         """Generate alert(s) using specified way(s).
142         """
143
144         for alert, alert_data in self.alerts.iteritems():
145             if alert_data["way"] == "email":
146                 text, html = self._create_alert_message(alert_data)
147                 conf = self.configs["email"]
148                 self._send_email(server=conf["server"],
149                                  addr_from=conf["address-from"],
150                                  addr_to=conf["address-to"],
151                                  subject=alert_data["title"],
152                                  text=text,
153                                  html=html)
154             elif alert_data["way"] == "jenkins":
155                 self._generate_email_body(alert_data)
156                 # TODO: Remove when not needed
157                 self._generate_files_for_jenkins(alert_data)
158             else:
159                 raise AlertingError("Alert with way '{0}' is not implemented.".
160                                     format(alert_data["way"]))
161
162     @staticmethod
163     def _send_email(server, addr_from, addr_to, subject, text=None, html=None):
164         """Send an email using predefined configuration.
165
166         :param server: SMTP server used to send email.
167         :param addr_from: Sender address.
168         :param addr_to: Recipient address(es).
169         :param subject: Subject of the email.
170         :param text: Message in the ASCII text format.
171         :param html: Message in the HTML format.
172         :type server: str
173         :type addr_from: str
174         :type addr_to: list
175         :type subject: str
176         :type text: str
177         :type html: str
178         """
179
180         if not text and not html:
181             raise AlertingError("No text/data to send.")
182
183         msg = MIMEMultipart('alternative')
184         msg['Subject'] = subject
185         msg['From'] = addr_from
186         msg['To'] = ", ".join(addr_to)
187
188         if text:
189             msg.attach(MIMEText(text, 'plain'))
190         if html:
191             msg.attach(MIMEText(html, 'html'))
192
193         smtp_server = None
194         try:
195             logging.info("Trying to send alert '{0}' ...".format(subject))
196             logging.debug("SMTP Server: {0}".format(server))
197             logging.debug("From: {0}".format(addr_from))
198             logging.debug("To: {0}".format(", ".join(addr_to)))
199             logging.debug("Message: {0}".format(msg.as_string()))
200             smtp_server = smtplib.SMTP(server)
201             smtp_server.sendmail(addr_from, addr_to, msg.as_string())
202         except smtplib.SMTPException as err:
203             raise AlertingError("Not possible to send the alert via email.",
204                                 str(err))
205         finally:
206             if smtp_server:
207                 smtp_server.quit()
208
209     def _get_compressed_failed_tests(self, alert, test_set, sort=True):
210         """Return the dictionary with compressed faild tests. The compression is
211         done by grouping the tests from the same area but with different NICs,
212         frame sizes and number of processor cores.
213
214         For example, the failed tests:
215           10ge2p1x520-64b-1c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
216           10ge2p1x520-64b-2c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
217           10ge2p1x520-64b-4c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
218           10ge2p1x520-imix-1c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
219           10ge2p1x520-imix-2c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
220           10ge2p1x520-imix-4c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
221
222         will be represented as:
223           ethip4udp-ip4scale4000-udpsrcscale15-nat44 \
224           (10ge2p1x520, 64b, imix, 1c, 2c, 4c)
225
226         Structure of returned data:
227
228         {
229             "trimmed_TC_name_1": {
230                 "nics": [],
231                 "framesizes": [],
232                 "cores": []
233             }
234             ...
235             "trimmed_TC_name_N": {
236                 "nics": [],
237                 "framesizes": [],
238                 "cores": []
239             }
240         }
241
242         :param alert: Files are created for this alert.
243         :param test_set: Specifies which set of tests will be included in the
244             result. Its name is the same as the name of file with failed tests.
245         :param sort: If True, the failed tests are sorted alphabetically.
246         :type alert: dict
247         :type test_set: str
248         :type sort: bool
249         :returns: CSIT build number, VPP version, Number of failed tests,
250             Compressed failed tests.
251         :rtype: tuple(str, str, int, OrderedDict)
252         """
253
254         directory = self.configs[alert["way"]]["output-dir"]
255         failed_tests = OrderedDict()
256         version = ""
257         try:
258             with open("{0}/{1}.txt".format(directory, test_set), 'r') as f_txt:
259                 for idx, line in enumerate(f_txt):
260                     if idx == 0:
261                         build = line[:-1]
262                         continue
263                     if idx == 1:
264                         version = line[:-1]
265                         continue
266                     try:
267                         test = line[:-1].split('-')
268                         nic = test[0]
269                         framesize = test[1]
270                         cores = test[2]
271                         name = '-'.join(test[3:-1])
272                     except IndexError:
273                         continue
274                     if failed_tests.get(name, None) is None:
275                         failed_tests[name] = dict(nics=list(),
276                                                   framesizes=list(),
277                                                   cores=list())
278                     if nic not in failed_tests[name]["nics"]:
279                         failed_tests[name]["nics"].append(nic)
280                     if framesize not in failed_tests[name]["framesizes"]:
281                         failed_tests[name]["framesizes"].append(framesize)
282                     if cores not in failed_tests[name]["cores"]:
283                         failed_tests[name]["cores"].append(cores)
284         except IOError as err:
285             logging.error(repr(err))
286             return None, None, None, None
287         if sort:
288             sorted_failed_tests = OrderedDict()
289             keys = [k for k in failed_tests.keys()]
290             keys.sort()
291             for key in keys:
292                 sorted_failed_tests[key] = failed_tests[key]
293             return build, version, idx-1, sorted_failed_tests
294         else:
295             return build, version, idx-1, failed_tests
296
297     def _generate_email_body(self, alert):
298         """Create the file which is used in the generated alert.
299
300         :param alert: Files are created for this alert.
301         :type alert: dict
302         """
303
304         if alert["type"] != "failed-tests":
305             raise AlertingError("Alert of type '{0}' is not implemented.".
306                                 format(alert["type"]))
307
308         config = self.configs[alert["way"]]
309
310         text = ""
311         for idx, test_set in enumerate(alert.get("include", [])):
312             build, version, nr, failed_tests = \
313                 self._get_compressed_failed_tests(alert, test_set)
314             if build is None:
315                 continue
316             text += ("\n\n{topo}-{arch}, "
317                      "{nr} tests failed, "
318                      "CSIT build: {link}/{build}, "
319                      "VPP version: {version}\n\n".
320                      format(topo=test_set.split('-')[-2],
321                             arch=test_set.split('-')[-1],
322                             nr=nr,
323                             link=alert["urls"][idx],
324                             build=build,
325                             version=version))
326             max_len_name = 0
327             max_len_nics = 0
328             max_len_framesizes = 0
329             max_len_cores = 0
330             for name, params in failed_tests.items():
331                 failed_tests[name]["nics"] = ",".join(sorted(params["nics"]))
332                 failed_tests[name]["framesizes"] = \
333                     ",".join(sorted(params["framesizes"]))
334                 failed_tests[name]["cores"] = ",".join(sorted(params["cores"]))
335                 if len(name) > max_len_name:
336                     max_len_name = len(name)
337                 if len(failed_tests[name]["nics"]) > max_len_nics:
338                     max_len_nics = len(failed_tests[name]["nics"])
339                 if len(failed_tests[name]["framesizes"]) > max_len_framesizes:
340                     max_len_framesizes = len(failed_tests[name]["framesizes"])
341                 if len(failed_tests[name]["cores"]) > max_len_cores:
342                     max_len_cores = len(failed_tests[name]["cores"])
343
344             for name, params in failed_tests.items():
345                 text += "{name}  {nics}  {frames}  {cores}\n".format(
346                     name=name + " " * (max_len_name - len(name)),
347                     nics=params["nics"] +
348                         " " * (max_len_nics - len(params["nics"])),
349                     frames=params["framesizes"] + " " *
350                         (max_len_framesizes - len(params["framesizes"])),
351                     cores=params["cores"] +
352                         " " * (max_len_cores - len(params["cores"])))
353
354         text += "\nFor detailed information visit: {url}\n".\
355             format(url=alert["url-details"])
356         file_name = "{0}/{1}".format(config["output-dir"],
357                                                 config["output-file"])
358         logging.info("Writing the file '{0}.txt' ...".format(file_name))
359
360         try:
361             with open("{0}.txt".format(file_name), 'w') as txt_file:
362                 txt_file.write(text)
363         except IOError:
364             logging.error("Not possible to write the file '{0}.txt'.".
365                           format(file_name))
366
367     def _generate_files_for_jenkins(self, alert):
368         """Create the file which is used in the generated alert.
369
370         # TODO: Remove when not needed.
371
372         :param alert: Files are created for this alert.
373         :type alert: dict
374         """
375
376         config = self.configs[alert["way"]]
377
378         zip_file = config.get("zip-output", None)
379         if zip_file:
380             logging.info("Writing the file '{0}/{1}' ...".
381                          format(config["output-dir"], zip_file))
382             execute_command("tar czvf {dir}/{zip} --directory={dir} "
383                             "{input}.txt".
384                             format(dir=config["output-dir"],
385                                    zip=zip_file,
386                                    input=config["output-file"]))