Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 #!/usr/bin/env python | |
| 1 # Copyright (c) 2012 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| 2 # 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 |
| 3 # found in the LICENSE file. | 4 # found in the LICENSE file. |
| 4 | 5 |
| 5 """File related utility functions. | 6 """Reads a manifest, creates a tree of hardlinks and runs the test. |
| 6 | 7 |
| 7 Creates a tree of hardlinks, symlinks or copy the inputs files. Calculate files | 8 Keeps a local cache. |
| 8 hash. | |
| 9 """ | 9 """ |
| 10 | 10 |
| 11 import ctypes | 11 import ctypes |
| 12 import hashlib | 12 import json |
| 13 import logging | 13 import logging |
| 14 import optparse | |
| 14 import os | 15 import os |
| 16 import re | |
| 15 import shutil | 17 import shutil |
| 16 import stat | 18 import subprocess |
| 17 import sys | 19 import sys |
| 20 import tempfile | |
| 18 import time | 21 import time |
| 22 import urllib | |
| 19 | 23 |
| 20 | 24 |
| 21 # Types of action accepted by recreate_tree(). | 25 # Types of action accepted by recreate_tree(). |
| 22 HARDLINK, SYMLINK, COPY = range(4)[1:] | 26 HARDLINK, SYMLINK, COPY = range(4)[1:] |
| 23 | 27 |
| 24 | 28 |
| 25 class MappingError(OSError): | 29 class MappingError(OSError): |
| 26 """Failed to recreate the tree.""" | 30 """Failed to recreate the tree.""" |
| 27 pass | 31 pass |
| 28 | 32 |
| 29 | 33 |
| 30 def os_link(source, link_name): | 34 def os_link(source, link_name): |
| 31 """Add support for os.link() on Windows.""" | 35 """Add support for os.link() on Windows.""" |
| 32 if sys.platform == 'win32': | 36 if sys.platform == 'win32': |
| 33 if not ctypes.windll.kernel32.CreateHardLinkW( | 37 if not ctypes.windll.kernel32.CreateHardLinkW( |
| 34 unicode(link_name), unicode(source), 0): | 38 unicode(link_name), unicode(source), 0): |
| 35 raise OSError() | 39 raise OSError() |
| 36 else: | 40 else: |
| 37 os.link(source, link_name) | 41 os.link(source, link_name) |
| 38 | 42 |
| 39 | 43 |
| 40 def expand_directories(indir, infiles, blacklist): | |
| 41 """Expands the directories, applies the blacklist and verifies files exist.""" | |
| 42 logging.debug('expand_directories(%s, %s, %s)' % (indir, infiles, blacklist)) | |
| 43 outfiles = [] | |
| 44 for relfile in infiles: | |
| 45 if os.path.isabs(relfile): | |
| 46 raise MappingError('Can\'t map absolute path %s' % relfile) | |
| 47 infile = os.path.normpath(os.path.join(indir, relfile)) | |
| 48 if not infile.startswith(indir): | |
| 49 raise MappingError('Can\'t map file %s outside %s' % (infile, indir)) | |
| 50 | |
| 51 if relfile.endswith('/'): | |
| 52 if not os.path.isdir(infile): | |
| 53 raise MappingError( | |
| 54 'Input directory %s must have a trailing slash' % infile) | |
| 55 for dirpath, dirnames, filenames in os.walk(infile): | |
| 56 # Convert the absolute path to subdir + relative subdirectory. | |
| 57 relpath = dirpath[len(indir)+1:] | |
| 58 outfiles.extend(os.path.join(relpath, f) for f in filenames) | |
| 59 for index, dirname in enumerate(dirnames): | |
| 60 # Do not process blacklisted directories. | |
| 61 if blacklist(os.path.join(relpath, dirname)): | |
| 62 del dirnames[index] | |
| 63 else: | |
| 64 if not os.path.isfile(infile): | |
| 65 raise MappingError('Input file %s doesn\'t exist' % infile) | |
| 66 outfiles.append(relfile) | |
| 67 return outfiles | |
| 68 | |
| 69 | |
| 70 def process_inputs(indir, infiles, need_hash, read_only): | |
| 71 """Returns a dictionary of input files, populated with the files' mode and | |
| 72 hash. | |
| 73 | |
| 74 The file mode is manipulated if read_only is True. In practice, we only save | |
| 75 one of 4 modes: 0755 (rwx), 0644 (rw), 0555 (rx), 0444 (r). | |
| 76 """ | |
| 77 outdict = {} | |
| 78 for infile in infiles: | |
| 79 filepath = os.path.join(indir, infile) | |
| 80 filemode = stat.S_IMODE(os.stat(filepath).st_mode) | |
| 81 # Remove write access for non-owner. | |
| 82 filemode &= ~(stat.S_IWGRP | stat.S_IWOTH) | |
| 83 if read_only: | |
| 84 filemode &= ~stat.S_IWUSR | |
| 85 if filemode & stat.S_IXUSR: | |
| 86 filemode |= (stat.S_IXGRP | stat.S_IXOTH) | |
| 87 else: | |
| 88 filemode &= ~(stat.S_IXGRP | stat.S_IXOTH) | |
| 89 outdict[infile] = { | |
| 90 'mode': filemode, | |
| 91 } | |
| 92 if need_hash: | |
| 93 h = hashlib.sha1() | |
| 94 with open(filepath, 'rb') as f: | |
| 95 h.update(f.read()) | |
| 96 outdict[infile]['sha-1'] = h.hexdigest() | |
| 97 return outdict | |
| 98 | |
| 99 | |
| 100 def link_file(outfile, infile, action): | 44 def link_file(outfile, infile, action): |
| 101 """Links a file. The type of link depends on |action|.""" | 45 """Links a file. The type of link depends on |action|.""" |
| 102 logging.debug('Mapping %s to %s' % (infile, outfile)) | 46 logging.debug('Mapping %s to %s' % (infile, outfile)) |
| 47 if action not in (HARDLINK, SYMLINK, COPY): | |
| 48 raise ValueError('Unknown mapping action %s' % action) | |
| 103 if os.path.isfile(outfile): | 49 if os.path.isfile(outfile): |
| 104 raise MappingError('%s already exist' % outfile) | 50 raise MappingError('%s already exist' % outfile) |
| 105 | 51 |
| 106 if action == COPY: | 52 if action == COPY: |
| 107 shutil.copy(infile, outfile) | 53 shutil.copy(infile, outfile) |
| 108 elif action == SYMLINK and sys.platform != 'win32': | 54 elif action == SYMLINK and sys.platform != 'win32': |
| 55 # On windows, symlink are converted to hardlink and fails over to copy. | |
| 109 os.symlink(infile, outfile) | 56 os.symlink(infile, outfile) |
| 110 elif action == HARDLINK: | 57 else: |
| 111 try: | 58 try: |
| 112 os_link(infile, outfile) | 59 os_link(infile, outfile) |
| 113 except OSError: | 60 except OSError: |
| 114 # Probably a different file system. | 61 # Probably a different file system. |
| 115 logging.warn( | 62 logging.warn( |
| 116 'Failed to hardlink, failing back to copy %s to %s' % ( | 63 'Failed to hardlink, failing back to copy %s to %s' % ( |
| 117 infile, outfile)) | 64 infile, outfile)) |
| 118 shutil.copy(infile, outfile) | 65 shutil.copy(infile, outfile) |
| 119 else: | |
| 120 raise ValueError('Unknown mapping action %s' % action) | |
| 121 | |
| 122 | |
| 123 def recreate_tree(outdir, indir, infiles, action): | |
| 124 """Creates a new tree with only the input files in it. | |
| 125 | |
| 126 Arguments: | |
| 127 outdir: Output directory to create the files in. | |
| 128 indir: Root directory the infiles are based in. | |
| 129 infiles: List of files to map from |indir| to |outdir|. | |
| 130 action: See assert below. | |
| 131 """ | |
| 132 logging.debug( | |
| 133 'recreate_tree(%s, %s, %s, %s)' % (outdir, indir, infiles, action)) | |
| 134 logging.info('Mapping from %s to %s' % (indir, outdir)) | |
| 135 | |
| 136 assert action in (HARDLINK, SYMLINK, COPY) | |
| 137 outdir = os.path.normpath(outdir) | |
| 138 if not os.path.isdir(outdir): | |
| 139 logging.info ('Creating %s' % outdir) | |
| 140 os.makedirs(outdir) | |
| 141 # Do not call abspath until the directory exists. | |
| 142 outdir = os.path.abspath(outdir) | |
| 143 | |
| 144 for relfile in infiles: | |
| 145 infile = os.path.join(indir, relfile) | |
| 146 outfile = os.path.join(outdir, relfile) | |
| 147 outsubdir = os.path.dirname(outfile) | |
| 148 if not os.path.isdir(outsubdir): | |
| 149 os.makedirs(outsubdir) | |
| 150 link_file(outfile, infile, action) | |
| 151 | 66 |
| 152 | 67 |
| 153 def _set_write_bit(path, read_only): | 68 def _set_write_bit(path, read_only): |
| 154 """Sets or resets the executable bit on a file or directory.""" | 69 """Sets or resets the executable bit on a file or directory.""" |
| 155 mode = os.stat(path).st_mode | 70 mode = os.stat(path).st_mode |
| 156 if read_only: | 71 if read_only: |
| 157 mode = mode & 0500 | 72 mode = mode & 0500 |
| 158 else: | 73 else: |
| 159 mode = mode | 0200 | 74 mode = mode | 0200 |
| 160 if hasattr(os, 'lchmod'): | 75 if hasattr(os, 'lchmod'): |
| 161 os.lchmod(path, mode) # pylint: disable=E1101 | 76 os.lchmod(path, mode) # pylint: disable=E1101 |
| 162 else: | 77 else: |
| 163 # TODO(maruel): Implement proper DACL modification on Windows. | 78 # TODO(maruel): Implement proper DACL modification on Windows. |
| 164 os.chmod(path, mode) | 79 os.chmod(path, mode) |
| 165 | 80 |
| 166 | 81 |
| 167 def make_writable(root, read_only): | 82 def make_writable(root, read_only): |
| 168 """Toggle the writable bit on a directory tree.""" | 83 """Toggle the writable bit on a directory tree.""" |
| 169 root = os.path.abspath(root) | 84 root = os.path.abspath(root) |
| 170 for dirpath, dirnames, filenames in os.walk(root, topdown=True): | 85 for dirpath, dirnames, filenames in os.walk(root, topdown=True): |
| 171 for filename in filenames: | 86 for filename in filenames: |
| 172 _set_write_bit(os.path.join(dirpath, filename), read_only) | 87 _set_write_bit(os.path.join(dirpath, filename), read_only) |
| 173 | 88 |
| 174 for dirname in dirnames: | 89 for dirname in dirnames: |
| 175 _set_write_bit(os.path.join(dirpath, dirname), read_only) | 90 _set_write_bit(os.path.join(dirpath, dirname), read_only) |
| 176 | 91 |
| 177 | 92 |
| 178 def rmtree(root): | 93 def rmtree(root): |
| 179 """Wrapper around shutil.rmtree() to retry automatically on Windows.""" | 94 """Wrapper around shutil.rmtree() to retry automatically on Windows.""" |
| 95 make_writable(root, False) | |
| 180 if sys.platform == 'win32': | 96 if sys.platform == 'win32': |
| 181 for i in range(3): | 97 for i in range(3): |
| 182 try: | 98 try: |
| 183 shutil.rmtree(root) | 99 shutil.rmtree(root) |
| 184 break | 100 break |
| 185 except WindowsError: # pylint: disable=E0602 | 101 except WindowsError: # pylint: disable=E0602 |
| 186 delay = (i+1)*2 | 102 delay = (i+1)*2 |
| 187 print >> sys.stderr, ( | 103 print >> sys.stderr, ( |
| 188 'The test has subprocess outliving it. Sleep %d seconds.' % delay) | 104 'The test has subprocess outliving it. Sleep %d seconds.' % delay) |
| 189 time.sleep(delay) | 105 time.sleep(delay) |
| 190 else: | 106 else: |
| 191 shutil.rmtree(root) | 107 shutil.rmtree(root) |
| 108 | |
| 109 | |
| 110 def open_remote(file_or_url): | |
| 111 """Reads a file or url.""" | |
| 112 if re.match(r'^https?://.+$', file_or_url): | |
| 113 return urllib.urlopen(file_or_url) | |
| 114 return open(file_or_url, 'rb') | |
| 115 | |
| 116 | |
| 117 def download_or_copy(file_or_url, dest): | |
| 118 """Copies a file or download an url.""" | |
| 119 if re.match(r'^https?://.+$', file_or_url): | |
| 120 urllib.urlretrieve(file_or_url, dest) | |
| 121 else: | |
| 122 shutil.copy(file_or_url, dest) | |
| 123 | |
| 124 | |
| 125 def get_free_space(path): | |
| 126 """Returns the number of free bytes.""" | |
| 127 if sys.platform == 'win32': | |
| 128 free_bytes = ctypes.c_ulonglong(0) | |
| 129 ctypes.windll.kernel32.GetDiskFreeSpaceExW( | |
| 130 ctypes.c_wchar_p(path), None, None, ctypes.pointer(free_bytes)) | |
| 131 return free_bytes.value | |
| 132 f = os.statvfs(path) | |
| 133 return f.f_bfree * f.f_frsize | |
| 134 | |
| 135 | |
| 136 class Cache(object): | |
| 137 """Stateful LRU cache. | |
| 138 | |
| 139 Saves its state as json file. | |
| 140 """ | |
| 141 STATE_FILE = 'state.json' | |
| 142 | |
| 143 def __init__(self, cache_dir, remote, max_cache_size, min_free_space): | |
| 144 """ | |
| 145 Arguments: | |
| 146 - cache_dir: Directory where to place the cache. | |
| 147 - remote: Remote directory (NFS, SMB, etc) or HTTP url to fetch the objects | |
| 148 from | |
| 149 - max_cache_size: Trim if the cache gets larger than this value. If 0, the | |
| 150 cache is effectively a leak. | |
| 151 - min_free_space: Trim if disk free space becomes lower than this value. If | |
| 152 0, it unconditionally fill the disk. | |
| 153 """ | |
| 154 self.cache_dir = cache_dir | |
| 155 self.remote = remote | |
| 156 self.max_cache_size = max_cache_size | |
| 157 self.min_free_space = min_free_space | |
| 158 self.state_file = os.path.join(cache_dir, self.STATE_FILE) | |
| 159 # The files are kept as an array in a LRU style. E.g. self.state[0] is the | |
| 160 # oldest item. | |
| 161 self.state = [] | |
| 162 | |
| 163 if not os.path.isdir(self.cache_dir): | |
| 164 os.makedirs(self.cache_dir) | |
| 165 if os.path.isfile(self.state_file): | |
| 166 try: | |
| 167 self.state = json.load(open(self.state_file, 'rb')) | |
| 168 except ValueError: | |
| 169 # Too bad. The file will be overwritten and the cache cleared. | |
| 170 pass | |
| 171 self.trim() | |
| 172 | |
| 173 def trim(self): | |
| 174 """Trims anything we don't know, make sure enough free space exists.""" | |
| 175 for f in os.listdir(self.cache_dir): | |
| 176 if f == self.STATE_FILE or f in self.state: | |
| 177 continue | |
| 178 logging.warn('Unknown file %s from cache' % f) | |
| 179 # Insert as the oldest file. It will be deleted eventually if not | |
| 180 # accessed. | |
| 181 self.state.insert(0, f) | |
| 182 | |
| 183 # Ensure enough free space. | |
| 184 while ( | |
| 185 self.min_free_space and | |
| 186 self.state and | |
| 187 get_free_space(self.cache_dir) < self.min_free_space): | |
| 188 os.remove(self.path(self.state.pop(0))) | |
| 189 | |
| 190 # Ensure maximum cache size. | |
| 191 if self.max_cache_size and self.state: | |
| 192 sizes = [os.stat(self.path(f)).st_size for f in self.state] | |
|
M-A Ruel
2012/03/26 15:55:59
This could be saved in self.state if this is found
| |
| 193 while sizes and sum(sizes) > self.max_cache_size: | |
| 194 # Delete the oldest item. | |
| 195 os.remove(self.path(self.state.pop(0))) | |
| 196 sizes.pop(0) | |
| 197 | |
| 198 self.save() | |
| 199 | |
| 200 def retrieve(self, item): | |
| 201 """Retrieves a file from the remote and add it to the cache.""" | |
| 202 assert not '/' in item | |
| 203 try: | |
| 204 index = self.state.index(item) | |
| 205 # Was already in cache. Update it's LRU value. | |
| 206 self.state.pop(index) | |
| 207 self.state.append(item) | |
| 208 return False | |
| 209 except ValueError: | |
| 210 out = self.path(item) | |
| 211 download_or_copy(os.path.join(self.remote, item), out) | |
| 212 self.state.append(item) | |
| 213 return True | |
| 214 finally: | |
| 215 self.save() | |
| 216 | |
| 217 def path(self, item): | |
| 218 """Returns the path to one item.""" | |
| 219 return os.path.join(self.cache_dir, item) | |
| 220 | |
| 221 def save(self): | |
| 222 """Saves the LRU ordering.""" | |
| 223 json.dump(self.state, open(self.state_file, 'wb')) | |
| 224 | |
| 225 | |
| 226 def run_tha_test(manifest, cache_dir, remote, max_cache_size, min_free_space): | |
| 227 """Downloads the dependencies in the cache, hardlinks them into a temporary | |
| 228 directory and runs the executable. | |
| 229 """ | |
| 230 cache = Cache(cache_dir, remote, max_cache_size, min_free_space) | |
| 231 outdir = tempfile.mkdtemp(prefix='run_tha_test') | |
| 232 try: | |
| 233 for filepath, properties in manifest['files'].iteritems(): | |
| 234 infile = properties['sha-1'] | |
| 235 outfile = os.path.join(outdir, filepath) | |
| 236 cache.retrieve(infile) | |
| 237 outfiledir = os.path.dirname(outfile) | |
| 238 if not os.path.isdir(outfiledir): | |
| 239 os.makedirs(outfiledir) | |
| 240 link_file(outfile, cache.path(infile), HARDLINK) | |
| 241 os.chmod(outfile, properties['mode']) | |
| 242 | |
| 243 cwd = os.path.join(outdir, manifest['relative_cwd']) | |
| 244 if not os.path.isdir(cwd): | |
| 245 os.makedirs(cwd) | |
| 246 if manifest.get('read_only'): | |
| 247 make_writable(outdir, True) | |
| 248 cmd = manifest['command'] | |
| 249 logging.info('Running %s, cwd=%s' % (cmd, cwd)) | |
| 250 return subprocess.call(cmd, cwd=cwd) | |
| 251 finally: | |
| 252 # Save first, in case an exception occur in the following lines, then clean | |
| 253 # up. | |
| 254 cache.save() | |
| 255 rmtree(outdir) | |
| 256 cache.trim() | |
| 257 | |
| 258 | |
| 259 def main(): | |
| 260 parser = optparse.OptionParser( | |
| 261 usage='%prog <options>', description=sys.modules[__name__].__doc__) | |
| 262 parser.add_option( | |
| 263 '-v', '--verbose', action='count', default=0, help='Use multiple times') | |
| 264 parser.add_option( | |
| 265 '-m', '--manifest', | |
| 266 metavar='FILE', | |
| 267 help='File/url describing what to map or run') | |
| 268 parser.add_option('--no-run', action='store_true', help='Skip the run part') | |
| 269 parser.add_option( | |
| 270 '--cache', | |
| 271 default='cache', | |
| 272 metavar='DIR', | |
| 273 help='Cache directory, default=%default') | |
| 274 parser.add_option( | |
| 275 '-r', '--remote', metavar='URL', help='Remote where to get the items') | |
| 276 parser.add_option( | |
| 277 '--max-cache-size', | |
| 278 type='int', | |
| 279 metavar='NNN', | |
| 280 default=20*1024*1024*1024, | |
| 281 help='Trim if the cache gets larger than this value, default=%default') | |
| 282 parser.add_option( | |
| 283 '--min-free-space', | |
| 284 type='int', | |
| 285 metavar='NNN', | |
| 286 default=1*1024*1024*1024, | |
| 287 help='Trim if disk free space becomes lower than this value, ' | |
| 288 'default=%default') | |
| 289 | |
| 290 options, args = parser.parse_args() | |
| 291 level = [logging.ERROR, logging.INFO, logging.DEBUG][min(2, options.verbose)] | |
| 292 logging.basicConfig( | |
| 293 level=level, | |
| 294 format='%(levelname)5s %(module)15s(%(lineno)3d): %(message)s') | |
| 295 | |
| 296 if not options.manifest: | |
| 297 parser.error('--manifest is required.') | |
| 298 if not options.remote: | |
| 299 parser.error('--remote is required.') | |
| 300 if args: | |
| 301 parser.error('Unsupported args %s' % ' '.join(args)) | |
| 302 | |
| 303 manifest = json.load(open_remote(options.manifest)) | |
| 304 return run_tha_test( | |
| 305 manifest, os.path.abspath(options.cache), options.remote, | |
| 306 options.max_cache_size, options.min_free_space) | |
| 307 | |
| 308 | |
| 309 if __name__ == '__main__': | |
| 310 sys.exit(main()) | |
| OLD | NEW |