#!/usr/bin/env python3 """Privileged one-shot worker for a previously validated browser OTA package.""" from __future__ import annotations import hashlib import json import os from pathlib import Path import shutil import signal import subprocess import sys import time from typing import Any from app.ota.package import extract_payload, inspect_package from app.ota.state import read_json, utc_now, write_json, write_last_result from app.ota.versioning import SoftwareVersion TARGET = Path("/opt/matrix-screen-controller") RELEASES = Path("/opt/matrix-screen-controller.releases") DATA_ROOT = Path("/var/lib/matrix-screen-controller") RUNTIME_ROOT = Path("/run/matrix-screen-controller") UNIT_PATH = Path("/etc/systemd/system/matrix-screen-controller.service") SERVICE = "matrix-screen-controller.service" REQUEST_PATH = RUNTIME_ROOT / "ota-request.json" class UpdateFailed(RuntimeError): pass def run(command: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None) -> None: subprocess.run(command, cwd=cwd, env=env, check=True) def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]: records: dict[str, tuple[int, str]] = {} ignored = ignored or set() if not root.exists(): return records for path in sorted((item for item in root.rglob("*") if item.is_file()), key=lambda item: item.as_posix()): relative = path.relative_to(root).as_posix() if relative in ignored: continue digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) records[relative] = (path.stat().st_size, digest.hexdigest()) return records class Transaction: def __init__(self, request: dict[str, Any]) -> None: self.request = request self.job_id = checked_token(request.get("job_id"), "job_id") self.target_version = SoftwareVersion.parse(str(request.get("target_version"))) self.current_version = SoftwareVersion.parse(str(request.get("current_version"))) if self.target_version <= self.current_version: raise UpdateFailed("target version is not newer than the installed version") self.package_path = Path(str(request.get("package_path"))) self.status_path = Path(str(request.get("status_path"))) if self.status_path != RUNTIME_ROOT / "ota-status.json": raise UpdateFailed("unexpected OTA status path") self.work_root = Path(f"/opt/matrix-screen-controller-ota/work.{self.job_id}") self.extract_root = self.work_root / "extracted" self.release = RELEASES / f"{self.target_version}-{self.job_id}" self.data_candidate = DATA_ROOT.with_name(f"matrix-screen-controller.ota.{self.job_id}") self.data_backup = DATA_ROOT.with_name(f"matrix-screen-controller.rollback.{self.job_id}") self.unit_backup = self.work_root / "matrix-screen-controller.service.old" self.previous_target: Path | None = None self.previous_directory: Path | None = None self.program_swapped = False self.data_swapped = False self.visual: subprocess.Popen[bytes] | None = None self.job = { "id": self.job_id, "target_version": str(self.target_version), "packaged_at": str(request.get("packaged_at") or ""), "phase": "preparing", "percent": 8, "message": "正在准备更新", "started_at": str(request.get("accepted_at") or utc_now()), "finished_at": None, "error": None, } def update(self, phase: str, percent: int, message: str) -> None: self.job.update(phase=phase, percent=max(0, min(100, percent)), message=message) write_json(self.status_path, {"schema_version": 1, "active": True, "job": self.job}) def prepare(self) -> None: if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)): raise UpdateFailed("OTA transaction paths already exist") self.work_root.mkdir(parents=True, mode=0o700) package = inspect_package(self.package_path, current_version=self.current_version) if package.target_version != self.target_version: raise UpdateFailed("request and package versions do not match") stat = shutil.disk_usage("/opt") required = max(package.expanded_bytes * 4, 512 * 1024 * 1024) if stat.free < required: raise UpdateFailed("not enough free space to stage and validate the OTA release") self.update("extracting", 12, "正在解压全量更新包") extract_payload(package, self.extract_root) software = self.extract_root / "software" wheelhouse = self.extract_root / "wheelhouse" if SoftwareVersion.parse((software / "VERSION").read_text(encoding="utf-8")) != self.target_version: raise UpdateFailed("extracted software VERSION does not match the package") self.update("dependencies", 22, "正在校验离线依赖并创建运行环境") run(["sha256sum", "-c", "SHA256SUMS"], cwd=wheelhouse) shutil.move(str(software), self.release) run(["python3", "-m", "venv", str(self.release / ".venv")]) run([ str(self.release / ".venv/bin/pip"), "install", "--no-index", "--disable-pip-version-check", "--find-links", str(wheelhouse), "-r", str(self.release / "requirements-dev.txt"), ]) self.update("building", 42, "正在编译并测试屏幕驱动") run(["make", "-C", str(self.release / "app/display/native"), "clean", "all", "test"]) run([str(self.release / ".venv/bin/python"), "-m", "compileall", "-q", str(self.release / "app")]) self.update("testing", 55, "正在运行新版本自动化测试") test_env = os.environ.copy() test_env.update( MATRIX_DRIVER="mock", MATRIX_DATA_DIR=str(self.work_root / "test-data"), MATRIX_RUNTIME_DIR=str(self.work_root / "test-runtime"), MATRIX_SOURCE_ONLY_UPDATE_TESTS="1", ) run([str(self.release / ".venv/bin/python"), "-m", "pytest", "-q"], cwd=self.release, env=test_env) run([str(self.release / ".venv/bin/python"), str(self.release / "scripts/dedicated_host.py"), "check"]) self.update("migrating", 68, "正在迁移用户数据副本") shutil.copytree(DATA_ROOT, self.data_candidate, symlinks=True) runtime_candidate = self.work_root / "migration-runtime" migration_env = os.environ.copy() migration_env["PYTHONPATH"] = str(self.release) run([ str(self.release / ".venv/bin/python"), "-m", "scripts.prepare_data_root", "--data-root", str(self.data_candidate), "--runtime-root", str(runtime_candidate), ], cwd=self.release, env=migration_env) def switch(self) -> None: self.update("switching", 78, "正在切换到新版本") shutil.copy2(UNIT_PATH, self.unit_backup) run(["systemctl", "stop", SERVICE]) self.start_visual() RELEASES.mkdir(parents=True, exist_ok=True) if TARGET.is_symlink(): self.previous_target = Path(os.readlink(TARGET)) TARGET.unlink() elif TARGET.is_dir(): self.previous_directory = RELEASES / f"{self.current_version}-pre-ota-{self.job_id}" TARGET.rename(self.previous_directory) else: raise UpdateFailed("production target is neither a release symlink nor a directory") os.symlink(self.release, TARGET, target_is_directory=True) self.program_swapped = True DATA_ROOT.rename(self.data_backup) self.data_candidate.rename(DATA_ROOT) self.data_swapped = True shutil.copy2(self.release / "systemd/matrix-screen-controller.service", UNIT_PATH) shutil.copy2(self.release / "systemd/matrix-screen-controller-ota.service", Path("/etc/systemd/system/matrix-screen-controller-ota.service")) run(["systemctl", "daemon-reload"]) self.stop_visual() run(["systemctl", "start", SERVICE]) def verify(self) -> None: self.update("verifying", 90, "正在验证新版本和真实屏幕驱动") deadline = time.monotonic() + 45 last_error = "service did not respond" while time.monotonic() < deadline: try: output = subprocess.check_output( ["curl", "--fail", "--silent", "http://127.0.0.1:8080/api/status"], timeout=5, ) status = json.loads(output.decode("utf-8")) screen = status.get("screen", {}) driver = screen.get("driver_status") or {} if status.get("service", {}).get("software_version") != str(self.target_version): raise UpdateFailed("new service reports the wrong software version") if screen.get("driver") != "walnutpi-h618-hub75": raise UpdateFailed("new service did not start the production HUB75 driver") if screen.get("hardware_mapping") != "walnutpi-pi-bank-pwm-oe-v2": raise UpdateFailed("new service reports the wrong hardware mapping") if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0: raise UpdateFailed("new service did not pass the PWM4 OE health check") return except (subprocess.SubprocessError, OSError, UnicodeError, json.JSONDecodeError) as exc: last_error = str(exc) time.sleep(1) raise UpdateFailed(f"new release health check timed out: {last_error}") def succeed(self) -> None: result = { "status": "success", "target_version": str(self.target_version), "packaged_at": str(self.request.get("packaged_at") or ""), "installed_at": utc_now(), "release_notes": str(self.request.get("release_notes") or ""), "error": None, } write_last_result(DATA_ROOT, result) self.job.update( phase="complete", percent=100, message="更新完成", finished_at=result["installed_at"], error=None, ) write_json(self.status_path, {"schema_version": 1, "active": False, "job": self.job}) self.package_path.unlink(missing_ok=True) self.request_path().unlink(missing_ok=True) shutil.rmtree(self.data_backup, ignore_errors=True) if self.previous_directory is not None: shutil.rmtree(self.previous_directory, ignore_errors=True) if self.previous_target is not None: previous_release = self.previous_target if self.previous_target.is_absolute() else TARGET.parent / self.previous_target if previous_release.parent == RELEASES: shutil.rmtree(previous_release, ignore_errors=True) shutil.rmtree(self.work_root, ignore_errors=True) prune_empty(self.package_path.parent) def rollback(self, error: BaseException) -> None: self.stop_visual() if self.program_swapped or self.data_swapped: subprocess.run(["systemctl", "stop", SERVICE], check=False) if self.data_swapped: failed_data = DATA_ROOT.with_name(f"matrix-screen-controller.failed.{self.job_id}") if DATA_ROOT.exists(): DATA_ROOT.rename(failed_data) if self.data_backup.exists(): self.data_backup.rename(DATA_ROOT) shutil.rmtree(failed_data, ignore_errors=True) if self.program_swapped: if TARGET.is_symlink(): TARGET.unlink() if self.previous_directory is not None and self.previous_directory.exists(): self.previous_directory.rename(TARGET) elif self.previous_target is not None: os.symlink(self.previous_target, TARGET, target_is_directory=True) if self.unit_backup.exists(): shutil.copy2(self.unit_backup, UNIT_PATH) subprocess.run(["systemctl", "daemon-reload"], check=False) if self.program_swapped or self.data_swapped: subprocess.run(["systemctl", "start", SERVICE], check=False) shutil.rmtree(self.release, ignore_errors=True) shutil.rmtree(self.data_candidate, ignore_errors=True) result = { "status": "failed", "target_version": str(self.target_version), "packaged_at": str(self.request.get("packaged_at") or ""), "installed_at": utc_now(), "release_notes": str(self.request.get("release_notes") or ""), "error": str(error), } try: write_last_result(DATA_ROOT, result) except OSError: pass self.job.update( phase="failed", percent=0, message="更新失败,已恢复原版本", finished_at=result["installed_at"], error=str(error), ) write_json(self.status_path, {"schema_version": 1, "active": False, "job": self.job}) self.package_path.unlink(missing_ok=True) self.request_path().unlink(missing_ok=True) shutil.rmtree(self.work_root, ignore_errors=True) def start_visual(self) -> None: python = TARGET / ".venv/bin/python" self.visual = subprocess.Popen([ str(python), "-m", "scripts.hub75_visual_hold", "--mode", "ota", "--brightness", "40", "--progress-file", str(self.status_path), "--orientation", str(int(self.request.get("orientation", 0))), ]) time.sleep(1) if self.visual.poll() is not None: raise UpdateFailed("independent OTA display process failed to start") def stop_visual(self) -> None: if self.visual is None: return if self.visual.poll() is None: self.visual.send_signal(signal.SIGTERM) try: self.visual.wait(timeout=5) except subprocess.TimeoutExpired: self.visual.kill() self.visual.wait(timeout=2) self.visual = None def request_path(self) -> Path: return Path(str(self.request.get("request_path") or REQUEST_PATH)) def checked_token(value: Any, name: str) -> str: candidate = str(value or "") if not candidate or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for character in candidate): raise UpdateFailed(f"invalid {name}") return candidate def prune_empty(path: Path) -> None: try: path.rmdir() except OSError: pass def main() -> int: if os.geteuid() != 0 or os.uname().machine != "aarch64": print("OTA worker must run as root on the WalnutPi AArch64 host", file=sys.stderr) return 2 request = read_json(REQUEST_PATH) if request is None or request.get("schema_version") != 1: print("OTA request is missing or invalid", file=sys.stderr) return 2 transaction: Transaction | None = None try: transaction = Transaction(request) transaction.prepare() transaction.switch() transaction.verify() transaction.succeed() return 0 except BaseException as exc: if transaction is not None: transaction.rollback(exc) print(f"OTA update failed: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())