summaryrefslogtreecommitdiffstats
path: root/scripts/update-kernel-cachyos.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/update-kernel-cachyos.py')
-rw-r--r--scripts/update-kernel-cachyos.py223
1 files changed, 223 insertions, 0 deletions
diff --git a/scripts/update-kernel-cachyos.py b/scripts/update-kernel-cachyos.py
new file mode 100644
index 0000000..2c9bd19
--- /dev/null
+++ b/scripts/update-kernel-cachyos.py
@@ -0,0 +1,223 @@
+#!/usr/bin/env python3
+"""Prepare a CachyOS kernel recipe from the latest AUR package release."""
+
+import hashlib
+import json
+import os
+import posixpath
+import re
+import subprocess
+import sys
+import tarfile
+import tempfile
+import urllib.error
+import urllib.request
+from pathlib import Path, PurePosixPath
+
+ROOT = Path(__file__).resolve().parent.parent
+AUR_URL = "https://aur.archlinux.org/rpc/v5/info/linux-cachyos"
+MIRROR = "https://mirror.cachyos.org/repo/x86_64_v3/cachyos-v3"
+RECIPE = re.compile(r"kernel-cachyos-([0-9]+(?:\.[0-9]+)*)(?:-r([1-9][0-9]*))?\.exheres-0\Z")
+RELEASE = re.compile(r"([0-9]+(?:\.[0-9]+)*)-([1-9][0-9]*)\Z")
+MAX_DOWNLOAD = 1_500_000_000
+
+
+class UpdateError(Exception):
+ pass
+
+
+def release_key(value):
+ match = RELEASE.fullmatch(value)
+ if not match:
+ raise UpdateError(f"Invalid upstream release: {value!r}")
+ return tuple(map(int, match.group(1).split("."))), int(match.group(2))
+
+
+def recipe_key(path):
+ match = RECIPE.fullmatch(path.name)
+ if not match:
+ raise UpdateError(f"Unexpected kernel recipe: {path.name}")
+ return tuple(map(int, match.group(1).split("."))), int(match.group(2) or 0)
+
+
+def recipe_release(path):
+ match = re.search(r'^MY_RELEASE="\$\{PV\}-([1-9][0-9]*)"$', path.read_text(), re.MULTILINE)
+ if not match:
+ raise UpdateError(f"Missing literal MY_RELEASE in {path.name}")
+ version = RECIPE.fullmatch(path.name).group(1)
+ return f"{version}-{match.group(1)}"
+
+
+def latest_aur_release():
+ try:
+ with urllib.request.urlopen(AUR_URL, timeout=20) as response:
+ data = json.load(response)
+ except (urllib.error.URLError, TimeoutError, ValueError) as error:
+ raise UpdateError(f"AUR request failed (rate limit or network issue): {error}") from error
+ if not isinstance(data, dict) or data.get("resultcount") != 1 or not isinstance(data.get("results"), list) or len(data["results"]) != 1:
+ raise UpdateError("AUR did not return exactly one linux-cachyos result")
+ result = data["results"][0]
+ if not isinstance(result, dict) or result.get("Name") != "linux-cachyos" or not isinstance(result.get("Version"), str):
+ raise UpdateError("AUR returned an unexpected package or version")
+ release_key(result["Version"])
+ return result["Version"]
+
+
+def download(url, destination):
+ digest = hashlib.sha256()
+ size = 0
+ try:
+ request = urllib.request.Request(url, headers={"User-Agent": "kernel-cachyos-updater/1.0"})
+ with urllib.request.urlopen(request, timeout=60) as response, destination.open("xb") as output:
+ while chunk := response.read(1024 * 1024):
+ size += len(chunk)
+ if size > MAX_DOWNLOAD:
+ raise UpdateError(f"Archive exceeds size limit: {url}")
+ digest.update(chunk)
+ output.write(chunk)
+ except (urllib.error.URLError, OSError, TimeoutError) as error:
+ raise UpdateError(f"Cannot download {url}: {error}") from error
+ if not size:
+ raise UpdateError(f"Empty archive: {url}")
+ return digest.hexdigest()
+
+
+def inspect_archive(archive, name, release):
+ try:
+ with archive.open("rb") as source, subprocess.Popen(
+ ["zstd", "-dc"], stdin=source, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
+ ) as decompressor:
+ try:
+ with tarfile.open(fileobj=decompressor.stdout, mode="r|") as tar:
+ paths = set()
+ regular = set()
+ metadata = None
+ for member in tar:
+ path = PurePosixPath(member.name.removeprefix("./"))
+ if path.is_absolute() or ".." in path.parts or str(path) == ".":
+ raise UpdateError(f"Unsafe archive member: {member.name!r}")
+ if member.issym() or member.islnk():
+ target = member.linkname
+ resolved = posixpath.normpath(posixpath.join(posixpath.dirname(str(path)), target))
+ if target.startswith("/") or resolved == ".." or resolved.startswith("../"):
+ raise UpdateError(f"Unsafe archive link: {member.name!r}")
+ paths.add(str(path))
+ if member.isfile():
+ regular.add(str(path))
+ if str(path) == ".PKGINFO":
+ if not member.isfile() or member.size > 64_000:
+ raise UpdateError(f"Invalid .PKGINFO in {archive.name}")
+ metadata = tar.extractfile(member).read().decode("utf-8")
+ # tar can stop at the end-of-archive marker before zstd reaches EOF.
+ for _ in iter(lambda: decompressor.stdout.read(1024 * 1024), b""):
+ pass
+ if decompressor.wait() != 0:
+ raise UpdateError(f"Cannot decompress {archive.name}")
+ except BaseException:
+ decompressor.kill()
+ decompressor.wait()
+ raise
+ except (tarfile.TarError, UnicodeError, OSError) as error:
+ raise UpdateError(f"Cannot inspect {archive.name}: {error}") from error
+ if metadata is None:
+ raise UpdateError(f"{archive.name} has no .PKGINFO")
+ fields = {}
+ for line in metadata.splitlines():
+ if " = " in line:
+ key, value = line.split(" = ", 1)
+ fields.setdefault(key, []).append(value)
+ expected = {"pkgname": name, "pkgver": release, "arch": "x86_64_v3"}
+ for key, value in expected.items():
+ if fields.get(key) != [value]:
+ raise UpdateError(f"{archive.name}: expected {key} = {value!r}, got {fields.get(key)!r}")
+ module = f"usr/lib/modules/{release}-cachyos"
+ if name == "linux-cachyos":
+ required = (f"{module}/vmlinuz",)
+ prefixes = (f"{module}/kernel/",)
+ else:
+ required = (f"{module}/build/.config", f"{module}/build/Module.symvers", f"{module}/build/System.map")
+ prefixes = (f"{module}/vdso/",)
+ if any(path not in regular for path in required) or any(
+ not any(path.startswith(prefix) for path in regular) for prefix in prefixes
+ ):
+ raise UpdateError(f"{archive.name}: expected kernel/module/header paths are missing")
+
+
+def replace_once(text, pattern, replacement):
+ result, count = re.subn(pattern, replacement, text, flags=re.MULTILINE)
+ if count != 1:
+ raise UpdateError(f"Recipe template did not match exactly once: {pattern}")
+ return result
+
+
+def render_recipe(template, release, hashes):
+ pkgrel = release_key(release)[1]
+ text = replace_once(template, r'^MY_RELEASE="\$\{PV\}-[1-9][0-9]*"$', f'MY_RELEASE="${{PV}}-{pkgrel}"')
+ for variable, digest in (("MY_KERNEL", hashes["linux-cachyos"]), ("MY_HEADERS", hashes["linux-cachyos-headers"])):
+ pattern = rf'("\${{{variable}}}"\) expected=)[0-9a-f]{{64}}( ;;)'
+ text = replace_once(text, pattern, lambda match: f"{match.group(1)}{digest}{match.group(2)}")
+ return text
+
+
+def update(root=ROOT):
+ package_dir = root / "packages/sys-kernel/kernel-cachyos"
+ recipes = list(package_dir.glob("*.exheres-0"))
+ if len(recipes) != 1:
+ raise UpdateError(f"Expected exactly one kernel recipe, found {len(recipes)}")
+ latest = recipes[0]
+ current = recipe_release(latest)
+ if recipe_key(latest)[1] != release_key(current)[1] - 1:
+ raise UpdateError(f"Recipe revision does not match upstream pkgrel: {latest.name}")
+ template = latest.read_text()
+ if not re.search(r'^SLOT="0"$', template, re.MULTILINE):
+ raise UpdateError(f"Kernel recipe must use SLOT=0: {latest.name}")
+ upstream = latest_aur_release()
+ if release_key(upstream) == release_key(current):
+ print(f"Already current: {upstream}")
+ return
+ if release_key(upstream) < release_key(current):
+ raise UpdateError(f"AUR release {upstream} is older than recipe release {current}")
+ version = upstream.rsplit("-", 1)[0]
+ if version == current.rsplit("-", 1)[0] and release_key(upstream)[1] != release_key(current)[1] + 1:
+ raise UpdateError("Upstream pkgrel skipped a release; review missing kernel revisions")
+ revision = recipe_key(latest)[1] + 1 if version == current.rsplit("-", 1)[0] else 0
+ suffix = f"-r{revision}" if revision else ""
+ target = package_dir / f"kernel-cachyos-{version}{suffix}.exheres-0"
+ if target.exists():
+ raise UpdateError(f"Refusing to overwrite {target}")
+
+ hashes = {}
+ with tempfile.TemporaryDirectory(prefix="kernel-cachyos-") as temporary:
+ for name in ("linux-cachyos", "linux-cachyos-headers"):
+ directory = Path(temporary) / name
+ directory.mkdir()
+ archive = directory / f"{name}-{upstream}-x86_64_v3.pkg.tar.zst"
+ hashes[name] = download(f"{MIRROR}/{archive.name}", archive)
+ inspect_archive(archive, name, upstream)
+
+ content = render_recipe(template, upstream, hashes)
+ with tempfile.TemporaryDirectory(prefix=".kernel-recipe-", dir=package_dir) as temporary:
+ staged = Path(temporary) / target.name
+ staged.write_text(content)
+ result = subprocess.run(["bash", "-n", str(staged)], capture_output=True, text=True, check=False)
+ if result.returncode:
+ raise UpdateError(f"Generated recipe failed bash -n: {result.stderr.strip()}")
+ # Publish a complete file without replacing one another process created.
+ os.link(staged, target)
+ try:
+ latest.unlink()
+ except OSError as error:
+ target.unlink()
+ raise UpdateError(f"Could not remove old recipe: {error}") from error
+ print(f"Created {target.relative_to(root)} for upstream {upstream}")
+ print(f"Removed {latest.relative_to(root)}")
+ for name, digest in hashes.items():
+ print(f"{name}: SHA-256 {digest} (verify against independently published hashes)")
+
+
+if __name__ == "__main__":
+ try:
+ update()
+ except (UpdateError, OSError, subprocess.SubprocessError) as error:
+ print(f"ERROR: {error}", file=sys.stderr)
+ sys.exit(1)