Trending: Reduce input data
[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"] == "jenkins":
146                 self._generate_email_body(alert_data)
147             else:
148                 raise AlertingError("Alert with way '{0}' is not implemented.".
149                                     format(alert_data["way"]))
150
151     @staticmethod
152     def _send_email(server, addr_from, addr_to, subject, text=None, html=None):
153         """Send an email using predefined configuration.
154
155         :param server: SMTP server used to send email.
156         :param addr_from: Sender address.
157         :param addr_to: Recipient address(es).
158         :param subject: Subject of the email.
159         :param text: Message in the ASCII text format.
160         :param html: Message in the HTML format.
161         :type server: str
162         :type addr_from: str
163         :type addr_to: list
164         :type subject: str
165         :type text: str
166         :type html: str
167         """
168
169         if not text and not html:
170             raise AlertingError("No text/data to send.")
171
172         msg = MIMEMultipart('alternative')
173         msg['Subject'] = subject
174         msg['From'] = addr_from
175         msg['To'] = ", ".join(addr_to)
176
177         if text:
178             msg.attach(MIMEText(text, 'plain'))
179         if html:
180             msg.attach(MIMEText(html, 'html'))
181
182         smtp_server = None
183         try:
184             logging.info("Trying to send alert '{0}' ...".format(subject))
185             logging.debug("SMTP Server: {0}".format(server))
186             logging.debug("From: {0}".format(addr_from))
187             logging.debug("To: {0}".format(", ".join(addr_to)))
188             logging.debug("Message: {0}".format(msg.as_string()))
189             smtp_server = smtplib.SMTP(server)
190             smtp_server.sendmail(addr_from, addr_to, msg.as_string())
191         except smtplib.SMTPException as err:
192             raise AlertingError("Not possible to send the alert via email.",
193                                 str(err))
194         finally:
195             if smtp_server:
196                 smtp_server.quit()
197
198     def _get_compressed_failed_tests(self, alert, test_set, sort=True):
199         """Return the dictionary with compressed faild tests. The compression is
200         done by grouping the tests from the same area but with different NICs,
201         frame sizes and number of processor cores.
202
203         For example, the failed tests:
204           10ge2p1x520-64b-1c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
205           10ge2p1x520-64b-2c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
206           10ge2p1x520-64b-4c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
207           10ge2p1x520-imix-1c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
208           10ge2p1x520-imix-2c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
209           10ge2p1x520-imix-4c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
210
211         will be represented as:
212           ethip4udp-ip4scale4000-udpsrcscale15-nat44 \
213           (10ge2p1x520, 64b, imix, 1c, 2c, 4c)
214
215         Structure of returned data:
216
217         {
218             "trimmed_TC_name_1": {
219                 "nics": [],
220                 "framesizes": [],
221                 "cores": []
222             }
223             ...
224             "trimmed_TC_name_N": {
225                 "nics": [],
226                 "framesizes": [],
227                 "cores": []
228             }
229         }
230
231         :param alert: Files are created for this alert.
232         :param test_set: Specifies which set of tests will be included in the
233             result. Its name is the same as the name of file with failed tests.
234         :param sort: If True, the failed tests are sorted alphabetically.
235         :type alert: dict
236         :type test_set: str
237         :type sort: bool
238         :returns: CSIT build number, VPP version, Number of failed tests,
239             Compressed failed tests.
240         :rtype: tuple(str, str, int, OrderedDict)
241         """
242
243         directory = self.configs[alert["way"]]["output-dir"]
244         failed_tests = OrderedDict()
245         file_path = "{0}/{1}.txt".format(directory, test_set)
246         version = ""
247         try:
248             with open(file_path, 'r') as f_txt:
249                 for idx, line in enumerate(f_txt):
250                     if idx == 0:
251                         build = line[:-1]
252                         continue
253                     if idx == 1:
254                         version = line[:-1]
255                         continue
256                     try:
257                         test = line[:-1].split('-')
258                         nic = test[0]
259                         framesize = test[1]
260                         cores = test[2]
261                         name = '-'.join(test[3:-1])
262                     except IndexError:
263                         continue
264                     if failed_tests.get(name, None) is None:
265                         failed_tests[name] = dict(nics=list(),
266                                                   framesizes=list(),
267                                                   cores=list())
268                     if nic not in failed_tests[name]["nics"]:
269                         failed_tests[name]["nics"].append(nic)
270                     if framesize not in failed_tests[name]["framesizes"]:
271                         failed_tests[name]["framesizes"].append(framesize)
272                     if cores not in failed_tests[name]["cores"]:
273                         failed_tests[name]["cores"].append(cores)
274         except IOError:
275             logging.error("No such file or directory: {file}".
276                           format(file=file_path))
277             return None, None, None, None
278         if sort:
279             sorted_failed_tests = OrderedDict()
280             keys = [k for k in failed_tests.keys()]
281             keys.sort()
282             for key in keys:
283                 sorted_failed_tests[key] = failed_tests[key]
284             return build, version, idx-1, sorted_failed_tests
285         else:
286             return build, version, idx-1, failed_tests
287
288     def _generate_email_body(self, alert):
289         """Create the file which is used in the generated alert.
290
291         :param alert: Files are created for this alert.
292         :type alert: dict
293         """
294
295         if alert["type"] != "failed-tests":
296             raise AlertingError("Alert of type '{0}' is not implemented.".
297                                 format(alert["type"]))
298
299         config = self.configs[alert["way"]]
300
301         text = ""
302         for idx, test_set in enumerate(alert.get("include", [])):
303             build, version, nr, failed_tests = \
304                 self._get_compressed_failed_tests(alert, test_set)
305             if build is None:
306                 text += "\n\nNo data for the test set '{set}'.\n".\
307                     format(set=test_set)
308                 continue
309             text += ("\n\n{topo}-{arch}, "
310                      "{nr} tests failed, "
311                      "CSIT build: {link}/{build}, "
312                      "VPP version: {version}\n\n".
313                      format(topo=test_set.split('-')[-2],
314                             arch=test_set.split('-')[-1],
315                             nr=nr,
316                             link=alert["urls"][idx],
317                             build=build,
318                             version=version))
319             max_len_name = 0
320             max_len_nics = 0
321             max_len_framesizes = 0
322             max_len_cores = 0
323             for name, params in failed_tests.items():
324                 failed_tests[name]["nics"] = ",".join(sorted(params["nics"]))
325                 failed_tests[name]["framesizes"] = \
326                     ",".join(sorted(params["framesizes"]))
327                 failed_tests[name]["cores"] = ",".join(sorted(params["cores"]))
328                 if len(name) > max_len_name:
329                     max_len_name = len(name)
330                 if len(failed_tests[name]["nics"]) > max_len_nics:
331                     max_len_nics = len(failed_tests[name]["nics"])
332                 if len(failed_tests[name]["framesizes"]) > max_len_framesizes:
333                     max_len_framesizes = len(failed_tests[name]["framesizes"])
334                 if len(failed_tests[name]["cores"]) > max_len_cores:
335                     max_len_cores = len(failed_tests[name]["cores"])
336
337             for name, params in failed_tests.items():
338                 text += "{name}  {nics}  {frames}  {cores}\n".format(
339                     name=name + " " * (max_len_name - len(name)),
340                     nics=params["nics"] +
341                         " " * (max_len_nics - len(params["nics"])),
342                     frames=params["framesizes"] + " " *
343                         (max_len_framesizes - len(params["framesizes"])),
344                     cores=params["cores"] +
345                         " " * (max_len_cores - len(params["cores"])))
346
347         text += "\nFor detailed information visit: {url}\n".\
348             format(url=alert["url-details"])
349         file_name = "{0}/{1}".format(config["output-dir"],
350                                                 config["output-file"])
351         logging.info("Writing the file '{0}.txt' ...".format(file_name))
352
353         try:
354             with open("{0}.txt".format(file_name), 'w') as txt_file:
355                 txt_file.write(text)
356         except IOError:
357             logging.error("Not possible to write the file '{0}.txt'.".
358                           format(file_name))