1# -*- coding: utf-8 -*- 2# Copyright (c) 2012 The Chromium OS 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"""Contains on-disk caching functionality.""" 7 8from __future__ import print_function 9 10import datetime 11import errno 12import os 13import shutil 14import tempfile 15 16from six.moves import urllib 17 18from autotest_lib.utils.frozen_chromite.lib import cros_build_lib 19from autotest_lib.utils.frozen_chromite.lib import cros_logging as logging 20from autotest_lib.utils.frozen_chromite.lib import locking 21from autotest_lib.utils.frozen_chromite.lib import osutils 22from autotest_lib.utils.frozen_chromite.lib import retry_util 23 24 25# pylint: disable=protected-access 26 27 28def EntryLock(f): 29 """Decorator that provides monitor access control.""" 30 31 def new_f(self, *args, **kwargs): 32 # Ensure we don't have a read lock before potentially blocking while trying 33 # to access the monitor. 34 if self.read_locked: 35 raise AssertionError( 36 'Cannot call %s while holding a read lock.' % f.__name__) 37 38 with self._entry_lock: 39 self._entry_lock.write_lock() 40 return f(self, *args, **kwargs) 41 return new_f 42 43 44def WriteLock(f): 45 """Decorator that takes a write lock.""" 46 47 def new_f(self, *args, **kwargs): 48 with self._lock.write_lock(): 49 return f(self, *args, **kwargs) 50 return new_f 51 52 53class CacheReference(object): 54 """Encapsulates operations on a cache key reference. 55 56 CacheReferences are returned by the DiskCache.Lookup() function. They are 57 used to read from and insert into the cache. 58 59 A typical example of using a CacheReference: 60 61 @contextlib.contextmanager 62 def FetchFromCache() 63 with cache.Lookup(key) as ref: 64 # If entry doesn't exist in cache already, generate it ourselves, and 65 # insert it into the cache, acquiring a read lock on it in the process. 66 # If the entry does exist, we grab a read lock on it. 67 if not ref.Exists(lock=True): 68 path = PrepareItem() 69 ref.SetDefault(path, lock=True) 70 71 # yield the path to the cached entry to consuming code. 72 yield ref.path 73 """ 74 75 def __init__(self, cache, key): 76 self._cache = cache 77 self.key = key 78 self.acquired = False 79 self.read_locked = False 80 self._lock = cache._LockForKey(key) 81 self._entry_lock = cache._LockForKey(key, suffix='.entry_lock') 82 83 @property 84 def path(self): 85 """Returns on-disk path to the cached item.""" 86 return self._cache.GetKeyPath(self.key) 87 88 def Acquire(self): 89 """Prepare the cache reference for operation. 90 91 This must be called (either explicitly or through entering a 'with' 92 context) before calling any methods that acquire locks, or mutates 93 reference. 94 """ 95 if self.acquired: 96 raise AssertionError( 97 'Attempting to acquire an already acquired reference.') 98 99 self.acquired = True 100 self._lock.__enter__() 101 102 def Release(self): 103 """Release the cache reference. Causes any held locks to be released.""" 104 if not self.acquired: 105 raise AssertionError( 106 'Attempting to release an unacquired reference.') 107 108 self.acquired = False 109 self._lock.__exit__(None, None, None) 110 self.read_locked = False 111 112 def __enter__(self): 113 self.Acquire() 114 return self 115 116 def __exit__(self, *args): 117 self.Release() 118 119 def _ReadLock(self): 120 self._lock.read_lock() 121 self.read_locked = True 122 123 @WriteLock 124 def _Assign(self, path): 125 self._cache._Insert(self.key, path) 126 127 @WriteLock 128 def _AssignText(self, text): 129 self._cache._InsertText(self.key, text) 130 131 @WriteLock 132 def _Remove(self): 133 self._cache._Remove(self.key) 134 osutils.SafeUnlink(self._lock.path) 135 osutils.SafeUnlink(self._entry_lock.path) 136 137 def _Exists(self): 138 return self._cache._KeyExists(self.key) 139 140 @EntryLock 141 def Assign(self, path): 142 """Insert a file or a directory into the cache at the referenced key.""" 143 self._Assign(path) 144 145 @EntryLock 146 def AssignText(self, text): 147 """Create a file containing |text| and assign it to the key. 148 149 Args: 150 text: Can be a string or an iterable. 151 """ 152 self._AssignText(text) 153 154 @EntryLock 155 def Remove(self): 156 """Removes the entry from the cache.""" 157 self._Remove() 158 159 @EntryLock 160 def Exists(self, lock=False): 161 """Tests for existence of entry. 162 163 Args: 164 lock: If the entry exists, acquire and maintain a read lock on it. 165 """ 166 if self._Exists(): 167 if lock: 168 self._ReadLock() 169 return True 170 return False 171 172 @EntryLock 173 def SetDefault(self, default_path, lock=False): 174 """Assigns default_path if the entry doesn't exist. 175 176 Args: 177 default_path: The path to assign if the entry doesn't exist. 178 lock: Acquire and maintain a read lock on the entry. 179 """ 180 if not self._Exists(): 181 self._Assign(default_path) 182 if lock: 183 self._ReadLock() 184 185 186class DiskCache(object): 187 """Locked file system cache keyed by tuples. 188 189 Key entries can be files or directories. Access to the cache is provided 190 through CacheReferences, which are retrieved by using the cache Lookup() 191 method. 192 """ 193 _STAGING_DIR = 'staging' 194 195 def __init__(self, cache_dir, cache_user=None, lock_suffix='.lock'): 196 self._cache_dir = cache_dir 197 self._cache_user = cache_user 198 self._lock_suffix = lock_suffix 199 self.staging_dir = os.path.join(cache_dir, self._STAGING_DIR) 200 201 osutils.SafeMakedirsNonRoot(self._cache_dir, user=self._cache_user) 202 osutils.SafeMakedirsNonRoot(self.staging_dir, user=self._cache_user) 203 204 def _KeyExists(self, key): 205 return os.path.lexists(self.GetKeyPath(key)) 206 207 def GetKeyPath(self, key): 208 """Get the on-disk path of a key.""" 209 return os.path.join(self._cache_dir, '+'.join(key)) 210 211 def _LockForKey(self, key, suffix=None): 212 """Returns an unacquired lock associated with a key.""" 213 suffix = suffix or self._lock_suffix 214 key_path = self.GetKeyPath(key) 215 osutils.SafeMakedirsNonRoot(os.path.dirname(key_path), 216 user=self._cache_user) 217 lock_path = os.path.join(self._cache_dir, os.path.dirname(key_path), 218 os.path.basename(key_path) + suffix) 219 return locking.FileLock(lock_path) 220 221 def _TempDirContext(self): 222 return osutils.TempDir(base_dir=self.staging_dir) 223 224 def _Insert(self, key, path): 225 """Insert a file or a directory into the cache at a given key.""" 226 self._Remove(key) 227 key_path = self.GetKeyPath(key) 228 osutils.SafeMakedirsNonRoot(os.path.dirname(key_path), 229 user=self._cache_user) 230 shutil.move(path, key_path) 231 232 def _InsertText(self, key, text): 233 """Inserts a file containing |text| into the cache.""" 234 with self._TempDirContext() as tempdir: 235 file_path = os.path.join(tempdir, 'tempfile') 236 osutils.WriteFile(file_path, text) 237 self._Insert(key, file_path) 238 239 def _Remove(self, key): 240 """Remove a key from the cache.""" 241 if self._KeyExists(key): 242 with self._TempDirContext() as tempdir: 243 shutil.move(self.GetKeyPath(key), tempdir) 244 245 def GetKey(self, path): 246 """Returns the key for an item's path in the cache.""" 247 if self._cache_dir in path: 248 path = os.path.relpath(path, self._cache_dir) 249 return tuple(path.split('+')) 250 251 def ListKeys(self): 252 """Returns a list of keys for every item present in the cache.""" 253 keys = [] 254 for root, dirs, files in os.walk(self._cache_dir): 255 for f in dirs + files: 256 key_path = os.path.join(root, f) 257 if os.path.exists(key_path + self._lock_suffix): 258 # Test for the presence of the key's lock file to determine if this 259 # is the root key path, or some file nested within a key's dir. 260 keys.append(self.GetKey(key_path)) 261 return keys 262 263 def Lookup(self, key): 264 """Get a reference to a given key.""" 265 return CacheReference(self, key) 266 267 def DeleteStale(self, max_age): 268 """Removes any item from the cache that was modified after a given lifetime. 269 270 Args: 271 max_age: An instance of datetime.timedelta. Any item not modified within 272 this amount of time will be removed. 273 274 Returns: 275 List of keys removed. 276 """ 277 if not isinstance(max_age, datetime.timedelta): 278 raise TypeError('max_age must be an instance of datetime.timedelta.') 279 keys_removed = [] 280 for key in self.ListKeys(): 281 path = self.GetKeyPath(key) 282 mtime = max(os.path.getmtime(path), os.path.getctime(path)) 283 time_since_last_modify = ( 284 datetime.datetime.now() - datetime.datetime.fromtimestamp(mtime)) 285 if time_since_last_modify > max_age: 286 self.Lookup(key).Remove() 287 keys_removed.append(key) 288 return keys_removed 289 290 291class RemoteCache(DiskCache): 292 """Supports caching of remote objects via URI.""" 293 294 def _Fetch(self, url, local_path): 295 """Fetch a remote file.""" 296 # We have to nest the import because gs.GSContext uses us to cache its own 297 # gsutil tarball. We know we won't get into a recursive loop though as it 298 # only fetches files via non-gs URIs. 299 from autotest_lib.utils.frozen_chromite.lib import gs 300 301 if gs.PathIsGs(url): 302 ctx = gs.GSContext() 303 ctx.Copy(url, local_path) 304 else: 305 # Note: unittests assume local_path is at the end. 306 retry_util.RunCurl(['--fail', url, '-o', local_path], 307 debug_level=logging.DEBUG, capture_output=True) 308 309 def _Insert(self, key, url): # pylint: disable=arguments-differ 310 """Insert a remote file into the cache.""" 311 o = urllib.parse.urlparse(url) 312 if o.scheme in ('file', ''): 313 DiskCache._Insert(self, key, o.path) 314 return 315 316 with tempfile.NamedTemporaryFile(dir=self.staging_dir, 317 delete=False) as local_path: 318 self._Fetch(url, local_path.name) 319 DiskCache._Insert(self, key, local_path.name) 320 321 322def Untar(path, cwd, sudo=False): 323 """Untar a tarball.""" 324 functor = cros_build_lib.sudo_run if sudo else cros_build_lib.run 325 comp = cros_build_lib.CompressionExtToType(path) 326 cmd = ['tar'] 327 if comp != cros_build_lib.COMP_NONE: 328 cmd += ['-I', cros_build_lib.FindCompressor(comp)] 329 functor(cmd + ['-xpf', path], cwd=cwd, debug_level=logging.DEBUG, quiet=True) 330 331 332class TarballCache(RemoteCache): 333 """Supports caching of extracted tarball contents.""" 334 335 def _Insert(self, key, tarball_path): # pylint: disable=arguments-differ 336 """Insert a tarball and its extracted contents into the cache. 337 338 Download the tarball first if a URL is provided as tarball_path. 339 """ 340 with osutils.TempDir(prefix='tarball-cache', 341 base_dir=self.staging_dir) as tempdir: 342 343 o = urllib.parse.urlsplit(tarball_path) 344 if o.scheme == 'file': 345 tarball_path = o.path 346 elif o.scheme: 347 url = tarball_path 348 tarball_path = os.path.join(tempdir, os.path.basename(o.path)) 349 self._Fetch(url, tarball_path) 350 351 extract_path = os.path.join(tempdir, 'extract') 352 os.mkdir(extract_path) 353 Untar(tarball_path, extract_path) 354 DiskCache._Insert(self, key, extract_path) 355 356 def _KeyExists(self, key): 357 """Specialized DiskCache._KeyExits that ignores empty directories. 358 359 The normal _KeyExists just checks to see if the key path exists in the cache 360 directory. Many tests mock out run then fetch a tarball. The mock 361 blocks untarring into it. This leaves behind an empty dir which blocks 362 future untarring in non-test scripts. 363 364 See crbug.com/468838 365 """ 366 # Wipe out empty directories before testing for existence. 367 key_path = self.GetKeyPath(key) 368 369 try: 370 os.rmdir(key_path) 371 except OSError as ex: 372 if ex.errno not in (errno.ENOTEMPTY, errno.ENOENT): 373 raise 374 375 return os.path.exists(key_path) 376