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
15*34b17c0aSThomas 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:
469903217aSDaniel P. Berrangé            sum_prog = 'sha256sum'
479903217aSDaniel P. Berrangé        elif len(self.hash) == 128:
489903217aSDaniel P. Berrangé            sum_prog = 'sha512sum'
499903217aSDaniel P. Berrangé        else:
509903217aSDaniel P. Berrangé            raise Exception("unknown hash type")
519903217aSDaniel P. Berrangé
529903217aSDaniel P. Berrangé        checksum = subprocess.check_output(
539903217aSDaniel P. Berrangé            [sum_prog, str(cache_file)]).split()[0]
549903217aSDaniel P. Berrangé        return self.hash == checksum.decode("utf-8")
559903217aSDaniel P. Berrangé
569903217aSDaniel P. Berrangé    def valid(self):
579903217aSDaniel P. Berrangé        return self.cache_file.exists() and self._check(self.cache_file)
589903217aSDaniel P. Berrangé
59*34b17c0aSThomas Huth    def _wait_for_other_download(self, tmp_cache_file):
60*34b17c0aSThomas Huth        # Another thread already seems to download the asset, so wait until
61*34b17c0aSThomas Huth        # it is done, while also checking the size to see whether it is stuck
62*34b17c0aSThomas Huth        try:
63*34b17c0aSThomas Huth            current_size = tmp_cache_file.stat().st_size
64*34b17c0aSThomas Huth            new_size = current_size
65*34b17c0aSThomas Huth        except:
66*34b17c0aSThomas Huth            if os.path.exists(self.cache_file):
67*34b17c0aSThomas Huth                return True
68*34b17c0aSThomas Huth            raise
69*34b17c0aSThomas Huth        waittime = lastchange = 600
70*34b17c0aSThomas Huth        while waittime > 0:
71*34b17c0aSThomas Huth            sleep(1)
72*34b17c0aSThomas Huth            waittime -= 1
73*34b17c0aSThomas Huth            try:
74*34b17c0aSThomas Huth                new_size = tmp_cache_file.stat().st_size
75*34b17c0aSThomas Huth            except:
76*34b17c0aSThomas Huth                if os.path.exists(self.cache_file):
77*34b17c0aSThomas Huth                    return True
78*34b17c0aSThomas Huth                raise
79*34b17c0aSThomas Huth            if new_size != current_size:
80*34b17c0aSThomas Huth                lastchange = waittime
81*34b17c0aSThomas Huth                current_size = new_size
82*34b17c0aSThomas Huth            elif lastchange - waittime > 90:
83*34b17c0aSThomas Huth                return False
84*34b17c0aSThomas Huth
85*34b17c0aSThomas Huth        self.log.debug("Time out while waiting for %s!", tmp_cache_file)
86*34b17c0aSThomas Huth        raise
87*34b17c0aSThomas Huth
889903217aSDaniel P. Berrangé    def fetch(self):
899903217aSDaniel P. Berrangé        if not self.cache_dir.exists():
909903217aSDaniel P. Berrangé            self.cache_dir.mkdir(parents=True, exist_ok=True)
919903217aSDaniel P. Berrangé
929903217aSDaniel P. Berrangé        if self.valid():
939903217aSDaniel P. Berrangé            self.log.debug("Using cached asset %s for %s",
949903217aSDaniel P. Berrangé                           self.cache_file, self.url)
959903217aSDaniel P. Berrangé            return str(self.cache_file)
969903217aSDaniel P. Berrangé
97f57213f8SDaniel P. Berrangé        if os.environ.get("QEMU_TEST_NO_DOWNLOAD", False):
98f57213f8SDaniel P. Berrangé            raise Exception("Asset cache is invalid and downloads disabled")
99f57213f8SDaniel P. Berrangé
1009903217aSDaniel P. Berrangé        self.log.info("Downloading %s to %s...", self.url, self.cache_file)
1019903217aSDaniel P. Berrangé        tmp_cache_file = self.cache_file.with_suffix(".download")
1029903217aSDaniel P. Berrangé
103*34b17c0aSThomas Huth        for retries in range(3):
1049903217aSDaniel P. Berrangé            try:
105*34b17c0aSThomas Huth                with tmp_cache_file.open("xb") as dst:
106*34b17c0aSThomas Huth                    with urllib.request.urlopen(self.url) as resp:
107*34b17c0aSThomas Huth                        copyfileobj(resp, dst)
108*34b17c0aSThomas Huth                break
109*34b17c0aSThomas Huth            except FileExistsError:
110*34b17c0aSThomas Huth                self.log.debug("%s already exists, "
111*34b17c0aSThomas Huth                               "waiting for other thread to finish...",
112*34b17c0aSThomas Huth                               tmp_cache_file)
113*34b17c0aSThomas Huth                if self._wait_for_other_download(tmp_cache_file):
114*34b17c0aSThomas Huth                    return str(self.cache_file)
115*34b17c0aSThomas Huth                self.log.debug("%s seems to be stale, "
116*34b17c0aSThomas Huth                               "deleting and retrying download...",
117*34b17c0aSThomas Huth                               tmp_cache_file)
118*34b17c0aSThomas Huth                tmp_cache_file.unlink()
119*34b17c0aSThomas Huth                continue
1209903217aSDaniel P. Berrangé            except Exception as e:
1219903217aSDaniel P. Berrangé                self.log.error("Unable to download %s: %s", self.url, e)
1229903217aSDaniel P. Berrangé                tmp_cache_file.unlink()
1239903217aSDaniel P. Berrangé                raise
124*34b17c0aSThomas Huth
1259903217aSDaniel P. Berrangé        try:
1269903217aSDaniel P. Berrangé            # Set these just for informational purposes
1279903217aSDaniel P. Berrangé            os.setxattr(str(tmp_cache_file), "user.qemu-asset-url",
1289903217aSDaniel P. Berrangé                        self.url.encode('utf8'))
1299903217aSDaniel P. Berrangé            os.setxattr(str(tmp_cache_file), "user.qemu-asset-hash",
1309903217aSDaniel P. Berrangé                        self.hash.encode('utf8'))
1319903217aSDaniel P. Berrangé        except Exception as e:
1329903217aSDaniel P. Berrangé            self.log.debug("Unable to set xattr on %s: %s", tmp_cache_file, e)
1339903217aSDaniel P. Berrangé            pass
1349903217aSDaniel P. Berrangé
1359903217aSDaniel P. Berrangé        if not self._check(tmp_cache_file):
1369903217aSDaniel P. Berrangé            tmp_cache_file.unlink()
1379903217aSDaniel P. Berrangé            raise Exception("Hash of %s does not match %s" %
1389903217aSDaniel P. Berrangé                            (self.url, self.hash))
1399903217aSDaniel P. Berrangé        tmp_cache_file.replace(self.cache_file)
1409903217aSDaniel P. Berrangé
1419903217aSDaniel P. Berrangé        self.log.info("Cached %s at %s" % (self.url, self.cache_file))
1429903217aSDaniel P. Berrangé        return str(self.cache_file)
143f57213f8SDaniel P. Berrangé
144f57213f8SDaniel P. Berrangé    def precache_test(test):
145f57213f8SDaniel P. Berrangé        log = logging.getLogger('qemu-test')
146f57213f8SDaniel P. Berrangé        log.setLevel(logging.DEBUG)
147f57213f8SDaniel P. Berrangé        handler = logging.StreamHandler(sys.stdout)
148f57213f8SDaniel P. Berrangé        handler.setLevel(logging.DEBUG)
149f57213f8SDaniel P. Berrangé        formatter = logging.Formatter(
150f57213f8SDaniel P. Berrangé            '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
151f57213f8SDaniel P. Berrangé        handler.setFormatter(formatter)
152f57213f8SDaniel P. Berrangé        log.addHandler(handler)
153f57213f8SDaniel P. Berrangé        for name, asset in vars(test.__class__).items():
154f57213f8SDaniel P. Berrangé            if name.startswith("ASSET_") and type(asset) == Asset:
155f57213f8SDaniel P. Berrangé                log.info("Attempting to cache '%s'" % asset)
156f57213f8SDaniel P. Berrangé                asset.fetch()
157f57213f8SDaniel P. Berrangé        log.removeHandler(handler)
158f57213f8SDaniel P. Berrangé
159f57213f8SDaniel P. Berrangé    def precache_suite(suite):
160f57213f8SDaniel P. Berrangé        for test in suite:
161f57213f8SDaniel P. Berrangé            if isinstance(test, unittest.TestSuite):
162f57213f8SDaniel P. Berrangé                Asset.precache_suite(test)
163f57213f8SDaniel P. Berrangé            elif isinstance(test, unittest.TestCase):
164f57213f8SDaniel P. Berrangé                Asset.precache_test(test)
165f57213f8SDaniel P. Berrangé
166f57213f8SDaniel P. Berrangé    def precache_suites(path, cacheTstamp):
167f57213f8SDaniel P. Berrangé        loader = unittest.loader.defaultTestLoader
168f57213f8SDaniel P. Berrangé        tests = loader.loadTestsFromNames([path], None)
169f57213f8SDaniel P. Berrangé
170f57213f8SDaniel P. Berrangé        with open(cacheTstamp, "w") as fh:
171f57213f8SDaniel P. Berrangé            Asset.precache_suite(tests)
172