Files
image-build/tools/export-private-modules.py
2026-09-07 14:52:03 +00:00

75 lines
2.8 KiB
Python

#!/usr/bin/env python3
"""Export exact committed offline-bootstrap tooling; never download anything."""
import argparse
import hashlib
import json
import pathlib
import re
import subprocess
SOURCE = "https://code.fritzlab.net/action/image-build"
FILES = {
"tools/private-modules/main.go": "tools/private-modules.go",
"tools/private-modules/main_test.go": "tools/private-modules_test.go",
}
def committed(root, *args):
return subprocess.check_output(["git", "-C", str(root), *args])
def export(root, target, revision, check=False):
if not re.fullmatch(r"[0-9a-f]{40}", revision):
raise ValueError("source revision must be a full Git commit ID")
resolved = committed(root, "rev-parse", revision + "^{commit}").decode().strip()
if resolved != revision:
raise ValueError("source revision is not a commit")
result = {}
entries = []
for source, destination in FILES.items():
tree = committed(root, "ls-tree", revision, "--", source).decode()
if not tree.startswith("100644 blob ") or not tree.endswith("\t" + source + "\n"):
raise ValueError("source must be one regular committed file")
data = committed(root, "show", revision + ":" + source)
if not data or len(data) > 128 * 1024:
raise ValueError("source exceeds its bounded export")
result[destination] = data
entries.append({"source": source, "destination": destination,
"sha256": hashlib.sha256(data).hexdigest()})
result["tools/private-modules.source.json"] = (json.dumps({
"version": 1, "repository": SOURCE, "revision": revision, "files": entries,
}, indent=2) + "\n").encode()
result["tools/private-modules.sha256"] = "".join(
hashlib.sha256(data).hexdigest() + " " + path + "\n"
for path, data in sorted(result.items())
).encode()
target = target.resolve(strict=True)
tools_dir = target / "tools"
if tools_dir.is_symlink():
raise ValueError("target tools directory must not be a symlink")
if not check:
tools_dir.mkdir(exist_ok=True)
for path, data in result.items():
destination = target / path
if destination.is_symlink():
raise ValueError("target file must not be a symlink")
if check:
if destination.read_bytes() != data:
raise ValueError("export differs from pinned canonical source: " + path)
else:
destination.write_bytes(data)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("target", type=pathlib.Path)
parser.add_argument("--revision", required=True)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
export(pathlib.Path(__file__).resolve().parents[1], args.target,
args.revision, args.check)
if __name__ == "__main__":
main()