/hooks/webkitpy/common/net/buildbot/buildbot.py

https://github.com/hwti/LunaSysMgr · Python · 495 lines · 327 code · 71 blank · 97 comment · 82 complexity · b4fbcffabadf1b689f11ac2ed94ec089 MD5 · raw file

  1. # Copyright (c) 2009, Google Inc. All rights reserved.
  2. #
  3. # Redistribution and use in source and binary forms, with or without
  4. # modification, are permitted provided that the following conditions are
  5. # met:
  6. #
  7. # * Redistributions of source code must retain the above copyright
  8. # notice, this list of conditions and the following disclaimer.
  9. # * Redistributions in binary form must reproduce the above
  10. # copyright notice, this list of conditions and the following disclaimer
  11. # in the documentation and/or other materials provided with the
  12. # distribution.
  13. # * Neither the name of Google Inc. nor the names of its
  14. # contributors may be used to endorse or promote products derived from
  15. # this software without specific prior written permission.
  16. #
  17. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
  18. # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
  19. # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
  20. # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
  21. # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
  22. # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
  23. # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
  24. # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  25. # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  26. # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  27. # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  28. #
  29. # WebKit's Python module for interacting with WebKit's buildbot
  30. try:
  31. import json
  32. except ImportError:
  33. # python 2.5 compatibility
  34. import webkitpy.thirdparty.simplejson as json
  35. import operator
  36. import re
  37. import urllib
  38. import urllib2
  39. import webkitpy.common.config.urls as config_urls
  40. from webkitpy.common.net.failuremap import FailureMap
  41. from webkitpy.common.net.layouttestresults import LayoutTestResults
  42. from webkitpy.common.net.networktransaction import NetworkTransaction
  43. from webkitpy.common.net.regressionwindow import RegressionWindow
  44. from webkitpy.common.system.logutils import get_logger
  45. from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup
  46. _log = get_logger(__file__)
  47. class Builder(object):
  48. def __init__(self, name, buildbot):
  49. self._name = name
  50. self._buildbot = buildbot
  51. self._builds_cache = {}
  52. self._revision_to_build_number = None
  53. from webkitpy.thirdparty.autoinstalled.mechanize import Browser
  54. self._browser = Browser()
  55. self._browser.set_handle_robots(False) # The builder pages are excluded by robots.txt
  56. def name(self):
  57. return self._name
  58. def results_url(self):
  59. return "%s/results/%s" % (self._buildbot.buildbot_url, self.url_encoded_name())
  60. # In addition to per-build results, the build.chromium.org builders also
  61. # keep a directory that accumulates test results over many runs.
  62. def accumulated_results_url(self):
  63. return None
  64. def url_encoded_name(self):
  65. return urllib.quote(self._name)
  66. def url(self):
  67. return "%s/builders/%s" % (self._buildbot.buildbot_url, self.url_encoded_name())
  68. # This provides a single place to mock
  69. def _fetch_build(self, build_number):
  70. build_dictionary = self._buildbot._fetch_build_dictionary(self, build_number)
  71. if not build_dictionary:
  72. return None
  73. revision_string = build_dictionary['sourceStamp']['revision']
  74. return Build(self,
  75. build_number=int(build_dictionary['number']),
  76. # 'revision' may be None if a trunk build was started by the force-build button on the web page.
  77. revision=(int(revision_string) if revision_string else None),
  78. # Buildbot uses any nubmer other than 0 to mean fail. Since we fetch with
  79. # filter=1, passing builds may contain no 'results' value.
  80. is_green=(not build_dictionary.get('results')),
  81. )
  82. def build(self, build_number):
  83. if not build_number:
  84. return None
  85. cached_build = self._builds_cache.get(build_number)
  86. if cached_build:
  87. return cached_build
  88. build = self._fetch_build(build_number)
  89. self._builds_cache[build_number] = build
  90. return build
  91. def latest_cached_build(self):
  92. revision_build_pairs = self.revision_build_pairs_with_results()
  93. revision_build_pairs.sort(key=lambda i: i[1])
  94. latest_build_number = revision_build_pairs[-1][1]
  95. return self.build(latest_build_number)
  96. def force_build(self, username="webkit-patch", comments=None):
  97. def predicate(form):
  98. try:
  99. return form.find_control("username")
  100. except Exception, e:
  101. return False
  102. self._browser.open(self.url())
  103. self._browser.select_form(predicate=predicate)
  104. self._browser["username"] = username
  105. if comments:
  106. self._browser["comments"] = comments
  107. return self._browser.submit()
  108. file_name_regexp = re.compile(r"r(?P<revision>\d+) \((?P<build_number>\d+)\)")
  109. def _revision_and_build_for_filename(self, filename):
  110. # Example: "r47483 (1)/" or "r47483 (1).zip"
  111. match = self.file_name_regexp.match(filename)
  112. return (int(match.group("revision")), int(match.group("build_number")))
  113. def _fetch_revision_to_build_map(self):
  114. # All _fetch requests go through _buildbot for easier mocking
  115. # FIXME: This should use NetworkTransaction's 404 handling instead.
  116. try:
  117. # FIXME: This method is horribly slow due to the huge network load.
  118. # FIXME: This is a poor way to do revision -> build mapping.
  119. # Better would be to ask buildbot through some sort of API.
  120. print "Loading revision/build list from %s." % self.results_url()
  121. print "This may take a while..."
  122. result_files = self._buildbot._fetch_twisted_directory_listing(self.results_url())
  123. except urllib2.HTTPError, error:
  124. if error.code != 404:
  125. raise
  126. result_files = []
  127. # This assumes there was only one build per revision, which is false but we don't care for now.
  128. return dict([self._revision_and_build_for_filename(file_info["filename"]) for file_info in result_files])
  129. def _revision_to_build_map(self):
  130. if not self._revision_to_build_number:
  131. self._revision_to_build_number = self._fetch_revision_to_build_map()
  132. return self._revision_to_build_number
  133. def revision_build_pairs_with_results(self):
  134. return self._revision_to_build_map().items()
  135. # This assumes there can be only one build per revision, which is false, but we don't care for now.
  136. def build_for_revision(self, revision, allow_failed_lookups=False):
  137. # NOTE: This lookup will fail if that exact revision was never built.
  138. build_number = self._revision_to_build_map().get(int(revision))
  139. if not build_number:
  140. return None
  141. build = self.build(build_number)
  142. if not build and allow_failed_lookups:
  143. # Builds for old revisions with fail to lookup via buildbot's json api.
  144. build = Build(self,
  145. build_number=build_number,
  146. revision=revision,
  147. is_green=False,
  148. )
  149. return build
  150. def find_regression_window(self, red_build, look_back_limit=30):
  151. if not red_build or red_build.is_green():
  152. return RegressionWindow(None, None)
  153. common_failures = None
  154. current_build = red_build
  155. build_after_current_build = None
  156. look_back_count = 0
  157. while current_build:
  158. if current_build.is_green():
  159. # current_build can't possibly have any failures in common
  160. # with red_build because it's green.
  161. break
  162. results = current_build.layout_test_results()
  163. # We treat a lack of results as if all the test failed.
  164. # This occurs, for example, when we can't compile at all.
  165. if results:
  166. failures = set(results.failing_tests())
  167. if common_failures == None:
  168. common_failures = failures
  169. else:
  170. common_failures = common_failures.intersection(failures)
  171. if not common_failures:
  172. # current_build doesn't have any failures in common with
  173. # the red build we're worried about. We assume that any
  174. # failures in current_build were due to flakiness.
  175. break
  176. look_back_count += 1
  177. if look_back_count > look_back_limit:
  178. return RegressionWindow(None, current_build, failing_tests=common_failures)
  179. build_after_current_build = current_build
  180. current_build = current_build.previous_build()
  181. # We must iterate at least once because red_build is red.
  182. assert(build_after_current_build)
  183. # Current build must either be green or have no failures in common
  184. # with red build, so we've found our failure transition.
  185. return RegressionWindow(current_build, build_after_current_build, failing_tests=common_failures)
  186. def find_blameworthy_regression_window(self, red_build_number, look_back_limit=30, avoid_flakey_tests=True):
  187. red_build = self.build(red_build_number)
  188. regression_window = self.find_regression_window(red_build, look_back_limit)
  189. if not regression_window.build_before_failure():
  190. return None # We ran off the limit of our search
  191. # If avoid_flakey_tests, require at least 2 bad builds before we
  192. # suspect a real failure transition.
  193. if avoid_flakey_tests and regression_window.failing_build() == red_build:
  194. return None
  195. return regression_window
  196. class Build(object):
  197. def __init__(self, builder, build_number, revision, is_green):
  198. self._builder = builder
  199. self._number = build_number
  200. self._revision = revision
  201. self._is_green = is_green
  202. self._layout_test_results = None
  203. @staticmethod
  204. def build_url(builder, build_number):
  205. return "%s/builds/%s" % (builder.url(), build_number)
  206. def url(self):
  207. return self.build_url(self.builder(), self._number)
  208. def results_url(self):
  209. results_directory = "r%s (%s)" % (self.revision(), self._number)
  210. return "%s/%s" % (self._builder.results_url(), urllib.quote(results_directory))
  211. def results_zip_url(self):
  212. return "%s.zip" % self.results_url()
  213. def _fetch_file_from_results(self, file_name):
  214. # It seems this can return None if the url redirects and then returns 404.
  215. result = urllib2.urlopen("%s/%s" % (self.results_url(), file_name))
  216. if not result:
  217. return None
  218. # urlopen returns a file-like object which sometimes works fine with str()
  219. # but sometimes is a addinfourl object. In either case calling read() is correct.
  220. return result.read()
  221. def layout_test_results(self):
  222. if self._layout_test_results:
  223. return self._layout_test_results
  224. # FIXME: This should cache that the result was a 404 and stop hitting the network.
  225. results_file = NetworkTransaction(convert_404_to_None=True).run(lambda: self._fetch_file_from_results("full_results.json"))
  226. if not results_file:
  227. results_file = NetworkTransaction(convert_404_to_None=True).run(lambda: self._fetch_file_from_results("results.html"))
  228. # results_from_string accepts either ORWT html or NRWT json.
  229. self._layout_test_results = LayoutTestResults.results_from_string(results_file)
  230. return self._layout_test_results
  231. def builder(self):
  232. return self._builder
  233. def revision(self):
  234. return self._revision
  235. def is_green(self):
  236. return self._is_green
  237. def previous_build(self):
  238. # previous_build() allows callers to avoid assuming build numbers are sequential.
  239. # They may not be sequential across all master changes, or when non-trunk builds are made.
  240. return self._builder.build(self._number - 1)
  241. class BuildBot(object):
  242. _builder_factory = Builder
  243. _default_url = config_urls.buildbot_url
  244. def __init__(self, url=None):
  245. self.buildbot_url = url if url else self._default_url
  246. self._builder_by_name = {}
  247. # If any core builder is red we should not be landing patches. Other
  248. # builders should be added to this list once they are known to be
  249. # reliable.
  250. # See https://bugs.webkit.org/show_bug.cgi?id=33296 and related bugs.
  251. self.core_builder_names_regexps = [
  252. "SnowLeopard.*Build",
  253. "SnowLeopard.*\(Test",
  254. "SnowLeopard.*\(WebKit2 Test",
  255. "Leopard.*\((?:Build|Test)",
  256. "Windows.*Build",
  257. "Windows.*\(Test",
  258. "WinCairo",
  259. "WinCE",
  260. "EFL",
  261. "GTK.*32",
  262. "GTK.*64.*Debug", # Disallow the 64-bit Release bot which is broken.
  263. "Qt",
  264. "Chromium.*(Mac|Linux|Win).*Release$",
  265. "Chromium.*(Mac|Linux|Win).*Release.*\(Tests",
  266. ]
  267. def _parse_last_build_cell(self, builder, cell):
  268. status_link = cell.find('a')
  269. if status_link:
  270. # Will be either a revision number or a build number
  271. revision_string = status_link.string
  272. # If revision_string has non-digits assume it's not a revision number.
  273. builder['built_revision'] = int(revision_string) \
  274. if not re.match('\D', revision_string) \
  275. else None
  276. # FIXME: We treat slave lost as green even though it is not to
  277. # work around the Qts bot being on a broken internet connection.
  278. # The real fix is https://bugs.webkit.org/show_bug.cgi?id=37099
  279. builder['is_green'] = not re.search('fail', cell.renderContents()) or \
  280. not not re.search('lost', cell.renderContents())
  281. status_link_regexp = r"builders/(?P<builder_name>.*)/builds/(?P<build_number>\d+)"
  282. link_match = re.match(status_link_regexp, status_link['href'])
  283. builder['build_number'] = int(link_match.group("build_number"))
  284. else:
  285. # We failed to find a link in the first cell, just give up. This
  286. # can happen if a builder is just-added, the first cell will just
  287. # be "no build"
  288. # Other parts of the code depend on is_green being present.
  289. builder['is_green'] = False
  290. builder['built_revision'] = None
  291. builder['build_number'] = None
  292. def _parse_current_build_cell(self, builder, cell):
  293. activity_lines = cell.renderContents().split("<br />")
  294. builder["activity"] = activity_lines[0] # normally "building" or "idle"
  295. # The middle lines document how long left for any current builds.
  296. match = re.match("(?P<pending_builds>\d) pending", activity_lines[-1])
  297. builder["pending_builds"] = int(match.group("pending_builds")) if match else 0
  298. def _parse_builder_status_from_row(self, status_row):
  299. status_cells = status_row.findAll('td')
  300. builder = {}
  301. # First cell is the name
  302. name_link = status_cells[0].find('a')
  303. builder["name"] = unicode(name_link.string)
  304. self._parse_last_build_cell(builder, status_cells[1])
  305. self._parse_current_build_cell(builder, status_cells[2])
  306. return builder
  307. def _matches_regexps(self, builder_name, name_regexps):
  308. for name_regexp in name_regexps:
  309. if re.match(name_regexp, builder_name):
  310. return True
  311. return False
  312. # FIXME: Should move onto Builder
  313. def _is_core_builder(self, builder_name):
  314. return self._matches_regexps(builder_name, self.core_builder_names_regexps)
  315. # FIXME: This method needs to die, but is used by a unit test at the moment.
  316. def _builder_statuses_with_names_matching_regexps(self, builder_statuses, name_regexps):
  317. return [builder for builder in builder_statuses if self._matches_regexps(builder["name"], name_regexps)]
  318. def red_core_builders(self):
  319. return [builder for builder in self.core_builder_statuses() if not builder["is_green"]]
  320. def red_core_builders_names(self):
  321. return [builder["name"] for builder in self.red_core_builders()]
  322. def idle_red_core_builders(self):
  323. return [builder for builder in self.red_core_builders() if builder["activity"] == "idle"]
  324. def core_builders_are_green(self):
  325. return not self.red_core_builders()
  326. # FIXME: These _fetch methods should move to a networking class.
  327. def _fetch_build_dictionary(self, builder, build_number):
  328. # Note: filter=1 will remove None and {} and '', which cuts noise but can
  329. # cause keys to be missing which you might otherwise expect.
  330. # FIXME: The bot sends a *huge* amount of data for each request, we should
  331. # find a way to reduce the response size further.
  332. json_url = "%s/json/builders/%s/builds/%s?filter=1" % (self.buildbot_url, urllib.quote(builder.name()), build_number)
  333. try:
  334. return json.load(urllib2.urlopen(json_url))
  335. except urllib2.URLError, err:
  336. build_url = Build.build_url(builder, build_number)
  337. _log.error("Error fetching data for %s build %s (%s, json: %s): %s" % (builder.name(), build_number, build_url, json_url, err))
  338. return None
  339. except ValueError, err:
  340. build_url = Build.build_url(builder, build_number)
  341. _log.error("Error decoding json data from %s: %s" % (build_url, err))
  342. return None
  343. def _fetch_one_box_per_builder(self):
  344. build_status_url = "%s/one_box_per_builder" % self.buildbot_url
  345. return urllib2.urlopen(build_status_url)
  346. def _file_cell_text(self, file_cell):
  347. """Traverses down through firstChild elements until one containing a string is found, then returns that string"""
  348. element = file_cell
  349. while element.string is None and element.contents:
  350. element = element.contents[0]
  351. return element.string
  352. def _parse_twisted_file_row(self, file_row):
  353. string_or_empty = lambda string: unicode(string) if string else u""
  354. file_cells = file_row.findAll('td')
  355. return {
  356. "filename": string_or_empty(self._file_cell_text(file_cells[0])),
  357. "size": string_or_empty(self._file_cell_text(file_cells[1])),
  358. "type": string_or_empty(self._file_cell_text(file_cells[2])),
  359. "encoding": string_or_empty(self._file_cell_text(file_cells[3])),
  360. }
  361. def _parse_twisted_directory_listing(self, page):
  362. soup = BeautifulSoup(page)
  363. # HACK: Match only table rows with a class to ignore twisted header/footer rows.
  364. file_rows = soup.find('table').findAll('tr', {'class': re.compile(r'\b(?:directory|file)\b')})
  365. return [self._parse_twisted_file_row(file_row) for file_row in file_rows]
  366. # FIXME: There should be a better way to get this information directly from twisted.
  367. def _fetch_twisted_directory_listing(self, url):
  368. return self._parse_twisted_directory_listing(urllib2.urlopen(url))
  369. def builders(self):
  370. return [self.builder_with_name(status["name"]) for status in self.builder_statuses()]
  371. # This method pulls from /one_box_per_builder as an efficient way to get information about
  372. def builder_statuses(self):
  373. soup = BeautifulSoup(self._fetch_one_box_per_builder())
  374. return [self._parse_builder_status_from_row(status_row) for status_row in soup.find('table').findAll('tr')]
  375. def core_builder_statuses(self):
  376. return [builder for builder in self.builder_statuses() if self._is_core_builder(builder["name"])]
  377. def builder_with_name(self, name):
  378. builder = self._builder_by_name.get(name)
  379. if not builder:
  380. builder = self._builder_factory(name, self)
  381. self._builder_by_name[name] = builder
  382. return builder
  383. def failure_map(self, only_core_builders=True):
  384. builder_statuses = self.core_builder_statuses() if only_core_builders else self.builder_statuses()
  385. failure_map = FailureMap()
  386. revision_to_failing_bots = {}
  387. for builder_status in builder_statuses:
  388. if builder_status["is_green"]:
  389. continue
  390. builder = self.builder_with_name(builder_status["name"])
  391. regression_window = builder.find_blameworthy_regression_window(builder_status["build_number"])
  392. if regression_window:
  393. failure_map.add_regression_window(builder, regression_window)
  394. return failure_map
  395. # This makes fewer requests than calling Builder.latest_build would. It grabs all builder
  396. # statuses in one request using self.builder_statuses (fetching /one_box_per_builder instead of builder pages).
  397. def _latest_builds_from_builders(self, only_core_builders=True):
  398. builder_statuses = self.core_builder_statuses() if only_core_builders else self.builder_statuses()
  399. return [self.builder_with_name(status["name"]).build(status["build_number"]) for status in builder_statuses]
  400. def _build_at_or_before_revision(self, build, revision):
  401. while build:
  402. if build.revision() <= revision:
  403. return build
  404. build = build.previous_build()
  405. def last_green_revision(self, only_core_builders=True):
  406. builds = self._latest_builds_from_builders(only_core_builders)
  407. target_revision = builds[0].revision()
  408. # An alternate way to do this would be to start at one revision and walk backwards
  409. # checking builder.build_for_revision, however build_for_revision is very slow on first load.
  410. while True:
  411. # Make builds agree on revision
  412. builds = [self._build_at_or_before_revision(build, target_revision) for build in builds]
  413. if None in builds: # One of the builds failed to load from the server.
  414. return None
  415. min_revision = min(map(lambda build: build.revision(), builds))
  416. if min_revision != target_revision:
  417. target_revision = min_revision
  418. continue # Builds don't all agree on revision, keep searching
  419. # Check to make sure they're all green
  420. all_are_green = reduce(operator.and_, map(lambda build: build.is_green(), builds))
  421. if not all_are_green:
  422. target_revision -= 1
  423. continue
  424. return min_revision