xref: /qemu/tests/functional/qemu_test/asset.py (revision 28ea66f6f9856c398afa75f2cabb1f21c8b04208)
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
11786bc225SDaniel P. Berrangéimport stat
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
18674a750bSDaniel P. Berrangéfrom urllib.error import HTTPError
199903217aSDaniel P. Berrangé
20*28adad0aSNicholas Pigginclass AssetError(Exception):
21*28adad0aSNicholas Piggin    def __init__(self, asset, msg, transient=False):
22*28adad0aSNicholas Piggin        self.url = asset.url
23*28adad0aSNicholas Piggin        self.msg = msg
24*28adad0aSNicholas Piggin        self.transient = transient
25*28adad0aSNicholas Piggin
26*28adad0aSNicholas Piggin    def __str__(self):
27*28adad0aSNicholas Piggin        return "%s: %s" % (self.url, self.msg)
289903217aSDaniel P. Berrangé
299903217aSDaniel P. Berrangé# Instances of this class must be declared as class level variables
309903217aSDaniel P. Berrangé# starting with a name "ASSET_". This enables the pre-caching logic
319903217aSDaniel P. Berrangé# to easily find all referenced assets and download them prior to
329903217aSDaniel P. Berrangé# execution of the tests.
339903217aSDaniel P. Berrangéclass Asset:
349903217aSDaniel P. Berrangé
359903217aSDaniel P. Berrangé    def __init__(self, url, hashsum):
369903217aSDaniel P. Berrangé        self.url = url
379903217aSDaniel P. Berrangé        self.hash = hashsum
389903217aSDaniel P. Berrangé        cache_dir_env = os.getenv('QEMU_TEST_CACHE_DIR')
399903217aSDaniel P. Berrangé        if cache_dir_env:
409903217aSDaniel P. Berrangé            self.cache_dir = Path(cache_dir_env, "download")
419903217aSDaniel P. Berrangé        else:
429903217aSDaniel P. Berrangé            self.cache_dir = Path(Path("~").expanduser(),
439903217aSDaniel P. Berrangé                                  ".cache", "qemu", "download")
449903217aSDaniel P. Berrangé        self.cache_file = Path(self.cache_dir, hashsum)
459903217aSDaniel P. Berrangé        self.log = logging.getLogger('qemu-test')
469903217aSDaniel P. Berrangé
479903217aSDaniel P. Berrangé    def __repr__(self):
489903217aSDaniel P. Berrangé        return "Asset: url=%s hash=%s cache=%s" % (
499903217aSDaniel P. Berrangé            self.url, self.hash, self.cache_file)
509903217aSDaniel P. Berrangé
51c27f452dSDaniel P. Berrangé    def __str__(self):
52c27f452dSDaniel P. Berrangé        return str(self.cache_file)
53c27f452dSDaniel P. Berrangé
549903217aSDaniel P. Berrangé    def _check(self, cache_file):
559903217aSDaniel P. Berrangé        if self.hash is None:
569903217aSDaniel P. Berrangé            return True
579903217aSDaniel P. Berrangé        if len(self.hash) == 64:
5805e30321SThomas Huth            hl = hashlib.sha256()
599903217aSDaniel P. Berrangé        elif len(self.hash) == 128:
6005e30321SThomas Huth            hl = hashlib.sha512()
619903217aSDaniel P. Berrangé        else:
62*28adad0aSNicholas Piggin            raise AssetError(self, "unknown hash type")
639903217aSDaniel P. Berrangé
6405e30321SThomas Huth        # Calculate the hash of the file:
6505e30321SThomas Huth        with open(cache_file, 'rb') as file:
6605e30321SThomas Huth            while True:
6705e30321SThomas Huth                chunk = file.read(1 << 20)
6805e30321SThomas Huth                if not chunk:
6905e30321SThomas Huth                    break
7005e30321SThomas Huth                hl.update(chunk)
7105e30321SThomas Huth
72db17daf8SThomas Huth        return self.hash == hl.hexdigest()
739903217aSDaniel P. Berrangé
749903217aSDaniel P. Berrangé    def valid(self):
759903217aSDaniel P. Berrangé        return self.cache_file.exists() and self._check(self.cache_file)
769903217aSDaniel P. Berrangé
776ff217c2SDaniel P. Berrangé    def fetchable(self):
786ff217c2SDaniel P. Berrangé        return not os.environ.get("QEMU_TEST_NO_DOWNLOAD", False)
796ff217c2SDaniel P. Berrangé
806ff217c2SDaniel P. Berrangé    def available(self):
816ff217c2SDaniel P. Berrangé        return self.valid() or self.fetchable()
826ff217c2SDaniel P. Berrangé
8334b17c0aSThomas Huth    def _wait_for_other_download(self, tmp_cache_file):
8434b17c0aSThomas Huth        # Another thread already seems to download the asset, so wait until
8534b17c0aSThomas Huth        # it is done, while also checking the size to see whether it is stuck
8634b17c0aSThomas Huth        try:
8734b17c0aSThomas Huth            current_size = tmp_cache_file.stat().st_size
8834b17c0aSThomas Huth            new_size = current_size
8934b17c0aSThomas Huth        except:
9034b17c0aSThomas Huth            if os.path.exists(self.cache_file):
9134b17c0aSThomas Huth                return True
9234b17c0aSThomas Huth            raise
9334b17c0aSThomas Huth        waittime = lastchange = 600
9434b17c0aSThomas Huth        while waittime > 0:
9534b17c0aSThomas Huth            sleep(1)
9634b17c0aSThomas Huth            waittime -= 1
9734b17c0aSThomas Huth            try:
9834b17c0aSThomas Huth                new_size = tmp_cache_file.stat().st_size
9934b17c0aSThomas Huth            except:
10034b17c0aSThomas Huth                if os.path.exists(self.cache_file):
10134b17c0aSThomas Huth                    return True
10234b17c0aSThomas Huth                raise
10334b17c0aSThomas Huth            if new_size != current_size:
10434b17c0aSThomas Huth                lastchange = waittime
10534b17c0aSThomas Huth                current_size = new_size
10634b17c0aSThomas Huth            elif lastchange - waittime > 90:
10734b17c0aSThomas Huth                return False
10834b17c0aSThomas Huth
10934b17c0aSThomas Huth        self.log.debug("Time out while waiting for %s!", tmp_cache_file)
11034b17c0aSThomas Huth        raise
11134b17c0aSThomas Huth
1129903217aSDaniel P. Berrangé    def fetch(self):
1139903217aSDaniel P. Berrangé        if not self.cache_dir.exists():
1149903217aSDaniel P. Berrangé            self.cache_dir.mkdir(parents=True, exist_ok=True)
1159903217aSDaniel P. Berrangé
1169903217aSDaniel P. Berrangé        if self.valid():
1179903217aSDaniel P. Berrangé            self.log.debug("Using cached asset %s for %s",
1189903217aSDaniel P. Berrangé                           self.cache_file, self.url)
1199903217aSDaniel P. Berrangé            return str(self.cache_file)
1209903217aSDaniel P. Berrangé
1216ff217c2SDaniel P. Berrangé        if not self.fetchable():
122*28adad0aSNicholas Piggin            raise AssetError(self,
123*28adad0aSNicholas Piggin                             "Asset cache is invalid and downloads disabled")
124f57213f8SDaniel P. Berrangé
1259903217aSDaniel P. Berrangé        self.log.info("Downloading %s to %s...", self.url, self.cache_file)
1269903217aSDaniel P. Berrangé        tmp_cache_file = self.cache_file.with_suffix(".download")
1279903217aSDaniel P. Berrangé
12834b17c0aSThomas Huth        for retries in range(3):
1299903217aSDaniel P. Berrangé            try:
13034b17c0aSThomas Huth                with tmp_cache_file.open("xb") as dst:
13134b17c0aSThomas Huth                    with urllib.request.urlopen(self.url) as resp:
13234b17c0aSThomas Huth                        copyfileobj(resp, dst)
1337524e1b3SNicholas Piggin                        length_hdr = resp.getheader("Content-Length")
1347524e1b3SNicholas Piggin
1357524e1b3SNicholas Piggin                # Verify downloaded file size against length metadata, if
1367524e1b3SNicholas Piggin                # available.
1377524e1b3SNicholas Piggin                if length_hdr is not None:
1387524e1b3SNicholas Piggin                    length = int(length_hdr)
1397524e1b3SNicholas Piggin                    fsize = tmp_cache_file.stat().st_size
1407524e1b3SNicholas Piggin                    if fsize != length:
1417524e1b3SNicholas Piggin                        self.log.error("Unable to download %s: "
1427524e1b3SNicholas Piggin                                       "connection closed before "
1437524e1b3SNicholas Piggin                                       "transfer complete (%d/%d)",
1447524e1b3SNicholas Piggin                                       self.url, fsize, length)
1457524e1b3SNicholas Piggin                        tmp_cache_file.unlink()
1467524e1b3SNicholas Piggin                        continue
14734b17c0aSThomas Huth                break
14834b17c0aSThomas Huth            except FileExistsError:
14934b17c0aSThomas Huth                self.log.debug("%s already exists, "
15034b17c0aSThomas Huth                               "waiting for other thread to finish...",
15134b17c0aSThomas Huth                               tmp_cache_file)
15234b17c0aSThomas Huth                if self._wait_for_other_download(tmp_cache_file):
15334b17c0aSThomas Huth                    return str(self.cache_file)
15434b17c0aSThomas Huth                self.log.debug("%s seems to be stale, "
15534b17c0aSThomas Huth                               "deleting and retrying download...",
15634b17c0aSThomas Huth                               tmp_cache_file)
15734b17c0aSThomas Huth                tmp_cache_file.unlink()
15834b17c0aSThomas Huth                continue
159*28adad0aSNicholas Piggin            except HTTPError as e:
1609903217aSDaniel P. Berrangé                tmp_cache_file.unlink()
161*28adad0aSNicholas Piggin                self.log.error("Unable to download %s: HTTP error %d",
162*28adad0aSNicholas Piggin                               self.url, e.code)
163*28adad0aSNicholas Piggin                # Treat 404 as fatal, since it is highly likely to
164*28adad0aSNicholas Piggin                # indicate a broken test rather than a transient
165*28adad0aSNicholas Piggin                # server or networking problem
166*28adad0aSNicholas Piggin                if e.code == 404:
167*28adad0aSNicholas Piggin                    raise AssetError(self, "Unable to download: "
168*28adad0aSNicholas Piggin                                     "HTTP error %d" % e.code)
169*28adad0aSNicholas Piggin                continue
170*28adad0aSNicholas Piggin            except Exception as e:
171*28adad0aSNicholas Piggin                tmp_cache_file.unlink()
172*28adad0aSNicholas Piggin                raise AssetError(self, "Unable to download: " % e)
17334b17c0aSThomas Huth
174a5e8299dSNicholas Piggin        if not os.path.exists(tmp_cache_file):
175*28adad0aSNicholas Piggin            raise AssetError(self, "Download retries exceeded", transient=True)
176a5e8299dSNicholas Piggin
1779903217aSDaniel P. Berrangé        try:
1789903217aSDaniel P. Berrangé            # Set these just for informational purposes
1799903217aSDaniel P. Berrangé            os.setxattr(str(tmp_cache_file), "user.qemu-asset-url",
1809903217aSDaniel P. Berrangé                        self.url.encode('utf8'))
1819903217aSDaniel P. Berrangé            os.setxattr(str(tmp_cache_file), "user.qemu-asset-hash",
1829903217aSDaniel P. Berrangé                        self.hash.encode('utf8'))
1839903217aSDaniel P. Berrangé        except Exception as e:
1849903217aSDaniel P. Berrangé            self.log.debug("Unable to set xattr on %s: %s", tmp_cache_file, e)
1859903217aSDaniel P. Berrangé            pass
1869903217aSDaniel P. Berrangé
1879903217aSDaniel P. Berrangé        if not self._check(tmp_cache_file):
1889903217aSDaniel P. Berrangé            tmp_cache_file.unlink()
189*28adad0aSNicholas Piggin            raise AssetError(self, "Hash does not match %s" % self.hash)
1909903217aSDaniel P. Berrangé        tmp_cache_file.replace(self.cache_file)
191786bc225SDaniel P. Berrangé        # Remove write perms to stop tests accidentally modifying them
192786bc225SDaniel P. Berrangé        os.chmod(self.cache_file, stat.S_IRUSR | stat.S_IRGRP)
1939903217aSDaniel P. Berrangé
1949903217aSDaniel P. Berrangé        self.log.info("Cached %s at %s" % (self.url, self.cache_file))
1959903217aSDaniel P. Berrangé        return str(self.cache_file)
196f57213f8SDaniel P. Berrangé
197f57213f8SDaniel P. Berrangé    def precache_test(test):
198f57213f8SDaniel P. Berrangé        log = logging.getLogger('qemu-test')
199f57213f8SDaniel P. Berrangé        log.setLevel(logging.DEBUG)
200f57213f8SDaniel P. Berrangé        handler = logging.StreamHandler(sys.stdout)
201f57213f8SDaniel P. Berrangé        handler.setLevel(logging.DEBUG)
202f57213f8SDaniel P. Berrangé        formatter = logging.Formatter(
203f57213f8SDaniel P. Berrangé            '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
204f57213f8SDaniel P. Berrangé        handler.setFormatter(formatter)
205f57213f8SDaniel P. Berrangé        log.addHandler(handler)
206f57213f8SDaniel P. Berrangé        for name, asset in vars(test.__class__).items():
207f57213f8SDaniel P. Berrangé            if name.startswith("ASSET_") and type(asset) == Asset:
208f57213f8SDaniel P. Berrangé                log.info("Attempting to cache '%s'" % asset)
209674a750bSDaniel P. Berrangé                try:
210f57213f8SDaniel P. Berrangé                    asset.fetch()
211*28adad0aSNicholas Piggin                except AssetError as e:
212*28adad0aSNicholas Piggin                    if not e.transient:
213674a750bSDaniel P. Berrangé                        raise
214*28adad0aSNicholas Piggin                    log.error("%s: skipping asset precache" % e)
215674a750bSDaniel P. Berrangé
216f57213f8SDaniel P. Berrangé        log.removeHandler(handler)
217f57213f8SDaniel P. Berrangé
218f57213f8SDaniel P. Berrangé    def precache_suite(suite):
219f57213f8SDaniel P. Berrangé        for test in suite:
220f57213f8SDaniel P. Berrangé            if isinstance(test, unittest.TestSuite):
221f57213f8SDaniel P. Berrangé                Asset.precache_suite(test)
222f57213f8SDaniel P. Berrangé            elif isinstance(test, unittest.TestCase):
223f57213f8SDaniel P. Berrangé                Asset.precache_test(test)
224f57213f8SDaniel P. Berrangé
225f57213f8SDaniel P. Berrangé    def precache_suites(path, cacheTstamp):
226f57213f8SDaniel P. Berrangé        loader = unittest.loader.defaultTestLoader
227f57213f8SDaniel P. Berrangé        tests = loader.loadTestsFromNames([path], None)
228f57213f8SDaniel P. Berrangé
229f57213f8SDaniel P. Berrangé        with open(cacheTstamp, "w") as fh:
230f57213f8SDaniel P. Berrangé            Asset.precache_suite(tests)
231