xref: /aosp_15_r20/external/autotest/utils/frozen_chromite/lib/cache.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
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