bot_update.py 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220
  1. #!/usr/bin/env python3
  2. # Copyright 2014 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. # TODO(hinoka): Use logging.
  6. from __future__ import division
  7. from __future__ import print_function
  8. import codecs
  9. from contextlib import contextmanager
  10. import copy
  11. import ctypes
  12. from datetime import datetime
  13. import functools
  14. import json
  15. import optparse
  16. import os
  17. import pprint
  18. import re
  19. import subprocess
  20. import sys
  21. import tempfile
  22. import threading
  23. import time
  24. import uuid
  25. import os.path as path
  26. # TODO(crbug.com/1227140): Clean up when py2 is no longer supported.
  27. from io import BytesIO
  28. try:
  29. import urlparse
  30. except ImportError: # pragma: no cover
  31. import urllib.parse as urlparse
  32. # Cache the string-escape codec to ensure subprocess can find it later.
  33. # See crbug.com/912292#c2 for context.
  34. # TODO(crbug.com/1227140): Clean up when py2 is no longer supported.
  35. if sys.version_info.major == 2:
  36. codecs.lookup('string-escape')
  37. # How many bytes at a time to read from pipes.
  38. BUF_SIZE = 256
  39. # How many seconds of no stdout activity before process is considered stale. Can
  40. # be overridden via environment variable `STALE_PROCESS_DURATION`. If set to 0,
  41. # process won't be terminated.
  42. STALE_PROCESS_DURATION = 1200
  43. # Define a bunch of directory paths.
  44. # Relative to this script's filesystem path.
  45. THIS_DIR = path.dirname(path.abspath(__file__))
  46. DEPOT_TOOLS_DIR = path.abspath(path.join(THIS_DIR, '..', '..', '..', '..'))
  47. CHROMIUM_GIT_HOST = 'https://chromium.googlesource.com'
  48. CHROMIUM_SRC_URL = CHROMIUM_GIT_HOST + '/chromium/src.git'
  49. BRANCH_HEADS_REFSPEC = '+refs/branch-heads/*'
  50. TAGS_REFSPEC = '+refs/tags/*'
  51. # Regular expression to match sha1 git revision.
  52. COMMIT_HASH_RE = re.compile(r'[0-9a-f]{5,40}', re.IGNORECASE)
  53. # Regular expression that matches a single commit footer line.
  54. COMMIT_FOOTER_ENTRY_RE = re.compile(r'([^:]+):\s*(.*)')
  55. # Footer metadata keys for regular and gsubtreed mirrored commit positions.
  56. COMMIT_POSITION_FOOTER_KEY = 'Cr-Commit-Position'
  57. COMMIT_ORIGINAL_POSITION_FOOTER_KEY = 'Cr-Original-Commit-Position'
  58. # Copied from scripts/recipes/chromium.py.
  59. GOT_REVISION_MAPPINGS = {
  60. CHROMIUM_SRC_URL: {
  61. 'got_revision': 'src/',
  62. 'got_nacl_revision': 'src/native_client/',
  63. 'got_v8_revision': 'src/v8/',
  64. 'got_webkit_revision': 'src/third_party/WebKit/',
  65. 'got_webrtc_revision': 'src/third_party/webrtc/',
  66. }
  67. }
  68. # List of bot update experiments
  69. EXP_NO_SYNC = 'no_sync' # Don't fetch/sync if current revision is recent enough
  70. # Don't sync if the checkout is less than 6 hours old.
  71. NO_SYNC_MAX_DELAY_S = 6 * 60 * 60
  72. GCLIENT_TEMPLATE = """solutions = %(solutions)s
  73. cache_dir = r%(cache_dir)s
  74. %(target_os)s
  75. %(target_os_only)s
  76. %(target_cpu)s
  77. """
  78. GIT_CACHE_PATH = path.join(DEPOT_TOOLS_DIR, 'git_cache.py')
  79. GCLIENT_PATH = path.join(DEPOT_TOOLS_DIR, 'gclient.py')
  80. class SubprocessFailed(Exception):
  81. def __init__(self, message, code, output):
  82. self.message = message
  83. self.code = code
  84. self.output = output
  85. class PatchFailed(SubprocessFailed):
  86. pass
  87. class GclientSyncFailed(SubprocessFailed):
  88. pass
  89. class InvalidDiff(Exception):
  90. pass
  91. RETRY = object()
  92. OK = object()
  93. FAIL = object()
  94. class RepeatingTimer(threading.Thread):
  95. """Call a function every n seconds, unless reset."""
  96. def __init__(self, interval, function, args=None, kwargs=None):
  97. threading.Thread.__init__(self)
  98. self.interval = interval
  99. self.function = function
  100. self.args = args if args is not None else []
  101. self.kwargs = kwargs if kwargs is not None else {}
  102. self.cond = threading.Condition()
  103. self.is_shutdown = False
  104. self.is_reset = False
  105. def reset(self):
  106. """Resets timer interval."""
  107. with self.cond:
  108. self.is_reset = True
  109. self.cond.notify_all()
  110. def shutdown(self):
  111. """Stops repeating timer."""
  112. with self.cond:
  113. self.is_shutdown = True
  114. self.cond.notify_all()
  115. def run(self):
  116. with self.cond:
  117. while not self.is_shutdown:
  118. self.cond.wait(self.interval)
  119. if not self.is_reset and not self.is_shutdown:
  120. self.function(*self.args, **self.kwargs)
  121. self.is_reset = False
  122. def _print_pstree():
  123. """Debugging function used to print "ps auxwwf" for stuck processes."""
  124. if sys.platform.startswith('linux2'):
  125. # Add new line for cleaner output
  126. print()
  127. subprocess.call(['ps', 'auxwwf'])
  128. def _kill_process(proc):
  129. print('Killing stale process...')
  130. proc.kill()
  131. # TODO(crbug.com/1227140): Clean up when py2 is no longer supported.
  132. def _stdout_write(buf):
  133. try:
  134. sys.stdout.buffer.write(buf)
  135. except AttributeError:
  136. sys.stdout.write(buf)
  137. def call(*args, **kwargs): # pragma: no cover
  138. """Interactive subprocess call."""
  139. kwargs['stdout'] = subprocess.PIPE
  140. kwargs['stderr'] = subprocess.STDOUT
  141. kwargs.setdefault('bufsize', BUF_SIZE)
  142. cwd = kwargs.get('cwd', os.getcwd())
  143. stdin_data = kwargs.pop('stdin_data', None)
  144. if stdin_data:
  145. kwargs['stdin'] = subprocess.PIPE
  146. out = BytesIO()
  147. new_env = kwargs.get('env', {})
  148. env = os.environ.copy()
  149. env.update(new_env)
  150. kwargs['env'] = env
  151. stale_process_duration = env.get('STALE_PROCESS_DURATION',
  152. STALE_PROCESS_DURATION)
  153. if new_env:
  154. print('===Injecting Environment Variables===')
  155. for k, v in sorted(new_env.items()):
  156. print('%s: %s' % (k, v))
  157. print('%s ===Running %s ===' % (datetime.now(), ' '.join(args),))
  158. print('In directory: %s' % cwd)
  159. start_time = time.time()
  160. try:
  161. proc = subprocess.Popen(args, **kwargs)
  162. except:
  163. print('\t%s failed to exectute.' % ' '.join(args))
  164. raise
  165. observers = [
  166. RepeatingTimer(300, _print_pstree),
  167. RepeatingTimer(int(stale_process_duration), _kill_process, [proc])]
  168. for observer in observers:
  169. observer.start()
  170. try:
  171. # If there's an exception in this block, we need to stop all observers.
  172. # Otherwise, observers will be spinning and main process won't exit while
  173. # the main thread will be doing nothing.
  174. if stdin_data:
  175. proc.stdin.write(stdin_data)
  176. proc.stdin.close()
  177. # This is here because passing 'sys.stdout' into stdout for proc will
  178. # produce out of order output.
  179. hanging_cr = False
  180. while True:
  181. for observer in observers:
  182. observer.reset()
  183. buf = proc.stdout.read(BUF_SIZE)
  184. if not buf:
  185. break
  186. if hanging_cr:
  187. buf = b'\r' + buf
  188. hanging_cr = buf.endswith(b'\r')
  189. if hanging_cr:
  190. buf = buf[:-1]
  191. buf = buf.replace(b'\r\n', b'\n').replace(b'\r', b'\n')
  192. _stdout_write(buf)
  193. out.write(buf)
  194. if hanging_cr:
  195. _stdout_write(b'\n')
  196. out.write(b'\n')
  197. code = proc.wait()
  198. finally:
  199. for observer in observers:
  200. observer.shutdown()
  201. elapsed_time = ((time.time() - start_time) / 60.0)
  202. outval = out.getvalue().decode('utf-8')
  203. if code:
  204. print('%s ===Failed in %.1f mins of %s ===' %
  205. (datetime.now(), elapsed_time, ' '.join(args)))
  206. print()
  207. raise SubprocessFailed('%s failed with code %d in %s.' %
  208. (' '.join(args), code, cwd),
  209. code, outval)
  210. print('%s ===Succeeded in %.1f mins of %s ===' %
  211. (datetime.now(), elapsed_time, ' '.join(args)))
  212. print()
  213. return outval
  214. def git(*args, **kwargs): # pragma: no cover
  215. """Wrapper around call specifically for Git commands."""
  216. if args and args[0] == 'cache':
  217. # Rewrite "git cache" calls into "python git_cache.py".
  218. cmd = (sys.executable, '-u', GIT_CACHE_PATH) + args[1:]
  219. else:
  220. git_executable = 'git'
  221. # On windows, subprocess doesn't fuzzy-match 'git' to 'git.bat', so we
  222. # have to do it explicitly. This is better than passing shell=True.
  223. if sys.platform.startswith('win'):
  224. git_executable += '.bat'
  225. cmd = (git_executable,) + args
  226. return call(*cmd, **kwargs)
  227. def get_gclient_spec(solutions, target_os, target_os_only, target_cpu,
  228. git_cache_dir):
  229. return GCLIENT_TEMPLATE % {
  230. 'solutions': pprint.pformat(solutions, indent=4),
  231. 'cache_dir': '"%s"' % git_cache_dir,
  232. 'target_os': ('\ntarget_os=%s' % target_os) if target_os else '',
  233. 'target_os_only': '\ntarget_os_only=%s' % target_os_only,
  234. 'target_cpu': ('\ntarget_cpu=%s' % target_cpu) if target_cpu else ''
  235. }
  236. def solutions_printer(solutions):
  237. """Prints gclient solution to stdout."""
  238. print('Gclient Solutions')
  239. print('=================')
  240. for solution in solutions:
  241. name = solution.get('name')
  242. url = solution.get('url')
  243. print('%s (%s)' % (name, url))
  244. if solution.get('deps_file'):
  245. print(' Dependencies file is %s' % solution['deps_file'])
  246. if 'managed' in solution:
  247. print(' Managed mode is %s' % ('ON' if solution['managed'] else 'OFF'))
  248. custom_vars = solution.get('custom_vars')
  249. if custom_vars:
  250. print(' Custom Variables:')
  251. for var_name, var_value in sorted(custom_vars.items()):
  252. print(' %s = %s' % (var_name, var_value))
  253. custom_deps = solution.get('custom_deps')
  254. if 'custom_deps' in solution:
  255. print(' Custom Dependencies:')
  256. for deps_name, deps_value in sorted(custom_deps.items()):
  257. if deps_value:
  258. print(' %s -> %s' % (deps_name, deps_value))
  259. else:
  260. print(' %s: Ignore' % deps_name)
  261. for k, v in solution.items():
  262. # Print out all the keys we don't know about.
  263. if k in ['name', 'url', 'deps_file', 'custom_vars', 'custom_deps',
  264. 'managed']:
  265. continue
  266. print(' %s is %s' % (k, v))
  267. print()
  268. def modify_solutions(input_solutions):
  269. """Modifies urls in solutions to point at Git repos.
  270. returns: new solution dictionary
  271. """
  272. assert input_solutions
  273. solutions = copy.deepcopy(input_solutions)
  274. for solution in solutions:
  275. original_url = solution['url']
  276. parsed_url = urlparse.urlparse(original_url)
  277. parsed_path = parsed_url.path
  278. solution['managed'] = False
  279. # We don't want gclient to be using a safesync URL. Instead it should
  280. # using the lkgr/lkcr branch/tags.
  281. if 'safesync_url' in solution:
  282. print('Removing safesync url %s from %s' % (solution['safesync_url'],
  283. parsed_path))
  284. del solution['safesync_url']
  285. return solutions
  286. def remove(target, cleanup_dir):
  287. """Remove a target by moving it into cleanup_dir."""
  288. if not path.exists(cleanup_dir):
  289. os.makedirs(cleanup_dir)
  290. dest = path.join(cleanup_dir, '%s_%s' % (
  291. path.basename(target), uuid.uuid4().hex))
  292. print('Marking for removal %s => %s' % (target, dest))
  293. try:
  294. os.rename(target, dest)
  295. except Exception as e:
  296. print('Error renaming %s to %s: %s' % (target, dest, str(e)))
  297. raise
  298. def ensure_no_checkout(dir_names, cleanup_dir):
  299. """Ensure that there is no undesired checkout under build/."""
  300. build_dir = os.getcwd()
  301. has_checkout = any(path.exists(path.join(build_dir, dir_name, '.git'))
  302. for dir_name in dir_names)
  303. if has_checkout:
  304. for filename in os.listdir(build_dir):
  305. deletion_target = path.join(build_dir, filename)
  306. print('.git detected in checkout, deleting %s...' % deletion_target,)
  307. remove(deletion_target, cleanup_dir)
  308. print('done')
  309. def call_gclient(*args, **kwargs):
  310. """Run the "gclient.py" tool with the supplied arguments.
  311. Args:
  312. args: command-line arguments to pass to gclient.
  313. kwargs: keyword arguments to pass to call.
  314. """
  315. cmd = [sys.executable, '-u', GCLIENT_PATH]
  316. cmd.extend(args)
  317. return call(*cmd, **kwargs)
  318. def gclient_configure(solutions, target_os, target_os_only, target_cpu,
  319. git_cache_dir):
  320. """Should do the same thing as gclient --spec='...'."""
  321. with codecs.open('.gclient', mode='w', encoding='utf-8') as f:
  322. f.write(get_gclient_spec(
  323. solutions, target_os, target_os_only, target_cpu, git_cache_dir))
  324. @contextmanager
  325. def git_config_if_not_set(key, value):
  326. """Set git config for key equal to value if key was not set.
  327. If key was not set, unset it once we're done."""
  328. should_unset = True
  329. try:
  330. git('config', '--global', key)
  331. should_unset = False
  332. except SubprocessFailed as e:
  333. git('config', '--global', key, value)
  334. try:
  335. yield
  336. finally:
  337. if should_unset:
  338. git('config', '--global', '--unset', key)
  339. def gclient_sync(
  340. with_branch_heads, with_tags, revisions,
  341. patch_refs, gerrit_reset,
  342. gerrit_rebase_patch_ref):
  343. args = ['sync', '--verbose', '--reset', '--force',
  344. '--nohooks', '--noprehooks', '--delete_unversioned_trees']
  345. if with_branch_heads:
  346. args += ['--with_branch_heads']
  347. if with_tags:
  348. args += ['--with_tags']
  349. for name, revision in sorted(revisions.items()):
  350. if revision.upper() == 'HEAD':
  351. revision = 'refs/remotes/origin/main'
  352. args.extend(['--revision', '%s@%s' % (name, revision)])
  353. if patch_refs:
  354. for patch_ref in patch_refs:
  355. args.extend(['--patch-ref', patch_ref])
  356. if not gerrit_reset:
  357. args.append('--no-reset-patch-ref')
  358. if not gerrit_rebase_patch_ref:
  359. args.append('--no-rebase-patch-ref')
  360. try:
  361. call_gclient(*args)
  362. except SubprocessFailed as e:
  363. # If gclient sync is handling patching, parse the output for a patch error
  364. # message.
  365. if 'Failed to apply patch.' in e.output:
  366. raise PatchFailed(e.message, e.code, e.output)
  367. # Throw a GclientSyncFailed exception so we can catch this independently.
  368. raise GclientSyncFailed(e.message, e.code, e.output)
  369. def normalize_git_url(url):
  370. """Normalize a git url to be consistent.
  371. This recognizes urls to the googlesoruce.com domain. It ensures that
  372. the url:
  373. * Do not end in .git
  374. * Do not contain /a/ in their path.
  375. """
  376. try:
  377. p = urlparse.urlparse(url)
  378. except Exception:
  379. # Not a url, just return it back.
  380. return url
  381. if not p.netloc.endswith('.googlesource.com'):
  382. # Not a googlesource.com URL, can't normalize this, just return as is.
  383. return url
  384. upath = p.path
  385. if upath.startswith('/a'):
  386. upath = upath[len('/a'):]
  387. if upath.endswith('.git'):
  388. upath = upath[:-len('.git')]
  389. return 'https://%s%s' % (p.netloc, upath)
  390. def create_manifest():
  391. fd, fname = tempfile.mkstemp()
  392. os.close(fd)
  393. try:
  394. revinfo = call_gclient(
  395. 'revinfo', '-a', '--ignore-dep-type', 'cipd', '--output-json', fname)
  396. with open(fname) as f:
  397. return {
  398. path: {
  399. 'repository': info['url'],
  400. 'revision': info['rev'],
  401. }
  402. for path, info in json.load(f).items()
  403. if info['rev'] is not None
  404. }
  405. except (ValueError, SubprocessFailed):
  406. return {}
  407. finally:
  408. os.remove(fname)
  409. def get_commit_message_footer_map(message):
  410. """Returns: (dict) A dictionary of commit message footer entries.
  411. """
  412. footers = {}
  413. # Extract the lines in the footer block.
  414. lines = []
  415. for line in message.strip().splitlines():
  416. line = line.strip()
  417. if len(line) == 0:
  418. del lines[:]
  419. continue
  420. lines.append(line)
  421. # Parse the footer
  422. for line in lines:
  423. m = COMMIT_FOOTER_ENTRY_RE.match(line)
  424. if not m:
  425. # If any single line isn't valid, continue anyway for compatibility with
  426. # Gerrit (which itself uses JGit for this).
  427. continue
  428. footers[m.group(1)] = m.group(2).strip()
  429. return footers
  430. def get_commit_message_footer(message, key):
  431. """Returns: (str/None) The footer value for 'key', or None if none was found.
  432. """
  433. return get_commit_message_footer_map(message).get(key)
  434. # Derived from:
  435. # http://code.activestate.com/recipes/577972-disk-usage/?in=user-4178764
  436. def get_total_disk_space():
  437. cwd = os.getcwd()
  438. # Windows is the only platform that doesn't support os.statvfs, so
  439. # we need to special case this.
  440. if sys.platform.startswith('win'):
  441. _, total, free = (ctypes.c_ulonglong(), ctypes.c_ulonglong(), \
  442. ctypes.c_ulonglong())
  443. if sys.version_info >= (3,) or isinstance(cwd, unicode):
  444. fn = ctypes.windll.kernel32.GetDiskFreeSpaceExW
  445. else:
  446. fn = ctypes.windll.kernel32.GetDiskFreeSpaceExA
  447. ret = fn(cwd, ctypes.byref(_), ctypes.byref(total), ctypes.byref(free))
  448. if ret == 0:
  449. # WinError() will fetch the last error code.
  450. raise ctypes.WinError()
  451. return (total.value, free.value)
  452. else:
  453. st = os.statvfs(cwd)
  454. free = st.f_bavail * st.f_frsize
  455. total = st.f_blocks * st.f_frsize
  456. return (total, free)
  457. def ref_to_remote_ref(ref):
  458. """Maps refs to equivalent remote ref.
  459. This maps
  460. - refs/heads/BRANCH -> refs/remotes/origin/BRANCH
  461. - refs/branch-heads/BRANCH_HEAD -> refs/remotes/branch-heads/BRANCH_HEAD
  462. - origin/BRANCH -> refs/remotes/origin/BRANCH
  463. and leaves other refs unchanged.
  464. """
  465. if ref.startswith('refs/heads/'):
  466. return 'refs/remotes/origin/' + ref[len('refs/heads/'):]
  467. elif ref.startswith('refs/branch-heads/'):
  468. return 'refs/remotes/branch-heads/' + ref[len('refs/branch-heads/'):]
  469. elif ref.startswith('origin/'):
  470. return 'refs/remotes/' + ref
  471. else:
  472. return ref
  473. def get_target_branch_and_revision(solution_name, git_url, revisions):
  474. solution_name = solution_name.strip('/')
  475. configured = revisions.get(solution_name) or revisions.get(git_url)
  476. if configured is None or COMMIT_HASH_RE.match(configured):
  477. # TODO(crbug.com/1104182): Get the default branch instead of assuming
  478. # 'main'.
  479. branch = 'refs/remotes/origin/main'
  480. revision = configured or 'HEAD'
  481. return branch, revision
  482. elif ':' in configured:
  483. branch, revision = configured.split(':', 1)
  484. else:
  485. branch = configured
  486. revision = 'HEAD'
  487. if not branch.startswith(('refs/', 'origin/')):
  488. branch = 'refs/remotes/origin/' + branch
  489. branch = ref_to_remote_ref(branch)
  490. return branch, revision
  491. def _has_in_git_cache(revision_sha1, refs, git_cache_dir, url):
  492. """Returns whether given revision_sha1 is contained in cache of a given repo.
  493. """
  494. try:
  495. mirror_dir = git(
  496. 'cache', 'exists', '--quiet', '--cache-dir', git_cache_dir, url).strip()
  497. if revision_sha1:
  498. git('cat-file', '-e', revision_sha1, cwd=mirror_dir)
  499. # Don't check refspecs.
  500. filtered_refs = [
  501. r for r in refs if r not in [BRANCH_HEADS_REFSPEC, TAGS_REFSPEC]
  502. ]
  503. for ref in filtered_refs:
  504. git('cat-file', '-e', ref, cwd=mirror_dir)
  505. return True
  506. except SubprocessFailed:
  507. return False
  508. def is_broken_repo_dir(repo_dir):
  509. # Treat absence of 'config' as a signal of a partially deleted repo.
  510. return not path.exists(os.path.join(repo_dir, '.git', 'config'))
  511. def _maybe_break_locks(checkout_path, tries=3):
  512. """This removes all .lock files from this repo's .git directory.
  513. In particular, this will cleanup index.lock files, as well as ref lock
  514. files.
  515. """
  516. def attempt():
  517. git_dir = os.path.join(checkout_path, '.git')
  518. for dirpath, _, filenames in os.walk(git_dir):
  519. for filename in filenames:
  520. if filename.endswith('.lock'):
  521. to_break = os.path.join(dirpath, filename)
  522. print('breaking lock: %s' % to_break)
  523. try:
  524. os.remove(to_break)
  525. except OSError as ex:
  526. print('FAILED to break lock: %s: %s' % (to_break, ex))
  527. raise
  528. for _ in range(tries):
  529. try:
  530. attempt()
  531. return
  532. except Exception:
  533. pass
  534. def _set_git_config(fn):
  535. @functools.wraps(fn)
  536. def wrapper(*args, **kwargs):
  537. with git_config_if_not_set('user.name', 'chrome-bot'), \
  538. git_config_if_not_set('user.email', 'chrome-bot@chromium.org'), \
  539. git_config_if_not_set('fetch.uriprotocols', 'https'):
  540. return fn(*args, **kwargs)
  541. return wrapper
  542. def git_checkouts(solutions, revisions, refs, no_fetch_tags, git_cache_dir,
  543. cleanup_dir, enforce_fetch, experiments):
  544. build_dir = os.getcwd()
  545. synced = []
  546. for sln in solutions:
  547. sln_dir = path.join(build_dir, sln['name'])
  548. did_sync = _git_checkout(
  549. sln, sln_dir, revisions, refs, no_fetch_tags, git_cache_dir,
  550. cleanup_dir, enforce_fetch, experiments)
  551. if did_sync:
  552. synced.append(sln['name'])
  553. return synced
  554. def _git_checkout_needs_sync(sln_url, sln_dir, refs):
  555. if not path.exists(sln_dir):
  556. return True
  557. for ref in refs:
  558. try:
  559. remote_ref = ref_to_remote_ref(ref)
  560. commit_time = git('show', '-s', '--format=%ct', remote_ref, cwd=sln_dir)
  561. commit_time = int(commit_time)
  562. except SubprocessError:
  563. return True
  564. if time.time() - commit_time >= NO_SYNC_MAX_DELAY_S:
  565. return True
  566. return False
  567. def _git_checkout(sln, sln_dir, revisions, refs, no_fetch_tags, git_cache_dir,
  568. cleanup_dir, enforce_fetch, experiments):
  569. name = sln['name']
  570. url = sln['url']
  571. branch, revision = get_target_branch_and_revision(name, url, revisions)
  572. pin = revision if COMMIT_HASH_RE.match(revision) else None
  573. if (EXP_NO_SYNC in experiments
  574. and not _git_checkout_needs_sync(url, sln_dir, refs)):
  575. git('checkout', '--force', pin or branch, '--', cwd=sln_dir)
  576. return False
  577. populate_cmd = (['cache', 'populate', '-v', '--cache-dir', git_cache_dir, url,
  578. '--reset-fetch-config'])
  579. if no_fetch_tags:
  580. populate_cmd.extend(['--no-fetch-tags'])
  581. if pin:
  582. populate_cmd.extend(['--commit', pin])
  583. for ref in refs:
  584. populate_cmd.extend(['--ref', ref])
  585. # Step 1: populate/refresh cache, if necessary.
  586. if enforce_fetch or not pin:
  587. git(*populate_cmd)
  588. # If cache still doesn't have required pin/refs, try again and fetch pin/refs
  589. # directly.
  590. if not _has_in_git_cache(pin, refs, git_cache_dir, url):
  591. for attempt in range(3):
  592. git(*populate_cmd)
  593. if _has_in_git_cache(pin, refs, git_cache_dir, url):
  594. break
  595. print('Some required refs/commits are still not present.')
  596. print('Waiting 60s and trying again.')
  597. time.sleep(60)
  598. # Step 2: populate a checkout from local cache. All operations are local.
  599. mirror_dir = git(
  600. 'cache', 'exists', '--quiet', '--cache-dir', git_cache_dir, url).strip()
  601. first_try = True
  602. while True:
  603. try:
  604. # If repo deletion was aborted midway, it may have left .git in broken
  605. # state.
  606. if path.exists(sln_dir) and is_broken_repo_dir(sln_dir):
  607. print('Git repo %s appears to be broken, removing it' % sln_dir)
  608. remove(sln_dir, cleanup_dir)
  609. # Use "tries=1", since we retry manually in this loop.
  610. if not path.isdir(sln_dir):
  611. git('clone', '--no-checkout', '--local', '--shared', mirror_dir,
  612. sln_dir)
  613. _git_disable_gc(sln_dir)
  614. else:
  615. _git_disable_gc(sln_dir)
  616. git('remote', 'set-url', 'origin', mirror_dir, cwd=sln_dir)
  617. git('fetch', 'origin', cwd=sln_dir)
  618. git('remote', 'set-url', '--push', 'origin', url, cwd=sln_dir)
  619. if pin:
  620. git('fetch', 'origin', pin, cwd=sln_dir)
  621. for ref in refs:
  622. refspec = '%s:%s' % (ref, ref_to_remote_ref(ref.lstrip('+')))
  623. git('fetch', 'origin', refspec, cwd=sln_dir)
  624. # Windows sometimes has trouble deleting files.
  625. # This can make git commands that rely on locks fail.
  626. # Try a few times in case Windows has trouble again (and again).
  627. if sys.platform.startswith('win'):
  628. _maybe_break_locks(sln_dir, tries=3)
  629. # Note that the '--' argument is needed to ensure that git treats
  630. # 'pin or branch' as revision or ref, and not as file/directory which
  631. # happens to have the exact same name.
  632. git('checkout', '--force', pin or branch, '--', cwd=sln_dir)
  633. git('clean', '-dff', cwd=sln_dir)
  634. return True
  635. except SubprocessFailed as e:
  636. # Exited abnormally, there's probably something wrong.
  637. print('Something failed: %s.' % str(e))
  638. if first_try:
  639. first_try = False
  640. # Lets wipe the checkout and try again.
  641. remove(sln_dir, cleanup_dir)
  642. else:
  643. raise
  644. return True
  645. def _git_disable_gc(cwd):
  646. git('config', 'gc.auto', '0', cwd=cwd)
  647. git('config', 'gc.autodetach', '0', cwd=cwd)
  648. git('config', 'gc.autopacklimit', '0', cwd=cwd)
  649. def get_commit_position(git_path, revision='HEAD'):
  650. """Dumps the 'git' log for a specific revision and parses out the commit
  651. position.
  652. If a commit position metadata key is found, its value will be returned.
  653. """
  654. # TODO(iannucci): Use git-footers for this.
  655. git_log = git('log', '--format=%B', '-n1', revision, cwd=git_path)
  656. footer_map = get_commit_message_footer_map(git_log)
  657. # Search for commit position metadata
  658. value = (footer_map.get(COMMIT_POSITION_FOOTER_KEY) or
  659. footer_map.get(COMMIT_ORIGINAL_POSITION_FOOTER_KEY))
  660. if value:
  661. return value
  662. return None
  663. def parse_got_revision(manifest, got_revision_mapping):
  664. """Translate git gclient revision mapping to build properties."""
  665. properties = {}
  666. manifest = {
  667. # Make sure path always ends with a single slash.
  668. '%s/' % path.rstrip('/'): info
  669. for path, info in manifest.items()
  670. }
  671. for property_name, dir_name in got_revision_mapping.items():
  672. # Make sure dir_name always ends with a single slash.
  673. dir_name = '%s/' % dir_name.rstrip('/')
  674. if dir_name not in manifest:
  675. continue
  676. info = manifest[dir_name]
  677. revision = git('rev-parse', 'HEAD', cwd=dir_name).strip()
  678. commit_position = get_commit_position(dir_name)
  679. properties[property_name] = revision
  680. if commit_position:
  681. properties['%s_cp' % property_name] = commit_position
  682. return properties
  683. def emit_json(out_file, did_run, **kwargs):
  684. """Write run information into a JSON file."""
  685. output = {}
  686. output.update({'did_run': did_run})
  687. output.update(kwargs)
  688. with open(out_file, 'wb') as f:
  689. f.write(json.dumps(output, sort_keys=True).encode('utf-8'))
  690. @_set_git_config
  691. def ensure_checkout(solutions, revisions, first_sln, target_os, target_os_only,
  692. target_cpu, patch_root, patch_refs, gerrit_rebase_patch_ref,
  693. no_fetch_tags, refs, git_cache_dir, cleanup_dir,
  694. gerrit_reset, enforce_fetch, experiments):
  695. # Get a checkout of each solution, without DEPS or hooks.
  696. # Calling git directly because there is no way to run Gclient without
  697. # invoking DEPS.
  698. print('Fetching Git checkout')
  699. synced_solutions = git_checkouts(
  700. solutions, revisions, refs, no_fetch_tags, git_cache_dir, cleanup_dir,
  701. enforce_fetch, experiments)
  702. # Ensure our build/ directory is set up with the correct .gclient file.
  703. gclient_configure(solutions, target_os, target_os_only, target_cpu,
  704. git_cache_dir)
  705. # We want to pass all non-solution revisions into the gclient sync call.
  706. solution_dirs = {sln['name'] for sln in solutions}
  707. gc_revisions = {
  708. dirname: rev for dirname, rev in revisions.items()
  709. if dirname not in solution_dirs}
  710. # Gclient sometimes ignores "unmanaged": "False" in the gclient solution
  711. # if --revision <anything> is passed (for example, for subrepos).
  712. # This forces gclient to always treat solutions deps as unmanaged.
  713. for solution_name in list(solution_dirs):
  714. gc_revisions[solution_name] = 'unmanaged'
  715. # Let gclient do the DEPS syncing.
  716. # The branch-head refspec is a special case because it's possible Chrome
  717. # src, which contains the branch-head refspecs, is DEPSed in.
  718. gclient_sync(
  719. BRANCH_HEADS_REFSPEC in refs,
  720. TAGS_REFSPEC in refs,
  721. gc_revisions,
  722. patch_refs,
  723. gerrit_reset,
  724. gerrit_rebase_patch_ref)
  725. # Now that gclient_sync has finished, we should revert any .DEPS.git so that
  726. # presubmit doesn't complain about it being modified.
  727. if git('ls-files', '.DEPS.git', cwd=first_sln).strip():
  728. git('checkout', 'HEAD', '--', '.DEPS.git', cwd=first_sln)
  729. # Reset the deps_file point in the solutions so that hooks get run properly.
  730. for sln in solutions:
  731. sln['deps_file'] = sln.get('deps_file', 'DEPS').replace('.DEPS.git', 'DEPS')
  732. gclient_configure(solutions, target_os, target_os_only, target_cpu,
  733. git_cache_dir)
  734. return synced_solutions
  735. def parse_revisions(revisions, root):
  736. """Turn a list of revision specs into a nice dictionary.
  737. We will always return a dict with {root: something}. By default if root
  738. is unspecified, or if revisions is [], then revision will be assigned 'HEAD'
  739. """
  740. results = {root.strip('/'): 'HEAD'}
  741. expanded_revisions = []
  742. for revision in revisions:
  743. # Allow rev1,rev2,rev3 format.
  744. # TODO(hinoka): Delete this when webkit switches to recipes.
  745. expanded_revisions.extend(revision.split(','))
  746. for revision in expanded_revisions:
  747. split_revision = revision.split('@', 1)
  748. if len(split_revision) == 1:
  749. # This is just a plain revision, set it as the revision for root.
  750. results[root] = split_revision[0]
  751. else:
  752. # This is an alt_root@revision argument.
  753. current_root, current_rev = split_revision
  754. parsed_root = urlparse.urlparse(current_root)
  755. if parsed_root.scheme in ['http', 'https']:
  756. # We want to normalize git urls into .git urls.
  757. normalized_root = 'https://' + parsed_root.netloc + parsed_root.path
  758. if not normalized_root.endswith('.git'):
  759. normalized_root += '.git'
  760. elif parsed_root.scheme:
  761. print('WARNING: Unrecognized scheme %s, ignoring' % parsed_root.scheme)
  762. continue
  763. else:
  764. # This is probably a local path.
  765. normalized_root = current_root.strip('/')
  766. results[normalized_root] = current_rev
  767. return results
  768. def parse_args():
  769. parse = optparse.OptionParser()
  770. parse.add_option('--experiments',
  771. help='Comma separated list of experiments to enable')
  772. parse.add_option('--patch_root', help='Directory to patch on top of.')
  773. parse.add_option('--patch_ref', dest='patch_refs', action='append', default=[],
  774. help='Git repository & ref to apply, as REPO@REF.')
  775. parse.add_option('--gerrit_no_rebase_patch_ref', action='store_true',
  776. help='Bypass rebase of Gerrit patch ref after checkout.')
  777. parse.add_option('--gerrit_no_reset', action='store_true',
  778. help='Bypass calling reset after applying a gerrit ref.')
  779. parse.add_option('--specs', help='Gcilent spec.')
  780. parse.add_option('--spec-path', help='Path to a Gcilent spec file.')
  781. parse.add_option('--revision_mapping_file',
  782. help=('Path to a json file of the form '
  783. '{"property_name": "path/to/repo/"}'))
  784. parse.add_option('--revision', action='append', default=[],
  785. help='Revision to check out. Can be any form of git ref. '
  786. 'Can prepend root@<rev> to specify which repository, '
  787. 'where root is either a filesystem path or git https '
  788. 'url. To specify Tip of Tree, set rev to HEAD. ')
  789. parse.add_option(
  790. '--no_fetch_tags',
  791. action='store_true',
  792. help=('Don\'t fetch tags from the server for the git checkout. '
  793. 'This can speed up fetch considerably when '
  794. 'there are many tags.'))
  795. parse.add_option(
  796. '--enforce_fetch',
  797. action='store_true',
  798. help=('Enforce a new fetch to refresh the git cache, even if the '
  799. 'solution revision passed in already exists in the current '
  800. 'git cache.'))
  801. parse.add_option('--clobber', action='store_true',
  802. help='Delete checkout first, always')
  803. parse.add_option('--output_json',
  804. help='Output JSON information into a specified file')
  805. parse.add_option('--refs', action='append',
  806. help='Also fetch this refspec for the main solution(s). '
  807. 'Eg. +refs/branch-heads/*')
  808. parse.add_option('--with_branch_heads', action='store_true',
  809. help='Always pass --with_branch_heads to gclient. This '
  810. 'does the same thing as --refs +refs/branch-heads/*')
  811. parse.add_option('--with_tags', action='store_true',
  812. help='Always pass --with_tags to gclient. This '
  813. 'does the same thing as --refs +refs/tags/*')
  814. parse.add_option('--git-cache-dir', help='Path to git cache directory.')
  815. parse.add_option('--cleanup-dir',
  816. help='Path to a cleanup directory that can be used for '
  817. 'deferred file cleanup.')
  818. options, args = parse.parse_args()
  819. if options.spec_path:
  820. if options.specs:
  821. parse.error('At most one of --spec-path and --specs may be specified.')
  822. with open(options.spec_path, 'r') as fd:
  823. options.specs = fd.read()
  824. if not options.output_json:
  825. parse.error('--output_json is required')
  826. if not options.git_cache_dir:
  827. parse.error('--git-cache-dir is required')
  828. if not options.refs:
  829. options.refs = []
  830. if options.with_branch_heads:
  831. options.refs.append(BRANCH_HEADS_REFSPEC)
  832. del options.with_branch_heads
  833. if options.with_tags:
  834. options.refs.append(TAGS_REFSPEC)
  835. del options.with_tags
  836. try:
  837. if not options.revision_mapping_file:
  838. parse.error('--revision_mapping_file is required')
  839. with open(options.revision_mapping_file, 'r') as f:
  840. options.revision_mapping = json.load(f)
  841. except Exception as e:
  842. print(
  843. 'WARNING: Caught exception while parsing revision_mapping*: %s'
  844. % (str(e),))
  845. # Because we print CACHE_DIR out into a .gclient file, and then later run
  846. # eval() on it, backslashes need to be escaped, otherwise "E:\b\build" gets
  847. # parsed as "E:[\x08][\x08]uild".
  848. if sys.platform.startswith('win'):
  849. options.git_cache_dir = options.git_cache_dir.replace('\\', '\\\\')
  850. return options, args
  851. def prepare(options, git_slns, active):
  852. """Prepares the target folder before we checkout."""
  853. dir_names = [sln.get('name') for sln in git_slns if 'name' in sln]
  854. if options.clobber:
  855. ensure_no_checkout(dir_names, options.cleanup_dir)
  856. # Make sure we tell recipes that we didn't run if the script exits here.
  857. emit_json(options.output_json, did_run=active)
  858. total_disk_space, free_disk_space = get_total_disk_space()
  859. total_disk_space_gb = int(total_disk_space / (1024 * 1024 * 1024))
  860. used_disk_space_gb = int((total_disk_space - free_disk_space)
  861. / (1024 * 1024 * 1024))
  862. percent_used = int(used_disk_space_gb * 100 / total_disk_space_gb)
  863. step_text = '[%dGB/%dGB used (%d%%)]' % (used_disk_space_gb,
  864. total_disk_space_gb,
  865. percent_used)
  866. # The first solution is where the primary DEPS file resides.
  867. first_sln = dir_names[0]
  868. # Split all the revision specifications into a nice dict.
  869. print('Revisions: %s' % options.revision)
  870. revisions = parse_revisions(options.revision, first_sln)
  871. print('Fetching Git checkout at %s@%s' % (first_sln, revisions[first_sln]))
  872. return revisions, step_text
  873. def checkout(options, git_slns, specs, revisions, step_text):
  874. print('Using Python version: %s' % (sys.version,))
  875. print('Checking git version...')
  876. ver = git('version').strip()
  877. print('Using %s' % ver)
  878. try:
  879. protocol = git('config', '--get', 'protocol.version')
  880. print('Using git protocol version %s' % protocol)
  881. except SubprocessFailed as e:
  882. print('git protocol version is not specified.')
  883. first_sln = git_slns[0]['name']
  884. dir_names = [sln.get('name') for sln in git_slns if 'name' in sln]
  885. dirty_path = '.dirty_bot_checkout'
  886. if os.path.exists(dirty_path):
  887. ensure_no_checkout(dir_names, options.cleanup_dir)
  888. with open(dirty_path, 'w') as f:
  889. # create file, no content
  890. pass
  891. should_delete_dirty_file = False
  892. synced_solutions = []
  893. experiments = []
  894. if options.experiments:
  895. experiments = options.experiments.split(',')
  896. try:
  897. # Outer try is for catching patch failures and exiting gracefully.
  898. # Inner try is for catching gclient failures and retrying gracefully.
  899. try:
  900. checkout_parameters = dict(
  901. # First, pass in the base of what we want to check out.
  902. solutions=git_slns,
  903. revisions=revisions,
  904. first_sln=first_sln,
  905. # Also, target os variables for gclient.
  906. target_os=specs.get('target_os', []),
  907. target_os_only=specs.get('target_os_only', False),
  908. # Also, target cpu variables for gclient.
  909. target_cpu=specs.get('target_cpu', []),
  910. # Then, pass in information about how to patch.
  911. patch_root=options.patch_root,
  912. patch_refs=options.patch_refs,
  913. gerrit_rebase_patch_ref=not options.gerrit_no_rebase_patch_ref,
  914. # Control how the fetch step will occur.
  915. no_fetch_tags=options.no_fetch_tags,
  916. enforce_fetch=options.enforce_fetch,
  917. # Finally, extra configurations cleanup dir location.
  918. refs=options.refs,
  919. git_cache_dir=options.git_cache_dir,
  920. cleanup_dir=options.cleanup_dir,
  921. gerrit_reset=not options.gerrit_no_reset,
  922. experiments=experiments)
  923. synced_solutions = ensure_checkout(**checkout_parameters)
  924. should_delete_dirty_file = True
  925. except GclientSyncFailed:
  926. print('We failed gclient sync, lets delete the checkout and retry.')
  927. ensure_no_checkout(dir_names, options.cleanup_dir)
  928. synced_solutions = ensure_checkout(**checkout_parameters)
  929. should_delete_dirty_file = True
  930. except PatchFailed as e:
  931. # Tell recipes information such as root, got_revision, etc.
  932. emit_json(options.output_json,
  933. did_run=True,
  934. root=first_sln,
  935. patch_apply_return_code=e.code,
  936. patch_root=options.patch_root,
  937. patch_failure=True,
  938. failed_patch_body=e.output,
  939. step_text='%s PATCH FAILED' % step_text,
  940. fixed_revisions=revisions,
  941. synced_solutions=synced_solutions)
  942. should_delete_dirty_file = True
  943. raise
  944. finally:
  945. if should_delete_dirty_file:
  946. try:
  947. os.remove(dirty_path)
  948. except OSError:
  949. print('Dirty file %s has been removed by a different process.' %
  950. dirty_path)
  951. # Take care of got_revisions outputs.
  952. revision_mapping = GOT_REVISION_MAPPINGS.get(git_slns[0]['url'], {})
  953. if options.revision_mapping:
  954. revision_mapping.update(options.revision_mapping)
  955. # If the repo is not in the default GOT_REVISION_MAPPINGS and no
  956. # revision_mapping were specified on the command line then
  957. # default to setting 'got_revision' based on the first solution.
  958. if not revision_mapping:
  959. revision_mapping['got_revision'] = first_sln
  960. manifest = create_manifest()
  961. got_revisions = parse_got_revision(manifest, revision_mapping)
  962. if not got_revisions:
  963. # TODO(hinoka): We should probably bail out here, but in the interest
  964. # of giving mis-configured bots some time to get fixed use a dummy
  965. # revision here.
  966. got_revisions = { 'got_revision': 'BOT_UPDATE_NO_REV_FOUND' }
  967. #raise Exception('No got_revision(s) found in gclient output')
  968. # Tell recipes information such as root, got_revision, etc.
  969. emit_json(options.output_json,
  970. did_run=True,
  971. root=first_sln,
  972. patch_root=options.patch_root,
  973. step_text=step_text,
  974. fixed_revisions=revisions,
  975. properties=got_revisions,
  976. manifest=manifest,
  977. synced_solutions=synced_solutions)
  978. def print_debug_info():
  979. print("Debugging info:")
  980. debug_params = {
  981. 'CURRENT_DIR': path.abspath(os.getcwd()),
  982. 'THIS_DIR': THIS_DIR,
  983. 'DEPOT_TOOLS_DIR': DEPOT_TOOLS_DIR,
  984. }
  985. for k, v in sorted(debug_params.items()):
  986. print("%s: %r" % (k, v))
  987. def main():
  988. # Get inputs.
  989. options, _ = parse_args()
  990. # Check if this script should activate or not.
  991. active = True
  992. # Print a helpful message to tell developers what's going on with this step.
  993. print_debug_info()
  994. # Parse, manipulate, and print the gclient solutions.
  995. specs = {}
  996. exec(options.specs, specs)
  997. orig_solutions = specs.get('solutions', [])
  998. git_slns = modify_solutions(orig_solutions)
  999. solutions_printer(git_slns)
  1000. try:
  1001. # Dun dun dun, the main part of bot_update.
  1002. # gn creates hardlinks during the build. By default, this makes
  1003. # `git reset` overwrite the sources of the hardlinks, which causes
  1004. # unnecessary rebuilds. (See crbug.com/330461#c13 for an explanation.)
  1005. with git_config_if_not_set('core.trustctime', 'false'):
  1006. revisions, step_text = prepare(options, git_slns, active)
  1007. checkout(options, git_slns, specs, revisions, step_text)
  1008. except PatchFailed as e:
  1009. # Return a specific non-zero exit code for patch failure (because it is
  1010. # a failure), but make it different than other failures to distinguish
  1011. # between infra failures (independent from patch author), and patch
  1012. # failures (that patch author can fix). However, PatchFailure due to
  1013. # download patch failure is still an infra problem.
  1014. if e.code == 3:
  1015. # Patch download problem.
  1016. return 87
  1017. # Genuine patch problem.
  1018. return 88
  1019. if __name__ == '__main__':
  1020. sys.exit(main())