269 lines
11 KiB
Python
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())
|