mirror of
https://github.com/cloud-hypervisor/cloud-hypervisor.git
synced 2026-08-05 02:19:16 +00:00
This will allow us to remove the download steps from the build scripts themselves. Assisted-by: Claude:Opus-4.6 Signed-off-by: Rob Bradford <rbradford@meta.com>
297 lines
9.4 KiB
Python
Executable File
297 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
#
|
|
# Copyright © 2025 Meta Platforms, Inc.
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Download workload assets for Cloud Hypervisor integration tests.
|
|
|
|
Reads scripts/test_assets.yaml, downloads missing files, and verifies
|
|
SHA-1 checksums. Uses only the Python 3 standard library.
|
|
|
|
Usage:
|
|
fetch_workloads.py [--arch ARCH] [--test TEST] [--workloads-dir DIR]
|
|
[--verify-only] [--asset-file FILE] [-j JOBS]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import hashlib
|
|
import os
|
|
import platform
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
_print_lock = threading.Lock()
|
|
|
|
|
|
def parse_yaml(path: Path) -> list[dict]:
|
|
"""Minimal parser for the flat asset list in test_assets.yaml.
|
|
|
|
Handles only the subset of YAML used by this project: a top-level
|
|
``assets:`` key containing a list of mappings with scalar and
|
|
flow-sequence values. Does not handle nested structures, multi-line
|
|
strings, or anchors.
|
|
"""
|
|
assets: list[dict] = []
|
|
current: dict | None = None
|
|
|
|
with open(path) as f:
|
|
for line in f:
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if stripped == "assets:":
|
|
continue
|
|
if stripped.startswith("- "):
|
|
if current is not None:
|
|
assets.append(current)
|
|
current = {}
|
|
stripped = stripped[2:].strip()
|
|
if not stripped:
|
|
continue
|
|
|
|
if current is None:
|
|
continue
|
|
|
|
m = re.match(r"([a-zA-Z_]\w*):\s*(.*)", stripped)
|
|
if not m:
|
|
continue
|
|
key, value = m.group(1), m.group(2)
|
|
|
|
# Flow-sequence: [a, b, c]
|
|
seq = re.match(r"\[([^\]]*)\]", value)
|
|
if seq:
|
|
items = [s.strip() for s in seq.group(1).split(",") if s.strip()]
|
|
current[key] = items
|
|
else:
|
|
current[key] = value if value else None
|
|
|
|
if current is not None:
|
|
assets.append(current)
|
|
|
|
return assets
|
|
|
|
|
|
def sha1_file(path: Path) -> str:
|
|
h = hashlib.sha1()
|
|
with open(path, "rb") as f:
|
|
while True:
|
|
chunk = f.read(1 << 20)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _fmt_size(n: int) -> str:
|
|
size = float(n)
|
|
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
if size < 1024:
|
|
return f"{size:.1f} {unit}"
|
|
size /= 1024
|
|
return f"{size:.1f} TiB"
|
|
|
|
|
|
def _log(msg: str, **kwargs) -> None:
|
|
with _print_lock:
|
|
print(msg, **kwargs)
|
|
|
|
|
|
def _log_err(msg: str) -> None:
|
|
with _print_lock:
|
|
print(msg, file=sys.stderr)
|
|
|
|
|
|
def download(url: str, dest: Path, auth_token: str | None = None,
|
|
retries: int = 3, delay: float = 5.0,
|
|
show_progress: bool = True) -> bool:
|
|
"""Download *url* to *dest* with retries. Returns True on success."""
|
|
tmp = dest.parent / (dest.name + ".part")
|
|
headers = {}
|
|
host = urllib.parse.urlparse(url).hostname or ""
|
|
if auth_token and (host == "github.com" or host.endswith(".github.com")):
|
|
headers["Authorization"] = f"token {auth_token}"
|
|
|
|
for attempt in range(1, retries + 1):
|
|
try:
|
|
req = urllib.request.Request(url, headers=headers)
|
|
_log(f" Downloading {url} (attempt {attempt}/{retries})")
|
|
t0 = time.monotonic()
|
|
with urllib.request.urlopen(req, timeout=300) as resp, \
|
|
open(tmp, "wb") as out:
|
|
total = resp.headers.get("Content-Length")
|
|
total = int(total) if total else None
|
|
downloaded = 0
|
|
while True:
|
|
chunk = resp.read(1 << 20)
|
|
if not chunk:
|
|
break
|
|
out.write(chunk)
|
|
downloaded += len(chunk)
|
|
if show_progress:
|
|
with _print_lock:
|
|
if total:
|
|
pct = downloaded * 100 // total
|
|
print(f"\r {_fmt_size(downloaded)} / "
|
|
f"{_fmt_size(total)} ({pct}%)",
|
|
end="", flush=True)
|
|
else:
|
|
print(f"\r {_fmt_size(downloaded)}",
|
|
end="", flush=True)
|
|
elapsed = time.monotonic() - t0
|
|
if show_progress:
|
|
with _print_lock:
|
|
print()
|
|
_log(f" {_fmt_size(downloaded)} in {elapsed:.1f}s")
|
|
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
|
_log_err(f" Attempt {attempt} failed: {e}")
|
|
tmp.unlink(missing_ok=True)
|
|
if attempt < retries:
|
|
time.sleep(delay)
|
|
continue
|
|
|
|
tmp.rename(dest)
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def process_asset(asset: dict, workloads: Path, auth_token: str | None,
|
|
verify_only: bool, show_progress: bool) -> bool:
|
|
"""Process a single asset. Returns True on success."""
|
|
filename = asset["filename"]
|
|
if ".." in Path(filename).parts:
|
|
_log(f"SKIPPED {filename}: path traversal in filename")
|
|
return False
|
|
url = asset.get("url")
|
|
expected_sha1 = asset.get("sha1")
|
|
dest = workloads / filename
|
|
|
|
if dest.exists():
|
|
if expected_sha1:
|
|
actual = sha1_file(dest)
|
|
if actual != expected_sha1:
|
|
_log(f"MISMATCH {filename}: expected {expected_sha1}, got {actual}")
|
|
if verify_only:
|
|
return False
|
|
dest.unlink()
|
|
else:
|
|
_log(f"OK {filename}")
|
|
return True
|
|
else:
|
|
_log(f"OK {filename} (no checksum)")
|
|
return True
|
|
|
|
if verify_only:
|
|
_log(f"MISSING {filename}")
|
|
return False
|
|
|
|
if not url:
|
|
_log(f"MISSING {filename} (no URL to download from)")
|
|
return False
|
|
|
|
if not download(url, dest, auth_token, show_progress=show_progress):
|
|
_log(f"FAILED {filename}")
|
|
return False
|
|
|
|
if expected_sha1:
|
|
actual = sha1_file(dest)
|
|
if actual != expected_sha1:
|
|
_log(f"CORRUPT {filename}: expected {expected_sha1}, got {actual}")
|
|
dest.unlink()
|
|
return False
|
|
|
|
if (asset.get("executable") or "").lower() == "true":
|
|
dest.chmod(0o755)
|
|
|
|
_log(f"FETCHED {filename}")
|
|
return True
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--arch", default=platform.machine(),
|
|
help="Target architecture (default: host arch)")
|
|
parser.add_argument("--test", dest="test_filter", default=None,
|
|
help="Only fetch assets for this test group")
|
|
parser.add_argument("--workloads-dir", default=None,
|
|
help="Directory to store assets (default: ~/workloads)")
|
|
parser.add_argument("--verify-only", action="store_true",
|
|
help="Check existing files without downloading")
|
|
parser.add_argument("--asset-file", default=None,
|
|
help="Path to test_assets.yaml (default: alongside this script)")
|
|
parser.add_argument("-j", "--jobs", type=int, default=os.cpu_count() or 1,
|
|
help="Parallel downloads (default: number of CPUs, use 1 to disable)")
|
|
args = parser.parse_args()
|
|
|
|
script_dir = Path(__file__).resolve().parent
|
|
asset_file = Path(args.asset_file) if args.asset_file else script_dir / "test_assets.yaml"
|
|
|
|
if not asset_file.exists():
|
|
print(f"Error: asset file not found: {asset_file}", file=sys.stderr)
|
|
return 1
|
|
|
|
if args.workloads_dir:
|
|
workloads = Path(args.workloads_dir)
|
|
else:
|
|
workloads = Path.home() / "workloads"
|
|
|
|
workloads.mkdir(parents=True, exist_ok=True)
|
|
|
|
assets = parse_yaml(asset_file)
|
|
auth_token = os.environ.get("AUTH_DOWNLOAD_TOKEN")
|
|
|
|
filtered = []
|
|
for asset in assets:
|
|
arch_list = asset.get("arch") or []
|
|
if arch_list and args.arch not in arch_list:
|
|
continue
|
|
if args.test_filter:
|
|
test_list = asset.get("test") or []
|
|
if test_list and args.test_filter not in test_list:
|
|
continue
|
|
filtered.append(asset)
|
|
|
|
if not filtered:
|
|
print(f"No assets match arch={args.arch}"
|
|
+ (f" test={args.test_filter}" if args.test_filter else ""))
|
|
return 0
|
|
|
|
jobs = max(1, args.jobs)
|
|
show_progress = jobs == 1
|
|
|
|
errors = 0
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
futures = {
|
|
pool.submit(process_asset, asset, workloads, auth_token,
|
|
args.verify_only, show_progress): asset
|
|
for asset in filtered
|
|
}
|
|
for f in concurrent.futures.as_completed(futures):
|
|
try:
|
|
if not f.result():
|
|
errors += 1
|
|
except Exception as e:
|
|
asset = futures[f]
|
|
_log_err(f"EXCEPTION {asset.get('filename', '?')}: {e}")
|
|
errors += 1
|
|
|
|
if errors:
|
|
print(f"\n{errors} error(s)")
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|