Files
StarPilot/tools/ci/run_device_preflight.py
T
firestar5683 6d742eb343 CI
Add control trace and platform qualification tools that exercise explicit safety modes and vehicle coverage.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Kacper Rączy <gfw.kra@gmail.com>
Co-authored-by: YassineYousfi <yyousfi1@binghamton.edu>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>

Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>

Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 19:56:17 -05:00

208 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""Read-only parked-device prerequisites; never starts StarPilot or probes hardware."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import re
import stat
import subprocess
import sys
import tomllib
SOURCE = Path(__file__).resolve().parents[2]
UI = Path("openpilot/starpilot/ui")
MANIFESTS = ("bitmap-fonts.json", "sora-brand-font.json", "home-assets.json", "settings-assets.json",
"toggles-assets.json", "onroad-assets.json")
IMPORTS = ("pyray", "capnp", "numpy", "cv2", "tinygrad", "openpilot.cereal.messaging")
MAX_MANIFEST = 1 << 20
MAX_ASSET = 16 << 20
PROBE_TIMEOUT = 8
def _read_regular(path: Path, limit: int) -> bytes:
flags = os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(path, flags)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
raise ValueError("not a bounded regular file")
with os.fdopen(fd, "rb", closefd=False) as stream:
data = stream.read(limit + 1)
if len(data) != info.st_size or len(data) > limit:
raise ValueError("file changed or exceeds limit")
return data
finally:
os.close(fd)
def _check(status: str, detail: str) -> dict[str, str]:
return {"status": status, "detail": detail}
def _policy(source: Path) -> tuple[dict, str]:
launch = _read_regular(source / "launch_env.sh", MAX_MANIFEST).decode("utf-8")
project = tomllib.loads(_read_regular(source / "pyproject.toml", MAX_MANIFEST).decode("utf-8"))
version = re.findall(r'^[ \t]*export AGNOS_VERSION="([^"]+)"$', launch, re.MULTILINE)
policy = re.findall(r'^[ \t]*export AGNOS_UPDATE_POLICY="([^"]+)"$', launch, re.MULTILINE)
required_python = project["project"]["requires-python"]
if version != ["19.8.1"] or policy != ["retain"]:
raise ValueError("retained AGNOS source policy differs from reviewed trial")
if required_python != ">= 3.12.3, < 3.13":
raise ValueError("project Python requirement differs from reviewed trial")
return {"agnos_version": version[0], "update_policy": policy[0],
"requires_python": required_python}, hashlib.sha256(launch.encode()).hexdigest()
def _manifest_records(source: Path, name: str) -> list[dict]:
data = json.loads(_read_regular(source / UI / name, MAX_MANIFEST))
records = data["files"]
if not isinstance(records, list) or not records:
raise ValueError("empty asset manifest")
names = set()
for record in records:
filename = record["file"]
relative = Path(filename)
if (not isinstance(filename, str) or relative.is_absolute() or
any(part in ("", ".", "..") for part in relative.parts) or filename in names or
not isinstance(record["bytes"], int) or isinstance(record["bytes"], bool) or
not 0 <= record["bytes"] <= MAX_ASSET or
not re.fullmatch(r"[0-9a-f]{64}", record["sha256"])):
raise ValueError("invalid asset manifest entry")
names.add(filename)
return records
def _assets(source: Path, font_dir: Path | None) -> dict[str, dict]:
result = {}
for name in MANIFESTS:
try:
records = _manifest_records(source, name)
if name == "bitmap-fonts.json":
root = font_dir if font_dir is not None else source / UI / "assets/fonts"
elif name == "sora-brand-font.json":
root = source / UI / "assets/fonts"
else:
root = source / "openpilot/selfdrive/assets"
for record in records:
data = _read_regular(root / record["file"], record["bytes"])
if len(data) != record["bytes"] or hashlib.sha256(data).hexdigest() != record["sha256"]:
raise ValueError("missing or mismatched asset")
result[name] = _check("pass", f"{len(records)} manifest-pinned files match")
except (OSError, ValueError, KeyError, TypeError, UnicodeError, json.JSONDecodeError):
result[name] = _check("fail", "missing, malformed or mismatched reviewed asset")
return result
def _probe_import(source: Path, python: str, module: str) -> dict[str, str]:
# The child imports one named library only. It creates no service, camera,
# Params instance or model runner. Do not include child stderr in the report.
program = """import importlib, pathlib, sys
source = pathlib.Path(sys.argv[1]).resolve()
vendor = {'openpilot': source / 'openpilot',
'msgq': source / 'msgq_repo',
'opendbc': source / 'opendbc_repo',
'tinygrad': source / 'tinygrad_repo',
'jetlink': source / 'jetlink_repo'}
sys.path[:0] = [str(source / path) for path in
('', 'msgq_repo', 'opendbc_repo', 'rednose_repo', 'teleoprtc_repo', 'tinygrad_repo', 'jetlink_repo')]
try: importlib.import_module(sys.argv[2])
except Exception as error:
print(type(error).__name__)
sys.exit(1)
for name, loaded in tuple(sys.modules.items()):
prefix = name.split('.', 1)[0]
if prefix in vendor:
origin = getattr(loaded, '__file__', None)
if origin is None or not pathlib.Path(origin).resolve().is_relative_to(vendor[prefix]):
print('OriginMismatch')
sys.exit(1)
"""
env = {key: os.environ[key] for key in ("PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH") if key in os.environ}
env["PYTHONDONTWRITEBYTECODE"] = "1"
try:
run = subprocess.run([python, "-I", "-B", "-c", program, str(source), module],
cwd=source, env=env, capture_output=True, timeout=PROBE_TIMEOUT, check=False)
except subprocess.TimeoutExpired:
return _check("fail", "isolated import timed out")
except OSError:
return _check("fail", "Python executable unavailable")
if run.returncode == 0:
return _check("pass", "isolated import succeeded")
error = (run.stdout or b"").decode("ascii", errors="ignore").strip()
allowed = {"ImportError", "ModuleNotFoundError", "OSError", "RuntimeError", "ValueError", "SyntaxError", "OriginMismatch"}
return _check("fail", f"isolated import failed: {error}" if error in allowed else "isolated import failed")
def collect(source: Path = SOURCE, font_dir: Path | None = None, *,
python: str = sys.executable, os_version_file: Path = Path("/VERSION"),
system: str | None = None, machine: str | None = None,
python_version: tuple[int, int, int] | None = None,
probe=_probe_import) -> dict:
source = source.resolve()
system = platform.system() if system is None else system
machine = platform.machine() if machine is None else machine
python_version = sys.version_info[:3] if python_version is None else python_version
try:
revision = subprocess.run(["git", "rev-parse", "HEAD"], cwd=source, capture_output=True,
text=True, timeout=3, check=True).stdout.strip()
except (OSError, subprocess.SubprocessError):
revision = "unavailable"
checks: dict[str, dict] = {}
try:
policy, launch_sha = _policy(source)
checks["source_policy"] = _check("pass", "retained AGNOS and Python source policy match")
except (OSError, ValueError, KeyError, TypeError, UnicodeError):
policy, launch_sha = None, None
checks["source_policy"] = _check("fail", "retained AGNOS or Python source policy unavailable/mismatched")
checks["python_version"] = _check("pass" if (3, 12, 3) <= python_version < (3, 13, 0) else "fail",
".".join(map(str, python_version)))
if system == "Linux" and machine in ("aarch64", "arm64"):
try:
actual = _read_regular(os_version_file, 128).decode("ascii").strip()
checks["target_agnos"] = _check("pass" if policy and actual == policy["agnos_version"] else "fail",
"retained version matches" if policy and actual == policy["agnos_version"] else "version unavailable/mismatched")
except (OSError, ValueError, UnicodeError):
checks["target_agnos"] = _check("unavailable", "target OS marker unavailable")
else:
checks["target_agnos"] = _check("unavailable", "target-device OS check unavailable on this host")
checks.update(_assets(source, font_dir))
checks["runtime_imports"] = {module: probe(source, python, module) for module in IMPORTS}
statuses = [item["status"] for key, item in checks.items() if key != "runtime_imports"]
statuses.extend(item["status"] for item in checks["runtime_imports"].values())
prerequisites = "fail" if "fail" in statuses else "incomplete" if "unavailable" in statuses else "pass"
source_hashes = {}
for relative in ("launch_env.sh", "pyproject.toml", *(str(UI / name) for name in MANIFESTS)):
try:
source_hashes[relative] = hashlib.sha256(_read_regular(source / relative, MAX_MANIFEST)).hexdigest()
except (OSError, ValueError):
source_hashes[relative] = None
return {"schema_version": 1, "source_commit": revision, "launch_env_sha256": launch_sha,
"source_files_sha256": source_hashes,
"host": {"system": system, "machine": machine}, "checks": checks,
"prerequisites": prerequisites,
"device_qualification": "not_assessed",
"scope": "Read-only prerequisites; no graphics, camera, CAN, model inference, hardware timing or driving check"}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--font-dir", type=Path, help="complete existing font directory; never copied")
parser.add_argument("--json-output", type=Path, help="write a local JSON report")
args = parser.parse_args(argv)
report = collect(font_dir=args.font_dir)
encoded = json.dumps(report, indent=2) + "\n"
if args.json_output:
args.json_output.write_text(encoded)
else:
print(encoded, end="")
return {"pass": 0, "fail": 1, "incomplete": 2}[report["prerequisites"]]
if __name__ == "__main__":
raise SystemExit(main())