1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3# Copyright 2019 The Chromium OS Authors. All rights reserved. 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6# 7# Redistribution and use in source and binary forms, with or without 8# modification, are permitted provided that the following conditions are 9# met: 10# 11# * Redistributions of source code must retain the above copyright 12# notice, this list of conditions and the following disclaimer. 13# * Redistributions in binary form must reproduce the above 14# copyright notice, this list of conditions and the following disclaimer 15# in the documentation and/or other materials provided with the 16# distribution. 17# * Neither the name of Google Inc. nor the names of its 18# contributors may be used to endorse or promote products derived from 19# this software without specific prior written permission. 20# 21# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 33# NOTE: This file is NOT under GPL. See above. 34 35"""Queries buildbot through the json interface. 36""" 37 38from __future__ import print_function 39 40__author__ = 'maruel@chromium.org' 41__version__ = '1.2' 42 43import code 44import datetime 45import functools 46import json 47 48# Pylint recommends we use "from chromite.lib import cros_logging as logging". 49# Chromite specific policy message, we want to keep using the standard logging. 50# pylint: disable=cros-logging-import 51import logging 52 53# pylint: disable=deprecated-module 54import optparse 55 56import time 57import sys 58import urllib.error 59import urllib.parse 60import urllib.request 61 62try: 63 from natsort import natsorted 64except ImportError: 65 # natsorted is a simple helper to sort "naturally", e.g. "vm40" is sorted 66 # after "vm7". Defaults to normal sorting. 67 natsorted = sorted 68 69# These values are buildbot constants used for Build and BuildStep. 70# This line was copied from master/buildbot/status/builder.py. 71SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION, RETRY = list(range(6)) 72 73## Generic node caching code. 74 75 76class Node(object): 77 """Root class for all nodes in the graph. 78 79 Provides base functionality for any node in the graph, independent if it has 80 children or not or if its content can be addressed through an url or needs to 81 be fetched as part of another node. 82 83 self.printable_attributes is only used for self documentation and for str() 84 implementation. 85 """ 86 printable_attributes = [] 87 88 def __init__(self, parent, url): 89 self.printable_attributes = self.printable_attributes[:] 90 if url: 91 self.printable_attributes.append('url') 92 url = url.rstrip('/') 93 if parent is not None: 94 self.printable_attributes.append('parent') 95 self.url = url 96 self.parent = parent 97 98 def __str__(self): 99 return self.to_string() 100 101 def __repr__(self): 102 """Embeds key if present.""" 103 key = getattr(self, 'key', None) 104 if key is not None: 105 return '<%s key=%s>' % (self.__class__.__name__, key) 106 cached_keys = getattr(self, 'cached_keys', None) 107 if cached_keys is not None: 108 return '<%s keys=%s>' % (self.__class__.__name__, cached_keys) 109 return super(Node, self).__repr__() 110 111 def to_string(self, maximum=100): 112 out = ['%s:' % self.__class__.__name__] 113 assert not 'printable_attributes' in self.printable_attributes 114 115 def limit(txt): 116 txt = str(txt) 117 if maximum > 0: 118 if len(txt) > maximum + 2: 119 txt = txt[:maximum] + '...' 120 return txt 121 122 for k in sorted(self.printable_attributes): 123 if k == 'parent': 124 # Avoid infinite recursion. 125 continue 126 out.append(limit(' %s: %r' % (k, getattr(self, k)))) 127 return '\n'.join(out) 128 129 def refresh(self): 130 """Refreshes the data.""" 131 self.discard() 132 return self.cache() 133 134 def cache(self): # pragma: no cover 135 """Caches the data.""" 136 raise NotImplementedError() 137 138 def discard(self): # pragma: no cover 139 """Discards cached data. 140 141 Pretty much everything is temporary except completed Build. 142 """ 143 raise NotImplementedError() 144 145 146class AddressableBaseDataNode(Node): # pylint: disable=W0223 147 """A node that contains a dictionary of data that can be fetched with an url. 148 149 The node is directly addressable. It also often can be fetched by the parent. 150 """ 151 printable_attributes = Node.printable_attributes + ['data'] 152 153 def __init__(self, parent, url, data): 154 super(AddressableBaseDataNode, self).__init__(parent, url) 155 self._data = data 156 157 @property 158 def cached_data(self): 159 return self._data 160 161 @property 162 def data(self): 163 self.cache() 164 return self._data 165 166 def cache(self): 167 if self._data is None: 168 self._data = self._readall() 169 return True 170 return False 171 172 def discard(self): 173 self._data = None 174 175 def read(self, suburl): 176 assert self.url, self.__class__.__name__ 177 url = self.url 178 if suburl: 179 url = '%s/%s' % (self.url, suburl) 180 return self.parent.read(url) 181 182 def _readall(self): 183 return self.read('') 184 185 186class AddressableDataNode(AddressableBaseDataNode): # pylint: disable=W0223 187 """Automatically encodes the url.""" 188 189 def __init__(self, parent, url, data): 190 super(AddressableDataNode, self).__init__(parent, urllib.parse.quote(url), 191 data) 192 193 194class NonAddressableDataNode(Node): # pylint: disable=W0223 195 """A node that cannot be addressed by an unique url. 196 197 The data comes directly from the parent. 198 """ 199 200 def __init__(self, parent, subkey): 201 super(NonAddressableDataNode, self).__init__(parent, None) 202 self.subkey = subkey 203 204 @property 205 def cached_data(self): 206 if self.parent.cached_data is None: 207 return None 208 return self.parent.cached_data[self.subkey] 209 210 @property 211 def data(self): 212 return self.parent.data[self.subkey] 213 214 def cache(self): 215 self.parent.cache() 216 217 def discard(self): # pragma: no cover 218 """Avoid invalid state when parent recreate the object.""" 219 raise AttributeError('Call parent discard() instead') 220 221 222class VirtualNodeList(Node): 223 """Base class for every node that has children. 224 225 Adds partial supports for keys and iterator functionality. 'key' can be a 226 string or a int. Not to be used directly. 227 """ 228 printable_attributes = Node.printable_attributes + ['keys'] 229 230 def __init__(self, parent, url): 231 super(VirtualNodeList, self).__init__(parent, url) 232 # Keeps the keys independently when ordering is needed. 233 self._is_cached = False 234 self._has_keys_cached = False 235 236 def __contains__(self, key): 237 """Enables 'if i in obj:'.""" 238 return key in self.keys 239 240 def __iter__(self): 241 """Enables 'for i in obj:'. It returns children.""" 242 self.cache_keys() 243 for key in self.keys: 244 yield self[key] 245 246 def __len__(self): 247 """Enables 'len(obj)' to get the number of childs.""" 248 return len(self.keys) 249 250 def discard(self): 251 """Discards data. 252 253 The default behavior is to not invalidate cached keys. The only place where 254 keys need to be invalidated is with Builds. 255 """ 256 self._is_cached = False 257 self._has_keys_cached = False 258 259 @property 260 def cached_children(self): # pragma: no cover 261 """Returns an iterator over the children that are cached.""" 262 raise NotImplementedError() 263 264 @property 265 def cached_keys(self): # pragma: no cover 266 raise NotImplementedError() 267 268 @property 269 def keys(self): # pragma: no cover 270 """Returns the keys for every children.""" 271 raise NotImplementedError() 272 273 def __getitem__(self, key): # pragma: no cover 274 """Returns a child, without fetching its data. 275 276 The children could be invalid since no verification is done. 277 """ 278 raise NotImplementedError() 279 280 def cache(self): # pragma: no cover 281 """Cache all the children.""" 282 raise NotImplementedError() 283 284 def cache_keys(self): # pragma: no cover 285 """Cache all children's keys.""" 286 raise NotImplementedError() 287 288 289class NodeList(VirtualNodeList): # pylint: disable=W0223 290 """Adds a cache of the keys.""" 291 292 def __init__(self, parent, url): 293 super(NodeList, self).__init__(parent, url) 294 self._keys = [] 295 296 @property 297 def cached_keys(self): 298 return self._keys 299 300 @property 301 def keys(self): 302 self.cache_keys() 303 return self._keys 304 305 306class NonAddressableNodeList(VirtualNodeList): # pylint: disable=W0223 307 """A node that contains children but retrieves all its data from its parent. 308 309 I.e. there's no url to get directly this data. 310 """ 311 # Child class object for children of this instance. For example, BuildSteps 312 # has BuildStep children. 313 _child_cls = None 314 315 def __init__(self, parent, subkey): 316 super(NonAddressableNodeList, self).__init__(parent, None) 317 self.subkey = subkey 318 assert (not isinstance(self._child_cls, NonAddressableDataNode) and 319 issubclass(self._child_cls, NonAddressableDataNode)), ( 320 self._child_cls.__name__) 321 322 @property 323 def cached_children(self): 324 if self.parent.cached_data is not None: 325 for i in range(len(self.parent.cached_data[self.subkey])): 326 yield self[i] 327 328 @property 329 def cached_data(self): 330 if self.parent.cached_data is None: 331 return None 332 return self.parent.data.get(self.subkey, None) 333 334 @property 335 def cached_keys(self): 336 if self.parent.cached_data is None: 337 return None 338 return list(range(len(self.parent.data.get(self.subkey, [])))) 339 340 @property 341 def data(self): 342 return self.parent.data[self.subkey] 343 344 def cache(self): 345 self.parent.cache() 346 347 def cache_keys(self): 348 self.parent.cache() 349 350 def discard(self): # pragma: no cover 351 """Do not call. 352 353 Avoid infinite recursion by having the caller calls the parent's 354 discard() explicitely. 355 """ 356 raise AttributeError('Call parent discard() instead') 357 358 def __iter__(self): 359 """Enables 'for i in obj:'. It returns children.""" 360 if self.data: 361 for i in range(len(self.data)): 362 yield self[i] 363 364 def __getitem__(self, key): 365 """Doesn't cache the value, it's not needed. 366 367 TODO(maruel): Cache? 368 """ 369 if isinstance(key, int) and key < 0: 370 key = len(self.data) + key 371 # pylint: disable=E1102 372 return self._child_cls(self, key) 373 374 375class AddressableNodeList(NodeList): 376 """A node that has children that can be addressed with an url.""" 377 378 # Child class object for children of this instance. For example, Builders has 379 # Builder children and Builds has Build children. 380 _child_cls = None 381 382 def __init__(self, parent, url): 383 super(AddressableNodeList, self).__init__(parent, url) 384 self._cache = {} 385 assert (not isinstance(self._child_cls, AddressableDataNode) and 386 issubclass(self._child_cls, AddressableDataNode)), ( 387 self._child_cls.__name__) 388 389 @property 390 def cached_children(self): 391 for item in self._cache.values(): 392 if item.cached_data is not None: 393 yield item 394 395 @property 396 def cached_keys(self): 397 return list(self._cache.keys()) 398 399 def __getitem__(self, key): 400 """Enables 'obj[i]'.""" 401 if self._has_keys_cached and not key in self._keys: 402 raise KeyError(key) 403 404 if not key in self._cache: 405 # Create an empty object. 406 self._create_obj(key, None) 407 return self._cache[key] 408 409 def cache(self): 410 if not self._is_cached: 411 data = self._readall() 412 for key in sorted(data): 413 self._create_obj(key, data[key]) 414 self._is_cached = True 415 self._has_keys_cached = True 416 417 def cache_partial(self, children): 418 """Caches a partial number of children. 419 420 This method is more efficient since it does a single request for all the 421 children instead of one request per children. 422 423 It only grab objects not already cached. 424 """ 425 # pylint: disable=W0212 426 if not self._is_cached: 427 to_fetch = [ 428 child for child in children 429 if not (child in self._cache and self._cache[child].cached_data) 430 ] 431 if to_fetch: 432 # Similar to cache(). The only reason to sort is to simplify testing. 433 params = '&'.join( 434 'select=%s' % urllib.parse.quote(str(v)) for v in sorted(to_fetch)) 435 data = self.read('?' + params) 436 for key in sorted(data): 437 self._create_obj(key, data[key]) 438 439 def cache_keys(self): 440 """Implement to speed up enumeration. Defaults to call cache().""" 441 if not self._has_keys_cached: 442 self.cache() 443 assert self._has_keys_cached 444 445 def discard(self): 446 """Discards temporary children.""" 447 super(AddressableNodeList, self).discard() 448 for v in self._cache.values(): 449 v.discard() 450 451 def read(self, suburl): 452 assert self.url, self.__class__.__name__ 453 url = self.url 454 if suburl: 455 url = '%s/%s' % (self.url, suburl) 456 return self.parent.read(url) 457 458 def _create_obj(self, key, data): 459 """Creates an object of type self._child_cls.""" 460 # pylint: disable=E1102 461 obj = self._child_cls(self, key, data) 462 # obj.key and key may be different. 463 # No need to overide cached data with None. 464 if data is not None or obj.key not in self._cache: 465 self._cache[obj.key] = obj 466 if obj.key not in self._keys: 467 self._keys.append(obj.key) 468 469 def _readall(self): 470 return self.read('') 471 472 473class SubViewNodeList(VirtualNodeList): # pylint: disable=W0223 474 """A node that shows a subset of children that comes from another structure. 475 476 The node is not addressable. 477 478 E.g. the keys are retrieved from parent but the actual data comes from 479 virtual_parent. 480 """ 481 482 def __init__(self, parent, virtual_parent, subkey): 483 super(SubViewNodeList, self).__init__(parent, None) 484 self.subkey = subkey 485 self.virtual_parent = virtual_parent 486 assert isinstance(self.parent, AddressableDataNode) 487 assert isinstance(self.virtual_parent, NodeList) 488 489 @property 490 def cached_children(self): 491 if self.parent.cached_data is not None: 492 for item in self.keys: 493 if item in self.virtual_parent.keys: 494 child = self[item] 495 if child.cached_data is not None: 496 yield child 497 498 @property 499 def cached_keys(self): 500 return (self.parent.cached_data or {}).get(self.subkey, []) 501 502 @property 503 def keys(self): 504 self.cache_keys() 505 return self.parent.data.get(self.subkey, []) 506 507 def cache(self): 508 """Batch request for each child in a single read request.""" 509 if not self._is_cached: 510 self.virtual_parent.cache_partial(self.keys) 511 self._is_cached = True 512 513 def cache_keys(self): 514 if not self._has_keys_cached: 515 self.parent.cache() 516 self._has_keys_cached = True 517 518 def discard(self): 519 if self.parent.cached_data is not None: 520 for child in self.virtual_parent.cached_children: 521 if child.key in self.keys: 522 child.discard() 523 self.parent.discard() 524 super(SubViewNodeList, self).discard() 525 526 def __getitem__(self, key): 527 """Makes sure the key is in our key but grab it from the virtual parent.""" 528 return self.virtual_parent[key] 529 530 def __iter__(self): 531 self.cache() 532 return super(SubViewNodeList, self).__iter__() 533 534 535# Buildbot-specific code 536 537 538class Slave(AddressableDataNode): 539 """Buildbot slave class.""" 540 printable_attributes = AddressableDataNode.printable_attributes + [ 541 'name', 542 'key', 543 'connected', 544 'version', 545 ] 546 547 def __init__(self, parent, name, data): 548 super(Slave, self).__init__(parent, name, data) 549 self.name = name 550 self.key = self.name 551 # TODO(maruel): Add SlaveBuilders and a 'builders' property. 552 # TODO(maruel): Add a 'running_builds' property. 553 554 @property 555 def connected(self): 556 return self.data.get('connected', False) 557 558 @property 559 def version(self): 560 return self.data.get('version') 561 562 563class Slaves(AddressableNodeList): 564 """Buildbot slaves.""" 565 _child_cls = Slave 566 printable_attributes = AddressableNodeList.printable_attributes + ['names'] 567 568 def __init__(self, parent): 569 super(Slaves, self).__init__(parent, 'slaves') 570 571 @property 572 def names(self): 573 return self.keys 574 575 576class BuilderSlaves(SubViewNodeList): 577 """Similar to Slaves but only list slaves connected to a specific builder.""" 578 printable_attributes = SubViewNodeList.printable_attributes + ['names'] 579 580 def __init__(self, parent): 581 super(BuilderSlaves, self).__init__(parent, parent.parent.parent.slaves, 582 'slaves') 583 584 @property 585 def names(self): 586 return self.keys 587 588 589class BuildStep(NonAddressableDataNode): 590 """Class for a buildbot build step.""" 591 printable_attributes = NonAddressableDataNode.printable_attributes + [ 592 'name', 593 'number', 594 'start_time', 595 'end_time', 596 'duration', 597 'is_started', 598 'is_finished', 599 'is_running', 600 'result', 601 'simplified_result', 602 ] 603 604 def __init__(self, parent, number): 605 """Pre-loaded, since the data is retrieved via the Build object.""" 606 assert isinstance(number, int) 607 super(BuildStep, self).__init__(parent, number) 608 self.number = number 609 610 @property 611 def start_time(self): 612 if self.data.get('times'): 613 return int(round(self.data['times'][0])) 614 615 @property 616 def end_time(self): 617 times = self.data.get('times') 618 if times and len(times) == 2 and times[1]: 619 return int(round(times[1])) 620 621 @property 622 def duration(self): 623 if self.start_time: 624 return (self.end_time or int(round(time.time()))) - self.start_time 625 626 @property 627 def name(self): 628 return self.data['name'] 629 630 @property 631 def is_started(self): 632 return self.data.get('isStarted', False) 633 634 @property 635 def is_finished(self): 636 return self.data.get('isFinished', False) 637 638 @property 639 def is_running(self): 640 return self.is_started and not self.is_finished 641 642 @property 643 def result(self): 644 result = self.data.get('results') 645 if result is None: 646 # results may be 0, in that case with filter=1, the value won't be 647 # present. 648 if self.data.get('isFinished'): 649 result = self.data.get('results', 0) 650 while isinstance(result, list): 651 result = result[0] 652 return result 653 654 @property 655 def simplified_result(self): 656 """Returns a simplified 3 state value, True, False or None.""" 657 result = self.result 658 if result in (SUCCESS, WARNINGS): 659 return True 660 elif result in (FAILURE, EXCEPTION, RETRY): 661 return False 662 assert result in (None, SKIPPED), (result, self.data) 663 return None 664 665 666class BuildSteps(NonAddressableNodeList): 667 """Duplicates keys to support lookup by both step number and step name.""" 668 printable_attributes = NonAddressableNodeList.printable_attributes + [ 669 'failed', 670 ] 671 _child_cls = BuildStep 672 673 def __init__(self, parent): 674 """Pre-loaded, since the data is retrieved via the Build object.""" 675 super(BuildSteps, self).__init__(parent, 'steps') 676 677 @property 678 def keys(self): 679 """Returns the steps name in order.""" 680 return [i['name'] for i in self.data or []] 681 682 @property 683 def failed(self): 684 """Shortcuts that lists the step names of steps that failed.""" 685 return [step.name for step in self if step.simplified_result is False] 686 687 def __getitem__(self, key): 688 """Accept step name in addition to index number.""" 689 if isinstance(key, str): 690 # It's a string, try to find the corresponding index. 691 for i, step in enumerate(self.data): 692 if step['name'] == key: 693 key = i 694 break 695 else: 696 raise KeyError(key) 697 return super(BuildSteps, self).__getitem__(key) 698 699 700class Build(AddressableDataNode): 701 """Buildbot build info.""" 702 printable_attributes = AddressableDataNode.printable_attributes + [ 703 'key', 704 'number', 705 'steps', 706 'blame', 707 'reason', 708 'revision', 709 'result', 710 'simplified_result', 711 'start_time', 712 'end_time', 713 'duration', 714 'slave', 715 'properties', 716 'completed', 717 ] 718 719 def __init__(self, parent, key, data): 720 super(Build, self).__init__(parent, str(key), data) 721 self.number = int(key) 722 self.key = self.number 723 self.steps = BuildSteps(self) 724 725 @property 726 def blame(self): 727 return self.data.get('blame', []) 728 729 @property 730 def builder(self): 731 """Returns the Builder object. 732 733 Goes up the hierarchy to find the Buildbot.builders[builder] instance. 734 """ 735 return self.parent.parent.parent.parent.builders[self.data['builderName']] 736 737 @property 738 def start_time(self): 739 if self.data.get('times'): 740 return int(round(self.data['times'][0])) 741 742 @property 743 def end_time(self): 744 times = self.data.get('times') 745 if times and len(times) == 2 and times[1]: 746 return int(round(times[1])) 747 748 @property 749 def duration(self): 750 if self.start_time: 751 return (self.end_time or int(round(time.time()))) - self.start_time 752 753 @property 754 def eta(self): 755 return self.data.get('eta', 0) 756 757 @property 758 def completed(self): 759 return self.data.get('currentStep') is None 760 761 @property 762 def properties(self): 763 return self.data.get('properties', []) 764 765 @property 766 def reason(self): 767 return self.data.get('reason') 768 769 @property 770 def result(self): 771 result = self.data.get('results') 772 while isinstance(result, list): 773 result = result[0] 774 if result is None and self.steps: 775 # results may be 0, in that case with filter=1, the value won't be 776 # present. 777 result = self.steps[-1].result 778 return result 779 780 @property 781 def revision(self): 782 return self.data.get('sourceStamp', {}).get('revision') 783 784 @property 785 def simplified_result(self): 786 """Returns a simplified 3 state value, True, False or None.""" 787 result = self.result 788 if result in (SUCCESS, WARNINGS, SKIPPED): 789 return True 790 elif result in (FAILURE, EXCEPTION, RETRY): 791 return False 792 assert result is None, (result, self.data) 793 return None 794 795 @property 796 def slave(self): 797 """Returns the Slave object. 798 799 Goes up the hierarchy to find the Buildbot.slaves[slave] instance. 800 """ 801 return self.parent.parent.parent.parent.slaves[self.data['slave']] 802 803 def discard(self): 804 """Completed Build isn't discarded.""" 805 if self._data and self.result is None: 806 assert not self.steps or not self.steps[-1].data.get('isFinished') 807 self._data = None 808 809 810class CurrentBuilds(SubViewNodeList): 811 """Lists of the current builds.""" 812 813 def __init__(self, parent): 814 super(CurrentBuilds, self).__init__(parent, parent.builds, 'currentBuilds') 815 816 817class PendingBuilds(AddressableDataNode): 818 """List of the pending builds.""" 819 820 def __init__(self, parent): 821 super(PendingBuilds, self).__init__(parent, 'pendingBuilds', None) 822 823 824class Builds(AddressableNodeList): 825 """Supports iteration. 826 827 Recommends using .cache() to speed up if a significant number of builds are 828 iterated over. 829 """ 830 _child_cls = Build 831 832 def __init__(self, parent): 833 super(Builds, self).__init__(parent, 'builds') 834 835 def __getitem__(self, key): 836 """Support for negative reference and enable retrieving non-cached builds. 837 838 e.g. -1 is the last build, -2 is the previous build before the last one. 839 """ 840 key = int(key) 841 if key < 0: 842 # Convert negative to positive build number. 843 self.cache_keys() 844 # Since the negative value can be outside of the cache keys range, use the 845 # highest key value and calculate from it. 846 key = max(self._keys) + key + 1 847 848 if key not in self._cache: 849 # Create an empty object. 850 self._create_obj(key, None) 851 return self._cache[key] 852 853 def __iter__(self): 854 """Returns cached Build objects in reversed order. 855 856 The most recent build is returned first and then in reverse chronological 857 order, up to the oldest cached build by the server. Older builds can be 858 accessed but will trigger significantly more I/O so they are not included by 859 default in the iteration. 860 861 To access the older builds, use self.iterall() instead. 862 """ 863 self.cache() 864 return reversed(list(self._cache.values())) 865 866 def iterall(self): 867 """Returns Build objects in decreasing order unbounded up to build 0. 868 869 The most recent build is returned first and then in reverse chronological 870 order. Older builds can be accessed and will trigger significantly more I/O 871 so use this carefully. 872 """ 873 # Only cache keys here. 874 self.cache_keys() 875 if self._keys: 876 for i in range(max(self._keys), -1, -1): 877 yield self[i] 878 879 def cache_keys(self): 880 """Grabs the keys (build numbers) from the builder.""" 881 if not self._has_keys_cached: 882 for i in self.parent.data.get('cachedBuilds', []): 883 i = int(i) 884 self._cache.setdefault(i, Build(self, i, None)) 885 if i not in self._keys: 886 self._keys.append(i) 887 self._has_keys_cached = True 888 889 def discard(self): 890 super(Builds, self).discard() 891 # Can't keep keys. 892 self._has_keys_cached = False 893 894 def _readall(self): 895 return self.read('_all') 896 897 898class Builder(AddressableDataNode): 899 """Builder status.""" 900 printable_attributes = AddressableDataNode.printable_attributes + [ 901 'name', 902 'key', 903 'builds', 904 'slaves', 905 'pending_builds', 906 'current_builds', 907 ] 908 909 def __init__(self, parent, name, data): 910 super(Builder, self).__init__(parent, name, data) 911 self.name = name 912 self.key = name 913 self.builds = Builds(self) 914 self.slaves = BuilderSlaves(self) 915 self.current_builds = CurrentBuilds(self) 916 self.pending_builds = PendingBuilds(self) 917 918 def discard(self): 919 super(Builder, self).discard() 920 self.builds.discard() 921 self.slaves.discard() 922 self.current_builds.discard() 923 924 925class Builders(AddressableNodeList): 926 """Root list of builders.""" 927 _child_cls = Builder 928 929 def __init__(self, parent): 930 super(Builders, self).__init__(parent, 'builders') 931 932 933class Buildbot(AddressableBaseDataNode): 934 """This object should be recreated on a master restart as it caches data.""" 935 # Throttle fetches to not kill the server. 936 auto_throttle = None 937 printable_attributes = AddressableDataNode.printable_attributes + [ 938 'slaves', 939 'builders', 940 'last_fetch', 941 ] 942 943 def __init__(self, url): 944 super(Buildbot, self).__init__(None, url.rstrip('/') + '/json', None) 945 self._builders = Builders(self) 946 self._slaves = Slaves(self) 947 self.last_fetch = None 948 949 @property 950 def builders(self): 951 return self._builders 952 953 @property 954 def slaves(self): 955 return self._slaves 956 957 def discard(self): 958 """Discards information about Builders and Slaves.""" 959 super(Buildbot, self).discard() 960 self._builders.discard() 961 self._slaves.discard() 962 963 def read(self, suburl): 964 if self.auto_throttle: 965 if self.last_fetch: 966 delta = datetime.datetime.utcnow() - self.last_fetch 967 remaining = (datetime.timedelta(seconds=self.auto_throttle) - delta) 968 if remaining > datetime.timedelta(seconds=0): 969 logging.debug('Sleeping for %ss', remaining) 970 time.sleep(remaining.seconds) 971 self.last_fetch = datetime.datetime.utcnow() 972 url = '%s/%s' % (self.url, suburl) 973 if '?' in url: 974 url += '&filter=1' 975 else: 976 url += '?filter=1' 977 logging.info('read(%s)', suburl) 978 channel = urllib.request.urlopen(url) 979 data = channel.read() 980 try: 981 return json.loads(data) 982 except ValueError: 983 if channel.getcode() >= 400: 984 # Convert it into an HTTPError for easier processing. 985 raise urllib.error.HTTPError(url, channel.getcode(), 986 '%s:\n%s' % (url, data), channel.headers, 987 None) 988 raise 989 990 def _readall(self): 991 return self.read('project') 992 993 994# Controller code 995 996 997def usage(more): 998 999 def hook(fn): 1000 fn.func_usage_more = more 1001 return fn 1002 1003 return hook 1004 1005 1006def need_buildbot(fn): 1007 """Post-parse args to create a buildbot object.""" 1008 1009 @functools.wraps(fn) 1010 def hook(parser, args, *extra_args, **kwargs): 1011 old_parse_args = parser.parse_args 1012 1013 def new_parse_args(args): 1014 options, args = old_parse_args(args) 1015 if len(args) < 1: 1016 parser.error('Need to pass the root url of the buildbot') 1017 url = args.pop(0) 1018 if not url.startswith('http'): 1019 url = 'http://' + url 1020 buildbot = Buildbot(url) 1021 buildbot.auto_throttle = options.throttle 1022 return options, args, buildbot 1023 1024 parser.parse_args = new_parse_args 1025 # Call the original function with the modified parser. 1026 return fn(parser, args, *extra_args, **kwargs) 1027 1028 hook.func_usage_more = '[options] <url>' 1029 return hook 1030 1031 1032@need_buildbot 1033def CMDpending(parser, args): 1034 """Lists pending jobs.""" 1035 parser.add_option( 1036 '-b', 1037 '--builder', 1038 dest='builders', 1039 action='append', 1040 default=[], 1041 help='Builders to filter on') 1042 options, args, buildbot = parser.parse_args(args) 1043 if args: 1044 parser.error('Unrecognized parameters: %s' % ' '.join(args)) 1045 if not options.builders: 1046 options.builders = buildbot.builders.keys 1047 for builder in options.builders: 1048 builder = buildbot.builders[builder] 1049 pending_builds = builder.data.get('pendingBuilds', 0) 1050 if not pending_builds: 1051 continue 1052 print('Builder %s: %d' % (builder.name, pending_builds)) 1053 if not options.quiet: 1054 for pending in builder.pending_builds.data: 1055 if 'revision' in pending['source']: 1056 print(' revision: %s' % pending['source']['revision']) 1057 for change in pending['source']['changes']: 1058 print(' change:') 1059 print(' comment: %r' % change['comments'][:50]) 1060 print(' who: %s' % change['who']) 1061 return 0 1062 1063 1064@usage('[options] <url> [commands] ...') 1065@need_buildbot 1066def CMDrun(parser, args): 1067 """Runs commands passed as parameters. 1068 1069 When passing commands on the command line, each command will be run as if it 1070 was on its own line. 1071 """ 1072 parser.add_option('-f', '--file', help='Read script from file') 1073 parser.add_option( 1074 '-i', dest='use_stdin', action='store_true', help='Read script on stdin') 1075 # Variable 'buildbot' is not used directly. 1076 # pylint: disable=W0612 1077 options, args, _ = parser.parse_args(args) 1078 if (bool(args) + bool(options.use_stdin) + bool(options.file)) != 1: 1079 parser.error('Need to pass only one of: <commands>, -f <file> or -i') 1080 if options.use_stdin: 1081 cmds = sys.stdin.read() 1082 elif options.file: 1083 cmds = open(options.file).read() 1084 else: 1085 cmds = '\n'.join(args) 1086 compiled = compile(cmds, '<cmd line>', 'exec') 1087 # pylint: disable=eval-used 1088 eval(compiled, globals(), locals()) 1089 return 0 1090 1091 1092@need_buildbot 1093def CMDinteractive(parser, args): 1094 """Runs an interactive shell to run queries.""" 1095 _, args, buildbot = parser.parse_args(args) 1096 if args: 1097 parser.error('Unrecognized parameters: %s' % ' '.join(args)) 1098 prompt = ('Buildbot interactive console for "%s".\n' 1099 "Hint: Start with typing: 'buildbot.printable_attributes' or " 1100 "'print str(buildbot)' to explore.") % buildbot.url[:-len('/json')] 1101 local_vars = {'buildbot': buildbot, 'b': buildbot} 1102 code.interact(prompt, None, local_vars) 1103 1104 1105@need_buildbot 1106def CMDidle(parser, args): 1107 """Lists idle slaves.""" 1108 return find_idle_busy_slaves(parser, args, True) 1109 1110 1111@need_buildbot 1112def CMDbusy(parser, args): 1113 """Lists idle slaves.""" 1114 return find_idle_busy_slaves(parser, args, False) 1115 1116 1117@need_buildbot 1118def CMDdisconnected(parser, args): 1119 """Lists disconnected slaves.""" 1120 _, args, buildbot = parser.parse_args(args) 1121 if args: 1122 parser.error('Unrecognized parameters: %s' % ' '.join(args)) 1123 for slave in buildbot.slaves: 1124 if not slave.connected: 1125 print(slave.name) 1126 return 0 1127 1128 1129def find_idle_busy_slaves(parser, args, show_idle): 1130 parser.add_option( 1131 '-b', 1132 '--builder', 1133 dest='builders', 1134 action='append', 1135 default=[], 1136 help='Builders to filter on') 1137 parser.add_option( 1138 '-s', 1139 '--slave', 1140 dest='slaves', 1141 action='append', 1142 default=[], 1143 help='Slaves to filter on') 1144 options, args, buildbot = parser.parse_args(args) 1145 if args: 1146 parser.error('Unrecognized parameters: %s' % ' '.join(args)) 1147 if not options.builders: 1148 options.builders = buildbot.builders.keys 1149 for builder in options.builders: 1150 builder = buildbot.builders[builder] 1151 if options.slaves: 1152 # Only the subset of slaves connected to the builder. 1153 slaves = list(set(options.slaves).intersection(set(builder.slaves.names))) 1154 if not slaves: 1155 continue 1156 else: 1157 slaves = builder.slaves.names 1158 busy_slaves = [build.slave.name for build in builder.current_builds] 1159 if show_idle: 1160 slaves = natsorted(set(slaves) - set(busy_slaves)) 1161 else: 1162 slaves = natsorted(set(slaves) & set(busy_slaves)) 1163 if options.quiet: 1164 for slave in slaves: 1165 print(slave) 1166 else: 1167 if slaves: 1168 print('Builder %s: %s' % (builder.name, ', '.join(slaves))) 1169 return 0 1170 1171 1172def last_failure(buildbot, 1173 builders=None, 1174 slaves=None, 1175 steps=None, 1176 no_cache=False): 1177 """Returns Build object with last failure with the specific filters.""" 1178 builders = builders or buildbot.builders.keys 1179 for builder in builders: 1180 builder = buildbot.builders[builder] 1181 if slaves: 1182 # Only the subset of slaves connected to the builder. 1183 builder_slaves = list(set(slaves).intersection(set(builder.slaves.names))) 1184 if not builder_slaves: 1185 continue 1186 else: 1187 builder_slaves = builder.slaves.names 1188 1189 if not no_cache and len(builder.slaves) > 2: 1190 # Unless you just want the last few builds, it's often faster to 1191 # fetch the whole thing at once, at the cost of a small hickup on 1192 # the buildbot. 1193 # TODO(maruel): Cache only N last builds or all builds since 1194 # datetime. 1195 builder.builds.cache() 1196 1197 found = [] 1198 for build in builder.builds: 1199 if build.slave.name not in builder_slaves or build.slave.name in found: 1200 continue 1201 # Only add the slave for the first completed build but still look for 1202 # incomplete builds. 1203 if build.completed: 1204 found.append(build.slave.name) 1205 1206 if steps: 1207 if any(build.steps[step].simplified_result is False for step in steps): 1208 yield build 1209 elif build.simplified_result is False: 1210 yield build 1211 1212 if len(found) == len(builder_slaves): 1213 # Found all the slaves, quit. 1214 break 1215 1216 1217@need_buildbot 1218def CMDlast_failure(parser, args): 1219 """Lists all slaves that failed on that step on their last build. 1220 1221 Examples: 1222 To find all slaves where their last build was a compile failure, 1223 run with --step compile 1224 """ 1225 parser.add_option( 1226 '-S', 1227 '--step', 1228 dest='steps', 1229 action='append', 1230 default=[], 1231 help='List all slaves that failed on that step on their last build') 1232 parser.add_option( 1233 '-b', 1234 '--builder', 1235 dest='builders', 1236 action='append', 1237 default=[], 1238 help='Builders to filter on') 1239 parser.add_option( 1240 '-s', 1241 '--slave', 1242 dest='slaves', 1243 action='append', 1244 default=[], 1245 help='Slaves to filter on') 1246 parser.add_option( 1247 '-n', 1248 '--no_cache', 1249 action='store_true', 1250 help="Don't load all builds at once") 1251 options, args, buildbot = parser.parse_args(args) 1252 if args: 1253 parser.error('Unrecognized parameters: %s' % ' '.join(args)) 1254 print_builders = not options.quiet and len(options.builders) != 1 1255 last_builder = None 1256 for build in last_failure( 1257 buildbot, 1258 builders=options.builders, 1259 slaves=options.slaves, 1260 steps=options.steps, 1261 no_cache=options.no_cache): 1262 1263 if print_builders and last_builder != build.builder: 1264 print(build.builder.name) 1265 last_builder = build.builder 1266 1267 if options.quiet: 1268 if options.slaves: 1269 print('%s: %s' % (build.builder.name, build.slave.name)) 1270 else: 1271 print(build.slave.name) 1272 else: 1273 out = '%d on %s: blame:%s' % (build.number, build.slave.name, ', '.join( 1274 build.blame)) 1275 if print_builders: 1276 out = ' ' + out 1277 print(out) 1278 1279 if len(options.steps) != 1: 1280 for step in build.steps: 1281 if step.simplified_result is False: 1282 # Assume the first line is the text name anyway. 1283 summary = ', '.join(step.data['text'][1:])[:40] 1284 out = ' %s: "%s"' % (step.data['name'], summary) 1285 if print_builders: 1286 out = ' ' + out 1287 print(out) 1288 return 0 1289 1290 1291@need_buildbot 1292def CMDcurrent(parser, args): 1293 """Lists current jobs.""" 1294 parser.add_option( 1295 '-b', 1296 '--builder', 1297 dest='builders', 1298 action='append', 1299 default=[], 1300 help='Builders to filter on') 1301 parser.add_option( 1302 '--blame', action='store_true', help='Only print the blame list') 1303 options, args, buildbot = parser.parse_args(args) 1304 if args: 1305 parser.error('Unrecognized parameters: %s' % ' '.join(args)) 1306 if not options.builders: 1307 options.builders = buildbot.builders.keys 1308 1309 if options.blame: 1310 blame = set() 1311 for builder in options.builders: 1312 for build in buildbot.builders[builder].current_builds: 1313 if build.blame: 1314 for blamed in build.blame: 1315 blame.add(blamed) 1316 print('\n'.join(blame)) 1317 return 0 1318 1319 for builder in options.builders: 1320 builder = buildbot.builders[builder] 1321 if not options.quiet and builder.current_builds: 1322 print(builder.name) 1323 for build in builder.current_builds: 1324 if options.quiet: 1325 print(build.slave.name) 1326 else: 1327 out = '%4d: slave=%10s' % (build.number, build.slave.name) 1328 out += ' duration=%5d' % (build.duration or 0) 1329 if build.eta: 1330 out += ' eta=%5.0f' % build.eta 1331 else: 1332 out += ' ' 1333 if build.blame: 1334 out += ' blame=' + ', '.join(build.blame) 1335 print(out) 1336 1337 return 0 1338 1339 1340@need_buildbot 1341def CMDbuilds(parser, args): 1342 """Lists all builds. 1343 1344 Examples: 1345 To find all builds on a single slave, run with -b bar -s foo. 1346 """ 1347 parser.add_option( 1348 '-r', '--result', type='int', help='Build result to filter on') 1349 parser.add_option( 1350 '-b', 1351 '--builder', 1352 dest='builders', 1353 action='append', 1354 default=[], 1355 help='Builders to filter on') 1356 parser.add_option( 1357 '-s', 1358 '--slave', 1359 dest='slaves', 1360 action='append', 1361 default=[], 1362 help='Slaves to filter on') 1363 parser.add_option( 1364 '-n', 1365 '--no_cache', 1366 action='store_true', 1367 help="Don't load all builds at once") 1368 options, args, buildbot = parser.parse_args(args) 1369 if args: 1370 parser.error('Unrecognized parameters: %s' % ' '.join(args)) 1371 builders = options.builders or buildbot.builders.keys 1372 for builder in builders: 1373 builder = buildbot.builders[builder] 1374 for build in builder.builds: 1375 if not options.slaves or build.slave.name in options.slaves: 1376 if options.quiet: 1377 out = '' 1378 if options.builders: 1379 out += '%s/' % builder.name 1380 if len(options.slaves) != 1: 1381 out += '%s/' % build.slave.name 1382 out += '%d revision:%s result:%s blame:%s' % ( 1383 build.number, build.revision, build.result, ','.join(build.blame)) 1384 print(out) 1385 else: 1386 print(build) 1387 return 0 1388 1389 1390@need_buildbot 1391def CMDcount(parser, args): 1392 """Count the number of builds that occured during a specific period.""" 1393 parser.add_option( 1394 '-o', '--over', type='int', help='Number of seconds to look for') 1395 parser.add_option( 1396 '-b', 1397 '--builder', 1398 dest='builders', 1399 action='append', 1400 default=[], 1401 help='Builders to filter on') 1402 options, args, buildbot = parser.parse_args(args) 1403 if args: 1404 parser.error('Unrecognized parameters: %s' % ' '.join(args)) 1405 if not options.over: 1406 parser.error( 1407 'Specify the number of seconds, e.g. --over 86400 for the last 24 ' 1408 'hours') 1409 builders = options.builders or buildbot.builders.keys 1410 counts = {} 1411 since = time.time() - options.over 1412 for builder in builders: 1413 builder = buildbot.builders[builder] 1414 counts[builder.name] = 0 1415 if not options.quiet: 1416 print(builder.name) 1417 for build in builder.builds.iterall(): 1418 try: 1419 start_time = build.start_time 1420 except urllib.error.HTTPError: 1421 # The build was probably trimmed. 1422 print( 1423 'Failed to fetch build %s/%d' % (builder.name, build.number), 1424 file=sys.stderr) 1425 continue 1426 if start_time >= since: 1427 counts[builder.name] += 1 1428 else: 1429 break 1430 if not options.quiet: 1431 print('.. %d' % counts[builder.name]) 1432 1433 align_name = max(len(b) for b in counts) 1434 align_number = max(len(str(c)) for c in counts.values()) 1435 for builder in sorted(counts): 1436 print('%*s: %*d' % (align_name, builder, align_number, counts[builder])) 1437 print('Total: %d' % sum(counts.values())) 1438 return 0 1439 1440 1441def gen_parser(): 1442 """Returns an OptionParser instance with default options. 1443 1444 It should be then processed with gen_usage() before being used. 1445 """ 1446 parser = optparse.OptionParser(version=__version__) 1447 # Remove description formatting 1448 parser.format_description = lambda x: parser.description 1449 # Add common parsing. 1450 old_parser_args = parser.parse_args 1451 1452 def Parse(*args, **kwargs): 1453 options, args = old_parser_args(*args, **kwargs) 1454 if options.verbose >= 2: 1455 logging.basicConfig(level=logging.DEBUG) 1456 elif options.verbose: 1457 logging.basicConfig(level=logging.INFO) 1458 else: 1459 logging.basicConfig(level=logging.WARNING) 1460 return options, args 1461 1462 parser.parse_args = Parse 1463 1464 parser.add_option( 1465 '-v', 1466 '--verbose', 1467 action='count', 1468 help='Use multiple times to increase logging leve') 1469 parser.add_option( 1470 '-q', 1471 '--quiet', 1472 action='store_true', 1473 help='Reduces the output to be parsed by scripts, independent of -v') 1474 parser.add_option( 1475 '--throttle', 1476 type='float', 1477 help='Minimum delay to sleep between requests') 1478 return parser 1479 1480 1481# Generic subcommand handling code 1482 1483 1484def Command(name): 1485 return getattr(sys.modules[__name__], 'CMD' + name, None) 1486 1487 1488@usage('<command>') 1489def CMDhelp(parser, args): 1490 """Print list of commands or use 'help <command>'.""" 1491 _, args = parser.parse_args(args) 1492 if len(args) == 1: 1493 return main(args + ['--help']) 1494 parser.print_help() 1495 return 0 1496 1497 1498def gen_usage(parser, command): 1499 """Modifies an OptionParser object with the command's documentation. 1500 1501 The documentation is taken from the function's docstring. 1502 """ 1503 obj = Command(command) 1504 more = getattr(obj, 'func_usage_more') 1505 # OptParser.description prefer nicely non-formatted strings. 1506 parser.description = obj.__doc__ + '\n' 1507 parser.set_usage('usage: %%prog %s %s' % (command, more)) 1508 1509 1510def main(args=None): 1511 # Do it late so all commands are listed. 1512 # pylint: disable=E1101 1513 CMDhelp.__doc__ += '\n\nCommands are:\n' + '\n'.join( 1514 ' %-12s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n', 1)[0]) 1515 for fn in dir(sys.modules[__name__]) 1516 if fn.startswith('CMD')) 1517 1518 parser = gen_parser() 1519 if args is None: 1520 args = sys.argv[1:] 1521 if args: 1522 command = Command(args[0]) 1523 if command: 1524 # "fix" the usage and the description now that we know the subcommand. 1525 gen_usage(parser, args[0]) 1526 return command(parser, args[1:]) 1527 1528 # Not a known command. Default to help. 1529 gen_usage(parser, 'help') 1530 return CMDhelp(parser, args) 1531 1532 1533if __name__ == '__main__': 1534 sys.exit(main()) 1535