初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Deployment and maintenance helpers for Matrix Screen Controller."""
|
||||
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
CANDIDATE_RELEASE = "6.1.31-matrix-axp313a1"
|
||||
MARKER_PATH = Path("/boot/matrix-kernel-good")
|
||||
CPUFREQ_ROOT = Path("/sys/devices/system/cpu/cpufreq")
|
||||
|
||||
|
||||
class HealthError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="ascii", errors="strict").strip()
|
||||
|
||||
|
||||
def check_axp313a_binding(sys_root: Path = Path("/sys")) -> None:
|
||||
matches = list((sys_root / "bus/i2c/devices").glob("*-0036/of_node/compatible"))
|
||||
if not matches:
|
||||
raise HealthError("PMIC device-tree node at I2C address 0x36 is missing")
|
||||
compatible = matches[0].read_bytes().replace(b"\0", b"\n").decode("ascii", errors="replace")
|
||||
if "x-powers,axp313a" not in compatible.splitlines():
|
||||
raise HealthError(f"PMIC compatible is not x-powers,axp313a: {compatible!r}")
|
||||
driver_link = matches[0].parents[1] / "driver"
|
||||
if not driver_link.exists() or Path(os.path.realpath(driver_link)).name != "axp20x-i2c":
|
||||
raise HealthError("AXP313A is not bound to axp20x-i2c")
|
||||
|
||||
|
||||
def check_cpufreq(
|
||||
cpufreq_root: Path = CPUFREQ_ROOT,
|
||||
debugfs_deferred: Path = Path("/sys/kernel/debug/devices_deferred"),
|
||||
) -> None:
|
||||
policy = cpufreq_root / "policy0"
|
||||
if not policy.is_dir():
|
||||
raise HealthError("cpufreq policy0 is missing")
|
||||
affected = set(_read(policy / "affected_cpus").split())
|
||||
if affected != {"0", "1", "2", "3"}:
|
||||
raise HealthError(f"policy0 does not cover CPU0-3: {sorted(affected)}")
|
||||
available = set(_read(policy / "scaling_available_governors").split())
|
||||
if "performance" not in available:
|
||||
raise HealthError("performance governor is unavailable")
|
||||
if debugfs_deferred.is_file() and "cpufreq-dt" in debugfs_deferred.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
):
|
||||
raise HealthError("cpufreq-dt is still deferred")
|
||||
|
||||
|
||||
def check_cpu_regulator(sys_root: Path = Path("/sys")) -> None:
|
||||
regulator_root = sys_root / "class/regulator"
|
||||
for directory in regulator_root.glob("regulator.*"):
|
||||
name_path = directory / "name"
|
||||
if not name_path.is_file() or _read(name_path) != "vdd-cpu":
|
||||
continue
|
||||
voltage = int(_read(directory / "microvolts"))
|
||||
if not 810_000 <= voltage <= 1_080_000:
|
||||
raise HealthError(f"vdd-cpu is outside the approved range: {voltage}uV")
|
||||
return
|
||||
raise HealthError("vdd-cpu regulator is missing")
|
||||
|
||||
|
||||
def check_kernel_messages(messages: str) -> None:
|
||||
forbidden = (
|
||||
"DCDC frequency on AXP313a is fixed to 3 MHz",
|
||||
"Error setting dcdc frequency",
|
||||
)
|
||||
for message in forbidden:
|
||||
if message in messages:
|
||||
raise HealthError(f"AXP313A regulator logged a boot error: {message}")
|
||||
|
||||
|
||||
def check_kernel_log() -> None:
|
||||
result = subprocess.run(
|
||||
["/bin/dmesg"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
check_kernel_messages(result.stdout)
|
||||
|
||||
|
||||
def _fetch_status(url: str) -> dict[str, Any]:
|
||||
# The candidate kernel is first validated against the already-installed
|
||||
# production application. That older build can take several seconds to
|
||||
# answer while its display compositor is busy, which must not masquerade
|
||||
# as a kernel/cpufreq failure. The stricter 500 ms HTTP performance gate
|
||||
# is measured separately after the candidate application is staged.
|
||||
with urllib.request.urlopen(url, timeout=10.0) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def check_driver_pair(first: dict[str, Any], second: dict[str, Any]) -> None:
|
||||
screen = second.get("screen") or {}
|
||||
if screen.get("driver") != "walnutpi-h618-hub75":
|
||||
raise HealthError("production H618 driver is not active")
|
||||
driver = screen.get("driver_status") or {}
|
||||
previous = (first.get("screen") or {}).get("driver_status") or {}
|
||||
for field in ("actual_refresh_rate_hz", "panel_scan_rate_hz"):
|
||||
if float(driver.get(field) or 0.0) < 95.0:
|
||||
raise HealthError(f"{field} is below 95Hz")
|
||||
if driver.get("scans_per_frame") != 1:
|
||||
raise HealthError("scans_per_frame is not 1")
|
||||
frame_delta = int(driver.get("completed_frames") or 0) - int(previous.get("completed_frames") or 0)
|
||||
scan_delta = int(driver.get("completed_scans") or 0) - int(previous.get("completed_scans") or 0)
|
||||
miss_delta = int(driver.get("deadline_misses") or 0) - int(previous.get("deadline_misses") or 0)
|
||||
if frame_delta <= 0 or scan_delta != frame_delta:
|
||||
raise HealthError("HUB75 completed frame/scan counters are stalled or unequal")
|
||||
if miss_delta < 0 or miss_delta / frame_delta > 0.001:
|
||||
raise HealthError("HUB75 deadline miss rate exceeds 0.1%")
|
||||
for field in ("oe_pulse_faults", "oe_forced_blanks"):
|
||||
if int(driver.get(field) or 0) != int(previous.get(field) or 0):
|
||||
raise HealthError(f"{field} increased during health check")
|
||||
for field in ("driver_error", "oe_timing_error", "safeoff_error"):
|
||||
if driver.get(field):
|
||||
raise HealthError(f"{field} is not empty: {driver[field]}")
|
||||
if driver.get("cpu_affinity") != 3 or driver.get("cpu_affinity_active") is not True:
|
||||
raise HealthError("CPU3 affinity is not active")
|
||||
if driver.get("realtime_priority") != 50 or driver.get("realtime_priority_active") is not True:
|
||||
raise HealthError("SCHED_FIFO 50 is not active")
|
||||
if driver.get("memory_locked") is not True:
|
||||
raise HealthError("refresh memory is not locked")
|
||||
|
||||
|
||||
def wait_for_driver(url: str, timeout_seconds: int = 60) -> None:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
first = _fetch_status(url)
|
||||
time.sleep(2)
|
||||
second = _fetch_status(url)
|
||||
check_driver_pair(first, second)
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
time.sleep(1)
|
||||
raise HealthError(f"application/GPIO health timed out: {last_error}")
|
||||
|
||||
|
||||
def write_marker(marker: Path = MARKER_PATH) -> None:
|
||||
temporary = marker.with_name(f".{marker.name}.tmp")
|
||||
with temporary.open("w", encoding="ascii", newline="\n") as handle:
|
||||
handle.write(f"{CANDIDATE_RELEASE}\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, marker)
|
||||
os.sync()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Confirm the one-shot WalnutPi AXP313A kernel")
|
||||
parser.add_argument("--status-url", default="http://127.0.0.1:8080/api/status")
|
||||
parser.add_argument("--marker", type=Path, default=MARKER_PATH)
|
||||
args = parser.parse_args()
|
||||
if platform.release() != CANDIDATE_RELEASE:
|
||||
raise HealthError(f"unexpected candidate release: {platform.release()}")
|
||||
check_axp313a_binding()
|
||||
check_cpufreq()
|
||||
check_cpu_regulator()
|
||||
check_kernel_log()
|
||||
wait_for_driver(args.status_url)
|
||||
write_marker(args.marker)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except HealthError as exc:
|
||||
print(f"AXP313A candidate health failed: {exc}", flush=True)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict, dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
|
||||
from scripts.kernel_artifact import ARTIFACT_NAME, REQUIRED_BOOT_FILES, verify_kernel_dependency
|
||||
from scripts.render_dual_kernel_boot import BootScriptError, render
|
||||
|
||||
|
||||
DEFAULT_SAFETY_BYTES = 32 * 1024 * 1024
|
||||
CONTROL_FILE_COUNT = 2 # matrix-original.SHA256SUMS and matrix-kernel-good
|
||||
|
||||
|
||||
class BootSpaceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootSpaceBudget:
|
||||
required_bytes: int
|
||||
safety_bytes: int
|
||||
total_bytes: int
|
||||
available_bytes: int
|
||||
cluster_bytes: int
|
||||
|
||||
|
||||
def _allocated(size: int, cluster_bytes: int) -> int:
|
||||
if type(size) is not int or size < 0:
|
||||
raise BootSpaceError("boot file size must be a non-negative integer")
|
||||
if type(cluster_bytes) is not int or cluster_bytes <= 0:
|
||||
raise BootSpaceError("boot cluster size must be a positive integer")
|
||||
return max(1, (size + cluster_bytes - 1) // cluster_bytes) * cluster_bytes
|
||||
|
||||
|
||||
def calculate_budget(
|
||||
*,
|
||||
candidate_sizes: dict[str, int],
|
||||
boot_cmd: bytes,
|
||||
boot_scr: bytes,
|
||||
cluster_bytes: int,
|
||||
available_bytes: int,
|
||||
safety_bytes: int = DEFAULT_SAFETY_BYTES,
|
||||
) -> BootSpaceBudget:
|
||||
if set(candidate_sizes) != REQUIRED_BOOT_FILES:
|
||||
raise BootSpaceError("candidate boot file set is not exact")
|
||||
if type(available_bytes) is not int or available_bytes < 0:
|
||||
raise BootSpaceError("available boot bytes must be a non-negative integer")
|
||||
if type(safety_bytes) is not int or safety_bytes < 0:
|
||||
raise BootSpaceError("boot safety bytes must be a non-negative integer")
|
||||
try:
|
||||
managed_cmd = render(boot_cmd.decode("utf-8")).encode("utf-8")
|
||||
except (UnicodeError, BootScriptError) as exc:
|
||||
raise BootSpaceError("original boot.cmd cannot be rendered safely") from exc
|
||||
# mkimage adds a 64-byte legacy image header. Count both temporary and final
|
||||
# managed scripts so the estimate remains safe even when FAT cannot reuse the
|
||||
# original chains during replacement.
|
||||
managed_scr_size = len(managed_cmd) + 64
|
||||
allocations = list(candidate_sizes.values())
|
||||
allocations.extend((len(boot_cmd), len(boot_scr))) # rollback copies
|
||||
allocations.extend((len(managed_cmd), managed_scr_size)) # mktemp files
|
||||
allocations.extend((len(managed_cmd), managed_scr_size)) # final replacements
|
||||
allocations.extend(1 for _ in range(CONTROL_FILE_COUNT))
|
||||
required = sum(_allocated(size, cluster_bytes) for size in allocations)
|
||||
total = required + safety_bytes
|
||||
return BootSpaceBudget(required, safety_bytes, total, available_bytes, cluster_bytes)
|
||||
|
||||
|
||||
def candidate_sizes_from_dependency(dependency: Path) -> dict[str, int]:
|
||||
dependency = Path(dependency).resolve()
|
||||
verify_kernel_dependency(dependency)
|
||||
with tarfile.open(dependency / ARTIFACT_NAME, mode="r:gz") as archive:
|
||||
normalized = {
|
||||
member.name.removeprefix("./"): member.size
|
||||
for member in archive.getmembers()
|
||||
if member.isfile()
|
||||
}
|
||||
return {name: normalized[name] for name in REQUIRED_BOOT_FILES}
|
||||
|
||||
|
||||
def candidate_sizes_from_artifact_root(root: Path) -> dict[str, int]:
|
||||
root = Path(root).resolve()
|
||||
result: dict[str, int] = {}
|
||||
for name in REQUIRED_BOOT_FILES:
|
||||
path = root / name
|
||||
if not path.is_file():
|
||||
raise BootSpaceError(f"candidate boot file is missing: {name}")
|
||||
result[name] = path.stat().st_size
|
||||
return result
|
||||
|
||||
|
||||
def check_mounted_boot(
|
||||
boot_root: Path,
|
||||
artifact_root: Path,
|
||||
*,
|
||||
safety_bytes: int = DEFAULT_SAFETY_BYTES,
|
||||
) -> BootSpaceBudget:
|
||||
boot_root = Path(boot_root).resolve()
|
||||
if not boot_root.is_dir() or boot_root == Path(boot_root.anchor):
|
||||
raise BootSpaceError("boot root must be an explicit mounted directory")
|
||||
boot_cmd = (boot_root / "boot.cmd").read_bytes()
|
||||
boot_scr = (boot_root / "boot.scr").read_bytes()
|
||||
stats = os.statvfs(boot_root)
|
||||
cluster_bytes = stats.f_frsize or stats.f_bsize
|
||||
available_bytes = stats.f_bavail * cluster_bytes
|
||||
budget = calculate_budget(
|
||||
candidate_sizes=candidate_sizes_from_artifact_root(artifact_root),
|
||||
boot_cmd=boot_cmd,
|
||||
boot_scr=boot_scr,
|
||||
cluster_bytes=cluster_bytes,
|
||||
available_bytes=available_bytes,
|
||||
safety_bytes=safety_bytes,
|
||||
)
|
||||
if budget.available_bytes < budget.total_bytes:
|
||||
raise BootSpaceError(
|
||||
"boot filesystem lacks room for the candidate kernel and rollback files: "
|
||||
f"available={budget.available_bytes}, required={budget.required_bytes}, "
|
||||
f" safety={budget.safety_bytes}, total={budget.total_bytes}"
|
||||
)
|
||||
return budget
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate safe /boot space before kernel installation")
|
||||
parser.add_argument("--boot-root", type=Path, required=True)
|
||||
parser.add_argument("--artifact-root", type=Path, required=True)
|
||||
parser.add_argument("--safety-bytes", type=int, default=DEFAULT_SAFETY_BYTES)
|
||||
args = parser.parse_args()
|
||||
result = check_mounted_boot(
|
||||
args.boot_root,
|
||||
args.artifact_root,
|
||||
safety_bytes=args.safety_bytes,
|
||||
)
|
||||
print(json.dumps(asdict(result), ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (BootSpaceError, OSError, tarfile.TarError) as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SOURCE=
|
||||
CONFIG=
|
||||
OUTPUT=
|
||||
JOBS=${JOBS:-2}
|
||||
RELEASE=6.1.31-matrix-axp313a1
|
||||
LOCALVERSION_SUFFIX=-matrix-axp313a1
|
||||
VENDOR_COMMIT=30ff3fd5cf45417622b447a12e7a947402ffe34d
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--source) SOURCE=$2; shift 2 ;;
|
||||
--config) CONFIG=$2; shift 2 ;;
|
||||
--output) OUTPUT=$2; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$SOURCE" ] || [ -z "$CONFIG" ] || [ -z "$OUTPUT" ]; then
|
||||
echo "usage: build_axp313a_kernel.sh --source DIR --config FILE --output EMPTY_DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
SOURCE=$(CDPATH= cd -- "$SOURCE" && pwd)
|
||||
CONFIG=$(readlink -f -- "$CONFIG")
|
||||
OUTPUT=$(readlink -f -- "$OUTPUT")
|
||||
SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
PATCH_ROOT=$SCRIPT_ROOT/kernel/patches
|
||||
BUILD=$OUTPUT/build
|
||||
STAGE=$OUTPUT/stage
|
||||
|
||||
case "$SOURCE" in /|/boot|/opt|/usr|/var|/home) echo "source path is too broad" >&2; exit 1 ;; esac
|
||||
case "$OUTPUT" in /|/boot|/opt|/usr|/var|/home) echo "output path is too broad" >&2; exit 1 ;; esac
|
||||
if [ ! -f "$SOURCE/Makefile" ] || [ ! -f "$CONFIG" ]; then
|
||||
echo "kernel source or config is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$(find "$OUTPUT" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||
echo "output directory must be empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$JOBS" in *[!0-9]*|'') echo "JOBS must be a positive integer" >&2; exit 1 ;; esac
|
||||
if [ "$JOBS" -lt 1 ] || [ "$JOBS" -gt 4 ]; then
|
||||
echo "JOBS must be within 1..4" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
(cd "$PATCH_ROOT" && sha256sum -c SHA256SUMS)
|
||||
for upstream_patch in \
|
||||
75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15.patch \
|
||||
60fd7eb89670d2636ac3156881acbd103c6eba6a.patch \
|
||||
4628b804555773daa982d9578d89fe04fcdde374.patch
|
||||
do
|
||||
test -f "$PATCH_ROOT/$upstream_patch"
|
||||
done
|
||||
grep -q 'AXP313A_ID' "$SOURCE/include/linux/mfd/axp20x.h"
|
||||
grep -q 'axp313a_regulators' "$SOURCE/drivers/regulator/axp20x-regulator.c"
|
||||
for patch_file in \
|
||||
0003-walnutpi-6.1-axp313a-strict-backport.patch \
|
||||
0004-walnutpi-axp313a-dts.patch \
|
||||
0005-walnutpi-axp313a-preserve-dldo1.patch \
|
||||
0006-walnutpi-uwe5622-out-of-tree-build.patch \
|
||||
0007-walnutpi-enabled-realtek-out-of-tree-build.patch \
|
||||
0008-walnutpi-rtl8192eu-out-of-tree-build.patch \
|
||||
0009-walnutpi-rtl8812au-out-of-tree-build.patch \
|
||||
0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch \
|
||||
0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch \
|
||||
0012-walnutpi-emmc-axp313a-dts.patch \
|
||||
0013-walnutpi-axp313a-fixed-dcdc-frequency.patch
|
||||
do
|
||||
patch -d "$SOURCE" -p1 --forward --batch --fuzz=0 < "$PATCH_ROOT/$patch_file"
|
||||
done
|
||||
|
||||
mkdir -p -- "$BUILD" "$STAGE/boot" "$STAGE/lib"
|
||||
cp -- "$CONFIG" "$BUILD/.config"
|
||||
make -C "$SOURCE" O="$BUILD" olddefconfig
|
||||
"$SOURCE/scripts/config" --file "$BUILD/.config" \
|
||||
--set-str LOCALVERSION '' \
|
||||
--disable LOCALVERSION_AUTO \
|
||||
--set-str SYSTEM_TRUSTED_KEYS '' \
|
||||
--set-str SYSTEM_REVOCATION_KEYS ''
|
||||
make -C "$SOURCE" O="$BUILD" olddefconfig
|
||||
actual_release=$(make -s -C "$SOURCE" O="$BUILD" \
|
||||
LOCALVERSION="$LOCALVERSION_SUFFIX" kernelrelease)
|
||||
if [ "$actual_release" != "$RELEASE" ]; then
|
||||
echo "unexpected kernel release: $actual_release" >&2
|
||||
exit 1
|
||||
fi
|
||||
make -C "$SOURCE" O="$BUILD" LOCALVERSION="$LOCALVERSION_SUFFIX" \
|
||||
-j"$JOBS" Image dtbs modules
|
||||
make -C "$SOURCE" O="$BUILD" LOCALVERSION="$LOCALVERSION_SUFFIX" \
|
||||
modules_install INSTALL_MOD_PATH="$STAGE"
|
||||
|
||||
install -m 0644 "$BUILD/arch/arm64/boot/Image" "$STAGE/boot/Image-matrix-axp313a1"
|
||||
install -m 0644 "$BUILD/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dtb" \
|
||||
"$STAGE/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb"
|
||||
install -m 0644 "$BUILD/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b-emmc.dtb" \
|
||||
"$STAGE/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb"
|
||||
install -m 0644 "$BUILD/System.map" "$STAGE/boot/System.map-$RELEASE"
|
||||
install -m 0644 "$BUILD/.config" "$STAGE/boot/config-$RELEASE"
|
||||
printf '%s\n' "$VENDOR_COMMIT" > "$STAGE/SOURCE_COMMIT"
|
||||
printf '%s\n' "$RELEASE" > "$STAGE/KERNEL_RELEASE"
|
||||
(cd "$STAGE" && find boot lib SOURCE_COMMIT KERNEL_RELEASE -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)
|
||||
echo "candidate kernel built at $STAGE"
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create one self-contained browser OTA package on the development computer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(SOURCE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from app.ota.package import build_package
|
||||
from app.ota.versioning import read_software_version
|
||||
from app.ota.policy import read_policy
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--notes", required=True, help="short release description")
|
||||
parser.add_argument("--output-root", type=Path)
|
||||
args = parser.parse_args()
|
||||
source_root = Path(__file__).resolve().parents[1]
|
||||
project_root = source_root.parent
|
||||
wheelhouse = project_root / "发布更新相关" / "其他依赖" / "aarch64-py311"
|
||||
frpc_bundle = project_root / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64"
|
||||
version = read_software_version(source_root)
|
||||
policy = read_policy(source_root)
|
||||
if policy["checkpoints"] and version < type(version).parse(policy["checkpoints"][-1]["version"]):
|
||||
parser.error("源码包含未发布的软件依赖;请使用 export_release.py ota --version 导出安装节点")
|
||||
if version.patch != 0:
|
||||
frpc_bundle = None
|
||||
output_root = (args.output_root or project_root / "发布更新相关" / "OTA数据包").resolve()
|
||||
version_dir = output_root / str(version)
|
||||
if version_dir.exists():
|
||||
parser.error(f"version output already exists and will not be overwritten: {version_dir}")
|
||||
version_dir.mkdir(parents=True)
|
||||
package_path = version_dir / f"matrix-screen-controller-{version}.ota"
|
||||
created = datetime.now(timezone.utc)
|
||||
try:
|
||||
info = build_package(
|
||||
source_root,
|
||||
wheelhouse,
|
||||
package_path,
|
||||
version=version,
|
||||
release_notes=args.notes,
|
||||
created_at=created,
|
||||
system_dependencies=frpc_bundle,
|
||||
)
|
||||
package_sha = hashlib.sha256(package_path.read_bytes()).hexdigest()
|
||||
readme = (
|
||||
f"# 奇妙小屏幕控制器 OTA {version}\n\n"
|
||||
f"- 软件版本:`{version}`\n"
|
||||
f"- 打包时间:`{info.created_at}`\n"
|
||||
f"- 更新说明:{args.notes.strip()}\n"
|
||||
f"- 更新包:`{package_path.name}`\n"
|
||||
f"- 文件大小:`{package_path.stat().st_size}` bytes\n"
|
||||
f"- 文件 SHA-256:`{package_sha}`\n"
|
||||
f"- 载荷 SHA-256:`{info.payload_sha256}`\n\n"
|
||||
"通过设备网页的“系统设置 → 软件更新”选择 `.ota` 文件。"
|
||||
"本包是离线全量包,不使用服务器下载、增量、加密或签名。\n"
|
||||
)
|
||||
(version_dir / "README.md").write_text(readme, encoding="utf-8", newline="\n")
|
||||
except BaseException:
|
||||
package_path.unlink(missing_ok=True)
|
||||
try:
|
||||
version_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
print(version_dir)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(SOURCE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from scripts.fat16_image import Fat16Image
|
||||
from scripts.image_config import PRODUCT_ID, create_config_file, read_config_file, validate_config
|
||||
from scripts.debian_closure import IMAGE_DEBIAN_ROOTS, verify_local_closure
|
||||
from scripts.kernel_artifact import KernelArtifactInfo, verify_kernel_dependency, verify_source_dependency
|
||||
from scripts.boot_space import DEFAULT_SAFETY_BYTES, calculate_budget, candidate_sizes_from_dependency
|
||||
|
||||
|
||||
EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"}
|
||||
NATIVE_BUILD_OUTPUTS = {
|
||||
"app/display/native/libh618_hub75.so",
|
||||
"app/display/native/hub75_benchmark",
|
||||
"app/display/native/hub75_native_test",
|
||||
"app/display/native/hub75_safeoff",
|
||||
}
|
||||
IMAGE_FORMAT_VERSION = 3
|
||||
BUNDLE_IMAGE_PATH = "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ"
|
||||
|
||||
|
||||
def _allowed_source(path: Path, root: Path) -> bool:
|
||||
relative = path.relative_to(root)
|
||||
if any(part in EXCLUDED_PARTS for part in relative.parts):
|
||||
return False
|
||||
if relative.as_posix() in NATIVE_BUILD_OUTPUTS:
|
||||
return False
|
||||
return path.suffix not in {".pyc", ".pyo"}
|
||||
|
||||
|
||||
def _add_file(tar: tarfile.TarFile, path: Path, name: str) -> None:
|
||||
info = tar.gettarinfo(str(path), arcname=name)
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
info.mtime = 0
|
||||
info.mode = 0o755 if path.suffix == ".sh" else 0o644
|
||||
with path.open("rb") as handle:
|
||||
tar.addfile(info, handle)
|
||||
|
||||
|
||||
def build_bundle(
|
||||
source: Path,
|
||||
wheelhouse: Path,
|
||||
debian: Path,
|
||||
output: Path,
|
||||
system_dependencies: Path | None = None,
|
||||
) -> None:
|
||||
with output.open("wb") as raw:
|
||||
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar:
|
||||
for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()):
|
||||
if path.is_file() and _allowed_source(path, source):
|
||||
_add_file(tar, path, f"project/核桃派软件源代码/{path.relative_to(source).as_posix()}")
|
||||
for root, archive_root in (
|
||||
(wheelhouse, "project/离线依赖/其他依赖/aarch64-py311"),
|
||||
(debian, "project/离线依赖/其他依赖/debian12-aarch64"),
|
||||
):
|
||||
for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()):
|
||||
if path.is_file():
|
||||
_add_file(tar, path, f"{archive_root}/{path.relative_to(root).as_posix()}")
|
||||
if system_dependencies is not None:
|
||||
for path in sorted(system_dependencies.rglob("*"), key=lambda item: item.relative_to(system_dependencies).as_posix()):
|
||||
if path.is_file():
|
||||
_add_file(
|
||||
tar,
|
||||
path,
|
||||
f"project/离线依赖/其他依赖/frp/0.71.0/linux-arm64/{path.relative_to(system_dependencies).as_posix()}",
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def image_metadata(
|
||||
version: str,
|
||||
bundle_bytes: int,
|
||||
bundle_sha256: str,
|
||||
kernel: KernelArtifactInfo,
|
||||
boot_required_bytes: int,
|
||||
boot_safety_bytes: int = DEFAULT_SAFETY_BYTES,
|
||||
) -> dict:
|
||||
return {
|
||||
"format_version": IMAGE_FORMAT_VERSION,
|
||||
"product": PRODUCT_ID,
|
||||
"software_version": version,
|
||||
"bundle_path": BUNDLE_IMAGE_PATH,
|
||||
"bundle_bytes": bundle_bytes,
|
||||
"bundle_sha256": bundle_sha256,
|
||||
"kernel_release": kernel.kernel_release,
|
||||
"kernel_artifact": kernel.artifact,
|
||||
"kernel_artifact_bytes": kernel.artifact_bytes,
|
||||
"kernel_artifact_sha256": kernel.artifact_sha256,
|
||||
"kernel_unpacked_file_bytes": kernel.unpacked_file_bytes,
|
||||
"boot_required_bytes": boot_required_bytes,
|
||||
"boot_safety_bytes": boot_safety_bytes,
|
||||
}
|
||||
|
||||
|
||||
def build_image(
|
||||
base_image: Path,
|
||||
source: Path,
|
||||
wheelhouse: Path,
|
||||
debian: Path,
|
||||
kernel: Path,
|
||||
kernel_source: Path,
|
||||
config: dict | bytes,
|
||||
output: Path,
|
||||
bootstrap_bundle: Path,
|
||||
system_dependencies: Path | None = None,
|
||||
) -> dict:
|
||||
base_image = Path(base_image).resolve()
|
||||
source = Path(source).resolve()
|
||||
wheelhouse = Path(wheelhouse).resolve()
|
||||
debian = Path(debian).resolve()
|
||||
kernel = Path(kernel).resolve()
|
||||
kernel_source = Path(kernel_source).resolve()
|
||||
output = Path(output).resolve()
|
||||
bootstrap_bundle = Path(bootstrap_bundle).resolve()
|
||||
system_dependencies = Path(system_dependencies).resolve() if system_dependencies is not None else None
|
||||
if system_dependencies is None:
|
||||
raise ValueError("the verified frpc system dependency bundle is required")
|
||||
if isinstance(config, bytes):
|
||||
config_bytes = config
|
||||
config, _, _ = read_config_file(config_bytes)
|
||||
config = validate_config(config)
|
||||
else:
|
||||
config = validate_config(config)
|
||||
config_bytes = create_config_file(config)
|
||||
version = config["software_version"]
|
||||
if (source / "VERSION").read_text(encoding="utf-8").strip() != version:
|
||||
raise ValueError("image configuration version does not match source VERSION")
|
||||
for manifest in (
|
||||
wheelhouse / "SHA256SUMS",
|
||||
debian / "SHA256SUMS",
|
||||
debian / "BASE_IMAGE_PACKAGES.tsv",
|
||||
system_dependencies / "SHA256SUMS",
|
||||
):
|
||||
if not manifest.is_file():
|
||||
raise FileNotFoundError(manifest)
|
||||
verify_local_closure(debian, debian / "BASE_IMAGE_PACKAGES.tsv", list(IMAGE_DEBIAN_ROOTS))
|
||||
kernel_info = verify_kernel_dependency(kernel)
|
||||
verify_source_dependency(kernel_source)
|
||||
if output.exists() or bootstrap_bundle.exists():
|
||||
raise FileExistsError(output if output.exists() else bootstrap_bundle)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
bootstrap_bundle.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
build_bundle(source, wheelhouse, debian, bootstrap_bundle, system_dependencies)
|
||||
bundle_digest = sha256_file(bootstrap_bundle)
|
||||
shutil.copyfile(base_image, output)
|
||||
except BaseException:
|
||||
output.unlink(missing_ok=True)
|
||||
bootstrap_bundle.unlink(missing_ok=True)
|
||||
raise
|
||||
try:
|
||||
firstboot = (source / "scripts" / "image_firstboot.sh").read_bytes()
|
||||
with Fat16Image(output, writable=True) as image:
|
||||
image.write_file("MSCCFG.BIN", config_bytes, contiguous=True)
|
||||
image.write_file("MSCINIT", firstboot)
|
||||
with Fat16Image(output) as image:
|
||||
boot_budget = calculate_budget(
|
||||
candidate_sizes=candidate_sizes_from_dependency(kernel),
|
||||
boot_cmd=image.read_file("BOOT.CMD"),
|
||||
boot_scr=image.read_file("BOOT.SCR"),
|
||||
cluster_bytes=image.cluster_bytes,
|
||||
available_bytes=image.free_bytes(),
|
||||
)
|
||||
if boot_budget.available_bytes < boot_budget.total_bytes:
|
||||
raise RuntimeError(
|
||||
"FAT boot partition lacks safe candidate-kernel space: "
|
||||
f"available={boot_budget.available_bytes}, required={boot_budget.required_bytes}, "
|
||||
f"safety={boot_budget.safety_bytes}, total={boot_budget.total_bytes}"
|
||||
)
|
||||
meta = image_metadata(
|
||||
version,
|
||||
bootstrap_bundle.stat().st_size,
|
||||
bundle_digest,
|
||||
kernel_info,
|
||||
boot_budget.required_bytes,
|
||||
boot_budget.safety_bytes,
|
||||
)
|
||||
rendered_meta = (
|
||||
json.dumps(meta, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||
).encode("utf-8")
|
||||
with Fat16Image(output, writable=True) as image:
|
||||
image.write_file("MSCMETA.JSN", rendered_meta)
|
||||
with Fat16Image(output) as image:
|
||||
if image.read_file("MSCCFG.BIN") != config_bytes:
|
||||
raise RuntimeError("image configuration read-back failed")
|
||||
if image.find("MSCBOOT.TGZ") is not None:
|
||||
raise RuntimeError("FAT boot partition unexpectedly contains the application bundle")
|
||||
if image.read_file("MSCMETA.JSN") != rendered_meta:
|
||||
raise RuntimeError("image metadata read-back failed")
|
||||
if image.read_file("MSCINIT") != firstboot:
|
||||
raise RuntimeError("image first-boot program read-back failed")
|
||||
final_budget = calculate_budget(
|
||||
candidate_sizes=candidate_sizes_from_dependency(kernel),
|
||||
boot_cmd=image.read_file("BOOT.CMD"),
|
||||
boot_scr=image.read_file("BOOT.SCR"),
|
||||
cluster_bytes=image.cluster_bytes,
|
||||
available_bytes=image.free_bytes(),
|
||||
)
|
||||
if final_budget.available_bytes < final_budget.total_bytes:
|
||||
raise RuntimeError("final FAT boot partition no longer satisfies the recorded space budget")
|
||||
except BaseException:
|
||||
output.unlink(missing_ok=True)
|
||||
bootstrap_bundle.unlink(missing_ok=True)
|
||||
raise
|
||||
return {
|
||||
**meta,
|
||||
"created_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
"image_bytes": output.stat().st_size,
|
||||
"image_sha256": sha256_file(output),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build a Rufus-writable WalnutPi controller image")
|
||||
parser.add_argument("--base-image", type=Path, required=True)
|
||||
parser.add_argument("--source", type=Path, required=True)
|
||||
parser.add_argument("--wheelhouse", type=Path, required=True)
|
||||
parser.add_argument("--debian", type=Path, required=True)
|
||||
parser.add_argument("--kernel", type=Path, required=True)
|
||||
parser.add_argument("--kernel-source", type=Path, required=True)
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--bootstrap-bundle-output", type=Path, required=True)
|
||||
parser.add_argument("--frpc-bundle", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
config = json.loads(args.config.read_text(encoding="utf-8"))
|
||||
info = build_image(
|
||||
args.base_image,
|
||||
args.source,
|
||||
args.wheelhouse,
|
||||
args.debian,
|
||||
args.kernel,
|
||||
args.kernel_source,
|
||||
config,
|
||||
args.output,
|
||||
args.bootstrap_bundle_output,
|
||||
args.frpc_bundle,
|
||||
)
|
||||
print(json.dumps(info, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC_ROOT = SOURCE_ROOT / "app" / "static"
|
||||
OUTPUT_PATH = STATIC_ROOT / "ui-copy.json"
|
||||
DYNAMIC_PATH = STATIC_ROOT / "ui-copy-dynamic.json"
|
||||
TEXT_ATTRIBUTES = ("aria-label", "placeholder", "data-unavailable-reason", "title")
|
||||
ANCHOR_TAGS = {"article", "section", "header", "main", "form", "fieldset", "div"}
|
||||
IGNORED_TAGS = {"script", "style", "svg", "path", "noscript"}
|
||||
PLACEHOLDER_PATTERN = re.compile(r"\{([a-zA-Z][a-zA-Z0-9_]*)\}")
|
||||
FUNCTIONAL_VALUE_PATTERN = re.compile(r"^[+\-]?(?:\d+(?:\.\d+)?|#[0-9a-fA-F]{3,8})(?:\s*(?:%|°|Hz|ms|px|V))?$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HtmlNode:
|
||||
tag: str
|
||||
attrs: dict[str, str]
|
||||
parent: "HtmlNode | None" = None
|
||||
children: list["HtmlNode | str"] = field(default_factory=list)
|
||||
|
||||
|
||||
class TreeParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = HtmlNode("document", {})
|
||||
self.stack = [self.root]
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
node = HtmlNode(tag, {key: value or "" for key, value in attrs}, self.stack[-1])
|
||||
self.stack[-1].children.append(node)
|
||||
if tag not in {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}:
|
||||
self.stack.append(node)
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
node = HtmlNode(tag, {key: value or "" for key, value in attrs}, self.stack[-1])
|
||||
self.stack[-1].children.append(node)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
for index in range(len(self.stack) - 1, 0, -1):
|
||||
if self.stack[index].tag == tag:
|
||||
del self.stack[index:]
|
||||
return
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self.stack[-1].children.append(data)
|
||||
|
||||
|
||||
def normalized_text(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def stable_id(prefix: str, *parts: object) -> str:
|
||||
raw = "\0".join(str(part) for part in parts)
|
||||
return f"{prefix}.{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def iter_nodes(node: HtmlNode) -> Iterable[HtmlNode]:
|
||||
for child in node.children:
|
||||
if isinstance(child, HtmlNode):
|
||||
yield child
|
||||
yield from iter_nodes(child)
|
||||
|
||||
|
||||
def nearest_scope(node: HtmlNode) -> str:
|
||||
current: HtmlNode | None = node
|
||||
while current:
|
||||
identifier = current.attrs.get("id", "")
|
||||
if identifier.startswith("workspace-"):
|
||||
return identifier.removeprefix("workspace-")
|
||||
if current.tag == "dialog" and identifier:
|
||||
return f"dialog:{identifier}"
|
||||
current = current.parent
|
||||
return "global"
|
||||
|
||||
|
||||
def copy_ignored(node: HtmlNode) -> bool:
|
||||
current: HtmlNode | None = node
|
||||
while current:
|
||||
if "data-ui-copy-ignore" in current.attrs or "data-ui-copy-value" in current.attrs:
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
|
||||
|
||||
def selector_for(node: HtmlNode) -> str:
|
||||
if identifier := node.attrs.get("id"):
|
||||
return f"#{identifier}"
|
||||
parts: list[str] = []
|
||||
current: HtmlNode | None = node
|
||||
while current and current.tag != "document":
|
||||
if identifier := current.attrs.get("id"):
|
||||
parts.append(f"#{identifier}")
|
||||
break
|
||||
parent = current.parent
|
||||
if parent is None:
|
||||
parts.append(current.tag)
|
||||
break
|
||||
siblings = [child for child in parent.children if isinstance(child, HtmlNode) and child.tag == current.tag]
|
||||
position = next(index for index, sibling in enumerate(siblings, 1) if sibling is current)
|
||||
parts.append(f"{current.tag}:nth-of-type({position})")
|
||||
current = parent
|
||||
return " > ".join(reversed(parts))
|
||||
|
||||
|
||||
def placeholders(text: str) -> list[str]:
|
||||
return PLACEHOLDER_PATTERN.findall(text)
|
||||
|
||||
|
||||
def add_item(items: dict[str, dict[str, Any]], item_id: str, *, text: str, scope: str,
|
||||
source: str, kind: str, render: dict[str, Any] | None = None) -> None:
|
||||
if not text or item_id in items:
|
||||
return
|
||||
entry: dict[str, Any] = {
|
||||
"text": text,
|
||||
"scope": scope,
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"placeholders": placeholders(text),
|
||||
}
|
||||
if render:
|
||||
entry["render"] = render
|
||||
items[item_id] = entry
|
||||
|
||||
|
||||
def collect_html(html_path: Path, items: dict[str, dict[str, Any]], anchors: dict[str, dict[str, str]]) -> None:
|
||||
parser = TreeParser()
|
||||
parser.feed(html_path.read_text(encoding="utf-8"))
|
||||
for node in iter_nodes(parser.root):
|
||||
if node.tag in IGNORED_TAGS or copy_ignored(node):
|
||||
continue
|
||||
selector = selector_for(node)
|
||||
scope = nearest_scope(node)
|
||||
if node.tag in ANCHOR_TAGS:
|
||||
anchor_id = stable_id("anchor", selector)
|
||||
anchors[anchor_id] = {"selector": selector, "scope": scope, "tag": node.tag}
|
||||
|
||||
text_position = 0
|
||||
default_hidden_text = normalized_text(node.attrs.get("data-ui-copy-default-text", ""))
|
||||
if default_hidden_text:
|
||||
item_id = stable_id("copy", "index.html", selector, "text", text_position)
|
||||
add_item(
|
||||
items,
|
||||
item_id,
|
||||
text=default_hidden_text,
|
||||
scope=scope,
|
||||
source=f"index.html::{selector}::text[{text_position}]",
|
||||
kind="html_text",
|
||||
render={
|
||||
"type": "static_text",
|
||||
"selector": selector,
|
||||
"text_index": text_position,
|
||||
"default_hidden": True,
|
||||
},
|
||||
)
|
||||
text_position += 1
|
||||
for child in node.children:
|
||||
if not isinstance(child, str):
|
||||
continue
|
||||
text = normalized_text(child)
|
||||
if not text or text == "-" or FUNCTIONAL_VALUE_PATTERN.fullmatch(text):
|
||||
continue
|
||||
item_id = stable_id("copy", "index.html", selector, "text", text_position)
|
||||
add_item(
|
||||
items,
|
||||
item_id,
|
||||
text=text,
|
||||
scope=scope,
|
||||
source=f"index.html::{selector}::text[{text_position}]",
|
||||
kind="html_text",
|
||||
render={"type": "static_text", "selector": selector, "text_index": text_position},
|
||||
)
|
||||
text_position += 1
|
||||
|
||||
for attribute in TEXT_ATTRIBUTES:
|
||||
text = normalized_text(node.attrs.get(attribute, ""))
|
||||
if not text:
|
||||
continue
|
||||
item_id = stable_id("copy", "index.html", selector, "attribute", attribute)
|
||||
add_item(
|
||||
items,
|
||||
item_id,
|
||||
text=text,
|
||||
scope=scope,
|
||||
source=f"index.html::{selector}::@{attribute}",
|
||||
kind="html_attribute",
|
||||
render={"type": "attribute", "selector": selector, "attribute": attribute},
|
||||
)
|
||||
|
||||
|
||||
def scan_javascript_literals(source: str) -> Iterable[tuple[int, str, str]]:
|
||||
index = 0
|
||||
while index < len(source):
|
||||
quote = source[index]
|
||||
if quote not in {'"', "'", "`"}:
|
||||
index += 1
|
||||
continue
|
||||
start = index
|
||||
index += 1
|
||||
value: list[str] = []
|
||||
expression_depth = 0
|
||||
placeholder_number = 0
|
||||
while index < len(source):
|
||||
char = source[index]
|
||||
if char == "\\":
|
||||
if index + 1 < len(source):
|
||||
value.extend((char, source[index + 1]))
|
||||
index += 2
|
||||
continue
|
||||
if quote == "`" and char == "$" and index + 1 < len(source) and source[index + 1] == "{":
|
||||
placeholder_number += 1
|
||||
value.append(f"{{value{placeholder_number}}}")
|
||||
index += 2
|
||||
expression_depth = 1
|
||||
inner_quote: str | None = None
|
||||
while index < len(source) and expression_depth:
|
||||
current = source[index]
|
||||
if inner_quote:
|
||||
if current == "\\":
|
||||
index += 2
|
||||
continue
|
||||
if current == inner_quote:
|
||||
inner_quote = None
|
||||
elif current in {'"', "'", "`"}:
|
||||
inner_quote = current
|
||||
elif current == "{":
|
||||
expression_depth += 1
|
||||
elif current == "}":
|
||||
expression_depth -= 1
|
||||
index += 1
|
||||
continue
|
||||
if char == quote:
|
||||
index += 1
|
||||
raw = "".join(value)
|
||||
raw = raw.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t")
|
||||
raw = raw.replace(f"\\{quote}", quote).replace("\\\\", "\\")
|
||||
yield start, quote, raw
|
||||
break
|
||||
value.append(char)
|
||||
index += 1
|
||||
|
||||
|
||||
def collect_dynamic(items: dict[str, dict[str, Any]]) -> None:
|
||||
raw = json.loads(DYNAMIC_PATH.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("ui-copy-dynamic.json must be an object")
|
||||
for item_id, value in raw.items():
|
||||
if not isinstance(value, dict) or set(value) - {"text", "scope", "source", "default_hidden"}:
|
||||
raise ValueError(f"invalid dynamic UI copy item: {item_id}")
|
||||
if not {"text", "scope", "source"} <= set(value) or type(value.get("default_hidden", False)) is not bool:
|
||||
raise ValueError(f"invalid dynamic UI copy item: {item_id}")
|
||||
add_item(
|
||||
items,
|
||||
item_id,
|
||||
text=normalized_text(value["text"]),
|
||||
scope=value["scope"],
|
||||
source=value["source"],
|
||||
kind="dynamic_template",
|
||||
render={"type": "dynamic_template", **({"default_hidden": True} if value.get("default_hidden") else {})},
|
||||
)
|
||||
|
||||
|
||||
def build_catalog() -> dict[str, Any]:
|
||||
items: dict[str, dict[str, Any]] = {}
|
||||
anchors: dict[str, dict[str, str]] = {}
|
||||
collect_html(STATIC_ROOT / "index.html", items, anchors)
|
||||
collect_dynamic(items)
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"items": dict(sorted(items.items())),
|
||||
"anchors": dict(sorted(anchors.items())),
|
||||
"insertions": [],
|
||||
}
|
||||
|
||||
|
||||
def canonical_bytes(catalog: dict[str, Any]) -> bytes:
|
||||
return (json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build or verify the UI copy catalog")
|
||||
parser.add_argument("--check", action="store_true", help="fail when ui-copy.json is not current")
|
||||
args = parser.parse_args()
|
||||
content = canonical_bytes(build_catalog())
|
||||
if args.check:
|
||||
if not OUTPUT_PATH.is_file() or OUTPUT_PATH.read_bytes() != content:
|
||||
raise SystemExit("ui-copy.json is out of date; run scripts/build_ui_copy_catalog.py")
|
||||
print("ui-copy.json is current")
|
||||
return 0
|
||||
OUTPUT_PATH.write_bytes(content)
|
||||
print(f"wrote {OUTPUT_PATH} ({len(json.loads(content)['items'])} items)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
OUTPUT=
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--output) OUTPUT=$2; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "state capture must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$OUTPUT" ]; then
|
||||
echo "usage: capture_axp313a_state.sh --output NEW_DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
parent=$(dirname -- "$OUTPUT")
|
||||
mkdir -p -- "$parent"
|
||||
parent=$(CDPATH= cd -- "$parent" && pwd)
|
||||
OUTPUT=$parent/$(basename -- "$OUTPUT")
|
||||
case "$OUTPUT" in
|
||||
/|/boot|/opt|/var|/var/lib|/var/lib/matrix-screen-controller|/run|/etc)
|
||||
echo "unsafe state output directory: $OUTPUT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ -e "$OUTPUT" ]; then
|
||||
echo "state output directory must not exist: $OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
install -d -m 0700 -- "$OUTPUT"
|
||||
|
||||
uname -a > "$OUTPUT/uname.txt"
|
||||
cat /proc/cmdline > "$OUTPUT/cmdline.txt"
|
||||
(cd /boot && find . -maxdepth 1 -type f -print0 | sort -z | xargs -0 sha256sum) \
|
||||
> "$OUTPUT/boot.sha256"
|
||||
(cd /var/lib && find matrix-screen-controller -type f -print0 | sort -z | xargs -0 sha256sum) \
|
||||
> "$OUTPUT/persistent.sha256"
|
||||
|
||||
if [ -d /sys/kernel/debug/regmap ]; then
|
||||
for registers in /sys/kernel/debug/regmap/*-0036/registers; do
|
||||
[ -f "$registers" ] || continue
|
||||
regmap_name=$(basename -- "$(dirname -- "$registers")")
|
||||
cp -- "$registers" "$OUTPUT/pmic-$regmap_name-registers.txt"
|
||||
done
|
||||
fi
|
||||
|
||||
: > "$OUTPUT/regulators.txt"
|
||||
for regulator in /sys/class/regulator/regulator.*; do
|
||||
[ -d "$regulator" ] || continue
|
||||
printf '[%s]\n' "$(basename -- "$regulator")" >> "$OUTPUT/regulators.txt"
|
||||
for field in name microvolts state status; do
|
||||
if [ -r "$regulator/$field" ]; then
|
||||
if value=$(cat "$regulator/$field" 2>/dev/null); then
|
||||
printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/regulators.txt"
|
||||
else
|
||||
printf '%s=<unavailable>\n' "$field" >> "$OUTPUT/regulators.txt"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
: > "$OUTPUT/cpufreq.txt"
|
||||
for policy in /sys/devices/system/cpu/cpufreq/policy*; do
|
||||
[ -d "$policy" ] || continue
|
||||
printf '[%s]\n' "$(basename -- "$policy")" >> "$OUTPUT/cpufreq.txt"
|
||||
for field in affected_cpus related_cpus scaling_available_frequencies \
|
||||
scaling_available_governors scaling_cur_freq scaling_governor; do
|
||||
if [ -r "$policy/$field" ]; then
|
||||
if value=$(cat "$policy/$field" 2>/dev/null); then
|
||||
printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/cpufreq.txt"
|
||||
else
|
||||
printf '%s=<unavailable>\n' "$field" >> "$OUTPUT/cpufreq.txt"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [ -r /sys/kernel/debug/devices_deferred ]; then
|
||||
cp -- /sys/kernel/debug/devices_deferred "$OUTPUT/devices_deferred.txt"
|
||||
fi
|
||||
|
||||
: > "$OUTPUT/gpu.txt"
|
||||
for devfreq in /sys/class/devfreq/*; do
|
||||
[ -d "$devfreq" ] || continue
|
||||
case "$(basename -- "$devfreq")" in
|
||||
*gpu*|*mali*)
|
||||
printf '[%s]\n' "$(basename -- "$devfreq")" >> "$OUTPUT/gpu.txt"
|
||||
for field in available_frequencies cur_freq governor min_freq max_freq; do
|
||||
if [ -r "$devfreq/$field" ]; then
|
||||
if value=$(cat "$devfreq/$field" 2>/dev/null); then
|
||||
printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/gpu.txt"
|
||||
else
|
||||
printf '%s=<unavailable>\n' "$field" >> "$OUTPUT/gpu.txt"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
sync
|
||||
echo "state captured at $OUTPUT"
|
||||
@@ -0,0 +1,284 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve()
|
||||
WORKSPACE_ROOT = SCRIPT_PATH.parents[2]
|
||||
PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt")
|
||||
EXAMPLE_CREDENTIAL = PurePosixPath(
|
||||
"测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt"
|
||||
)
|
||||
BINARY_EXTENSIONS = {
|
||||
".deb",
|
||||
".dll",
|
||||
".docx",
|
||||
".exe",
|
||||
".gif",
|
||||
".gz",
|
||||
".img",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".otf",
|
||||
".ota",
|
||||
".pdf",
|
||||
".png",
|
||||
".rar",
|
||||
".so",
|
||||
".ttf",
|
||||
".whl",
|
||||
".woff",
|
||||
".woff2",
|
||||
".zip",
|
||||
}
|
||||
PRIVATE_IP_ALLOWED_PREFIXES = (
|
||||
"整体开发需求/",
|
||||
"测试相关资料/如何测试/",
|
||||
"核桃派软件源代码/",
|
||||
"发布更新相关/其他依赖/",
|
||||
)
|
||||
SAFE_SECRET_VALUES = {
|
||||
"changeme",
|
||||
"example",
|
||||
"example123",
|
||||
"fake",
|
||||
"fake-secret",
|
||||
"password",
|
||||
"secret123",
|
||||
"test",
|
||||
"test-password",
|
||||
}
|
||||
# The password is a high-confidence private marker. Addresses, usernames and
|
||||
# hostnames are checked structurally because common fixture values also occur in
|
||||
# provisioning source and tests.
|
||||
CURRENT_DEVICE_KEYS = ("密码",)
|
||||
PUBLIC_IMAGE_KEYS = (
|
||||
"镜像默认用户名",
|
||||
"镜像默认账户密码",
|
||||
"镜像默认WiFi SSID",
|
||||
"镜像默认WiFi密码",
|
||||
)
|
||||
|
||||
|
||||
class HygieneError(RuntimeError):
|
||||
"""Repository state cannot be audited safely."""
|
||||
|
||||
|
||||
def _git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
|
||||
return subprocess.run(
|
||||
("git", *arguments),
|
||||
cwd=WORKSPACE_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _decode_paths(payload: bytes) -> list[PurePosixPath]:
|
||||
return [
|
||||
PurePosixPath(item.decode("utf-8", errors="surrogateescape"))
|
||||
for item in payload.split(b"\0")
|
||||
if item
|
||||
]
|
||||
|
||||
|
||||
def candidate_paths(staged: bool) -> list[PurePosixPath]:
|
||||
if staged:
|
||||
result = _git("diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z")
|
||||
return _decode_paths(result.stdout)
|
||||
tracked = _decode_paths(_git("ls-files", "-z").stdout)
|
||||
untracked = _decode_paths(_git("ls-files", "--others", "--exclude-standard", "-z").stdout)
|
||||
return sorted(set((*tracked, *untracked)), key=str)
|
||||
|
||||
|
||||
def _is_binary(path: Path) -> bool:
|
||||
if path.suffix.casefold() in BINARY_EXTENSIONS:
|
||||
return True
|
||||
try:
|
||||
return b"\0" in path.read_bytes()[:8192]
|
||||
except OSError as error:
|
||||
raise HygieneError(f"无法读取候选文件:{path.relative_to(WORKSPACE_ROOT)}") from error
|
||||
|
||||
|
||||
def _host_text_patterns() -> list[re.Pattern[str]]:
|
||||
windows_prefix = r"[A-Za-z]:[\\/]" + r"(?:Users|Documents and Settings)[\\/]"
|
||||
mac_prefix = r"/" + r"Users/[A-Za-z0-9._-]+/"
|
||||
linux_home = r"/" + r"home/[A-Za-z0-9._-]+/"
|
||||
patterns = [re.compile(windows_prefix, re.IGNORECASE), re.compile(mac_prefix), re.compile(linux_home)]
|
||||
for value in (Path.home().name, os.environ.get("COMPUTERNAME", "")):
|
||||
if value and len(value) >= 4:
|
||||
patterns.append(re.compile(re.escape(value), re.IGNORECASE))
|
||||
return patterns
|
||||
|
||||
|
||||
def _private_value_markers() -> tuple[str, ...]:
|
||||
path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts)
|
||||
if not path.is_file():
|
||||
return ()
|
||||
fields: dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
separator = ":" if ":" in line else ":" if ":" in line else None
|
||||
if separator is None:
|
||||
continue
|
||||
key, value = (part.strip() for part in line.split(separator, 1))
|
||||
fields[key] = value
|
||||
public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS}
|
||||
return tuple(
|
||||
value
|
||||
for key in CURRENT_DEVICE_KEYS
|
||||
if len(value := fields.get(key, "")) >= 4 and value not in public_values
|
||||
)
|
||||
|
||||
|
||||
def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]:
|
||||
markers: list[bytes] = []
|
||||
host_values = {
|
||||
str(Path.home()),
|
||||
str(WORKSPACE_ROOT),
|
||||
Path.home().name,
|
||||
os.environ.get("COMPUTERNAME", ""),
|
||||
}
|
||||
for value in (*host_values, *private_values):
|
||||
if value and len(value) >= 4:
|
||||
variants = {value, value.replace("\\", "/")}
|
||||
for variant in variants:
|
||||
markers.extend((variant.encode("utf-8"), variant.encode("utf-16le")))
|
||||
return markers
|
||||
|
||||
|
||||
def _binary_contains_forbidden_marker(path: Path, private_values: tuple[str, ...]) -> bool:
|
||||
markers = _binary_markers(private_values)
|
||||
overlap = max(map(len, markers)) - 1
|
||||
tail = b""
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(8 * 1024 * 1024):
|
||||
sample = tail + chunk
|
||||
lowered_sample = sample.lower()
|
||||
if any(marker.lower() in lowered_sample for marker in markers):
|
||||
return True
|
||||
tail = sample[-overlap:] if overlap else b""
|
||||
return False
|
||||
|
||||
|
||||
def _text_issues(
|
||||
relative: PurePosixPath,
|
||||
text: str,
|
||||
private_values: tuple[str, ...] = (),
|
||||
) -> list[str]:
|
||||
issues: list[str] = []
|
||||
if any(pattern.search(text) for pattern in _host_text_patterns()):
|
||||
issues.append("包含开发电脑专属路径、用户名或主机名")
|
||||
if any(value in text for value in private_values):
|
||||
issues.append("包含当前设备私有凭据值")
|
||||
|
||||
key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----")
|
||||
if key_header.search(text):
|
||||
issues.append("包含私钥正文")
|
||||
|
||||
relative_string = relative.as_posix()
|
||||
if not (
|
||||
relative_string.startswith("核桃派软件源代码/tests/")
|
||||
or PurePosixPath(relative_string).name.startswith("test_")
|
||||
or relative == EXAMPLE_CREDENTIAL
|
||||
):
|
||||
assignment = re.compile(
|
||||
r"(?i)(?:password|passwd|token|secret|api[_-]?key|密码)\s*[:=]\s*[\"']([^\"']{4,})[\"']"
|
||||
)
|
||||
for match in assignment.finditer(text):
|
||||
if match.group(1).casefold() not in SAFE_SECRET_VALUES:
|
||||
issues.append("包含疑似硬编码秘密")
|
||||
break
|
||||
|
||||
if not (
|
||||
relative_string.startswith(PRIVATE_IP_ALLOWED_PREFIXES)
|
||||
or "/tests/" in f"/{relative_string}"
|
||||
):
|
||||
for token in re.findall(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])", text):
|
||||
try:
|
||||
address = ipaddress.ip_address(token)
|
||||
except ValueError:
|
||||
continue
|
||||
if address.is_private and not address.is_loopback and not address.is_unspecified:
|
||||
issues.append("归档或普通文档包含私有 IPv4 地址")
|
||||
break
|
||||
return issues
|
||||
|
||||
|
||||
def audit_paths(paths: list[PurePosixPath], *, scan_binary: bool) -> list[tuple[str, str]]:
|
||||
findings: list[tuple[str, str]] = []
|
||||
private_values = _private_value_markers()
|
||||
for relative in paths:
|
||||
if relative == PRIVATE_CREDENTIAL:
|
||||
findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合"))
|
||||
continue
|
||||
path = WORKSPACE_ROOT.joinpath(*relative.parts)
|
||||
if not path.is_file():
|
||||
continue
|
||||
if _is_binary(path):
|
||||
if scan_binary and _binary_contains_forbidden_marker(path, private_values):
|
||||
findings.append((relative.as_posix(), "二进制包含开发电脑标识、主目录路径或当前设备秘密"))
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
findings.append((relative.as_posix(), "文本候选不是有效 UTF-8"))
|
||||
continue
|
||||
findings.extend(
|
||||
(relative.as_posix(), issue) for issue in _text_issues(relative, text, private_values)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list[tuple[str, str]]:
|
||||
findings: list[tuple[str, str]] = []
|
||||
ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False)
|
||||
if ignored.returncode != 0:
|
||||
findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除"))
|
||||
|
||||
if staged:
|
||||
example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False)
|
||||
if example_in_index.returncode != 0:
|
||||
findings.append((EXAMPLE_CREDENTIAL.as_posix(), "示例凭据没有进入本次提交"))
|
||||
elif EXAMPLE_CREDENTIAL not in paths:
|
||||
findings.append((EXAMPLE_CREDENTIAL.as_posix(), "示例凭据不在 Git 候选集合"))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="检查 Git 候选文件中的凭据、主机路径和秘密")
|
||||
parser.add_argument("--staged", action="store_true", help="只检查已暂存的新建或修改文件")
|
||||
parser.add_argument(
|
||||
"--skip-binary-scan",
|
||||
action="store_true",
|
||||
help="跳过大体积二进制字节扫描,仅供快速诊断",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
paths = candidate_paths(args.staged)
|
||||
findings = _check_repository_contract(paths, args.staged)
|
||||
findings.extend(audit_paths(paths, scan_binary=not args.skip_binary_scan))
|
||||
except (HygieneError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(f"仓库卫生检查无法完成:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if findings:
|
||||
print("仓库卫生检查失败;以下输出只包含文件路径和问题类型:", file=sys.stderr)
|
||||
for path, issue in findings:
|
||||
print(f"- {path}: {issue}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"仓库卫生检查通过:已检查 {len(paths)} 个 Git 候选文件,未输出任何秘密值。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import json
|
||||
import lzma
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tarfile
|
||||
from urllib.parse import urljoin
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
PACKAGE_NAME = re.compile(r"^([a-z0-9][a-z0-9+.-]*(?::(?:any|native|arm64))?)")
|
||||
ARCH_FILTER = re.compile(r"\[([^]]+)]")
|
||||
VERSION_FILTER = re.compile(r"\((=|>=|<=|>>|<<)\s*([^)\s]+)\)")
|
||||
IMAGE_DEBIAN_ROOTS = ("ffmpeg", "libheif-examples", "python3.11-venv")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dependency:
|
||||
name: str
|
||||
operator: str = ""
|
||||
version: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageRecord:
|
||||
name: str
|
||||
version: str
|
||||
architecture: str
|
||||
dependencies: tuple[tuple[Dependency, ...], ...]
|
||||
provides: tuple[str, ...]
|
||||
filename: str = ""
|
||||
sha256: str = ""
|
||||
size: int = 0
|
||||
repository_base: str = ""
|
||||
local_path: Path | None = None
|
||||
|
||||
|
||||
def normalize_package_name(value: str) -> str:
|
||||
value = value.strip()
|
||||
if ":" in value:
|
||||
name, qualifier = value.rsplit(":", 1)
|
||||
if qualifier in {"any", "native", "arm64"}:
|
||||
return name
|
||||
return value
|
||||
|
||||
|
||||
def _arch_applies(value: str, architecture: str = "arm64") -> bool:
|
||||
match = ARCH_FILTER.search(value)
|
||||
if not match:
|
||||
return True
|
||||
filters = match.group(1).split()
|
||||
positives = {item for item in filters if not item.startswith("!")}
|
||||
negatives = {item[1:] for item in filters if item.startswith("!")}
|
||||
return architecture not in negatives and (not positives or architecture in positives)
|
||||
|
||||
|
||||
def parse_dependency_field(value: str) -> tuple[tuple[Dependency, ...], ...]:
|
||||
groups: list[tuple[Dependency, ...]] = []
|
||||
for raw_group in value.split(","):
|
||||
alternatives: list[Dependency] = []
|
||||
for raw_alternative in raw_group.split("|"):
|
||||
if not _arch_applies(raw_alternative):
|
||||
continue
|
||||
match = PACKAGE_NAME.match(raw_alternative.strip())
|
||||
if not match:
|
||||
continue
|
||||
version_match = VERSION_FILTER.search(raw_alternative)
|
||||
alternatives.append(
|
||||
Dependency(
|
||||
normalize_package_name(match.group(1)),
|
||||
version_match.group(1) if version_match else "",
|
||||
version_match.group(2) if version_match else "",
|
||||
)
|
||||
)
|
||||
if alternatives:
|
||||
groups.append(tuple(alternatives))
|
||||
return tuple(groups)
|
||||
|
||||
|
||||
def parse_control_stanzas(text: str) -> list[dict[str, str]]:
|
||||
stanzas: list[dict[str, str]] = []
|
||||
current: dict[str, str] = {}
|
||||
current_key = ""
|
||||
for line in text.splitlines() + [""]:
|
||||
if not line:
|
||||
if current:
|
||||
stanzas.append(current)
|
||||
current = {}
|
||||
current_key = ""
|
||||
continue
|
||||
if line[0].isspace() and current_key:
|
||||
current[current_key] += " " + line.strip()
|
||||
continue
|
||||
key, separator, value = line.partition(":")
|
||||
if separator:
|
||||
current_key = key
|
||||
current[key] = value.strip()
|
||||
return stanzas
|
||||
|
||||
|
||||
def record_from_fields(
|
||||
fields: dict[str, str], *, repository_base: str = "", local_path: Path | None = None
|
||||
) -> PackageRecord:
|
||||
dependencies = ", ".join(
|
||||
value for key in ("Pre-Depends", "Depends") if (value := fields.get(key, ""))
|
||||
)
|
||||
provides = tuple(
|
||||
normalize_package_name(item.strip().split()[0])
|
||||
for item in fields.get("Provides", "").split(",")
|
||||
if item.strip()
|
||||
)
|
||||
return PackageRecord(
|
||||
name=normalize_package_name(fields["Package"]),
|
||||
version=fields["Version"],
|
||||
architecture=fields.get("Architecture", "arm64"),
|
||||
dependencies=parse_dependency_field(dependencies),
|
||||
provides=provides,
|
||||
filename=fields.get("Filename", ""),
|
||||
sha256=fields.get("SHA256", "").lower(),
|
||||
size=int(fields.get("Size", "0")),
|
||||
repository_base=repository_base,
|
||||
local_path=local_path,
|
||||
)
|
||||
|
||||
|
||||
def load_package_index(path: Path, repository_base: str) -> dict[str, PackageRecord]:
|
||||
path = Path(path)
|
||||
if path.suffix == ".xz":
|
||||
with lzma.open(path, "rt", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
else:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
records: dict[str, PackageRecord] = {}
|
||||
for fields in parse_control_stanzas(text):
|
||||
if fields.get("Architecture") not in {"arm64", "all"}:
|
||||
continue
|
||||
record = record_from_fields(fields, repository_base=repository_base)
|
||||
records.setdefault(record.name, record)
|
||||
return records
|
||||
|
||||
|
||||
def _read_ar_member(path: Path, wanted_prefix: str) -> bytes:
|
||||
data = Path(path).read_bytes()
|
||||
if not data.startswith(b"!<arch>\n"):
|
||||
raise ValueError(f"not a Debian ar archive: {path}")
|
||||
offset = 8
|
||||
while offset + 60 <= len(data):
|
||||
header = data[offset : offset + 60]
|
||||
name = header[:16].decode("ascii").strip().rstrip("/")
|
||||
size = int(header[48:58].decode("ascii").strip())
|
||||
start = offset + 60
|
||||
end = start + size
|
||||
if name.startswith(wanted_prefix):
|
||||
return data[start:end]
|
||||
offset = end + (size % 2)
|
||||
raise ValueError(f"{wanted_prefix} is missing from {path}")
|
||||
|
||||
|
||||
def read_deb_control(path: Path) -> dict[str, str]:
|
||||
control_archive = _read_ar_member(path, "control.tar")
|
||||
with tarfile.open(fileobj=BytesIO(control_archive), mode="r:*") as archive:
|
||||
member = next(
|
||||
(item for item in archive.getmembers() if item.name.lstrip("./") == "control"),
|
||||
None,
|
||||
)
|
||||
if member is None:
|
||||
raise ValueError(f"control file is missing from {path}")
|
||||
handle = archive.extractfile(member)
|
||||
if handle is None:
|
||||
raise ValueError(f"control file cannot be read from {path}")
|
||||
stanzas = parse_control_stanzas(handle.read().decode("utf-8"))
|
||||
if len(stanzas) != 1:
|
||||
raise ValueError(f"unexpected control data in {path}")
|
||||
return stanzas[0]
|
||||
|
||||
|
||||
def load_local_packages(directory: Path) -> dict[str, PackageRecord]:
|
||||
records: dict[str, PackageRecord] = {}
|
||||
for path in sorted(Path(directory).glob("*.deb")):
|
||||
record = record_from_fields(read_deb_control(path), local_path=path.resolve())
|
||||
if record.name in records:
|
||||
raise ValueError(f"duplicate package {record.name} in {directory}")
|
||||
records[record.name] = record
|
||||
return records
|
||||
|
||||
|
||||
def load_installed_inventory(path: Path) -> dict[str, str]:
|
||||
installed: dict[str, str] = {}
|
||||
for number, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"invalid inventory line {number}")
|
||||
status, name, version = parts
|
||||
if status == "ii ":
|
||||
installed[normalize_package_name(name)] = version
|
||||
return installed
|
||||
|
||||
|
||||
def _exact_version_matches(dependency: Dependency, version: str) -> bool:
|
||||
return dependency.operator != "=" or dependency.version == version
|
||||
|
||||
|
||||
def resolve_closure(
|
||||
roots: list[str],
|
||||
installed: dict[str, str],
|
||||
local: dict[str, PackageRecord],
|
||||
available: dict[str, PackageRecord],
|
||||
) -> tuple[dict[str, PackageRecord], list[str]]:
|
||||
installed = dict(installed)
|
||||
for name in local:
|
||||
installed.pop(name, None)
|
||||
providers: dict[str, list[PackageRecord]] = {}
|
||||
for record in [*local.values(), *available.values()]:
|
||||
for provided in record.provides:
|
||||
providers.setdefault(provided, []).append(record)
|
||||
selected: dict[str, PackageRecord] = {}
|
||||
queue: deque[str] = deque(normalize_package_name(item) for item in roots)
|
||||
unresolved: list[str] = []
|
||||
|
||||
def candidate_for(dependency: Dependency) -> PackageRecord | None:
|
||||
direct = local.get(dependency.name) or available.get(dependency.name)
|
||||
if direct and _exact_version_matches(dependency, direct.version):
|
||||
return direct
|
||||
for provider in providers.get(dependency.name, []):
|
||||
if _exact_version_matches(dependency, provider.version):
|
||||
return provider
|
||||
return None
|
||||
|
||||
while queue:
|
||||
name = queue.popleft()
|
||||
if name in selected:
|
||||
continue
|
||||
record = local.get(name) or available.get(name)
|
||||
if record is None:
|
||||
unresolved.append(f"required package is unavailable: {name}")
|
||||
continue
|
||||
selected[name] = record
|
||||
for group in record.dependencies:
|
||||
if any(
|
||||
dependency.name in installed
|
||||
and _exact_version_matches(dependency, installed[dependency.name])
|
||||
for dependency in group
|
||||
):
|
||||
continue
|
||||
if any(
|
||||
dependency.name in selected
|
||||
and _exact_version_matches(dependency, selected[dependency.name].version)
|
||||
for dependency in group
|
||||
):
|
||||
continue
|
||||
choice = next((candidate_for(dependency) for dependency in group if candidate_for(dependency)), None)
|
||||
if choice is None:
|
||||
unresolved.append(
|
||||
f"{record.name} cannot satisfy: "
|
||||
+ " | ".join(
|
||||
f"{item.name} {item.operator} {item.version}".strip() for item in group
|
||||
)
|
||||
)
|
||||
continue
|
||||
queue.append(choice.name)
|
||||
return selected, sorted(set(unresolved))
|
||||
|
||||
|
||||
def verify_local_closure(directory: Path, inventory: Path, roots: list[str]) -> dict[str, PackageRecord]:
|
||||
local = load_local_packages(directory)
|
||||
selected, unresolved = resolve_closure(roots, load_installed_inventory(inventory), local, {})
|
||||
missing = sorted(set(roots) - set(selected))
|
||||
if missing or unresolved:
|
||||
detail = "; ".join([*(f"missing root: {item}" for item in missing), *unresolved])
|
||||
raise ValueError(f"offline Debian dependency closure is incomplete: {detail}")
|
||||
return selected
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download_records(records: dict[str, PackageRecord], output: Path) -> list[Path]:
|
||||
output = Path(output)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
downloaded: list[Path] = []
|
||||
for record in sorted(records.values(), key=lambda item: item.name):
|
||||
if record.local_path is not None:
|
||||
continue
|
||||
if not record.filename or not record.sha256 or not record.size:
|
||||
raise ValueError(f"repository metadata is incomplete for {record.name}")
|
||||
target = output / Path(record.filename).name
|
||||
if target.exists():
|
||||
if target.stat().st_size != record.size or _sha256_file(target) != record.sha256:
|
||||
raise ValueError(f"existing download does not match repository metadata: {target}")
|
||||
downloaded.append(target)
|
||||
continue
|
||||
url = urljoin(record.repository_base.rstrip("/") + "/", record.filename)
|
||||
partial = target.with_suffix(target.suffix + ".partial")
|
||||
with urlopen(url, timeout=60) as response, partial.open("wb") as handle:
|
||||
while chunk := response.read(1024 * 1024):
|
||||
handle.write(chunk)
|
||||
if partial.stat().st_size != record.size or _sha256_file(partial) != record.sha256:
|
||||
partial.unlink(missing_ok=True)
|
||||
raise ValueError(f"download verification failed: {record.name}")
|
||||
partial.replace(target)
|
||||
downloaded.append(target)
|
||||
return downloaded
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Resolve a minimal Debian 12/AArch64 offline closure")
|
||||
parser.add_argument("--inventory", type=Path, required=True)
|
||||
parser.add_argument("--local", type=Path, required=True)
|
||||
parser.add_argument("--index", action="append", default=[], metavar="PATH=REPOSITORY_BASE")
|
||||
parser.add_argument("--root", action="append", required=True)
|
||||
parser.add_argument("--download-to", type=Path)
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
available: dict[str, PackageRecord] = {}
|
||||
index_info: list[dict[str, str]] = []
|
||||
for value in args.index:
|
||||
path_text, separator, repository_base = value.partition("=")
|
||||
if not separator:
|
||||
parser.error("--index must be PATH=REPOSITORY_BASE")
|
||||
path = Path(path_text).resolve()
|
||||
for name, record in load_package_index(path, repository_base).items():
|
||||
available.setdefault(name, record)
|
||||
index_info.append({"file": path.name, "sha256": _sha256_file(path), "base": repository_base})
|
||||
|
||||
local = load_local_packages(args.local)
|
||||
selected, unresolved = resolve_closure(
|
||||
args.root, load_installed_inventory(args.inventory), local, available
|
||||
)
|
||||
if unresolved:
|
||||
raise SystemExit("\n".join(unresolved))
|
||||
downloaded = download_records(selected, args.download_to) if args.download_to else []
|
||||
report = {
|
||||
"schema_version": 1,
|
||||
"architecture": "arm64",
|
||||
"roots": args.root,
|
||||
"inventory_sha256": _sha256_file(args.inventory),
|
||||
"indices": index_info,
|
||||
"selected": [
|
||||
{
|
||||
"package": item.name,
|
||||
"version": item.version,
|
||||
"source": "local" if item.local_path else item.filename,
|
||||
"sha256": _sha256_file(item.local_path) if item.local_path else item.sha256,
|
||||
"size": item.local_path.stat().st_size if item.local_path else item.size,
|
||||
}
|
||||
for item in sorted(selected.values(), key=lambda record: record.name)
|
||||
],
|
||||
"downloaded_files": [item.name for item in downloaded],
|
||||
}
|
||||
rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||
if args.report:
|
||||
args.report.write_text(rendered, encoding="utf-8")
|
||||
else:
|
||||
print(rendered, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Sequence
|
||||
|
||||
|
||||
EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl"
|
||||
DISABLED_SERVICES = (
|
||||
"lightdm.service",
|
||||
"bluetooth.service",
|
||||
"aw859-bluetooth.service",
|
||||
"gpioc-server.service",
|
||||
"map_device.service",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
returncode: int
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPaths:
|
||||
pinctrl_compatible: Path
|
||||
dev_mem: Path
|
||||
i2c_device: Path
|
||||
cpu3: Path
|
||||
native_library: Path
|
||||
|
||||
@classmethod
|
||||
def detect(
|
||||
cls,
|
||||
root: Path = Path("/"),
|
||||
source_root: Path | None = None,
|
||||
) -> "HostPaths":
|
||||
source_root = source_root or Path(__file__).resolve().parents[1]
|
||||
compatible = (
|
||||
root
|
||||
/ "proc"
|
||||
/ "device-tree"
|
||||
/ "soc"
|
||||
/ "pinctrl@300b000"
|
||||
/ "compatible"
|
||||
)
|
||||
return cls(
|
||||
pinctrl_compatible=compatible,
|
||||
dev_mem=root / "dev" / "mem",
|
||||
i2c_device=root / "dev" / "i2c-1",
|
||||
cpu3=root / "sys" / "devices" / "system" / "cpu" / "cpu3",
|
||||
native_library=(
|
||||
source_root / "app" / "display" / "native" / "libh618_hub75.so"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostCheck:
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str
|
||||
|
||||
|
||||
Runner = Callable[[Sequence[str]], CommandResult]
|
||||
|
||||
|
||||
def run_command(args: Sequence[str]) -> CommandResult:
|
||||
completed = subprocess.run(
|
||||
list(args),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
|
||||
|
||||
|
||||
def _compatible_values(path: Path) -> tuple[str, ...]:
|
||||
if not path.is_file():
|
||||
return ()
|
||||
return tuple(
|
||||
value.decode("ascii", errors="replace")
|
||||
for value in path.read_bytes().split(b"\0")
|
||||
if value
|
||||
)
|
||||
|
||||
|
||||
def check_profile(
|
||||
paths: HostPaths,
|
||||
*,
|
||||
service_only: bool = False,
|
||||
runner: Runner = run_command,
|
||||
) -> list[HostCheck]:
|
||||
compatible = _compatible_values(paths.pinctrl_compatible)
|
||||
architecture = runner(("uname", "-m"))
|
||||
network_manager = runner(("systemctl", "is-active", "NetworkManager.service"))
|
||||
|
||||
checks = [
|
||||
HostCheck(
|
||||
"H618 PI pinctrl",
|
||||
EXPECTED_PINCTRL in compatible,
|
||||
", ".join(compatible) if compatible else "compatible node missing",
|
||||
),
|
||||
HostCheck(
|
||||
"/dev/mem accessible",
|
||||
paths.dev_mem.exists()
|
||||
and os.access(paths.dev_mem, os.R_OK | os.W_OK),
|
||||
str(paths.dev_mem),
|
||||
),
|
||||
HostCheck(
|
||||
"/dev/i2c-1 present",
|
||||
paths.i2c_device.exists(),
|
||||
str(paths.i2c_device),
|
||||
),
|
||||
HostCheck("CPU3 present", paths.cpu3.is_dir(), str(paths.cpu3)),
|
||||
HostCheck(
|
||||
"native HUB75 library",
|
||||
paths.native_library.is_file(),
|
||||
str(paths.native_library),
|
||||
),
|
||||
HostCheck(
|
||||
"AArch64 userspace",
|
||||
architecture.returncode == 0 and architecture.stdout.strip() == "aarch64",
|
||||
architecture.stdout.strip() or architecture.stderr.strip(),
|
||||
),
|
||||
HostCheck(
|
||||
"NetworkManager active",
|
||||
network_manager.returncode == 0
|
||||
and network_manager.stdout.strip() == "active",
|
||||
network_manager.stdout.strip() or network_manager.stderr.strip(),
|
||||
),
|
||||
]
|
||||
if not service_only:
|
||||
route = runner(("ip", "route", "show", "default"))
|
||||
checks.insert(
|
||||
-1,
|
||||
HostCheck(
|
||||
"default network route",
|
||||
route.returncode == 0 and bool(route.stdout.strip()),
|
||||
route.stdout.strip() or route.stderr.strip(),
|
||||
),
|
||||
)
|
||||
for unit in DISABLED_SERVICES:
|
||||
state = runner(("systemctl", "is-active", unit))
|
||||
checks.append(
|
||||
HostCheck(
|
||||
f"{unit} inactive",
|
||||
state.stdout.strip() != "active",
|
||||
state.stdout.strip() or state.stderr.strip() or "inactive",
|
||||
)
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Verify the dedicated WalnutPi ZeroW/H618 host profile."
|
||||
)
|
||||
parser.add_argument("command", choices=("check",))
|
||||
parser.add_argument("--service", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
checks = check_profile(HostPaths.detect(), service_only=args.service)
|
||||
if args.json:
|
||||
print(json.dumps([asdict(check) for check in checks], ensure_ascii=False))
|
||||
else:
|
||||
for check in checks:
|
||||
print(f"{'PASS' if check.ok else 'FAIL'}: {check.name}: {check.detail}")
|
||||
return 0 if all(check.ok for check in checks) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "deploy_walnutpi.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd)
|
||||
WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/发布更新相关/其他依赖/aarch64-py311}
|
||||
FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64}
|
||||
DEFER_SERVICE_START=${DEFER_SERVICE_START:-0}
|
||||
MANIFEST=$WHEELHOUSE/SHA256SUMS
|
||||
TARGET=/opt/matrix-screen-controller
|
||||
UNIT=/etc/systemd/system/matrix-screen-controller.service
|
||||
OTA_UNIT=/etc/systemd/system/matrix-screen-controller-ota.service
|
||||
CONVERTER_UNIT=/etc/systemd/system/matrix-screen-converter@.service
|
||||
|
||||
if [ "$(uname -m)" != "aarch64" ]; then
|
||||
echo "this payload is only for the WalnutPi AArch64 host" >&2
|
||||
exit 1
|
||||
fi
|
||||
for command in ffmpeg ffprobe heif-convert flock; do
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
echo "required media decoder command is missing: $command" >&2
|
||||
echo "install Debian packages ffmpeg and libheif-examples before deployment" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
FILTERS=$(ffmpeg -hide_banner -filters 2>/dev/null)
|
||||
for filter in zscale tonemap; do
|
||||
if ! printf '%s\n' "$FILTERS" | grep -Eq "[[:space:]]$filter[[:space:]]"; then
|
||||
echo "required FFmpeg filter is missing: $filter" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "offline wheel manifest missing: $MANIFEST" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$TARGET" ]; then
|
||||
if [ "$DEFER_SERVICE_START" != "1" ]; then
|
||||
echo "$TARGET already exists; this fresh-install script will not overwrite it" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$TARGET/VERSION" ] || [ "$(cat "$TARGET/VERSION")" != "$(cat "$SOURCE_ROOT/VERSION")" ]; then
|
||||
echo "$TARGET exists but is not the same software version; refusing first-boot recovery" >&2
|
||||
exit 1
|
||||
fi
|
||||
for path in \
|
||||
"$TARGET/.venv/bin/python" \
|
||||
"$TARGET/scripts/dedicated_host.py" \
|
||||
"$TARGET/app/display/native/libh618_hub75.so" \
|
||||
"$TARGET/app/display/native/hub75_safeoff" \
|
||||
"$TARGET/systemd/matrix-screen-controller.service" \
|
||||
"$TARGET/systemd/matrix-screen-controller-ota.service" \
|
||||
"$TARGET/systemd/matrix-screen-converter@.service"; do
|
||||
if [ ! -e "$path" ]; then
|
||||
echo "incomplete first-boot deployment cannot be recovered: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
FRPC_BUNDLE="$FRPC_BUNDLE" DEFER_SERVICE_START=1 /bin/sh "$TARGET/scripts/install_frpc_system.sh"
|
||||
"$TARGET/.venv/bin/python" "$TARGET/scripts/dedicated_host.py" check --service
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-converter@.service" "$CONVERTER_UNIT"
|
||||
systemctl daemon-reload
|
||||
systemctl enable matrix-screen-controller.service
|
||||
systemctl is-enabled --quiet matrix-screen-controller.service
|
||||
echo "Recovered the verified same-version deployment; service start is deferred until reboot"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
(cd "$WHEELHOUSE" && sha256sum -c SHA256SUMS)
|
||||
STAGING=$(mktemp -d /opt/matrix-screen-controller.stage.XXXXXX)
|
||||
cleanup() {
|
||||
case ${STAGING:-} in
|
||||
/opt/matrix-screen-controller.stage.*)
|
||||
if [ -d "$STAGING" ]; then
|
||||
rm -rf -- "$STAGING"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
cp -a "$SOURCE_ROOT/." "$STAGING/"
|
||||
python3 -m venv "$STAGING/.venv"
|
||||
"$STAGING/.venv/bin/pip" install \
|
||||
--no-index \
|
||||
--find-links "$WHEELHOUSE" \
|
||||
-r "$STAGING/requirements.txt"
|
||||
make -C "$STAGING/app/display/native" clean all test
|
||||
systemctl set-default multi-user.target
|
||||
systemctl disable --now \
|
||||
lightdm.service \
|
||||
bluetooth.service \
|
||||
aw859-bluetooth.service \
|
||||
gpioc-server.service \
|
||||
map_device.service
|
||||
if [ "$DEFER_SERVICE_START" = "1" ]; then
|
||||
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service
|
||||
else
|
||||
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check
|
||||
fi
|
||||
|
||||
install -d -m 0711 /var/lib/matrix-screen-controller
|
||||
install -d -m 0750 /run/matrix-screen-controller
|
||||
mv -- "$STAGING" "$TARGET"
|
||||
STAGING=
|
||||
FRPC_BUNDLE="$FRPC_BUNDLE" DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$TARGET/scripts/install_frpc_system.sh"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-converter@.service" "$CONVERTER_UNIT"
|
||||
systemctl daemon-reload
|
||||
if [ "$DEFER_SERVICE_START" = "1" ]; then
|
||||
systemctl enable matrix-screen-controller.service
|
||||
systemctl is-enabled --quiet matrix-screen-controller.service
|
||||
echo "WalnutPi Matrix Screen Controller installed; service start is deferred until reboot"
|
||||
exit 0
|
||||
fi
|
||||
systemctl enable --now matrix-screen-controller.service
|
||||
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 30 ]; do
|
||||
if curl --fail --silent http://127.0.0.1:8080/api/status >/dev/null; then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
systemctl --no-pager --full status matrix-screen-controller.service
|
||||
curl --fail --silent --show-error http://127.0.0.1:8080/api/status >/dev/null
|
||||
echo "WalnutPi Matrix Screen Controller deployed successfully"
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(SOURCE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from app.ota.package import build_package, extract_payload
|
||||
from app.ota.versioning import SoftwareVersion, read_software_version
|
||||
from app.ota.policy import export_bundle, package_format, release_metadata, read_policy
|
||||
from scripts.build_sd_image import build_image, sha256_file
|
||||
from scripts.fat16_image import Fat16Image
|
||||
from scripts.image_config import create_config_file, read_config_file
|
||||
|
||||
|
||||
def next_patch(version: SoftwareVersion) -> SoftwareVersion:
|
||||
return SoftwareVersion(version.major, version.minor, version.patch + 1)
|
||||
|
||||
|
||||
def _image_readme(version: SoftwareVersion, manifest: dict, notes: str, config: dict) -> str:
|
||||
return (
|
||||
f"# 奇妙小屏幕控制器 IMAGE {version}\n\n"
|
||||
f"- 软件版本:`{version}`\n"
|
||||
f"- 导出时间:`{manifest['created_at']}`\n"
|
||||
f"- 说明:{notes.strip()}\n"
|
||||
f"- 产物:`{manifest['artifact']}`\n"
|
||||
f"- SHA-256:`{manifest['image_sha256']}`\n\n"
|
||||
"## 未修改镜像的默认设置\n\n"
|
||||
f"- Linux 用户名:`{config['account']['username']}`\n"
|
||||
f"- Linux 密码:`{config['account']['password']}`\n"
|
||||
f"- Wi-Fi SSID:`{config['wifi']['ssid']}`\n"
|
||||
f"- Wi-Fi 密码:`{config['wifi']['password']}`\n"
|
||||
"- IPv4:`DHCP`\n\n"
|
||||
"> 这些是公开的默认凭据。建议刷写前使用镜像编辑器修改;直接使用未修改镜像时,"
|
||||
"请按上述 SSID 和密码开启热点,并在首次登录后立即更换账户及 Wi-Fi 设置。\n\n"
|
||||
"首次启动会离线安装全部软件并自动重启一次。请等待设备重新连接热点后再通过 SSH 或网页访问。\n\n"
|
||||
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
|
||||
)
|
||||
|
||||
|
||||
def _ota_readme(version: SoftwareVersion, manifest: dict, notes: str) -> str:
|
||||
metadata = release_metadata(version)
|
||||
return (
|
||||
f"# 奇妙小屏幕控制器 OTA {version}\n\n"
|
||||
f"- 软件版本:`{version}`\n"
|
||||
f"- 导出时间:`{manifest['created_at']}`\n"
|
||||
f"- 说明:{notes.strip()}\n"
|
||||
f"- 产物:`{manifest['artifact']}`\n\n"
|
||||
f"- 包类型:{'软件安装包(必经升级版本)' if metadata['is_checkpoint'] else '应用更新包'}\n"
|
||||
f"- 前置系列基线:`{metadata['required_checkpoint']}`\n\n"
|
||||
"在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。"
|
||||
"1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。"
|
||||
"初次安装默认关闭。禁止跳过失败测试;失败自动恢复。\n\n"
|
||||
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
|
||||
)
|
||||
|
||||
|
||||
def _copy_source(source: Path, destination: Path, version: SoftwareVersion) -> None:
|
||||
ignored = shutil.ignore_patterns(".venv", "__pycache__", ".pytest_cache", "node_modules", "data", "output", ".playwright-cli", "system-dependencies")
|
||||
shutil.copytree(source, destination, ignore=ignored)
|
||||
(destination / "VERSION").write_text(f"{version}\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def _history(path: Path) -> dict:
|
||||
if path.is_file():
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(document, dict) or document.get("schema_version") != 1 or not isinstance(document.get("releases"), list):
|
||||
raise ValueError("发布记录.json is invalid")
|
||||
return document
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "1.0.1",
|
||||
"artifact_type": "ota",
|
||||
"created_at": "2026-08-13T15:31:23+08:00",
|
||||
"notes": "新增浏览器全量 OTA。",
|
||||
"artifact_path": "发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota",
|
||||
"artifact_sha256": sha256_file(path.parent / "发布更新相关" / "OTA数据包" / "1.0.1" / "matrix-screen-controller-1.0.1.ota"),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _atomic_json(path: Path, document: dict) -> None:
|
||||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def _atomic_bytes(path: Path, body: bytes) -> None:
|
||||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
temporary.write_bytes(body)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def _require_image_host() -> None:
|
||||
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
raise ValueError("image export must run as root on Linux")
|
||||
missing = [name for name in ("bash", "losetup", "mount", "umount", "systemctl", "sha256sum") if shutil.which(name) is None]
|
||||
if missing:
|
||||
raise ValueError(f"image export host is missing required commands: {', '.join(missing)}")
|
||||
|
||||
|
||||
def _validated_repair_input(
|
||||
final_directory: Path,
|
||||
history: dict,
|
||||
version: SoftwareVersion,
|
||||
) -> tuple[bytes, int]:
|
||||
manifest_path = final_directory / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
raise ValueError("current image release manifest is missing")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
artifact_name = f"matrix-screen-controller-{version}.img"
|
||||
artifact = final_directory / artifact_name
|
||||
sidecar = final_directory / f"{artifact_name}.sha256"
|
||||
if (
|
||||
manifest.get("artifact_type") != "image"
|
||||
or manifest.get("software_version") != str(version)
|
||||
or manifest.get("artifact") != artifact_name
|
||||
or not artifact.is_file()
|
||||
or not sidecar.is_file()
|
||||
):
|
||||
raise ValueError("current image release files do not match the repair target")
|
||||
digest = sha256_file(artifact)
|
||||
expected_sidecar = f"{digest} {artifact_name}\n".encode("ascii")
|
||||
if sidecar.read_bytes() != expected_sidecar or manifest.get("image_sha256") != digest:
|
||||
raise ValueError("current image release digest is inconsistent")
|
||||
matches = [
|
||||
(index, item)
|
||||
for index, item in enumerate(history["releases"])
|
||||
if item.get("version") == str(version)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise ValueError("current version must have exactly one release record before repair")
|
||||
index, record = matches[0]
|
||||
expected_path = f"发布更新相关/导出包/{version}/{artifact_name}"
|
||||
if (
|
||||
record.get("artifact_type") != "image"
|
||||
or record.get("artifact_path") != expected_path
|
||||
or record.get("artifact_sha256") != digest
|
||||
):
|
||||
raise ValueError("current image release record is inconsistent")
|
||||
with Fat16Image(artifact) as image:
|
||||
config_bytes = image.read_file("MSCCFG.BIN")
|
||||
config, _, _ = read_config_file(config_bytes)
|
||||
if config["software_version"] != str(version):
|
||||
raise ValueError("current image configuration version does not match VERSION")
|
||||
return config_bytes, index
|
||||
|
||||
|
||||
def _commit_repair(
|
||||
final_directory: Path,
|
||||
temporary_directory: Path,
|
||||
history_path: Path,
|
||||
history: dict,
|
||||
) -> Path | None:
|
||||
backup = final_directory.parent / f".{final_directory.name}.{uuid.uuid4().hex}.invalid-backup"
|
||||
os.replace(final_directory, backup)
|
||||
try:
|
||||
os.replace(temporary_directory, final_directory)
|
||||
_atomic_json(history_path, history)
|
||||
except BaseException:
|
||||
if final_directory.exists():
|
||||
shutil.rmtree(final_directory, ignore_errors=True)
|
||||
os.replace(backup, final_directory)
|
||||
raise
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
return backup if backup.exists() else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Export one versioned OTA or SD image release")
|
||||
parser.add_argument("kind", choices=("ota", "image"))
|
||||
parser.add_argument("--notes", required=True)
|
||||
parser.add_argument("--version", type=SoftwareVersion.parse, help="explicit release version, e.g. 1.1.0")
|
||||
parser.add_argument("--config", type=Path, help="required JSON provisioning config for image export")
|
||||
parser.add_argument("--candidate-output", type=Path, help="build a verified image candidate directory without publishing")
|
||||
parser.add_argument("--validated-candidate", type=Path, help="exact real-device validated OTA file or image candidate directory")
|
||||
parser.add_argument("--validation-report", type=Path, help="matching real-device validation JSON")
|
||||
parser.add_argument(
|
||||
"--repair-current",
|
||||
action="store_true",
|
||||
help="rebuild and atomically replace the registered current-version image",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.repair_current and args.version is not None:
|
||||
parser.error("--repair-current cannot be combined with --version")
|
||||
if args.kind == "ota" and args.repair_current:
|
||||
if args.validated_candidate is None or args.validation_report is None:
|
||||
parser.error("OTA repair requires --validated-candidate and --validation-report")
|
||||
from scripts.repair_ota_release import promote
|
||||
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
|
||||
return 0
|
||||
image_promotion = args.kind == "image" and args.validated_candidate is not None
|
||||
if image_promotion:
|
||||
if args.validation_report is None:
|
||||
parser.error("image candidate promotion requires --validation-report")
|
||||
if args.config is not None or args.version is not None or args.candidate_output is not None or args.repair_current:
|
||||
parser.error("image candidate promotion cannot use --config, --version, --candidate-output, or --repair-current")
|
||||
try:
|
||||
_require_image_host()
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
from scripts.promote_image_release import promote
|
||||
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
|
||||
return 0
|
||||
if args.validated_candidate is not None or args.validation_report is not None:
|
||||
parser.error("candidate validation options require OTA --repair-current or image promotion")
|
||||
if args.kind == "image" and args.config is None and not args.repair_current:
|
||||
parser.error("--config is required for image candidate export")
|
||||
if args.kind == "image" and not args.repair_current and args.candidate_output is None:
|
||||
parser.error("new image releases must use --candidate-output and pass real-card validation before publishing")
|
||||
if args.kind == "ota" and args.candidate_output is not None:
|
||||
parser.error("--candidate-output is only supported for image export")
|
||||
if args.repair_current and args.config is not None:
|
||||
parser.error("--repair-current reuses the registered image configuration; do not pass --config")
|
||||
if args.kind == "image":
|
||||
try:
|
||||
_require_image_host()
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
source = SOURCE_ROOT
|
||||
project = source.parent
|
||||
lock = project / ".release-export.lock"
|
||||
try:
|
||||
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
parser.error("another release export is already active")
|
||||
os.close(descriptor)
|
||||
try:
|
||||
current = read_software_version(source)
|
||||
target = current if args.repair_current else (args.version or next_patch(current))
|
||||
dependency_bundle = None
|
||||
if not args.repair_current and args.kind == "ota":
|
||||
dependency_bundle = export_bundle(source, project / "发布更新相关" / "其他依赖", current, target)
|
||||
if args.kind == "image" and not args.repair_current and target <= current:
|
||||
parser.error("image candidate version must be newer than the current version")
|
||||
output_root = project / "发布更新相关" / "OTA数据包" if args.kind == "ota" else project / "发布更新相关" / "导出包"
|
||||
official_directory = output_root / str(target)
|
||||
final_directory = args.candidate_output.resolve() if args.candidate_output is not None else official_directory
|
||||
if args.candidate_output is not None:
|
||||
try:
|
||||
final_directory.relative_to(output_root.resolve())
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
parser.error("candidate output must be outside the formal image release directory")
|
||||
if final_directory.exists() and not args.repair_current:
|
||||
parser.error(f"release directory already exists: {final_directory}")
|
||||
if official_directory.exists() and args.candidate_output is not None:
|
||||
parser.error(f"formal image release directory already exists: {official_directory}")
|
||||
if args.repair_current and not final_directory.is_dir():
|
||||
parser.error(f"current image release directory is missing: {final_directory}")
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
history_path = project / "发布记录.json"
|
||||
history = _history(history_path)
|
||||
policy = read_policy(source)
|
||||
for previous in history["releases"]:
|
||||
recorded = previous.get("component_checkpoints", [])
|
||||
if any(entry not in policy["checkpoints"] for entry in recorded):
|
||||
parser.error("已发布的依赖节点不可改写;请追加新的 minor .0 节点")
|
||||
history_original = history_path.read_bytes() if history_path.is_file() else None
|
||||
version_original = (source / "VERSION").read_bytes()
|
||||
repair_record_index: int | None = None
|
||||
repair_config: bytes | None = None
|
||||
if args.repair_current:
|
||||
try:
|
||||
repair_config, repair_record_index = _validated_repair_input(
|
||||
final_directory, history, target
|
||||
)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
parser.error(str(exc))
|
||||
build_parent = final_directory.parent if args.candidate_output is not None else output_root
|
||||
build_parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_directory = build_parent / f".{final_directory.name}.{uuid.uuid4().hex}.building"
|
||||
temporary_directory.mkdir()
|
||||
published = False
|
||||
replaced_directory: Path | None = None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="matrix-release-source-") as staging_text:
|
||||
staged_source = Path(staging_text) / "核桃派软件源代码"
|
||||
_copy_source(source, staged_source, target)
|
||||
created = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
if args.kind == "ota":
|
||||
artifact = temporary_directory / f"matrix-screen-controller-{target}.ota"
|
||||
info = build_package(
|
||||
staged_source,
|
||||
project / "发布更新相关" / "其他依赖" / "aarch64-py311",
|
||||
artifact,
|
||||
version=target,
|
||||
release_notes=args.notes,
|
||||
system_dependencies=dependency_bundle,
|
||||
)
|
||||
verified = Path(staging_text) / "verified-ota"
|
||||
extract_payload(info, verified)
|
||||
if (verified / "software/VERSION").read_text(encoding="utf-8").strip() != str(target):
|
||||
raise ValueError("exported payload version mismatch")
|
||||
if (verified / "software/FEATURE_UPDATED_AT").read_bytes() != (source / "FEATURE_UPDATED_AT").read_bytes():
|
||||
raise ValueError("exported feature timestamp mismatch")
|
||||
if read_policy(verified / "software") != policy:
|
||||
raise ValueError("exported dependency policy mismatch")
|
||||
manifest = {
|
||||
"format_version": package_format(target),
|
||||
"artifact_type": "ota",
|
||||
"software_version": str(target),
|
||||
"created_at": info.created_at,
|
||||
"notes": args.notes.strip(),
|
||||
"artifact": artifact.name,
|
||||
"artifact_bytes": artifact.stat().st_size,
|
||||
"artifact_sha256": sha256_file(artifact),
|
||||
**release_metadata(target),
|
||||
}
|
||||
else:
|
||||
if repair_config is not None:
|
||||
config: dict | bytes = repair_config
|
||||
else:
|
||||
config = json.loads(args.config.read_text(encoding="utf-8"))
|
||||
config["software_version"] = str(target)
|
||||
artifact = temporary_directory / f"matrix-screen-controller-{target}.img"
|
||||
bootstrap_bundle = Path(staging_text) / "MSCBOOT.TGZ"
|
||||
kernel_dependency = (
|
||||
project
|
||||
/ "发布更新相关"
|
||||
/ "其他依赖"
|
||||
/ "aarch64-kernel"
|
||||
/ "6.1.31-matrix-axp313a1"
|
||||
)
|
||||
kernel_source = (
|
||||
project
|
||||
/ "发布更新相关"
|
||||
/ "其他依赖"
|
||||
/ "kernel-source"
|
||||
/ "walnutpi-linux-6.1.31-30ff3fd5"
|
||||
)
|
||||
manifest = build_image(
|
||||
project / "发布更新相关" / "核桃派镜像" / "2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img",
|
||||
staged_source,
|
||||
project / "发布更新相关" / "其他依赖" / "aarch64-py311",
|
||||
project / "发布更新相关" / "其他依赖" / "debian12-aarch64",
|
||||
kernel_dependency,
|
||||
kernel_source,
|
||||
config,
|
||||
artifact,
|
||||
bootstrap_bundle,
|
||||
project / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64",
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(staged_source / "scripts" / "install_image_bootstrap.sh"),
|
||||
str(artifact),
|
||||
str(staged_source / "systemd" / "matrix-image-firstboot.service"),
|
||||
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
|
||||
str(kernel_dependency),
|
||||
str(bootstrap_bundle),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(staged_source / "scripts" / "verify_image_bootstrap.sh"),
|
||||
str(artifact),
|
||||
str(staged_source / "systemd" / "matrix-image-firstboot.service"),
|
||||
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
|
||||
str(kernel_dependency),
|
||||
str(bootstrap_bundle),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
manifest["created_at"] = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
manifest["image_bytes"] = artifact.stat().st_size
|
||||
manifest["image_sha256"] = sha256_file(artifact)
|
||||
manifest.update({"artifact_type": "image", "notes": args.notes.strip(), "artifact": artifact.name})
|
||||
config_document, _generation, _slot = read_config_file(
|
||||
repair_config if repair_config is not None else create_config_file(config)
|
||||
)
|
||||
manifest["config_sha256"] = hashlib.sha256(
|
||||
repair_config if repair_config is not None else create_config_file(config_document)
|
||||
).hexdigest()
|
||||
(temporary_directory / f"{artifact.name}.sha256").write_text(
|
||||
f"{manifest['image_sha256'] if args.kind == 'image' else manifest['artifact_sha256']} {artifact.name}\n",
|
||||
encoding="ascii",
|
||||
newline="\n",
|
||||
)
|
||||
(temporary_directory / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
(temporary_directory / "README.md").write_text(
|
||||
_image_readme(target, manifest, args.notes, config_document)
|
||||
if args.kind == "image"
|
||||
else _ota_readme(target, manifest, args.notes),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
if args.candidate_output is not None:
|
||||
os.replace(temporary_directory, final_directory)
|
||||
published = True
|
||||
print(final_directory)
|
||||
return 0
|
||||
release_record = {
|
||||
"version": str(target),
|
||||
"artifact_type": args.kind,
|
||||
"created_at": manifest["created_at"],
|
||||
"notes": args.notes.strip(),
|
||||
"artifact_path": f"{'发布更新相关/OTA数据包' if args.kind == 'ota' else '发布更新相关/导出包'}/{target}/{artifact.name}",
|
||||
"artifact_sha256": manifest["image_sha256"] if args.kind == "image" else manifest["artifact_sha256"],
|
||||
**(release_metadata(target) if args.kind == "ota" else {}),
|
||||
**({"component_checkpoints": policy["checkpoints"]} if args.kind == "ota" else {}),
|
||||
}
|
||||
if args.repair_current:
|
||||
assert repair_record_index is not None
|
||||
history["releases"][repair_record_index] = release_record
|
||||
replaced_directory = _commit_repair(
|
||||
final_directory,
|
||||
temporary_directory,
|
||||
history_path,
|
||||
history,
|
||||
)
|
||||
published = True
|
||||
if replaced_directory is not None and replaced_directory.exists():
|
||||
print(f"warning: obsolete image backup still requires cleanup: {replaced_directory}", file=sys.stderr)
|
||||
replaced_directory = None
|
||||
else:
|
||||
os.replace(temporary_directory, final_directory)
|
||||
published = True
|
||||
history["releases"].append(release_record)
|
||||
_atomic_json(history_path, history)
|
||||
version_temp = source / ".VERSION.release.tmp"
|
||||
version_temp.write_text(f"{target}\n", encoding="utf-8", newline="\n")
|
||||
os.replace(version_temp, source / "VERSION")
|
||||
print(final_directory)
|
||||
except BaseException:
|
||||
shutil.rmtree(temporary_directory, ignore_errors=True)
|
||||
if published and not args.repair_current and args.candidate_output is None:
|
||||
shutil.rmtree(final_directory, ignore_errors=True)
|
||||
if history_original is None:
|
||||
history_path.unlink(missing_ok=True)
|
||||
else:
|
||||
_atomic_bytes(history_path, history_original)
|
||||
_atomic_bytes(source / "VERSION", version_original)
|
||||
(source / ".VERSION.release.tmp").unlink(missing_ok=True)
|
||||
if replaced_directory is not None and replaced_directory.exists() and not final_directory.exists():
|
||||
os.replace(replaced_directory, final_directory)
|
||||
raise
|
||||
finally:
|
||||
lock.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import struct
|
||||
from typing import BinaryIO
|
||||
|
||||
|
||||
class Fat16Error(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FatEntry:
|
||||
directory_offset: int
|
||||
first_cluster: int
|
||||
size: int
|
||||
|
||||
|
||||
class Fat16Image:
|
||||
def __init__(self, path: Path, *, writable: bool = False):
|
||||
self.path = Path(path)
|
||||
self.handle: BinaryIO = self.path.open("r+b" if writable else "rb")
|
||||
self.writable = writable
|
||||
self._load_geometry()
|
||||
|
||||
def __enter__(self) -> "Fat16Image":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.handle.close()
|
||||
|
||||
def _load_geometry(self) -> None:
|
||||
self.handle.seek(0)
|
||||
mbr = self.handle.read(512)
|
||||
if len(mbr) != 512 or mbr[510:512] != b"\x55\xaa":
|
||||
raise Fat16Error("image does not contain a valid MBR")
|
||||
entry = mbr[446:462]
|
||||
self.partition_lba = struct.unpack_from("<I", entry, 8)[0]
|
||||
self.partition_sectors = struct.unpack_from("<I", entry, 12)[0]
|
||||
self.partition_offset = self.partition_lba * 512
|
||||
if entry[4] not in {0x04, 0x06, 0x0E, 0x0C} or not self.partition_lba:
|
||||
raise Fat16Error("first image partition is not a supported FAT partition")
|
||||
self.handle.seek(self.partition_offset)
|
||||
bpb = self.handle.read(512)
|
||||
self.bytes_per_sector = struct.unpack_from("<H", bpb, 11)[0]
|
||||
self.sectors_per_cluster = bpb[13]
|
||||
self.reserved_sectors = struct.unpack_from("<H", bpb, 14)[0]
|
||||
self.fat_count = bpb[16]
|
||||
self.root_entries = struct.unpack_from("<H", bpb, 17)[0]
|
||||
self.fat_sectors = struct.unpack_from("<H", bpb, 22)[0]
|
||||
if self.bytes_per_sector != 512 or not self.fat_sectors or not self.root_entries:
|
||||
raise Fat16Error("first image partition is not FAT16")
|
||||
self.cluster_bytes = self.bytes_per_sector * self.sectors_per_cluster
|
||||
self.fat_offset = self.partition_offset + self.reserved_sectors * self.bytes_per_sector
|
||||
self.root_offset = self.fat_offset + self.fat_count * self.fat_sectors * self.bytes_per_sector
|
||||
self.root_bytes = self.root_entries * 32
|
||||
self.data_offset = self.root_offset + self.root_bytes
|
||||
data_sectors = self.partition_sectors - (
|
||||
self.reserved_sectors + self.fat_count * self.fat_sectors + self.root_bytes // self.bytes_per_sector
|
||||
)
|
||||
self.cluster_count = data_sectors // self.sectors_per_cluster
|
||||
if not 4085 <= self.cluster_count < 65525:
|
||||
raise Fat16Error("FAT partition cluster count is not FAT16")
|
||||
|
||||
@staticmethod
|
||||
def _name83(name: str) -> bytes:
|
||||
value = name.upper()
|
||||
if value.count(".") > 1:
|
||||
raise Fat16Error("FAT filename must use 8.3 form")
|
||||
base, dot, extension = value.partition(".")
|
||||
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$~!#%&-{}()@'`"
|
||||
if not 1 <= len(base) <= 8 or len(extension) > 3 or any(c not in allowed for c in base + extension):
|
||||
raise Fat16Error(f"unsupported FAT 8.3 filename: {name}")
|
||||
return base.ljust(8).encode("ascii") + extension.ljust(3).encode("ascii")
|
||||
|
||||
def _read_fat(self) -> bytearray:
|
||||
self.handle.seek(self.fat_offset)
|
||||
return bytearray(self.handle.read(self.fat_sectors * self.bytes_per_sector))
|
||||
|
||||
def _fat_value(self, fat: bytes, cluster: int) -> int:
|
||||
return struct.unpack_from("<H", fat, cluster * 2)[0]
|
||||
|
||||
def _set_fat_value(self, fat: bytearray, cluster: int, value: int) -> None:
|
||||
struct.pack_into("<H", fat, cluster * 2, value)
|
||||
|
||||
def _write_fats(self, fat: bytes) -> None:
|
||||
if not self.writable:
|
||||
raise Fat16Error("image was opened read-only")
|
||||
for index in range(self.fat_count):
|
||||
self.handle.seek(self.fat_offset + index * self.fat_sectors * self.bytes_per_sector)
|
||||
self.handle.write(fat)
|
||||
|
||||
def find(self, name: str) -> FatEntry | None:
|
||||
expected = self._name83(name)
|
||||
self.handle.seek(self.root_offset)
|
||||
root = self.handle.read(self.root_bytes)
|
||||
for index in range(self.root_entries):
|
||||
offset = index * 32
|
||||
raw = root[offset : offset + 32]
|
||||
if raw[0] == 0x00:
|
||||
break
|
||||
if raw[0] == 0xE5 or raw[11] == 0x0F:
|
||||
continue
|
||||
if raw[:11] == expected:
|
||||
return FatEntry(
|
||||
self.root_offset + offset,
|
||||
struct.unpack_from("<H", raw, 26)[0],
|
||||
struct.unpack_from("<I", raw, 28)[0],
|
||||
)
|
||||
return None
|
||||
|
||||
def _chain(self, first_cluster: int, fat: bytes) -> list[int]:
|
||||
if first_cluster < 2:
|
||||
return []
|
||||
result: list[int] = []
|
||||
seen: set[int] = set()
|
||||
cluster = first_cluster
|
||||
while 2 <= cluster < 0xFFF8:
|
||||
if cluster in seen or cluster > self.cluster_count + 1:
|
||||
raise Fat16Error("FAT cluster chain is invalid")
|
||||
seen.add(cluster)
|
||||
result.append(cluster)
|
||||
cluster = self._fat_value(fat, cluster)
|
||||
return result
|
||||
|
||||
def _cluster_offset(self, cluster: int) -> int:
|
||||
return self.data_offset + (cluster - 2) * self.cluster_bytes
|
||||
|
||||
def read_file(self, name: str) -> bytes:
|
||||
entry = self.find(name)
|
||||
if entry is None:
|
||||
raise FileNotFoundError(name)
|
||||
fat = self._read_fat()
|
||||
remaining = entry.size
|
||||
result = bytearray()
|
||||
for cluster in self._chain(entry.first_cluster, fat):
|
||||
self.handle.seek(self._cluster_offset(cluster))
|
||||
chunk = self.handle.read(min(self.cluster_bytes, remaining))
|
||||
result.extend(chunk)
|
||||
remaining -= len(chunk)
|
||||
if remaining == 0:
|
||||
break
|
||||
if remaining:
|
||||
raise Fat16Error(f"FAT file is truncated: {name}")
|
||||
return bytes(result)
|
||||
|
||||
def free_bytes(self) -> int:
|
||||
fat = self._read_fat()
|
||||
free_clusters = sum(
|
||||
1
|
||||
for cluster in range(2, self.cluster_count + 2)
|
||||
if self._fat_value(fat, cluster) == 0
|
||||
)
|
||||
return free_clusters * self.cluster_bytes
|
||||
|
||||
def _free_chain(self, first_cluster: int, fat: bytearray) -> None:
|
||||
for cluster in self._chain(first_cluster, fat):
|
||||
self._set_fat_value(fat, cluster, 0)
|
||||
|
||||
def _directory_slot(self, name: str) -> int:
|
||||
existing = self.find(name)
|
||||
if existing is not None:
|
||||
return existing.directory_offset
|
||||
self.handle.seek(self.root_offset)
|
||||
root = self.handle.read(self.root_bytes)
|
||||
for index in range(self.root_entries):
|
||||
if root[index * 32] in {0x00, 0xE5}:
|
||||
return self.root_offset + index * 32
|
||||
raise Fat16Error("FAT16 root directory is full")
|
||||
|
||||
def write_file(self, name: str, data: bytes, *, contiguous: bool = False) -> None:
|
||||
if not self.writable:
|
||||
raise Fat16Error("image was opened read-only")
|
||||
content = bytes(data)
|
||||
fat = self._read_fat()
|
||||
existing = self.find(name)
|
||||
if existing is not None:
|
||||
self._free_chain(existing.first_cluster, fat)
|
||||
required = max(1, (len(content) + self.cluster_bytes - 1) // self.cluster_bytes)
|
||||
free = [cluster for cluster in range(2, self.cluster_count + 2) if self._fat_value(fat, cluster) == 0]
|
||||
if contiguous:
|
||||
clusters: list[int] = []
|
||||
run: list[int] = []
|
||||
previous = -2
|
||||
for cluster in free:
|
||||
run = run + [cluster] if cluster == previous + 1 else [cluster]
|
||||
previous = cluster
|
||||
if len(run) == required:
|
||||
clusters = run
|
||||
break
|
||||
else:
|
||||
clusters = free[:required]
|
||||
if len(clusters) != required:
|
||||
raise Fat16Error("FAT16 partition does not have enough free clusters")
|
||||
for index, cluster in enumerate(clusters):
|
||||
next_cluster = clusters[index + 1] if index + 1 < len(clusters) else 0xFFFF
|
||||
self._set_fat_value(fat, cluster, next_cluster)
|
||||
chunk = content[index * self.cluster_bytes : (index + 1) * self.cluster_bytes]
|
||||
self.handle.seek(self._cluster_offset(cluster))
|
||||
self.handle.write(chunk)
|
||||
if len(chunk) < self.cluster_bytes:
|
||||
self.handle.write(bytes(self.cluster_bytes - len(chunk)))
|
||||
self._write_fats(fat)
|
||||
directory_offset = self._directory_slot(name)
|
||||
record = bytearray(32)
|
||||
record[:11] = self._name83(name)
|
||||
record[11] = 0x20
|
||||
struct.pack_into("<H", record, 26, clusters[0])
|
||||
struct.pack_into("<I", record, 28, len(content))
|
||||
self.handle.seek(directory_offset)
|
||||
self.handle.write(record)
|
||||
self.handle.flush()
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from app.animations.store import AnimationStore
|
||||
|
||||
|
||||
PRODUCTION_DATA_ROOT = Path("/var/lib/matrix-screen-controller")
|
||||
REQUIRED_BASENAME_PREFIX = "matrix-screen-controller-preview-perf-"
|
||||
|
||||
|
||||
class FixtureError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_empty_test_root(value: str | Path) -> Path:
|
||||
path = Path(value).expanduser().resolve(strict=True)
|
||||
if not path.is_dir():
|
||||
raise FixtureError("pressure-test data root must be an existing directory")
|
||||
if path == Path(path.anchor) or path == Path.home().resolve():
|
||||
raise FixtureError("pressure-test data root is too broad")
|
||||
if path == PRODUCTION_DATA_ROOT or PRODUCTION_DATA_ROOT in path.parents:
|
||||
raise FixtureError("refusing to use the production data root")
|
||||
if not path.name.startswith(REQUIRED_BASENAME_PREFIX):
|
||||
raise FixtureError(
|
||||
f"pressure-test directory name must start with {REQUIRED_BASENAME_PREFIX!r}"
|
||||
)
|
||||
if any(path.iterdir()):
|
||||
raise FixtureError("pressure-test data root must be empty")
|
||||
return path
|
||||
|
||||
|
||||
def solid_scene(sequence: int) -> dict[str, object]:
|
||||
if not 0 <= sequence <= 0xFFFFFF:
|
||||
raise FixtureError("fixture sequence exceeds unique RGB color space")
|
||||
color = bytes(((sequence >> 16) & 0xFF, (sequence >> 8) & 0xFF, sequence & 0xFF))
|
||||
return {
|
||||
"version": 1,
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"pixelRgb": base64.b64encode(color * (64 * 64)).decode("ascii"),
|
||||
"elements": [],
|
||||
}
|
||||
|
||||
|
||||
def fixture_name(index: int) -> str:
|
||||
if index % 3 == 0:
|
||||
return f"压力动图_{index:03d}_" + "很长的中文名称" * 7
|
||||
if index % 3 == 1:
|
||||
return f"stress_{index:03d}_" + "abcdefghijklmnopqrstuvwxyz" * 2
|
||||
return f"混合Stress_{index:03d}_" + "无空格LongName" * 5
|
||||
|
||||
|
||||
def generate_fixture(
|
||||
data_root: Path,
|
||||
*,
|
||||
animation_count: int = 100,
|
||||
frames_per_animation: int = 100,
|
||||
duration_ms: int = 50,
|
||||
) -> dict[str, int | str]:
|
||||
if not 1 <= animation_count <= 1000:
|
||||
raise FixtureError("animation count must be within 1..1000")
|
||||
if not 1 <= frames_per_animation <= 30_000:
|
||||
raise FixtureError("frames per animation must be within 1..30000")
|
||||
if not 50 <= duration_ms <= 604_800_000:
|
||||
raise FixtureError("frame duration must be within 50..604800000ms")
|
||||
if animation_count * frames_per_animation > 0x1000000:
|
||||
raise FixtureError("fixture needs more than 24-bit unique solid colors")
|
||||
|
||||
store = AnimationStore(data_root)
|
||||
sequence = 0
|
||||
for animation_index in range(animation_count):
|
||||
def frames():
|
||||
nonlocal sequence
|
||||
for _frame_index in range(frames_per_animation):
|
||||
scene = solid_scene(sequence)
|
||||
sequence += 1
|
||||
yield {"scene": scene, "name": None, "duration_ms": duration_ms}
|
||||
|
||||
store.create_from_frames(fixture_name(animation_index), frames())
|
||||
|
||||
return {
|
||||
"data_root": str(data_root),
|
||||
"animations": animation_count,
|
||||
"frames_per_animation": frames_per_animation,
|
||||
"total_frames": animation_count * frames_per_animation,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate isolated 64x64 animations for thumbnail performance tests."
|
||||
)
|
||||
parser.add_argument("--data-root", required=True)
|
||||
parser.add_argument("--animations", type=int, default=100)
|
||||
parser.add_argument("--frames", type=int, default=100)
|
||||
parser.add_argument("--duration-ms", type=int, default=50)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
root = validate_empty_test_root(args.data_root)
|
||||
result = generate_fixture(
|
||||
root,
|
||||
animation_count=args.animations,
|
||||
frames_per_animation=args.frames,
|
||||
duration_ms=args.duration_ms,
|
||||
)
|
||||
except (FixtureError, OSError, ValueError) as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||||
return 1
|
||||
print(json.dumps({"ok": True, **result}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Hold a real-driver visual pattern until systemd or the operator stops it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.display.matrix_driver import MatrixDisplayDriver
|
||||
from app.display.ota_indicator import render_ota_indicator
|
||||
from app.display.transforms import apply_orientation, apply_row_bit_order, get_row_bit_order
|
||||
from app.display.wifi_indicator import (
|
||||
WIFI_SCROLL_FPS,
|
||||
WIFI_SCROLL_GAP_PIXELS,
|
||||
WIFI_SCROLL_PIXELS_PER_SECOND,
|
||||
render_wifi_indicator,
|
||||
text_width,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--brightness", type=int, default=50)
|
||||
parser.add_argument("--mode", choices=("wifi", "complex", "ota"), default="wifi")
|
||||
parser.add_argument(
|
||||
"--message",
|
||||
default="SSID: DRIVER-TEST IP: http://192.168.1.2:8080/",
|
||||
)
|
||||
parser.add_argument("--progress-file")
|
||||
parser.add_argument("--orientation", type=int, choices=(0, 90, 180, 270), default=0)
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.brightness <= 100:
|
||||
parser.error("brightness must be in 1..100")
|
||||
|
||||
stopping = False
|
||||
|
||||
def request_stop(_signum: int, _frame: object) -> None:
|
||||
nonlocal stopping
|
||||
stopping = True
|
||||
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
|
||||
driver = MatrixDisplayDriver(args.brightness, limit_refresh_rate_hz=100)
|
||||
cycle_pixels = (
|
||||
64 + text_width(args.message) + WIFI_SCROLL_GAP_PIXELS
|
||||
if args.mode == "wifi"
|
||||
else 192
|
||||
)
|
||||
started = time.monotonic()
|
||||
next_frame = started
|
||||
try:
|
||||
while not stopping:
|
||||
now = time.monotonic()
|
||||
offset = ((now - started) * WIFI_SCROLL_PIXELS_PER_SECOND) % cycle_pixels
|
||||
if args.mode == "wifi":
|
||||
frame = render_wifi_indicator(
|
||||
args.message,
|
||||
offset=offset,
|
||||
connected=True,
|
||||
)
|
||||
elif args.mode == "complex":
|
||||
frame = render_complex_pattern(offset)
|
||||
else:
|
||||
percent, stage = read_ota_progress(args.progress_file)
|
||||
frame = render_ota_indicator(percent, f"{stage}:{int(offset) % 4}")
|
||||
transformed = apply_row_bit_order(
|
||||
apply_orientation(frame, args.orientation),
|
||||
get_row_bit_order(),
|
||||
)
|
||||
driver.set_image(transformed)
|
||||
next_frame += 1.0 / WIFI_SCROLL_FPS
|
||||
time.sleep(max(0.0, next_frame - time.monotonic()))
|
||||
finally:
|
||||
driver.clear()
|
||||
driver.close()
|
||||
return 0
|
||||
|
||||
|
||||
def read_ota_progress(filename: str | None) -> tuple[int, str]:
|
||||
if not filename:
|
||||
return 0, "准备更新"
|
||||
try:
|
||||
document = json.loads(Path(filename).read_text(encoding="utf-8"))
|
||||
job = document.get("job") if isinstance(document, dict) else None
|
||||
if not isinstance(job, dict):
|
||||
return 0, "准备更新"
|
||||
return max(0, min(100, int(job.get("percent", 0)))), str(job.get("message") or "更新中")[:80]
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
|
||||
return 0, "准备更新"
|
||||
|
||||
|
||||
def render_complex_pattern(offset: float) -> Image.Image:
|
||||
"""Render a dense, high-contrast 192-pixel panorama through a 64px window."""
|
||||
phase = int(offset) % 192
|
||||
pixels: list[tuple[int, int, int]] = []
|
||||
palette = (
|
||||
(255, 32, 32),
|
||||
(255, 176, 24),
|
||||
(48, 255, 64),
|
||||
(32, 224, 255),
|
||||
(48, 64, 255),
|
||||
(224, 48, 255),
|
||||
)
|
||||
for y in range(64):
|
||||
for x in range(64):
|
||||
source_x = (x + phase) % 192
|
||||
base = palette[(source_x // 16) % len(palette)]
|
||||
checker = ((source_x // 4) ^ (y // 4)) & 1
|
||||
diagonal = ((source_x + y * 2) // 8) & 1
|
||||
gain = 1.0 if checker else 0.48
|
||||
if diagonal:
|
||||
gain *= 0.78
|
||||
radial = ((source_x % 32) - 16) ** 2 + (y - 32) ** 2
|
||||
if 70 <= radial <= 150:
|
||||
color = (255, 255, 255)
|
||||
elif radial < 36:
|
||||
color = (8, 8, 8)
|
||||
else:
|
||||
color = tuple(max(8, min(255, int(channel * gain))) for channel in base)
|
||||
pixels.append(color)
|
||||
image = Image.new("RGB", (64, 64))
|
||||
image.putdata(pixels)
|
||||
return image
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from scripts.kernel_artifact import ARTIFACT_NAME, KernelArtifactError, verify_kernel_dependency
|
||||
|
||||
|
||||
TARGET_RELATIVE = Path("opt/matrix-image-bootstrap")
|
||||
APP_RELATIVE = Path("app/MSCBOOT.TGZ")
|
||||
KERNEL_RELATIVE = Path("axp313a")
|
||||
DEFAULT_RESERVE_BYTES = 256 * 1024 * 1024
|
||||
KERNEL_FILES = {ARTIFACT_NAME, "SHA256SUMS", "METADATA.json"}
|
||||
|
||||
|
||||
class ImageBootstrapPayloadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _root(path: Path) -> Path:
|
||||
resolved = Path(path).resolve()
|
||||
if not resolved.is_dir() or resolved == Path(resolved.anchor):
|
||||
raise ImageBootstrapPayloadError("image root must be an explicit mounted or isolated directory")
|
||||
return resolved
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_staged_payload(root: Path, bundle: Path, dependency: Path) -> dict[str, object]:
|
||||
root = _root(root)
|
||||
bundle = Path(bundle).resolve()
|
||||
dependency = Path(dependency).resolve()
|
||||
expected_kernel = verify_kernel_dependency(dependency)
|
||||
target = root / TARGET_RELATIVE
|
||||
app_target = target / APP_RELATIVE
|
||||
kernel_target = target / KERNEL_RELATIVE
|
||||
if not target.is_dir() or {path.name for path in target.iterdir()} != {"app", "axp313a"}:
|
||||
raise ImageBootstrapPayloadError("staged image bootstrap payload directories are not exact")
|
||||
if not app_target.is_file() or {path.name for path in app_target.parent.iterdir()} != {"MSCBOOT.TGZ"}:
|
||||
raise ImageBootstrapPayloadError("staged image application payload files are not exact")
|
||||
if app_target.stat().st_size != bundle.stat().st_size or _sha256(app_target) != _sha256(bundle):
|
||||
raise ImageBootstrapPayloadError("staged image application payload differs from the build bundle")
|
||||
if not kernel_target.is_dir() or {path.name for path in kernel_target.iterdir()} != KERNEL_FILES:
|
||||
raise ImageBootstrapPayloadError("staged image kernel payload files are not exact")
|
||||
for name in ("SHA256SUMS", "METADATA.json"):
|
||||
if (kernel_target / name).read_bytes() != (dependency / name).read_bytes():
|
||||
raise ImageBootstrapPayloadError(f"staged image kernel {name} differs from the dependency")
|
||||
actual_kernel = verify_kernel_dependency(kernel_target)
|
||||
if actual_kernel != expected_kernel:
|
||||
raise ImageBootstrapPayloadError("staged image kernel payload identity changed")
|
||||
return {
|
||||
"target": TARGET_RELATIVE.as_posix(),
|
||||
"bundle_path": (TARGET_RELATIVE / APP_RELATIVE).as_posix(),
|
||||
"bundle_bytes": app_target.stat().st_size,
|
||||
"bundle_sha256": _sha256(app_target),
|
||||
"kernel": asdict(actual_kernel),
|
||||
}
|
||||
|
||||
|
||||
def stage_payload(
|
||||
root: Path,
|
||||
bundle: Path,
|
||||
dependency: Path,
|
||||
*,
|
||||
reserve_bytes: int = DEFAULT_RESERVE_BYTES,
|
||||
) -> dict[str, object]:
|
||||
root = _root(root)
|
||||
bundle = Path(bundle).resolve()
|
||||
dependency = Path(dependency).resolve()
|
||||
if not bundle.is_file():
|
||||
raise ImageBootstrapPayloadError("image application bundle is missing")
|
||||
if type(reserve_bytes) is not int or reserve_bytes < 0:
|
||||
raise ImageBootstrapPayloadError("reserve_bytes must be a non-negative integer")
|
||||
verify_kernel_dependency(dependency)
|
||||
payload_bytes = bundle.stat().st_size + sum((dependency / name).stat().st_size for name in KERNEL_FILES)
|
||||
if shutil.disk_usage(root).free < payload_bytes + reserve_bytes:
|
||||
raise ImageBootstrapPayloadError("copied image root filesystem lacks room for both offline payloads")
|
||||
target = root / TARGET_RELATIVE
|
||||
if target.exists():
|
||||
raise ImageBootstrapPayloadError("image bootstrap payload target already exists")
|
||||
created = False
|
||||
try:
|
||||
(target / APP_RELATIVE.parent).mkdir(parents=True, mode=0o755)
|
||||
(target / KERNEL_RELATIVE).mkdir(mode=0o755)
|
||||
created = True
|
||||
shutil.copy2(bundle, target / APP_RELATIVE)
|
||||
for name in KERNEL_FILES:
|
||||
shutil.copy2(dependency / name, target / KERNEL_RELATIVE / name)
|
||||
return verify_staged_payload(root, bundle, dependency)
|
||||
except BaseException:
|
||||
if created:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Stage or verify rootfs image bootstrap payloads")
|
||||
parser.add_argument("action", choices=("stage", "verify"))
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--bundle", type=Path, required=True)
|
||||
parser.add_argument("--dependency", type=Path, required=True)
|
||||
parser.add_argument("--reserve-bytes", type=int, default=DEFAULT_RESERVE_BYTES)
|
||||
args = parser.parse_args()
|
||||
if args.action == "stage":
|
||||
result = stage_payload(args.root, args.bundle, args.dependency, reserve_bytes=args.reserve_bytes)
|
||||
else:
|
||||
result = verify_staged_payload(args.root, args.bundle, args.dependency)
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (ImageBootstrapPayloadError, KernelArtifactError) as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
|
||||
PRODUCT_ID = "matrix-screen-controller-walnutpi"
|
||||
CONFIG_FILE_BYTES = 64 * 1024
|
||||
HEADER_BYTES = 4096
|
||||
SLOT_BYTES = (CONFIG_FILE_BYTES - HEADER_BYTES) // 2
|
||||
FILE_MAGIC = b"MSCCFG2\0"
|
||||
SLOT_MAGIC = b"MSCSLOT\0"
|
||||
FORMAT_VERSION = 1
|
||||
_HEADER = struct.Struct("<8sII")
|
||||
_SLOT_HEADER = struct.Struct("<8sQI32s")
|
||||
_USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
||||
|
||||
|
||||
class ImageConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_config(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
"schema_version",
|
||||
"product",
|
||||
"software_version",
|
||||
"account",
|
||||
"wifi",
|
||||
"ipv4",
|
||||
}:
|
||||
raise ImageConfigError("image configuration fields are invalid")
|
||||
if value["schema_version"] != 1 or value["product"] != PRODUCT_ID:
|
||||
raise ImageConfigError("image configuration product or schema is unsupported")
|
||||
if not isinstance(value["software_version"], str) or not re.fullmatch(
|
||||
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)",
|
||||
value["software_version"],
|
||||
):
|
||||
raise ImageConfigError("software_version must use MAJOR.MINOR.PATCH")
|
||||
|
||||
account = value["account"]
|
||||
if not isinstance(account, dict) or set(account) != {"username", "password"}:
|
||||
raise ImageConfigError("account configuration is invalid")
|
||||
if not isinstance(account["username"], str) or not _USERNAME.fullmatch(account["username"]):
|
||||
raise ImageConfigError("account username is not a supported Linux username")
|
||||
_validate_secret(account["password"], "account password", minimum=8, maximum=128)
|
||||
|
||||
wifi = value["wifi"]
|
||||
if not isinstance(wifi, dict) or set(wifi) != {"ssid", "password"}:
|
||||
raise ImageConfigError("Wi-Fi configuration is invalid")
|
||||
_validate_text(wifi["ssid"], "Wi-Fi SSID", minimum=1, maximum_bytes=32)
|
||||
_validate_secret(wifi["password"], "Wi-Fi password", minimum=8, maximum=63)
|
||||
|
||||
ipv4 = value["ipv4"]
|
||||
if not isinstance(ipv4, dict) or set(ipv4) != {
|
||||
"mode",
|
||||
"address",
|
||||
"prefix",
|
||||
"gateway",
|
||||
"dns",
|
||||
}:
|
||||
raise ImageConfigError("IPv4 configuration is invalid")
|
||||
if ipv4["mode"] not in {"dhcp", "static"}:
|
||||
raise ImageConfigError("IPv4 mode must be dhcp or static")
|
||||
if ipv4["mode"] == "dhcp":
|
||||
if ipv4 != {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []}:
|
||||
raise ImageConfigError("DHCP configuration must not include static fields")
|
||||
else:
|
||||
try:
|
||||
address = ipaddress.IPv4Address(ipv4["address"])
|
||||
gateway = ipaddress.IPv4Address(ipv4["gateway"])
|
||||
except (ipaddress.AddressValueError, TypeError) as exc:
|
||||
raise ImageConfigError("static IPv4 address or gateway is invalid") from exc
|
||||
prefix = ipv4["prefix"]
|
||||
if type(prefix) is not int or not 1 <= prefix <= 32:
|
||||
raise ImageConfigError("static IPv4 prefix must be between 1 and 32")
|
||||
network = ipaddress.IPv4Network(f"{address}/{prefix}", strict=False)
|
||||
if gateway not in network:
|
||||
raise ImageConfigError("static IPv4 gateway must be in the same subnet")
|
||||
if not isinstance(ipv4["dns"], list) or not 1 <= len(ipv4["dns"]) <= 4:
|
||||
raise ImageConfigError("static IPv4 DNS must contain one to four addresses")
|
||||
try:
|
||||
ipv4["dns"] = [str(ipaddress.IPv4Address(item)) for item in ipv4["dns"]]
|
||||
except (ipaddress.AddressValueError, TypeError) as exc:
|
||||
raise ImageConfigError("static IPv4 DNS contains an invalid address") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _validate_text(value: Any, label: str, *, minimum: int, maximum_bytes: int) -> None:
|
||||
if not isinstance(value, str) or len(value) < minimum or len(value.encode("utf-8")) > maximum_bytes:
|
||||
raise ImageConfigError(f"{label} length is invalid")
|
||||
if any(character in value for character in ("\0", "\r", "\n")):
|
||||
raise ImageConfigError(f"{label} contains a forbidden character")
|
||||
|
||||
|
||||
def _validate_secret(value: Any, label: str, *, minimum: int, maximum: int) -> None:
|
||||
if not isinstance(value, str) or not minimum <= len(value) <= maximum:
|
||||
raise ImageConfigError(f"{label} length is invalid")
|
||||
if any(character in value for character in ("\0", "\r", "\n")):
|
||||
raise ImageConfigError(f"{label} contains a forbidden character")
|
||||
|
||||
|
||||
def _slot_offset(index: int) -> int:
|
||||
return HEADER_BYTES + index * SLOT_BYTES
|
||||
|
||||
|
||||
def _encode_slot(config: dict[str, Any], generation: int) -> bytes:
|
||||
payload = (json.dumps(validate_config(config), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
maximum = SLOT_BYTES - _SLOT_HEADER.size
|
||||
if len(payload) > maximum:
|
||||
raise ImageConfigError("image configuration payload is too large")
|
||||
header = _SLOT_HEADER.pack(SLOT_MAGIC, generation, len(payload), hashlib.sha256(payload).digest())
|
||||
return header + payload + bytes(SLOT_BYTES - len(header) - len(payload))
|
||||
|
||||
|
||||
def create_config_file(config: dict[str, Any]) -> bytes:
|
||||
result = bytearray(CONFIG_FILE_BYTES)
|
||||
_HEADER.pack_into(result, 0, FILE_MAGIC, FORMAT_VERSION, CONFIG_FILE_BYTES)
|
||||
result[_slot_offset(0) : _slot_offset(0) + SLOT_BYTES] = _encode_slot(config, 1)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def read_config_file(data: bytes) -> tuple[dict[str, Any], int, int]:
|
||||
if len(data) != CONFIG_FILE_BYTES:
|
||||
raise ImageConfigError("image configuration file size is invalid")
|
||||
magic, version, declared_size = _HEADER.unpack_from(data, 0)
|
||||
if magic != FILE_MAGIC or version != FORMAT_VERSION or declared_size != CONFIG_FILE_BYTES:
|
||||
raise ImageConfigError("image configuration header is invalid")
|
||||
valid: list[tuple[int, int, dict[str, Any]]] = []
|
||||
for index in range(2):
|
||||
offset = _slot_offset(index)
|
||||
slot_magic, generation, payload_bytes, digest = _SLOT_HEADER.unpack_from(data, offset)
|
||||
if slot_magic != SLOT_MAGIC or payload_bytes <= 0 or payload_bytes > SLOT_BYTES - _SLOT_HEADER.size:
|
||||
continue
|
||||
payload = data[offset + _SLOT_HEADER.size : offset + _SLOT_HEADER.size + payload_bytes]
|
||||
if hashlib.sha256(payload).digest() != digest:
|
||||
continue
|
||||
try:
|
||||
document = validate_config(json.loads(payload.decode("utf-8")))
|
||||
except (UnicodeError, json.JSONDecodeError, ImageConfigError):
|
||||
continue
|
||||
valid.append((generation, index, document))
|
||||
if not valid:
|
||||
raise ImageConfigError("image configuration has no valid slot")
|
||||
generation, index, document = max(valid, key=lambda item: (item[0], item[1]))
|
||||
return document, generation, index
|
||||
|
||||
|
||||
def update_config_file(data: bytes, config: dict[str, Any]) -> bytes:
|
||||
_current, generation, active = read_config_file(data)
|
||||
target = 1 - active
|
||||
result = bytearray(data)
|
||||
offset = _slot_offset(target)
|
||||
result[offset : offset + SLOT_BYTES] = _encode_slot(config, generation + 1)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def read_config_path(path: Path) -> dict[str, Any]:
|
||||
document, _generation, _slot = read_config_file(Path(path).read_bytes())
|
||||
return document
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/bin/bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
STATUS=/boot/MSCSTAT.TXT
|
||||
PACKAGE_LOG=/boot/MSCPKG.TXT
|
||||
PACKAGE_INVENTORY=/boot/MSCPKGS.TSV
|
||||
DEPLOY_LOG=/boot/MSCDEPLOY.TXT
|
||||
CONFIG=/boot/MSCCFG.BIN
|
||||
BUNDLE=/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ
|
||||
META=/boot/MSCMETA.JSN
|
||||
WORK=/run/matrix-image-provision
|
||||
KERNEL_ROOT=/opt/matrix-image-bootstrap/axp313a
|
||||
KERNEL_ARCHIVE=$KERNEL_ROOT/axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz
|
||||
KERNEL_METADATA=$KERNEL_ROOT/METADATA.json
|
||||
KERNEL_WORK=/var/tmp/matrix-axp313a-image-install
|
||||
|
||||
status() {
|
||||
printf '%s\n' "$1" > "$STATUS"
|
||||
sync "$STATUS" || true
|
||||
}
|
||||
|
||||
failed() {
|
||||
code=$?
|
||||
trap - ERR
|
||||
rm -rf -- /var/tmp/matrix-axp313a-image-install
|
||||
status "FAILED: stage=${STAGE:-starting}; code=$code; line=${BASH_LINENO[0]:-unknown}. Power off, remove the TF card, and inspect this file on Windows."
|
||||
exit "$code"
|
||||
}
|
||||
trap failed ERR
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
status "FAILED: the one-time provisioner did not run as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STAGE=validate
|
||||
status "RUNNING: validating the offline image payload."
|
||||
rm -rf -- "$WORK"
|
||||
install -d -m 0700 "$WORK"
|
||||
python3 - "$META" "$BUNDLE" "$KERNEL_ARCHIVE" "$KERNEL_METADATA" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
meta = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
bundle = Path(sys.argv[2])
|
||||
kernel_artifact = Path(sys.argv[3])
|
||||
kernel = json.loads(Path(sys.argv[4]).read_text(encoding="utf-8"))
|
||||
assert meta == {
|
||||
"format_version": 3,
|
||||
"product": "matrix-screen-controller-walnutpi",
|
||||
"software_version": meta["software_version"],
|
||||
"bundle_path": "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ",
|
||||
"bundle_bytes": meta["bundle_bytes"],
|
||||
"bundle_sha256": meta["bundle_sha256"],
|
||||
"kernel_release": "6.1.31-matrix-axp313a1",
|
||||
"kernel_artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz",
|
||||
"kernel_artifact_bytes": meta["kernel_artifact_bytes"],
|
||||
"kernel_artifact_sha256": meta["kernel_artifact_sha256"],
|
||||
"kernel_unpacked_file_bytes": meta["kernel_unpacked_file_bytes"],
|
||||
"boot_required_bytes": meta["boot_required_bytes"],
|
||||
"boot_safety_bytes": 32 * 1024 * 1024,
|
||||
}
|
||||
assert isinstance(meta["boot_required_bytes"], int) and meta["boot_required_bytes"] > 0
|
||||
assert bundle.stat().st_size == meta["bundle_bytes"]
|
||||
digest = hashlib.sha256()
|
||||
with bundle.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
assert digest.hexdigest() == meta["bundle_sha256"]
|
||||
assert kernel["schema_version"] == 1
|
||||
assert kernel["architecture"] == "aarch64"
|
||||
assert kernel["kernel_release"] == meta["kernel_release"]
|
||||
assert kernel["artifact"] == meta["kernel_artifact"]
|
||||
assert kernel["artifact_bytes"] == meta["kernel_artifact_bytes"] == kernel_artifact.stat().st_size
|
||||
assert kernel["artifact_sha256"] == meta["kernel_artifact_sha256"]
|
||||
assert kernel["unpacked_file_bytes"] == meta["kernel_unpacked_file_bytes"]
|
||||
digest = hashlib.sha256()
|
||||
with kernel_artifact.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
assert digest.hexdigest() == meta["kernel_artifact_sha256"]
|
||||
PY
|
||||
tar -xzf "$BUNDLE" -C "$WORK"
|
||||
cd "$WORK/project/核桃派软件源代码"
|
||||
PYTHONPATH=. python3 scripts/kernel_artifact.py --kernel "$KERNEL_ROOT" >/dev/null
|
||||
cd "$WORK/project/离线依赖/其他依赖/aarch64-py311"
|
||||
sha256sum -c SHA256SUMS
|
||||
cd "$WORK/project/离线依赖/其他依赖/debian12-aarch64"
|
||||
sha256sum -c SHA256SUMS
|
||||
|
||||
STAGE=packages
|
||||
status "RUNNING: installing offline Debian packages."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
dpkg-query -W -f='${db:Status-Abbrev}\t${binary:Package}\t${Version}\n' >"$PACKAGE_INVENTORY"
|
||||
if ! dpkg -i "$WORK"/project/离线依赖/其他依赖/debian12-aarch64/*.deb >"$PACKAGE_LOG" 2>&1; then
|
||||
status "FAILED: stage=packages; offline Debian package installation failed. Inspect MSCPKG.TXT on Windows; it contains package diagnostics but no configured credentials."
|
||||
exit 1
|
||||
fi
|
||||
rm -f -- "$PACKAGE_LOG" "$PACKAGE_INVENTORY"
|
||||
|
||||
STAGE=network
|
||||
status "RUNNING: applying the requested network configuration."
|
||||
cd "$WORK/project/核桃派软件源代码"
|
||||
PYTHONPATH=. python3 -m scripts.provision_device network --config "$CONFIG"
|
||||
systemctl restart NetworkManager.service
|
||||
|
||||
STAGE=deploy
|
||||
status "RUNNING: installing and testing matrix-screen-controller."
|
||||
{
|
||||
if [ -e /opt/matrix-screen-controller ]; then
|
||||
printf '%s\n' 'A previous deployment attempt left /opt/matrix-screen-controller in place.'
|
||||
systemctl --no-pager --full status matrix-screen-controller.service || true
|
||||
journalctl --no-pager -u matrix-screen-controller.service -n 120 || true
|
||||
fi
|
||||
} >"$DEPLOY_LOG" 2>&1
|
||||
FRPC_RUN_USER=$(PYTHONPATH=. python3 -c 'from pathlib import Path; from scripts.image_config import read_config_path; import sys; print(read_config_path(Path(sys.argv[1]))["account"]["username"])' "$CONFIG")
|
||||
if ! FRPC_RUN_USER="$FRPC_RUN_USER" DEFER_SERVICE_START=1 WHEELHOUSE="$WORK/project/离线依赖/其他依赖/aarch64-py311" FRPC_BUNDLE="$WORK/project/离线依赖/其他依赖/frp/0.71.0/linux-arm64" ./scripts/deploy_walnutpi.sh >>"$DEPLOY_LOG" 2>&1; then
|
||||
status "FAILED: stage=deploy; controller deployment failed. Inspect MSCDEPLOY.TXT on Windows; it contains deployment diagnostics but no configured credentials."
|
||||
exit 1
|
||||
fi
|
||||
rm -f -- "$DEPLOY_LOG"
|
||||
|
||||
STAGE=account
|
||||
status "RUNNING: creating the configured account and unique device identity."
|
||||
PYTHONPATH=. python3 -m scripts.provision_device account --config "$CONFIG"
|
||||
FRPC_RUN_USER="$FRPC_RUN_USER" DEFER_SERVICE_START=1 FRPC_BUNDLE="$WORK/project/离线依赖/其他依赖/frp/0.71.0/linux-arm64" /bin/sh ./scripts/install_frpc_system.sh
|
||||
systemctl disable boot_script.service >/dev/null 2>&1 || true
|
||||
PYTHONPATH=. python3 -m scripts.provision_device identity --config "$CONFIG"
|
||||
|
||||
STAGE=ssh
|
||||
status "RUNNING: enabling password-authenticated SSH for the configured administrator."
|
||||
PYTHONPATH=. python3 -m scripts.provision_device ssh --config "$CONFIG"
|
||||
|
||||
STAGE=kernel
|
||||
status "RUNNING: installing the verified offline AXP313A kernel."
|
||||
kernel_unpacked_bytes=$(python3 -c 'import json; print(json.load(open("/opt/matrix-image-bootstrap/axp313a/METADATA.json", encoding="utf-8"))["unpacked_file_bytes"])')
|
||||
available_bytes=$(df -Pk /var/tmp | awk 'NR == 2 { printf "%.0f\n", $4 * 1024 }')
|
||||
required_bytes=$((kernel_unpacked_bytes * 2 + 268435456))
|
||||
if [ "$available_bytes" -lt "$required_bytes" ]; then
|
||||
printf '%s\n' 'insufficient root filesystem space to install the offline kernel safely' >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf -- "$KERNEL_WORK"
|
||||
install -d -m 0700 "$KERNEL_WORK"
|
||||
tar -xzf "$KERNEL_ARCHIVE" -C "$KERNEL_WORK"
|
||||
cd "$WORK/project/核桃派软件源代码"
|
||||
./scripts/install_axp313a_kernel.sh --artifact "$KERNEL_WORK"
|
||||
rm -rf -- "$KERNEL_WORK"
|
||||
rm -rf -- /opt/matrix-image-bootstrap
|
||||
|
||||
STAGE=finish
|
||||
install -d -m 0711 /var/lib/matrix-screen-controller
|
||||
printf '%s\n' "$(cat VERSION)" > /var/lib/matrix-screen-controller/.factory-image-version
|
||||
if command -v shred >/dev/null 2>&1; then
|
||||
shred -n 1 -z "$CONFIG" || true
|
||||
fi
|
||||
rm -f -- "$CONFIG" "$META" /boot/MSCINIT
|
||||
systemctl disable matrix-image-firstboot.service >/dev/null 2>&1 || true
|
||||
rm -f -- /etc/systemd/system/matrix-image-firstboot.service
|
||||
rm -rf -- "$WORK"
|
||||
sync
|
||||
if nmcli -t -f NAME connection show --active 2>/dev/null | grep -Fxq 'matrix-screen' \
|
||||
&& ip route show default | grep -q .; then
|
||||
status "SUCCESS: provisioning completed; the device is rebooting into the installed controller."
|
||||
else
|
||||
status "SUCCESS: provisioning completed while Wi-Fi is not connected; the installed controller will keep trying after reboot."
|
||||
fi
|
||||
systemctl reboot
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from scripts.kernel_artifact import ARTIFACT_NAME, KernelArtifactError, verify_kernel_dependency
|
||||
|
||||
|
||||
TARGET_RELATIVE = Path("opt/matrix-image-bootstrap/axp313a")
|
||||
DEFAULT_RESERVE_BYTES = 256 * 1024 * 1024
|
||||
EXPECTED_FILES = {ARTIFACT_NAME, "SHA256SUMS", "METADATA.json"}
|
||||
|
||||
|
||||
class ImageKernelPayloadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _root(path: Path) -> Path:
|
||||
resolved = Path(path).resolve()
|
||||
if not resolved.is_dir() or resolved == Path(resolved.anchor):
|
||||
raise ImageKernelPayloadError("image root must be an explicit mounted or isolated directory")
|
||||
return resolved
|
||||
|
||||
|
||||
def verify_staged_payload(root: Path, dependency: Path) -> dict[str, object]:
|
||||
root = _root(root)
|
||||
dependency = Path(dependency).resolve()
|
||||
expected = verify_kernel_dependency(dependency)
|
||||
target = root / TARGET_RELATIVE
|
||||
if not target.is_dir() or {path.name for path in target.iterdir()} != EXPECTED_FILES:
|
||||
raise ImageKernelPayloadError("staged image kernel payload files are not exact")
|
||||
for name in ("SHA256SUMS", "METADATA.json"):
|
||||
if (target / name).read_bytes() != (dependency / name).read_bytes():
|
||||
raise ImageKernelPayloadError(f"staged image kernel {name} differs from the dependency")
|
||||
actual = verify_kernel_dependency(target)
|
||||
if actual != expected:
|
||||
raise ImageKernelPayloadError("staged image kernel payload identity changed")
|
||||
return {"target": TARGET_RELATIVE.as_posix(), **asdict(actual)}
|
||||
|
||||
|
||||
def stage_payload(
|
||||
root: Path,
|
||||
dependency: Path,
|
||||
*,
|
||||
reserve_bytes: int = DEFAULT_RESERVE_BYTES,
|
||||
) -> dict[str, object]:
|
||||
root = _root(root)
|
||||
dependency = Path(dependency).resolve()
|
||||
if type(reserve_bytes) is not int or reserve_bytes < 0:
|
||||
raise ImageKernelPayloadError("reserve_bytes must be a non-negative integer")
|
||||
info = verify_kernel_dependency(dependency)
|
||||
required = info.artifact_bytes + reserve_bytes
|
||||
if shutil.disk_usage(root).free < required:
|
||||
raise ImageKernelPayloadError("copied image root filesystem lacks room for the offline kernel payload")
|
||||
target = root / TARGET_RELATIVE
|
||||
if target.exists():
|
||||
raise ImageKernelPayloadError("image kernel payload target already exists")
|
||||
created = False
|
||||
try:
|
||||
target.mkdir(parents=True, mode=0o755)
|
||||
created = True
|
||||
for name in EXPECTED_FILES:
|
||||
shutil.copy2(dependency / name, target / name)
|
||||
return verify_staged_payload(root, dependency)
|
||||
except BaseException:
|
||||
if created:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Stage or verify the offline kernel in a copied IMG root")
|
||||
parser.add_argument("action", choices=("stage", "verify"))
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--dependency", type=Path, required=True)
|
||||
parser.add_argument("--reserve-bytes", type=int, default=DEFAULT_RESERVE_BYTES)
|
||||
args = parser.parse_args()
|
||||
if args.action == "stage":
|
||||
result = stage_payload(args.root, args.dependency, reserve_bytes=args.reserve_bytes)
|
||||
else:
|
||||
result = verify_staged_payload(args.root, args.dependency)
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (ImageKernelPayloadError, KernelArtifactError) as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ARTIFACT=
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--artifact) ARTIFACT=$2; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
if [ "$(id -u)" -ne 0 ] || [ "$(uname -m)" != "aarch64" ]; then
|
||||
echo "installer must run as root on the WalnutPi AArch64 host" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$ARTIFACT" ]; then
|
||||
echo "usage: install_axp313a_kernel.sh --artifact DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
ARTIFACT=$(CDPATH= cd -- "$ARTIFACT" && pwd)
|
||||
SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
RELEASE=6.1.31-matrix-axp313a1
|
||||
MODULE_TARGET=/lib/modules/$RELEASE
|
||||
IMAGE_TARGET=/boot/Image-matrix-axp313a1
|
||||
DTB_TARGET=/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb
|
||||
EMMC_DTB_TARGET=/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb
|
||||
BOOT_CMD=/boot/boot.cmd
|
||||
BOOT_SCR=/boot/boot.scr
|
||||
ORIGINAL_CMD=/boot/boot.cmd.matrix-original
|
||||
ORIGINAL_SCR=/boot/boot.scr.matrix-original
|
||||
COMMITTED=0
|
||||
BOOT_REPLACED=0
|
||||
SYSTEM_FILES_INSTALLED=0
|
||||
|
||||
cleanup_failed_install() {
|
||||
code=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if [ "$code" -ne 0 ] && [ "$COMMITTED" -eq 0 ]; then
|
||||
if [ "$BOOT_REPLACED" -eq 1 ] && [ -f "$ORIGINAL_CMD" ] && [ -f "$ORIGINAL_SCR" ]; then
|
||||
install -m 0644 "$ORIGINAL_CMD" "$BOOT_CMD"
|
||||
install -m 0644 "$ORIGINAL_SCR" "$BOOT_SCR"
|
||||
fi
|
||||
rm -f -- "$IMAGE_TARGET" "$DTB_TARGET" "$EMMC_DTB_TARGET" \
|
||||
"/boot/System.map-$RELEASE" "/boot/config-$RELEASE" /boot/matrix-kernel-good
|
||||
case "$MODULE_TARGET" in /lib/modules/6.1.31-matrix-axp313a1) rm -rf -- "$MODULE_TARGET" ;; esac
|
||||
if [ "$SYSTEM_FILES_INSTALLED" -eq 1 ]; then
|
||||
rm -f -- /etc/systemd/system/matrix-axp313a-health.service \
|
||||
/etc/systemd/system/matrix-axp313a-rollback.service \
|
||||
/opt/matrix-screen-controller-system/axp313a_kernel_health.py
|
||||
fi
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
sync
|
||||
fi
|
||||
exit "$code"
|
||||
}
|
||||
trap cleanup_failed_install EXIT HUP INT TERM
|
||||
|
||||
if [ ! -f "$ARTIFACT/SHA256SUMS" ]; then
|
||||
echo "candidate artifact manifest is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
(cd "$ARTIFACT" && sha256sum -c SHA256SUMS)
|
||||
for path in \
|
||||
"$ARTIFACT/boot/Image-matrix-axp313a1" \
|
||||
"$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb" \
|
||||
"$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb" \
|
||||
"$ARTIFACT/lib/modules/$RELEASE"
|
||||
do
|
||||
if [ ! -e "$path" ]; then echo "candidate artifact is incomplete: $path" >&2; exit 1; fi
|
||||
done
|
||||
if [ -e "$MODULE_TARGET" ] || [ -e "$IMAGE_TARGET" ] || [ -e "$DTB_TARGET" ] || [ -e "$EMMC_DTB_TARGET" ]; then
|
||||
echo "candidate kernel paths already exist; refusing to overwrite" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$BOOT_CMD" ] || [ ! -f "$BOOT_SCR" ]; then
|
||||
echo "original WalnutPi boot files are missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v mkimage >/dev/null
|
||||
command -v fdtget >/dev/null
|
||||
command -v strings >/dev/null
|
||||
|
||||
# Refuse before the first candidate boot write unless the complete candidate,
|
||||
# rollback set, temporary scripts and fixed safety reserve all fit.
|
||||
PYTHONPATH="$SOURCE_ROOT" python3 "$SOURCE_ROOT/scripts/boot_space.py" \
|
||||
--boot-root /boot --artifact-root "$ARTIFACT"
|
||||
|
||||
if [ "$(cat "$ARTIFACT/KERNEL_RELEASE" 2>/dev/null || true)" != "$RELEASE" ]; then
|
||||
echo "candidate kernel release marker is wrong" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat "$ARTIFACT/SOURCE_COMMIT" 2>/dev/null || true)" != \
|
||||
"30ff3fd5cf45417622b447a12e7a947402ffe34d" ]; then
|
||||
echo "candidate vendor source commit marker is wrong" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! strings "$ARTIFACT/boot/Image-matrix-axp313a1" | \
|
||||
grep -Fq "Linux version $RELEASE"; then
|
||||
echo "candidate Image does not contain the expected release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify_dtb() {
|
||||
dtb=$1
|
||||
pmic=/soc/i2c@7081400/pmic@36
|
||||
regulators=$pmic/regulators
|
||||
if [ "$(fdtget -t s "$dtb" "$pmic" compatible)" != "x-powers,axp313a" ]; then
|
||||
echo "candidate DTB does not use the exact AXP313A compatible: $dtb" >&2
|
||||
return 1
|
||||
fi
|
||||
for specification in \
|
||||
"aldo1 1800000 1800000 vcc1v8" \
|
||||
"dldo1 3300000 3300000 vcc3v3" \
|
||||
"dcdc1 810000 1160000 vdd-gpu-sys" \
|
||||
"dcdc2 810000 1080000 vdd-cpu" \
|
||||
"dcdc3 1100000 1100000 vdd-dram"
|
||||
do
|
||||
set -- $specification
|
||||
node=$regulators/$1
|
||||
[ "$(fdtget -t i "$dtb" "$node" regulator-min-microvolt)" = "$2" ]
|
||||
[ "$(fdtget -t i "$dtb" "$node" regulator-max-microvolt)" = "$3" ]
|
||||
[ "$(fdtget -t s "$dtb" "$node" regulator-name)" = "$4" ]
|
||||
fdtget "$dtb" "$node" regulator-always-on >/dev/null
|
||||
done
|
||||
cpu_supply=$(fdtget -t x "$dtb" /cpus/cpu@0 cpu-supply)
|
||||
dcdc2_phandle=$(fdtget -t x "$dtb" "$regulators/dcdc2" phandle)
|
||||
if [ "$cpu_supply" != "$dcdc2_phandle" ]; then
|
||||
echo "candidate DTB CPU0 supply is not DCDC2: $dtb" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_dtb "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb"
|
||||
verify_dtb "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb"
|
||||
|
||||
install -m 0644 "$ARTIFACT/boot/Image-matrix-axp313a1" "$IMAGE_TARGET"
|
||||
install -m 0644 "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb" "$DTB_TARGET"
|
||||
install -m 0644 "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb" "$EMMC_DTB_TARGET"
|
||||
install -m 0644 "$ARTIFACT/boot/System.map-$RELEASE" "/boot/System.map-$RELEASE"
|
||||
install -m 0644 "$ARTIFACT/boot/config-$RELEASE" "/boot/config-$RELEASE"
|
||||
cp -a -- "$ARTIFACT/lib/modules/$RELEASE" "$MODULE_TARGET"
|
||||
depmod "$RELEASE"
|
||||
|
||||
if [ ! -f "$ORIGINAL_CMD" ]; then install -m 0644 "$BOOT_CMD" "$ORIGINAL_CMD"; fi
|
||||
if [ ! -f "$ORIGINAL_SCR" ]; then install -m 0644 "$BOOT_SCR" "$ORIGINAL_SCR"; fi
|
||||
sha256sum "$ORIGINAL_CMD" "$ORIGINAL_SCR" > /boot/matrix-original.SHA256SUMS
|
||||
|
||||
install -d -m 0755 /opt/matrix-screen-controller-system
|
||||
install -m 0755 "$SOURCE_ROOT/scripts/axp313a_kernel_health.py" \
|
||||
/opt/matrix-screen-controller-system/axp313a_kernel_health.py
|
||||
install -m 0644 "$SOURCE_ROOT/systemd/matrix-axp313a-health.service" \
|
||||
/etc/systemd/system/matrix-axp313a-health.service
|
||||
install -m 0644 "$SOURCE_ROOT/systemd/matrix-axp313a-rollback.service" \
|
||||
/etc/systemd/system/matrix-axp313a-rollback.service
|
||||
SYSTEM_FILES_INSTALLED=1
|
||||
|
||||
cmd_tmp=$(mktemp /boot/.boot.cmd.matrix.XXXXXX)
|
||||
scr_tmp=$(mktemp /boot/.boot.scr.matrix.XXXXXX)
|
||||
python3 "$SOURCE_ROOT/scripts/render_dual_kernel_boot.py" "$ORIGINAL_CMD" "$cmd_tmp"
|
||||
mkimage -C none -A arm -T script -d "$cmd_tmp" "$scr_tmp" >/dev/null
|
||||
install -m 0644 "$cmd_tmp" "$BOOT_CMD"
|
||||
install -m 0644 "$scr_tmp" "$BOOT_SCR"
|
||||
BOOT_REPLACED=1
|
||||
rm -f -- "$cmd_tmp" "$scr_tmp"
|
||||
printf '%s\n' "$RELEASE" > /boot/matrix-kernel-good
|
||||
sync
|
||||
systemctl daemon-reload
|
||||
systemctl enable matrix-axp313a-health.service >/dev/null
|
||||
COMMITTED=1
|
||||
trap - EXIT HUP INT TERM
|
||||
echo "AXP313A candidate installed; reboot is required"
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "install_frpc_system.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -m)" != "aarch64" ]; then
|
||||
echo "frpc system payload is only for AArch64" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd)
|
||||
BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64}
|
||||
UNIT_SOURCE=$SOURCE_ROOT/systemd/matrix-screen-frpc.service
|
||||
UNIT=/etc/systemd/system/matrix-screen-frpc.service
|
||||
DROPIN_DIR=/etc/systemd/system/matrix-screen-frpc.service.d
|
||||
DROPIN=$DROPIN_DIR/user.conf
|
||||
BINARY=/usr/local/bin/frpc
|
||||
SERVICE=matrix-screen-frpc.service
|
||||
STATE_ROOT=/var/lib/matrix-screen-controller/frp
|
||||
|
||||
if [ ! -f "$BUNDLE/SHA256SUMS" ] || [ ! -f "$BUNDLE/frpc" ] || [ ! -f "$BUNDLE/LICENSE" ]; then
|
||||
echo "verified frpc offline bundle is incomplete: $BUNDLE" >&2
|
||||
exit 1
|
||||
fi
|
||||
(cd "$BUNDLE" && sha256sum -c SHA256SUMS)
|
||||
|
||||
RUN_USER=${FRPC_RUN_USER:-${SUDO_USER:-}}
|
||||
if [ -z "$RUN_USER" ] && [ -f "$DROPIN" ]; then
|
||||
RUN_USER=$(sed -n 's/^User=//p' "$DROPIN" | head -n 1)
|
||||
fi
|
||||
if [ -z "$RUN_USER" ]; then
|
||||
RUN_USER=$(python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); from scripts.ota_components import account; print(account())' "$SOURCE_ROOT")
|
||||
fi
|
||||
case "$RUN_USER" in
|
||||
''|root|*[!a-zA-Z0-9_-]*)
|
||||
echo "FRPC_RUN_USER must identify the non-root maintenance account" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if ! getent passwd "$RUN_USER" >/dev/null 2>&1 && [ "${DEFER_SERVICE_START:-0}" != "1" ]; then
|
||||
echo "FRP maintenance account does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_GROUP=$RUN_USER
|
||||
if getent passwd "$RUN_USER" >/dev/null 2>&1; then RUN_GROUP=$(id -gn "$RUN_USER"); fi
|
||||
binary_matches=0
|
||||
if [ -f "$BINARY" ] && [ ! -L "$BINARY" ] && cmp -s "$BUNDLE/frpc" "$BINARY" && [ "$(stat -c %a:%u:%g "$BINARY")" = 755:0:0 ]; then binary_matches=1; fi
|
||||
unit_matches=0
|
||||
if [ -f "$DROPIN" ] && cmp -s "$UNIT_SOURCE" "$UNIT" && [ "$(stat -c %a:%u:%g "$UNIT")" = 644:0:0 ] && [ "$(stat -c %a:%u:%g "$DROPIN")" = 644:0:0 ] && [ "$(cat "$DROPIN")" = "$(printf '[Service]\nUser=%s\nGroup=%s\n' "$RUN_USER" "$RUN_GROUP")" ]; then unit_matches=1; fi
|
||||
|
||||
was_active=0
|
||||
was_enabled=0
|
||||
systemctl is-active --quiet "$SERVICE" 2>/dev/null && was_active=1 || true
|
||||
systemctl is-enabled --quiet "$SERVICE" 2>/dev/null && was_enabled=1 || true
|
||||
if [ "$was_active" -eq 1 ] && { [ "$binary_matches" -eq 0 ] || [ "$unit_matches" -eq 0 ]; }; then systemctl stop "$SERVICE"; fi
|
||||
|
||||
install -d -m 0755 /usr/local/bin
|
||||
temporary=/usr/local/bin/.frpc.install.$$
|
||||
cleanup() {
|
||||
case "$temporary" in
|
||||
/usr/local/bin/.frpc.install.*) rm -f -- "$temporary" ;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
if [ "$binary_matches" -eq 0 ]; then
|
||||
install -m 0755 "$BUNDLE/frpc" "$temporary"
|
||||
"$temporary" --version | grep -Fx '0.71.0' >/dev/null
|
||||
mv -f -- "$temporary" "$BINARY"
|
||||
else
|
||||
"$BINARY" --version | grep -Fx '0.71.0' >/dev/null
|
||||
echo "frpc binary already verified; skipped"
|
||||
fi
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
if [ "$unit_matches" -eq 0 ]; then
|
||||
install -m 0644 "$UNIT_SOURCE" "$UNIT"
|
||||
install -d -m 0755 "$DROPIN_DIR"
|
||||
printf '[Service]\nUser=%s\nGroup=%s\n' "$RUN_USER" "$RUN_GROUP" > "$DROPIN"
|
||||
chmod 0644 "$DROPIN"
|
||||
fi
|
||||
|
||||
if getent passwd "$RUN_USER" >/dev/null 2>&1; then
|
||||
RUN_GROUP=$(id -gn "$RUN_USER")
|
||||
# Local accounts may only traverse (not list) the persistent root; the
|
||||
# FRP subtree itself remains restricted to root and the maintenance group.
|
||||
chmod 0711 "$(dirname -- "$STATE_ROOT")"
|
||||
install -d -o root -g "$RUN_GROUP" -m 2750 "$STATE_ROOT"
|
||||
install -d -o root -g "$RUN_GROUP" -m 2750 "$STATE_ROOT/profiles"
|
||||
find "$STATE_ROOT" -type d -exec chown root:"$RUN_GROUP" {} \; -exec chmod 2750 {} \;
|
||||
find "$STATE_ROOT" -type f -exec chown root:"$RUN_GROUP" {} \; -exec chmod 0640 {} \;
|
||||
if [ -f "$STATE_ROOT/active.env" ]; then chmod 0600 "$STATE_ROOT/active.env"; fi
|
||||
elif [ "${DEFER_SERVICE_START:-0}" != "1" ]; then
|
||||
echo "FRP maintenance account does not exist: $RUN_USER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$unit_matches" -eq 0 ]; then
|
||||
systemctl daemon-reload
|
||||
if [ "$was_enabled" -eq 1 ]; then systemctl enable "$SERVICE" >/dev/null; else systemctl disable "$SERVICE" >/dev/null 2>&1 || true; fi
|
||||
fi
|
||||
if [ "$was_active" -eq 1 ] && { [ "$binary_matches" -eq 0 ] || [ "$unit_matches" -eq 0 ]; }; then systemctl start "$SERVICE"; fi
|
||||
echo "frpc 0.71.0 installed as a system component"
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
printf '%s\n' 'install_image_bootstrap.sh must run as root.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$#" -ne 5 ]; then
|
||||
printf '%s\n' 'usage: install_image_bootstrap.sh IMAGE SERVICE_UNIT SSH_CONFIG KERNEL_DEPENDENCY APP_BUNDLE' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
IMAGE=$(readlink -f -- "$1")
|
||||
UNIT=$(readlink -f -- "$2")
|
||||
SSH_CONFIG=$(readlink -f -- "$3")
|
||||
KERNEL_DEPENDENCY=$(readlink -f -- "$4")
|
||||
APP_BUNDLE=$(readlink -f -- "$5")
|
||||
SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
[ -f "$IMAGE" ]
|
||||
[ -f "$UNIT" ]
|
||||
[ -f "$SSH_CONFIG" ]
|
||||
[ -d "$KERNEL_DEPENDENCY" ]
|
||||
[ -f "$APP_BUNDLE" ]
|
||||
KERNEL_ARCHIVE=axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz
|
||||
[ -f "$KERNEL_DEPENDENCY/$KERNEL_ARCHIVE" ]
|
||||
[ -f "$KERNEL_DEPENDENCY/SHA256SUMS" ]
|
||||
[ -f "$KERNEL_DEPENDENCY/METADATA.json" ]
|
||||
PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/kernel_artifact.py" \
|
||||
--kernel "$KERNEL_DEPENDENCY" >/dev/null
|
||||
|
||||
LOOP=
|
||||
MOUNT_DIR=$(mktemp -d /var/tmp/matrix-image-root.XXXXXX)
|
||||
cleanup() {
|
||||
if mountpoint -q "$MOUNT_DIR"; then
|
||||
umount "$MOUNT_DIR"
|
||||
fi
|
||||
if [ -n "$LOOP" ]; then
|
||||
losetup -d "$LOOP"
|
||||
fi
|
||||
rmdir "$MOUNT_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
LOOP=$(losetup --find --show --partscan "$IMAGE")
|
||||
ROOT_PARTITION="${LOOP}p2"
|
||||
for _attempt in $(seq 1 20); do
|
||||
[ -b "$ROOT_PARTITION" ] && break
|
||||
sleep 0.25
|
||||
done
|
||||
[ -b "$ROOT_PARTITION" ]
|
||||
mount -o rw "$ROOT_PARTITION" "$MOUNT_DIR"
|
||||
|
||||
[ -x "$MOUNT_DIR/usr/sbin/sshd" ]
|
||||
[ -x "$MOUNT_DIR/usr/bin/sudo" ]
|
||||
[ -x "$MOUNT_DIR/usr/sbin/visudo" ]
|
||||
[ -f "$MOUNT_DIR/lib/systemd/system/ssh.service" ]
|
||||
|
||||
install -D -m 0644 "$UNIT" "$MOUNT_DIR/etc/systemd/system/matrix-image-firstboot.service"
|
||||
install -D -m 0644 "$SSH_CONFIG" "$MOUNT_DIR/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf"
|
||||
install -d -m 0755 "$MOUNT_DIR/etc/systemd/system/multi-user.target.wants"
|
||||
ln -sfn ../matrix-image-firstboot.service \
|
||||
"$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/matrix-image-firstboot.service"
|
||||
systemctl --root="$MOUNT_DIR" unmask ssh.service
|
||||
systemctl --root="$MOUNT_DIR" enable ssh.service
|
||||
|
||||
# The exported image must not clone identity or network state from the baseline.
|
||||
find "$MOUNT_DIR/etc/NetworkManager/system-connections" -mindepth 1 -maxdepth 1 -type f -delete 2>/dev/null || true
|
||||
find "$MOUNT_DIR/var/lib/NetworkManager" -mindepth 1 -maxdepth 1 -type f -delete 2>/dev/null || true
|
||||
rm -f -- "$MOUNT_DIR"/etc/ssh/ssh_host_* "$MOUNT_DIR/etc/machine-id"
|
||||
: > "$MOUNT_DIR/etc/machine-id"
|
||||
rm -f -- "$MOUNT_DIR/var/lib/dbus/machine-id"
|
||||
ln -sfn /etc/machine-id "$MOUNT_DIR/var/lib/dbus/machine-id"
|
||||
|
||||
PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/image_bootstrap_payload.py" stage \
|
||||
--root "$MOUNT_DIR" --bundle "$APP_BUNDLE" --dependency "$KERNEL_DEPENDENCY" >/dev/null
|
||||
|
||||
sync
|
||||
umount "$MOUNT_DIR"
|
||||
losetup -d "$LOOP"
|
||||
LOOP=
|
||||
|
||||
printf '%s\n' 'rootfs bootstrap installed'
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict, dataclass
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import tarfile
|
||||
from typing import BinaryIO
|
||||
|
||||
|
||||
KERNEL_RELEASE = "6.1.31-matrix-axp313a1"
|
||||
SOURCE_COMMIT = "30ff3fd5cf45417622b447a12e7a947402ffe34d"
|
||||
SOURCE_ARCHIVE_NAME = "walnutpi-linux-6.1.31-30ff3fd5.tar.gz"
|
||||
SOURCE_ARCHIVE_SHA256 = "8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f"
|
||||
ARTIFACT_NAME = "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz"
|
||||
MAX_REGULAR_FILES = 4_096
|
||||
MAX_UNPACKED_BYTES = 512 * 1024 * 1024
|
||||
REQUIRED_BOOT_FILES = {
|
||||
"boot/Image-matrix-axp313a1",
|
||||
"boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb",
|
||||
"boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb",
|
||||
f"boot/System.map-{KERNEL_RELEASE}",
|
||||
f"boot/config-{KERNEL_RELEASE}",
|
||||
}
|
||||
|
||||
|
||||
class KernelArtifactError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KernelArtifactInfo:
|
||||
artifact: str
|
||||
artifact_bytes: int
|
||||
artifact_sha256: str
|
||||
kernel_release: str
|
||||
source_commit: str
|
||||
unpacked_file_bytes: int
|
||||
regular_file_count: int
|
||||
module_file_count: int
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise KernelArtifactError(f"invalid JSON metadata: {path}") from exc
|
||||
if not isinstance(document, dict):
|
||||
raise KernelArtifactError(f"metadata must be an object: {path}")
|
||||
return document
|
||||
|
||||
|
||||
def _verify_outer_sum(directory: Path, expected_name: str) -> tuple[Path, str]:
|
||||
manifest = directory / "SHA256SUMS"
|
||||
if not manifest.is_file():
|
||||
raise KernelArtifactError(f"SHA256SUMS is missing: {directory}")
|
||||
try:
|
||||
lines = [line for line in manifest.read_text(encoding="ascii").splitlines() if line]
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise KernelArtifactError(f"SHA256SUMS is unreadable: {directory}") from exc
|
||||
if len(lines) != 1 or " " not in lines[0]:
|
||||
raise KernelArtifactError(f"SHA256SUMS must contain exactly one binary: {directory}")
|
||||
expected_digest, name = lines[0].split(" ", 1)
|
||||
if name != expected_name or len(expected_digest) != 64:
|
||||
raise KernelArtifactError(f"SHA256SUMS entry is wrong: {directory}")
|
||||
path = directory / name
|
||||
if not path.is_file():
|
||||
raise KernelArtifactError(f"dependency binary is missing: {path}")
|
||||
actual_digest = sha256_file(path)
|
||||
if actual_digest != expected_digest:
|
||||
raise KernelArtifactError(f"dependency SHA-256 mismatch: {path}")
|
||||
return path, actual_digest
|
||||
|
||||
|
||||
def _normalized_member_name(raw: str) -> str:
|
||||
while raw.startswith("./"):
|
||||
raw = raw[2:]
|
||||
pure = PurePosixPath(raw)
|
||||
if not raw or raw == "." or pure.is_absolute() or ".." in pure.parts or "\\" in raw:
|
||||
if raw == ".":
|
||||
return ""
|
||||
raise KernelArtifactError(f"unsafe archive path: {raw!r}")
|
||||
return pure.as_posix()
|
||||
|
||||
|
||||
def _member_bytes(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
|
||||
handle: BinaryIO | None = archive.extractfile(member)
|
||||
if handle is None:
|
||||
raise KernelArtifactError(f"archive member cannot be read: {member.name}")
|
||||
return handle.read()
|
||||
|
||||
|
||||
def _parse_internal_sums(body: bytes) -> dict[str, str]:
|
||||
try:
|
||||
lines = body.decode("ascii").splitlines()
|
||||
except UnicodeError as exc:
|
||||
raise KernelArtifactError("internal SHA256SUMS is not ASCII") from exc
|
||||
result: dict[str, str] = {}
|
||||
for line in lines:
|
||||
if not line or " " not in line:
|
||||
raise KernelArtifactError("internal SHA256SUMS contains an invalid line")
|
||||
digest, name = line.split(" ", 1)
|
||||
normalized = _normalized_member_name(name)
|
||||
if len(digest) != 64 or normalized in result:
|
||||
raise KernelArtifactError("internal SHA256SUMS contains an invalid entry")
|
||||
result[normalized] = digest
|
||||
return result
|
||||
|
||||
|
||||
def verify_kernel_dependency(directory: Path) -> KernelArtifactInfo:
|
||||
directory = Path(directory).resolve()
|
||||
artifact, artifact_digest = _verify_outer_sum(directory, ARTIFACT_NAME)
|
||||
metadata = _load_json(directory / "METADATA.json")
|
||||
expected_keys = {
|
||||
"schema_version",
|
||||
"architecture",
|
||||
"kernel_release",
|
||||
"source_commit",
|
||||
"source_archive_sha256",
|
||||
"artifact",
|
||||
"artifact_bytes",
|
||||
"artifact_sha256",
|
||||
"unpacked_file_bytes",
|
||||
"regular_file_count",
|
||||
"module_file_count",
|
||||
"provenance",
|
||||
}
|
||||
if set(metadata) != expected_keys:
|
||||
raise KernelArtifactError("kernel metadata fields are not exact")
|
||||
fixed = {
|
||||
"schema_version": 1,
|
||||
"architecture": "aarch64",
|
||||
"kernel_release": KERNEL_RELEASE,
|
||||
"source_commit": SOURCE_COMMIT,
|
||||
"source_archive_sha256": SOURCE_ARCHIVE_SHA256,
|
||||
"artifact": ARTIFACT_NAME,
|
||||
}
|
||||
if any(metadata.get(key) != value for key, value in fixed.items()):
|
||||
raise KernelArtifactError("kernel metadata identity is wrong")
|
||||
if metadata["artifact_bytes"] != artifact.stat().st_size:
|
||||
raise KernelArtifactError("kernel artifact size metadata is wrong")
|
||||
if metadata["artifact_sha256"] != artifact_digest:
|
||||
raise KernelArtifactError("kernel artifact digest metadata is wrong")
|
||||
for key in ("unpacked_file_bytes", "regular_file_count", "module_file_count"):
|
||||
if type(metadata[key]) is not int or metadata[key] <= 0:
|
||||
raise KernelArtifactError(f"kernel metadata {key} must be a positive integer")
|
||||
if not isinstance(metadata["provenance"], str) or not metadata["provenance"].strip():
|
||||
raise KernelArtifactError("kernel metadata provenance is missing")
|
||||
|
||||
with artifact.open("rb") as raw:
|
||||
header = raw.read(10)
|
||||
if len(header) != 10 or header[:2] != b"\x1f\x8b" or header[4:8] != b"\0\0\0\0":
|
||||
raise KernelArtifactError("kernel artifact gzip header is not deterministic")
|
||||
|
||||
try:
|
||||
archive = tarfile.open(artifact, mode="r:gz")
|
||||
except (tarfile.TarError, OSError, gzip.BadGzipFile) as exc:
|
||||
raise KernelArtifactError("kernel artifact is not a readable tar.gz") from exc
|
||||
with archive:
|
||||
regular: dict[str, tarfile.TarInfo] = {}
|
||||
seen: set[str] = set()
|
||||
unpacked_bytes = 0
|
||||
for member in archive.getmembers():
|
||||
name = _normalized_member_name(member.name)
|
||||
if not name:
|
||||
if not member.isdir():
|
||||
raise KernelArtifactError("archive root must be a directory")
|
||||
continue
|
||||
if name in seen:
|
||||
raise KernelArtifactError(f"duplicate archive path: {name}")
|
||||
seen.add(name)
|
||||
if not (member.isfile() or member.isdir()):
|
||||
raise KernelArtifactError(f"archive contains a link or special file: {name}")
|
||||
if member.uid != 0 or member.gid != 0 or int(member.mtime) != 0:
|
||||
raise KernelArtifactError(f"archive ownership or timestamp is not normalized: {name}")
|
||||
if member.isfile():
|
||||
regular[name] = member
|
||||
unpacked_bytes += member.size
|
||||
if len(regular) > MAX_REGULAR_FILES or unpacked_bytes > MAX_UNPACKED_BYTES:
|
||||
raise KernelArtifactError("kernel artifact exceeds safety limits")
|
||||
|
||||
required = REQUIRED_BOOT_FILES | {"KERNEL_RELEASE", "SOURCE_COMMIT", "SHA256SUMS"}
|
||||
missing = required - regular.keys()
|
||||
if missing:
|
||||
raise KernelArtifactError(f"kernel artifact is incomplete: {sorted(missing)}")
|
||||
module_prefix = f"lib/modules/{KERNEL_RELEASE}/"
|
||||
unexpected = {
|
||||
name for name in regular if name not in required and not name.startswith(module_prefix)
|
||||
}
|
||||
if unexpected:
|
||||
raise KernelArtifactError(f"kernel artifact contains an unowned file: {sorted(unexpected)}")
|
||||
forbidden = {
|
||||
f"lib/modules/{KERNEL_RELEASE}/build",
|
||||
f"lib/modules/{KERNEL_RELEASE}/source",
|
||||
}
|
||||
if forbidden & seen:
|
||||
raise KernelArtifactError("kernel artifact contains build/source module links")
|
||||
if _member_bytes(archive, regular["KERNEL_RELEASE"]) != f"{KERNEL_RELEASE}\n".encode():
|
||||
raise KernelArtifactError("kernel release marker is wrong")
|
||||
if _member_bytes(archive, regular["SOURCE_COMMIT"]) != f"{SOURCE_COMMIT}\n".encode():
|
||||
raise KernelArtifactError("kernel source commit marker is wrong")
|
||||
image = _member_bytes(archive, regular["boot/Image-matrix-axp313a1"])
|
||||
if f"Linux version {KERNEL_RELEASE}".encode() not in image:
|
||||
raise KernelArtifactError("kernel Image does not contain the expected release")
|
||||
|
||||
internal = _parse_internal_sums(_member_bytes(archive, regular["SHA256SUMS"]))
|
||||
verified_files = set(regular) - {"SHA256SUMS"}
|
||||
if set(internal) != verified_files:
|
||||
raise KernelArtifactError("internal SHA256SUMS does not cover exactly every payload file")
|
||||
for name, digest in internal.items():
|
||||
actual = hashlib.sha256(_member_bytes(archive, regular[name])).hexdigest()
|
||||
if actual != digest:
|
||||
raise KernelArtifactError(f"internal SHA-256 mismatch: {name}")
|
||||
|
||||
module_count = sum(1 for name in regular if name.startswith(module_prefix))
|
||||
measurements = {
|
||||
"unpacked_file_bytes": unpacked_bytes,
|
||||
"regular_file_count": len(regular),
|
||||
"module_file_count": module_count,
|
||||
}
|
||||
if any(metadata[key] != value for key, value in measurements.items()):
|
||||
raise KernelArtifactError("kernel artifact measurements do not match metadata")
|
||||
return KernelArtifactInfo(
|
||||
artifact=ARTIFACT_NAME,
|
||||
artifact_bytes=artifact.stat().st_size,
|
||||
artifact_sha256=artifact_digest,
|
||||
kernel_release=KERNEL_RELEASE,
|
||||
source_commit=SOURCE_COMMIT,
|
||||
**measurements,
|
||||
)
|
||||
|
||||
|
||||
def verify_source_dependency(directory: Path) -> dict[str, object]:
|
||||
directory = Path(directory).resolve()
|
||||
archive, digest = _verify_outer_sum(directory, SOURCE_ARCHIVE_NAME)
|
||||
metadata = _load_json(directory / "METADATA.json")
|
||||
expected = {
|
||||
"schema_version": 1,
|
||||
"source": "WalnutPi Linux 6.1.31",
|
||||
"source_commit": SOURCE_COMMIT,
|
||||
"archive": SOURCE_ARCHIVE_NAME,
|
||||
"archive_bytes": archive.stat().st_size,
|
||||
"archive_sha256": SOURCE_ARCHIVE_SHA256,
|
||||
"purpose": "Offline reproducible build input for 6.1.31-matrix-axp313a1",
|
||||
}
|
||||
if metadata != expected or digest != SOURCE_ARCHIVE_SHA256:
|
||||
raise KernelArtifactError("kernel source dependency metadata is wrong")
|
||||
return expected
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify offline AXP313A kernel dependencies")
|
||||
parser.add_argument("--kernel", type=Path, required=True)
|
||||
parser.add_argument("--source", type=Path)
|
||||
args = parser.parse_args()
|
||||
result: dict[str, object] = {"kernel": asdict(verify_kernel_dependency(args.kernel))}
|
||||
if args.source:
|
||||
result["source"] = verify_source_dependency(args.source)
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KernelArtifactError as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
import urllib.request
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class MeasurementError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def fetch(url: str, *, timeout: float = 2.0) -> tuple[bytes, float]:
|
||||
started = time.monotonic()
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||
body = response.read()
|
||||
return body, (time.monotonic() - started) * 1000.0
|
||||
|
||||
|
||||
def rgb_digest(body: bytes) -> str:
|
||||
with Image.open(BytesIO(body)) as image:
|
||||
rgb = image.convert("RGB")
|
||||
return hashlib.sha256(rgb.tobytes()).hexdigest()
|
||||
|
||||
|
||||
def percentile_95(values: list[float]) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
return ordered[max(0, min(len(ordered) - 1, int(len(ordered) * 0.95 + 0.999999) - 1))]
|
||||
|
||||
|
||||
def driver_status(sample: dict[str, Any]) -> dict[str, Any]:
|
||||
driver = (sample.get("screen") or {}).get("driver_status")
|
||||
if not isinstance(driver, dict):
|
||||
raise MeasurementError("status does not contain screen.driver_status")
|
||||
return driver
|
||||
|
||||
|
||||
def validate_samples(
|
||||
samples: list[dict[str, Any]],
|
||||
*,
|
||||
performance: str,
|
||||
frame_before: str,
|
||||
frame_after: str,
|
||||
) -> dict[str, Any]:
|
||||
if len(samples) < 2:
|
||||
raise MeasurementError("at least two status samples are required")
|
||||
drivers = [driver_status(sample) for sample in samples]
|
||||
minimum_actual = min(float(item.get("actual_refresh_rate_hz") or 0) for item in drivers)
|
||||
minimum_scan = min(float(item.get("panel_scan_rate_hz") or 0) for item in drivers)
|
||||
if minimum_actual < 95.0 or minimum_scan < 95.0:
|
||||
raise MeasurementError("actual or panel scan refresh rate fell below 95Hz")
|
||||
if any(item.get("scans_per_frame") != 1 for item in drivers):
|
||||
raise MeasurementError("scans_per_frame was not 1 throughout the sample")
|
||||
|
||||
stalled_intervals = 0
|
||||
maximum_consecutive_stalls = 0
|
||||
for previous, current in zip(drivers, drivers[1:]):
|
||||
frame_delta = int(current.get("completed_frames") or 0) - int(previous.get("completed_frames") or 0)
|
||||
scan_delta = int(current.get("completed_scans") or 0) - int(previous.get("completed_scans") or 0)
|
||||
if frame_delta < 0 or scan_delta != frame_delta:
|
||||
raise MeasurementError("completed frame and scan deltas were negative or unequal")
|
||||
stalled_intervals = stalled_intervals + 1 if frame_delta == 0 else 0
|
||||
maximum_consecutive_stalls = max(maximum_consecutive_stalls, stalled_intervals)
|
||||
if maximum_consecutive_stalls >= 2:
|
||||
raise MeasurementError("completed frames stalled for two consecutive samples")
|
||||
|
||||
frame_delta = int(drivers[-1].get("completed_frames") or 0) - int(drivers[0].get("completed_frames") or 0)
|
||||
scan_delta = int(drivers[-1].get("completed_scans") or 0) - int(drivers[0].get("completed_scans") or 0)
|
||||
miss_delta = int(drivers[-1].get("deadline_misses") or 0) - int(drivers[0].get("deadline_misses") or 0)
|
||||
if frame_delta <= 0 or scan_delta != frame_delta:
|
||||
raise MeasurementError("completed frame/scan totals did not advance equally")
|
||||
if miss_delta < 0 or miss_delta / frame_delta > 0.001:
|
||||
raise MeasurementError("deadline miss rate exceeded 0.1%")
|
||||
|
||||
fault_fields = sorted(
|
||||
key for key, value in drivers[0].items()
|
||||
if "fault" in key and isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
)
|
||||
for field in fault_fields:
|
||||
if any(float(current.get(field) or 0) != float(drivers[0].get(field) or 0) for current in drivers[1:]):
|
||||
raise MeasurementError(f"{field} changed during the measurement")
|
||||
for field in ("driver_error", "oe_timing_error", "safeoff_error"):
|
||||
if any(item.get(field) for item in drivers):
|
||||
raise MeasurementError(f"{field} was not empty")
|
||||
for item in drivers:
|
||||
if item.get("cpu_affinity") != 3 or item.get("cpu_affinity_active") is not True:
|
||||
raise MeasurementError("CPU3 affinity was not continuously active")
|
||||
if item.get("realtime_priority") != 50 or item.get("realtime_priority_active") is not True:
|
||||
raise MeasurementError("SCHED_FIFO 50 was not continuously active")
|
||||
if item.get("memory_locked") is not True:
|
||||
raise MeasurementError("refresh memory was not continuously locked")
|
||||
|
||||
for sample in samples:
|
||||
status = ((sample.get("system") or {}).get("performance_mode") or {})
|
||||
if performance == "on":
|
||||
if status.get("requested") is not True or status.get("effective") is not True:
|
||||
raise MeasurementError("performance mode was not requested and effective")
|
||||
if not status.get("current_governors") or any(
|
||||
governor != "performance" for governor in status["current_governors"].values()
|
||||
):
|
||||
raise MeasurementError("not every cpufreq policy used the performance governor")
|
||||
elif performance == "off":
|
||||
if status.get("effective") is not False:
|
||||
raise MeasurementError("performance mode remained effective while required off")
|
||||
current = status.get("current_governors") or {}
|
||||
restore = status.get("restore_governors") or {}
|
||||
if current and current != restore:
|
||||
raise MeasurementError("governors were not restored while performance mode was off")
|
||||
|
||||
if frame_before != frame_after:
|
||||
raise MeasurementError("current RGB frame changed during thumbnail browsing")
|
||||
return {
|
||||
"ok": True,
|
||||
"samples": len(samples),
|
||||
"minimum_actual_refresh_rate_hz": minimum_actual,
|
||||
"minimum_panel_scan_rate_hz": minimum_scan,
|
||||
"completed_frames_delta": frame_delta,
|
||||
"completed_scans_delta": scan_delta,
|
||||
"deadline_misses_delta": miss_delta,
|
||||
"deadline_miss_rate": miss_delta / frame_delta,
|
||||
"maximum_consecutive_stalled_intervals": maximum_consecutive_stalls,
|
||||
"fault_fields_checked": fault_fields,
|
||||
"current_frame_rgb_sha256": frame_before,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Measure real HUB75 GPIO refresh while browsing")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8080")
|
||||
parser.add_argument("--seconds", type=int, default=60)
|
||||
parser.add_argument("--performance", choices=("on", "off", "ignore"), default="ignore")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if not 2 <= args.seconds <= 600:
|
||||
parser.error("--seconds must be within 2..600")
|
||||
|
||||
frame_body, _ = fetch(f"{args.base_url}/api/display/current-frame")
|
||||
frame_before = rgb_digest(frame_body)
|
||||
samples: list[dict[str, Any]] = []
|
||||
latencies: list[float] = []
|
||||
started = time.monotonic()
|
||||
for second in range(args.seconds + 1):
|
||||
target = started + second
|
||||
if target > time.monotonic():
|
||||
time.sleep(target - time.monotonic())
|
||||
body, latency = fetch(f"{args.base_url}/api/status")
|
||||
samples.append(json.loads(body.decode("utf-8")))
|
||||
latencies.append(latency)
|
||||
frame_body, _ = fetch(f"{args.base_url}/api/display/current-frame")
|
||||
result = validate_samples(
|
||||
samples,
|
||||
performance=args.performance,
|
||||
frame_before=frame_before,
|
||||
frame_after=rgb_digest(frame_body),
|
||||
)
|
||||
result["status_latency_p95_ms"] = percentile_95(latencies)
|
||||
if result["status_latency_p95_ms"] > 500.0:
|
||||
raise MeasurementError("/api/status p95 latency exceeded 500ms")
|
||||
encoded = json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||
if args.output:
|
||||
args.output.write_text(encoded, encoding="utf-8", newline="\n")
|
||||
print(encoded, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except MeasurementError as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from app.media.worker import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_SOURCE = Path("/opt/matrix-screen-controller/data")
|
||||
DEFAULT_TARGET = Path("/var/lib/matrix-screen-controller")
|
||||
STAGING_NAME = ".matrix-screen-controller.migrate"
|
||||
|
||||
ROOT_RUNTIME_FILES = {
|
||||
"last_frame.png",
|
||||
"startup_indicator.json",
|
||||
"startup_indicator.json.tmp",
|
||||
"config.json.tmp",
|
||||
}
|
||||
ROOT_RUNTIME_DIRECTORIES = {"runtime"}
|
||||
ATOMIC_TEMP_RE = re.compile(r"^\..+\.[0-9a-f]{32}\.tmp$")
|
||||
|
||||
|
||||
class StateRootMigrationError(RuntimeError):
|
||||
"""Raised when the state root cannot be migrated without data loss."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationResult:
|
||||
status: str
|
||||
source: Path
|
||||
target: Path
|
||||
file_count: int
|
||||
total_bytes: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"source": str(self.source),
|
||||
"target": str(self.target),
|
||||
"file_count": self.file_count,
|
||||
"total_bytes": self.total_bytes,
|
||||
}
|
||||
|
||||
|
||||
def _is_transient(relative: Path) -> bool:
|
||||
if relative.parts and relative.parts[0] in ROOT_RUNTIME_DIRECTORIES:
|
||||
return True
|
||||
if len(relative.parts) == 1 and relative.name in ROOT_RUNTIME_FILES:
|
||||
return True
|
||||
return bool(ATOMIC_TEMP_RE.fullmatch(relative.name))
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_manifest(root: Path) -> dict[str, dict[str, Any]]:
|
||||
root = Path(root)
|
||||
if not root.exists():
|
||||
return {}
|
||||
if not root.is_dir() or root.is_symlink():
|
||||
raise StateRootMigrationError(f"state root must be a real directory: {root}")
|
||||
|
||||
manifest: dict[str, dict[str, Any]] = {}
|
||||
for path in sorted(root.rglob("*")):
|
||||
relative = path.relative_to(root)
|
||||
if _is_transient(relative):
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise StateRootMigrationError(f"state root contains an unsupported symlink: {relative}")
|
||||
if path.is_dir():
|
||||
manifest[relative.as_posix()] = {"type": "directory"}
|
||||
continue
|
||||
if not path.is_file():
|
||||
raise StateRootMigrationError(f"state root contains an unsupported entry: {relative}")
|
||||
size = path.stat().st_size
|
||||
manifest[relative.as_posix()] = {
|
||||
"type": "file",
|
||||
"size": size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
return manifest
|
||||
|
||||
|
||||
def _summary(manifest: dict[str, dict[str, Any]]) -> tuple[int, int]:
|
||||
files = [entry for entry in manifest.values() if entry["type"] == "file"]
|
||||
return len(files), sum(int(entry["size"]) for entry in files)
|
||||
|
||||
|
||||
def _copy_ignore(source: Path):
|
||||
def ignore(directory: str, names: list[str]) -> set[str]:
|
||||
base = Path(directory)
|
||||
ignored: set[str] = set()
|
||||
for name in names:
|
||||
relative = (base / name).relative_to(source)
|
||||
if _is_transient(relative):
|
||||
ignored.add(name)
|
||||
return ignored
|
||||
|
||||
return ignore
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
if os.name == "nt":
|
||||
return
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _fsync_tree(root: Path) -> None:
|
||||
if os.name == "nt":
|
||||
return
|
||||
directories = [root]
|
||||
for path in root.rglob("*"):
|
||||
if path.is_file():
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
elif path.is_dir():
|
||||
directories.append(path)
|
||||
for directory in reversed(directories):
|
||||
_fsync_directory(directory)
|
||||
|
||||
|
||||
def _assert_staging_path(staging: Path, target: Path) -> None:
|
||||
if staging.parent != target.parent or staging.name != STAGING_NAME:
|
||||
raise StateRootMigrationError(f"refusing to clean unexpected staging path: {staging}")
|
||||
|
||||
|
||||
def _remove_staging(staging: Path, target: Path) -> None:
|
||||
if not staging.exists():
|
||||
return
|
||||
_assert_staging_path(staging, target)
|
||||
if staging.is_symlink() or not staging.is_dir():
|
||||
raise StateRootMigrationError(f"refusing to replace unexpected staging entry: {staging}")
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def migrate_state_root(source: Path, target: Path) -> MigrationResult:
|
||||
source_input = Path(source)
|
||||
target_input = Path(target)
|
||||
if source_input.is_symlink() or target_input.is_symlink():
|
||||
raise StateRootMigrationError("source and target state roots must not be symlinks")
|
||||
source = source_input.resolve(strict=False)
|
||||
target = target_input.resolve(strict=False)
|
||||
if source == target:
|
||||
raise StateRootMigrationError("source and target state roots must be different")
|
||||
if source in target.parents or target in source.parents:
|
||||
raise StateRootMigrationError("source and target state roots must not contain each other")
|
||||
|
||||
source_manifest = build_manifest(source)
|
||||
if not source.exists():
|
||||
target_manifest = build_manifest(target)
|
||||
file_count, total_bytes = _summary(target_manifest)
|
||||
if file_count == 0:
|
||||
raise StateRootMigrationError(
|
||||
"legacy source is missing and target contains no persistent files"
|
||||
)
|
||||
return MigrationResult("target-only", source, target, file_count, total_bytes)
|
||||
source_file_count, _source_total_bytes = _summary(source_manifest)
|
||||
if source_file_count == 0:
|
||||
raise StateRootMigrationError("legacy source contains no persistent files")
|
||||
|
||||
target_manifest = build_manifest(target)
|
||||
if target_manifest:
|
||||
if target_manifest != source_manifest:
|
||||
raise StateRootMigrationError(
|
||||
"source and target both contain different state; refusing to merge or overwrite"
|
||||
)
|
||||
file_count, total_bytes = _summary(target_manifest)
|
||||
return MigrationResult("already-prepared", source, target, file_count, total_bytes)
|
||||
|
||||
target_parent = target.parent
|
||||
target_parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target_parent / STAGING_NAME
|
||||
_remove_staging(staging, target)
|
||||
|
||||
if target.exists():
|
||||
if target.is_symlink() or not target.is_dir():
|
||||
raise StateRootMigrationError(f"target state root is not a real directory: {target}")
|
||||
if any(target.iterdir()):
|
||||
raise StateRootMigrationError(f"target state root is not empty: {target}")
|
||||
|
||||
try:
|
||||
shutil.copytree(
|
||||
source,
|
||||
staging,
|
||||
copy_function=shutil.copy2,
|
||||
ignore=_copy_ignore(source),
|
||||
)
|
||||
copied_manifest = build_manifest(staging)
|
||||
if copied_manifest != source_manifest:
|
||||
raise StateRootMigrationError("copied state failed SHA-256 inventory verification")
|
||||
_fsync_tree(staging)
|
||||
if target.exists():
|
||||
target.rmdir()
|
||||
os.replace(staging, target)
|
||||
if os.name != "nt":
|
||||
os.chmod(target, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP)
|
||||
_fsync_directory(target_parent)
|
||||
except Exception:
|
||||
_remove_staging(staging, target)
|
||||
raise
|
||||
|
||||
file_count, total_bytes = _summary(source_manifest)
|
||||
return MigrationResult("migrated", source, target, file_count, total_bytes)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Copy the legacy Matrix Screen Controller data directory to its persistent "
|
||||
"Linux state root without deleting the source."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
|
||||
parser.add_argument("--target", type=Path, default=DEFAULT_TARGET)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
result = migrate_state_root(args.source, args.target)
|
||||
except (OSError, StateRootMigrationError) as exc:
|
||||
print(f"state migration failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result.to_dict(), ensure_ascii=True, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(SOURCE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from app.persistence import atomic_write_many_bytes
|
||||
|
||||
|
||||
def _read_json(path: Path) -> tuple[dict[str, Any], bytes]:
|
||||
content = path.read_bytes()
|
||||
value = json.loads(content.decode("utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{path} must contain a JSON object")
|
||||
return value, content
|
||||
|
||||
|
||||
def _json_bytes(value: dict[str, Any]) -> bytes:
|
||||
return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def build_migration(
|
||||
draft: dict[str, Any],
|
||||
old_catalog: dict[str, Any],
|
||||
old_catalog_bytes: bytes,
|
||||
new_catalog: dict[str, Any],
|
||||
new_catalog_bytes: bytes,
|
||||
*,
|
||||
excluded_copy_ids: set[str],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
if old_catalog.get("schema_version") != 1:
|
||||
raise ValueError("old catalog must use schema v1")
|
||||
if new_catalog.get("schema_version") != 2:
|
||||
raise ValueError("new catalog must use schema v2")
|
||||
if draft.get("schema_version") != 1:
|
||||
raise ValueError("draft must use schema v1")
|
||||
old_digest = hashlib.sha256(old_catalog_bytes).hexdigest()
|
||||
if draft.get("base_catalog_digest") != old_digest:
|
||||
raise ValueError("draft does not match the supplied old catalog")
|
||||
overrides = draft.get("overrides")
|
||||
insertions = draft.get("insertions")
|
||||
if not isinstance(overrides, dict) or not isinstance(insertions, list):
|
||||
raise ValueError("draft collections are invalid")
|
||||
|
||||
new_items = new_catalog.get("items")
|
||||
new_anchors = new_catalog.get("anchors")
|
||||
if not isinstance(new_items, dict) or not isinstance(new_anchors, dict):
|
||||
raise ValueError("new catalog collections are invalid")
|
||||
|
||||
retained_overrides: dict[str, Any] = {}
|
||||
removed_overrides: list[dict[str, Any]] = []
|
||||
for copy_id, override in overrides.items():
|
||||
if copy_id in excluded_copy_ids:
|
||||
removed_overrides.append({"copy_id": copy_id, "reason": "explicitly_excluded", "override": override})
|
||||
elif copy_id not in new_items:
|
||||
removed_overrides.append({"copy_id": copy_id, "reason": "not_registered_in_v2", "override": override})
|
||||
else:
|
||||
retained_overrides[copy_id] = override
|
||||
|
||||
retained_insertions: list[dict[str, Any]] = []
|
||||
removed_insertions: list[dict[str, Any]] = []
|
||||
for insertion in insertions:
|
||||
anchor = new_anchors.get(insertion.get("anchor_id")) if isinstance(insertion, dict) else None
|
||||
if not isinstance(anchor, dict) or anchor.get("scope") != insertion.get("workspace_id"):
|
||||
removed_insertions.append({"reason": "anchor_not_registered_in_v2", "insertion": insertion})
|
||||
else:
|
||||
retained_insertions.append(insertion)
|
||||
|
||||
migrated_at = _now()
|
||||
new_digest = hashlib.sha256(new_catalog_bytes).hexdigest()
|
||||
migrated = {
|
||||
"schema_version": 1,
|
||||
"base_catalog_digest": new_digest,
|
||||
"revision": int(draft.get("revision", -1)) + 1,
|
||||
"updated_at": migrated_at,
|
||||
"overrides": retained_overrides,
|
||||
"insertions": retained_insertions,
|
||||
}
|
||||
report = {
|
||||
"schema_version": 1,
|
||||
"migrated_at": migrated_at,
|
||||
"from_catalog_digest": old_digest,
|
||||
"to_catalog_digest": new_digest,
|
||||
"from_revision": draft.get("revision"),
|
||||
"to_revision": migrated["revision"],
|
||||
"retained_override_ids": sorted(retained_overrides),
|
||||
"removed_overrides": removed_overrides,
|
||||
"retained_insertion_ids": [item.get("id") for item in retained_insertions],
|
||||
"removed_insertions": removed_insertions,
|
||||
}
|
||||
return migrated, report
|
||||
|
||||
|
||||
def migrate_files(
|
||||
draft_path: Path,
|
||||
old_catalog_path: Path,
|
||||
new_catalog_path: Path,
|
||||
report_path: Path,
|
||||
*,
|
||||
excluded_copy_ids: set[str],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
draft, _draft_bytes = _read_json(draft_path)
|
||||
old_catalog, old_bytes = _read_json(old_catalog_path)
|
||||
new_catalog, new_bytes = _read_json(new_catalog_path)
|
||||
migrated, report = build_migration(
|
||||
draft,
|
||||
old_catalog,
|
||||
old_bytes,
|
||||
new_catalog,
|
||||
new_bytes,
|
||||
excluded_copy_ids=excluded_copy_ids,
|
||||
)
|
||||
atomic_write_many_bytes(
|
||||
(
|
||||
(draft_path, _json_bytes(migrated)),
|
||||
(report_path, _json_bytes(report)),
|
||||
)
|
||||
)
|
||||
return migrated, report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Migrate a UI copy draft from catalog v1 to catalog v2")
|
||||
parser.add_argument("--draft", type=Path, required=True)
|
||||
parser.add_argument("--old-catalog", type=Path, required=True)
|
||||
parser.add_argument("--new-catalog", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
parser.add_argument("--exclude-copy-id", action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
migrated, report = migrate_files(
|
||||
args.draft,
|
||||
args.old_catalog,
|
||||
args.new_catalog,
|
||||
args.report,
|
||||
excluded_copy_ids=set(args.exclude_copy_id),
|
||||
)
|
||||
print(json.dumps({
|
||||
"revision": migrated["revision"],
|
||||
"retained_overrides": len(report["retained_override_ids"]),
|
||||
"removed_overrides": len(report["removed_overrides"]),
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable component transaction; also runnable by old workers and at boot.
|
||||
|
||||
The journal is mirrored into the original and candidate data roots before any
|
||||
system mutation. The worker may rename either root; one journal always survives.
|
||||
The recovery executable is independent of releases that the old worker deletes.
|
||||
Only stdlib imports: recovery must not depend on a candidate venv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
DATA = Path('/var/lib/matrix-screen-controller')
|
||||
TARGET = Path('/opt/matrix-screen-controller')
|
||||
RELEASES = Path('/opt/matrix-screen-controller.releases')
|
||||
RUNTIME = Path('/run/matrix-screen-controller')
|
||||
HELPER = Path('/opt/matrix-screen-controller-component-recovery.py')
|
||||
RECOVERY_UNIT = Path('/etc/systemd/system/matrix-screen-component-recovery.service')
|
||||
ACCOUNT_POLICY = Path('/etc/sudoers.d/90-matrix-screen-controller-account')
|
||||
WORK_ROOT = Path('/opt/matrix-screen-controller-ota')
|
||||
RUNTIME_GUARD = Path('/run/systemd/system/matrix-screen-controller.service.d/90-matrix-ota-runtime.conf')
|
||||
RUNTIME_GUARD_BODY = b'# Managed by the OTA component transaction\n[Service]\nRuntimeDirectoryPreserve=yes\n'
|
||||
SERVICE = 'matrix-screen-controller.service'
|
||||
WORKER = 'matrix-screen-controller-ota.service'
|
||||
FRP = 'matrix-screen-frpc.service'
|
||||
JOURNAL = Path('ota/component-transaction')
|
||||
FILES = {
|
||||
'frpc': Path('/usr/local/bin/frpc'),
|
||||
'frpc-unit': Path('/etc/systemd/system/matrix-screen-frpc.service'),
|
||||
'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'),
|
||||
'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'),
|
||||
'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'),
|
||||
}
|
||||
|
||||
|
||||
def run(args: list[str], *, check=True, **kwargs):
|
||||
return subprocess.run(args, check=check, capture_output=True, timeout=60, **kwargs)
|
||||
|
||||
|
||||
def sync_directory(path: Path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def atomic(path: Path, body: bytes, mode=0o600):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = path.with_name(path.name + '.tmp')
|
||||
with temp.open('wb') as handle:
|
||||
os.chmod(temp, mode)
|
||||
handle.write(body)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp, path)
|
||||
sync_directory(path.parent)
|
||||
|
||||
|
||||
def read(path: Path):
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def protect_runtime():
|
||||
if RUNTIME_GUARD.exists():
|
||||
raise RuntimeError('OTA runtime protection path already exists; refusing to overwrite')
|
||||
atomic(RUNTIME_GUARD, RUNTIME_GUARD_BODY, 0o644)
|
||||
run(['systemctl', 'daemon-reload'])
|
||||
value = run(['systemctl', 'show', SERVICE, '--property=RuntimeDirectoryPreserve', '--value']).stdout.strip()
|
||||
if value != b'yes':
|
||||
raise RuntimeError('OTA runtime directory protection did not take effect; refusing to stop service')
|
||||
|
||||
|
||||
def release_runtime_guard():
|
||||
if RUNTIME_GUARD.exists():
|
||||
if RUNTIME_GUARD.read_bytes() != RUNTIME_GUARD_BODY:
|
||||
raise RuntimeError('OTA runtime protection was modified; preserving it for inspection')
|
||||
RUNTIME_GUARD.unlink()
|
||||
try:
|
||||
RUNTIME_GUARD.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
run(['systemctl', 'daemon-reload'])
|
||||
|
||||
|
||||
def metadata(path: Path):
|
||||
info = path.stat(follow_symlinks=False)
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
raise RuntimeError('managed component paths must not be symbolic links')
|
||||
return {'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid}
|
||||
|
||||
|
||||
def apply_metadata(path: Path, item: dict):
|
||||
os.chown(path, item['uid'], item['gid'], follow_symlinks=False)
|
||||
path.chmod(item['mode'])
|
||||
|
||||
|
||||
def permission_snapshot(root: Path):
|
||||
paths = [root]
|
||||
if (root / 'frp').exists():
|
||||
paths += [root / 'frp', *(root / 'frp').rglob('*')]
|
||||
return {str(p.relative_to(root)): metadata(p) for p in paths}
|
||||
|
||||
|
||||
def restore_permissions(root: Path, saved: dict):
|
||||
for name, item in saved.items():
|
||||
path = root / name
|
||||
if path.exists():
|
||||
apply_metadata(path, item)
|
||||
# Remove only empty directories created by the installer, never user files.
|
||||
for name in ('frp/profiles', 'frp'):
|
||||
if name not in saved:
|
||||
try:
|
||||
(root / name).rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def mirror_permissions(candidate: Path):
|
||||
for name, item in permission_snapshot(DATA).items():
|
||||
path = candidate / name
|
||||
if not path.exists() and (DATA / name).is_dir():
|
||||
path.mkdir(parents=True)
|
||||
if path.exists():
|
||||
apply_metadata(path, item)
|
||||
|
||||
|
||||
def account() -> str:
|
||||
import grp
|
||||
import pwd
|
||||
dropin = FILES['frpc-dropin']
|
||||
if dropin.is_file():
|
||||
users = re.findall(r'^User=([a-zA-Z0-9_-]+)$', dropin.read_text(encoding='utf-8'), re.M)
|
||||
if len(users) == 1:
|
||||
try:
|
||||
if pwd.getpwnam(users[0]).pw_uid != 0:
|
||||
return users[0]
|
||||
except KeyError:
|
||||
pass
|
||||
if ACCOUNT_POLICY.is_file():
|
||||
match = re.fullmatch(r'([a-zA-Z0-9_-]+)\s+ALL=\(ALL:ALL\)\s+ALL\s*',
|
||||
ACCOUNT_POLICY.read_text(encoding='utf-8').strip())
|
||||
if match:
|
||||
try:
|
||||
if pwd.getpwnam(match[1]).pw_uid != 0:
|
||||
return match[1]
|
||||
except KeyError:
|
||||
pass
|
||||
raise RuntimeError('项目维护账户配置无效;尚未修改系统组件')
|
||||
group = grp.getgrnam('sudo')
|
||||
users = [p.pw_name for p in pwd.getpwall() if 1000 <= p.pw_uid < 65534
|
||||
and (p.pw_name in group.gr_mem or p.pw_gid == group.gr_gid)
|
||||
and p.pw_shell not in ('/usr/sbin/nologin', '/bin/false')]
|
||||
if len(users) != 1:
|
||||
raise RuntimeError('无法唯一确定非 root 维护账户,尚未修改系统组件')
|
||||
return users[0]
|
||||
|
||||
|
||||
def check_installed(source: Path, version: str):
|
||||
import pwd
|
||||
import grp
|
||||
entries = read(source / 'UPGRADE_POLICY.json')['checkpoints']
|
||||
required = {}
|
||||
version_tuple = tuple(map(int, version.split('.')))
|
||||
for entry in entries:
|
||||
if tuple(map(int, entry['version'].split('.'))) <= version_tuple:
|
||||
required.update(entry['components'])
|
||||
if not required:
|
||||
return
|
||||
expected = required['frpc']
|
||||
binary = FILES['frpc']
|
||||
error = 'frp 系统组件缺失或不完整;请先修复软件安装包组件后重试'
|
||||
if not binary.is_file() or hashlib.sha256(binary.read_bytes()).hexdigest() != expected['sha256']:
|
||||
raise RuntimeError(error)
|
||||
if metadata(binary)['mode'] != 0o755 or run([str(binary), '--version']).stdout.decode().strip() != expected['version']:
|
||||
raise RuntimeError(error)
|
||||
if not FILES['frpc-unit'].is_file() or FILES['frpc-unit'].read_bytes() != (source / 'systemd/matrix-screen-frpc.service').read_bytes():
|
||||
raise RuntimeError(error)
|
||||
user = account()
|
||||
group = grp.getgrgid(pwd.getpwnam(user).pw_gid).gr_name
|
||||
wanted = f'[Service]\nUser={user}\nGroup={group}\n'.encode()
|
||||
if not FILES['frpc-dropin'].is_file() or FILES['frpc-dropin'].read_bytes() != wanted:
|
||||
raise RuntimeError(error)
|
||||
for name in ('frpc-unit', 'frpc-dropin'):
|
||||
if metadata(FILES[name]) != {'mode': 0o644, 'uid': 0, 'gid': 0}:
|
||||
raise RuntimeError(error)
|
||||
if binary.stat().st_uid != 0 or binary.stat().st_gid != 0:
|
||||
raise RuntimeError(error)
|
||||
gid = pwd.getpwnam(user).pw_gid
|
||||
if stat.S_IMODE(DATA.stat().st_mode) != 0o711:
|
||||
raise RuntimeError(error)
|
||||
for name in ('frp', 'frp/profiles'):
|
||||
if not (DATA / name).is_dir():
|
||||
raise RuntimeError(error)
|
||||
for path in [DATA / 'frp', *(DATA / 'frp').rglob('*')]:
|
||||
mode = 0o2750 if path.is_dir() else (0o600 if path == DATA / 'frp/active.env' else 0o640)
|
||||
if metadata(path) != {'mode': mode, 'uid': 0, 'gid': gid}:
|
||||
raise RuntimeError(error)
|
||||
|
||||
|
||||
def begin(source: Path, candidate: Path):
|
||||
request_path = RUNTIME / 'ota-request.json'
|
||||
# A regular migration / local test is not authorization to touch the host.
|
||||
if not request_path.is_file():
|
||||
return
|
||||
request = read(request_path)
|
||||
job = request.get('job_id', '')
|
||||
if not re.fullmatch(r'[a-zA-Z0-9_-]+', job):
|
||||
raise RuntimeError('invalid component transaction id')
|
||||
version = (source / 'VERSION').read_text(encoding='utf-8').strip()
|
||||
expected_release = RELEASES / f'{version}-{job}'
|
||||
expected_candidate = DATA.with_name(f'matrix-screen-controller.ota.{job}')
|
||||
if source != expected_release or candidate != expected_candidate or version != request['target_version']:
|
||||
return
|
||||
if os.geteuid() != 0 or os.uname().machine != 'aarch64':
|
||||
raise RuntimeError('component transaction requires AArch64 root')
|
||||
from app.ota.policy import check_upgrade
|
||||
from app.ota.versioning import SoftwareVersion
|
||||
check_upgrade(SoftwareVersion.parse(request['current_version']), SoftwareVersion.parse(version))
|
||||
if RUNTIME_GUARD.exists():
|
||||
raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复')
|
||||
bundle = source / 'system-dependencies/frpc'
|
||||
if not bundle.is_dir():
|
||||
check_installed(source, version)
|
||||
return
|
||||
# Verify the pinned binary, not just a self-reported checksum file.
|
||||
from app.ota.policy import required_components
|
||||
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
|
||||
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
|
||||
raise RuntimeError('frpc payload differs from the registered dependency')
|
||||
user = account()
|
||||
for root in (DATA, candidate):
|
||||
if (root / JOURNAL).exists():
|
||||
raise RuntimeError('存在未完成组件事务,请先恢复')
|
||||
journal = DATA / JOURNAL
|
||||
journal.mkdir(parents=True, mode=0o700)
|
||||
record = {'job_id': job, 'version': version, 'old_version': request['current_version'],
|
||||
'accepted_at': request.get('accepted_at', ''),
|
||||
'previous_target': os.readlink(TARGET) if TARGET.is_symlink() else None,
|
||||
'permissions': permission_snapshot(DATA), 'files': {},
|
||||
'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0,
|
||||
'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0}
|
||||
try:
|
||||
for name, path in FILES.items():
|
||||
record['files'][name] = metadata(path) if path.exists() else None
|
||||
if path.exists():
|
||||
atomic(journal / name, path.read_bytes())
|
||||
atomic(journal / 'state.json', json.dumps(record).encode())
|
||||
shutil.copytree(journal, candidate / JOURNAL)
|
||||
# copytree itself is not durable across power loss.
|
||||
for path in (candidate / JOURNAL).iterdir():
|
||||
with path.open('rb') as handle:
|
||||
os.fsync(handle.fileno())
|
||||
sync_directory(candidate / JOURNAL)
|
||||
atomic(HELPER, Path(__file__).read_bytes(), 0o700)
|
||||
unit = ('[Unit]\nDescription=Recover interrupted OTA component transaction\n'
|
||||
'After=local-fs.target\nBefore=matrix-screen-controller.service\n'
|
||||
'[Service]\nType=oneshot\nExecStart=/usr/bin/python3 ' + str(HELPER) + ' recover\n'
|
||||
'[Install]\nWantedBy=multi-user.target\n')
|
||||
atomic(RECOVERY_UNIT, unit.encode(), 0o644)
|
||||
run(['systemctl', 'daemon-reload'])
|
||||
run(['systemctl', 'enable', RECOVERY_UNIT.name])
|
||||
run(['systemd-run', '--quiet', '--collect', '--unit=matrix-screen-component-watch',
|
||||
'/usr/bin/python3', str(HELPER), 'watch'])
|
||||
protect_runtime()
|
||||
env = os.environ.copy()
|
||||
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
|
||||
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
|
||||
mirror_permissions(candidate)
|
||||
check_installed(source, version)
|
||||
print('FRP component transaction prepared; waiting for OTA health result')
|
||||
except BaseException:
|
||||
# The watcher handles subsequent worker failure. Restore immediately too,
|
||||
# so a failed migration never leaves new system files while rolling back.
|
||||
if (journal / 'state.json').is_file():
|
||||
restore_components(journal, record)
|
||||
else:
|
||||
shutil.rmtree(journal)
|
||||
raise
|
||||
|
||||
|
||||
def restore_components(journal: Path, record: dict):
|
||||
run(['systemctl', 'stop', FRP], check=False)
|
||||
for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
|
||||
path = FILES[name]
|
||||
item = record['files'][name]
|
||||
if item is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
atomic(path, (journal / name).read_bytes(), item['mode'])
|
||||
apply_metadata(path, item)
|
||||
restore_permissions(DATA, record['permissions'])
|
||||
run(['systemctl', 'daemon-reload'])
|
||||
# A previously absent unit cannot be disabled; avoid treating absence as error.
|
||||
if record['files']['frpc-unit'] is not None:
|
||||
run(['systemctl', 'enable' if record['enabled'] else 'disable', FRP])
|
||||
else:
|
||||
run(['systemctl', 'disable', FRP], check=False)
|
||||
link = Path('/etc/systemd/system/multi-user.target.wants') / FRP
|
||||
link.unlink(missing_ok=True)
|
||||
if record['active']:
|
||||
run(['systemctl', 'start', FRP])
|
||||
|
||||
|
||||
def locate_journal():
|
||||
if (DATA / JOURNAL / 'state.json').is_file():
|
||||
return DATA / JOURNAL
|
||||
candidates = list(DATA.parent.glob('matrix-screen-controller.rollback.*/ota/component-transaction/state.json'))
|
||||
if len(candidates) == 1:
|
||||
return candidates[0].parent
|
||||
if candidates:
|
||||
raise RuntimeError('multiple recovery journals; refusing ambiguous recovery')
|
||||
return None
|
||||
|
||||
|
||||
def committed(record: dict) -> bool:
|
||||
try:
|
||||
result = read(DATA / 'ota/state.json')['last_result']
|
||||
return (result['status'] == 'success' and result['target_version'] == record['version']
|
||||
and (TARGET / 'VERSION').read_text(encoding='utf-8').strip() == record['version']
|
||||
and result['installed_at'] >= record['accepted_at'])
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def recover_application(journal: Path, record: dict):
|
||||
job = record['job_id']
|
||||
release = RELEASES / f"{record['version']}-{job}"
|
||||
backup = DATA.with_name(f'matrix-screen-controller.rollback.{job}')
|
||||
displaced = RELEASES / f"{record['old_version']}-pre-ota-{job}"
|
||||
switched = TARGET.is_symlink() and TARGET.resolve() == release.resolve()
|
||||
moved = not TARGET.exists() and (displaced.exists() or record['previous_target'])
|
||||
if switched or moved or backup.exists():
|
||||
run(['systemctl', 'stop', SERVICE], check=False)
|
||||
if backup.exists():
|
||||
if DATA.exists():
|
||||
failed = DATA.with_name(f'matrix-screen-controller.failed.{job}')
|
||||
if failed.exists():
|
||||
raise RuntimeError('failed data recovery path already exists')
|
||||
DATA.rename(failed)
|
||||
backup.rename(DATA)
|
||||
shutil.rmtree(failed)
|
||||
else:
|
||||
backup.rename(DATA)
|
||||
journal = DATA / JOURNAL
|
||||
if switched:
|
||||
TARGET.unlink()
|
||||
if switched or moved:
|
||||
if record['previous_target']:
|
||||
os.symlink(record['previous_target'], TARGET, target_is_directory=True)
|
||||
elif displaced.exists():
|
||||
displaced.rename(TARGET)
|
||||
else:
|
||||
raise RuntimeError('old application is missing; retaining recovery journal')
|
||||
# Legacy rollback restores only its main unit, not its OTA unit.
|
||||
for name in ('app-unit', 'ota-unit'):
|
||||
if record['files'][name]:
|
||||
atomic(FILES[name], (journal / name).read_bytes(), record['files'][name]['mode'])
|
||||
apply_metadata(FILES[name], record['files'][name])
|
||||
return journal
|
||||
|
||||
|
||||
def finish(*, boot=False):
|
||||
journal = locate_journal()
|
||||
if journal is None:
|
||||
return
|
||||
record = read(journal / 'state.json')
|
||||
success = committed(record)
|
||||
if not success:
|
||||
journal = recover_application(journal, record)
|
||||
restore_components(journal, record)
|
||||
runtime_log = RUNTIME / 'ota-worker.log'
|
||||
try:
|
||||
if runtime_log.is_file():
|
||||
body = runtime_log.read_bytes()[-1024 * 1024:]
|
||||
body = body.decode('utf-8', errors='replace').encode('utf-8')[-1024 * 1024:]
|
||||
if body:
|
||||
atomic(DATA / 'ota/last-failure.log', body)
|
||||
except OSError:
|
||||
# Diagnostic storage must never prevent recovery or its cleanup.
|
||||
pass
|
||||
result = {'schema_version': 1, 'last_result': {
|
||||
'status': 'failed', 'target_version': record['version'],
|
||||
'installed_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'packaged_at': '', 'release_notes': '', 'error': 'OTA 未完成,组件与应用已恢复'}}
|
||||
# Preserve a more detailed failure result produced by the worker.
|
||||
try:
|
||||
existing = read(DATA / 'ota/state.json')['last_result']
|
||||
except (OSError, ValueError, KeyError):
|
||||
existing = {}
|
||||
if existing.get('status') != 'failed' or existing.get('target_version') != record['version']:
|
||||
atomic(DATA / 'ota/state.json', json.dumps(result).encode())
|
||||
status_path = RUNTIME / 'ota-status.json'
|
||||
try:
|
||||
status = read(status_path)
|
||||
except (OSError, ValueError):
|
||||
status = {}
|
||||
if status.get('job', {}).get('id') == record['job_id'] and status.get('job', {}).get('phase') != 'failed':
|
||||
status['active'] = False
|
||||
status['job'].update(phase='failed', percent=0, message='更新中断,已恢复原版本',
|
||||
error='组件事务已恢复', finished_at=result['last_result']['installed_at'])
|
||||
atomic(status_path, json.dumps(status).encode())
|
||||
run(['systemctl', 'daemon-reload'])
|
||||
if not boot:
|
||||
run(['systemctl', 'start', '--no-block', SERVICE])
|
||||
job = record['job_id']
|
||||
# Finish cleanup even if the old worker died after persisting its success.
|
||||
release = RELEASES / f"{record['version']}-{job}"
|
||||
candidate = DATA.with_name(f'matrix-screen-controller.ota.{job}')
|
||||
backup = DATA.with_name(f'matrix-screen-controller.rollback.{job}')
|
||||
if success:
|
||||
old_release = RELEASES / f"{record['old_version']}-pre-ota-{job}"
|
||||
if record['previous_target']:
|
||||
previous = Path(record['previous_target'])
|
||||
previous = previous if previous.is_absolute() else TARGET.parent / previous
|
||||
if previous.parent == RELEASES and previous != release:
|
||||
old_release = previous
|
||||
if old_release.exists() and old_release != TARGET.resolve():
|
||||
shutil.rmtree(old_release)
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
elif release.exists() and release != TARGET.resolve():
|
||||
shutil.rmtree(release)
|
||||
if candidate.exists():
|
||||
shutil.rmtree(candidate)
|
||||
work = WORK_ROOT / f'work.{job}'
|
||||
if work.exists():
|
||||
shutil.rmtree(work)
|
||||
request_path = RUNTIME / 'ota-request.json'
|
||||
try:
|
||||
request = read(request_path)
|
||||
except (OSError, ValueError):
|
||||
request = {}
|
||||
if request.get('job_id') == job:
|
||||
package = Path(request.get('package_path', ''))
|
||||
if package.parent == WORK_ROOT / 'uploads' and package.name == job + '.ota':
|
||||
package.unlink(missing_ok=True)
|
||||
request_path.unlink(missing_ok=True)
|
||||
release_runtime_guard()
|
||||
for root in (DATA, DATA.with_name(f'matrix-screen-controller.ota.{job}'),
|
||||
DATA.with_name(f'matrix-screen-controller.rollback.{job}')):
|
||||
if (root / JOURNAL).exists():
|
||||
shutil.rmtree(root / JOURNAL)
|
||||
# Installer payloads are transaction inputs, not persistent application data.
|
||||
if success and (release / 'system-dependencies').is_dir():
|
||||
shutil.rmtree(release / 'system-dependencies')
|
||||
run(['systemctl', 'disable', RECOVERY_UNIT.name])
|
||||
RECOVERY_UNIT.unlink(missing_ok=True)
|
||||
run(['systemctl', 'daemon-reload'])
|
||||
HELPER.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('mode', choices=['watch', 'recover'])
|
||||
args = parser.parse_args()
|
||||
if os.geteuid() != 0:
|
||||
raise RuntimeError('root required')
|
||||
if args.mode == 'watch':
|
||||
while True:
|
||||
state = run(['systemctl', 'show', WORKER, '--property=ActiveState', '--value']).stdout.strip()
|
||||
if state not in (b'active', b'activating', b'deactivating'):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
finish(boot=args.mode == 'recover')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,482 @@
|
||||
#!/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
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file
|
||||
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")
|
||||
OTA_WORK_ROOT = Path("/opt/matrix-screen-controller-ota")
|
||||
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 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 = OTA_WORK_ROOT / f"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.diagnostics = DiagnosticLog(RUNTIME_ROOT / "ota-worker.log")
|
||||
self.diagnostics.reset()
|
||||
self.diagnostics.section("更新任务")
|
||||
self.diagnostics.write(
|
||||
f"job_id={self.job_id} current_version={self.current_version} "
|
||||
f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}"
|
||||
)
|
||||
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.diagnostics.write(f"phase={phase} percent={percent} message={message}")
|
||||
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 run(
|
||||
self,
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
check: bool = True,
|
||||
failure_label: str | None = None,
|
||||
log_output: bool = True,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
return self.diagnostics.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
check=check,
|
||||
failure_label=failure_label,
|
||||
log_output=log_output,
|
||||
)
|
||||
|
||||
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)
|
||||
test_root = self.work_root / "test-root"
|
||||
test_tmp = self.work_root / "test-tmp"
|
||||
test_root.mkdir(parents=True, mode=0o700)
|
||||
test_tmp.mkdir(parents=True, mode=0o700)
|
||||
self.diagnostics.host_summary(test_root=test_root)
|
||||
self.diagnostics.section("更新包校验")
|
||||
self.diagnostics.write(f"package={self.package_path.name} sha256={sha256_file(self.package_path)}")
|
||||
package = inspect_package(self.package_path, current_version=self.current_version)
|
||||
self.diagnostics.write(
|
||||
f"package_valid=true target_version={package.target_version} expanded_bytes={package.expanded_bytes}"
|
||||
)
|
||||
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"
|
||||
frpc_bundle = software / "system-dependencies/frpc"
|
||||
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, "正在校验离线依赖并创建运行环境")
|
||||
self.run(["sha256sum", "-c", "SHA256SUMS"], cwd=wheelhouse, failure_label="离线 wheel 摘要校验")
|
||||
if frpc_bundle.is_dir():
|
||||
self.run(["sha256sum", "-c", "SHA256SUMS"], cwd=frpc_bundle, failure_label="frpc 系统依赖摘要校验")
|
||||
shutil.move(str(software), self.release)
|
||||
self.run(["python3", "--version"], failure_label="系统 Python 版本检查")
|
||||
self.run(["python3", "-m", "venv", str(self.release / ".venv")], failure_label="创建虚拟环境")
|
||||
self.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"),
|
||||
], failure_label="安装离线依赖")
|
||||
self.run([str(self.release / ".venv/bin/python"), "-m", "pip", "--version"], failure_label="pip 版本检查")
|
||||
self.run([str(self.release / ".venv/bin/python"), "-m", "pytest", "--version"], failure_label="pytest 版本检查")
|
||||
|
||||
self.update("building", 42, "正在编译并测试屏幕驱动")
|
||||
self.run(
|
||||
["make", "-C", str(self.release / "app/display/native"), "clean", "all", "test"],
|
||||
failure_label="原生驱动编译与测试",
|
||||
)
|
||||
self.run(
|
||||
[str(self.release / ".venv/bin/python"), "-m", "compileall", "-q", str(self.release / "app")],
|
||||
failure_label="Python 静态编译",
|
||||
)
|
||||
|
||||
self.update("testing", 55, "正在运行新版本自动化测试")
|
||||
test_env = os.environ.copy()
|
||||
test_env.update(
|
||||
MATRIX_DRIVER="mock",
|
||||
MATRIX_TEST_ROOT=str(test_root),
|
||||
MATRIX_DATA_DIR=str(test_root / "data"),
|
||||
MATRIX_RUNTIME_DIR=str(test_root / "runtime"),
|
||||
MATRIX_SOURCE_ONLY_UPDATE_TESTS="1",
|
||||
TMPDIR=str(test_tmp),
|
||||
TEMP=str(test_tmp),
|
||||
TMP=str(test_tmp),
|
||||
)
|
||||
self.run([
|
||||
str(self.release / ".venv/bin/python"), "-m", "pytest",
|
||||
"-p", "no:cacheprovider", "-q", f"--basetemp={self.work_root / 'pytest'}",
|
||||
"--tb=short", "--disable-warnings",
|
||||
], cwd=self.release, env=test_env, failure_label="新版本 pytest")
|
||||
self.run(
|
||||
[str(self.release / ".venv/bin/python"), str(self.release / "scripts/dedicated_host.py"), "check"],
|
||||
failure_label="专用主机检查",
|
||||
)
|
||||
|
||||
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)
|
||||
self.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, failure_label="用户数据副本迁移")
|
||||
|
||||
def switch(self) -> None:
|
||||
self.update("switching", 78, "正在切换到新版本")
|
||||
shutil.copy2(UNIT_PATH, self.unit_backup)
|
||||
self.run(["systemctl", "stop", SERVICE], failure_label="停止旧服务")
|
||||
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"))
|
||||
# prepare_data_root owns the component transaction, including for legacy
|
||||
# workers. Its independent guardian commits after our final health result.
|
||||
self.run(["systemctl", "daemon-reload"], failure_label="重载 systemd unit")
|
||||
self.stop_visual()
|
||||
self.run(["systemctl", "start", SERVICE], failure_label="启动新服务")
|
||||
|
||||
def verify(self) -> None:
|
||||
self.update("verifying", 90, "正在验证新版本和真实屏幕驱动")
|
||||
deadline = time.monotonic() + 45
|
||||
last_error = "service did not respond"
|
||||
attempt = 0
|
||||
while time.monotonic() < deadline:
|
||||
attempt += 1
|
||||
try:
|
||||
response = self.run(
|
||||
[
|
||||
"curl", "--fail", "--silent", "--max-time", "5",
|
||||
"http://127.0.0.1:8080/api/status",
|
||||
],
|
||||
check=False,
|
||||
failure_label=f"健康检查第 {attempt} 次",
|
||||
log_output=False,
|
||||
)
|
||||
if response.returncode != 0:
|
||||
raise UpdateFailed(f"health endpoint returned exit code {response.returncode}")
|
||||
status = json.loads(response.stdout.decode("utf-8", errors="strict"))
|
||||
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")
|
||||
self.diagnostics.write(
|
||||
"health_summary="
|
||||
f"version:{status.get('service', {}).get('software_version')} "
|
||||
f"driver:{screen.get('driver')} mapping:{screen.get('hardware_mapping')} "
|
||||
f"oe_backend:{driver.get('oe_timing_backend')} oe_pulse_faults:{driver.get('oe_pulse_faults')}"
|
||||
)
|
||||
self.diagnostics.write(f"health_check_passed=true attempts={attempt}")
|
||||
return
|
||||
except (UpdateFailed, OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
last_error = str(exc)
|
||||
self.diagnostics.write(f"health_check_attempt={attempt} failed={exc}")
|
||||
time.sleep(1)
|
||||
raise UpdateFailed(f"new release health check timed out: {last_error}")
|
||||
|
||||
def succeed(self) -> None:
|
||||
self.diagnostics.section("更新成功")
|
||||
self.diagnostics.write("health_check=passed rollback_required=false")
|
||||
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,
|
||||
}
|
||||
clear_failure_log(DATA_ROOT)
|
||||
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)
|
||||
self.diagnostics.path.unlink(missing_ok=True)
|
||||
|
||||
def rollback(self, error: BaseException) -> None:
|
||||
self.diagnostics.section("失败与回滚")
|
||||
self.diagnostics.write(f"failure={error.__class__.__name__}: {error}")
|
||||
self.diagnostics.write(
|
||||
"traceback_short:\n" + "".join(traceback.format_exception(error, limit=8)).rstrip()
|
||||
)
|
||||
rollback_errors: list[str] = []
|
||||
|
||||
def attempt(label: str, action) -> None:
|
||||
try:
|
||||
action()
|
||||
self.diagnostics.write(f"rollback_step={label} result=ok")
|
||||
except BaseException as rollback_error:
|
||||
detail = f"{label}: {rollback_error.__class__.__name__}: {rollback_error}"
|
||||
rollback_errors.append(detail)
|
||||
self.diagnostics.write(f"rollback_step={label} result=failed detail={detail}")
|
||||
|
||||
attempt("停止独立显示进程", self.stop_visual)
|
||||
if self.program_swapped or self.data_swapped:
|
||||
attempt(
|
||||
"停止待回滚服务",
|
||||
lambda: self.run(["systemctl", "stop", SERVICE], check=False, failure_label="回滚停止服务"),
|
||||
)
|
||||
if self.data_swapped:
|
||||
failed_data = DATA_ROOT.with_name(f"matrix-screen-controller.failed.{self.job_id}")
|
||||
if DATA_ROOT.exists():
|
||||
attempt("移开失败数据", lambda: DATA_ROOT.rename(failed_data))
|
||||
if self.data_backup.exists():
|
||||
attempt("恢复原数据", lambda: self.data_backup.rename(DATA_ROOT))
|
||||
attempt("清理失败数据", lambda: shutil.rmtree(failed_data, ignore_errors=True))
|
||||
if self.program_swapped:
|
||||
if TARGET.is_symlink():
|
||||
attempt("移除失败版本链接", TARGET.unlink)
|
||||
if self.previous_directory is not None and self.previous_directory.exists():
|
||||
attempt("恢复原程序目录", lambda: self.previous_directory.rename(TARGET))
|
||||
elif self.previous_target is not None:
|
||||
attempt(
|
||||
"恢复原程序链接",
|
||||
lambda: os.symlink(self.previous_target, TARGET, target_is_directory=True),
|
||||
)
|
||||
if self.unit_backup.exists():
|
||||
attempt("恢复原 systemd unit", lambda: shutil.copy2(self.unit_backup, UNIT_PATH))
|
||||
attempt(
|
||||
"回滚后重载 systemd",
|
||||
lambda: self.run(["systemctl", "daemon-reload"], check=False, failure_label="回滚重载 systemd"),
|
||||
)
|
||||
if self.program_swapped or self.data_swapped:
|
||||
attempt(
|
||||
"启动原服务",
|
||||
lambda: self.run(["systemctl", "start", SERVICE], check=False, failure_label="回滚启动原服务"),
|
||||
)
|
||||
attempt("清理失败程序候选", lambda: shutil.rmtree(self.release, ignore_errors=True))
|
||||
attempt("清理数据候选", lambda: shutil.rmtree(self.data_candidate, ignore_errors=True))
|
||||
recovery = "回滚完成" if not rollback_errors else "回滚存在错误:" + ";".join(rollback_errors)
|
||||
self.diagnostics.write(f"final_recovery_state={recovery}")
|
||||
brief_error = str(error).splitlines()[0][:1000] or error.__class__.__name__
|
||||
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": brief_error,
|
||||
}
|
||||
try:
|
||||
self.diagnostics.persist(DATA_ROOT)
|
||||
self.diagnostics.write("persistent_failure_log=written")
|
||||
except OSError as persist_error:
|
||||
self.diagnostics.write(
|
||||
f"persistent_failure_log=failed detail={persist_error.__class__.__name__}: {persist_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=brief_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)
|
||||
self.diagnostics.path.unlink(missing_ok=True)
|
||||
|
||||
def start_visual(self) -> None:
|
||||
self.diagnostics.section("启动独立更新显示")
|
||||
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")
|
||||
self.diagnostics.write("ota_visual_process=running")
|
||||
|
||||
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
|
||||
self.diagnostics.write("ota_visual_process=stopped")
|
||||
|
||||
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,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run every registered persistent-data migration against an isolated data root."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from app.animations.store import AnimationStore
|
||||
from app.config.store import ConfigStore
|
||||
from app.fonts.store import FontCatalog
|
||||
from app.library_order import LibraryOrderStore
|
||||
from app.network.store import WifiConfigStore
|
||||
from app.templates.store import TemplateStore
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data-root", required=True)
|
||||
parser.add_argument("--runtime-root", required=True)
|
||||
args = parser.parse_args()
|
||||
data_root = Path(args.data_root).resolve()
|
||||
runtime_root = Path(args.runtime_root).resolve()
|
||||
os.environ["MATRIX_DATA_DIR"] = str(data_root)
|
||||
os.environ["MATRIX_RUNTIME_DIR"] = str(runtime_root)
|
||||
|
||||
config = ConfigStore()
|
||||
fonts = FontCatalog(config.data_dir)
|
||||
TemplateStore(config.data_dir, font_resolver=fonts.resolve)
|
||||
AnimationStore(config.data_dir, font_resolver=fonts.resolve)
|
||||
LibraryOrderStore(config.data_dir)
|
||||
WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="ota-preflight")
|
||||
from scripts.ota_components import begin
|
||||
begin(Path(__file__).resolve().parents[1], data_root)
|
||||
print(f"Persistent data prepared successfully: schema={config.path.name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Promote an exact, real-card-validated image candidate without rebuilding it."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
from app.ota.versioning import SoftwareVersion, read_software_version
|
||||
from scripts.build_sd_image import build_bundle, sha256_file
|
||||
from scripts.fat16_image import Fat16Image
|
||||
from scripts.image_config import read_config_file
|
||||
|
||||
|
||||
REPORT_FIELDS = {
|
||||
"schema_version", "artifact_type", "image_sha256", "software_version", "status",
|
||||
"firstboot_completed", "automatic_reboot_passed", "default_wifi_connected",
|
||||
"ssh_password_login_passed", "sudo_password_passed", "root_login_rejected",
|
||||
"networkmanager_passed", "controller_service_passed", "kernel_health_passed",
|
||||
"h618_mapping_passed", "web_ui_passed", "plaintext_config_removed",
|
||||
"machine_identity_regenerated", "second_reboot_passed", "validated_at",
|
||||
}
|
||||
PASS_FIELDS = REPORT_FIELDS - {
|
||||
"schema_version", "artifact_type", "image_sha256", "software_version", "status", "validated_at"
|
||||
}
|
||||
|
||||
|
||||
def _validate_report(path: Path, digest: str, version: SoftwareVersion) -> dict:
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(report, dict) or set(report) != REPORT_FIELDS:
|
||||
raise ValueError("image validation report fields are invalid")
|
||||
try:
|
||||
validated_at = datetime.fromisoformat(report.get("validated_at", ""))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("image validation timestamp is invalid") from exc
|
||||
if (
|
||||
report["schema_version"] != 1
|
||||
or report["artifact_type"] != "image"
|
||||
or report["image_sha256"] != digest
|
||||
or report["software_version"] != str(version)
|
||||
or report["status"] != "success"
|
||||
or validated_at.tzinfo is None
|
||||
or any(report[name] is not True for name in PASS_FIELDS)
|
||||
):
|
||||
raise ValueError("candidate lacks matching successful real-card validation")
|
||||
return report
|
||||
|
||||
|
||||
def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path:
|
||||
from scripts.export_release import (
|
||||
_atomic_bytes, _atomic_json, _copy_source, _history, _image_readme, next_patch,
|
||||
)
|
||||
|
||||
source = Path(source).resolve()
|
||||
project = source.parent
|
||||
candidate = Path(candidate).resolve()
|
||||
validation = Path(validation).resolve()
|
||||
lock = project / ".release-export.lock"
|
||||
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.close(descriptor)
|
||||
archive: Path | None = None
|
||||
staged: Path | None = None
|
||||
final: Path | None = None
|
||||
published = False
|
||||
history_path = project / "发布记录.json"
|
||||
history_original = history_path.read_bytes()
|
||||
version_original = (source / "VERSION").read_bytes()
|
||||
try:
|
||||
if not candidate.is_dir():
|
||||
raise ValueError("validated image candidate directory is missing")
|
||||
manifest = json.loads((candidate / "manifest.json").read_text(encoding="utf-8"))
|
||||
version = SoftwareVersion.parse(manifest.get("software_version", ""))
|
||||
current = read_software_version(source)
|
||||
if version != next_patch(current):
|
||||
raise ValueError("validated image candidate must be the next patch version")
|
||||
artifact_name = f"matrix-screen-controller-{version}.img"
|
||||
if {path.name for path in candidate.iterdir()} != {
|
||||
"README.md", "manifest.json", artifact_name, f"{artifact_name}.sha256",
|
||||
}:
|
||||
raise ValueError("image candidate directory contents are not exact")
|
||||
artifact = candidate / artifact_name
|
||||
digest = sha256_file(artifact)
|
||||
if (
|
||||
manifest.get("artifact_type") != "image"
|
||||
or manifest.get("artifact") != artifact_name
|
||||
or manifest.get("image_sha256") != digest
|
||||
or manifest.get("image_bytes") != artifact.stat().st_size
|
||||
or manifest.get("notes") != notes.strip()
|
||||
or (candidate / f"{artifact_name}.sha256").read_bytes()
|
||||
!= f"{digest} {artifact_name}\n".encode("ascii")
|
||||
):
|
||||
raise ValueError("image candidate metadata does not match its artifact")
|
||||
with Fat16Image(artifact) as image:
|
||||
config_bytes = image.read_file("MSCCFG.BIN")
|
||||
config, _generation, _slot = read_config_file(config_bytes)
|
||||
if config["software_version"] != str(version):
|
||||
raise ValueError("image candidate configuration version is invalid")
|
||||
if manifest.get("config_sha256") != hashlib.sha256(config_bytes).hexdigest():
|
||||
raise ValueError("image candidate configuration digest is invalid")
|
||||
if (candidate / "README.md").read_text(encoding="utf-8") != _image_readme(version, manifest, notes, config):
|
||||
raise ValueError("image candidate README does not match its embedded defaults")
|
||||
_validate_report(validation, digest, version)
|
||||
|
||||
dependency_root = project / "发布更新相关" / "其他依赖"
|
||||
kernel = dependency_root / "aarch64-kernel" / "6.1.31-matrix-axp313a1"
|
||||
with tempfile.TemporaryDirectory(prefix="matrix-image-promotion-") as temporary_text:
|
||||
temporary = Path(temporary_text)
|
||||
staged_source = temporary / "核桃派软件源代码"
|
||||
_copy_source(source, staged_source, version)
|
||||
expected_bundle = temporary / "MSCBOOT.TGZ"
|
||||
build_bundle(
|
||||
staged_source,
|
||||
dependency_root / "aarch64-py311",
|
||||
dependency_root / "debian12-aarch64",
|
||||
expected_bundle,
|
||||
dependency_root / "frp" / "0.71.0" / "linux-arm64",
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"bash", str(staged_source / "scripts" / "verify_image_bootstrap.sh"),
|
||||
str(artifact), str(staged_source / "systemd" / "matrix-image-firstboot.service"),
|
||||
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
|
||||
str(kernel), str(expected_bundle),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
history = _history(history_path)
|
||||
if any(record.get("version") == str(version) for record in history["releases"]):
|
||||
raise ValueError("validated image version is already registered")
|
||||
final = project / "发布更新相关" / "导出包" / str(version)
|
||||
if final.exists():
|
||||
raise ValueError("formal image release directory already exists")
|
||||
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
archive = project / "各种归档" / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_IMAGE{version}实卡验收_{uuid.uuid4().hex[:6]}"
|
||||
archive.mkdir(parents=True)
|
||||
shutil.copy2(validation, archive / "实机验收.json")
|
||||
staged = final.parent / f".{version}.{uuid.uuid4().hex}.publishing"
|
||||
shutil.copytree(candidate, staged)
|
||||
if sha256_file(staged / artifact_name) != digest:
|
||||
raise ValueError("candidate copy checksum mismatch")
|
||||
history["releases"].append({
|
||||
"version": str(version),
|
||||
"artifact_type": "image",
|
||||
"created_at": manifest["created_at"],
|
||||
"notes": notes.strip(),
|
||||
"artifact_path": f"发布更新相关/导出包/{version}/{artifact_name}",
|
||||
"artifact_sha256": digest,
|
||||
"validation_path": f"{archive.relative_to(project).as_posix()}/实机验收.json",
|
||||
"validated_at": stamp,
|
||||
})
|
||||
os.replace(staged, final)
|
||||
staged = None
|
||||
published = True
|
||||
_atomic_json(history_path, history)
|
||||
_atomic_bytes(source / "VERSION", f"{version}\n".encode("utf-8"))
|
||||
return final
|
||||
except BaseException:
|
||||
if staged is not None:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
if published and final is not None:
|
||||
shutil.rmtree(final, ignore_errors=True)
|
||||
_atomic_bytes(history_path, history_original)
|
||||
_atomic_bytes(source / "VERSION", version_original)
|
||||
if archive is not None:
|
||||
shutil.rmtree(archive, ignore_errors=True)
|
||||
raise
|
||||
finally:
|
||||
lock.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import pwd
|
||||
except ImportError: # pragma: no cover - allows Windows unit tests to import this module
|
||||
pwd = None # type: ignore[assignment]
|
||||
|
||||
from scripts.image_config import read_config_path
|
||||
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
SSH_POLICY_SOURCE = SOURCE_ROOT / "systemd" / "10-walnutpi-screen-hardening.conf"
|
||||
SSH_POLICY_TARGET = Path("/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf")
|
||||
SSH_RUNTIME_DIRECTORY = Path("/run/sshd")
|
||||
SUDOERS_TARGET = Path("/etc/sudoers.d/90-matrix-screen-controller-account")
|
||||
SUDOERS_MAIN = Path("/etc/sudoers")
|
||||
HOSTS_PATH = Path("/etc/hosts")
|
||||
SSH_EXPECTED = {
|
||||
"permitrootlogin": "no",
|
||||
"passwordauthentication": "yes",
|
||||
"kbdinteractiveauthentication": "no",
|
||||
"permitemptypasswords": "no",
|
||||
}
|
||||
|
||||
|
||||
def run(args: list[str], *, input_text: str | None = None) -> None:
|
||||
subprocess.run(args, input=input_text, text=True, check=True)
|
||||
|
||||
|
||||
def _nm_escape(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace(";", "\\;")
|
||||
|
||||
|
||||
def _rooted(root: Path, path: Path) -> Path:
|
||||
return root / path.relative_to("/")
|
||||
|
||||
|
||||
def _remove_temporary(path: Path) -> None:
|
||||
if os.name == "nt" and path.exists():
|
||||
path.chmod(0o600)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: str, mode: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
temporary.write_text(data, encoding="utf-8", newline="\n")
|
||||
temporary.chmod(mode)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
_remove_temporary(temporary)
|
||||
|
||||
|
||||
def _sanitize_vendor_sudoers(data: str) -> str:
|
||||
forbidden = (
|
||||
re.compile(r"^\s*Defaults\s+rootpw\s*(?:#.*)?$"),
|
||||
re.compile(r"^\s*pi\s+ALL\s*=\s*\(ALL\)\s+NOPASSWD\s*:\s*ALL\s*(?:#.*)?$"),
|
||||
)
|
||||
lines = [line for line in data.splitlines() if not any(pattern.fullmatch(line) for pattern in forbidden)]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _install_sudoers(path: Path, main_path: Path, username: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
main_temporary = main_path.with_name(f".{main_path.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
temporary.write_text(f"{username} ALL=(ALL:ALL) ALL\n", encoding="utf-8", newline="\n")
|
||||
temporary.chmod(0o440)
|
||||
main_temporary.write_text(
|
||||
_sanitize_vendor_sudoers(main_path.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
main_temporary.chmod(0o440)
|
||||
run(["visudo", "-cf", str(temporary)])
|
||||
run(["visudo", "-cf", str(main_temporary)])
|
||||
os.replace(main_temporary, main_path)
|
||||
os.replace(temporary, path)
|
||||
run(["visudo", "-c"])
|
||||
finally:
|
||||
_remove_temporary(temporary)
|
||||
_remove_temporary(main_temporary)
|
||||
|
||||
|
||||
def _configure_hostname_mapping(path: Path, hostname: str) -> None:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
replacement = f"127.0.1.1\t{hostname}"
|
||||
replaced = False
|
||||
for index, line in enumerate(lines):
|
||||
fields = line.split()
|
||||
if fields and fields[0] == "127.0.1.1":
|
||||
lines[index] = replacement
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
lines.append(replacement)
|
||||
_atomic_write(path, "\n".join(lines) + "\n", 0o644)
|
||||
|
||||
|
||||
def configure_network(config: dict) -> None:
|
||||
wifi = config["wifi"]
|
||||
ipv4 = config["ipv4"]
|
||||
connection_uuid = str(uuid.uuid4())
|
||||
lines = [
|
||||
"[connection]",
|
||||
"id=matrix-screen",
|
||||
f"uuid={connection_uuid}",
|
||||
"type=wifi",
|
||||
"interface-name=wlan0",
|
||||
"autoconnect=true",
|
||||
"",
|
||||
"[wifi]",
|
||||
"mode=infrastructure",
|
||||
f"ssid={_nm_escape(wifi['ssid'])}",
|
||||
"",
|
||||
"[wifi-security]",
|
||||
"key-mgmt=wpa-psk",
|
||||
f"psk={_nm_escape(wifi['password'])}",
|
||||
"",
|
||||
"[ipv4]",
|
||||
]
|
||||
if ipv4["mode"] == "dhcp":
|
||||
lines.extend(("method=auto", ""))
|
||||
else:
|
||||
dns = ";".join(ipv4["dns"]) + ";"
|
||||
lines.extend(
|
||||
(
|
||||
f"address1={ipv4['address']}/{ipv4['prefix']},{ipv4['gateway']}",
|
||||
f"dns={dns}",
|
||||
"method=manual",
|
||||
"",
|
||||
)
|
||||
)
|
||||
lines.extend(("[ipv6]", "method=disabled", ""))
|
||||
root = Path("/etc/NetworkManager/system-connections")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for candidate in root.glob("*.nmconnection"):
|
||||
candidate.unlink()
|
||||
target = root / "matrix-screen.nmconnection"
|
||||
target.write_text("\n".join(lines), encoding="utf-8", newline="\n")
|
||||
target.chmod(0o600)
|
||||
|
||||
|
||||
def configure_account(config: dict, *, root: Path = Path("/")) -> None:
|
||||
if pwd is None:
|
||||
raise RuntimeError("account provisioning requires the POSIX pwd module")
|
||||
account = config["account"]
|
||||
username = account["username"]
|
||||
try:
|
||||
pwd.getpwnam(username)
|
||||
except KeyError:
|
||||
pi = pwd.getpwnam("pi")
|
||||
groups = subprocess.check_output(["id", "-nG", "pi"], text=True).split()
|
||||
supplementary = ",".join(group for group in groups if group not in {"pi", username})
|
||||
args = ["useradd", "--create-home", "--shell", "/bin/bash"]
|
||||
if supplementary:
|
||||
args.extend(("--groups", supplementary))
|
||||
args.append(username)
|
||||
run(args)
|
||||
home = _rooted(root, Path(f"/home/{username}"))
|
||||
if Path(pi.pw_dir).is_dir() and not any(home.iterdir()):
|
||||
for source in Path(pi.pw_dir).iterdir():
|
||||
if source.name not in {".ssh", ".bash_history"}:
|
||||
destination = home / source.name
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, destination, symlinks=True)
|
||||
elif source.is_file():
|
||||
shutil.copy2(source, destination)
|
||||
run(["chown", "-R", f"{username}:{username}", str(home)])
|
||||
run(["chpasswd"], input_text=f"{username}:{account['password']}\n")
|
||||
run(["usermod", "--append", "--groups", "sudo", username])
|
||||
sudoers = _rooted(root, SUDOERS_TARGET)
|
||||
_install_sudoers(sudoers, _rooted(root, SUDOERS_MAIN), username)
|
||||
if username != "pi":
|
||||
run(["passwd", "--lock", "pi"])
|
||||
|
||||
|
||||
def reset_identity(*, root: Path = Path("/")) -> None:
|
||||
_configure_hostname_mapping(_rooted(root, HOSTS_PATH), "matrix-screen")
|
||||
run(["hostnamectl", "set-hostname", "matrix-screen"])
|
||||
run(["systemctl", "stop", "ssh.service"])
|
||||
for key in _rooted(root, Path("/etc/ssh")).glob("ssh_host_*"):
|
||||
if key.is_file():
|
||||
key.unlink()
|
||||
_rooted(root, Path("/etc/machine-id")).write_text("", encoding="ascii")
|
||||
dbus = _rooted(root, Path("/var/lib/dbus/machine-id"))
|
||||
if dbus.exists() or dbus.is_symlink():
|
||||
dbus.unlink()
|
||||
run(["systemd-machine-id-setup"])
|
||||
run(["ssh-keygen", "-A"])
|
||||
|
||||
|
||||
def configure_ssh(*, root: Path = Path("/")) -> None:
|
||||
policy = SSH_POLICY_SOURCE.read_text(encoding="utf-8")
|
||||
target = _rooted(root, SSH_POLICY_TARGET)
|
||||
_atomic_write(target, policy, 0o644)
|
||||
runtime = _rooted(root, SSH_RUNTIME_DIRECTORY)
|
||||
runtime.mkdir(parents=True, exist_ok=True)
|
||||
runtime.chmod(0o755)
|
||||
run(["/usr/sbin/sshd", "-t"])
|
||||
effective = subprocess.check_output(["/usr/sbin/sshd", "-T"], text=True)
|
||||
observed = {}
|
||||
for line in effective.splitlines():
|
||||
key, separator, value = line.partition(" ")
|
||||
if separator and key in SSH_EXPECTED:
|
||||
observed[key] = value.strip()
|
||||
if observed != SSH_EXPECTED:
|
||||
raise RuntimeError(f"effective SSH policy is invalid: {observed!r}")
|
||||
run(["systemctl", "unmask", "ssh.service"])
|
||||
run(["systemctl", "enable", "ssh.service"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=("network", "account", "identity", "ssh", "print-public"))
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
config = read_config_path(args.config)
|
||||
if args.command == "network":
|
||||
configure_network(config)
|
||||
elif args.command == "account":
|
||||
configure_account(config)
|
||||
elif args.command == "identity":
|
||||
reset_identity()
|
||||
elif args.command == "ssh":
|
||||
configure_ssh()
|
||||
else:
|
||||
public = {
|
||||
"software_version": config["software_version"],
|
||||
"username": config["account"]["username"],
|
||||
"ssid": config["wifi"]["ssid"],
|
||||
"ipv4": config["ipv4"],
|
||||
}
|
||||
print(json.dumps(public, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(SOURCE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from scripts.fat16_image import Fat16Image
|
||||
from scripts.image_config import PRODUCT_ID, read_config_file
|
||||
|
||||
|
||||
def reject_in_place_refresh(image_path: Path) -> None:
|
||||
image_path = Path(image_path).resolve()
|
||||
with Fat16Image(image_path) as image:
|
||||
config, _, _ = read_config_file(image.read_file("MSCCFG.BIN"))
|
||||
metadata = json.loads(image.read_file("MSCMETA.JSN").decode("utf-8"))
|
||||
if config.get("product") != PRODUCT_ID or metadata.get("product") != PRODUCT_ID:
|
||||
raise ValueError("image product does not match this project")
|
||||
raise ValueError(
|
||||
"in-place image payload refresh is retired; rebuild from the official baseline with "
|
||||
"export_release.py image --repair-current"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Reject unsafe in-place image refreshes and direct callers to the transactional exporter"
|
||||
)
|
||||
parser.add_argument("--image", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
reject_in_place_refresh(args.image)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RELEASE_TAG = "matrix-axp313a1"
|
||||
MARKER = "matrix-kernel-good"
|
||||
|
||||
|
||||
class BootScriptError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def render(source: str) -> str:
|
||||
if "matrix_kernel_candidate=1" in source:
|
||||
raise BootScriptError("boot script is already candidate-managed")
|
||||
anchor = (
|
||||
'if test "${docker_optimizations}" = "on"; then setenv bootargs '
|
||||
'"${bootargs} cgroup_enable=memory swapaccount=1"; fi\n'
|
||||
)
|
||||
if source.count(anchor) != 1:
|
||||
raise BootScriptError("bootargs anchor is missing or ambiguous")
|
||||
candidate = f'''
|
||||
|
||||
# Matrix AXP313A candidate: consume the marker before boot so a failed reboot falls back.
|
||||
setenv matrix_kernel_file "Image"
|
||||
setenv matrix_dtb_suffix ""
|
||||
if test -e ${{devtype}} ${{devnum}} ${{prefix}}{MARKER}; then
|
||||
if test -e ${{devtype}} ${{devnum}} ${{prefix}}Image-{RELEASE_TAG}; then
|
||||
fatrm ${{devtype}} ${{devnum}} ${{prefix}}{MARKER}
|
||||
setenv matrix_kernel_file "Image-{RELEASE_TAG}"
|
||||
setenv matrix_dtb_suffix "-{RELEASE_TAG}"
|
||||
setenv bootargs "${{bootargs}} matrix_kernel_candidate=1 panic=10"
|
||||
echo "Booting one-shot Matrix AXP313A candidate"
|
||||
fi
|
||||
fi
|
||||
'''
|
||||
replacements = {
|
||||
"${prefix}${fdtfile_emmc}.dtb": "${prefix}${fdtfile_emmc}${matrix_dtb_suffix}.dtb",
|
||||
"${prefix}${fdtfile}.dtb": "${prefix}${fdtfile}${matrix_dtb_suffix}.dtb",
|
||||
"${prefix}Image": "${prefix}${matrix_kernel_file}",
|
||||
}
|
||||
expected = {
|
||||
"${prefix}${fdtfile_emmc}.dtb": 1,
|
||||
"${prefix}${fdtfile}.dtb": 2,
|
||||
"${prefix}Image": 1,
|
||||
}
|
||||
for old, new in replacements.items():
|
||||
if source.count(old) != expected[old]:
|
||||
raise BootScriptError(f"boot path {old!r} is missing or ambiguous")
|
||||
source = source.replace(old, new)
|
||||
rendered = source.replace(anchor, anchor + candidate, 1)
|
||||
return rendered
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Render a one-shot dual-kernel WalnutPi boot.cmd")
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("destination", type=Path)
|
||||
args = parser.parse_args()
|
||||
source = args.source.read_text(encoding="utf-8")
|
||||
rendered = render(source)
|
||||
args.destination.write_text(rendered, encoding="utf-8", newline="\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Promote an explicitly authorized, device-validated OTA without rebuilding it."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
from app.ota.package import inspect_package, extract_payload, _source_file_allowed
|
||||
from app.ota.policy import read_policy, required_components, release_metadata, package_format
|
||||
from app.ota.versioning import read_software_version
|
||||
from app.ota.diagnostics import sha256_file
|
||||
|
||||
|
||||
def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path:
|
||||
from scripts.export_release import _history, _commit_repair
|
||||
project = source.parent
|
||||
lock = project / '.release-export.lock'
|
||||
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.close(fd)
|
||||
try:
|
||||
version = read_software_version(source)
|
||||
current = project / '发布更新相关' / 'OTA数据包' / str(version)
|
||||
name = f'matrix-screen-controller-{version}.ota'
|
||||
history_path = project / '发布记录.json'
|
||||
history = _history(history_path)
|
||||
matches = [(i, r) for i, r in enumerate(history['releases']) if r['version'] == str(version)]
|
||||
if len(matches) != 1 or matches[0][1]['artifact_type'] != 'ota':
|
||||
raise ValueError('current version must have exactly one OTA release record')
|
||||
index, previous = matches[0]
|
||||
original_sha = sha256_file(current / name)
|
||||
manifest = json.loads((current / 'manifest.json').read_text(encoding='utf-8'))
|
||||
if (previous['artifact_sha256'] != original_sha or manifest['artifact_sha256'] != original_sha
|
||||
or (current / (name + '.sha256')).read_text(encoding='ascii') != f'{original_sha} {name}\n'):
|
||||
raise ValueError('original OTA release metadata does not match its artifact')
|
||||
info = inspect_package(candidate)
|
||||
digest = sha256_file(candidate)
|
||||
report = json.loads(validation.read_text(encoding='utf-8'))
|
||||
if (info.target_version != version or report.get('package_sha256') != digest
|
||||
or report.get('installed_version') != str(version) or report.get('status') != 'success'
|
||||
or report.get('runtime_lifecycle_passed') is not True
|
||||
or report.get('user_data_preserved') is not True
|
||||
or report.get('frp_state_preserved') is not True
|
||||
or report.get('transaction_cleanup_passed') is not True):
|
||||
raise ValueError('candidate lacks matching successful real-device validation')
|
||||
if digest == original_sha:
|
||||
raise ValueError('repair candidate is identical to the current artifact')
|
||||
with tempfile.TemporaryDirectory(prefix='matrix-ota-repair-check-') as text:
|
||||
unpacked = Path(text) / 'unpacked'
|
||||
extract_payload(info, unpacked)
|
||||
software = unpacked / 'software'
|
||||
expected = {p.relative_to(source).as_posix() for p in source.rglob('*')
|
||||
if p.is_file() and _source_file_allowed(p, source)}
|
||||
actual = {p.relative_to(software).as_posix() for p in software.rglob('*')
|
||||
if p.is_file() and _source_file_allowed(p, software)}
|
||||
if actual != expected or any((software / n).read_bytes() != (source / n).read_bytes() for n in expected):
|
||||
raise ValueError('validated candidate differs from current source; validate a new candidate')
|
||||
for component, requirement in required_components(source, version).items():
|
||||
if version.patch == 0 and sha256_file(software / 'system-dependencies' / component / component) != requirement['sha256']:
|
||||
raise ValueError('candidate system dependency differs from policy')
|
||||
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds')
|
||||
archive = project / '各种归档' / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_OTA{version}修复前_{uuid.uuid4().hex[:6]}"
|
||||
archive.mkdir(parents=True)
|
||||
shutil.copytree(current, archive / '原安装包')
|
||||
shutil.copy2(history_path, archive / '发布记录_修复前.json')
|
||||
shutil.copy2(validation, archive / '实机验收.json')
|
||||
staged = current.parent / f'.{version}.{uuid.uuid4().hex}.repairing'
|
||||
staged.mkdir()
|
||||
try:
|
||||
shutil.copy2(candidate, staged / name)
|
||||
if sha256_file(staged / name) != digest:
|
||||
raise ValueError('candidate copy checksum mismatch')
|
||||
metadata = {**manifest, **release_metadata(version), 'format_version': package_format(version),
|
||||
'created_at': info.created_at, 'notes': notes, 'artifact_bytes': candidate.stat().st_size,
|
||||
'artifact_sha256': digest, 'repaired_at': stamp, 'replaces_sha256': original_sha}
|
||||
(staged / 'manifest.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
(staged / (name+'.sha256')).write_text(f'{digest} {name}\n', encoding='ascii', newline='\n')
|
||||
(staged / 'README.md').write_text(
|
||||
f'# OTA {version} 软件安装包(修复版)\n\n{notes}\n\n'
|
||||
f'- SHA-256:`{digest}`\n- 修复时间:`{stamp}`\n'
|
||||
'- 通过系统设置上传 `.ota`,无需解压。1.0.x 必须先安装本节点,再安装后续补丁。\n'
|
||||
'- 已通过真实 systemd 停启测试及测试设备 OTA;正式包与实机验收包字节一致。\n'
|
||||
'- 已安装 frp 时保留配置和启停状态,完整组件跳过,缺失或损坏时离线修复。\n'
|
||||
'- 本包修复停止旧服务时运行目录被删除的问题;仍为 1.1.0,不是 1.1.1。\n'
|
||||
f'- 原失败包与旧记录:`{archive.relative_to(project).as_posix()}`。\n', encoding='utf-8')
|
||||
repair = {'at': stamp, 'reason': notes, 'previous_sha256': original_sha,
|
||||
'archive_path': archive.relative_to(project).as_posix()}
|
||||
history['releases'][index] = {**previous, 'created_at': info.created_at, 'notes': notes,
|
||||
'artifact_sha256': digest, 'component_checkpoints': read_policy(source)['checkpoints'],
|
||||
'repairs': [*previous.get('repairs', []), repair]}
|
||||
leftover = _commit_repair(current, staged, history_path, history)
|
||||
if leftover:
|
||||
print(f'obsolete temporary backup requires cleanup: {leftover}')
|
||||
except BaseException:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise
|
||||
return current
|
||||
finally:
|
||||
lock.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit opt-in real-systemd regression; no production service is stopped."""
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0 or not Path('/run/systemd/system').is_dir():
|
||||
raise RuntimeError('this explicit integration check requires root and real systemd')
|
||||
spec = importlib.util.spec_from_file_location('runtime_guard', Path(__file__).with_name('ota_components.py'))
|
||||
component = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(component)
|
||||
token = 'matrix-runtime-test-' + uuid.uuid4().hex
|
||||
service = token + '.service'
|
||||
unit = Path('/run/systemd/system') / service
|
||||
directory = Path('/run') / token
|
||||
dropin = unit.with_name(service + '.d') / '90-matrix-ota-runtime.conf'
|
||||
component.SERVICE = service
|
||||
component.RUNTIME_GUARD = dropin
|
||||
def run(*args, check=True):
|
||||
return subprocess.run(['systemctl', *args], check=check, capture_output=True)
|
||||
def write_unit(command='/bin/true'):
|
||||
unit.write_text('[Unit]\nDescription=Isolated OTA RuntimeDirectory regression\n'
|
||||
'[Service]\nType=oneshot\nRemainAfterExit=yes\n'
|
||||
f'ExecStart={command}\nRuntimeDirectory={token}\nRuntimeDirectoryMode=0750\n'
|
||||
'RuntimeDirectoryPreserve=restart\n', encoding='utf-8')
|
||||
def markers():
|
||||
for name in ('ota-request.json', 'ota-status.json', 'ota-worker.log'):
|
||||
(directory / name).write_bytes(b'unchanged')
|
||||
def assert_markers():
|
||||
assert all((directory / n).read_bytes() == b'unchanged'
|
||||
for n in ('ota-request.json', 'ota-status.json', 'ota-worker.log'))
|
||||
try:
|
||||
write_unit()
|
||||
run('daemon-reload');run('start', service);markers()
|
||||
run('stop', service)
|
||||
assert not directory.exists(), 'negative control did not reproduce stop cleanup'
|
||||
print('PASS: real systemd Preserve=restart deletes runtime directory on explicit stop', flush=True)
|
||||
run('start', service);markers()
|
||||
component.protect_runtime()
|
||||
run('restart', service);assert_markers()
|
||||
run('stop', service);assert_markers()
|
||||
write_unit('/bin/false');run('daemon-reload')
|
||||
assert run('start', service, check=False).returncode != 0
|
||||
assert_markers()
|
||||
component.release_runtime_guard()
|
||||
assert not dropin.exists()
|
||||
assert run('show', service, '--property=RuntimeDirectoryPreserve', '--value').stdout.strip() == b'restart'
|
||||
print('PASS: real stop/restart/start-failure preserve OTA request/status/log; temporary protection cleaned', flush=True)
|
||||
print(json.dumps({'runtime_lifecycle_passed': True}))
|
||||
finally:
|
||||
run('stop', service, check=False)
|
||||
unit.unlink(missing_ok=True)
|
||||
if dropin.exists():
|
||||
dropin.unlink()
|
||||
try:
|
||||
dropin.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
run('daemon-reload');run('reset-failed', service, check=False)
|
||||
assert directory.parent == Path('/run') and directory.name.startswith('matrix-runtime-test-')
|
||||
if directory.exists():
|
||||
shutil.rmtree(directory)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "update_walnutpi.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -m)" != "aarch64" ]; then
|
||||
echo "this update is only for the WalnutPi AArch64 host" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd)
|
||||
WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/发布更新相关/其他依赖/aarch64-py311}
|
||||
FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64}
|
||||
MANIFEST=$WHEELHOUSE/SHA256SUMS
|
||||
TARGET=/opt/matrix-screen-controller
|
||||
DATA_ROOT=/var/lib/matrix-screen-controller
|
||||
UNIT=/etc/systemd/system/matrix-screen-controller.service
|
||||
OTA_UNIT=/etc/systemd/system/matrix-screen-controller-ota.service
|
||||
SERVICE=matrix-screen-controller.service
|
||||
SOFTWARE_VERSION=$(tr -d '\r\n' < "$SOURCE_ROOT/VERSION")
|
||||
RELEASE_ID=${RELEASE_ID:-$SOFTWARE_VERSION-$(date -u +%Y%m%dT%H%M%SZ)}
|
||||
RELEASES=/opt/matrix-screen-controller.releases
|
||||
|
||||
case "$RELEASE_ID" in
|
||||
*[!A-Za-z0-9._-]*|'')
|
||||
echo "RELEASE_ID may contain only letters, digits, dot, underscore, and dash" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
STAGING=/opt/matrix-screen-controller.stage.$RELEASE_ID
|
||||
BACKUP=/opt/matrix-screen-controller.rollback.$RELEASE_ID
|
||||
NEW_RELEASE=$RELEASES/$RELEASE_ID
|
||||
DATA_CANDIDATE=/var/lib/matrix-screen-controller.update.$RELEASE_ID
|
||||
DATA_BACKUP=/var/lib/matrix-screen-controller.rollback.$RELEASE_ID
|
||||
MIGRATION_RUNTIME=/run/matrix-screen-controller-update.$RELEASE_ID.migration
|
||||
STATUS_FILE=/run/matrix-screen-controller-update.$RELEASE_ID.status
|
||||
TEST_DATA=/run/matrix-screen-controller-update.$RELEASE_ID.test-data
|
||||
TEST_RUNTIME=/run/matrix-screen-controller-update.$RELEASE_ID.test-runtime
|
||||
FRPC_BACKUP=/run/matrix-screen-controller-update.$RELEASE_ID.frpc-backup
|
||||
SWAPPED=0
|
||||
DATA_BACKED_UP=0
|
||||
SUCCESS=0
|
||||
SERVICE_STOPPED=0
|
||||
FRPC_CHANGED=0
|
||||
FRPC_WAS_ACTIVE=0
|
||||
FRPC_WAS_ENABLED=0
|
||||
PREVIOUS_LINK=
|
||||
|
||||
cleanup_paths() {
|
||||
for path in "$STAGING" "$DATA_CANDIDATE" "$MIGRATION_RUNTIME" "$STATUS_FILE" "$TEST_DATA" "$TEST_RUNTIME" "$FRPC_BACKUP"; do
|
||||
case "$path" in
|
||||
/opt/matrix-screen-controller.stage.$RELEASE_ID|/var/lib/matrix-screen-controller.update.$RELEASE_ID|/run/matrix-screen-controller-update.$RELEASE_ID.migration|/run/matrix-screen-controller-update.$RELEASE_ID.status|/run/matrix-screen-controller-update.$RELEASE_ID.test-data|/run/matrix-screen-controller-update.$RELEASE_ID.test-runtime|/run/matrix-screen-controller-update.$RELEASE_ID.frpc-backup)
|
||||
if [ -d "$path" ]; then rm -rf -- "$path"; else rm -f -- "$path"; fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
rollback() {
|
||||
if [ "$SUCCESS" -eq 1 ]; then
|
||||
return
|
||||
fi
|
||||
if [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then
|
||||
echo "new release failed; restoring previous program and persistent data" >&2
|
||||
fi
|
||||
if [ "$SERVICE_STOPPED" -eq 1 ] || [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then
|
||||
systemctl stop "$SERVICE" || true
|
||||
fi
|
||||
if [ "$DATA_BACKED_UP" -eq 1 ]; then
|
||||
FAILED_DATA=/var/lib/matrix-screen-controller.failed.$RELEASE_ID
|
||||
if [ -e "$DATA_ROOT" ] && [ ! -e "$FAILED_DATA" ]; then mv -- "$DATA_ROOT" "$FAILED_DATA"; fi
|
||||
if [ -d "$DATA_BACKUP" ]; then mv -- "$DATA_BACKUP" "$DATA_ROOT"; fi
|
||||
case "$FAILED_DATA" in
|
||||
/var/lib/matrix-screen-controller.failed.$RELEASE_ID)
|
||||
if [ -d "$FAILED_DATA" ]; then rm -rf -- "$FAILED_DATA"; fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [ "$FRPC_CHANGED" -eq 1 ]; then
|
||||
systemctl stop matrix-screen-frpc.service 2>/dev/null || true
|
||||
if [ -f "$FRPC_BACKUP/frpc" ]; then install -m 0755 "$FRPC_BACKUP/frpc" /usr/local/bin/frpc; else rm -f -- /usr/local/bin/frpc; fi
|
||||
if [ -f "$FRPC_BACKUP/unit" ]; then install -m 0644 "$FRPC_BACKUP/unit" /etc/systemd/system/matrix-screen-frpc.service; else rm -f -- /etc/systemd/system/matrix-screen-frpc.service; fi
|
||||
if [ -f "$FRPC_BACKUP/dropin" ]; then
|
||||
install -d -m 0755 /etc/systemd/system/matrix-screen-frpc.service.d
|
||||
install -m 0644 "$FRPC_BACKUP/dropin" /etc/systemd/system/matrix-screen-frpc.service.d/user.conf
|
||||
else
|
||||
rm -f -- /etc/systemd/system/matrix-screen-frpc.service.d/user.conf
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
if [ "$FRPC_WAS_ENABLED" -eq 1 ]; then systemctl enable matrix-screen-frpc.service >/dev/null; else systemctl disable matrix-screen-frpc.service >/dev/null 2>&1 || true; fi
|
||||
if [ "$FRPC_WAS_ACTIVE" -eq 1 ]; then systemctl start matrix-screen-frpc.service || true; fi
|
||||
fi
|
||||
if [ "$SWAPPED" -eq 1 ]; then
|
||||
if [ -L "$TARGET" ]; then
|
||||
rm -f -- "$TARGET"
|
||||
elif [ -d "$TARGET" ]; then
|
||||
FAILED=/opt/matrix-screen-controller.failed.$RELEASE_ID
|
||||
if [ ! -e "$FAILED" ]; then mv -- "$TARGET" "$FAILED"; fi
|
||||
fi
|
||||
if [ -d "$BACKUP" ]; then
|
||||
mv -- "$BACKUP" "$TARGET"
|
||||
elif [ -n "$PREVIOUS_LINK" ]; then
|
||||
ln -s -- "$PREVIOUS_LINK" "$TARGET"
|
||||
fi
|
||||
rm -rf -- "$NEW_RELEASE"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if [ "$SERVICE_STOPPED" -eq 1 ] || [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then
|
||||
systemctl start "$SERVICE" || true
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
code=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
rollback
|
||||
cleanup_paths
|
||||
exit "$code"
|
||||
}
|
||||
trap finish EXIT HUP INT TERM
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "offline wheel manifest missing: $MANIFEST" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$TARGET" ]; then
|
||||
echo "existing production target missing: $TARGET" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$DATA_ROOT" ]; then
|
||||
echo "existing persistent data root missing: $DATA_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$STAGING" ] || [ -e "$BACKUP" ] || [ -e "$NEW_RELEASE" ] || [ -e "$DATA_CANDIDATE" ] || [ -e "$DATA_BACKUP" ]; then
|
||||
echo "release staging or rollback path already exists for $RELEASE_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
(cd "$WHEELHOUSE" && sha256sum -c SHA256SUMS)
|
||||
mkdir -m 0750 -- "$STAGING"
|
||||
cp -a "$SOURCE_ROOT/." "$STAGING/"
|
||||
python3 -m venv "$STAGING/.venv"
|
||||
"$STAGING/.venv/bin/pip" install \
|
||||
--no-index \
|
||||
--find-links "$WHEELHOUSE" \
|
||||
-r "$STAGING/requirements-dev.txt"
|
||||
make -C "$STAGING/app/display/native" clean all test
|
||||
"$STAGING/.venv/bin/python" -m compileall -q "$STAGING/app"
|
||||
mkdir -p -- "$TEST_DATA" "$TEST_RUNTIME/tmp"
|
||||
(cd "$STAGING" && \
|
||||
MATRIX_DRIVER=mock \
|
||||
MATRIX_TEST_ROOT="$TEST_DATA" \
|
||||
MATRIX_DATA_DIR="$TEST_DATA" \
|
||||
MATRIX_RUNTIME_DIR="$TEST_RUNTIME" \
|
||||
MATRIX_SOURCE_ONLY_UPDATE_TESTS=1 \
|
||||
TMPDIR="$TEST_RUNTIME/tmp" \
|
||||
TEMP="$TEST_RUNTIME/tmp" \
|
||||
TMP="$TEST_RUNTIME/tmp" \
|
||||
"$STAGING/.venv/bin/python" -m pytest -p no:cacheprovider -q \
|
||||
--basetemp="$TEST_RUNTIME/pytest" --tb=short --disable-warnings)
|
||||
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check
|
||||
|
||||
systemctl stop "$SERVICE"
|
||||
SERVICE_STOPPED=1
|
||||
cp -a -- "$DATA_ROOT" "$DATA_CANDIDATE"
|
||||
PYTHONPATH="$STAGING" "$STAGING/.venv/bin/python" -m scripts.prepare_data_root \
|
||||
--data-root "$DATA_CANDIDATE" \
|
||||
--runtime-root "$MIGRATION_RUNTIME"
|
||||
mkdir -p -- "$RELEASES"
|
||||
if [ -L "$TARGET" ]; then
|
||||
PREVIOUS_LINK=$(readlink "$TARGET")
|
||||
rm -f -- "$TARGET"
|
||||
SWAPPED=1
|
||||
else
|
||||
mv -- "$TARGET" "$BACKUP"
|
||||
SWAPPED=1
|
||||
fi
|
||||
mv -- "$STAGING" "$NEW_RELEASE"
|
||||
ln -s -- "$NEW_RELEASE" "$TARGET"
|
||||
mv -- "$DATA_ROOT" "$DATA_BACKUP"
|
||||
DATA_BACKED_UP=1
|
||||
mv -- "$DATA_CANDIDATE" "$DATA_ROOT"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT"
|
||||
install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT"
|
||||
install -d -m 0700 "$FRPC_BACKUP"
|
||||
systemctl is-active --quiet matrix-screen-frpc.service 2>/dev/null && FRPC_WAS_ACTIVE=1 || true
|
||||
systemctl is-enabled --quiet matrix-screen-frpc.service 2>/dev/null && FRPC_WAS_ENABLED=1 || true
|
||||
if [ -f /usr/local/bin/frpc ]; then cp -p -- /usr/local/bin/frpc "$FRPC_BACKUP/frpc"; fi
|
||||
if [ -f /etc/systemd/system/matrix-screen-frpc.service ]; then cp -p -- /etc/systemd/system/matrix-screen-frpc.service "$FRPC_BACKUP/unit"; fi
|
||||
if [ -f /etc/systemd/system/matrix-screen-frpc.service.d/user.conf ]; then cp -p -- /etc/systemd/system/matrix-screen-frpc.service.d/user.conf "$FRPC_BACKUP/dropin"; fi
|
||||
FRPC_CHANGED=1
|
||||
FRPC_BUNDLE="$FRPC_BUNDLE" /bin/sh "$TARGET/scripts/install_frpc_system.sh"
|
||||
systemctl daemon-reload
|
||||
systemctl start "$SERVICE"
|
||||
|
||||
check_health() {
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 30 ]; do
|
||||
if curl --fail --silent http://127.0.0.1:8080/api/status >"$STATUS_FILE"; then
|
||||
if "$TARGET/.venv/bin/python" - "$STATUS_FILE" <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
status = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
screen = status.get("screen", {})
|
||||
driver = screen.get("driver_status") or {}
|
||||
expected_version = Path("/opt/matrix-screen-controller/VERSION").read_text(encoding="utf-8").strip()
|
||||
assert status.get("service", {}).get("software_version") == expected_version
|
||||
assert screen.get("driver") == "walnutpi-h618-hub75"
|
||||
assert screen.get("hardware_mapping") == "walnutpi-pi-bank-pwm-oe-v2"
|
||||
assert driver.get("oe_timing_backend") == "h618-pwm4"
|
||||
assert driver.get("oe_pulse_faults") == 0
|
||||
PY
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! check_health; then
|
||||
echo "new release did not pass the production health check" >&2
|
||||
exit 1
|
||||
fi
|
||||
systemctl restart "$SERVICE"
|
||||
if ! check_health; then
|
||||
echo "new release did not pass the restart health check" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUCCESS=1
|
||||
SWAPPED=0
|
||||
DATA_BACKED_UP=0
|
||||
SERVICE_STOPPED=0
|
||||
FRPC_CHANGED=0
|
||||
case "$BACKUP" in
|
||||
/opt/matrix-screen-controller.rollback.$RELEASE_ID)
|
||||
rm -rf -- "$BACKUP"
|
||||
;;
|
||||
esac
|
||||
case "$DATA_BACKUP" in
|
||||
/var/lib/matrix-screen-controller.rollback.$RELEASE_ID)
|
||||
rm -rf -- "$DATA_BACKUP"
|
||||
;;
|
||||
esac
|
||||
case "$PREVIOUS_LINK" in
|
||||
/opt/matrix-screen-controller.releases/*)
|
||||
if [ "$PREVIOUS_LINK" != "$NEW_RELEASE" ]; then rm -rf -- "$PREVIOUS_LINK"; fi
|
||||
;;
|
||||
esac
|
||||
systemctl --no-pager --full status "$SERVICE"
|
||||
echo "WalnutPi Matrix Screen Controller updated successfully: $RELEASE_ID"
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
printf '%s\n' 'verify_image_bootstrap.sh must run as root.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$#" -ne 5 ]; then
|
||||
printf '%s\n' 'usage: verify_image_bootstrap.sh IMAGE SERVICE_UNIT SSH_CONFIG KERNEL_DEPENDENCY APP_BUNDLE' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
IMAGE=$(readlink -f -- "$1")
|
||||
UNIT=$(readlink -f -- "$2")
|
||||
SSH_CONFIG=$(readlink -f -- "$3")
|
||||
KERNEL_DEPENDENCY=$(readlink -f -- "$4")
|
||||
APP_BUNDLE=$(readlink -f -- "$5")
|
||||
SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
[ -f "$IMAGE" ]
|
||||
[ -f "$UNIT" ]
|
||||
[ -f "$SSH_CONFIG" ]
|
||||
[ -d "$KERNEL_DEPENDENCY" ]
|
||||
[ -f "$APP_BUNDLE" ]
|
||||
[ -f "$KERNEL_DEPENDENCY/SHA256SUMS" ]
|
||||
[ -f "$KERNEL_DEPENDENCY/METADATA.json" ]
|
||||
|
||||
LOOP=
|
||||
MOUNT_DIR=$(mktemp -d /var/tmp/matrix-image-verify.XXXXXX)
|
||||
BOOT_MOUNT=$(mktemp -d /var/tmp/matrix-image-boot-verify.XXXXXX)
|
||||
cleanup() {
|
||||
if mountpoint -q "$BOOT_MOUNT"; then
|
||||
umount "$BOOT_MOUNT"
|
||||
fi
|
||||
if mountpoint -q "$MOUNT_DIR"; then
|
||||
umount "$MOUNT_DIR"
|
||||
fi
|
||||
if [ -n "$LOOP" ]; then
|
||||
losetup -d "$LOOP"
|
||||
fi
|
||||
rmdir "$MOUNT_DIR" 2>/dev/null || true
|
||||
rmdir "$BOOT_MOUNT" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
LOOP=$(losetup --find --show --partscan --read-only "$IMAGE")
|
||||
ROOT_PARTITION="${LOOP}p2"
|
||||
BOOT_PARTITION="${LOOP}p1"
|
||||
for _attempt in $(seq 1 20); do
|
||||
[ -b "$ROOT_PARTITION" ] && break
|
||||
sleep 0.25
|
||||
done
|
||||
[ -b "$ROOT_PARTITION" ]
|
||||
[ -b "$BOOT_PARTITION" ]
|
||||
mount -o ro,noload "$ROOT_PARTITION" "$MOUNT_DIR"
|
||||
mount -o ro "$BOOT_PARTITION" "$BOOT_MOUNT"
|
||||
|
||||
INSTALLED="$MOUNT_DIR/etc/systemd/system/matrix-image-firstboot.service"
|
||||
ENABLED="$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/matrix-image-firstboot.service"
|
||||
SSH_ENABLED="$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/ssh.service"
|
||||
SSH_INSTALLED="$MOUNT_DIR/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf"
|
||||
cmp -s "$UNIT" "$INSTALLED"
|
||||
cmp -s "$SSH_CONFIG" "$SSH_INSTALLED"
|
||||
[ -L "$ENABLED" ]
|
||||
[ "$(readlink "$ENABLED")" = '../matrix-image-firstboot.service' ]
|
||||
grep -Fxq 'ExecStart=/boot/MSCINIT' "$INSTALLED"
|
||||
grep -Eq '^Before=.*ssh\.service.*boot_script\.service' "$INSTALLED"
|
||||
[ -x "$MOUNT_DIR/usr/sbin/sshd" ]
|
||||
[ -x "$MOUNT_DIR/usr/bin/sudo" ]
|
||||
[ -x "$MOUNT_DIR/usr/sbin/visudo" ]
|
||||
[ -f "$MOUNT_DIR/lib/systemd/system/ssh.service" ]
|
||||
[ -L "$SSH_ENABLED" ]
|
||||
[ "$(basename "$(readlink "$SSH_ENABLED")")" = 'ssh.service' ]
|
||||
[ ! -L "$MOUNT_DIR/etc/systemd/system/ssh.service" ] || [ "$(readlink "$MOUNT_DIR/etc/systemd/system/ssh.service")" != '/dev/null' ]
|
||||
[ ! -s "$MOUNT_DIR/etc/machine-id" ]
|
||||
[ "$(readlink "$MOUNT_DIR/var/lib/dbus/machine-id")" = '/etc/machine-id' ]
|
||||
if find "$MOUNT_DIR/etc/ssh" -maxdepth 1 -name 'ssh_host_*' -print -quit 2>/dev/null | grep -q .; then
|
||||
printf '%s\n' 'export still contains an SSH host key' >&2
|
||||
exit 1
|
||||
fi
|
||||
if find "$MOUNT_DIR/etc/NetworkManager/system-connections" -mindepth 1 -maxdepth 1 -type f -print -quit 2>/dev/null | grep -q .; then
|
||||
printf '%s\n' 'export still contains a NetworkManager connection' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KERNEL_ARCHIVE=axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz
|
||||
KERNEL_TARGET=$MOUNT_DIR/opt/matrix-image-bootstrap/axp313a
|
||||
APP_TARGET=$MOUNT_DIR/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ
|
||||
PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/image_bootstrap_payload.py" verify \
|
||||
--root "$MOUNT_DIR" --bundle "$APP_BUNDLE" --dependency "$KERNEL_DEPENDENCY" >/dev/null
|
||||
[ ! -e "$BOOT_MOUNT/MSCBOOT.TGZ" ]
|
||||
python3 - "$BOOT_MOUNT/MSCMETA.JSN" "$KERNEL_DEPENDENCY/METADATA.json" \
|
||||
"$KERNEL_TARGET/$KERNEL_ARCHIVE" "$APP_TARGET" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
image = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
kernel = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
|
||||
artifact = Path(sys.argv[3])
|
||||
bundle = Path(sys.argv[4])
|
||||
assert image["format_version"] == 3
|
||||
assert image["bundle_path"] == "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ"
|
||||
assert image["bundle_bytes"] == bundle.stat().st_size
|
||||
assert image["kernel_release"] == kernel["kernel_release"]
|
||||
assert image["kernel_artifact"] == kernel["artifact"]
|
||||
assert image["kernel_artifact_bytes"] == kernel["artifact_bytes"] == artifact.stat().st_size
|
||||
assert image["kernel_artifact_sha256"] == kernel["artifact_sha256"]
|
||||
assert image["kernel_unpacked_file_bytes"] == kernel["unpacked_file_bytes"]
|
||||
digest = hashlib.sha256()
|
||||
with artifact.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
assert digest.hexdigest() == kernel["artifact_sha256"]
|
||||
digest = hashlib.sha256()
|
||||
with bundle.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
assert digest.hexdigest() == image["bundle_sha256"]
|
||||
assert image["boot_safety_bytes"] == 32 * 1024 * 1024
|
||||
assert image["boot_required_bytes"] > 0
|
||||
PY
|
||||
|
||||
printf '%s\n' 'rootfs bootstrap verified'
|
||||
Reference in New Issue
Block a user