19903217aSDaniel P. Berrangé# Test utilities for fetching & caching assets
29903217aSDaniel P. Berrangé#
39903217aSDaniel P. Berrangé# Copyright 2024 Red Hat, Inc.
49903217aSDaniel P. Berrangé#
59903217aSDaniel P. Berrangé# This work is licensed under the terms of the GNU GPL, version 2 or
69903217aSDaniel P. Berrangé# later.  See the COPYING file in the top-level directory.
79903217aSDaniel P. Berrangé
89903217aSDaniel P. Berrangéimport hashlib
99903217aSDaniel P. Berrangéimport logging
109903217aSDaniel P. Berrangéimport os
11*786bc225SDaniel P. Berrangéimport stat
129903217aSDaniel P. Berrangéimport subprocess
13f57213f8SDaniel P. Berrangéimport sys
14f57213f8SDaniel P. Berrangéimport unittest
159903217aSDaniel P. Berrangéimport urllib.request
1634b17c0aSThomas Huthfrom time import sleep
179903217aSDaniel P. Berrangéfrom pathlib import Path
189903217aSDaniel P. Berrangéfrom shutil import copyfileobj
199903217aSDaniel P. Berrangé
209903217aSDaniel P. Berrangé
219903217aSDaniel P. Berrangé# Instances of this class must be declared as class level variables
229903217aSDaniel P. Berrangé# starting with a name "ASSET_". This enables the pre-caching logic
239903217aSDaniel P. Berrangé# to easily find all referenced assets and download them prior to
249903217aSDaniel P. Berrangé# execution of the tests.
259903217aSDaniel P. Berrangéclass Asset:
269903217aSDaniel P. Berrangé
279903217aSDaniel P. Berrangé    def __init__(self, url, hashsum):
289903217aSDaniel P. Berrangé        self.url = url
299903217aSDaniel P. Berrangé        self.hash = hashsum
309903217aSDaniel P. Berrangé        cache_dir_env = os.getenv('QEMU_TEST_CACHE_DIR')
319903217aSDaniel P. Berrangé        if cache_dir_env:
329903217aSDaniel P. Berrangé            self.cache_dir = Path(cache_dir_env, "download")
339903217aSDaniel P. Berrangé        else:
349903217aSDaniel P. Berrangé            self.cache_dir = Path(Path("~").expanduser(),
359903217aSDaniel P. Berrangé                                  ".cache", "qemu", "download")
369903217aSDaniel P. Berrangé        self.cache_file = Path(self.cache_dir, hashsum)
379903217aSDaniel P. Berrangé        self.log = logging.getLogger('qemu-test')
389903217aSDaniel P. Berrangé
399903217aSDaniel P. Berrangé    def __repr__(self):
409903217aSDaniel P. Berrangé        return "Asset: url=%s hash=%s cache=%s" % (
419903217aSDaniel P. Berrangé            self.url, self.hash, self.cache_file)
429903217aSDaniel P. Berrangé
439903217aSDaniel P. Berrangé    def _check(self, cache_file):
449903217aSDaniel P. Berrangé        if self.hash is None:
459903217aSDaniel P. Berrangé            return True
469903217aSDaniel P. Berrangé        if len(self.hash) == 64:
4705e30321SThomas Huth            hl = hashlib.sha256()
489903217aSDaniel P. Berrangé        elif len(self.hash) == 128:
4905e30321SThomas Huth            hl = hashlib.sha512()
509903217aSDaniel P. Berrangé        else:
519903217aSDaniel P. Berrangé            raise Exception("unknown hash type")
529903217aSDaniel P. Berrangé
5305e30321SThomas Huth        # Calculate the hash of the file:
5405e30321SThomas Huth        with open(cache_file, 'rb') as file:
5505e30321SThomas Huth            while True:
5605e30321SThomas Huth                chunk = file.read(1 << 20)
5705e30321SThomas Huth                if not chunk:
5805e30321SThomas Huth                    break
5905e30321SThomas Huth                hl.update(chunk)
6005e30321SThomas Huth
61db17daf8SThomas Huth        return self.hash == hl.hexdigest()
629903217aSDaniel P. Berrangé
639903217aSDaniel P. Berrangé    def valid(self):
649903217aSDaniel P. Berrangé        return self.cache_file.exists() and self._check(self.cache_file)
659903217aSDaniel P. Berrangé
6634b17c0aSThomas Huth    def _wait_for_other_download(self, tmp_cache_file):
6734b17c0aSThomas Huth        # Another thread already seems to download the asset, so wait until
6834b17c0aSThomas Huth        # it is done, while also checking the size to see whether it is stuck
6934b17c0aSThomas Huth        try:
7034b17c0aSThomas Huth            current_size = tmp_cache_file.stat().st_size
7134b17c0aSThomas Huth            new_size = current_size
7234b17c0aSThomas Huth        except:
7334b17c0aSThomas Huth            if os.path.exists(self.cache_file):
7434b17c0aSThomas Huth                return True
7534b17c0aSThomas Huth            raise
7634b17c0aSThomas Huth        waittime = lastchange = 600
7734b17c0aSThomas Huth        while waittime > 0:
7834b17c0aSThomas Huth            sleep(1)
7934b17c0aSThomas Huth            waittime -= 1
8034b17c0aSThomas Huth            try:
8134b17c0aSThomas Huth                new_size = tmp_cache_file.stat().st_size
8234b17c0aSThomas Huth            except:
8334b17c0aSThomas Huth                if os.path.exists(self.cache_file):
8434b17c0aSThomas Huth                    return True
8534b17c0aSThomas Huth                raise
8634b17c0aSThomas Huth            if new_size != current_size:
8734b17c0aSThomas Huth                lastchange = waittime
8834b17c0aSThomas Huth                current_size = new_size
8934b17c0aSThomas Huth            elif lastchange - waittime > 90:
9034b17c0aSThomas Huth                return False
9134b17c0aSThomas Huth
9234b17c0aSThomas Huth        self.log.debug("Time out while waiting for %s!", tmp_cache_file)
9334b17c0aSThomas Huth        raise
9434b17c0aSThomas Huth
959903217aSDaniel P. Berrangé    def fetch(self):
969903217aSDaniel P. Berrangé        if not self.cache_dir.exists():
979903217aSDaniel P. Berrangé            self.cache_dir.mkdir(parents=True, exist_ok=True)
989903217aSDaniel P. Berrangé
999903217aSDaniel P. Berrangé        if self.valid():
1009903217aSDaniel P. Berrangé            self.log.debug("Using cached asset %s for %s",
1019903217aSDaniel P. Berrangé                           self.cache_file, self.url)
1029903217aSDaniel P. Berrangé            return str(self.cache_file)
1039903217aSDaniel P. Berrangé
104f57213f8SDaniel P. Berrangé        if os.environ.get("QEMU_TEST_NO_DOWNLOAD", False):
105f57213f8SDaniel P. Berrangé            raise Exception("Asset cache is invalid and downloads disabled")
106f57213f8SDaniel P. Berrangé
1079903217aSDaniel P. Berrangé        self.log.info("Downloading %s to %s...", self.url, self.cache_file)
1089903217aSDaniel P. Berrangé        tmp_cache_file = self.cache_file.with_suffix(".download")
1099903217aSDaniel P. Berrangé
11034b17c0aSThomas Huth        for retries in range(3):
1119903217aSDaniel P. Berrangé            try:
11234b17c0aSThomas Huth                with tmp_cache_file.open("xb") as dst:
11334b17c0aSThomas Huth                    with urllib.request.urlopen(self.url) as resp:
11434b17c0aSThomas Huth                        copyfileobj(resp, dst)
11534b17c0aSThomas Huth                break
11634b17c0aSThomas Huth            except FileExistsError:
11734b17c0aSThomas Huth                self.log.debug("%s already exists, "
11834b17c0aSThomas Huth                               "waiting for other thread to finish...",
11934b17c0aSThomas Huth                               tmp_cache_file)
12034b17c0aSThomas Huth                if self._wait_for_other_download(tmp_cache_file):
12134b17c0aSThomas Huth                    return str(self.cache_file)
12234b17c0aSThomas Huth                self.log.debug("%s seems to be stale, "
12334b17c0aSThomas Huth                               "deleting and retrying download...",
12434b17c0aSThomas Huth                               tmp_cache_file)
12534b17c0aSThomas Huth                tmp_cache_file.unlink()
12634b17c0aSThomas Huth                continue
1279903217aSDaniel P. Berrangé            except Exception as e:
1289903217aSDaniel P. Berrangé                self.log.error("Unable to download %s: %s", self.url, e)
1299903217aSDaniel P. Berrangé                tmp_cache_file.unlink()
1309903217aSDaniel P. Berrangé                raise
13134b17c0aSThomas Huth
1329903217aSDaniel P. Berrangé        try:
1339903217aSDaniel P. Berrangé            # Set these just for informational purposes
1349903217aSDaniel P. Berrangé            os.setxattr(str(tmp_cache_file), "user.qemu-asset-url",
1359903217aSDaniel P. Berrangé                        self.url.encode('utf8'))
1369903217aSDaniel P. Berrangé            os.setxattr(str(tmp_cache_file), "user.qemu-asset-hash",
1379903217aSDaniel P. Berrangé                        self.hash.encode('utf8'))
1389903217aSDaniel P. Berrangé        except Exception as e:
1399903217aSDaniel P. Berrangé            self.log.debug("Unable to set xattr on %s: %s", tmp_cache_file, e)
1409903217aSDaniel P. Berrangé            pass
1419903217aSDaniel P. Berrangé
1429903217aSDaniel P. Berrangé        if not self._check(tmp_cache_file):
1439903217aSDaniel P. Berrangé            tmp_cache_file.unlink()
1449903217aSDaniel P. Berrangé            raise Exception("Hash of %s does not match %s" %
1459903217aSDaniel P. Berrangé                            (self.url, self.hash))
1469903217aSDaniel P. Berrangé        tmp_cache_file.replace(self.cache_file)
147*786bc225SDaniel P. Berrangé        # Remove write perms to stop tests accidentally modifying them
148*786bc225SDaniel P. Berrangé        os.chmod(self.cache_file, stat.S_IRUSR | stat.S_IRGRP)
1499903217aSDaniel P. Berrangé
1509903217aSDaniel P. Berrangé        self.log.info("Cached %s at %s" % (self.url, self.cache_file))
1519903217aSDaniel P. Berrangé        return str(self.cache_file)
152f57213f8SDaniel P. Berrangé
153f57213f8SDaniel P. Berrangé    def precache_test(test):
154f57213f8SDaniel P. Berrangé        log = logging.getLogger('qemu-test')
155f57213f8SDaniel P. Berrangé        log.setLevel(logging.DEBUG)
156f57213f8SDaniel P. Berrangé        handler = logging.StreamHandler(sys.stdout)
157f57213f8SDaniel P. Berrangé        handler.setLevel(logging.DEBUG)
158f57213f8SDaniel P. Berrangé        formatter = logging.Formatter(
159f57213f8SDaniel P. Berrangé            '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
160f57213f8SDaniel P. Berrangé        handler.setFormatter(formatter)
161f57213f8SDaniel P. Berrangé        log.addHandler(handler)
162f57213f8SDaniel P. Berrangé        for name, asset in vars(test.__class__).items():
163f57213f8SDaniel P. Berrangé            if name.startswith("ASSET_") and type(asset) == Asset:
164f57213f8SDaniel P. Berrangé                log.info("Attempting to cache '%s'" % asset)
165f57213f8SDaniel P. Berrangé                asset.fetch()
166f57213f8SDaniel P. Berrangé        log.removeHandler(handler)
167f57213f8SDaniel P. Berrangé
168f57213f8SDaniel P. Berrangé    def precache_suite(suite):
169f57213f8SDaniel P. Berrangé        for test in suite:
170f57213f8SDaniel P. Berrangé            if isinstance(test, unittest.TestSuite):
171f57213f8SDaniel P. Berrangé                Asset.precache_suite(test)
172f57213f8SDaniel P. Berrangé            elif isinstance(test, unittest.TestCase):
173f57213f8SDaniel P. Berrangé                Asset.precache_test(test)
174f57213f8SDaniel P. Berrangé
175f57213f8SDaniel P. Berrangé    def precache_suites(path, cacheTstamp):
176f57213f8SDaniel P. Berrangé        loader = unittest.loader.defaultTestLoader
177f57213f8SDaniel P. Berrangé        tests = loader.loadTestsFromNames([path], None)
178f57213f8SDaniel P. Berrangé
179f57213f8SDaniel P. Berrangé        with open(cacheTstamp, "w") as fh:
180f57213f8SDaniel P. Berrangé            Asset.precache_suite(tests)
181