download_kernel_archives.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. import argparse
  2. import base64
  3. import json
  4. import os
  5. import shutil
  6. import subprocess
  7. import tempfile
  8. import threading
  9. import time
  10. import urllib.parse
  11. import urllib.request
  12. import xml.etree.ElementTree as ET
  13. from concurrent.futures import ThreadPoolExecutor
  14. from typing import Any
  15. from urllib.error import HTTPError
  16. from urllib.request import Request
  17. from urllib.request import urlopen
  18. def parse_args() -> argparse.Namespace:
  19. parser = argparse.ArgumentParser()
  20. parser.add_argument("--android-version", required=True)
  21. parser.add_argument("--kernel-version", required=True)
  22. parser.add_argument("--os-patch-level", required=True)
  23. return parser.parse_args()
  24. def write_github_output(name: str, value: str) -> None:
  25. path = os.environ.get("GITHUB_OUTPUT")
  26. if not path:
  27. return
  28. with open(path, "a", encoding="utf-8") as f:
  29. f.write(f"{name}={value}\n")
  30. def main() -> int:
  31. args = parse_args()
  32. formatted_branch = f"{args.android_version}-{args.kernel_version}-{args.os_patch_level}"
  33. manifest_branch = f"common-{formatted_branch}"
  34. manifest_ref = f"refs/heads/{manifest_branch}"
  35. manifest_base = f"https://android.googlesource.com/kernel/manifest/+/{manifest_ref}/"
  36. target_repo = os.environ.get("GITHUB_REPOSITORY", "")
  37. github_token = os.environ.get("GITHUB_TOKEN", "")
  38. toolchain_map = {
  39. "clang/host/linux-x86": "clang",
  40. "prebuilts/rust": "rust",
  41. "prebuilts/clang-tools": "clang-tools",
  42. "prebuilts/build-tools": "build-tools",
  43. }
  44. release_assets_lock = threading.Lock()
  45. release_assets_cache: list[dict[str, Any]] | None = None
  46. release_info_lock = threading.Lock()
  47. release_info_cache: dict[str, Any] | None = None
  48. upload_locks_guard = threading.Lock()
  49. upload_locks: dict[tuple[str, str], threading.Lock] = {}
  50. def is_deprecated_branch(branch: str) -> bool:
  51. try:
  52. out = subprocess.check_output(
  53. ["git", "ls-remote", "https://android.googlesource.com/kernel/common", branch],
  54. text=True,
  55. stderr=subprocess.STDOUT,
  56. timeout=60,
  57. )
  58. return "deprecated" in out
  59. except Exception:
  60. return False
  61. deprecated = is_deprecated_branch(formatted_branch)
  62. write_github_output("deprecated", str(deprecated).lower())
  63. print(f"Manifest branch: {manifest_branch}")
  64. print(f"Deprecated: {deprecated}")
  65. def fetch_gitiles_file_text(file_name: str) -> str:
  66. url = f"{manifest_base}{file_name}?format=TEXT"
  67. req = Request(url, headers={"User-Agent": "actions-download-kernel"})
  68. with urlopen(req, timeout=60) as resp:
  69. data = resp.read()
  70. decoded = base64.b64decode(data)
  71. text = decoded.decode("utf-8", errors="replace")
  72. if deprecated:
  73. text = text.replace(f"\"{formatted_branch}\"", f"\"deprecated/{formatted_branch}\"")
  74. return text
  75. def load_manifest_with_includes(entry_file: str) -> tuple[ET.Element, list[tuple[str, ET.Element]]]:
  76. seen_files: set[str] = set()
  77. to_process = [entry_file]
  78. combined = ET.Element("manifest")
  79. combined_default: ET.Element | None = None
  80. combined_remotes: dict[str, ET.Element] = {}
  81. projects: list[ET.Element] = []
  82. link_copy: list[tuple[str, ET.Element]] = []
  83. while to_process:
  84. file_name = to_process.pop()
  85. if file_name in seen_files:
  86. continue
  87. seen_files.add(file_name)
  88. xml_text = fetch_gitiles_file_text(file_name)
  89. root = ET.fromstring(xml_text)
  90. for include in root.findall("include"):
  91. inc_name = include.get("name")
  92. if inc_name:
  93. to_process.append(inc_name)
  94. for remote in root.findall("remote"):
  95. remote_name = remote.get("name")
  96. if remote_name and remote_name not in combined_remotes:
  97. combined_remotes[remote_name] = remote
  98. if combined_default is None:
  99. d = root.find("default")
  100. if d is not None:
  101. combined_default = d
  102. for project in root.findall("project"):
  103. projects.append(project)
  104. path = project.get("path", project.get("name") or "")
  105. for child in list(project):
  106. if child.tag in ("linkfile", "copyfile"):
  107. link_copy.append((path, child))
  108. for r in combined_remotes.values():
  109. combined.append(r)
  110. if combined_default is not None:
  111. combined.append(combined_default)
  112. for p in projects:
  113. combined.append(p)
  114. return combined, link_copy
  115. manifest_root, link_copy = load_manifest_with_includes("default.xml")
  116. remotes = {r.get("name"): (r.get("fetch") or "").rstrip("/") for r in manifest_root.findall("remote")}
  117. default = manifest_root.find("default")
  118. def_remote = default.get("remote") if default is not None else None
  119. def_rev = default.get("revision") if default is not None else None
  120. def get_toolchain_label(project_name: str) -> str | None:
  121. for key, value in toolchain_map.items():
  122. if key in project_name:
  123. return value
  124. return None
  125. def github_api_get_json(url: str) -> object:
  126. headers = {
  127. "Accept": "application/vnd.github.v3+json",
  128. "User-Agent": "actions-download-kernel",
  129. }
  130. if github_token:
  131. headers["Authorization"] = f"token {github_token}"
  132. req = Request(url, headers=headers)
  133. with urlopen(req, timeout=60) as resp:
  134. data = resp.read()
  135. return json.loads(data.decode("utf-8", errors="replace"))
  136. def github_api_post_json(url: str, payload: dict[str, Any]) -> object:
  137. headers = {
  138. "Accept": "application/vnd.github.v3+json",
  139. "User-Agent": "actions-download-kernel",
  140. "Content-Type": "application/json",
  141. }
  142. if github_token:
  143. headers["Authorization"] = f"token {github_token}"
  144. data = json.dumps(payload).encode("utf-8")
  145. req = Request(url, headers=headers, data=data, method="POST")
  146. with urlopen(req, timeout=60) as resp:
  147. body = resp.read()
  148. return json.loads(body.decode("utf-8", errors="replace"))
  149. def get_toolchain_release_assets() -> list[dict[str, Any]]:
  150. nonlocal release_assets_cache
  151. with release_assets_lock:
  152. if release_assets_cache is not None:
  153. return release_assets_cache
  154. if not target_repo:
  155. release_assets_cache = []
  156. return release_assets_cache
  157. url = f"https://api.github.com/repos/{target_repo}/releases?per_page=100"
  158. try:
  159. releases = github_api_get_json(url)
  160. if not isinstance(releases, list):
  161. release_assets_cache = []
  162. return release_assets_cache
  163. release = next(
  164. (
  165. r
  166. for r in releases
  167. if isinstance(r, dict)
  168. and (r.get("name") == "Toolchains Mirror Cache" or r.get("tag_name") == "toolchain-cache")
  169. ),
  170. None,
  171. )
  172. if not release:
  173. release_assets_cache = []
  174. return release_assets_cache
  175. assets = release.get("assets", [])
  176. release_assets_cache = assets if isinstance(assets, list) else []
  177. return release_assets_cache
  178. except Exception:
  179. release_assets_cache = []
  180. return release_assets_cache
  181. def get_or_create_toolchain_release() -> dict[str, Any] | None:
  182. nonlocal release_info_cache, release_assets_cache
  183. with release_info_lock:
  184. if release_info_cache is not None:
  185. return release_info_cache
  186. if not target_repo or not github_token:
  187. release_info_cache = None
  188. return release_info_cache
  189. try:
  190. url = f"https://api.github.com/repos/{target_repo}/releases/tags/toolchain-cache"
  191. release = github_api_get_json(url)
  192. if isinstance(release, dict):
  193. release_info_cache = release
  194. assets = release.get("assets", [])
  195. with release_assets_lock:
  196. release_assets_cache = assets if isinstance(assets, list) else []
  197. return release_info_cache
  198. except Exception:
  199. pass
  200. try:
  201. create_url = f"https://api.github.com/repos/{target_repo}/releases"
  202. release = github_api_post_json(
  203. create_url,
  204. {
  205. "tag_name": "toolchain-cache",
  206. "name": "Toolchains Mirror Cache",
  207. "draft": False,
  208. "prerelease": False,
  209. },
  210. )
  211. if isinstance(release, dict):
  212. release_info_cache = release
  213. assets = release.get("assets", [])
  214. with release_assets_lock:
  215. release_assets_cache = assets if isinstance(assets, list) else []
  216. return release_info_cache
  217. except Exception:
  218. try:
  219. url = f"https://api.github.com/repos/{target_repo}/releases/tags/toolchain-cache"
  220. release = github_api_get_json(url)
  221. if isinstance(release, dict):
  222. release_info_cache = release
  223. assets = release.get("assets", [])
  224. with release_assets_lock:
  225. release_assets_cache = assets if isinstance(assets, list) else []
  226. return release_info_cache
  227. except Exception:
  228. release_info_cache = None
  229. return release_info_cache
  230. release_info_cache = None
  231. return release_info_cache
  232. def download_github_release_asset(api_url: str, out_path: str) -> None:
  233. headers = {
  234. "Accept": "application/octet-stream",
  235. "User-Agent": "actions-download-kernel",
  236. }
  237. if github_token:
  238. headers["Authorization"] = f"token {github_token}"
  239. req = Request(api_url, headers=headers)
  240. with urlopen(req, timeout=120) as resp, open(out_path, "wb") as f:
  241. shutil.copyfileobj(resp, f, length=1024 * 1024)
  242. def run_curl_download(url: str, out_path: str) -> None:
  243. subprocess.run(
  244. ["curl", "-LfsS", "--retry", "5", "--connect-timeout", "30", "-o", out_path, url],
  245. check=True,
  246. text=True,
  247. )
  248. def extract_tar_gz(archive_path: str, dest_dir: str, strip_components: int) -> None:
  249. cmd = ["tar", "-xzf", archive_path, "-C", dest_dir]
  250. if strip_components > 0:
  251. cmd.extend(["--strip-components", str(strip_components)])
  252. subprocess.run(cmd, check=True, text=True)
  253. def upload_release_asset(upload_url_template: str, file_path: str, asset_name: str) -> None:
  254. upload_url = upload_url_template.split("{", 1)[0]
  255. url = f"{upload_url}?name={urllib.parse.quote(asset_name, safe='')}"
  256. tmp_fd, tmp_resp = tempfile.mkstemp(prefix="gh-release-upload-", suffix=".json")
  257. os.close(tmp_fd)
  258. cmd = [
  259. "curl",
  260. "-LsS",
  261. "-X",
  262. "POST",
  263. "-H",
  264. "Accept: application/vnd.github.v3+json",
  265. "-H",
  266. "Content-Type: application/octet-stream",
  267. "-H",
  268. "User-Agent: actions-download-kernel",
  269. "-o",
  270. tmp_resp,
  271. "-w",
  272. "%{http_code}",
  273. ]
  274. if github_token:
  275. cmd.extend(["-H", f"Authorization: token {github_token}"])
  276. cmd.extend(["--data-binary", f"@{file_path}", url])
  277. try:
  278. res = subprocess.run(cmd, check=True, text=True, capture_output=True)
  279. code = (res.stdout or "").strip()
  280. if code in {"200", "201"}:
  281. return
  282. if code == "422":
  283. return
  284. body = ""
  285. try:
  286. with open(tmp_resp, "r", encoding="utf-8", errors="replace") as f:
  287. body = f.read()
  288. except Exception:
  289. body = ""
  290. raise RuntimeError(f"Release asset upload failed (HTTP {code}): {body}")
  291. finally:
  292. if os.path.exists(tmp_resp):
  293. os.remove(tmp_resp)
  294. def ensure_toolchain_cached(label: str, rev: str, src_dir: str) -> None:
  295. nonlocal release_assets_cache
  296. if not github_token or not target_repo:
  297. return
  298. cache_key = (label, rev)
  299. with upload_locks_guard:
  300. lock = upload_locks.get(cache_key)
  301. if lock is None:
  302. lock = threading.Lock()
  303. upload_locks[cache_key] = lock
  304. with lock:
  305. try:
  306. base_filename = f"{label}-{rev}.tar.gz"
  307. assets = get_toolchain_release_assets()
  308. for a in assets:
  309. if isinstance(a, dict):
  310. asset_name = a.get("name")
  311. if isinstance(asset_name, str) and asset_name.startswith(base_filename):
  312. return
  313. release = get_or_create_toolchain_release()
  314. if not release:
  315. return
  316. upload_url_template = release.get("upload_url")
  317. if not isinstance(upload_url_template, str) or not upload_url_template:
  318. return
  319. tmp_dir = tempfile.mkdtemp(prefix="toolchain-cache-")
  320. try:
  321. archive_path = os.path.join(tmp_dir, base_filename)
  322. subprocess.run(
  323. ["tar", "-I", "gzip -1", "-cf", archive_path, "-C", src_dir, "."],
  324. check=True,
  325. text=True,
  326. )
  327. size = os.path.getsize(archive_path)
  328. max_part = 1900 * 1024 * 1024
  329. if size <= max_part:
  330. upload_release_asset(upload_url_template, archive_path, base_filename)
  331. else:
  332. subprocess.run(
  333. ["split", "-b", str(max_part), "-d", "-a", "2", archive_path, f"{archive_path}.part"],
  334. check=True,
  335. text=True,
  336. )
  337. for name in sorted(os.listdir(tmp_dir)):
  338. if name.startswith(f"{base_filename}.part"):
  339. part_path = os.path.join(tmp_dir, name)
  340. upload_release_asset(upload_url_template, part_path, name)
  341. with release_assets_lock:
  342. release_assets_cache = None
  343. finally:
  344. shutil.rmtree(tmp_dir, ignore_errors=True)
  345. except Exception:
  346. return
  347. def try_extract_toolchain_cache(project_name: str, rev: str, dest_dir: str, strip_components: int) -> bool:
  348. label = get_toolchain_label(project_name)
  349. if not label:
  350. return False
  351. assets = get_toolchain_release_assets()
  352. if not assets:
  353. return False
  354. base_filename = f"{label}-{rev}.tar.gz"
  355. matching: list[tuple[str, str]] = []
  356. for a in assets:
  357. if not isinstance(a, dict):
  358. continue
  359. asset_name = a.get("name")
  360. asset_url = a.get("url")
  361. if isinstance(asset_name, str) and isinstance(asset_url, str) and asset_name.startswith(base_filename):
  362. matching.append((asset_name, asset_url))
  363. if not matching:
  364. return False
  365. matching.sort(key=lambda x: x[0])
  366. part_paths: list[str] = []
  367. try:
  368. for asset_name, asset_url in matching:
  369. part_path = os.path.abspath(asset_name)
  370. download_github_release_asset(asset_url, part_path)
  371. part_paths.append(part_path)
  372. if len(part_paths) == 1 and part_paths[0].endswith(".tar.gz"):
  373. extract_tar_gz(part_paths[0], dest_dir, strip_components)
  374. else:
  375. tmp_fd, tmp_path = tempfile.mkstemp(prefix="toolchain-", suffix=".tar.gz")
  376. os.close(tmp_fd)
  377. try:
  378. with open(tmp_path, "wb") as out_f:
  379. for p in part_paths:
  380. with open(p, "rb") as in_f:
  381. shutil.copyfileobj(in_f, out_f, length=1024 * 1024)
  382. extract_tar_gz(tmp_path, dest_dir, strip_components)
  383. finally:
  384. if os.path.exists(tmp_path):
  385. os.remove(tmp_path)
  386. return True
  387. except Exception:
  388. return False
  389. finally:
  390. for p in part_paths:
  391. if os.path.exists(p):
  392. os.remove(p)
  393. def url_quote_rev(rev: str) -> str:
  394. return urllib.parse.quote(rev, safe="")
  395. def googlesource_archive_urls(repo_url: str, rev: str) -> list[str]:
  396. qrev = url_quote_rev(rev)
  397. if rev.startswith("refs/"):
  398. return [f"{repo_url}/+archive/{qrev}.tar.gz"]
  399. return [
  400. f"{repo_url}/+archive/{url_quote_rev(f'refs/heads/{rev}')}.tar.gz",
  401. f"{repo_url}/+archive/{url_quote_rev(f'refs/tags/{rev}')}.tar.gz",
  402. f"{repo_url}/+archive/{qrev}.tar.gz",
  403. ]
  404. def github_archive_urls(repo_url: str, rev: str) -> list[str]:
  405. if rev.startswith("refs/"):
  406. return [f"{repo_url}/archive/{rev}.tar.gz"]
  407. return [
  408. f"{repo_url}/archive/refs/heads/{rev}.tar.gz",
  409. f"{repo_url}/archive/refs/tags/{rev}.tar.gz",
  410. f"{repo_url}/archive/{rev}.tar.gz",
  411. ]
  412. def gitlab_archive_urls(repo_url: str, rev: str, repo_basename: str) -> list[str]:
  413. qrev = urllib.parse.quote(rev, safe="")
  414. return [f"{repo_url}/-/archive/{qrev}/{repo_basename}-{qrev}.tar.gz"]
  415. def build_project_urls(base_url: str, name: str, rev: str) -> tuple[list[str], int]:
  416. repo_url = f"{base_url}/{name}"
  417. if "github.com" in base_url:
  418. return github_archive_urls(repo_url, rev), 1
  419. if "googlesource.com" in base_url or "android.googlesource.com" in base_url:
  420. return googlesource_archive_urls(repo_url, rev), 0
  421. if "git.codelinaro.org" in base_url or "gitlab" in base_url:
  422. repo_basename = name.rstrip("/").split("/")[-1]
  423. return gitlab_archive_urls(repo_url, rev, repo_basename), 1
  424. return [], 0
  425. def sync_project(task: tuple[str, str, str, str]) -> dict[str, Any]:
  426. name, path, rev, base_url = task
  427. start_time = time.time()
  428. dest_dir = os.path.abspath(path if path not in ("./", ".") else ".")
  429. os.makedirs(dest_dir, exist_ok=True)
  430. try:
  431. urls, strip_components = build_project_urls(base_url, name, rev)
  432. if not urls:
  433. return {
  434. "ok": False,
  435. "name": name,
  436. "path": path,
  437. "rev": rev,
  438. "base_url": base_url,
  439. "error": "no supported remote",
  440. "urls": [],
  441. }
  442. if try_extract_toolchain_cache(name, rev, dest_dir, strip_components):
  443. duration = time.time() - start_time
  444. print(f"Synced {name} -> {path} ({duration:.2f}s)")
  445. return {"ok": True}
  446. tmp_fd, tmp_path = tempfile.mkstemp(prefix="kernel-src-", suffix=".tar.gz")
  447. os.close(tmp_fd)
  448. try:
  449. last_err: str | None = None
  450. for url in urls:
  451. try:
  452. run_curl_download(url, tmp_path)
  453. extract_tar_gz(tmp_path, dest_dir, strip_components)
  454. label = get_toolchain_label(name)
  455. if label:
  456. ensure_toolchain_cached(label, rev, dest_dir)
  457. duration = time.time() - start_time
  458. print(f"Synced {name} -> {path} ({duration:.2f}s)")
  459. return {"ok": True}
  460. except subprocess.CalledProcessError as e:
  461. last_err = str(e)
  462. continue
  463. return {
  464. "ok": False,
  465. "name": name,
  466. "path": path,
  467. "rev": rev,
  468. "base_url": base_url,
  469. "error": last_err or "download/extract failed",
  470. "urls": urls,
  471. }
  472. finally:
  473. if os.path.exists(tmp_path):
  474. os.remove(tmp_path)
  475. except Exception as e:
  476. return {
  477. "ok": False,
  478. "name": name,
  479. "path": path,
  480. "rev": rev,
  481. "base_url": base_url,
  482. "error": f"unexpected exception: {e}",
  483. "urls": [],
  484. }
  485. sync_tasks: list[tuple[str, str, str, str]] = []
  486. for project in manifest_root.findall("project"):
  487. name = project.get("name")
  488. if not name:
  489. continue
  490. path = project.get("path", name)
  491. remote_name = project.get("remote", def_remote)
  492. if not remote_name:
  493. continue
  494. base_url = remotes.get(remote_name)
  495. if not base_url:
  496. continue
  497. rev = project.get("revision", def_rev)
  498. if not rev:
  499. continue
  500. sync_tasks.append((name, path, rev, base_url))
  501. max_workers = min(32, (os.cpu_count() or 2) * 2)
  502. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  503. results: list[dict[str, Any]] = list(executor.map(sync_project, sync_tasks))
  504. failed = [r for r in results if not r.get("ok")]
  505. if failed:
  506. for r in failed[:25]:
  507. print(
  508. f"[FAIL] {r.get('name')} -> {r.get('path')} rev={r.get('rev')} remote={r.get('base_url')} err={r.get('error')}"
  509. )
  510. for u in (r.get("urls") or [])[:5]:
  511. print(f" url={u}")
  512. return 1
  513. for project_path, child in link_copy:
  514. src_rel = child.get("src")
  515. dest_rel = child.get("dest")
  516. if not src_rel or not dest_rel:
  517. continue
  518. src_path = os.path.join(os.getcwd(), project_path, src_rel)
  519. dest_path = os.path.join(os.getcwd(), dest_rel)
  520. os.makedirs(os.path.dirname(dest_path), exist_ok=True)
  521. if child.tag == "linkfile":
  522. if os.path.lexists(dest_path):
  523. os.remove(dest_path)
  524. rel_target = os.path.relpath(src_path, os.path.dirname(dest_path))
  525. os.symlink(rel_target, dest_path)
  526. elif child.tag == "copyfile":
  527. shutil.copy2(src_path, dest_path)
  528. return 0
  529. if __name__ == "__main__":
  530. raise SystemExit(main())