1 # Copyright (c) 2022 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:
6 # http://www.apache.org/licenses/LICENSE-2.0
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.
14 """Generator of alerts:
25 from difflib import SequenceMatcher
26 from email.mime.text import MIMEText
27 from email.mime.multipart import MIMEMultipart
28 from os.path import isdir
29 from collections import OrderedDict, defaultdict
31 from pal_errors import PresentationError
34 class AlertingError(PresentationError):
35 """Exception(s) raised by the alerting module.
37 When raising this exception, put this information to the message in this
39 - short description of the encountered problem (parameter msg),
40 - relevant messages if there are any collected, e.g., from caught
41 exception (optional parameter details),
42 - relevant data if there are any collected (optional parameter details).
45 def __init__(self, msg, details=u'', level=u"CRITICAL"):
46 """Sets the exception message and the level.
48 :param msg: Short description of the encountered problem.
49 :param details: Relevant messages if there are any collected, e.g.,
50 from caught exception (optional parameter details), or relevant data
51 if there are any collected (optional parameter details).
52 :param level: Level of the error, possible choices are: "DEBUG", "INFO",
53 "WARNING", "ERROR" and "CRITICAL".
59 super(AlertingError, self).__init__(f"Alerting: {msg}", details, level)
63 f"AlertingError(msg={self._msg!r},details={self._details!r},"
64 f"level={self._level!r})"
69 """Class implementing the alerting mechanism.
72 def __init__(self, spec):
75 :param spec: The CPTA specification.
76 :type spec: Specification
80 self._implemented_alerts = (u"failed-tests", )
84 self.error_msgs = list()
87 self._spec_alert = spec.alerting
88 except KeyError as err:
90 u"Alerting is not configured, skipped.", repr(err), u"WARNING"
93 self._path_failed_tests = spec.environment[u"paths"][u"DIR[STATIC,VPP]"]
95 # Verify and validate input specification:
96 self.configs = self._spec_alert.get(u"configurations", None)
98 raise AlertingError(u"No alert configuration is specified.")
99 for config_type, config_data in self.configs.items():
100 if config_type == u"email":
101 if not config_data.get(u"server", None):
102 raise AlertingError(u"Parameter 'server' is missing.")
103 if not config_data.get(u"address-to", None):
104 raise AlertingError(u"Parameter 'address-to' (recipient) "
106 if not config_data.get(u"address-from", None):
107 raise AlertingError(u"Parameter 'address-from' (sender) is "
109 elif config_type == u"jenkins":
110 if not isdir(config_data.get(u"output-dir", u"")):
111 raise AlertingError(u"Parameter 'output-dir' is "
112 u"missing or it is not a directory.")
113 if not config_data.get(u"output-file", None):
114 raise AlertingError(u"Parameter 'output-file' is missing.")
117 f"Alert of type {config_type} is not implemented."
120 self.alerts = self._spec_alert.get(u"alerts", None)
122 raise AlertingError(u"No alert is specified.")
123 for alert_data in self.alerts.values():
124 if not alert_data.get(u"title", None):
125 raise AlertingError(u"Parameter 'title' is missing.")
126 if not alert_data.get(u"type", None) in self._implemented_alerts:
127 raise AlertingError(u"Parameter 'failed-tests' is missing or "
129 if not alert_data.get(u"way", None) in self.configs.keys():
130 raise AlertingError(u"Parameter 'way' is missing or incorrect.")
131 if not alert_data.get(u"include", None):
132 raise AlertingError(u"Parameter 'include' is missing or the "
136 """Return string with human readable description of the alert.
138 :returns: Readable description.
141 return f"configs={self.configs}, alerts={self.alerts}"
144 """Return string executable as Python constructor call.
146 :returns: Executable constructor call.
149 return f"Alerting(spec={self._spec})"
151 def generate_alerts(self):
152 """Generate alert(s) using specified way(s).
155 for alert_data in self.alerts.values():
156 if alert_data[u"way"] == u"jenkins":
157 self._generate_email_body(alert_data)
160 f"Alert with way {alert_data[u'way']} is not implemented."
164 def _send_email(server, addr_from, addr_to, subject, text=None, html=None):
165 """Send an email using predefined configuration.
167 :param server: SMTP server used to send email.
168 :param addr_from: Sender address.
169 :param addr_to: Recipient address(es).
170 :param subject: Subject of the email.
171 :param text: Message in the ASCII text format.
172 :param html: Message in the HTML format.
181 if not text and not html:
182 raise AlertingError(u"No text/data to send.")
184 msg = MIMEMultipart(u'alternative')
185 msg[u'Subject'] = subject
186 msg[u'From'] = addr_from
187 msg[u'To'] = u", ".join(addr_to)
190 msg.attach(MIMEText(text, u'plain'))
192 msg.attach(MIMEText(html, u'html'))
196 logging.info(f"Trying to send alert {subject} ...")
197 logging.debug(f"SMTP Server: {server}")
198 logging.debug(f"From: {addr_from}")
199 logging.debug(f"To: {u', '.join(addr_to)}")
200 logging.debug(f"Message: {msg.as_string()}")
201 smtp_server = smtplib.SMTP(server)
202 smtp_server.sendmail(addr_from, addr_to, msg.as_string())
203 except smtplib.SMTPException as err:
204 raise AlertingError(u"Not possible to send the alert via email.",
210 def _get_compressed_failed_tests(self, alert, test_set, sort=True):
211 """Return the dictionary with compressed faild tests. The compression is
212 done by grouping the tests from the same area but with different NICs,
213 frame sizes and number of processor cores.
215 For example, the failed tests:
216 10ge2p1x520-64b-1c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
217 10ge2p1x520-64b-2c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
218 10ge2p1x520-64b-4c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
219 10ge2p1x520-imix-1c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
220 10ge2p1x520-imix-2c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
221 10ge2p1x520-imix-4c-ethip4udp-ip4scale4000-udpsrcscale15-nat44-mrr
223 will be represented as:
224 ethip4udp-ip4scale4000-udpsrcscale15-nat44 \
225 (10ge2p1x520, 64b, imix, 1c, 2c, 4c)
227 Structure of returned data:
230 "trimmed_TC_name_1": {
236 "trimmed_TC_name_N": {
243 :param alert: Files are created for this alert.
244 :param test_set: Specifies which set of tests will be included in the
245 result. Its name is the same as the name of file with failed tests.
246 :param sort: If True, the failed tests are sorted alphabetically.
250 :returns: CSIT build number, VPP version, Number of passed tests,
251 Number of failed tests, Compressed failed tests.
252 :rtype: tuple(str, str, int, int, str, OrderedDict)
255 directory = self.configs[alert[u"way"]][u"output-dir"]
256 failed_tests = defaultdict(dict)
257 file_path = f"{directory}/{test_set}.txt"
260 with open(file_path, u'r') as f_txt:
261 for idx, line in enumerate(f_txt):
275 minutes = int(line[:-1]) // 60000
276 duration = f"{(minutes // 60):02d}:{(minutes % 60):02d}"
279 line, error_msg = line[:-1].split(u'###', maxsplit=1)
280 test = line.split(u'-')
281 name = u'-'.join(test[3:-1])
282 if len(error_msg) > 128:
283 if u";" in error_msg[128:256]:
285 f"{error_msg[:128]}" \
286 f"{error_msg[128:].split(u';', 1)[0]}..."
287 elif u":" in error_msg[128:256]:
289 f"{error_msg[:128]}" \
290 f"{error_msg[128:].split(u':', 1)[0]}..."
291 elif u"." in error_msg[128:256]:
293 f"{error_msg[:128]}" \
294 f"{error_msg[128:].split(u'.', 1)[0]}..."
295 elif u"?" in error_msg[128:256]:
297 f"{error_msg[:128]}" \
298 f"{error_msg[128:].split(u'?', 1)[0]}..."
299 elif u"!" in error_msg[128:256]:
301 f"{error_msg[:128]}" \
302 f"{error_msg[128:].split(u'!', 1)[0]}..."
303 elif u"," in error_msg[128:256]:
305 f"{error_msg[:128]}" \
306 f"{error_msg[128:].split(u',', 1)[0]}..."
307 elif u" " in error_msg[128:256]:
309 f"{error_msg[:128]}" \
310 f"{error_msg[128:].split(u' ', 1)[0]}..."
312 error_msg = error_msg[:128]
317 for e_msg in self.error_msgs:
318 if SequenceMatcher(None, e_msg,
319 error_msg).ratio() > 0.5:
322 if error_msg not in self.error_msgs:
323 self.error_msgs.append(error_msg)
325 error_msg_index = self.error_msgs.index(error_msg)
327 if failed_tests.get(name, {}).get(error_msg_index) is None:
328 failed_tests[name][error_msg_index] = \
334 failed_tests[name][error_msg_index][u"nics"]:
335 failed_tests[name][error_msg_index][u"nics"].\
338 failed_tests[name][error_msg_index][u"framesizes"]:
339 failed_tests[name][error_msg_index][u"framesizes"].\
341 check_core = test[2] + f"[{str(error_msg_index)}]"
342 if check_core not in \
343 failed_tests[name][error_msg_index][u"cores"]:
344 failed_tests[name][error_msg_index][u"cores"].\
345 append(test[2] + "[" + str(error_msg_index) + "]")
348 logging.error(f"No such file or directory: {file_path}")
349 return None, None, None, None, None, None
351 sorted_failed_tests = OrderedDict()
352 for key in sorted(failed_tests.keys()):
353 sorted_failed_tests[key] = failed_tests[key]
354 return build, version, passed, failed, duration, sorted_failed_tests
356 return build, version, passed, failed, duration, failed_tests
358 def _list_gressions(self, alert, idx, header, re_pro):
359 """Create a file with regressions or progressions for the test set
362 :param alert: Files are created for this alert.
363 :param idx: Index of the test set as it is specified in the
365 :param header: The header of the list of [re|pro]gressions.
366 :param re_pro: 'regressions' or 'progressions'.
373 if re_pro not in (u"regressions", u"progressions"):
377 f"{self.configs[alert[u'way']][u'output-dir']}/"
378 f"{re_pro}-{alert[u'urls'][idx].split(u'/')[-1]}.txt"
381 f"{self.configs[alert[u'way']][u'output-dir']}/"
382 f"trending-{re_pro}.txt"
386 with open(in_file, u'r') as txt_file:
387 file_content = txt_file.read()
388 with open(out_file, u'a+') as reg_file:
389 reg_file.write(header)
391 reg_file.write(file_content)
393 reg_file.write(f"No {re_pro}")
394 except IOError as err:
395 logging.warning(repr(err))
397 def _generate_email_body(self, alert):
398 """Create the file which is used in the generated alert.
400 :param alert: Files are created for this alert.
404 if alert[u"type"] != u"failed-tests":
406 f"Alert of type {alert[u'type']} is not implemented."
411 legend = (f"Legend: Test-name NIC Frame-size Trend[Mpps] Runs[#] "
412 f"Long-Term change[%]")
415 f"{self.configs[alert[u'way']][u'output-dir']}/"
416 f"trending-regressions.txt"
419 with open(out_file, u'w') as reg_file:
420 reg_file.write(legend)
422 logging.error(f"Not possible to write the file {out_file}.txt.")
425 f"{self.configs[alert[u'way']][u'output-dir']}/"
426 f"trending-progressions.txt"
429 with open(out_file, u'w') as reg_file:
430 reg_file.write(legend)
432 logging.error(f"Not possible to write the file {out_file}.txt.")
434 for idx, test_set in enumerate(alert.get(u"include", list())):
440 r'((vpp|dpdk)-\dn-(skx|clx|tsh|dnv|zn2|tx2|icx)-.*)'
444 test_set_short = groups.group(1)
445 device = groups.group(2)
446 except (AttributeError, IndexError):
448 f"The test set {test_set} does not include information "
449 f"about test bed. Using empty string instead."
451 build, version, passed, failed, duration, failed_tests = \
452 self._get_compressed_failed_tests(alert, test_set)
455 f"\n\nNo input data available for {test_set_short}. "
456 f"See CSIT job {alert[u'urls'][idx]} for more "
461 f"\n\n{test_set_short}, "
462 f"{failed} tests failed, "
463 f"{passed} tests passed, "
464 f"duration: {duration}, "
465 f"CSIT build: {alert[u'urls'][idx]}/{build}, "
466 f"{device} version: {version}\n\n"
470 """Class to store the max lengths of strings displayed in
473 def __init__(self, tst_name, nics, framesizes, cores):
476 :param tst_name: Name of the test.
477 :param nics: NICs used in the test.
478 :param framesizes: Frame sizes used in the tests
479 :param cores: Cores used in th test.
483 self.frmsizes = framesizes
486 max_len = MaxLens(0, 0, 0, 0)
488 for test, message in failed_tests.items():
489 for e_message, params in message.items():
490 failed_tests[test][e_message][u"nics"] = \
491 u" ".join(sorted(params[u"nics"]))
492 failed_tests[test][e_message][u"framesizes"] = \
493 u" ".join(sorted(params[u"framesizes"]))
494 failed_tests[test][e_message][u"cores"] = \
495 u" ".join(sorted(params[u"cores"]))
496 if len(test) > max_len.name:
497 max_len.name = len(test)
498 if len(failed_tests[test][e_message][u"nics"]) > \
501 len(failed_tests[test][e_message][u"nics"])
502 if len(failed_tests[test][e_message][u"framesizes"]) > \
505 len(failed_tests[test][e_message][u"framesizes"])
506 if len(failed_tests[test][e_message][u"cores"]) > \
509 len(failed_tests[test][e_message][u"cores"])
511 for test, message in failed_tests.items():
513 for e_message, params in message.items():
519 f"{test + u' ' * (max_len.name - len(test))} "
521 f"{u' ' * (max_len.nics - len(params[u'nics']))} "
522 f"{params[u'framesizes']}"
523 f"""{u' ' * (max_len.frmsizes
524 - len(params[u'framesizes']))} """
525 f"{params[u'cores']}"
526 f"{u' ' * (max_len.cores - len(params[u'cores']))}\n"
530 f"\n\n{test_set_short}, "
531 f"CSIT build: {alert[u'urls'][idx]}/{build}, "
532 f"{device} version: {version}\n\n"
534 # Add list of regressions:
535 self._list_gressions(alert, idx, gression_hdr, u"regressions")
537 # Add list of progressions:
538 self._list_gressions(alert, idx, gression_hdr, u"progressions")
540 text += f"\nFor detailed information visit: {alert[u'url-details']}\n"
541 file_name = f"{self.configs[alert[u'way']][u'output-dir']}/" \
542 f"{self.configs[alert[u'way']][u'output-file']}"
543 logging.info(f"Writing the file {file_name}.txt ...")
545 text += f"\n\nLegend:\n\n"
547 for e_msg in self.error_msgs:
548 text += f"[{self.error_msgs.index(e_msg)}] - {e_msg}\n"
551 with open(f"{file_name}.txt", u'w') as txt_file:
554 logging.error(f"Not possible to write the file {file_name}.txt.")