初始化奇妙小屏幕控制器项目
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
|
||||
Reference in New Issue
Block a user