初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -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')
|
||||
Binary file not shown.
Reference in New Issue
Block a user