OLD | NEW |
---|---|
1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
2 # Copyright 2014 The Chromium Authors. All rights reserved. | 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 | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 | 5 |
6 """A utility script for downloading versioned Syzygy binaries.""" | 6 """A utility script for downloading versioned Syzygy binaries.""" |
7 | 7 |
8 import cStringIO | |
9 import hashlib | 8 import hashlib |
10 import errno | 9 import errno |
11 import json | 10 import json |
12 import logging | 11 import logging |
13 import optparse | 12 import optparse |
14 import os | 13 import os |
15 import re | 14 import re |
16 import shutil | 15 import shutil |
17 import stat | 16 import stat |
18 import sys | 17 import sys |
19 import subprocess | 18 import subprocess |
20 import urllib2 | 19 import tempfile |
20 import time | |
21 import zipfile | 21 import zipfile |
22 | 22 |
23 | 23 |
24 _LOGGER = logging.getLogger(os.path.basename(__file__)) | 24 _LOGGER = logging.getLogger(os.path.basename(__file__)) |
25 | 25 |
26 # The URL where official builds are archived. | 26 # The relative path where official builds are archived in their GS bucket. |
27 _SYZYGY_ARCHIVE_URL = ('https://syzygy-archive.commondatastorage.googleapis.com' | 27 _SYZYGY_ARCHIVE_PATH = ('/builds/official/%(revision)s') |
28 '/builds/official/%(revision)s') | |
29 | 28 |
30 # A JSON file containing the state of the download directory. If this file and | 29 # A JSON file containing the state of the download directory. If this file and |
31 # directory state do not agree, then the binaries will be downloaded and | 30 # directory state do not agree, then the binaries will be downloaded and |
32 # installed again. | 31 # installed again. |
33 _STATE = '.state' | 32 _STATE = '.state' |
34 | 33 |
35 # This matches an integer (an SVN revision number) or a SHA1 value (a GIT hash). | 34 # This matches an integer (an SVN revision number) or a SHA1 value (a GIT hash). |
36 # The archive exclusively uses lowercase GIT hashes. | 35 # The archive exclusively uses lowercase GIT hashes. |
37 _REVISION_RE = re.compile('^(?:\d+|[a-f0-9]{40})$') | 36 _REVISION_RE = re.compile('^(?:\d+|[a-f0-9]{40})$') |
38 | 37 |
39 # This matches an MD5 hash. | 38 # This matches an MD5 hash. |
40 _MD5_RE = re.compile('^[a-f0-9]{32}$') | 39 _MD5_RE = re.compile('^[a-f0-9]{32}$') |
41 | 40 |
42 # List of reources to be downloaded and installed. These are tuples with the | 41 # List of reources to be downloaded and installed. These are tuples with the |
43 # following format: | 42 # following format: |
44 # (basename, logging name, relative installation path, extraction filter) | 43 # (basename, logging name, relative installation path, extraction filter) |
45 _RESOURCES = [ | 44 _RESOURCES = [ |
46 ('benchmark.zip', 'benchmark', '', None), | 45 ('benchmark.zip', 'benchmark', '', None), |
47 ('binaries.zip', 'binaries', 'exe', None), | 46 ('binaries.zip', 'binaries', 'exe', None), |
48 ('symbols.zip', 'symbols', 'exe', | 47 ('symbols.zip', 'symbols', 'exe', |
49 lambda x: x.filename.endswith('.dll.pdb'))] | 48 lambda x: x.filename.endswith('.dll.pdb'))] |
50 | 49 |
51 | 50 |
52 def _Shell(*cmd, **kw): | |
53 """Runs |cmd|, returns the results from Popen(cmd).communicate().""" | |
54 _LOGGER.debug('Executing %s.', cmd) | |
55 prog = subprocess.Popen(cmd, shell=True, **kw) | |
56 | |
57 stdout, stderr = prog.communicate() | |
58 if prog.returncode != 0: | |
59 raise RuntimeError('Command "%s" returned %d.' % (cmd, prog.returncode)) | |
60 return (stdout, stderr) | |
61 | |
62 | |
63 def _LoadState(output_dir): | 51 def _LoadState(output_dir): |
64 """Loads the contents of the state file for a given |output_dir|, returning | 52 """Loads the contents of the state file for a given |output_dir|, returning |
65 None if it doesn't exist. | 53 None if it doesn't exist. |
66 """ | 54 """ |
67 path = os.path.join(output_dir, _STATE) | 55 path = os.path.join(output_dir, _STATE) |
68 if not os.path.exists(path): | 56 if not os.path.exists(path): |
69 _LOGGER.debug('No state file found.') | 57 _LOGGER.debug('No state file found.') |
70 return None | 58 return None |
71 with open(path, 'rb') as f: | 59 with open(path, 'rb') as f: |
72 _LOGGER.debug('Reading state file: %s', path) | 60 _LOGGER.debug('Reading state file: %s', path) |
(...skipping 168 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
241 dirs = sorted(dirs.keys(), key=lambda x: len(x), reverse=True) | 229 dirs = sorted(dirs.keys(), key=lambda x: len(x), reverse=True) |
242 for p in dirs: | 230 for p in dirs: |
243 if os.path.exists(p) and _DirIsEmpty(p): | 231 if os.path.exists(p) and _DirIsEmpty(p): |
244 _LOGGER.debug('Deleting empty directory "%s".', p) | 232 _LOGGER.debug('Deleting empty directory "%s".', p) |
245 if not dry_run: | 233 if not dry_run: |
246 _RmTree(p) | 234 _RmTree(p) |
247 | 235 |
248 return deleted | 236 return deleted |
249 | 237 |
250 | 238 |
251 def _Download(url): | 239 def _FindGsUtil(): |
252 """Downloads the given URL and returns the contents as a string.""" | 240 """Looks for depot_tools and returns the absolute path to gsutil.py.""" |
253 response = urllib2.urlopen(url) | 241 for path in os.environ['PATH'].split(';'): |
Nico
2015/07/14 17:58:41
os.pathsep instead of ;
chrisha
2015/07/14 18:05:06
Done.
| |
254 if response.code != 200: | 242 path = os.path.abspath(path) |
255 raise RuntimeError('Failed to download "%s".' % url) | 243 git_cl = os.path.join(path, 'git_cl.py') |
256 return response.read() | 244 gs_util = os.path.join(path, 'gsutil.py') |
245 if os.path.exists(git_cl) and os.path.exists(gs_util): | |
246 return gs_util | |
247 return None | |
248 | |
249 | |
250 def _GsUtil(*cmd): | |
251 """Runs the given command in gsutil with exponential backoff and retries.""" | |
252 gs_util = _FindGsUtil() | |
253 cmd = [sys.executable, gs_util] + list(cmd) | |
254 | |
255 retries = 3 | |
256 timeout = 4 # Seconds. | |
257 while True: | |
258 _LOGGER.debug('Running %s', cmd) | |
259 prog = subprocess.Popen(cmd, shell=True) | |
260 prog.communicate() | |
261 | |
262 # Stop retrying on success. | |
263 if prog.returncode == 0: | |
264 return | |
265 | |
266 # Raise a permanent failure if retries have been exhausted. | |
267 if retries == 0: | |
268 raise RuntimeError('Command "%s" returned %d.' % (cmd, prog.returncode)) | |
269 | |
270 _LOGGER.debug('Sleeping %d seconds and trying again.', timeout) | |
271 time.sleep(timeout) | |
272 retries -= 1 | |
273 timeout *= 2 | |
274 | |
275 | |
276 def _Download(resource): | |
277 """Downloads the given GS resource to a temporary file, returning its path.""" | |
278 tmp = tempfile.mkstemp(suffix='syzygy_archive') | |
279 os.close(tmp[0]) | |
280 url = 'gs://syzygy-archive' + resource | |
281 _GsUtil('cp', url, tmp[1]) | |
282 return tmp[1] | |
257 | 283 |
258 | 284 |
259 def _InstallBinaries(options, deleted={}): | 285 def _InstallBinaries(options, deleted={}): |
260 """Installs Syzygy binaries. This assumes that the output directory has | 286 """Installs Syzygy binaries. This assumes that the output directory has |
261 already been cleaned, as it will refuse to overwrite existing files.""" | 287 already been cleaned, as it will refuse to overwrite existing files.""" |
262 contents = {} | 288 contents = {} |
263 state = { 'revision': options.revision, 'contents': contents } | 289 state = { 'revision': options.revision, 'contents': contents } |
264 archive_url = _SYZYGY_ARCHIVE_URL % { 'revision': options.revision } | 290 archive_path = _SYZYGY_ARCHIVE_PATH % { 'revision': options.revision } |
265 if options.resources: | 291 if options.resources: |
266 resources = [(resource, resource, '', None) | 292 resources = [(resource, resource, '', None) |
267 for resource in options.resources] | 293 for resource in options.resources] |
268 else: | 294 else: |
269 resources = _RESOURCES | 295 resources = _RESOURCES |
270 for (base, name, subdir, filt) in resources: | 296 for (base, name, subdir, filt) in resources: |
271 # Create the output directory if it doesn't exist. | 297 # Create the output directory if it doesn't exist. |
272 fulldir = os.path.join(options.output_dir, subdir) | 298 fulldir = os.path.join(options.output_dir, subdir) |
273 if os.path.isfile(fulldir): | 299 if os.path.isfile(fulldir): |
274 raise Exception('File exists where a directory needs to be created: %s' % | 300 raise Exception('File exists where a directory needs to be created: %s' % |
275 fulldir) | 301 fulldir) |
276 if not os.path.exists(fulldir): | 302 if not os.path.exists(fulldir): |
277 _LOGGER.debug('Creating directory: %s', fulldir) | 303 _LOGGER.debug('Creating directory: %s', fulldir) |
278 if not options.dry_run: | 304 if not options.dry_run: |
279 os.makedirs(fulldir) | 305 os.makedirs(fulldir) |
280 | 306 |
281 # Download the archive. | 307 # Download and read the archive. |
282 url = archive_url + '/' + base | 308 resource = archive_path + '/' + base |
283 _LOGGER.debug('Retrieving %s archive at "%s".', name, url) | 309 _LOGGER.debug('Retrieving %s archive at "%s".', name, resource) |
284 data = _Download(url) | 310 path = _Download(resource) |
285 | 311 |
286 _LOGGER.debug('Unzipping %s archive.', name) | 312 _LOGGER.debug('Unzipping %s archive.', name) |
287 archive = zipfile.ZipFile(cStringIO.StringIO(data)) | 313 archive = zipfile.ZipFile(open(path, 'rb')) |
Nico
2015/07/14 17:58:41
nit: `with open(path, 'rb') as path:`, then you do
chrisha
2015/07/14 18:05:06
Done.
| |
288 for entry in archive.infolist(): | 314 for entry in archive.infolist(): |
289 if not filt or filt(entry): | 315 if not filt or filt(entry): |
290 fullpath = os.path.normpath(os.path.join(fulldir, entry.filename)) | 316 fullpath = os.path.normpath(os.path.join(fulldir, entry.filename)) |
291 relpath = os.path.relpath(fullpath, options.output_dir) | 317 relpath = os.path.relpath(fullpath, options.output_dir) |
292 if os.path.exists(fullpath): | 318 if os.path.exists(fullpath): |
293 # If in a dry-run take into account the fact that the file *would* | 319 # If in a dry-run take into account the fact that the file *would* |
294 # have been deleted. | 320 # have been deleted. |
295 if options.dry_run and relpath in deleted: | 321 if options.dry_run and relpath in deleted: |
296 pass | 322 pass |
297 else: | 323 else: |
298 raise Exception('Path already exists: %s' % fullpath) | 324 raise Exception('Path already exists: %s' % fullpath) |
299 | 325 |
300 # Extract the file and update the state dictionary. | 326 # Extract the file and update the state dictionary. |
301 _LOGGER.debug('Extracting "%s".', fullpath) | 327 _LOGGER.debug('Extracting "%s".', fullpath) |
302 if not options.dry_run: | 328 if not options.dry_run: |
303 archive.extract(entry.filename, fulldir) | 329 archive.extract(entry.filename, fulldir) |
304 md5 = _Md5(fullpath) | 330 md5 = _Md5(fullpath) |
305 contents[relpath] = md5 | 331 contents[relpath] = md5 |
306 if sys.platform == 'cygwin': | 332 if sys.platform == 'cygwin': |
307 os.chmod(fullpath, os.stat(fullpath).st_mode | stat.S_IXUSR) | 333 os.chmod(fullpath, os.stat(fullpath).st_mode | stat.S_IXUSR) |
308 | 334 |
335 _LOGGER.debug('Removing temporary file "%s".', path) | |
336 archive.close() | |
337 os.remove(path) | |
338 | |
309 return state | 339 return state |
310 | 340 |
311 | 341 |
312 def _ParseCommandLine(): | 342 def _ParseCommandLine(): |
313 """Parses the command-line and returns an options structure.""" | 343 """Parses the command-line and returns an options structure.""" |
314 option_parser = optparse.OptionParser() | 344 option_parser = optparse.OptionParser() |
315 option_parser.add_option('--dry-run', action='store_true', default=False, | 345 option_parser.add_option('--dry-run', action='store_true', default=False, |
316 help='If true then will simply list actions that would be performed.') | 346 help='If true then will simply list actions that would be performed.') |
317 option_parser.add_option('--force', action='store_true', default=False, | 347 option_parser.add_option('--force', action='store_true', default=False, |
318 help='Force an installation even if the binaries are up to date.') | 348 help='Force an installation even if the binaries are up to date.') |
349 option_parser.add_option('--no-cleanup', action='store_true', default=False, | |
350 help='Allow installation on non-Windows platforms, and skip the forced ' | |
351 'cleanup step.') | |
319 option_parser.add_option('--output-dir', type='string', | 352 option_parser.add_option('--output-dir', type='string', |
320 help='The path where the binaries will be replaced. Existing binaries ' | 353 help='The path where the binaries will be replaced. Existing binaries ' |
321 'will only be overwritten if not up to date.') | 354 'will only be overwritten if not up to date.') |
322 option_parser.add_option('--overwrite', action='store_true', default=False, | 355 option_parser.add_option('--overwrite', action='store_true', default=False, |
323 help='If specified then the installation will happily delete and rewrite ' | 356 help='If specified then the installation will happily delete and rewrite ' |
324 'the entire output directory, blasting any local changes.') | 357 'the entire output directory, blasting any local changes.') |
325 option_parser.add_option('--revision', type='string', | 358 option_parser.add_option('--revision', type='string', |
326 help='The SVN revision or GIT hash associated with the required version.') | 359 help='The SVN revision or GIT hash associated with the required version.') |
327 option_parser.add_option('--revision-file', type='string', | 360 option_parser.add_option('--revision-file', type='string', |
328 help='A text file containing an SVN revision or GIT hash.') | 361 help='A text file containing an SVN revision or GIT hash.') |
(...skipping 72 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
401 options = _ParseCommandLine() | 434 options = _ParseCommandLine() |
402 | 435 |
403 if options.dry_run: | 436 if options.dry_run: |
404 _LOGGER.debug('Performing a dry-run.') | 437 _LOGGER.debug('Performing a dry-run.') |
405 | 438 |
406 # We only care about Windows platforms, as the Syzygy binaries aren't used | 439 # We only care about Windows platforms, as the Syzygy binaries aren't used |
407 # elsewhere. However, there was a short period of time where this script | 440 # elsewhere. However, there was a short period of time where this script |
408 # wasn't gated on OS types, and those OSes downloaded and installed binaries. | 441 # wasn't gated on OS types, and those OSes downloaded and installed binaries. |
409 # This will cleanup orphaned files on those operating systems. | 442 # This will cleanup orphaned files on those operating systems. |
410 if sys.platform not in ('win32', 'cygwin'): | 443 if sys.platform not in ('win32', 'cygwin'): |
411 return _RemoveOrphanedFiles(options) | 444 if options.no_cleanup: |
445 _LOGGER.debug('Skipping usual cleanup for non-Windows platforms.') | |
446 else: | |
447 return _RemoveOrphanedFiles(options) | |
412 | 448 |
413 # Load the current installation state, and validate it against the | 449 # Load the current installation state, and validate it against the |
414 # requested installation. | 450 # requested installation. |
415 state, is_consistent = _GetCurrentState(options.revision, options.output_dir) | 451 state, is_consistent = _GetCurrentState(options.revision, options.output_dir) |
416 | 452 |
417 # Decide whether or not an install is necessary. | 453 # Decide whether or not an install is necessary. |
418 if options.force: | 454 if options.force: |
419 _LOGGER.debug('Forcing reinstall of binaries.') | 455 _LOGGER.debug('Forcing reinstall of binaries.') |
420 elif is_consistent: | 456 elif is_consistent: |
421 # Avoid doing any work if the contents of the directory are consistent. | 457 # Avoid doing any work if the contents of the directory are consistent. |
(...skipping 20 matching lines...) Expand all Loading... | |
442 # Install the new binaries. In a dry-run this will actually download the | 478 # Install the new binaries. In a dry-run this will actually download the |
443 # archives, but it won't write anything to disk. | 479 # archives, but it won't write anything to disk. |
444 state = _InstallBinaries(options, deleted) | 480 state = _InstallBinaries(options, deleted) |
445 | 481 |
446 # Build and save the state for the directory. | 482 # Build and save the state for the directory. |
447 _SaveState(options.output_dir, state, options.dry_run) | 483 _SaveState(options.output_dir, state, options.dry_run) |
448 | 484 |
449 | 485 |
450 if __name__ == '__main__': | 486 if __name__ == '__main__': |
451 main() | 487 main() |
OLD | NEW |