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 execute_command
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", )
75 self._spec = spec.alerting
76 except KeyError as err:
77 raise AlertingError("Alerting is not configured, skipped.",
81 self._path_failed_tests = spec.environment["paths"]["DIR[STATIC,VPP]"]
83 # Verify and validate input specification:
84 self.configs = self._spec.get("configurations", None)
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 "
94 if not config_data.get("address-from", None):
95 raise AlertingError("Parameter 'address-from' (sender) is "
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.")
104 raise AlertingError("Alert of type '{0}' is not implemented.".
107 self.alerts = self._spec.get("alerts", None)
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 "
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 "
123 """Return string with human readable description of the alert.
125 :returns: Readable description.
128 return "configs={configs}, alerts={alerts}".format(
129 configs=self.configs, alerts=self.alerts)
132 """Return string executable as Python constructor call.
134 :returns: Executable constructor call.
137 return "Alerting(spec={spec})".format(
140 def generate_alerts(self):
141 """Generate alert(s) using specified way(s).
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"],
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)
159 raise AlertingError("Alert with way '{0}' is not implemented.".
160 format(alert_data["way"]))
163 def _send_email(server, addr_from, addr_to, subject, text=None, html=None):
164 """Send an email using predefined configuration.
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.
180 if not text and not html:
181 raise AlertingError("No text/data to send.")
183 msg = MIMEMultipart('alternative')
184 msg['Subject'] = subject
185 msg['From'] = addr_from
186 msg['To'] = ", ".join(addr_to)
189 msg.attach(MIMEText(text, 'plain'))
191 msg.attach(MIMEText(html, 'html'))
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.",
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.
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
222 will be represented as:
223 ethip4udp-ip4scale4000-udpsrcscale15-nat44 \
224 (10ge2p1x520, 64b, imix, 1c, 2c, 4c)
226 Structure of returned data:
229 "trimmed_TC_name_1": {
235 "trimmed_TC_name_N": {
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.
249 :returns: CSIT build number, VPP version, Number of failed tests,
250 Compressed failed tests.
251 :rtype: tuple(str, str, int, OrderedDict)
254 directory = self.configs[alert["way"]]["output-dir"]
255 failed_tests = OrderedDict()
258 with open("{0}/{1}.txt".format(directory, test_set), 'r') as f_txt:
259 for idx, line in enumerate(f_txt):
267 test = line[:-1].split('-')
271 name = '-'.join(test[3:-1])
274 if failed_tests.get(name, None) is None:
275 failed_tests[name] = dict(nics=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
288 sorted_failed_tests = OrderedDict()
289 keys = [k for k in failed_tests.keys()]
292 sorted_failed_tests[key] = failed_tests[key]
293 return build, version, idx-1, sorted_failed_tests
295 return build, version, idx-1, failed_tests
297 def _generate_email_body(self, alert):
298 """Create the file which is used in the generated alert.
300 :param alert: Files are created for this alert.
304 if alert["type"] != "failed-tests":
305 raise AlertingError("Alert of type '{0}' is not implemented.".
306 format(alert["type"]))
308 config = self.configs[alert["way"]]
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)
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],
323 link=alert["urls"][idx],
328 max_len_framesizes = 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"])
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"])))
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))
361 with open("{0}.txt".format(file_name), 'w') as txt_file:
364 logging.error("Not possible to write the file '{0}.txt'.".
367 def _generate_files_for_jenkins(self, alert):
368 """Create the file which is used in the generated alert.
370 # TODO: Remove when not needed.
372 :param alert: Files are created for this alert.
376 config = self.configs[alert["way"]]
378 zip_file = config.get("zip-output", None)
380 logging.info("Writing the file '{0}/{1}' ...".
381 format(config["output-dir"], zip_file))
382 execute_command("tar czvf {dir}/{zip} --directory={dir} "
384 format(dir=config["output-dir"],
386 input=config["output-file"]))