Files
matrix-screen-controller/核桃派软件源代码/scripts/build_sd_image.py
T

269 lines
11 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import gzip
import hashlib
import json
from pathlib import Path
import shutil
import sys
import tarfile
SOURCE_ROOT = Path(__file__).resolve().parents[1]
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from scripts.fat16_image import Fat16Image
from scripts.image_config import PRODUCT_ID, create_config_file, read_config_file, validate_config
from scripts.debian_closure import IMAGE_DEBIAN_ROOTS, verify_local_closure
from scripts.kernel_artifact import KernelArtifactInfo, verify_kernel_dependency, verify_source_dependency
from scripts.boot_space import DEFAULT_SAFETY_BYTES, calculate_budget, candidate_sizes_from_dependency
EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"}
NATIVE_BUILD_OUTPUTS = {
"app/display/native/libh618_hub75.so",
"app/display/native/hub75_benchmark",
"app/display/native/hub75_native_test",
"app/display/native/hub75_safeoff",
}
IMAGE_FORMAT_VERSION = 3
BUNDLE_IMAGE_PATH = "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ"
def _allowed_source(path: Path, root: Path) -> bool:
relative = path.relative_to(root)
if any(part in EXCLUDED_PARTS for part in relative.parts):
return False
if relative.as_posix() in NATIVE_BUILD_OUTPUTS:
return False
return path.suffix not in {".pyc", ".pyo"}
def _add_file(tar: tarfile.TarFile, path: Path, name: str) -> None:
info = tar.gettarinfo(str(path), arcname=name)
info.uid = info.gid = 0
info.uname = info.gname = "root"
info.mtime = 0
info.mode = 0o755 if path.suffix == ".sh" else 0o644
with path.open("rb") as handle:
tar.addfile(info, handle)
def build_bundle(
source: Path,
wheelhouse: Path,
debian: Path,
output: Path,
system_dependencies: Path | None = None,
) -> None:
with output.open("wb") as raw:
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed:
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar:
for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()):
if path.is_file() and _allowed_source(path, source):
_add_file(tar, path, f"project/核桃派软件源代码/{path.relative_to(source).as_posix()}")
for root, archive_root in (
(wheelhouse, "project/离线依赖/其他依赖/aarch64-py311"),
(debian, "project/离线依赖/其他依赖/debian12-aarch64"),
):
for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()):
if path.is_file():
_add_file(tar, path, f"{archive_root}/{path.relative_to(root).as_posix()}")
if system_dependencies is not None:
for path in sorted(system_dependencies.rglob("*"), key=lambda item: item.relative_to(system_dependencies).as_posix()):
if path.is_file():
_add_file(
tar,
path,
f"project/离线依赖/其他依赖/frp/0.71.0/linux-arm64/{path.relative_to(system_dependencies).as_posix()}",
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def image_metadata(
version: str,
bundle_bytes: int,
bundle_sha256: str,
kernel: KernelArtifactInfo,
boot_required_bytes: int,
boot_safety_bytes: int = DEFAULT_SAFETY_BYTES,
) -> dict:
return {
"format_version": IMAGE_FORMAT_VERSION,
"product": PRODUCT_ID,
"software_version": version,
"bundle_path": BUNDLE_IMAGE_PATH,
"bundle_bytes": bundle_bytes,
"bundle_sha256": bundle_sha256,
"kernel_release": kernel.kernel_release,
"kernel_artifact": kernel.artifact,
"kernel_artifact_bytes": kernel.artifact_bytes,
"kernel_artifact_sha256": kernel.artifact_sha256,
"kernel_unpacked_file_bytes": kernel.unpacked_file_bytes,
"boot_required_bytes": boot_required_bytes,
"boot_safety_bytes": boot_safety_bytes,
}
def build_image(
base_image: Path,
source: Path,
wheelhouse: Path,
debian: Path,
kernel: Path,
kernel_source: Path,
config: dict | bytes,
output: Path,
bootstrap_bundle: Path,
system_dependencies: Path | None = None,
) -> dict:
base_image = Path(base_image).resolve()
source = Path(source).resolve()
wheelhouse = Path(wheelhouse).resolve()
debian = Path(debian).resolve()
kernel = Path(kernel).resolve()
kernel_source = Path(kernel_source).resolve()
output = Path(output).resolve()
bootstrap_bundle = Path(bootstrap_bundle).resolve()
system_dependencies = Path(system_dependencies).resolve() if system_dependencies is not None else None
if system_dependencies is None:
raise ValueError("the verified frpc system dependency bundle is required")
if isinstance(config, bytes):
config_bytes = config
config, _, _ = read_config_file(config_bytes)
config = validate_config(config)
else:
config = validate_config(config)
config_bytes = create_config_file(config)
version = config["software_version"]
if (source / "VERSION").read_text(encoding="utf-8").strip() != version:
raise ValueError("image configuration version does not match source VERSION")
for manifest in (
wheelhouse / "SHA256SUMS",
debian / "SHA256SUMS",
debian / "BASE_IMAGE_PACKAGES.tsv",
system_dependencies / "SHA256SUMS",
):
if not manifest.is_file():
raise FileNotFoundError(manifest)
verify_local_closure(debian, debian / "BASE_IMAGE_PACKAGES.tsv", list(IMAGE_DEBIAN_ROOTS))
kernel_info = verify_kernel_dependency(kernel)
verify_source_dependency(kernel_source)
if output.exists() or bootstrap_bundle.exists():
raise FileExistsError(output if output.exists() else bootstrap_bundle)
output.parent.mkdir(parents=True, exist_ok=True)
bootstrap_bundle.parent.mkdir(parents=True, exist_ok=True)
try:
build_bundle(source, wheelhouse, debian, bootstrap_bundle, system_dependencies)
bundle_digest = sha256_file(bootstrap_bundle)
shutil.copyfile(base_image, output)
except BaseException:
output.unlink(missing_ok=True)
bootstrap_bundle.unlink(missing_ok=True)
raise
try:
firstboot = (source / "scripts" / "image_firstboot.sh").read_bytes()
with Fat16Image(output, writable=True) as image:
image.write_file("MSCCFG.BIN", config_bytes, contiguous=True)
image.write_file("MSCINIT", firstboot)
with Fat16Image(output) as image:
boot_budget = calculate_budget(
candidate_sizes=candidate_sizes_from_dependency(kernel),
boot_cmd=image.read_file("BOOT.CMD"),
boot_scr=image.read_file("BOOT.SCR"),
cluster_bytes=image.cluster_bytes,
available_bytes=image.free_bytes(),
)
if boot_budget.available_bytes < boot_budget.total_bytes:
raise RuntimeError(
"FAT boot partition lacks safe candidate-kernel space: "
f"available={boot_budget.available_bytes}, required={boot_budget.required_bytes}, "
f"safety={boot_budget.safety_bytes}, total={boot_budget.total_bytes}"
)
meta = image_metadata(
version,
bootstrap_bundle.stat().st_size,
bundle_digest,
kernel_info,
boot_budget.required_bytes,
boot_budget.safety_bytes,
)
rendered_meta = (
json.dumps(meta, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
).encode("utf-8")
with Fat16Image(output, writable=True) as image:
image.write_file("MSCMETA.JSN", rendered_meta)
with Fat16Image(output) as image:
if image.read_file("MSCCFG.BIN") != config_bytes:
raise RuntimeError("image configuration read-back failed")
if image.find("MSCBOOT.TGZ") is not None:
raise RuntimeError("FAT boot partition unexpectedly contains the application bundle")
if image.read_file("MSCMETA.JSN") != rendered_meta:
raise RuntimeError("image metadata read-back failed")
if image.read_file("MSCINIT") != firstboot:
raise RuntimeError("image first-boot program read-back failed")
final_budget = calculate_budget(
candidate_sizes=candidate_sizes_from_dependency(kernel),
boot_cmd=image.read_file("BOOT.CMD"),
boot_scr=image.read_file("BOOT.SCR"),
cluster_bytes=image.cluster_bytes,
available_bytes=image.free_bytes(),
)
if final_budget.available_bytes < final_budget.total_bytes:
raise RuntimeError("final FAT boot partition no longer satisfies the recorded space budget")
except BaseException:
output.unlink(missing_ok=True)
bootstrap_bundle.unlink(missing_ok=True)
raise
return {
**meta,
"created_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"image_bytes": output.stat().st_size,
"image_sha256": sha256_file(output),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Build a Rufus-writable WalnutPi controller image")
parser.add_argument("--base-image", type=Path, required=True)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--wheelhouse", type=Path, required=True)
parser.add_argument("--debian", type=Path, required=True)
parser.add_argument("--kernel", type=Path, required=True)
parser.add_argument("--kernel-source", type=Path, required=True)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--bootstrap-bundle-output", type=Path, required=True)
parser.add_argument("--frpc-bundle", type=Path, required=True)
args = parser.parse_args()
config = json.loads(args.config.read_text(encoding="utf-8"))
info = build_image(
args.base_image,
args.source,
args.wheelhouse,
args.debian,
args.kernel,
args.kernel_source,
config,
args.output,
args.bootstrap_bundle_output,
args.frpc_bundle,
)
print(json.dumps(info, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())