diff options
| author | Chris Laplante <chris.laplante@agilent.com> | 2020-01-24 10:16:23 -0500 |
|---|---|---|
| committer | Richard Purdie <richard.purdie@linuxfoundation.org> | 2020-03-07 16:07:00 +0000 |
| commit | 1d20618c55a8f74c81fefa5b36223280513654b1 (patch) | |
| tree | f9a7b3903e3fffc444ce6f9147c68d648f5f87a6 /bitbake/lib/bb/ui | |
| parent | 8b190c3e134ffb9fb7454e78718c6a9369770f62 (diff) | |
| download | poky-1d20618c55a8f74c81fefa5b36223280513654b1.tar.gz | |
bitbake: ui/teamcity: add a TeamCity service message frontend
Exposes build status via TeamCity service messages, see
https://www.jetbrains.com/help/teamcity/build-script-interaction-with-teamcity.html.
(Bitbake rev: 26ff7fa314d0f84f2557b183fb71fa873d914ee0)
Signed-off-by: Chris Laplante <chris.laplante@agilent.com>
Signed-off-by: Richard Purdie <richard.purdie@linuxfoundation.org>
Diffstat (limited to 'bitbake/lib/bb/ui')
| -rw-r--r-- | bitbake/lib/bb/ui/teamcity.py | 398 |
1 files changed, 398 insertions, 0 deletions
diff --git a/bitbake/lib/bb/ui/teamcity.py b/bitbake/lib/bb/ui/teamcity.py new file mode 100644 index 0000000000..1854292fa1 --- /dev/null +++ b/bitbake/lib/bb/ui/teamcity.py | |||
| @@ -0,0 +1,398 @@ | |||
| 1 | # | ||
| 2 | # TeamCity UI Implementation | ||
| 3 | # | ||
| 4 | # Implements a TeamCity frontend for the BitBake utility, via service messages. | ||
| 5 | # See https://www.jetbrains.com/help/teamcity/build-script-interaction-with-teamcity.html | ||
| 6 | # | ||
| 7 | # Based on ncurses.py and knotty.py, variously by Michael Lauer and Richard Purdie | ||
| 8 | # | ||
| 9 | # Copyright (C) 2006 Michael 'Mickey' Lauer | ||
| 10 | # Copyright (C) 2006-2012 Richard Purdie | ||
| 11 | # Copyright (C) 2018-2020 Agilent Technologies, Inc. | ||
| 12 | # | ||
| 13 | # SPDX-License-Identifier: GPL-2.0-only | ||
| 14 | # | ||
| 15 | # Author: Chris Laplante <chris.laplante@agilent.com> | ||
| 16 | |||
| 17 | from __future__ import division | ||
| 18 | |||
| 19 | import datetime | ||
| 20 | import logging | ||
| 21 | import math | ||
| 22 | import os | ||
| 23 | import re | ||
| 24 | import sys | ||
| 25 | import xmlrpc.client | ||
| 26 | from collections import deque | ||
| 27 | |||
| 28 | import bb | ||
| 29 | import bb.build | ||
| 30 | import bb.command | ||
| 31 | import bb.cooker | ||
| 32 | import bb.event | ||
| 33 | import bb.exceptions | ||
| 34 | import bb.runqueue | ||
| 35 | from bb.ui import uihelper | ||
| 36 | |||
| 37 | logger = logging.getLogger("BitBake") | ||
| 38 | |||
| 39 | |||
| 40 | class TeamCityUI: | ||
| 41 | def __init__(self): | ||
| 42 | self._block_stack = [] | ||
| 43 | self._last_progress_state = None | ||
| 44 | |||
| 45 | @classmethod | ||
| 46 | def escape_service_value(cls, value): | ||
| 47 | """ | ||
| 48 | Escape a value for inclusion in a service message. TeamCity uses the vertical pipe character for escaping. | ||
| 49 | See: https://confluence.jetbrains.com/display/TCD10/Build+Script+Interaction+with+TeamCity#BuildScriptInteractionwithTeamCity-Escapedvalues | ||
| 50 | """ | ||
| 51 | return re.sub(r"(['|\[\]])", r"|\1", value).replace("\n", "|n").replace("\r", "|r") | ||
| 52 | |||
| 53 | @classmethod | ||
| 54 | def emit_service_message(cls, message_type, **kwargs): | ||
| 55 | print(cls.format_service_message(message_type, **kwargs), flush=True) | ||
| 56 | |||
| 57 | @classmethod | ||
| 58 | def format_service_message(cls, message_type, **kwargs): | ||
| 59 | payload = " ".join(["{0}='{1}'".format(k, cls.escape_service_value(v)) for k, v in kwargs.items()]) | ||
| 60 | return "##teamcity[{0} {1}]".format(message_type, payload) | ||
| 61 | |||
| 62 | @classmethod | ||
| 63 | def emit_simple_service_message(cls, message_type, message): | ||
| 64 | print(cls.format_simple_service_message(message_type, message), flush=True) | ||
| 65 | |||
| 66 | @classmethod | ||
| 67 | def format_simple_service_message(cls, message_type, message): | ||
| 68 | return "##teamcity[{0} '{1}']".format(message_type, cls.escape_service_value(message)) | ||
| 69 | |||
| 70 | @classmethod | ||
| 71 | def format_build_message(cls, text, status): | ||
| 72 | return cls.format_service_message("message", text=text, status=status) | ||
| 73 | |||
| 74 | def block_start(self, name): | ||
| 75 | self._block_stack.append(name) | ||
| 76 | self.emit_service_message("blockOpened", name=name) | ||
| 77 | |||
| 78 | def block_end(self): | ||
| 79 | if self._block_stack: | ||
| 80 | name = self._block_stack.pop() | ||
| 81 | self.emit_service_message("blockClosed", name=name) | ||
| 82 | |||
| 83 | def progress(self, message, percent, extra=None): | ||
| 84 | now = datetime.datetime.now() | ||
| 85 | percent = "{0: >3.0f}".format(percent) | ||
| 86 | |||
| 87 | report = False | ||
| 88 | if not self._last_progress_state \ | ||
| 89 | or (self._last_progress_state[0] == message | ||
| 90 | and self._last_progress_state[1] != percent | ||
| 91 | and (now - self._last_progress_state[2]).microseconds >= 5000) \ | ||
| 92 | or self._last_progress_state[0] != message: | ||
| 93 | report = True | ||
| 94 | self._last_progress_state = (message, percent, now) | ||
| 95 | |||
| 96 | if report or percent in [0, 100]: | ||
| 97 | self.emit_simple_service_message("progressMessage", "{0}: {1}%{2}".format(message, percent, extra or "")) | ||
| 98 | |||
| 99 | |||
| 100 | class TeamcityLogFormatter(logging.Formatter): | ||
| 101 | def format(self, record): | ||
| 102 | details = "" | ||
| 103 | if hasattr(record, 'bb_exc_formatted'): | ||
| 104 | details = ''.join(record.bb_exc_formatted) | ||
| 105 | elif hasattr(record, 'bb_exc_info'): | ||
| 106 | etype, value, tb = record.bb_exc_info | ||
| 107 | formatted = bb.exceptions.format_exception(etype, value, tb, limit=5) | ||
| 108 | details = ''.join(formatted) | ||
| 109 | |||
| 110 | if record.levelno in [bb.msg.BBLogFormatter.ERROR, bb.msg.BBLogFormatter.CRITICAL]: | ||
| 111 | # ERROR gets a separate errorDetails field | ||
| 112 | msg = TeamCityUI.format_service_message("message", text=record.getMessage(), status="ERROR", | ||
| 113 | errorDetails=details) | ||
| 114 | else: | ||
| 115 | payload = record.getMessage() | ||
| 116 | if details: | ||
| 117 | payload += "\n" + details | ||
| 118 | if record.levelno == bb.msg.BBLogFormatter.PLAIN: | ||
| 119 | msg = payload | ||
| 120 | elif record.levelno == bb.msg.BBLogFormatter.WARNING: | ||
| 121 | msg = TeamCityUI.format_service_message("message", text=payload, status="WARNING") | ||
| 122 | else: | ||
| 123 | msg = TeamCityUI.format_service_message("message", text=payload, status="NORMAL") | ||
| 124 | |||
| 125 | return msg | ||
| 126 | |||
| 127 | |||
| 128 | _evt_list = ["bb.runqueue.runQueueExitWait", "bb.event.LogExecTTY", "logging.LogRecord", | ||
| 129 | "bb.build.TaskFailed", "bb.build.TaskBase", "bb.event.ParseStarted", | ||
| 130 | "bb.event.ParseProgress", "bb.event.ParseCompleted", "bb.event.CacheLoadStarted", | ||
| 131 | "bb.event.CacheLoadProgress", "bb.event.CacheLoadCompleted", "bb.command.CommandFailed", | ||
| 132 | "bb.command.CommandExit", "bb.command.CommandCompleted", "bb.cooker.CookerExit", | ||
| 133 | "bb.event.MultipleProviders", "bb.event.NoProvider", "bb.runqueue.sceneQueueTaskStarted", | ||
| 134 | "bb.runqueue.runQueueTaskStarted", "bb.runqueue.runQueueTaskFailed", "bb.runqueue.sceneQueueTaskFailed", | ||
| 135 | "bb.event.BuildBase", "bb.build.TaskStarted", "bb.build.TaskSucceeded", "bb.build.TaskFailedSilent", | ||
| 136 | "bb.build.TaskProgress", "bb.event.ProcessStarted", "bb.event.ProcessProgress", "bb.event.ProcessFinished"] | ||
| 137 | |||
| 138 | |||
| 139 | def _log_settings_from_server(server): | ||
| 140 | # Get values of variables which control our output | ||
| 141 | includelogs, error = server.runCommand(["getVariable", "BBINCLUDELOGS"]) | ||
| 142 | if error: | ||
| 143 | logger.error("Unable to get the value of BBINCLUDELOGS variable: %s" % error) | ||
| 144 | raise BaseException(error) | ||
| 145 | loglines, error = server.runCommand(["getVariable", "BBINCLUDELOGS_LINES"]) | ||
| 146 | if error: | ||
| 147 | logger.error("Unable to get the value of BBINCLUDELOGS_LINES variable: %s" % error) | ||
| 148 | raise BaseException(error) | ||
| 149 | return includelogs, loglines | ||
| 150 | |||
| 151 | |||
| 152 | def main(server, eventHandler, params): | ||
| 153 | params.updateToServer(server, os.environ.copy()) | ||
| 154 | |||
| 155 | includelogs, loglines = _log_settings_from_server(server) | ||
| 156 | |||
| 157 | ui = TeamCityUI() | ||
| 158 | |||
| 159 | helper = uihelper.BBUIHelper() | ||
| 160 | |||
| 161 | console = logging.StreamHandler(sys.stdout) | ||
| 162 | errconsole = logging.StreamHandler(sys.stderr) | ||
| 163 | format = TeamcityLogFormatter() | ||
| 164 | if params.options.quiet == 0: | ||
| 165 | forcelevel = None | ||
| 166 | elif params.options.quiet > 2: | ||
| 167 | forcelevel = bb.msg.BBLogFormatter.ERROR | ||
| 168 | else: | ||
| 169 | forcelevel = bb.msg.BBLogFormatter.WARNING | ||
| 170 | bb.msg.addDefaultlogFilter(console, bb.msg.BBLogFilterStdOut, forcelevel) | ||
| 171 | bb.msg.addDefaultlogFilter(errconsole, bb.msg.BBLogFilterStdErr) | ||
| 172 | console.setFormatter(format) | ||
| 173 | errconsole.setFormatter(format) | ||
| 174 | if not bb.msg.has_console_handler(logger): | ||
| 175 | logger.addHandler(console) | ||
| 176 | logger.addHandler(errconsole) | ||
| 177 | |||
| 178 | if params.options.remote_server and params.options.kill_server: | ||
| 179 | server.terminateServer() | ||
| 180 | return | ||
| 181 | |||
| 182 | if params.observe_only: | ||
| 183 | logger.error("Observe-only mode not supported in this UI") | ||
| 184 | return 1 | ||
| 185 | |||
| 186 | llevel, debug_domains = bb.msg.constructLogOptions() | ||
| 187 | server.runCommand(["setEventMask", server.getEventHandle(), llevel, debug_domains, _evt_list]) | ||
| 188 | |||
| 189 | try: | ||
| 190 | params.updateFromServer(server) | ||
| 191 | cmdline = params.parseActions() | ||
| 192 | if not cmdline: | ||
| 193 | logger.error("No task given") | ||
| 194 | return 1 | ||
| 195 | if 'msg' in cmdline and cmdline['msg']: | ||
| 196 | logger.error(cmdline['msg']) | ||
| 197 | return 1 | ||
| 198 | cmdline = cmdline['action'] | ||
| 199 | ret, error = server.runCommand(cmdline) | ||
| 200 | if error: | ||
| 201 | logger.error("{0}: {1}".format(cmdline, error)) | ||
| 202 | return 1 | ||
| 203 | elif not ret: | ||
| 204 | logger.error("Couldn't get default commandline: {0}".format(re)) | ||
| 205 | return 1 | ||
| 206 | except xmlrpc.client.Fault as x: | ||
| 207 | logger.error("XMLRPC Fault getting commandline: {0}".format(x)) | ||
| 208 | return 1 | ||
| 209 | |||
| 210 | active_process_total = None | ||
| 211 | is_tasks_running = False | ||
| 212 | |||
| 213 | while True: | ||
| 214 | try: | ||
| 215 | event = eventHandler.waitEvent(0.25) | ||
| 216 | if not event: | ||
| 217 | continue | ||
| 218 | |||
| 219 | helper.eventHandler(event) | ||
| 220 | |||
| 221 | if isinstance(event, bb.build.TaskBase): | ||
| 222 | logger.info(event._message) | ||
| 223 | if isinstance(event, logging.LogRecord): | ||
| 224 | # Don't report sstate failures as errors, since Yocto will just run the tasks for real | ||
| 225 | if event.msg == "No suitable staging package found" or (event.msg.startswith( | ||
| 226 | "Fetcher failure: Unable to find file") and "downloadfilename" in event.msg and "sstate" in event.msg): | ||
| 227 | event.levelno = bb.msg.BBLogFormatter.WARNING | ||
| 228 | if event.taskpid != 0: | ||
| 229 | # For "normal" logging conditions, don't show note logs from tasks | ||
| 230 | # but do show them if the user has changed the default log level to | ||
| 231 | # include verbose/debug messages | ||
| 232 | if event.levelno <= bb.msg.BBLogFormatter.NOTE and (event.levelno < llevel or ( | ||
| 233 | event.levelno == bb.msg.BBLogFormatter.NOTE and llevel != bb.msg.BBLogFormatter.VERBOSE)): | ||
| 234 | continue | ||
| 235 | |||
| 236 | # Prefix task messages with recipe/task | ||
| 237 | if event.taskpid in helper.running_tasks and event.levelno != bb.msg.BBLogFormatter.PLAIN: | ||
| 238 | taskinfo = helper.running_tasks[event.taskpid] | ||
| 239 | event.msg = taskinfo['title'] + ': ' + event.msg | ||
| 240 | if hasattr(event, 'fn'): | ||
| 241 | event.msg = event.fn + ': ' + event.msg | ||
| 242 | logger.handle(event) | ||
| 243 | if isinstance(event, bb.build.TaskFailedSilent): | ||
| 244 | logger.warning("Logfile for failed setscene task is %s" % event.logfile) | ||
| 245 | continue | ||
| 246 | if isinstance(event, bb.build.TaskFailed): | ||
| 247 | rt = "{0}-{1}:{2}".format(event.pn, event.pv.replace("AUTOINC", "0"), event.task) | ||
| 248 | |||
| 249 | logfile = event.logfile | ||
| 250 | if not logfile or not os.path.exists(logfile): | ||
| 251 | TeamCityUI.emit_service_message("buildProblem", description="{0}\nUnknown failure (no log file available)".format(rt)) | ||
| 252 | if not event.task.endswith("_setscene"): | ||
| 253 | server.runCommand(["stateForceShutdown"]) | ||
| 254 | continue | ||
| 255 | |||
| 256 | details = deque(maxlen=loglines) | ||
| 257 | error_lines = [] | ||
| 258 | if includelogs and not event.errprinted: | ||
| 259 | with open(logfile, "r") as f: | ||
| 260 | while True: | ||
| 261 | line = f.readline() | ||
| 262 | if not line: | ||
| 263 | break | ||
| 264 | line = line.rstrip() | ||
| 265 | details.append(' | %s' % line) | ||
| 266 | # TODO: a less stupid check for errors | ||
| 267 | if (event.task == "do_compile") and ("error:" in line): | ||
| 268 | error_lines.append(line) | ||
| 269 | |||
| 270 | if error_lines: | ||
| 271 | TeamCityUI.emit_service_message("compilationStarted", compiler=rt) | ||
| 272 | for line in error_lines: | ||
| 273 | TeamCityUI.emit_service_message("message", text=line, status="ERROR") | ||
| 274 | TeamCityUI.emit_service_message("compilationFinished", compiler=rt) | ||
| 275 | else: | ||
| 276 | TeamCityUI.emit_service_message("buildProblem", description=rt) | ||
| 277 | |||
| 278 | err = "Logfile of failure stored in: %s" % logfile | ||
| 279 | if details: | ||
| 280 | ui.block_start("{0} task log".format(rt)) | ||
| 281 | # TeamCity seems to choke on service messages longer than about 63800 characters, so if error | ||
| 282 | # details is longer than, say, 60000, batch it up into several messages. | ||
| 283 | first_message = True | ||
| 284 | while details: | ||
| 285 | detail_len = 0 | ||
| 286 | batch = deque() | ||
| 287 | while details and detail_len < 60000: | ||
| 288 | # TODO: This code doesn't bother to handle lines that themselves are extremely long. | ||
| 289 | line = details.popleft() | ||
| 290 | batch.append(line) | ||
| 291 | detail_len += len(line) | ||
| 292 | |||
| 293 | if first_message: | ||
| 294 | batch.appendleft("Log data follows:") | ||
| 295 | first_message = False | ||
| 296 | TeamCityUI.emit_service_message("message", text=err, status="ERROR", | ||
| 297 | errorDetails="\n".join(batch)) | ||
| 298 | else: | ||
| 299 | TeamCityUI.emit_service_message("message", text="[continued]", status="ERROR", | ||
| 300 | errorDetails="\n".join(batch)) | ||
| 301 | ui.block_end() | ||
| 302 | else: | ||
| 303 | TeamCityUI.emit_service_message("message", text=err, status="ERROR", errorDetails="") | ||
| 304 | |||
| 305 | if not event.task.endswith("_setscene"): | ||
| 306 | server.runCommand(["stateForceShutdown"]) | ||
| 307 | |||
| 308 | if isinstance(event, bb.event.ProcessStarted): | ||
| 309 | if event.processname in ["Initialising tasks", "Checking sstate mirror object availability"]: | ||
| 310 | active_process_total = event.total | ||
| 311 | ui.block_start(event.processname) | ||
| 312 | if isinstance(event, bb.event.ProcessFinished): | ||
| 313 | if event.processname in ["Initialising tasks", "Checking sstate mirror object availability"]: | ||
| 314 | ui.progress(event.processname, 100) | ||
| 315 | ui.block_end() | ||
| 316 | if isinstance(event, bb.event.ProcessProgress): | ||
| 317 | if event.processname in ["Initialising tasks", | ||
| 318 | "Checking sstate mirror object availability"] and active_process_total != 0: | ||
| 319 | ui.progress(event.processname, event.progress * 100 / active_process_total) | ||
| 320 | if isinstance(event, bb.event.CacheLoadStarted): | ||
| 321 | ui.block_start("Loading cache") | ||
| 322 | if isinstance(event, bb.event.CacheLoadProgress): | ||
| 323 | if event.total != 0: | ||
| 324 | ui.progress("Loading cache", math.floor(event.current * 100 / event.total)) | ||
| 325 | if isinstance(event, bb.event.CacheLoadCompleted): | ||
| 326 | ui.progress("Loading cache", 100) | ||
| 327 | ui.block_end() | ||
| 328 | if isinstance(event, bb.event.ParseStarted): | ||
| 329 | ui.block_start("Parsing recipes and checking upstream revisions") | ||
| 330 | if isinstance(event, bb.event.ParseProgress): | ||
| 331 | if event.total != 0: | ||
| 332 | ui.progress("Parsing recipes", math.floor(event.current * 100 / event.total)) | ||
| 333 | if isinstance(event, bb.event.ParseCompleted): | ||
| 334 | ui.progress("Parsing recipes", 100) | ||
| 335 | ui.block_end() | ||
| 336 | if isinstance(event, bb.command.CommandCompleted): | ||
| 337 | return | ||
| 338 | if isinstance(event, bb.command.CommandFailed): | ||
| 339 | logger.error(str(event)) | ||
| 340 | return 1 | ||
| 341 | if isinstance(event, bb.event.MultipleProviders): | ||
| 342 | logger.warning(str(event)) | ||
| 343 | continue | ||
| 344 | if isinstance(event, bb.event.NoProvider): | ||
| 345 | logger.error(str(event)) | ||
| 346 | continue | ||
| 347 | if isinstance(event, bb.command.CommandExit): | ||
| 348 | return | ||
| 349 | if isinstance(event, bb.cooker.CookerExit): | ||
| 350 | return | ||
| 351 | if isinstance(event, bb.runqueue.sceneQueueTaskStarted): | ||
| 352 | if not is_tasks_running: | ||
| 353 | is_tasks_running = True | ||
| 354 | ui.block_start("Running tasks") | ||
| 355 | if event.stats.total != 0: | ||
| 356 | ui.progress("Running setscene tasks", ( | ||
| 357 | event.stats.completed + event.stats.active + event.stats.failed + 1) * 100 / event.stats.total) | ||
| 358 | if isinstance(event, bb.runqueue.runQueueTaskStarted): | ||
| 359 | if not is_tasks_running: | ||
| 360 | is_tasks_running = True | ||
| 361 | ui.block_start("Running tasks") | ||
| 362 | if event.stats.total != 0: | ||
| 363 | pseudo_total = event.stats.total - event.stats.skipped | ||
| 364 | pseudo_complete = event.stats.completed + event.stats.active - event.stats.skipped + event.stats.failed + 1 | ||
| 365 | # TODO: sometimes this gives over 100% | ||
| 366 | ui.progress("Running runqueue tasks", (pseudo_complete) * 100 / pseudo_total, | ||
| 367 | " ({0}/{1})".format(pseudo_complete, pseudo_total)) | ||
| 368 | if isinstance(event, bb.runqueue.sceneQueueTaskFailed): | ||
| 369 | logger.warning(str(event)) | ||
| 370 | continue | ||
| 371 | if isinstance(event, bb.runqueue.runQueueTaskFailed): | ||
| 372 | logger.error(str(event)) | ||
| 373 | return 1 | ||
| 374 | if isinstance(event, bb.event.LogExecTTY): | ||
| 375 | pass | ||
| 376 | except EnvironmentError as ioerror: | ||
| 377 | # ignore interrupted io | ||
| 378 | if ioerror.args[0] == 4: | ||
| 379 | pass | ||
| 380 | except Exception as ex: | ||
| 381 | logger.error(str(ex)) | ||
| 382 | |||
| 383 | # except KeyboardInterrupt: | ||
| 384 | # if shutdown == 2: | ||
| 385 | # mw.appendText("Third Keyboard Interrupt, exit.\n") | ||
| 386 | # exitflag = True | ||
| 387 | # if shutdown == 1: | ||
| 388 | # mw.appendText("Second Keyboard Interrupt, stopping...\n") | ||
| 389 | # _, error = server.runCommand(["stateForceShutdown"]) | ||
| 390 | # if error: | ||
| 391 | # print("Unable to cleanly stop: %s" % error) | ||
| 392 | # if shutdown == 0: | ||
| 393 | # mw.appendText("Keyboard Interrupt, closing down...\n") | ||
| 394 | # _, error = server.runCommand(["stateShutdown"]) | ||
| 395 | # if error: | ||
| 396 | # print("Unable to cleanly shutdown: %s" % error) | ||
| 397 | # shutdown = shutdown + 1 | ||
| 398 | # pass | ||
