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:
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.
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
22 from utils import get_last_completed_build_number
23 from errors import PresentationError
26 class AlertingError(PresentationError):
27 """Exception(s) raised by the alerting module.
29 When raising this exception, put this information to the message in this
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).
37 def __init__(self, msg, details='', level="CRITICAL"):
38 """Sets the exception message and the level.
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".
51 super(AlertingError, self).__init__(
52 "Alerting: {0}".format(msg), details, level)
56 "AlertingError(msg={msg!r},details={dets!r},level={level!r})".
57 format(msg=self._msg, dets=self._details, level=self._level))
60 class Alerting(object):
61 """Class implementing the alerting mechanism.
64 def __init__(self, spec):
67 :param spec: The CPTA specification.
68 :type spec: Specification
72 self._ALERTS = ("failed-tests", )
77 self._spec_alert = spec.alerting
78 except KeyError as err:
79 raise AlertingError("Alerting is not configured, skipped.",
83 self._path_failed_tests = spec.environment["paths"]["DIR[STATIC,VPP]"]
85 # Verify and validate input specification:
86 self.configs = self._spec_alert.get("configurations", None)
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 "
96 if not config_data.get("address-from", None):
97 raise AlertingError("Parameter 'address-from' (sender) is "
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.")
106 raise AlertingError("Alert of type '{0}' is not implemented.".
109 self.alerts = self._spec_alert.get("alerts", None)
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 "
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 "
125 """Return string with human readable description of the alert.
127 :returns: Readable description.
130 return "configs={configs}, alerts={alerts}".format(
131 configs=self.configs, alerts=self.alerts)
134 """Return string executable as Python constructor call.
136 :returns: Executable constructor call.
139 return "Alerting(spec={spec})".format(
142 def generate_alerts(self):
143 """Generate alert(s) using specified way(s).
146 for alert, alert_data in self.alerts.iteritems():
147 if alert_data["way"] == "jenkins":
148 self._generate_email_body(alert_data)
150 raise AlertingError("Alert with way '{0}' is not implemented.".
151 format(alert_data["way"]))
154 def _send_email(server, addr_from, addr_to, subject, text=None, html=None):
155 """Send an email using predefined configuration.
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.
171 if not text and not html:
172 raise AlertingError("No text/data to send.")
174 msg = MIMEMultipart('alternative')
175 msg['Subject'] = subject
176 msg['From'] = addr_from
177 msg['To'] = ", ".join(addr_to)
180 msg.attach(MIMEText(text, 'plain'))
182 msg.attach(MIMEText(html, 'html'))
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.",
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.
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
213 will be represented as:
214 ethip4udp-ip4scale4000-udpsrcscale15-nat44 \
215 (10ge2p1x520, 64b, imix, 1c, 2c, 4c)
217 Structure of returned data:
220 "trimmed_TC_name_1": {
226 "trimmed_TC_name_N": {
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.
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)
245 directory = self.configs[alert["way"]]["output-dir"]
246 failed_tests = OrderedDict()
247 file_path = "{0}/{1}.txt".format(directory, test_set)
250 with open(file_path, 'r') as f_txt:
251 for idx, line in enumerate(f_txt):
265 test = line[:-1].split('-')
269 name = '-'.join(test[3:-1])
272 if failed_tests.get(name, None) is None:
273 failed_tests[name] = dict(nics=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)
283 logging.error("No such file or directory: {file}".
284 format(file=file_path))
285 return None, None, None, None, None
287 sorted_failed_tests = OrderedDict()
288 keys = [k for k in failed_tests.keys()]
291 sorted_failed_tests[key] = failed_tests[key]
292 return build, version, passed, failed, sorted_failed_tests
294 return build, version, passed, failed, failed_tests
296 def _generate_email_body(self, alert):
297 """Create the file which is used in the generated alert.
299 :param alert: Files are created for this alert.
303 if alert["type"] != "failed-tests":
304 raise AlertingError("Alert of type '{0}' is not implemented.".
305 format(alert["type"]))
307 config = self.configs[alert["way"]]
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)
314 ret_code, build_nr, _ = get_last_completed_build_number(
315 self._spec.environment["urls"]["URL[JENKINS,CSIT]"],
316 alert["urls"][idx].split('/')[-1])
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],
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],
334 link=alert["urls"][idx],
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],
348 max_len_framesizes = 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"])
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"])))
374 # Add list of regressions:
375 file_name = "{0}/cpta-regressions-{1}.txt".\
376 format(config["output-dir"], alert["urls"][idx].split('/')[-1])
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)
385 reg_file.write(file_content)
387 reg_file.write("No regressions")
388 except IOError as err:
389 logging.warning(repr(err))
391 # Add list of progressions:
392 file_name = "{0}/cpta-progressions-{1}.txt".\
393 format(config["output-dir"], alert["urls"][idx].split('/')[-1])
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)
402 pro_file.write(file_content)
404 pro_file.write("No progressions")
405 except IOError as err:
406 logging.warning(repr(err))
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))
415 with open("{0}.txt".format(file_name), 'w') as txt_file:
418 logging.error("Not possible to write the file '{0}.txt'.".