初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,358 @@
#!/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())
@@ -0,0 +1,275 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path, PurePosixPath
import shutil
import tarfile
import tempfile
from typing import Any, BinaryIO
import zipfile
from .versioning import SoftwareVersion
PRODUCT_ID = "matrix-screen-controller-walnutpi"
PACKAGE_FORMAT_VERSION = 1
MAX_OTA_UPLOAD_BYTES = 256 * 1024 * 1024
MAX_OTA_PAYLOAD_BYTES = 256 * 1024 * 1024
MAX_OTA_EXPANDED_BYTES = 1024 * 1024 * 1024
_MAX_MANIFEST_BYTES = 64 * 1024
_PACKAGE_MEMBERS = {"manifest.json", "payload.tar.gz"}
_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",
}
_EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"}
class OtaPackageError(ValueError):
"""The uploaded archive is not a supported complete release."""
@dataclass(frozen=True)
class OtaPackageInfo:
path: Path
target_version: SoftwareVersion
created_at: str
release_notes: str
payload_sha256: str
payload_bytes: int
expanded_bytes: int
def document(self) -> dict[str, Any]:
return {
"target_version": str(self.target_version),
"created_at": self.created_at,
"release_notes": self.release_notes,
"payload_sha256": self.payload_sha256,
"payload_bytes": self.payload_bytes,
"expanded_bytes": self.expanded_bytes,
}
def _validated_manifest(raw: bytes) -> tuple[dict[str, Any], SoftwareVersion]:
if len(raw) > _MAX_MANIFEST_BYTES:
raise OtaPackageError("OTA manifest is too large")
try:
manifest = json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise OtaPackageError("OTA manifest is not valid UTF-8 JSON") from exc
expected = {
"format_version",
"product",
"software_version",
"created_at",
"release_notes",
"payload_sha256",
"payload_bytes",
"expanded_bytes",
}
if not isinstance(manifest, dict) or set(manifest) != expected:
raise OtaPackageError("OTA manifest fields do not match format version 1")
if manifest["format_version"] != PACKAGE_FORMAT_VERSION:
raise OtaPackageError("OTA package format is unsupported")
if manifest["product"] != PRODUCT_ID:
raise OtaPackageError("OTA package is for a different product")
try:
version = SoftwareVersion.parse(manifest["software_version"])
except ValueError as exc:
raise OtaPackageError(str(exc)) from exc
if not isinstance(manifest["created_at"], str) or not manifest["created_at"].strip():
raise OtaPackageError("OTA package created_at is missing")
if not isinstance(manifest["release_notes"], str) or len(manifest["release_notes"]) > 4000:
raise OtaPackageError("OTA release notes are invalid")
digest = manifest["payload_sha256"]
if not isinstance(digest, str) or len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
raise OtaPackageError("OTA payload SHA-256 is invalid")
for key, maximum in (
("payload_bytes", MAX_OTA_PAYLOAD_BYTES),
("expanded_bytes", MAX_OTA_EXPANDED_BYTES),
):
value = manifest[key]
if type(value) is not int or value <= 0 or value > maximum:
raise OtaPackageError(f"OTA {key} is outside the supported limit")
return manifest, version
def _payload_digest(handle: BinaryIO) -> tuple[str, int]:
digest = hashlib.sha256()
total = 0
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_OTA_PAYLOAD_BYTES:
raise OtaPackageError("OTA payload exceeds the supported limit")
digest.update(chunk)
return digest.hexdigest(), total
def inspect_package(path: Path, *, current_version: SoftwareVersion | None = None) -> OtaPackageInfo:
package_path = Path(path)
try:
size = package_path.stat().st_size
except OSError as exc:
raise OtaPackageError("OTA package cannot be read") from exc
if size <= 0 or size > MAX_OTA_UPLOAD_BYTES:
raise OtaPackageError("OTA package exceeds the 256 MiB upload limit")
try:
with zipfile.ZipFile(package_path, "r") as archive:
entries = archive.infolist()
if {entry.filename for entry in entries} != _PACKAGE_MEMBERS or len(entries) != 2:
raise OtaPackageError("OTA package must contain only manifest.json and payload.tar.gz")
if any(entry.is_dir() or entry.flag_bits & 0x1 for entry in entries):
raise OtaPackageError("OTA package entries must be unencrypted files")
manifest_entry = archive.getinfo("manifest.json")
payload_entry = archive.getinfo("payload.tar.gz")
if manifest_entry.file_size > _MAX_MANIFEST_BYTES:
raise OtaPackageError("OTA manifest is too large")
if payload_entry.file_size > MAX_OTA_PAYLOAD_BYTES:
raise OtaPackageError("OTA payload exceeds the supported limit")
manifest, target_version = _validated_manifest(archive.read(manifest_entry))
with archive.open(payload_entry, "r") as payload:
digest, payload_bytes = _payload_digest(payload)
except (OtaPackageError, zipfile.BadZipFile):
raise
except (KeyError, OSError, RuntimeError) as exc:
raise OtaPackageError("OTA package cannot be inspected") from exc
if payload_bytes != manifest["payload_bytes"] or digest != manifest["payload_sha256"]:
raise OtaPackageError("OTA payload checksum or size does not match the manifest")
if current_version is not None and target_version <= current_version:
raise OtaPackageError(
f"OTA target {target_version} must be newer than installed version {current_version}"
)
return OtaPackageInfo(
path=package_path,
target_version=target_version,
created_at=manifest["created_at"],
release_notes=manifest["release_notes"],
payload_sha256=digest,
payload_bytes=payload_bytes,
expanded_bytes=manifest["expanded_bytes"],
)
def _safe_member_path(name: str) -> Path:
pure = PurePosixPath(name)
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
raise OtaPackageError(f"unsafe OTA payload path: {name}")
if pure.parts[0] not in {"software", "wheelhouse"}:
raise OtaPackageError(f"unexpected OTA payload root: {pure.parts[0]}")
return Path(*pure.parts)
def extract_payload(package: OtaPackageInfo, destination: Path) -> None:
target = Path(destination)
target.mkdir(parents=True, exist_ok=False)
expanded = 0
try:
with zipfile.ZipFile(package.path, "r") as archive:
with archive.open("payload.tar.gz", "r") as payload:
with tarfile.open(fileobj=payload, mode="r|gz") as tar:
for member in tar:
relative = _safe_member_path(member.name)
output = target / relative
if member.isdir():
output.mkdir(parents=True, exist_ok=True)
continue
if not member.isfile():
raise OtaPackageError("OTA payload may contain only regular files and directories")
expanded += member.size
if expanded > MAX_OTA_EXPANDED_BYTES or expanded > package.expanded_bytes:
raise OtaPackageError("OTA payload expands beyond its declared limit")
output.parent.mkdir(parents=True, exist_ok=True)
source = tar.extractfile(member)
if source is None:
raise OtaPackageError(f"OTA payload member cannot be read: {member.name}")
with output.open("xb") as handle:
shutil.copyfileobj(source, handle, length=1024 * 1024)
output.chmod(member.mode & 0o777 or 0o644)
if expanded != package.expanded_bytes:
raise OtaPackageError("OTA expanded size does not match the manifest")
if not (target / "software" / "VERSION").is_file():
raise OtaPackageError("OTA payload does not contain software/VERSION")
if not (target / "wheelhouse" / "SHA256SUMS").is_file():
raise OtaPackageError("OTA payload does not contain the offline wheel manifest")
except BaseException:
shutil.rmtree(target, ignore_errors=True)
raise
def _source_file_allowed(path: Path, source_root: Path) -> bool:
relative = path.relative_to(source_root)
if any(part in _EXCLUDED_PARTS for part in relative.parts):
return False
if relative.as_posix() in _NATIVE_BUILD_OUTPUTS:
return False
if path.suffix in {".pyc", ".pyo"}:
return False
return True
def _tar_add_file(tar: tarfile.TarFile, source: Path, archive_name: str) -> int:
info = tar.gettarinfo(str(source), arcname=archive_name)
info.uid = info.gid = 0
info.uname = info.gname = "root"
info.mtime = 0
info.mode = 0o755 if source.suffix == ".sh" else 0o644
with source.open("rb") as handle:
tar.addfile(info, handle)
return info.size
def build_package(
source_root: Path,
wheelhouse: Path,
output_path: Path,
*,
version: SoftwareVersion,
release_notes: str,
created_at: datetime | None = None,
) -> OtaPackageInfo:
source_root = Path(source_root).resolve()
wheelhouse = Path(wheelhouse).resolve()
if SoftwareVersion.parse((source_root / "VERSION").read_text(encoding="utf-8")) != version:
raise OtaPackageError("requested package version does not match source VERSION")
if not (wheelhouse / "SHA256SUMS").is_file():
raise OtaPackageError("offline wheelhouse SHA256SUMS is missing")
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
raise FileExistsError(output)
timestamp = (created_at or datetime.now(timezone.utc)).astimezone().isoformat(timespec="seconds")
with tempfile.TemporaryDirectory(prefix="matrix-ota-build-") as temporary:
payload_path = Path(temporary) / "payload.tar.gz"
expanded = 0
with tarfile.open(payload_path, "w:gz", format=tarfile.PAX_FORMAT) as tar:
for path in sorted(source_root.rglob("*"), key=lambda item: item.relative_to(source_root).as_posix()):
if path.is_file() and _source_file_allowed(path, source_root):
expanded += _tar_add_file(tar, path, f"software/{path.relative_to(source_root).as_posix()}")
for path in sorted(wheelhouse.rglob("*"), key=lambda item: item.relative_to(wheelhouse).as_posix()):
if path.is_file():
expanded += _tar_add_file(tar, path, f"wheelhouse/{path.relative_to(wheelhouse).as_posix()}")
payload_bytes = payload_path.stat().st_size
if payload_bytes > MAX_OTA_PAYLOAD_BYTES or expanded > MAX_OTA_EXPANDED_BYTES:
raise OtaPackageError("generated OTA payload exceeds the supported limit")
digest = hashlib.sha256(payload_path.read_bytes()).hexdigest()
manifest = {
"format_version": PACKAGE_FORMAT_VERSION,
"product": PRODUCT_ID,
"software_version": str(version),
"created_at": timestamp,
"release_notes": str(release_notes).strip(),
"payload_sha256": digest,
"payload_bytes": payload_bytes,
"expanded_bytes": expanded,
}
with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
archive.write(payload_path, "payload.tar.gz")
return inspect_package(output)
@@ -0,0 +1,44 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import Any
from app.persistence import atomic_write_bytes
OTA_STATE_SCHEMA_VERSION = 1
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def read_json(path: Path) -> dict[str, Any] | None:
try:
value = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def write_json(path: Path, document: dict[str, Any]) -> None:
atomic_write_bytes(
Path(path),
(json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8"),
)
def read_last_result(data_root: Path) -> dict[str, Any] | None:
document = read_json(Path(data_root) / "ota" / "state.json")
if not document or document.get("schema_version") != OTA_STATE_SCHEMA_VERSION:
return None
result = document.get("last_result")
return dict(result) if isinstance(result, dict) else None
def write_last_result(data_root: Path, result: dict[str, Any]) -> None:
write_json(
Path(data_root) / "ota" / "state.json",
{"schema_version": OTA_STATE_SCHEMA_VERSION, "last_result": dict(result)},
)
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import re
_VERSION_PATTERN = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
@dataclass(frozen=True, order=True)
class SoftwareVersion:
major: int
minor: int
patch: int
@classmethod
def parse(cls, value: str) -> "SoftwareVersion":
if not isinstance(value, str):
raise ValueError("software version must be a string")
match = _VERSION_PATTERN.fullmatch(value.strip())
if match is None:
raise ValueError("software version must use strict MAJOR.MINOR.PATCH digits")
return cls(*(int(part) for part in match.groups()))
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
def read_software_version(code_root: Path) -> SoftwareVersion:
path = Path(code_root) / "VERSION"
try:
return SoftwareVersion.parse(path.read_text(encoding="utf-8"))
except OSError as exc:
raise RuntimeError(f"software VERSION file is unreadable: {path}") from exc
@@ -0,0 +1,40 @@
# OTA 1.1.0 交付与一次性验证记录
## 交付
- 正式包:`发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota`
- 字节数:27993930(约 26.7 MiB)。
- SHA-256:`379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5`。
- 功能时间:`2026-09-07T22:00+08:00`。导出成功后 VERSION 推进到 1.1.0,发布记录追加一次。
- 软件安装节点:1.1.0;1.0.x 可直接安装本节点,同系列补丁可跳过,跨多个 minor 必须逐个安装 .0。1.1.0 包协议 v1,后续包协议 v2。
- 固定系统组件:官方 Linux ARM64 frpc 0.71.0,摘要见源码 UPGRADE_POLICY.json;首次默认关闭,已有完整二进制跳过,缺失或损坏自动修复并保留配置和启停状态。
## 验证证据
- 本机全套 Python/前端与静态 JavaScript 检查通过。
- 复制源码、需求、测试流程及完整其他离线依赖至独立目录,在目标版本 1.1.0 下运行:406 passed、9 skipped。跳过为已登记平台条件。
- 最后相关回归:48 passed(版本/组件事务、日志、发布导出)。
- 从现有 1.0.3 IMG 的 rootfs 内只读提取真实 package.py 与 ota_worker.py;不修改镜像、不刷写设备。worker SHA-256:`cabac4403146a2f2482a2cba5a9599b2d7542617aed949458dc2a241edfbdb56`。
- AArch64 私有 mount namespace 隔离 `/opt`、`/var/lib`、`/run`、`/usr/local/bin`、systemd unit 和 sudoers 目录;真实旧 worker 创建离线 venv、安装 wheel、编译驱动、执行 pytest:413 passed、2 skipped。原生 bitplane 与 fake safeoff 通过。
- 真实旧 worker 从 1.0.3 完成候选安装、数据迁移、frpc 安装、程序和数据切换;systemd 命令与最终 HTTP/显示健康结果使用测试替身。专用主机检查在 namespace 外只读完成。
- 首轮暴露账户识别遗漏,改为优先读取既有 FRP 账户、镜像首启创建的专用 sudoers,再使用唯一 sudo 普通账户。随后发现当前开发设备并非镜像首启账户布局,隔离夹具补齐与 1.0.3 首启一致的账户策略;同一候选包已通过的完整测试不重复,从失败的迁移入口继续完成验证。
- 验证无 frp 安装、已有二进制不重写、损坏二进制和缺 drop-in 修复、开启/启用状态保留。
- 注入安装后的失败、切换后的中断、数据两次 rename 之间的中断、服务启动失败、健康检查失败,恢复原程序、用户内容和系统组件。
- 真实 1.0.3 包解析器确认拒绝 v2 普通补丁,防止绕过 1.1.0。
- 最终补充脚本验证真实 ARM64 安装器保持 active.env 的 0600 权限、完整二进制 mtime 不变、启停状态保留;已有损坏组件先修复,失败后精确恢复原字节及权限。最后仅补充保留原 worker 失败简报的条件判断,并通过本机相关回归。
- 正式包已重新解包逐文件核对与最终源码相同,含 246 个文件,只有 software/wheelhouse 顶层,恰好一个已校验 frpc;无 venv、缓存、浏览器产物、持久数据或旧镜像。包摘要、sidecar、manifest、发布记录一致。
## 调试设备
- 用户确认设备 SSH 指纹后连接;凭据未归档。
- 仅兼容修正旧 worker 的依赖目录查找和组件安装分支,未部署整个应用、未执行正式 OTA、未重启服务。
- 原 worker 摘要:`3eec0eab7b2ce2bd43f11cccb0ba3063b2c820532d78abc2e06ba3823f8c33e5`。
- 修正后摘要:`219cb5eea3121e2b6b54ff06319900e3fa88199ef43945434d5af13f7aef6bc0`。
- 实际开发包解析器成功接收并解压 1.1.0,设备仍报告 1.0.6,主服务 active,frpc 摘要保持原值。
- 原 worker 备份保存在随附原始记录归档中。必要时可恢复该单文件;不涉及用户数据。
## 边界与清理
真实浏览器安装、真实 systemd 组件监护、整机断电/重启与实屏结果尚未验收;上述模拟结果不代表这些项目通过。用户后续自行上传正式 1.1.0。
本目录是一次性证据,不得成为源码、日常测试或未来导出的依赖。板端临时验证目录与本机候选包/隔离副本在收集记录后清理;持久测试环境与其他任务文件保留。
@@ -0,0 +1,35 @@
set -eu
python3 - <<'PY'
from pathlib import Path
import hashlib,os,ast,json
root=Path('/opt/matrix-screen-controller')
p=root/'scripts/ota_worker.py'
original=p.read_bytes()
assert hashlib.sha256(original).hexdigest()=='3eec0eab7b2ce2bd43f11cccb0ba3063b2c820532d78abc2e06ba3823f8c33e5', 'worker changed; re-inspect before patching'
status=Path('/run/matrix-screen-controller/ota-status.json')
assert not status.exists() or not json.loads(status.read_text()).get('active'), 'OTA currently active'
s=original.decode('utf-8')
old=' frpc_bundle = self.extract_root / "system-dependencies/frpc"'
new=' frpc_bundle = software / "system-dependencies/frpc"\n if not frpc_bundle.is_dir():\n frpc_bundle = self.extract_root / "system-dependencies/frpc"'
assert s.count(old)==1
s=s.replace(old,new)
start=s.index(' self.backup_frpc_system()')
end=s.index(' self.run(["systemctl", "daemon-reload"]',start)
legacy=s[start:end]
replacement=' if (self.release / "scripts/ota_components.py").is_file():\n # Candidate migration has already prepared the durable transaction.\n pass\n else:\n'
replacement += ''.join(' '+line+'\n' for line in legacy.splitlines())
s=s[:start]+replacement+s[end:]
ast.parse(s)
backup=Path('/var/tmp/matrix-ota110-validation/development-worker-before.py')
backup.write_bytes(original)
body=s.encode('utf-8')
temp=p.with_name('.ota_worker.compat.tmp')
with temp.open('wb') as f:
f.write(body);f.flush();os.fsync(f.fileno())
os.chmod(temp,p.stat().st_mode & 0o777)
os.replace(temp,p)
print('development_worker_compatibility_patched=true')
print('software_version='+ (root/'VERSION').read_text().strip())
print('worker_sha256='+hashlib.sha256(p.read_bytes()).hexdigest())
print('FRP binary/config and running services unchanged')
PY
@@ -0,0 +1,139 @@
from pathlib import Path
import importlib.util, json, os, shutil, subprocess, sys, tarfile, zipfile
ROOT = Path(__file__).resolve().parent
SEED = ROOT/'seed'
SEED.mkdir(exist_ok=True)
with zipfile.ZipFile(ROOT/'candidate.ota') as z:
with z.open('payload.tar.gz') as p, tarfile.open(fileobj=p, mode='r|gz') as t:
for m in t:
assert m.isfile() and not m.name.startswith('/') and '..' not in Path(m.name).parts
out=SEED/m.name;out.parent.mkdir(parents=True,exist_ok=True)
out.write_bytes(t.extractfile(m).read());out.chmod(m.mode)
sys.path.insert(0,str(SEED/'software'))
def module(name,path):
spec=importlib.util.spec_from_file_location(name,path)
m=importlib.util.module_from_spec(spec);sys.modules[name]=m;spec.loader.exec_module(m);return m
oldpackage=module('app.ota.legacy_package',ROOT/'baseline/package.py')
worker=module('legacy_worker',ROOT/'baseline/ota_worker.py')
worker.inspect_package=oldpackage.inspect_package
worker.extract_payload=oldpackage.extract_payload
from scripts import ota_components as c
def reset():
for p in [Path('/opt'),Path('/var/lib'),Path('/run'),Path('/usr/local/bin'),Path('/etc/systemd/system')]:
for entry in p.iterdir():
if entry.is_dir() and not entry.is_symlink(): shutil.rmtree(entry)
else: entry.unlink()
c.DATA.mkdir()
c.TARGET.mkdir()
(c.TARGET/'VERSION').write_text('1.0.3',encoding='utf-8')
c.RELEASES.mkdir()
(c.DATA/'keep.txt').write_bytes(b'user resource preserved')
for name in ('app-unit','ota-unit'):
c.FILES[name].write_bytes(('old '+name).encode())
if '--resume' not in sys.argv:
reset()
else:
assert '413 passed' in (ROOT/'validation.log').read_text(encoding='utf-8',errors='replace')
request={'schema_version':1,'job_id':'baseline103','target_version':'1.1.0','current_version':'1.0.3',
'package_path':'/opt/candidate.ota','status_path':str(c.RUNTIME/'ota-status.json'),
'accepted_at':'2026-09-07T00:00:00Z'}
c.RUNTIME.mkdir(parents=True,exist_ok=True)
(c.RUNTIME/'ota-request.json').write_text(json.dumps(request),encoding='utf-8')
shutil.copy2(ROOT/'candidate.ota',request['package_path'])
tx=worker.Transaction(request)
tx.start_visual=lambda: None
tx.stop_visual=lambda: None
actual_run=worker.run
def run(command,**kwargs):
if any('dedicated_host.py' in p for p in command):
print('Dedicated host policy: separately checked outside namespace',flush=True)
return
actual_run(command,**kwargs)
worker.run=run
if '--resume' in sys.argv:
actual_run([str(tx.release/'.venv/bin/python'),'-m','scripts.prepare_data_root','--data-root',str(tx.data_candidate),'--runtime-root',str(tx.work_root/'migration-runtime')],cwd=tx.release,env={**os.environ,'PYTHONPATH':str(tx.release)})
else:
tx.prepare()
assert c.FILES['frpc'].is_file()
assert (c.DATA/c.JOURNAL/'state.json').is_file()
tx.switch()
# Hardware/HTTP health is intentionally simulated in this isolated filesystem.
# The real worker ordering, venv, compile, pytest, migration and installation run.
tx.succeed()
c.finish()
assert (c.TARGET/'VERSION').read_text().strip()=='1.1.0'
assert (c.DATA/'keep.txt').read_bytes()==b'user resource preserved'
assert not (c.DATA/c.JOURNAL).exists()
c.check_installed(c.TARGET,'1.1.0')
print('PASS: real 1.0.3 worker -> 1.1.0, actual offline venv/tests/native compile/frpc; simulated service health',flush=True)
# Exercise idempotence and repair with the actual ARM64 shell installer.
bundle=SEED/'software/system-dependencies/frpc'
env=os.environ.copy();env['FRPC_BUNDLE']=str(bundle);env['FRPC_RUN_USER']=c.account()
installer=['/bin/sh',str(c.TARGET/'scripts/install_frpc_system.sh')]
state_path=Path('/run/fake-systemctl.json')
state_path.write_text(json.dumps({c.FRP:{'active':True,'enabled':True}}))
before=c.FILES['frpc'].stat().st_mtime_ns
subprocess.run(installer,env=env,check=True)
assert c.FILES['frpc'].stat().st_mtime_ns==before
c.FILES['frpc'].write_bytes(b'corrupt')
subprocess.run(installer,env=env,check=True)
c.check_installed(c.TARGET,'1.1.0')
c.FILES['frpc-dropin'].unlink()
subprocess.run(installer,env=env,check=True)
c.check_installed(c.TARGET,'1.1.0')
assert json.loads(state_path.read_text())[c.FRP]=={'active':True,'enabled':True}
print('PASS: existing binary skipped; damaged binary and missing drop-in repaired; enabled/active preserved',flush=True)
# Test independent rollback after the legacy worker switches program and data.
for stage in ('installed','switched','data-gap','service-failure','health-failure'):
reset()
source=c.RELEASES/'1.1.0-baseline103'
shutil.copytree(SEED/'software',source)
candidate=c.DATA.with_name('matrix-screen-controller.ota.baseline103')
shutil.copytree(c.DATA,candidate)
c.RUNTIME.mkdir(parents=True,exist_ok=True)
(c.RUNTIME/'ota-request.json').write_text(json.dumps(request),encoding='utf-8')
c.begin(source,candidate)
assert c.FILES['frpc'].exists()
if stage in ('service-failure','health-failure'):
failed_tx=worker.Transaction(request)
failed_tx.work_root.mkdir(parents=True)
failed_tx.start_visual=lambda: None
failed_tx.stop_visual=lambda: None
if stage=='service-failure':
state_path.write_text(json.dumps({'start_fail_once':c.SERVICE}))
try:
failed_tx.switch()
raise RuntimeError('injected health failure')
except Exception as error:
failed_tx.rollback(error)
if stage in ('switched','data-gap'):
c.TARGET.rename(c.RELEASES/'1.0.3-pre-ota-baseline103')
c.TARGET.symlink_to(source,target_is_directory=True)
c.DATA.rename(c.DATA.with_name('matrix-screen-controller.rollback.baseline103'))
if stage=='switched': candidate.rename(c.DATA)
c.finish(boot=True)
assert (c.TARGET/'VERSION').read_text().strip()=='1.0.3'
assert (c.DATA/'keep.txt').read_bytes()==b'user resource preserved'
assert not c.FILES['frpc'].exists()
assert not (c.DATA/c.JOURNAL).exists()
print('PASS: component/application interruption recovery '+stage,flush=True)
# v2 patch must fail in the real old parser, even without its own path policy.
from app.ota.package import build_package
from app.ota.versioning import SoftwareVersion
mini=ROOT/'patch-source';mini.mkdir(exist_ok=True)
(mini/'VERSION').write_text('1.1.1',encoding='utf-8')
patch=ROOT/'patch.ota';patch.unlink(missing_ok=True)
build_package(mini,SEED/'wheelhouse',patch,version=SoftwareVersion.parse('1.1.1'),release_notes='gate test')
try:
oldpackage.inspect_package(patch,current_version=SoftwareVersion.parse('1.0.3'))
except oldpackage.OtaPackageError:
print('PASS: real 1.0.3 parser rejects v2 patch before installation',flush=True)
else: raise AssertionError('legacy updater accepted patch')
@@ -0,0 +1,44 @@
set -eu
ROOT=/var/tmp/matrix-ota110-validation
export ROOT
mkdir -p "$ROOT/fs/sudoers"
python3 - "$ROOT/fs/sudoers/90-matrix-screen-controller-account" <<'PY'
from pathlib import Path
import re,pwd,sys
text=Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf').read_text(encoding='utf-8')
users=re.findall(r'^User=([a-zA-Z0-9_-]+)$',text,re.M)
assert len(users)==1 and pwd.getpwnam(users[0]).pw_uid!=0
Path(sys.argv[1]).write_text(users[0]+' ALL=(ALL:ALL) ALL'+chr(10),encoding='utf-8')
PY
mkdir -p "$ROOT/fs/opt" "$ROOT/fs/varlib" "$ROOT/fs/run" "$ROOT/fs/bin" "$ROOT/fs/units" "$ROOT/fake-bin"
cat > "$ROOT/fake-bin/systemctl" <<'PY'
#!/usr/bin/python3
import sys,json
from pathlib import Path
if Path(sys.argv[0]).name=='systemd-run': sys.exit(0)
args=sys.argv[1:];state_path=Path('/run/fake-systemctl.json')
state=json.loads(state_path.read_text()) if state_path.exists() else {}
cmd=args[0] if args else '';unit=args[-1] if args else ''
if cmd in ('is-active','is-enabled'): sys.exit(0 if state.get(unit,{}).get('active' if cmd=='is-active' else 'enabled',False) else 1)
if cmd=='show': print('inactive')
if cmd in ('enable','disable','start','stop'):
if cmd=='start' and state.get('start_fail_once')==unit:
del state['start_fail_once'];state_path.write_text(json.dumps(state));sys.exit(1)
entry=state.setdefault(unit,{})
entry['active' if cmd in ('start','stop') else 'enabled']=cmd in ('start','enable')
state_path.write_text(json.dumps(state))
sys.exit(0)
PY
cp "$ROOT/fake-bin/systemctl" "$ROOT/fake-bin/systemd-run"
chmod 755 "$ROOT/fake-bin/systemctl" "$ROOT/fake-bin/systemd-run"
export PATH="$ROOT/fake-bin:$PATH"
unshare --mount --propagation private /bin/sh -c '
set -eu
mount --bind "$ROOT/fs/opt" /opt
mount --bind "$ROOT/fs/varlib" /var/lib
mount --bind "$ROOT/fs/run" /run
mount --bind "$ROOT/fs/bin" /usr/local/bin
mount --bind "$ROOT/fs/units" /etc/systemd/system
mount --bind "$ROOT/fs/sudoers" /etc/sudoers.d
python3 "$ROOT/${TEST_SCRIPT:-ota110_integration.py}" ${TEST_ARG:-}
'
@@ -0,0 +1,44 @@
from pathlib import Path
import importlib.util, json, os, shutil, subprocess, sys
ROOT=Path(__file__).resolve().parent
seed=ROOT/'seed/software'
sys.path.insert(0,str(seed))
spec=importlib.util.spec_from_file_location('latest_components',ROOT/'latest/ota_components.py')
c=importlib.util.module_from_spec(spec);spec.loader.exec_module(c)
for root in (Path('/opt'),Path('/var/lib'),Path('/run'),Path('/usr/local/bin'),Path('/etc/systemd/system')):
for path in root.iterdir():
if path.is_dir() and not path.is_symlink(): shutil.rmtree(path)
else: path.unlink()
c.DATA.mkdir();c.TARGET.mkdir();c.RELEASES.mkdir();c.RUNTIME.mkdir(parents=True)
(c.TARGET/'VERSION').write_text('1.0.6',encoding='utf-8')
for name in ('app-unit','ota-unit'): c.FILES[name].write_bytes(('old '+name).encode())
source=c.RELEASES/'1.1.0-supplement'
shutil.copytree(seed,source)
for name in ('ota_components.py','install_frpc_system.sh'):
shutil.copy2(ROOT/'latest'/name,source/'scripts'/name)
env=os.environ.copy();env['FRPC_RUN_USER']=c.account();env['FRPC_BUNDLE']=str(source/'system-dependencies/frpc')
installer=['/bin/sh',str(source/'scripts/install_frpc_system.sh')]
subprocess.run(installer,env=env,check=True,capture_output=True)
active=c.DATA/'frp/active.env';active.write_bytes(b'# isolated fixture\n');active.chmod(0o600)
state=Path('/run/fake-systemctl.json');state.write_text(json.dumps({c.FRP:{'active':True,'enabled':True}}),encoding='utf-8')
before=c.FILES['frpc'].stat().st_mtime_ns
subprocess.run(installer,env=env,check=True,capture_output=True)
assert before==c.FILES['frpc'].stat().st_mtime_ns
c.check_installed(source,'1.1.0')
assert active.stat().st_mode & 0o777 == 0o600
assert json.loads(state.read_text(encoding='utf-8'))[c.FRP]=={'active':True,'enabled':True}
print('PASS: final installer idempotence, active.env 0600, active/enabled state preserved')
original=c.FILES['frpc'].read_bytes()
c.FILES['frpc'].write_bytes(b'corrupt-before-OTA')
candidate=c.DATA.with_name('matrix-screen-controller.ota.supplement');shutil.copytree(c.DATA,candidate)
request={'schema_version':1,'job_id':'supplement','current_version':'1.0.6','target_version':'1.1.0','accepted_at':'2026-09-07T00:00:00Z'}
(c.RUNTIME/'ota-request.json').write_text(json.dumps(request),encoding='utf-8')
c.begin(source,candidate)
assert c.FILES['frpc'].read_bytes()==original
c.finish(boot=True)
assert c.FILES['frpc'].read_bytes()==b'corrupt-before-OTA'
assert active.read_bytes()==b'# isolated fixture\n'
assert active.stat().st_mode & 0o777 == 0o600
assert json.loads(state.read_text(encoding='utf-8'))[c.FRP]=={'active':True,'enabled':True}
assert not (c.DATA/c.JOURNAL).exists()
print('PASS: final component transaction repairs then exactly restores preinstalled FRP and permissions on failure')