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
119903217aSDaniel P. Berrangéimport subprocess
12f57213f8SDaniel P. Berrangéimport sys
13f57213f8SDaniel P. Berrangéimport unittest
149903217aSDaniel P. Berrangéimport urllib.request
1534b17c0aSThomas Huthfrom time import sleep
169903217aSDaniel P. Berrangéfrom pathlib import Path
179903217aSDaniel P. Berrangéfrom shutil import copyfileobj
189903217aSDaniel P. Berrangé
199903217aSDaniel P. Berrangé
209903217aSDaniel P. Berrangé# Instances of this class must be declared as class level variables
219903217aSDaniel P. Berrangé# starting with a name "ASSET_". This enables the pre-caching logic
229903217aSDaniel P. Berrangé# to easily find all referenced assets and download them prior to
239903217aSDaniel P. Berrangé# execution of the tests.
249903217aSDaniel P. Berrangéclass Asset:
259903217aSDaniel P. Berrangé
269903217aSDaniel P. Berrangé    def __init__(self, url, hashsum):
279903217aSDaniel P. Berrangé        self.url = url
289903217aSDaniel P. Berrangé        self.hash = hashsum
299903217aSDaniel P. Berrangé        cache_dir_env = os.getenv('QEMU_TEST_CACHE_DIR')
309903217aSDaniel P. Berrangé        if cache_dir_env:
319903217aSDaniel P. Berrangé            self.cache_dir = Path(cache_dir_env, "download")
329903217aSDaniel P. Berrangé        else:
339903217aSDaniel P. Berrangé            self.cache_dir = Path(Path("~").expanduser(),
349903217aSDaniel P. Berrangé                                  ".cache", "qemu", "download")
359903217aSDaniel P. Berrangé        self.cache_file = Path(self.cache_dir, hashsum)
369903217aSDaniel P. Berrangé        self.log = logging.getLogger('qemu-test')
379903217aSDaniel P. Berrangé
389903217aSDaniel P. Berrangé    def __repr__(self):
399903217aSDaniel P. Berrangé        return "Asset: url=%s hash=%s cache=%s" % (
409903217aSDaniel P. Berrangé            self.url, self.hash, self.cache_file)
419903217aSDaniel P. Berrangé
429903217aSDaniel P. Berrangé    def _check(self, cache_file):
439903217aSDaniel P. Berrangé        if self.hash is None:
449903217aSDaniel P. Berrangé            return True
459903217aSDaniel P. Berrangé        if len(self.hash) == 64:
4605e30321SThomas Huth            hl = hashlib.sha256()
479903217aSDaniel P. Berrangé        elif len(self.hash) == 128:
4805e30321SThomas Huth            hl = hashlib.sha512()
499903217aSDaniel P. Berrangé        else:
509903217aSDaniel P. Berrangé            raise Exception("unknown hash type")
519903217aSDaniel P. Berrangé
5205e30321SThomas Huth        # Calculate the hash of the file:
5305e30321SThomas Huth        with open(cache_file, 'rb') as file:
5405e30321SThomas Huth            while True:
5505e30321SThomas Huth                chunk = file.read(1 << 20)
5605e30321SThomas Huth                if not chunk:
5705e30321SThomas Huth                    break
5805e30321SThomas Huth                hl.update(chunk)
5905e30321SThomas Huth
60*db17daf8SThomas Huth        return self.hash == hl.hexdigest()
619903217aSDaniel P. Berrangé
629903217aSDaniel P. Berrangé    def valid(self):
639903217aSDaniel P. Berrangé        return self.cache_file.exists() and self._check(self.cache_file)
649903217aSDaniel P. Berrangé
6534b17c0aSThomas Huth    def _wait_for_other_download(self, tmp_cache_file):
6634b17c0aSThomas Huth        # Another thread already seems to download the asset, so wait until
6734b17c0aSThomas Huth        # it is done, while also checking the size to see whether it is stuck
6834b17c0aSThomas Huth        try:
6934b17c0aSThomas Huth            current_size = tmp_cache_file.stat().st_size
7034b17c0aSThomas Huth            new_size = current_size
7134b17c0aSThomas Huth        except:
7234b17c0aSThomas Huth            if os.path.exists(self.cache_file):
7334b17c0aSThomas Huth                return True
7434b17c0aSThomas Huth            raise
7534b17c0aSThomas Huth        waittime = lastchange = 600
7634b17c0aSThomas Huth        while waittime > 0:
7734b17c0aSThomas Huth            sleep(1)
7834b17c0aSThomas Huth            waittime -= 1
7934b17c0aSThomas Huth            try:
8034b17c0aSThomas Huth                new_size = tmp_cache_file.stat().st_size
8134b17c0aSThomas Huth            except:
8234b17c0aSThomas Huth                if os.path.exists(self.cache_file):
8334b17c0aSThomas Huth                    return True
8434b17c0aSThomas Huth                raise
8534b17c0aSThomas Huth            if new_size != current_size:
8634b17c0aSThomas Huth                lastchange = waittime
8734b17c0aSThomas Huth                current_size = new_size
8834b17c0aSThomas Huth            elif lastchange - waittime > 90:
8934b17c0aSThomas Huth                return False
9034b17c0aSThomas Huth
9134b17c0aSThomas Huth        self.log.debug("Time out while waiting for %s!", tmp_cache_file)
9234b17c0aSThomas Huth        raise
9334b17c0aSThomas Huth
949903217aSDaniel P. Berrangé    def fetch(self):
959903217aSDaniel P. Berrangé        if not self.cache_dir.exists():
969903217aSDaniel P. Berrangé            self.cache_dir.mkdir(parents=True, exist_ok=True)
979903217aSDaniel P. Berrangé
989903217aSDaniel P. Berrangé        if self.valid():
999903217aSDaniel P. Berrangé            self.log.debug("Using cached asset %s for %s",
1009903217aSDaniel P. Berrangé                           self.cache_file, self.url)
1019903217aSDaniel P. Berrangé            return str(self.cache_file)
1029903217aSDaniel P. Berrangé
103f57213f8SDaniel P. Berrangé        if os.environ.get("QEMU_TEST_NO_DOWNLOAD", False):
104f57213f8SDaniel P. Berrangé            raise Exception("Asset cache is invalid and downloads disabled")
105f57213f8SDaniel P. Berrangé
1069903217aSDaniel P. Berrangé        self.log.info("Downloading %s to %s...", self.url, self.cache_file)
1079903217aSDaniel P. Berrangé        tmp_cache_file = self.cache_file.with_suffix(".download")
1089903217aSDaniel P. Berrangé
10934b17c0aSThomas Huth        for retries in range(3):
1109903217aSDaniel P. Berrangé            try:
11134b17c0aSThomas Huth                with tmp_cache_file.open("xb") as dst:
11234b17c0aSThomas Huth                    with urllib.request.urlopen(self.url) as resp:
11334b17c0aSThomas Huth                        copyfileobj(resp, dst)
11434b17c0aSThomas Huth                break
11534b17c0aSThomas Huth            except FileExistsError:
11634b17c0aSThomas Huth                self.log.debug("%s already exists, "
11734b17c0aSThomas Huth                               "waiting for other thread to finish...",
11834b17c0aSThomas Huth                               tmp_cache_file)
11934b17c0aSThomas Huth                if self._wait_for_other_download(tmp_cache_file):
12034b17c0aSThomas Huth                    return str(self.cache_file)
12134b17c0aSThomas Huth                self.log.debug("%s seems to be stale, "
12234b17c0aSThomas Huth                               "deleting and retrying download...",
12334b17c0aSThomas Huth                               tmp_cache_file)
12434b17c0aSThomas Huth                tmp_cache_file.unlink()
12534b17c0aSThomas Huth                continue
1269903217aSDaniel P. Berrangé            except Exception as e:
1279903217aSDaniel P. Berrangé                self.log.error("Unable to download %s: %s", self.url, e)
1289903217aSDaniel P. Berrangé                tmp_cache_file.unlink()
1299903217aSDaniel P. Berrangé                raise
13034b17c0aSThomas Huth
1319903217aSDaniel P. Berrangé        try:
1329903217aSDaniel P. Berrangé            # Set these just for informational purposes
1339903217aSDaniel P. Berrangé            os.setxattr(str(tmp_cache_file), "user.qemu-asset-url",
1349903217aSDaniel P. Berrangé                        self.url.encode('utf8'))
1359903217aSDaniel P. Berrangé            os.setxattr(str(tmp_cache_file), "user.qemu-asset-hash",
1369903217aSDaniel P. Berrangé                        self.hash.encode('utf8'))
1379903217aSDaniel P. Berrangé        except Exception as e:
1389903217aSDaniel P. Berrangé            self.log.debug("Unable to set xattr on %s: %s", tmp_cache_file, e)
1399903217aSDaniel P. Berrangé            pass
1409903217aSDaniel P. Berrangé
1419903217aSDaniel P. Berrangé        if not self._check(tmp_cache_file):
1429903217aSDaniel P. Berrangé            tmp_cache_file.unlink()
1439903217aSDaniel P. Berrangé            raise Exception("Hash of %s does not match %s" %
1449903217aSDaniel P. Berrangé                            (self.url, self.hash))
1459903217aSDaniel P. Berrangé        tmp_cache_file.replace(self.cache_file)
1469903217aSDaniel P. Berrangé
1479903217aSDaniel P. Berrangé        self.log.info("Cached %s at %s" % (self.url, self.cache_file))
1489903217aSDaniel P. Berrangé        return str(self.cache_file)
149f57213f8SDaniel P. Berrangé
150f57213f8SDaniel P. Berrangé    def precache_test(test):
151f57213f8SDaniel P. Berrangé        log = logging.getLogger('qemu-test')
152f57213f8SDaniel P. Berrangé        log.setLevel(logging.DEBUG)
153f57213f8SDaniel P. Berrangé        handler = logging.StreamHandler(sys.stdout)
154f57213f8SDaniel P. Berrangé        handler.setLevel(logging.DEBUG)
155f57213f8SDaniel P. Berrangé        formatter = logging.Formatter(
156f57213f8SDaniel P. Berrangé            '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
157f57213f8SDaniel P. Berrangé        handler.setFormatter(formatter)
158f57213f8SDaniel P. Berrangé        log.addHandler(handler)
159f57213f8SDaniel P. Berrangé        for name, asset in vars(test.__class__).items():
160f57213f8SDaniel P. Berrangé            if name.startswith("ASSET_") and type(asset) == Asset:
161f57213f8SDaniel P. Berrangé                log.info("Attempting to cache '%s'" % asset)
162f57213f8SDaniel P. Berrangé                asset.fetch()
163f57213f8SDaniel P. Berrangé        log.removeHandler(handler)
164f57213f8SDaniel P. Berrangé
165f57213f8SDaniel P. Berrangé    def precache_suite(suite):
166f57213f8SDaniel P. Berrangé        for test in suite:
167f57213f8SDaniel P. Berrangé            if isinstance(test, unittest.TestSuite):
168f57213f8SDaniel P. Berrangé                Asset.precache_suite(test)
169f57213f8SDaniel P. Berrangé            elif isinstance(test, unittest.TestCase):
170f57213f8SDaniel P. Berrangé                Asset.precache_test(test)
171f57213f8SDaniel P. Berrangé
172f57213f8SDaniel P. Berrangé    def precache_suites(path, cacheTstamp):
173f57213f8SDaniel P. Berrangé        loader = unittest.loader.defaultTestLoader
174f57213f8SDaniel P. Berrangé        tests = loader.loadTestsFromNames([path], None)
175f57213f8SDaniel P. Berrangé
176f57213f8SDaniel P. Berrangé        with open(cacheTstamp, "w") as fh:
177f57213f8SDaniel P. Berrangé            Asset.precache_suite(tests)
178