Files
StarPilot/openpilot/system/updated/tests/test_vendored_update.py
T
firestar5683 a82935f326 User interfaces and Android Auto
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: Shane Smiskol <shane@smiskol.com>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: pharmacomaniac <blittle65@gmail.com>

Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

273 lines
12 KiB
Python

"""Updater Git integration against temporary local repositories.
Native Params, logging, hardware, and consistency syncing are explicit test doubles.
Git fetch/checkout/reset/clean and finalization copies run against real temporary
repositories. No device paths, mounts, firmware calls, or network remotes are used.
"""
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType
from unittest.mock import Mock, patch
from openpilot.common.vendor_manifest import DEPENDENCIES
BRANCH = "test-vendored"
class MemoryParams:
def __init__(self):
self.values = {"UpdaterTargetBranch": BRANCH}
def get(self, key):
return self.values.get(key)
def put(self, key, value, **kwargs):
self.values[key] = value
put_bool = put
def load_updater():
"""Load unchanged updater code with explicit service boundaries substituted."""
services = {
"openpilot.common.params": {"Params": MemoryParams},
"openpilot.common.time_helpers": {"system_time_valid": Mock()},
"openpilot.common.markdown": {"parse_markdown": Mock()},
"openpilot.common.swaglog": {"cloudlog": Mock()},
"openpilot.selfdrive.selfdrived.alertmanager": {"set_offroad_alert": Mock()},
"openpilot.common.hardware": {"AGNOS": False, "HARDWARE": Mock()},
"openpilot.starpilot.drive_state.evidence": {"PhysicalSource": Mock()},
"openpilot.common.version": {"get_build_metadata": Mock()},
}
modules = {}
for name, attributes in services.items():
module = ModuleType(name)
module.__dict__.update(attributes)
modules[name] = module
path = Path(__file__).resolve().parents[1] / "updated.py"
spec = importlib.util.spec_from_file_location("updater_git_test", path)
assert spec is not None and spec.loader is not None
updated = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, modules):
spec.loader.exec_module(updated)
return updated
class TestVendoredUpdate(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.source = self.root / "source"
self.merged = self.root / "merged"
self.finalized = self.root / "finalized"
self.source.mkdir()
self.env_patch = patch.dict(os.environ, {
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull,
"GIT_AUTHOR_NAME": "Updater test", "GIT_COMMITTER_NAME": "Updater test",
"GIT_AUTHOR_EMAIL": "updater@example.invalid", "GIT_COMMITTER_EMAIL": "updater@example.invalid",
})
self.env_patch.start()
self.addCleanup(self.env_patch.stop)
self.git(self.source, "init", "-b", BRANCH)
for name in DEPENDENCIES:
folder = self.source / name
folder.mkdir()
(folder / "README.md").write_text(f"{name} tracked source\n")
self.manifest = {
"schema_version": 1,
"upstream": {"url": "https://github.com/commaai/openpilot.git", "commit": "1" * 40},
"dependencies": [
{"path": name, "url": f"https://github.com/commaai/{name}.git", "commit": "2" * 40, "tree": "3" * 40, "exclude": []}
for name in DEPENDENCIES
],
}
self.write_manifest()
self.initial_commit = self.commit_source("Initial vendored tree")
self.git(self.root, "clone", "--no-local", "--no-hardlinks", str(self.source), str(self.merged))
# Simulate inherited Git settings that would otherwise recurse on fetch/reset.
self.git(self.merged, "config", "submodule.recurse", "true")
self.git(self.merged, "config", "fetch.recurseSubmodules", "true")
self.updated = load_updater()
self.updated.OVERLAY_MERGED = str(self.merged)
self.updated.FINALIZED = str(self.finalized)
self.updated.BASEDIR = str(self.merged)
self.updated.set_consistent_flag = self.set_consistent_flag
self.updated.handle_agnos_update = Mock()
self.commands = []
real_run = self.updated.run
def bounded_run(command, cwd=None):
if cwd is None:
raise AssertionError("Every updater command must name its isolated working directory")
self.assertTrue(Path(cwd).resolve().is_relative_to(self.root.resolve()))
self.assertNotEqual(command[:2], ["git", "submodule"])
self.commands.append(command)
if command in (["git", "gc"], ["git", "lfs", "prune"]):
return ""
return real_run(command, cwd)
self.updated.run = bounded_run
self.updater = self.updated.Updater()
@staticmethod
def git(repo, *args):
return subprocess.check_output(["git", "-C", str(repo), *args], stderr=subprocess.STDOUT, text=True).strip()
def write_manifest(self):
(self.source / "upstream-sync.json").write_text(json.dumps(self.manifest))
def commit_source(self, message):
self.git(self.source, "add", "--all")
self.git(self.source, "commit", "-m", message)
return self.git(self.source, "rev-parse", "HEAD")
def set_consistent_flag(self, consistent):
marker = self.finalized / ".overlay_consistent"
if consistent:
marker.touch()
else:
marker.unlink(missing_ok=True)
def assert_rejected_before_checkout(self):
self.updated.AGNOS = True
with self.assertRaises(ValueError):
self.updater.fetch_update()
self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), self.initial_commit)
self.assertFalse(any(command[:2] == ["git", "checkout"] for command in self.commands))
self.assertFalse((self.finalized / ".overlay_consistent").exists())
self.updated.handle_agnos_update.assert_not_called()
self.assertFalse(self.updater.params.values["UpdateAvailable"])
def test_automatic_update_uses_fast_path_and_only_falls_back_on_incompatibility(self):
fast_update = importlib.import_module("openpilot.starpilot.software.fast_update")
for error in (None, self.updated.FastUpdateIncompatible("AGNOS changed"), self.updated.FastUpdateError("Tracked edits")):
with self.subTest(error=error), patch.object(fast_update, "fast_update", side_effect=error, return_value="reboot-requested"), \
patch.object(self.updater, "normal_update", return_value="reboot-requested") as normal:
if error is not None and not isinstance(error, self.updated.FastUpdateIncompatible):
with self.assertRaises(self.updated.FastUpdateError):
self.updater.fast_update(BRANCH, self.initial_commit, automatic=True)
else:
self.assertEqual(self.updater.fast_update(BRANCH, self.initial_commit, automatic=True), "reboot-requested")
self.assertEqual(normal.call_count, int(isinstance(error, self.updated.FastUpdateIncompatible)))
with patch.object(fast_update, "fast_update", side_effect=self.updated.FastUpdateIncompatible("AGNOS changed")), \
patch.object(self.updater, "normal_update") as normal:
with self.assertRaises(self.updated.FastUpdateIncompatible):
self.updater.fast_update(BRANCH, self.initial_commit)
normal.assert_not_called() # Explicit fast/rollback/version operations keep their contract.
def test_normal_fallback_stages_flashes_and_requests_reboot_with_fresh_parked_state(self):
installed = self.root / "installed"
self.git(self.root, "clone", "--no-local", str(self.merged), str(installed))
self.updated.BASEDIR = str(installed)
(self.source / "panda/README.md").write_text("New native revision")
target = self.commit_source("Full update")
self.updated.AGNOS = True
progress = Mock()
with patch.object(self.updated, "init_overlay"):
self.assertEqual(self.updater.normal_update(BRANCH, self.initial_commit, lambda: True, progress), "reboot-requested")
self.updated.handle_agnos_update.assert_called_once()
self.assertEqual(self.git(installed, "rev-parse", "HEAD"), self.initial_commit)
self.assertEqual(self.git(self.finalized, "rev-parse", "HEAD"), target)
self.assertTrue(self.updater.params.values["DoReboot"])
self.assertTrue(self.updater.params.values["UpdateAvailable"])
progress.assert_called_with("rebooting", "Update installed. Restarting device.")
def test_normal_fallback_never_reboots_after_parked_authority_is_lost(self):
installed = self.root / "installed"
self.git(self.root, "clone", "--no-local", str(self.merged), str(installed))
self.updated.BASEDIR = str(installed)
(self.source / "panda/README.md").write_text("New native revision")
self.commit_source("Full update")
parked = iter((True, False))
with patch.object(self.updated, "init_overlay"), self.assertRaises(self.updated.FastUpdateError):
self.updater.normal_update(BRANCH, self.initial_commit, lambda: next(parked), Mock())
self.assertNotIn("DoReboot", self.updater.params.values)
def test_check_reports_confirmed_os_requirement_without_changing_installed_files(self):
self.updated.AGNOS = True
self.updated.HARDWARE.get_os_version.return_value = "12"
for launch, required in (("export AGNOS_VERSION='12'\n", False), ("export AGNOS_VERSION='13'\n", True),
("# Unknown OS version\n", None)):
with self.subTest(launch=launch):
(self.source / "launch_env.sh").write_text(launch)
self.commit_source("Change target OS version")
self.updater.check_for_update()
expected = {} if required is None else {"branch": BRANCH, "required": required}
self.assertEqual(self.updater.params.values["UpdaterOSUpdate"], expected)
self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), self.initial_commit)
self.updated.handle_agnos_update.assert_not_called()
def test_vendored_fetch_and_finalize(self):
(self.source / "panda" / "README.md").write_text("Updated ordinary tracked source\n")
target = self.commit_source("Update dependency source atomically")
self.updater.fetch_update()
self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), target)
self.assertEqual(self.git(self.finalized, "rev-parse", "HEAD"), target)
self.assertEqual((self.finalized / "panda" / "README.md").read_text(), "Updated ordinary tracked source\n")
self.assertTrue((self.finalized / ".overlay_consistent").is_file())
for name in DEPENDENCIES:
self.assertFalse((self.finalized / name / ".git").exists())
self.assertFalse(any(line.startswith("160000 ") for line in self.git(self.finalized, "ls-tree", "-r", "HEAD").splitlines()))
self.assertEqual(self.git(self.merged, "config", "submodule.recurse"), "false")
self.assertEqual(self.git(self.merged, "config", "fetch.recurseSubmodules"), "false")
self.updated.handle_agnos_update.assert_not_called()
def test_invalid_manifest_rejected_before_checkout(self):
self.manifest["schema_version"] = 999
self.write_manifest()
self.commit_source("Unsupported manifest")
self.assert_rejected_before_checkout()
def test_missing_manifest_rejected_before_checkout(self):
(self.source / "upstream-sync.json").unlink()
self.commit_source("Missing manifest")
self.assert_rejected_before_checkout()
def test_malformed_manifest_rejected_before_checkout(self):
(self.source / "upstream-sync.json").write_text("{invalid JSON")
self.commit_source("Malformed manifest")
self.assert_rejected_before_checkout()
def test_missing_dependency_rejected_before_checkout(self):
shutil.rmtree(self.source / "panda")
self.commit_source("Missing ordinary dependency folder")
self.assert_rejected_before_checkout()
def test_gitlink_rejected_before_checkout(self):
self.git(self.source, "rm", "-r", "panda")
self.git(self.source, "update-index", "--add", "--cacheinfo", f"160000,{self.initial_commit},panda")
self.git(self.source, "commit", "-m", "Invalid gitlink dependency")
self.assert_rejected_before_checkout()
def test_gitmodules_rejected_before_checkout(self):
(self.source / ".gitmodules").write_text('[submodule "panda"]\npath = panda\nurl = https://example.invalid/panda\n')
self.commit_source("Invalid submodule configuration")
self.assert_rejected_before_checkout()
def test_finalize_invalid_tree_clears_existing_readiness(self):
self.finalized.mkdir()
(self.finalized / ".overlay_consistent").touch()
self.manifest["dependencies"] = []
self.write_manifest()
self.commit_source("Invalid empty dependency manifest")
shutil.rmtree(self.merged)
self.git(self.root, "clone", "--no-local", "--no-hardlinks", str(self.source), str(self.merged))
with self.assertRaises(ValueError):
self.updated.finalize_update()
self.assertFalse((self.finalized / ".overlay_consistent").exists())
if __name__ == "__main__":
unittest.main()