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