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 | |
6 """Reads a .isolated, creates a tree of hardlinks and runs the test. | |
7 | |
8 Keeps a local cache. | |
9 """ | |
10 | |
11 __version__ = '0.2' | |
12 | |
13 import ctypes | |
14 import logging | |
15 import optparse | |
16 import os | |
17 import re | |
18 import shutil | |
19 import stat | |
20 import subprocess | |
21 import sys | |
22 import tempfile | |
23 import time | |
24 | |
25 from third_party.depot_tools import fix_encoding | |
26 | |
27 from utils import lru | |
28 from utils import threading_utils | |
29 from utils import tools | |
30 from utils import zip_package | |
31 | |
32 import isolateserver | |
33 | |
34 | |
35 # Absolute path to this file (can be None if running from zip on Mac). | |
36 THIS_FILE_PATH = os.path.abspath(__file__) if __file__ else None | |
37 | |
38 # Directory that contains this file (might be inside zip package). | |
39 BASE_DIR = os.path.dirname(THIS_FILE_PATH) if __file__ else None | |
40 | |
41 # Directory that contains currently running script file. | |
42 if zip_package.get_main_script_path(): | |
43 MAIN_DIR = os.path.dirname( | |
44 os.path.abspath(zip_package.get_main_script_path())) | |
45 else: | |
46 # This happens when 'import run_isolated' is executed at the python | |
47 # interactive prompt, in that case __file__ is undefined. | |
48 MAIN_DIR = None | |
49 | |
50 # Types of action accepted by link_file(). | |
51 HARDLINK, HARDLINK_WITH_FALLBACK, SYMLINK, COPY = range(1, 5) | |
52 | |
53 # The name of the log file to use. | |
54 RUN_ISOLATED_LOG_FILE = 'run_isolated.log' | |
55 | |
56 # The name of the log to use for the run_test_cases.py command | |
57 RUN_TEST_CASES_LOG = 'run_test_cases.log' | |
58 | |
59 | |
60 # Used by get_flavor(). | |
61 FLAVOR_MAPPING = { | |
62 'cygwin': 'win', | |
63 'win32': 'win', | |
64 'darwin': 'mac', | |
65 'sunos5': 'solaris', | |
66 'freebsd7': 'freebsd', | |
67 'freebsd8': 'freebsd', | |
68 } | |
69 | |
70 | |
71 def get_as_zip_package(executable=True): | |
72 """Returns ZipPackage with this module and all its dependencies. | |
73 | |
74 If |executable| is True will store run_isolated.py as __main__.py so that | |
75 zip package is directly executable be python. | |
76 """ | |
77 # Building a zip package when running from another zip package is | |
78 # unsupported and probably unneeded. | |
79 assert not zip_package.is_zipped_module(sys.modules[__name__]) | |
80 assert THIS_FILE_PATH | |
81 assert BASE_DIR | |
82 package = zip_package.ZipPackage(root=BASE_DIR) | |
83 package.add_python_file(THIS_FILE_PATH, '__main__.py' if executable else None) | |
84 package.add_python_file(os.path.join(BASE_DIR, 'isolateserver.py')) | |
85 package.add_directory(os.path.join(BASE_DIR, 'third_party')) | |
86 package.add_directory(os.path.join(BASE_DIR, 'utils')) | |
87 return package | |
88 | |
89 | |
90 def get_flavor(): | |
91 """Returns the system default flavor. Copied from gyp/pylib/gyp/common.py.""" | |
92 return FLAVOR_MAPPING.get(sys.platform, 'linux') | |
93 | |
94 | |
95 def os_link(source, link_name): | |
96 """Add support for os.link() on Windows.""" | |
97 if sys.platform == 'win32': | |
98 if not ctypes.windll.kernel32.CreateHardLinkW( | |
99 unicode(link_name), unicode(source), 0): | |
100 raise OSError() | |
101 else: | |
102 os.link(source, link_name) | |
103 | |
104 | |
105 def readable_copy(outfile, infile): | |
106 """Makes a copy of the file that is readable by everyone.""" | |
107 shutil.copy2(infile, outfile) | |
108 read_enabled_mode = (os.stat(outfile).st_mode | stat.S_IRUSR | | |
109 stat.S_IRGRP | stat.S_IROTH) | |
110 os.chmod(outfile, read_enabled_mode) | |
111 | |
112 | |
113 def link_file(outfile, infile, action): | |
114 """Links a file. The type of link depends on |action|.""" | |
115 logging.debug('Mapping %s to %s' % (infile, outfile)) | |
116 if action not in (HARDLINK, HARDLINK_WITH_FALLBACK, SYMLINK, COPY): | |
117 raise ValueError('Unknown mapping action %s' % action) | |
118 if not os.path.isfile(infile): | |
119 raise isolateserver.MappingError('%s is missing' % infile) | |
120 if os.path.isfile(outfile): | |
121 raise isolateserver.MappingError( | |
122 '%s already exist; insize:%d; outsize:%d' % | |
123 (outfile, os.stat(infile).st_size, os.stat(outfile).st_size)) | |
124 | |
125 if action == COPY: | |
126 readable_copy(outfile, infile) | |
127 elif action == SYMLINK and sys.platform != 'win32': | |
128 # On windows, symlink are converted to hardlink and fails over to copy. | |
129 os.symlink(infile, outfile) # pylint: disable=E1101 | |
130 else: | |
131 try: | |
132 os_link(infile, outfile) | |
133 except OSError as e: | |
134 if action == HARDLINK: | |
135 raise isolateserver.MappingError( | |
136 'Failed to hardlink %s to %s: %s' % (infile, outfile, e)) | |
137 # Probably a different file system. | |
138 logging.warning( | |
139 'Failed to hardlink, failing back to copy %s to %s' % ( | |
140 infile, outfile)) | |
141 readable_copy(outfile, infile) | |
142 | |
143 | |
144 def _set_write_bit(path, read_only): | |
145 """Sets or resets the executable bit on a file or directory.""" | |
146 mode = os.lstat(path).st_mode | |
147 if read_only: | |
148 mode = mode & 0500 | |
149 else: | |
150 mode = mode | 0200 | |
151 if hasattr(os, 'lchmod'): | |
152 os.lchmod(path, mode) # pylint: disable=E1101 | |
153 else: | |
154 if stat.S_ISLNK(mode): | |
155 # Skip symlink without lchmod() support. | |
156 logging.debug('Can\'t change +w bit on symlink %s' % path) | |
157 return | |
158 | |
159 # TODO(maruel): Implement proper DACL modification on Windows. | |
160 os.chmod(path, mode) | |
161 | |
162 | |
163 def make_writable(root, read_only): | |
164 """Toggle the writable bit on a directory tree.""" | |
165 assert os.path.isabs(root), root | |
166 for dirpath, dirnames, filenames in os.walk(root, topdown=True): | |
167 for filename in filenames: | |
168 _set_write_bit(os.path.join(dirpath, filename), read_only) | |
169 | |
170 for dirname in dirnames: | |
171 _set_write_bit(os.path.join(dirpath, dirname), read_only) | |
172 | |
173 | |
174 def rmtree(root): | |
175 """Wrapper around shutil.rmtree() to retry automatically on Windows.""" | |
176 make_writable(root, False) | |
177 if sys.platform == 'win32': | |
178 for i in range(3): | |
179 try: | |
180 shutil.rmtree(root) | |
181 break | |
182 except WindowsError: # pylint: disable=E0602 | |
183 delay = (i+1)*2 | |
184 print >> sys.stderr, ( | |
185 'The test has subprocess outliving it. Sleep %d seconds.' % delay) | |
186 time.sleep(delay) | |
187 else: | |
188 shutil.rmtree(root) | |
189 | |
190 | |
191 def try_remove(filepath): | |
192 """Removes a file without crashing even if it doesn't exist.""" | |
193 try: | |
194 os.remove(filepath) | |
195 except OSError: | |
196 pass | |
197 | |
198 | |
199 def is_same_filesystem(path1, path2): | |
200 """Returns True if both paths are on the same filesystem. | |
201 | |
202 This is required to enable the use of hardlinks. | |
203 """ | |
204 assert os.path.isabs(path1), path1 | |
205 assert os.path.isabs(path2), path2 | |
206 if sys.platform == 'win32': | |
207 # If the drive letter mismatches, assume it's a separate partition. | |
208 # TODO(maruel): It should look at the underlying drive, a drive letter could | |
209 # be a mount point to a directory on another drive. | |
210 assert re.match(r'^[a-zA-Z]\:\\.*', path1), path1 | |
211 assert re.match(r'^[a-zA-Z]\:\\.*', path2), path2 | |
212 if path1[0].lower() != path2[0].lower(): | |
213 return False | |
214 return os.stat(path1).st_dev == os.stat(path2).st_dev | |
215 | |
216 | |
217 def get_free_space(path): | |
218 """Returns the number of free bytes.""" | |
219 if sys.platform == 'win32': | |
220 free_bytes = ctypes.c_ulonglong(0) | |
221 ctypes.windll.kernel32.GetDiskFreeSpaceExW( | |
222 ctypes.c_wchar_p(path), None, None, ctypes.pointer(free_bytes)) | |
223 return free_bytes.value | |
224 # For OSes other than Windows. | |
225 f = os.statvfs(path) # pylint: disable=E1101 | |
226 return f.f_bfree * f.f_frsize | |
227 | |
228 | |
229 def make_temp_dir(prefix, root_dir): | |
230 """Returns a temporary directory on the same file system as root_dir.""" | |
231 base_temp_dir = None | |
232 if not is_same_filesystem(root_dir, tempfile.gettempdir()): | |
233 base_temp_dir = os.path.dirname(root_dir) | |
234 return tempfile.mkdtemp(prefix=prefix, dir=base_temp_dir) | |
235 | |
236 | |
237 class CachePolicies(object): | |
238 def __init__(self, max_cache_size, min_free_space, max_items): | |
239 """ | |
240 Arguments: | |
241 - max_cache_size: Trim if the cache gets larger than this value. If 0, the | |
242 cache is effectively a leak. | |
243 - min_free_space: Trim if disk free space becomes lower than this value. If | |
244 0, it unconditionally fill the disk. | |
245 - max_items: Maximum number of items to keep in the cache. If 0, do not | |
246 enforce a limit. | |
247 """ | |
248 self.max_cache_size = max_cache_size | |
249 self.min_free_space = min_free_space | |
250 self.max_items = max_items | |
251 | |
252 | |
253 class DiskCache(isolateserver.LocalCache): | |
254 """Stateful LRU cache in a flat hash table in a directory. | |
255 | |
256 Saves its state as json file. | |
257 """ | |
258 STATE_FILE = 'state.json' | |
259 | |
260 def __init__(self, cache_dir, policies, algo): | |
261 """ | |
262 Arguments: | |
263 cache_dir: directory where to place the cache. | |
264 policies: cache retention policies. | |
265 algo: hashing algorithm used. | |
266 """ | |
267 super(DiskCache, self).__init__() | |
268 self.algo = algo | |
269 self.cache_dir = cache_dir | |
270 self.policies = policies | |
271 self.state_file = os.path.join(cache_dir, self.STATE_FILE) | |
272 | |
273 # All protected methods (starting with '_') except _path should be called | |
274 # with this lock locked. | |
275 self._lock = threading_utils.LockWithAssert() | |
276 self._lru = lru.LRUDict() | |
277 | |
278 # Profiling values. | |
279 self._added = [] | |
280 self._removed = [] | |
281 self._free_disk = 0 | |
282 | |
283 with tools.Profiler('Setup'): | |
284 with self._lock: | |
285 self._load() | |
286 | |
287 def __enter__(self): | |
288 return self | |
289 | |
290 def __exit__(self, _exc_type, _exec_value, _traceback): | |
291 with tools.Profiler('CleanupTrimming'): | |
292 with self._lock: | |
293 self._trim() | |
294 | |
295 logging.info( | |
296 '%5d (%8dkb) added', | |
297 len(self._added), sum(self._added) / 1024) | |
298 logging.info( | |
299 '%5d (%8dkb) current', | |
300 len(self._lru), | |
301 sum(self._lru.itervalues()) / 1024) | |
302 logging.info( | |
303 '%5d (%8dkb) removed', | |
304 len(self._removed), sum(self._removed) / 1024) | |
305 logging.info( | |
306 ' %8dkb free', | |
307 self._free_disk / 1024) | |
308 return False | |
309 | |
310 def cached_set(self): | |
311 with self._lock: | |
312 return self._lru.keys_set() | |
313 | |
314 def touch(self, digest, size): | |
315 # Verify an actual file is valid. Note that is doesn't compute the hash so | |
316 # it could still be corrupted. Do it outside the lock. | |
317 if not isolateserver.is_valid_file(self._path(digest), size): | |
318 return False | |
319 | |
320 # Update it's LRU position. | |
321 with self._lock: | |
322 if digest not in self._lru: | |
323 return False | |
324 self._lru.touch(digest) | |
325 return True | |
326 | |
327 def evict(self, digest): | |
328 with self._lock: | |
329 self._lru.pop(digest) | |
330 self._delete_file(digest, isolateserver.UNKNOWN_FILE_SIZE) | |
331 | |
332 def read(self, digest): | |
333 with open(self._path(digest), 'rb') as f: | |
334 return f.read() | |
335 | |
336 def write(self, digest, content): | |
337 path = self._path(digest) | |
338 try: | |
339 size = isolateserver.file_write(path, content) | |
340 except: | |
341 # There are two possible places were an exception can occur: | |
342 # 1) Inside |content| generator in case of network or unzipping errors. | |
343 # 2) Inside file_write itself in case of disk IO errors. | |
344 # In any case delete an incomplete file and propagate the exception to | |
345 # caller, it will be logged there. | |
346 try_remove(path) | |
347 raise | |
348 with self._lock: | |
349 self._add(digest, size) | |
350 | |
351 def link(self, digest, dest, file_mode=None): | |
352 link_file(dest, self._path(digest), HARDLINK) | |
353 if file_mode is not None: | |
354 os.chmod(dest, file_mode) | |
355 | |
356 def _load(self): | |
357 """Loads state of the cache from json file.""" | |
358 self._lock.assert_locked() | |
359 | |
360 if not os.path.isdir(self.cache_dir): | |
361 os.makedirs(self.cache_dir) | |
362 | |
363 # Load state of the cache. | |
364 if os.path.isfile(self.state_file): | |
365 try: | |
366 self._lru = lru.LRUDict.load(self.state_file) | |
367 except ValueError as err: | |
368 logging.error('Failed to load cache state: %s' % (err,)) | |
369 # Don't want to keep broken state file. | |
370 os.remove(self.state_file) | |
371 | |
372 # Ensure that all files listed in the state still exist and add new ones. | |
373 previous = self._lru.keys_set() | |
374 unknown = [] | |
375 for filename in os.listdir(self.cache_dir): | |
376 if filename == self.STATE_FILE: | |
377 continue | |
378 if filename in previous: | |
379 previous.remove(filename) | |
380 continue | |
381 # An untracked file. | |
382 if not isolateserver.is_valid_hash(filename, self.algo): | |
383 logging.warning('Removing unknown file %s from cache', filename) | |
384 try_remove(self._path(filename)) | |
385 continue | |
386 # File that's not referenced in 'state.json'. | |
387 # TODO(vadimsh): Verify its SHA1 matches file name. | |
388 logging.warning('Adding unknown file %s to cache', filename) | |
389 unknown.append(filename) | |
390 | |
391 if unknown: | |
392 # Add as oldest files. They will be deleted eventually if not accessed. | |
393 self._add_oldest_list(unknown) | |
394 logging.warning('Added back %d unknown files', len(unknown)) | |
395 | |
396 if previous: | |
397 # Filter out entries that were not found. | |
398 logging.warning('Removed %d lost files', len(previous)) | |
399 for filename in previous: | |
400 self._lru.pop(filename) | |
401 self._trim() | |
402 | |
403 def _save(self): | |
404 """Saves the LRU ordering.""" | |
405 self._lock.assert_locked() | |
406 self._lru.save(self.state_file) | |
407 | |
408 def _trim(self): | |
409 """Trims anything we don't know, make sure enough free space exists.""" | |
410 self._lock.assert_locked() | |
411 | |
412 # Ensure maximum cache size. | |
413 if self.policies.max_cache_size: | |
414 total_size = sum(self._lru.itervalues()) | |
415 while total_size > self.policies.max_cache_size: | |
416 total_size -= self._remove_lru_file() | |
417 | |
418 # Ensure maximum number of items in the cache. | |
419 if self.policies.max_items and len(self._lru) > self.policies.max_items: | |
420 for _ in xrange(len(self._lru) - self.policies.max_items): | |
421 self._remove_lru_file() | |
422 | |
423 # Ensure enough free space. | |
424 self._free_disk = get_free_space(self.cache_dir) | |
425 trimmed_due_to_space = False | |
426 while ( | |
427 self.policies.min_free_space and | |
428 self._lru and | |
429 self._free_disk < self.policies.min_free_space): | |
430 trimmed_due_to_space = True | |
431 self._remove_lru_file() | |
432 self._free_disk = get_free_space(self.cache_dir) | |
433 if trimmed_due_to_space: | |
434 total = sum(self._lru.itervalues()) | |
435 logging.warning( | |
436 'Trimmed due to not enough free disk space: %.1fkb free, %.1fkb ' | |
437 'cache (%.1f%% of its maximum capacity)', | |
438 self._free_disk / 1024., | |
439 total / 1024., | |
440 100. * self.policies.max_cache_size / float(total), | |
441 ) | |
442 self._save() | |
443 | |
444 def _path(self, digest): | |
445 """Returns the path to one item.""" | |
446 return os.path.join(self.cache_dir, digest) | |
447 | |
448 def _remove_lru_file(self): | |
449 """Removes the last recently used file and returns its size.""" | |
450 self._lock.assert_locked() | |
451 digest, size = self._lru.pop_oldest() | |
452 self._delete_file(digest, size) | |
453 return size | |
454 | |
455 def _add(self, digest, size=isolateserver.UNKNOWN_FILE_SIZE): | |
456 """Adds an item into LRU cache marking it as a newest one.""" | |
457 self._lock.assert_locked() | |
458 if size == isolateserver.UNKNOWN_FILE_SIZE: | |
459 size = os.stat(self._path(digest)).st_size | |
460 self._added.append(size) | |
461 self._lru.add(digest, size) | |
462 | |
463 def _add_oldest_list(self, digests): | |
464 """Adds a bunch of items into LRU cache marking them as oldest ones.""" | |
465 self._lock.assert_locked() | |
466 pairs = [] | |
467 for digest in digests: | |
468 size = os.stat(self._path(digest)).st_size | |
469 self._added.append(size) | |
470 pairs.append((digest, size)) | |
471 self._lru.batch_insert_oldest(pairs) | |
472 | |
473 def _delete_file(self, digest, size=isolateserver.UNKNOWN_FILE_SIZE): | |
474 """Deletes cache file from the file system.""" | |
475 self._lock.assert_locked() | |
476 try: | |
477 if size == isolateserver.UNKNOWN_FILE_SIZE: | |
478 size = os.stat(self._path(digest)).st_size | |
479 os.remove(self._path(digest)) | |
480 self._removed.append(size) | |
481 except OSError as e: | |
482 logging.error('Error attempting to delete a file %s:\n%s' % (digest, e)) | |
483 | |
484 | |
485 def run_tha_test(isolated_hash, storage, cache, algo, outdir): | |
486 """Downloads the dependencies in the cache, hardlinks them into a |outdir| | |
487 and runs the executable. | |
488 """ | |
489 try: | |
490 try: | |
491 settings = isolateserver.fetch_isolated( | |
492 isolated_hash=isolated_hash, | |
493 storage=storage, | |
494 cache=cache, | |
495 algo=algo, | |
496 outdir=outdir, | |
497 os_flavor=get_flavor(), | |
498 require_command=True) | |
499 except isolateserver.ConfigError as e: | |
500 tools.report_error(e) | |
501 return 1 | |
502 | |
503 if settings.read_only: | |
504 logging.info('Making files read only') | |
505 make_writable(outdir, True) | |
506 cwd = os.path.normpath(os.path.join(outdir, settings.relative_cwd)) | |
507 logging.info('Running %s, cwd=%s' % (settings.command, cwd)) | |
508 | |
509 # TODO(csharp): This should be specified somewhere else. | |
510 # TODO(vadimsh): Pass it via 'env_vars' in manifest. | |
511 # Add a rotating log file if one doesn't already exist. | |
512 env = os.environ.copy() | |
513 if MAIN_DIR: | |
514 env.setdefault('RUN_TEST_CASES_LOG_FILE', | |
515 os.path.join(MAIN_DIR, RUN_TEST_CASES_LOG)) | |
516 try: | |
517 with tools.Profiler('RunTest'): | |
518 return subprocess.call(settings.command, cwd=cwd, env=env) | |
519 except OSError: | |
520 tools.report_error('Failed to run %s; cwd=%s' % (settings.command, cwd)) | |
521 return 1 | |
522 finally: | |
523 if outdir: | |
524 rmtree(outdir) | |
525 | |
526 | |
527 def main(): | |
528 tools.disable_buffering() | |
529 parser = tools.OptionParserWithLogging( | |
530 usage='%prog <options>', | |
531 version=__version__, | |
532 log_file=RUN_ISOLATED_LOG_FILE) | |
533 | |
534 group = optparse.OptionGroup(parser, 'Data source') | |
535 group.add_option( | |
536 '-s', '--isolated', | |
537 metavar='FILE', | |
538 help='File/url describing what to map or run') | |
539 group.add_option( | |
540 '-H', '--hash', | |
541 help='Hash of the .isolated to grab from the hash table') | |
542 group.add_option( | |
543 '-I', '--isolate-server', | |
544 metavar='URL', default='', | |
545 help='Isolate server to use') | |
546 group.add_option( | |
547 '-n', '--namespace', | |
548 default='default-gzip', | |
549 help='namespace to use when using isolateserver, default: %default') | |
550 parser.add_option_group(group) | |
551 | |
552 group = optparse.OptionGroup(parser, 'Cache management') | |
553 group.add_option( | |
554 '--cache', | |
555 default='cache', | |
556 metavar='DIR', | |
557 help='Cache directory, default=%default') | |
558 group.add_option( | |
559 '--max-cache-size', | |
560 type='int', | |
561 metavar='NNN', | |
562 default=20*1024*1024*1024, | |
563 help='Trim if the cache gets larger than this value, default=%default') | |
564 group.add_option( | |
565 '--min-free-space', | |
566 type='int', | |
567 metavar='NNN', | |
568 default=2*1024*1024*1024, | |
569 help='Trim if disk free space becomes lower than this value, ' | |
570 'default=%default') | |
571 group.add_option( | |
572 '--max-items', | |
573 type='int', | |
574 metavar='NNN', | |
575 default=100000, | |
576 help='Trim if more than this number of items are in the cache ' | |
577 'default=%default') | |
578 parser.add_option_group(group) | |
579 | |
580 options, args = parser.parse_args() | |
581 | |
582 if bool(options.isolated) == bool(options.hash): | |
583 logging.debug('One and only one of --isolated or --hash is required.') | |
584 parser.error('One and only one of --isolated or --hash is required.') | |
585 if args: | |
586 logging.debug('Unsupported args %s' % ' '.join(args)) | |
587 parser.error('Unsupported args %s' % ' '.join(args)) | |
588 if not options.isolate_server: | |
589 parser.error('--isolate-server is required.') | |
590 | |
591 options.cache = os.path.abspath(options.cache) | |
592 policies = CachePolicies( | |
593 options.max_cache_size, options.min_free_space, options.max_items) | |
594 storage = isolateserver.get_storage(options.isolate_server, options.namespace) | |
595 algo = isolateserver.get_hash_algo(options.namespace) | |
596 | |
597 try: | |
598 # |options.cache| may not exist until DiskCache() instance is created. | |
599 cache = DiskCache(options.cache, policies, algo) | |
600 outdir = make_temp_dir('run_tha_test', options.cache) | |
601 return run_tha_test( | |
602 options.isolated or options.hash, storage, cache, algo, outdir) | |
603 except Exception as e: | |
604 # Make sure any exception is logged. | |
605 tools.report_error(e) | |
606 logging.exception(e) | |
607 return 1 | |
608 | |
609 | |
610 if __name__ == '__main__': | |
611 # Ensure that we are always running with the correct encoding. | |
612 fix_encoding.fix_encoding() | |
613 sys.exit(main()) | |
OLD | NEW |