OLD | NEW |
| (Empty) |
1 #!/usr/bin/env python | |
2 # Copyright (c) 2012 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 """Commit queue executable. | |
6 | |
7 Reuse Rietveld and the Chromium Try Server to process and automatically commit | |
8 patches. | |
9 """ | |
10 | |
11 import logging | |
12 import logging.handlers | |
13 import optparse | |
14 import os | |
15 import shutil | |
16 import signal | |
17 import socket | |
18 import sys | |
19 import tempfile | |
20 import time | |
21 | |
22 import find_depot_tools # pylint: disable=W0611 | |
23 import checkout | |
24 import fix_encoding | |
25 import rietveld | |
26 import subprocess2 | |
27 | |
28 import async_push | |
29 import cq_alerts | |
30 import creds | |
31 import errors | |
32 import projects | |
33 import sig_handler | |
34 | |
35 | |
36 ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
37 | |
38 | |
39 class OnlyIssueRietveld(rietveld.Rietveld): | |
40 """Returns a single issue for end-to-end in prod testing.""" | |
41 def __init__(self, url, email, password, extra_headers, only_issue): | |
42 super(OnlyIssueRietveld, self).__init__(url, email, password, extra_headers) | |
43 self._only_issue = only_issue | |
44 | |
45 def get_pending_issues(self): | |
46 """If it's set to return a single issue, only return this one.""" | |
47 if self._only_issue: | |
48 return [self._only_issue] | |
49 return [] | |
50 | |
51 def get_issue_properties(self, issue, messages): | |
52 """Hacks the result to fake that the issue has the commit bit set.""" | |
53 data = super(OnlyIssueRietveld, self).get_issue_properties(issue, messages) | |
54 if issue == self._only_issue: | |
55 data['commit'] = True | |
56 return data | |
57 | |
58 def set_flag(self, issue, patchset, flag, value): | |
59 if issue == self._only_issue and flag == 'commit' and value == 'False': | |
60 self._only_issue = None | |
61 return super(OnlyIssueRietveld, self).set_flag(issue, patchset, flag, value) | |
62 | |
63 | |
64 class FakeCheckout(object): | |
65 def __init__(self): | |
66 self.project_path = os.getcwd() | |
67 self.project_name = os.path.basename(self.project_path) | |
68 | |
69 @staticmethod | |
70 def prepare(_revision): | |
71 logging.info('FakeCheckout is syncing') | |
72 return unicode('FAKE') | |
73 | |
74 @staticmethod | |
75 def apply_patch(*_args): | |
76 logging.info('FakeCheckout is applying a patch') | |
77 | |
78 @staticmethod | |
79 def commit(*_args): | |
80 logging.info('FakeCheckout is committing patch') | |
81 return 'FAKED' | |
82 | |
83 @staticmethod | |
84 def get_settings(_key): | |
85 return None | |
86 | |
87 @staticmethod | |
88 def revisions(*_args): | |
89 return None | |
90 | |
91 | |
92 def AlertOnUncleanCheckout(): | |
93 """Sends an alert if the cq is running live with local edits.""" | |
94 diff = subprocess2.capture(['gclient', 'diff'], cwd=ROOT_DIR).strip() | |
95 if diff: | |
96 cq_alerts.SendAlert( | |
97 'CQ running with local diff.', | |
98 ('Ruh-roh! Commit queue was started with an unclean checkout.\n\n' | |
99 '$ gclient diff\n%s' % diff)) | |
100 | |
101 | |
102 def SetupLogging(options): | |
103 """Configures the logging module.""" | |
104 logging.getLogger().setLevel(logging.DEBUG) | |
105 if options.verbose: | |
106 level = logging.DEBUG | |
107 else: | |
108 level = logging.INFO | |
109 console_logging = logging.StreamHandler() | |
110 console_logging.setFormatter(logging.Formatter( | |
111 '%(asctime)s %(levelname)7s %(message)s')) | |
112 console_logging.setLevel(level) | |
113 logging.getLogger().addHandler(console_logging) | |
114 | |
115 log_directory = 'logs-' + options.project | |
116 if not os.path.exists(log_directory): | |
117 os.mkdir(log_directory) | |
118 | |
119 logging_rotating_file = logging.handlers.RotatingFileHandler( | |
120 filename=os.path.join(log_directory, 'commit_queue.log'), | |
121 maxBytes= 10*1024*1024, | |
122 backupCount=50) | |
123 logging_rotating_file.setLevel(logging.DEBUG) | |
124 logging_rotating_file.setFormatter(logging.Formatter( | |
125 '%(asctime)s %(levelname)-8s %(module)15s(%(lineno)4d): %(message)s')) | |
126 logging.getLogger().addHandler(logging_rotating_file) | |
127 | |
128 | |
129 class SignalInterrupt(Exception): | |
130 """Exception that indicates being interrupted by a caught signal.""" | |
131 | |
132 def __init__(self, signal_set=None, *args, **kwargs): | |
133 super(SignalInterrupt, self).__init__(*args, **kwargs) | |
134 self.signal_set = signal_set | |
135 | |
136 | |
137 def SaveDatabaseCopyForDebugging(db_path): | |
138 """Saves database file for debugging. Returns name of the saved file.""" | |
139 with tempfile.NamedTemporaryFile( | |
140 dir=os.path.dirname(db_path), | |
141 prefix='db.debug.', | |
142 suffix='.json', | |
143 delete=False) as tmp_file: | |
144 with open(db_path) as db_file: | |
145 shutil.copyfileobj(db_file, tmp_file) | |
146 return tmp_file.name | |
147 | |
148 | |
149 def main(): | |
150 # Set a default timeout for sockets. This is critical when talking to remote | |
151 # services like AppEngine and buildbot. | |
152 # TODO(phajdan.jr): This used to be 70s. Investigate lowering it again. | |
153 socket.setdefaulttimeout(60.0 * 15) | |
154 | |
155 parser = optparse.OptionParser( | |
156 description=sys.modules['__main__'].__doc__) | |
157 project_choices = projects.supported_projects() | |
158 parser.add_option('-v', '--verbose', action='store_true') | |
159 parser.add_option( | |
160 '--no-dry-run', | |
161 action='store_false', | |
162 dest='dry_run', | |
163 default=True, | |
164 help='Run for real instead of dry-run mode which is the default. ' | |
165 'WARNING: while the CQ won\'t touch rietveld in dry-run mode, the ' | |
166 'Try Server will. So it is recommended to use --only-issue') | |
167 parser.add_option( | |
168 '--only-issue', | |
169 type='int', | |
170 help='Limits to a single issue. Useful for live testing; WARNING: it ' | |
171 'will fake that the issue has the CQ bit set, so only try with an ' | |
172 'issue you don\'t mind about.') | |
173 parser.add_option( | |
174 '--fake', | |
175 action='store_true', | |
176 help='Run with a fake checkout to speed up testing') | |
177 parser.add_option( | |
178 '--no-try', | |
179 action='store_true', | |
180 help='Don\'t send try jobs.') | |
181 parser.add_option( | |
182 '-p', | |
183 '--poll-interval', | |
184 type='int', | |
185 default=10, | |
186 help='Minimum delay between each polling loop, default: %default') | |
187 parser.add_option( | |
188 '--query-only', | |
189 action='store_true', | |
190 help='Return internal state') | |
191 parser.add_option( | |
192 '--project', | |
193 choices=project_choices, | |
194 help='Project to run the commit queue against: %s' % | |
195 ', '.join(project_choices)) | |
196 parser.add_option( | |
197 '-u', | |
198 '--user', | |
199 default='commit-bot@chromium.org', | |
200 help='User to use instead of %default') | |
201 parser.add_option( | |
202 '--rietveld', | |
203 default='https://codereview.chromium.org', | |
204 help='Rietveld server to use instead of %default') | |
205 options, args = parser.parse_args() | |
206 if args: | |
207 parser.error('Unsupported args: %s' % args) | |
208 if not options.project: | |
209 parser.error('Need to pass a valid project to --project.\nOptions are: %s' % | |
210 ', '.join(project_choices)) | |
211 | |
212 SetupLogging(options) | |
213 try: | |
214 work_dir = os.path.join(ROOT_DIR, 'workdir') | |
215 # Use our specific subversion config. | |
216 checkout.SvnMixIn.svn_config = checkout.SvnConfig( | |
217 os.path.join(ROOT_DIR, 'subversion_config')) | |
218 | |
219 url = options.rietveld | |
220 gaia_creds = creds.Credentials(os.path.join(work_dir, '.gaia_pwd')) | |
221 if options.dry_run: | |
222 logging.debug('Dry run - skipping SCM check.') | |
223 if options.only_issue: | |
224 parser.error('--only-issue is not supported with dry run') | |
225 else: | |
226 print('Using read-only Rietveld') | |
227 # Make sure rietveld is not modified. Pass empty email and | |
228 # password to bypass authentication; this additionally | |
229 # guarantees rietveld will not allow any changes. | |
230 rietveld_obj = rietveld.ReadOnlyRietveld(url, email='', password='') | |
231 else: | |
232 AlertOnUncleanCheckout() | |
233 print('WARNING: The Commit Queue is going to commit stuff') | |
234 if options.only_issue: | |
235 print('Using only issue %d' % options.only_issue) | |
236 rietveld_obj = OnlyIssueRietveld( | |
237 url, | |
238 options.user, | |
239 gaia_creds.get(options.user), | |
240 None, | |
241 options.only_issue) | |
242 else: | |
243 rietveld_obj = rietveld.Rietveld( | |
244 url, | |
245 options.user, | |
246 gaia_creds.get(options.user), | |
247 None) | |
248 | |
249 pc = projects.load_project( | |
250 options.project, | |
251 options.user, | |
252 work_dir, | |
253 rietveld_obj, | |
254 options.no_try) | |
255 | |
256 if options.dry_run: | |
257 if options.fake: | |
258 # Disable the checkout. | |
259 print 'Using no checkout' | |
260 pc.context.checkout = FakeCheckout() | |
261 else: | |
262 print 'Using read-only checkout' | |
263 pc.context.checkout = checkout.ReadOnlyCheckout(pc.context.checkout) | |
264 # Save pushed events on disk. | |
265 print 'Using read-only chromium-status interface' | |
266 pc.context.status = async_push.AsyncPushStore() | |
267 | |
268 landmine_path = os.path.join(work_dir, | |
269 pc.context.checkout.project_name + '.landmine') | |
270 db_path = os.path.join(work_dir, pc.context.checkout.project_name + '.json') | |
271 if os.path.isfile(db_path): | |
272 if os.path.isfile(landmine_path): | |
273 debugging_path = SaveDatabaseCopyForDebugging(db_path) | |
274 os.remove(db_path) | |
275 logging.warning(('Deleting database because previous shutdown ' | |
276 'was unclean. The copy of the database is saved ' | |
277 'as %s.') % debugging_path) | |
278 else: | |
279 try: | |
280 pc.load(db_path) | |
281 except ValueError as e: | |
282 debugging_path = SaveDatabaseCopyForDebugging(db_path) | |
283 os.remove(db_path) | |
284 logging.warning(('Failed to parse database (%r), deleting it. ' | |
285 'The copy of the database is saved as %s.') % | |
286 (e, debugging_path)) | |
287 raise e | |
288 | |
289 # Create a file to indicate unclean shutdown. | |
290 with open(landmine_path, 'w'): | |
291 pass | |
292 | |
293 sig_handler.installHandlers( | |
294 signal.SIGINT, | |
295 signal.SIGHUP | |
296 ) | |
297 | |
298 # Sync every 5 minutes. | |
299 SYNC_DELAY = 5*60 | |
300 try: | |
301 if options.query_only: | |
302 pc.look_for_new_pending_commit() | |
303 pc.update_status() | |
304 print(str(pc.queue)) | |
305 os.remove(landmine_path) | |
306 return 0 | |
307 | |
308 now = time.time() | |
309 next_loop = now + options.poll_interval | |
310 # First sync is on second loop. | |
311 next_sync = now + options.poll_interval * 2 | |
312 while True: | |
313 # In theory, we would gain in performance to parallelize these tasks. In | |
314 # practice I'm not sure it matters. | |
315 pc.look_for_new_pending_commit() | |
316 pc.process_new_pending_commit() | |
317 pc.update_status() | |
318 pc.scan_results() | |
319 if sig_handler.getTriggeredSignals(): | |
320 raise SignalInterrupt(signal_set=sig_handler.getTriggeredSignals()) | |
321 # Save the db at each loop. The db can easily be in the 1mb range so | |
322 # it's slowing down the CQ a tad but it in the 100ms range even for that | |
323 # size. | |
324 pc.save(db_path) | |
325 | |
326 # More than a second to wait and due to sync. | |
327 now = time.time() | |
328 if (next_loop - now) >= 1 and (next_sync - now) <= 0: | |
329 if sys.stdout.isatty(): | |
330 sys.stdout.write('Syncing while waiting \r') | |
331 sys.stdout.flush() | |
332 try: | |
333 pc.context.checkout.prepare(None) | |
334 except subprocess2.CalledProcessError as e: | |
335 # Don't crash, most of the time it's the svn server that is dead. | |
336 # How fun. Send a stack trace to annoy the maintainer. | |
337 errors.send_stack(e) | |
338 next_sync = time.time() + SYNC_DELAY | |
339 | |
340 now = time.time() | |
341 next_loop = max(now, next_loop) | |
342 while True: | |
343 # Abort if any signals are set | |
344 if sig_handler.getTriggeredSignals(): | |
345 raise SignalInterrupt(signal_set=sig_handler.getTriggeredSignals()) | |
346 delay = next_loop - now | |
347 if delay <= 0: | |
348 break | |
349 if sys.stdout.isatty(): | |
350 sys.stdout.write('Sleeping for %1.1f seconds \r' % delay) | |
351 sys.stdout.flush() | |
352 time.sleep(min(delay, 0.1)) | |
353 now = time.time() | |
354 if sys.stdout.isatty(): | |
355 sys.stdout.write('Running (please do not interrupt) \r') | |
356 sys.stdout.flush() | |
357 next_loop = time.time() + options.poll_interval | |
358 except: # Catch all fatal exit conditions. | |
359 logging.exception('CQ loop terminating') | |
360 raise | |
361 finally: | |
362 logging.warning('Saving db...') | |
363 pc.save(db_path) | |
364 pc.close() | |
365 logging.warning('db save successful.') | |
366 except SignalInterrupt: | |
367 # This is considered a clean shutdown: we only throw this exception | |
368 # from selected places in the code where the database should be | |
369 # in a known and consistent state. | |
370 os.remove(landmine_path) | |
371 | |
372 print 'Bye bye (SignalInterrupt)' | |
373 # 23 is an arbitrary value to signal loop.sh that it must stop looping. | |
374 return 23 | |
375 except KeyboardInterrupt: | |
376 # This is actually an unclean shutdown. Do not remove the landmine file. | |
377 # One example of this is user hitting ctrl-c twice at an arbitrary point | |
378 # inside the CQ loop. There are no guarantees about consistent state | |
379 # of the database then. | |
380 | |
381 print 'Bye bye (KeyboardInterrupt - this is considered unclean shutdown)' | |
382 # 23 is an arbitrary value to signal loop.sh that it must stop looping. | |
383 return 23 | |
384 except errors.ConfigurationError as e: | |
385 parser.error(str(e)) | |
386 return 1 | |
387 | |
388 # CQ generally doesn't exit by itself, but if we ever get here, it looks | |
389 # like a clean shutdown so remove the landmine file. | |
390 # TODO(phajdan.jr): Do we ever get here? | |
391 os.remove(landmine_path) | |
392 return 0 | |
393 | |
394 | |
395 if __name__ == '__main__': | |
396 fix_encoding.fix_encoding() | |
397 sys.exit(main()) | |
OLD | NEW |