Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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 |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 170 | console.setFormatter(format) |
| 171 | errconsole.setFormatter(format) |
| 172 | if not bb.msg.has_console_handler(logger): |
| 173 | logger.addHandler(console) |
| 174 | logger.addHandler(errconsole) |
| 175 | |
| 176 | if params.options.remote_server and params.options.kill_server: |
| 177 | server.terminateServer() |
| 178 | return |
| 179 | |
| 180 | if params.observe_only: |
| 181 | logger.error("Observe-only mode not supported in this UI") |
| 182 | return 1 |
| 183 | |
| 184 | llevel, debug_domains = bb.msg.constructLogOptions() |
| 185 | server.runCommand(["setEventMask", server.getEventHandle(), llevel, debug_domains, _evt_list]) |
| 186 | |
| 187 | try: |
| 188 | params.updateFromServer(server) |
| 189 | cmdline = params.parseActions() |
| 190 | if not cmdline: |
| 191 | logger.error("No task given") |
| 192 | return 1 |
| 193 | if 'msg' in cmdline and cmdline['msg']: |
| 194 | logger.error(cmdline['msg']) |
| 195 | return 1 |
| 196 | cmdline = cmdline['action'] |
| 197 | ret, error = server.runCommand(cmdline) |
| 198 | if error: |
| 199 | logger.error("{0}: {1}".format(cmdline, error)) |
| 200 | return 1 |
| 201 | elif not ret: |
| 202 | logger.error("Couldn't get default commandline: {0}".format(re)) |
| 203 | return 1 |
| 204 | except xmlrpc.client.Fault as x: |
| 205 | logger.error("XMLRPC Fault getting commandline: {0}".format(x)) |
| 206 | return 1 |
| 207 | |
| 208 | active_process_total = None |
| 209 | is_tasks_running = False |
| 210 | |
| 211 | while True: |
| 212 | try: |
| 213 | event = eventHandler.waitEvent(0.25) |
| 214 | if not event: |
| 215 | continue |
| 216 | |
| 217 | helper.eventHandler(event) |
| 218 | |
| 219 | if isinstance(event, bb.build.TaskBase): |
| 220 | logger.info(event._message) |
| 221 | if isinstance(event, logging.LogRecord): |
| 222 | # Don't report sstate failures as errors, since Yocto will just run the tasks for real |
| 223 | if event.msg == "No suitable staging package found" or (event.msg.startswith( |
| 224 | "Fetcher failure: Unable to find file") and "downloadfilename" in event.msg and "sstate" in event.msg): |
| 225 | event.levelno = bb.msg.BBLogFormatter.WARNING |
| 226 | if event.taskpid != 0: |
| 227 | # For "normal" logging conditions, don't show note logs from tasks |
| 228 | # but do show them if the user has changed the default log level to |
| 229 | # include verbose/debug messages |
| 230 | if event.levelno <= bb.msg.BBLogFormatter.NOTE and (event.levelno < llevel or ( |
| 231 | event.levelno == bb.msg.BBLogFormatter.NOTE and llevel != bb.msg.BBLogFormatter.VERBOSE)): |
| 232 | continue |
| 233 | |
| 234 | # Prefix task messages with recipe/task |
| 235 | if event.taskpid in helper.running_tasks and event.levelno != bb.msg.BBLogFormatter.PLAIN: |
| 236 | taskinfo = helper.running_tasks[event.taskpid] |
| 237 | event.msg = taskinfo['title'] + ': ' + event.msg |
| 238 | if hasattr(event, 'fn'): |
| 239 | event.msg = event.fn + ': ' + event.msg |
| 240 | logger.handle(event) |
| 241 | if isinstance(event, bb.build.TaskFailedSilent): |
| 242 | logger.warning("Logfile for failed setscene task is %s" % event.logfile) |
| 243 | continue |
| 244 | if isinstance(event, bb.build.TaskFailed): |
| 245 | rt = "{0}-{1}:{2}".format(event.pn, event.pv.replace("AUTOINC", "0"), event.task) |
| 246 | |
| 247 | logfile = event.logfile |
| 248 | if not logfile or not os.path.exists(logfile): |
| 249 | TeamCityUI.emit_service_message("buildProblem", description="{0}\nUnknown failure (no log file available)".format(rt)) |
| 250 | if not event.task.endswith("_setscene"): |
| 251 | server.runCommand(["stateForceShutdown"]) |
| 252 | continue |
| 253 | |
| 254 | details = deque(maxlen=loglines) |
| 255 | error_lines = [] |
| 256 | if includelogs and not event.errprinted: |
| 257 | with open(logfile, "r") as f: |
| 258 | while True: |
| 259 | line = f.readline() |
| 260 | if not line: |
| 261 | break |
| 262 | line = line.rstrip() |
| 263 | details.append(' | %s' % line) |
| 264 | # TODO: a less stupid check for errors |
| 265 | if (event.task == "do_compile") and ("error:" in line): |
| 266 | error_lines.append(line) |
| 267 | |
| 268 | if error_lines: |
| 269 | TeamCityUI.emit_service_message("compilationStarted", compiler=rt) |
| 270 | for line in error_lines: |
| 271 | TeamCityUI.emit_service_message("message", text=line, status="ERROR") |
| 272 | TeamCityUI.emit_service_message("compilationFinished", compiler=rt) |
| 273 | else: |
| 274 | TeamCityUI.emit_service_message("buildProblem", description=rt) |
| 275 | |
| 276 | err = "Logfile of failure stored in: %s" % logfile |
| 277 | if details: |
| 278 | ui.block_start("{0} task log".format(rt)) |
| 279 | # TeamCity seems to choke on service messages longer than about 63800 characters, so if error |
| 280 | # details is longer than, say, 60000, batch it up into several messages. |
| 281 | first_message = True |
| 282 | while details: |
| 283 | detail_len = 0 |
| 284 | batch = deque() |
| 285 | while details and detail_len < 60000: |
| 286 | # TODO: This code doesn't bother to handle lines that themselves are extremely long. |
| 287 | line = details.popleft() |
| 288 | batch.append(line) |
| 289 | detail_len += len(line) |
| 290 | |
| 291 | if first_message: |
| 292 | batch.appendleft("Log data follows:") |
| 293 | first_message = False |
| 294 | TeamCityUI.emit_service_message("message", text=err, status="ERROR", |
| 295 | errorDetails="\n".join(batch)) |
| 296 | else: |
| 297 | TeamCityUI.emit_service_message("message", text="[continued]", status="ERROR", |
| 298 | errorDetails="\n".join(batch)) |
| 299 | ui.block_end() |
| 300 | else: |
| 301 | TeamCityUI.emit_service_message("message", text=err, status="ERROR", errorDetails="") |
| 302 | |
| 303 | if not event.task.endswith("_setscene"): |
| 304 | server.runCommand(["stateForceShutdown"]) |
| 305 | |
| 306 | if isinstance(event, bb.event.ProcessStarted): |
| 307 | if event.processname in ["Initialising tasks", "Checking sstate mirror object availability"]: |
| 308 | active_process_total = event.total |
| 309 | ui.block_start(event.processname) |
| 310 | if isinstance(event, bb.event.ProcessFinished): |
| 311 | if event.processname in ["Initialising tasks", "Checking sstate mirror object availability"]: |
| 312 | ui.progress(event.processname, 100) |
| 313 | ui.block_end() |
| 314 | if isinstance(event, bb.event.ProcessProgress): |
| 315 | if event.processname in ["Initialising tasks", |
| 316 | "Checking sstate mirror object availability"] and active_process_total != 0: |
| 317 | ui.progress(event.processname, event.progress * 100 / active_process_total) |
| 318 | if isinstance(event, bb.event.CacheLoadStarted): |
| 319 | ui.block_start("Loading cache") |
| 320 | if isinstance(event, bb.event.CacheLoadProgress): |
| 321 | if event.total != 0: |
| 322 | ui.progress("Loading cache", math.floor(event.current * 100 / event.total)) |
| 323 | if isinstance(event, bb.event.CacheLoadCompleted): |
| 324 | ui.progress("Loading cache", 100) |
| 325 | ui.block_end() |
| 326 | if isinstance(event, bb.event.ParseStarted): |
| 327 | ui.block_start("Parsing recipes and checking upstream revisions") |
| 328 | if isinstance(event, bb.event.ParseProgress): |
| 329 | if event.total != 0: |
| 330 | ui.progress("Parsing recipes", math.floor(event.current * 100 / event.total)) |
| 331 | if isinstance(event, bb.event.ParseCompleted): |
| 332 | ui.progress("Parsing recipes", 100) |
| 333 | ui.block_end() |
| 334 | if isinstance(event, bb.command.CommandCompleted): |
| 335 | return |
| 336 | if isinstance(event, bb.command.CommandFailed): |
| 337 | logger.error(str(event)) |
| 338 | return 1 |
| 339 | if isinstance(event, bb.event.MultipleProviders): |
| 340 | logger.warning(str(event)) |
| 341 | continue |
| 342 | if isinstance(event, bb.event.NoProvider): |
| 343 | logger.error(str(event)) |
| 344 | continue |
| 345 | if isinstance(event, bb.command.CommandExit): |
| 346 | return |
| 347 | if isinstance(event, bb.cooker.CookerExit): |
| 348 | return |
| 349 | if isinstance(event, bb.runqueue.sceneQueueTaskStarted): |
| 350 | if not is_tasks_running: |
| 351 | is_tasks_running = True |
| 352 | ui.block_start("Running tasks") |
| 353 | if event.stats.total != 0: |
| 354 | ui.progress("Running setscene tasks", ( |
| 355 | event.stats.completed + event.stats.active + event.stats.failed + 1) * 100 / event.stats.total) |
| 356 | if isinstance(event, bb.runqueue.runQueueTaskStarted): |
| 357 | if not is_tasks_running: |
| 358 | is_tasks_running = True |
| 359 | ui.block_start("Running tasks") |
| 360 | if event.stats.total != 0: |
| 361 | pseudo_total = event.stats.total - event.stats.skipped |
| 362 | pseudo_complete = event.stats.completed + event.stats.active - event.stats.skipped + event.stats.failed + 1 |
| 363 | # TODO: sometimes this gives over 100% |
| 364 | ui.progress("Running runqueue tasks", (pseudo_complete) * 100 / pseudo_total, |
| 365 | " ({0}/{1})".format(pseudo_complete, pseudo_total)) |
| 366 | if isinstance(event, bb.runqueue.sceneQueueTaskFailed): |
| 367 | logger.warning(str(event)) |
| 368 | continue |
| 369 | if isinstance(event, bb.runqueue.runQueueTaskFailed): |
| 370 | logger.error(str(event)) |
| 371 | return 1 |
| 372 | if isinstance(event, bb.event.LogExecTTY): |
| 373 | pass |
| 374 | except EnvironmentError as ioerror: |
| 375 | # ignore interrupted io |
| 376 | if ioerror.args[0] == 4: |
| 377 | pass |
| 378 | except Exception as ex: |
| 379 | logger.error(str(ex)) |
| 380 | |
| 381 | # except KeyboardInterrupt: |
| 382 | # if shutdown == 2: |
| 383 | # mw.appendText("Third Keyboard Interrupt, exit.\n") |
| 384 | # exitflag = True |
| 385 | # if shutdown == 1: |
| 386 | # mw.appendText("Second Keyboard Interrupt, stopping...\n") |
| 387 | # _, error = server.runCommand(["stateForceShutdown"]) |
| 388 | # if error: |
| 389 | # print("Unable to cleanly stop: %s" % error) |
| 390 | # if shutdown == 0: |
| 391 | # mw.appendText("Keyboard Interrupt, closing down...\n") |
| 392 | # _, error = server.runCommand(["stateShutdown"]) |
| 393 | # if error: |
| 394 | # print("Unable to cleanly shutdown: %s" % error) |
| 395 | # shutdown = shutdown + 1 |
| 396 | # pass |