blob: 728482480a960ad536b02eccd008a69b69847475 [file] [log] [blame]
#!/usr/bin/env python
# Copyright (c) 2012 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
""" Creates a zip file in the staging dir with the result of a compile.
It can be sent to other machines for testing.
"""
import csv
import fnmatch
import glob
import json
import optparse
import os
import re
import shutil
import stat
import sys
import tempfile
from common import chromium_utils
import build_directory
import slave_utils
# Mojo JS bindings path relative to the build directory.
MOJO_BINDINGS_PATH = 'gen/mojo/public/js/mojo_bindings.js'
# A list of mojom search paths relative to the build directory.
MOJOM_SEARCH_DIRS = [
'gen/components',
'gen/content/test/data',
'gen/device',
'gen/gpu/ipc/common/',
'gen/media/capture/mojo',
'gen/media/mojo/interfaces/',
'gen/mojo',
'gen/services',
'gen/skia/public/interfaces',
'gen/third_party/WebKit/public',
'gen/third_party/WebKit/Source',
'gen/third_party/blink/public',
'gen/third_party/blink/renderer',
'gen/url/mojo',
'gen/ui',
]
# Layout test data directory relative to the build directory.
LAYOUT_TEST_DATA_DIR = 'gen/layout_test_data'
class StagingError(Exception):
pass
def CopyDebugCRT(build_dir):
# Copy the relevant CRT DLLs to |build_dir|. We copy DLLs from all versions
# of VS installed to make sure we have the correct CRT version, unused DLLs
# should not conflict with the others anyways.
crt_dlls = glob.glob(
'C:\\Program Files (x86)\\Microsoft Visual Studio *\\VC\\redist\\'
'Debug_NonRedist\\x86\\Microsoft.*.DebugCRT\\*.dll'
)
for dll in crt_dlls:
shutil.copy(dll, build_dir)
def GetRecentBuildsByBuildNumber(zip_list, zip_base, zip_ext, prune_limit):
# Build an ordered list of build numbers we have zip files for.
regexp = re.compile(zip_base + '_([0-9]+)(_old)?' + zip_ext)
build_list = []
for x in zip_list:
regexp_match = regexp.match(os.path.basename(x))
if regexp_match:
build_list.append(int(regexp_match.group(1)))
# Since we match both ###.zip and ###_old.zip, bounce through a set and back
# to a list to get an order list of build numbers.
build_list = list(set(build_list))
build_list.sort()
# Only keep the last prune_limit number (that means we could have
# 2*prune_limit due to _old files if someone forced a respin of
# every single one)
saved_build_list = build_list[-prune_limit:]
ordered_asc_by_build_number_list = []
for saved_build in saved_build_list:
recent_name = zip_base + ('_%d' % saved_build) + zip_ext
ordered_asc_by_build_number_list.append(recent_name)
ordered_asc_by_build_number_list.append(
recent_name.replace(zip_ext, '_old' + zip_ext)
)
return ordered_asc_by_build_number_list
def GetRecentBuildsByModificationTime(zip_list, prune_limit):
"""Return the prune_limit most recent builds by modification time."""
# Get the modification times for all of the entries in zip_list.
mtimes_to_files = {}
for zip_file in zip_list:
mtime = int(os.stat(zip_file).st_mtime)
mtimes_to_files.setdefault(mtime, [])
mtimes_to_files[mtime].append(zip_file)
# Order all files in our list by modification time.
mtimes_to_files_keys = mtimes_to_files.keys()
mtimes_to_files_keys.sort()
ordered_asc_by_mtime_list = []
for key in mtimes_to_files_keys:
ordered_asc_by_mtime_list.extend(mtimes_to_files[key])
# Return the most recent 10 builds.
return ordered_asc_by_mtime_list[-prune_limit:]
def FileRegexWhitelist(options):
if chromium_utils.IsWindows() and options.target == 'Release':
# Special case for chrome. Add back all the chrome*.pdb files to the list.
# Also add browser_test*.pdb, ui_tests.pdb and ui_tests.pdb.
# TODO(nsylvain): This should really be defined somewhere else.
return (
r'^(chrome[_.]dll|chrome[_.]exe'
# r'|browser_test.+|unit_tests'
r')\.pdb$'
)
return '$NO_FILTER^'
def FileRegexBlacklist(options):
if chromium_utils.IsWindows():
return r'^.+\.(rc|res|lib|exp|ilk|7z|([pP]recompile\.h\.pch.*))$'
if chromium_utils.IsMac():
# The static libs are just built as intermediate targets, and we don't
# need to pull the dSYMs over to the testers most of the time (except for
# the memory tools).
if options.package_dsym_files:
return r'^.+\.(a)$'
else:
return r'^.+\.(a|dSYM)$'
if chromium_utils.IsLinux():
# object files, archives, and gcc (make build) dependency info.
return r'^.+\.(o|a|d)$'
return '$NO_FILTER^'
def _MojomFiles(build_dir, suffixes):
"""Lists all mojom files that need to be included in the archive.
Args:
build_dir: The build directory.
Returns:
A list of mojom file paths which are relative to the build
directory.
"""
mojom_files = []
for walk_dir in MOJOM_SEARCH_DIRS:
walk_dir = os.path.join(build_dir, walk_dir)
for path, _, files in os.walk(walk_dir):
rel_path = os.path.relpath(path, build_dir)
for suffix in suffixes or []:
for mojom_file in fnmatch.filter(files, '*%s' % suffix):
mojom_files.append(os.path.join(rel_path, mojom_file))
return mojom_files
def _LayoutTestFiles(build_dir):
"""Lists all layout test data files that need to be included in the archive.
Args:
build_dir: The build directory.
Returns:
A list of file paths which are relative to the build directory.
"""
results = []
layout_test_data_dir = os.path.join(build_dir, LAYOUT_TEST_DATA_DIR)
for path, _, files in os.walk(layout_test_data_dir):
rel_path = os.path.relpath(path, build_dir)
for entry in files:
results.append(os.path.join(rel_path, entry))
return results
def WriteRevisionFile(dirname, build_revision):
"""Writes a file containing revision number to given directory.
Replaces the target file in place.
Args:
dirname: Directory to write the file in.
build_revision: Revision number or hash.
Returns: The path of the written file.
"""
try:
# Script only works on python 2.6
# pylint: disable=E1123
tmp_revision_file = tempfile.NamedTemporaryFile(
mode='w', dir=dirname, delete=False
)
tmp_revision_file.write('%s' % build_revision)
tmp_revision_file.close()
chromium_utils.MakeWorldReadable(tmp_revision_file.name)
dest_path = os.path.join(
dirname, chromium_utils.FULL_BUILD_REVISION_FILENAME
)
shutil.move(tmp_revision_file.name, dest_path)
return dest_path
except IOError:
print 'Writing to revision file in %s failed.' % dirname
def MakeUnversionedArchive(
build_dir, staging_dir, zip_file_list, zip_file_name, strip_files=None
):
"""Creates an unversioned full build archive.
Returns the path of the created archive."""
# Prevents having zip_file_list to contain duplicates
zip_file_list = list(set(zip_file_list))
(zip_dir, zip_file) = chromium_utils.MakeZip(
staging_dir,
zip_file_name,
zip_file_list,
build_dir,
raise_error=True,
strip_files=strip_files
)
chromium_utils.RemoveDirectory(zip_dir)
if not os.path.exists(zip_file):
raise StagingError('Failed to make zip package %s' % zip_file)
chromium_utils.MakeWorldReadable(zip_file)
# Report the size of the zip file to help catch when it gets too big and
# can cause bot failures from timeouts during downloads to testers.
zip_size = os.stat(zip_file)[stat.ST_SIZE]
print 'Zip file is %ld bytes' % zip_size
return zip_file
def MakeVersionedArchive(zip_file, file_suffix, options):
"""Takes a file name, e.g. /foo/bar.zip and an extra suffix, e.g. _baz,
and copies (or hardlinks) the file to /foo/bar_baz.zip.
Returns: A tuple containing three elements: the base filename, the extension
and the full versioned filename."""
zip_template = os.path.basename(zip_file)
zip_base, zip_ext = os.path.splitext(zip_template)
# Create a versioned copy of the file.
versioned_file = zip_file.replace(zip_ext, file_suffix + zip_ext)
# Allow for overriding the name of the file based on the given upload url.
if options.use_build_url_name:
new_build_url, new_archive_name = options.build_url.rsplit('/', 1)
if new_archive_name and new_archive_name.endswith('.zip'):
options.build_url = new_build_url
versioned_file = zip_file.replace(zip_template, new_archive_name)
if os.path.exists(versioned_file):
# This file already exists. Maybe we are doing a clobber build at the same
# revision. We can move this file away.
old_file = versioned_file.replace(zip_ext, '_old' + zip_ext)
chromium_utils.MoveFile(versioned_file, old_file)
if chromium_utils.IsWindows():
shutil.copyfile(zip_file, versioned_file)
else:
os.link(zip_file, versioned_file)
chromium_utils.MakeWorldReadable(versioned_file)
print 'Created versioned archive', versioned_file
return (zip_base, zip_ext, versioned_file)
def UploadToGoogleStorage(
versioned_file, revision_file, build_url, gs_acl, gsutil_py_path=None
):
override_gsutil = None
if gsutil_py_path:
override_gsutil = [sys.executable, gsutil_py_path]
if slave_utils.GSUtilCopyFile(versioned_file, build_url, gs_acl=gs_acl,
override_gsutil=override_gsutil):
raise chromium_utils.ExternalError(
'gsutil returned non-zero status when uploading %s to %s!' %
(versioned_file, build_url)
)
print 'Successfully uploaded %s to %s' % (versioned_file, build_url)
# The file showing the latest uploaded revision must be named LAST_CHANGE
# locally since that filename is used in the GS bucket as well.
last_change_file = os.path.join(os.path.dirname(revision_file), 'LAST_CHANGE')
shutil.copy(revision_file, last_change_file)
if slave_utils.GSUtilCopyFile(last_change_file, build_url, gs_acl=gs_acl,
override_gsutil=override_gsutil):
raise chromium_utils.ExternalError(
'gsutil returned non-zero status when uploading %s to %s!' %
(last_change_file, build_url)
)
print 'Successfully uploaded %s to %s' % (last_change_file, build_url)
os.remove(last_change_file)
return '/'.join([build_url, os.path.basename(versioned_file)])
def PruneOldArchives(staging_dir, zip_base, zip_ext, prune_limit):
"""Removes old archives so that we don't exceed disk space."""
zip_list = glob.glob(os.path.join(staging_dir, zip_base + '_*' + zip_ext))
saved_zip_list = GetRecentBuildsByBuildNumber(
zip_list, zip_base, zip_ext, prune_limit
)
saved_mtime_list = GetRecentBuildsByModificationTime(zip_list, prune_limit)
# Prune zip files not matched by the whitelists above.
for zip_file in zip_list:
if zip_file not in saved_zip_list and zip_file not in saved_mtime_list:
print 'Pruning zip %s.' % zip_file
chromium_utils.RemoveFile(staging_dir, zip_file)
class PathMatcher(object):
"""Generates a matcher which can be used to filter file paths."""
def __init__(self, options):
def CommaStrParser(val):
return [f.strip() for f in csv.reader([val]).next()]
self.inclusions = CommaStrParser(options.include_files)
self.exclusions = (
CommaStrParser(options.exclude_files) + chromium_utils.FileExclusions()
)
self.regex_whitelist = FileRegexWhitelist(options)
self.regex_blacklist = FileRegexBlacklist(options)
self.exclude_unmatched = options.exclude_unmatched
self.exclude_extra = options.exclude_extra
self.custom_whitelist = options.whitelist
def __str__(self):
return '\n '.join([
'Zip rules',
'Inclusions: %s' % self.inclusions,
'Exclusions: %s' % self.exclusions,
"Whitelist regex: '%s'" % self.regex_whitelist,
"Blacklist regex: '%s'" % self.regex_blacklist,
'Zip unmatched files: %s' % (not self.exclude_unmatched),
'Exclude extra: %s' % self.exclude_extra,
"Custom Whitelist regex: '%s'" % self.custom_whitelist
])
def Match(self, filename):
for p in self.inclusions:
if fnmatch.fnmatch(filename, p):
return True
for p in self.exclusions:
if fnmatch.fnmatch(filename, p):
return False
if (self.custom_whitelist and re.match(self.custom_whitelist, filename)):
return True
if self.exclude_extra:
return False
if re.match(self.regex_whitelist, filename):
return True
if re.match(self.regex_blacklist, filename):
return False
return not self.exclude_unmatched
def Archive(options):
build_dir = build_directory.GetBuildOutputDirectory(options.src_dir)
build_dir = os.path.abspath(os.path.join(build_dir, options.target))
staging_dir = (
options.staging_dir or slave_utils.GetStagingDir(options.src_dir)
)
if not os.path.exists(staging_dir):
os.makedirs(staging_dir)
chromium_utils.MakeParentDirectoriesWorldReadable(staging_dir)
if not options.build_revision:
build_revision = slave_utils.GetBuildRevisions(
options.src_dir, options.revision_dir
)
else:
build_revision = options.build_revision
unversioned_base_name, version_suffix = slave_utils.GetZipFileNames(
options.builder_group,
options.build_number,
options.parent_build_number,
build_revision,
use_try_buildnumber=(not options.append_deps_patch_sha)
)
# TODO(robertocn): Remove this if no one other than bisect uses it.
if options.append_deps_patch_sha:
deps_sha = os.path.join('src', 'DEPS.sha')
if os.path.exists(deps_sha):
sha = open(deps_sha).read()
version_suffix = '%s_%s' % (version_suffix, sha.strip())
print 'Appending sha of the patch: %s' % sha
else:
print 'DEPS.sha file not found, not appending sha.'
print 'Full Staging in %s' % staging_dir
print 'Build Directory %s' % build_dir
# Include the revision file in tarballs
WriteRevisionFile(build_dir, build_revision)
# Copy the crt files if necessary.
if options.target == 'Debug' and chromium_utils.IsWindows():
CopyDebugCRT(build_dir)
path_filter = PathMatcher(options)
print path_filter
# Build the list of files to archive.
zip_file_list = []
for root, dirs, files in os.walk(build_dir):
def rel(f):
return os.path.relpath(os.path.join(root, f), build_dir)
zip_file_list += [rel(f) for f in files if path_filter.Match(rel(f))]
# For some reason os.walk returns symbolic links that point to directories
# in dirs and not files, so we need to handle any symlinks in dirs as if
# they appeared in files instead.
def islink(f):
return os.path.islink(os.path.join(root, f))
zip_file_list += [
rel(d) for d in dirs if islink(d) and path_filter.Match(rel(d))
]
dirs[:] = [d for d in dirs if not islink(d) and path_filter.Match(rel(d))]
# Include mojo public JS library.
if (os.path.exists(os.path.join(build_dir, MOJO_BINDINGS_PATH))):
print 'Include mojo public JS library: %s' % MOJO_BINDINGS_PATH
zip_file_list.append(MOJO_BINDINGS_PATH)
# TODO(yzshen): Switch layout tests to use files from 'gen/layout_test_data'
# and remove this.
mojom_files = _MojomFiles(build_dir, ['.mojom.js', '_mojom.py'])
print 'Include mojom files: %s' % mojom_files
zip_file_list.extend(mojom_files)
layout_test_data_files = _LayoutTestFiles(build_dir)
print 'Include layout test data: %s' % layout_test_data_files
zip_file_list.extend(layout_test_data_files)
zip_file = MakeUnversionedArchive(
build_dir,
staging_dir,
zip_file_list,
unversioned_base_name,
strip_files=options.strip_files
)
zip_base, zip_ext, versioned_file = MakeVersionedArchive(
zip_file, version_suffix, options
)
prune_limit = 10
if options.build_url.startswith('gs://'):
# Don't keep builds lying around when uploading them to google storage.
prune_limit = 3
PruneOldArchives(staging_dir, zip_base, zip_ext, prune_limit=prune_limit)
# Update the latest revision file in the staging directory
# to allow testers to figure out the latest packaged revision
# without downloading tarballs.
revision_file = WriteRevisionFile(staging_dir, build_revision)
urls = {}
if options.build_url.startswith('gs://'):
zip_url = UploadToGoogleStorage(
versioned_file, revision_file, options.build_url, options.gs_acl,
options.gsutil_py_path
)
storage_url = (
'https://storage.cloud.google.com/%s/%s' %
(options.build_url[len('gs://'):], os.path.basename(versioned_file))
)
urls['storage_url'] = storage_url
else:
staging_path = (
os.path.splitdrive(versioned_file)[1].replace(os.path.sep, '/')
)
zip_url = 'http://' + options.slave_name + staging_path
urls['zip_url'] = zip_url
return urls
def AddOptions(option_parser):
option_parser.add_option(
'--target', help='build target to archive (Debug or Release)'
)
option_parser.add_option(
'--src-dir',
default='src',
help='path to the top-level sources directory'
)
option_parser.add_option('--build-dir', help='ignored')
option_parser.add_option(
'--exclude-files',
default='',
help='Comma separated list of files that should '
'always be excluded from the zip.'
)
option_parser.add_option(
'--include-files',
default='',
help='Comma separated list of files that should '
'always be included in the zip.'
)
option_parser.add_option(
'--whitelist', default='', help='Custom regex whitelist to include files'
)
option_parser.add_option(
'--exclude-extra',
action='store_true',
default=False,
help='Only includes include file list'
'and regex whitelist match provided'
)
option_parser.add_option('--builder-group', help='Name of the builder group.')
option_parser.add_option('--slave-name', help='Name of the buildbot slave.')
option_parser.add_option(
'--build-number', type=int, help='Buildbot build number.'
)
option_parser.add_option(
'--parent-build-number', type=int, help='Buildbot parent build number.'
)
option_parser.add_option(
'--revision-dir',
help='Directory path that shall be used to decide '
'the revision number for the archive, '
'relative to --src-dir'
)
option_parser.add_option(
'--build_revision',
help='The revision the archive should be at. '
'Overrides the revision found on disk.'
)
option_parser.add_option(
'--exclude-unmatched',
action='store_true',
help='Exclude all files not matched by a whitelist'
)
option_parser.add_option(
'--build-url',
default='',
help=(
'Optional URL to which to upload build '
'(overrides build_url build property)'
)
)
option_parser.add_option(
'--use-build-url-name',
action='store_true',
help=(
'Use the filename given in --build-url instead'
'of generating one.'
)
)
option_parser.add_option(
'--package-dsym-files',
action='store_true',
default=False,
help='Add also dSYM files.'
)
option_parser.add_option('--append-deps-patch-sha', action='store_true')
option_parser.add_option('--gs-acl')
option_parser.add_option(
'--strip-files',
default='',
help='Comma separated list of files that should '
'be stripped of symbols in the zip.'
)
option_parser.add_option(
'--json-urls',
help=(
'Path to json file containing uploaded '
'archive urls. If this is omitted then '
'the urls will be emitted as buildbot '
'annotations.'
)
)
option_parser.add_option(
'--staging-dir',
help='Directory to use for staging the archives. '
'Default behavior is to automatically detect '
'slave\'s build directory.'
)
option_parser.add_option(
'--gsutil-py-path', help='Specify path to gsutil.py script.'
)
def main(argv):
option_parser = optparse.OptionParser()
AddOptions(option_parser)
chromium_utils.AddPropertiesOptions(option_parser)
slave_utils_callback = slave_utils.AddOpts(option_parser)
options, args = option_parser.parse_args(argv)
slave_utils_callback(options)
if not options.builder_group:
options.builder_group = options.build_properties.get('builder_group', '')
if not options.slave_name:
options.slave_name = options.build_properties.get('slavename')
if not options.build_number:
options.build_number = options.build_properties.get('buildnumber')
if not options.parent_build_number:
options.parent_build_number = options.build_properties.get(
'parent_buildumber'
)
if not options.target:
options.target = options.build_properties.get('target', 'Release')
if not options.build_url:
options.build_url = options.build_properties.get('build_url', '')
if not options.append_deps_patch_sha:
options.append_deps_patch_sha = options.build_properties.get(
'append_deps_patch_sha'
)
if not options.gs_acl:
options.gs_acl = options.build_properties.get('gs_acl')
if options.strip_files:
options.strip_files = options.strip_files.split(',')
# When option_parser is passed argv as a list, it can return the caller as
# first unknown arg. So throw a warning if we have two or more unknown
# arguments.
if args[1:]:
print 'Warning -- unknown arguments' % args[1:]
urls = Archive(options)
if options.json_urls: # we need to dump json
with open(options.json_urls, 'w') as json_file:
json.dump(urls, json_file)
return 0
if '__main__' == __name__:
sys.exit(main(sys.argv))