mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-05 20:13:47 +08:00
774 lines
36 KiB
Python
774 lines
36 KiB
Python
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
|
|
def load(name, path):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
vendor = load('openpilot.common.vendor_manifest', ROOT / 'common/vendor_manifest.py')
|
|
prebuilt = load('openpilot.common.prebuilt_manifest', ROOT / 'common/prebuilt_manifest.py')
|
|
owner = load('fast_update_owner', ROOT / 'starpilot/software/fast_update.py')
|
|
|
|
|
|
class Params:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def put_bool(self, *args, **kwargs):
|
|
self.calls.append((args, kwargs))
|
|
|
|
|
|
class TestFastUpdate(unittest.TestCase):
|
|
def git(self, repo, *args):
|
|
return subprocess.check_output(['git', '-C', str(repo), *args], stderr=subprocess.STDOUT).decode().strip()
|
|
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
base = Path(self.temp.name)
|
|
self.remote, self.repo = base / 'remote', base / 'repo'
|
|
self.remote.mkdir()
|
|
self.git(self.remote, 'init', '-b', 'Dom')
|
|
self.git(self.remote, 'config', 'user.name', 'Test')
|
|
self.git(self.remote, 'config', 'user.email', 'test@example.invalid')
|
|
dependencies = []
|
|
for name in sorted(vendor.DEPENDENCIES):
|
|
(self.remote / name).mkdir()
|
|
(self.remote / name / 'source').write_text('vendored')
|
|
dependencies.append({'path': name, 'commit': '1' * 40, 'tree': '2' * 40, 'url': 'https://example.invalid/source', 'exclude': []})
|
|
manifest = {'schema_version': 1, 'upstream': {'commit': '3' * 40, 'url': 'https://example.invalid/upstream'}, 'dependencies': dependencies}
|
|
(self.remote / 'upstream-sync.json').write_text(json.dumps(manifest))
|
|
(self.remote / 'launch_env.sh').write_text('export AGNOS_VERSION="19.8.1"\n')
|
|
(self.remote / 'prebuilt').write_text('')
|
|
(self.remote / 'openpilot/starpilot/ui').mkdir(parents=True)
|
|
(self.remote / 'openpilot/starpilot/ui/source.py').write_text('old')
|
|
self.git(self.remote, 'add', '.')
|
|
self.git(self.remote, 'commit', '-m', 'initial')
|
|
self.git(base, 'clone', '--no-local', str(self.remote), str(self.repo))
|
|
self.previous = self.git(self.repo, 'rev-parse', 'HEAD')
|
|
(self.remote / 'openpilot/starpilot/ui/source.py').write_text('new')
|
|
self.git(self.remote, 'commit', '-am', 'update')
|
|
self.target = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
self.params = Params()
|
|
self.invalidations = []
|
|
|
|
def update(self, **kwargs):
|
|
arguments = {
|
|
'params': self.params,
|
|
'parked': lambda: True,
|
|
'expected_commit': self.git(self.repo, 'rev-parse', 'HEAD'),
|
|
'current_os': '19.8.1',
|
|
'invalidate': lambda: self.invalidations.append(True),
|
|
'sleep': lambda _: None,
|
|
}
|
|
arguments.update(kwargs)
|
|
return owner.fast_update(self.repo, 'Dom', **arguments)
|
|
|
|
def legacy_release(self, branch='Dom', version='6.7.9'):
|
|
self.git(self.repo, 'checkout', '-b', 'SecretGoodStarPilot')
|
|
self.git(self.remote, 'rm', 'upstream-sync.json')
|
|
files = {'launch_openpilot.sh': '#!/bin/sh\n', 'launch_chffrplus.sh': '#!/bin/sh\n',
|
|
'selfdrive/ui/lib/starpilot_version.py': f'STARPILOT_DISPLAY_VERSION = "{version}"\n',
|
|
'selfdrive/car/card.py': 'pass\n', 'selfdrive/modeld/modeld.py': 'pass\n'}
|
|
for name, text in files.items():
|
|
path = self.remote / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
if name.endswith('.sh'):
|
|
path.chmod(0o755)
|
|
self.git(self.remote, 'add', '.')
|
|
self.git(self.remote, 'commit', '-m', 'legacy release')
|
|
self.target = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
if branch != 'Dom':
|
|
self.git(self.remote, 'branch', branch)
|
|
return self.target
|
|
|
|
def test_legacy_dom_and_starpilot_return_without_manifest_or_build_receipt(self):
|
|
for branch in ('Dom', 'StarPilot'):
|
|
with self.subTest(branch=branch):
|
|
if branch == 'Dom':
|
|
self.legacy_release()
|
|
self.git(self.remote, 'branch', 'StarPilot')
|
|
else:
|
|
self.git(self.repo, 'checkout', '-B', 'SecretGoodStarPilot', self.previous)
|
|
model = self.repo / 'downloaded-model'
|
|
model.write_text('keep')
|
|
stages = []
|
|
self.assertEqual(owner.fast_update(self.repo, branch, params=self.params, parked=lambda: True,
|
|
expected_commit=self.previous, current_os='19.8.1', invalidate=lambda: None,
|
|
sleep=lambda _: None, progress=lambda stage, detail, stages=stages: stages.append((stage, detail))), 'reboot-requested')
|
|
self.assertEqual(self.git(self.repo, 'symbolic-ref', '--short', 'HEAD'), branch)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.target)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'refs/starpilot/previous'), self.previous)
|
|
self.assertEqual(model.read_text(), 'keep')
|
|
self.assertTrue(any('6.7.9' in detail for _, detail in stages))
|
|
self.assertFalse((self.repo / 'upstream-sync.json').exists())
|
|
self.assertEqual(self.params.calls[-1], (('DoReboot', True), {'block': True}))
|
|
|
|
def test_legacy_explicit_accepted_os_and_missing_required_os(self):
|
|
self.legacy_release()
|
|
launch = self.remote / 'launch_env.sh'
|
|
launch.write_text('export AGNOS_VERSION="19.8.1"\nexport AGNOS_ACCEPTED_VERSIONS="19.8.1 19.8.2"\n')
|
|
self.git(self.remote, 'commit', '-am', 'accepted OS')
|
|
self.assertEqual(self.update(current_os='19.8.2'), 'reboot-requested')
|
|
self.git(self.repo, 'checkout', '-B', 'SecretGoodStarPilot', self.previous)
|
|
self.params.calls.clear()
|
|
launch.write_text('export AGNOS_ACCEPTED_VERSIONS="19.8.2"\n')
|
|
self.git(self.remote, 'commit', '-am', 'missing required OS')
|
|
with self.assertRaisesRegex(owner.FastUpdateIncompatible, 'operating system'):
|
|
self.update(current_os='19.8.2')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.params.calls, [])
|
|
|
|
def test_legacy_missing_marker_new_major_and_different_os_refused(self):
|
|
self.legacy_release(version='7.0.0')
|
|
with self.assertRaisesRegex(owner.FastUpdateIncompatible, '6.x'):
|
|
self.update()
|
|
version = self.remote / 'selfdrive/ui/lib/starpilot_version.py'
|
|
version.write_text('STARPILOT_DISPLAY_VERSION = "6.7.9"\n')
|
|
self.git(self.remote, 'commit', '-am', 'legacy version')
|
|
with self.assertRaisesRegex(owner.FastUpdateIncompatible, 'operating system'):
|
|
self.update(current_os='different')
|
|
self.git(self.remote, 'rm', 'prebuilt')
|
|
self.git(self.remote, 'commit', '-m', 'missing prebuilt')
|
|
with self.assertRaisesRegex(owner.FastUpdateIncompatible, 'prebuilt'):
|
|
self.update()
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.params.calls, [])
|
|
|
|
def test_legacy_untracked_collision_and_parked_withdrawal_still_refused(self):
|
|
self.legacy_release()
|
|
collision = self.repo / 'selfdrive/car/card.py'
|
|
collision.parent.mkdir(parents=True)
|
|
collision.write_text('private data')
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'untracked data'):
|
|
self.update()
|
|
collision.unlink()
|
|
checks = iter((True, False))
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'parked state'):
|
|
self.update(parked=lambda: next(checks))
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.params.calls, [])
|
|
|
|
def test_legacy_failed_post_apply_validation_restores_rebase(self):
|
|
self.legacy_release()
|
|
original = owner.validate_legacy_return
|
|
def fail_post(git, revision, branch):
|
|
if revision == 'HEAD':
|
|
raise owner.FastUpdateIncompatible('legacy post-check failed')
|
|
return original(git, revision, branch)
|
|
with patch.object(owner, 'validate_legacy_return', fail_post):
|
|
with self.assertRaisesRegex(owner.FastUpdateIncompatible, 'legacy post-check'):
|
|
self.update()
|
|
self.assertEqual(self.git(self.repo, 'symbolic-ref', '--short', 'HEAD'), 'SecretGoodStarPilot')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertTrue((self.repo / 'upstream-sync.json').is_file())
|
|
self.assertEqual(self.params.calls, [])
|
|
|
|
def test_legacy_exception_never_accepts_other_branches_or_malformed_manifest(self):
|
|
self.legacy_release()
|
|
with self.assertRaises(owner.FastUpdateIncompatible):
|
|
owner.validate_legacy_return(lambda *args: self.git(self.remote, *args), self.target, 'other')
|
|
(self.remote / 'upstream-sync.json').write_text('{}')
|
|
self.git(self.remote, 'add', '.')
|
|
self.git(self.remote, 'commit', '-m', 'malformed current manifest')
|
|
with self.assertRaises(ValueError):
|
|
self.update()
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
|
|
def test_recent_listing_preserves_installed_state_and_rollback_receipt(self):
|
|
self.git(self.repo, 'update-ref', 'refs/starpilot/previous', self.previous)
|
|
(self.repo / 'private-data').write_text('preserved')
|
|
commands = []
|
|
def traced(command, cwd):
|
|
commands.append(command)
|
|
return owner.run(command, cwd)
|
|
self.assertEqual(self.update(versions_only=True, run=traced), 'versions-listed')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'refs/starpilot/previous'), self.previous)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'refs/starpilot/recent/Dom'), self.target)
|
|
self.assertEqual((self.repo / 'private-data').read_text(), 'preserved')
|
|
self.assertEqual(self.params.calls, [])
|
|
self.assertEqual(self.invalidations, [])
|
|
self.assertIn('--depth=20', next(command for command in commands if 'fetch' in command))
|
|
self.assertIn('--filter=blob:none', next(command for command in commands if 'fetch' in command))
|
|
|
|
def test_selected_older_version_uses_same_install_and_rollback_transaction(self):
|
|
self.update()
|
|
self.params.calls.clear()
|
|
self.invalidations.clear()
|
|
self.assertEqual(self.update(selected_commit=self.previous), 'reboot-requested')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'refs/starpilot/previous'), self.target)
|
|
self.assertEqual(self.invalidations, [True])
|
|
self.assertEqual(self.params.calls, [(('DoReboot', True), {'block': True})])
|
|
|
|
def test_forged_out_of_window_and_rebased_selected_versions_rejected(self):
|
|
def rejected(target):
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'recent branch history'):
|
|
self.update(selected_commit=target)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.params.calls, [])
|
|
self.assertEqual(self.invalidations, [])
|
|
rejected('f' * 40)
|
|
for index in range(owner.MAX_COMMITS + 1):
|
|
(self.remote / 'openpilot/starpilot/ui/source.py').write_text(str(index))
|
|
self.git(self.remote, 'commit', '-am', 'recent build')
|
|
rejected(self.target)
|
|
abandoned = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
self.git(self.remote, 'reset', '--hard', self.previous)
|
|
(self.remote / 'openpilot/starpilot/ui/source.py').write_text('replacement branch history')
|
|
self.git(self.remote, 'commit', '-am', 'replacement')
|
|
rejected(abandoned)
|
|
|
|
def test_installed_parent_remains_selectable_after_leaving_recent_window(self):
|
|
self.git(self.repo, 'fetch', 'origin')
|
|
self.git(self.repo, 'reset', '--hard', self.target)
|
|
for index in range(owner.MAX_COMMITS + 1):
|
|
(self.remote / 'openpilot/starpilot/ui/source.py').write_text(str(index))
|
|
self.git(self.remote, 'commit', '-am', 'newer build')
|
|
self.assertEqual(self.update(selected_commit=self.previous), 'reboot-requested')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
|
|
def test_selected_version_fetches_only_its_artifacts(self):
|
|
self.git(self.remote, 'config', 'uploadpack.allowFilter', 'true')
|
|
self.git(self.remote, 'config', 'uploadpack.allowReachableSHA1InWant', 'true')
|
|
self.git(self.repo, 'remote', 'set-url', 'origin', self.remote.as_uri())
|
|
artifact = self.remote / 'openpilot/starpilot/galaxy/web/assets/artifact.png'
|
|
artifact.parent.mkdir(parents=True)
|
|
artifact.write_bytes(b'chosen artifact' * 10000)
|
|
self.git(self.remote, 'add', '.')
|
|
self.git(self.remote, 'commit', '-m', 'Chosen version')
|
|
selected = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
selected_blob = self.git(self.remote, 'rev-parse', 'HEAD:openpilot/starpilot/galaxy/web/assets/artifact.png')
|
|
artifact.write_bytes(b'unselected newer artifact' * 10000)
|
|
self.git(self.remote, 'commit', '-am', 'Unselected version')
|
|
unselected_blob = self.git(self.remote, 'rev-parse', 'HEAD:openpilot/starpilot/galaxy/web/assets/artifact.png')
|
|
self.assertEqual(self.update(selected_commit=selected), 'reboot-requested')
|
|
objects = set(self.git(self.repo, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
|
|
self.assertIn(selected_blob, objects)
|
|
self.assertNotIn(unselected_blob, objects)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), selected)
|
|
|
|
def test_selected_version_rechecks_os_and_native_artifact_contracts(self):
|
|
(self.remote / 'launch_env.sh').write_text('export AGNOS_VERSION="different"\n')
|
|
self.git(self.remote, 'commit', '-am', 'different OS')
|
|
bad_os = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
(self.remote / 'launch_env.sh').write_text('export AGNOS_VERSION="19.8.1"\n')
|
|
self.git(self.remote, 'commit', '-am', 'compatible latest')
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'operating system'):
|
|
self.update(selected_commit=bad_os)
|
|
(self.remote / 'native.cc').write_text('int missing_artifact_proof;')
|
|
self.git(self.remote, 'add', '.')
|
|
self.git(self.remote, 'commit', '-m', 'unqualified native inputs')
|
|
bad_native = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'matching build receipt'):
|
|
self.update(selected_commit=bad_native)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.params.calls, [])
|
|
|
|
def test_version_snapshot_and_parked_state_withdrawal_prevent_listing_or_install(self):
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'Approved source revision changed'):
|
|
self.update(versions_only=True, expected_commit='f' * 40)
|
|
checks = iter((True, False))
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'parked state'):
|
|
self.update(versions_only=True, parked=lambda: next(checks))
|
|
self.assertEqual(self.params.calls, [])
|
|
self.assertEqual(self.invalidations, [])
|
|
|
|
def test_applies_exact_revision_and_retains_untracked_models(self):
|
|
(self.repo / 'models').mkdir()
|
|
(self.repo / 'models' / 'download').write_bytes(b'model')
|
|
commands = []
|
|
|
|
def traced(command, cwd):
|
|
commands.append(command)
|
|
return owner.run(command, cwd)
|
|
|
|
self.assertEqual(self.update(run=traced), 'reboot-requested')
|
|
fetch = next(command for command in commands if 'fetch' in command)
|
|
self.assertIn('gc.auto=0', fetch)
|
|
self.assertIn('maintenance.auto=false', fetch)
|
|
self.assertIn('--depth=1', fetch)
|
|
self.assertIn('--no-tags', fetch)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.target)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'refs/starpilot/previous'), self.previous)
|
|
self.assertEqual((self.repo / 'models' / 'download').read_bytes(), b'model')
|
|
self.assertEqual(self.params.calls, [(('DoReboot', True), {'block': True})])
|
|
self.assertEqual(self.update(), 'up-to-date')
|
|
self.assertEqual(len(self.invalidations), 1)
|
|
|
|
def test_actual_updater_owned_transaction_and_durable_restart_state(self):
|
|
from openpilot.system.updated import updated
|
|
from openpilot.starpilot.drive_state import evidence
|
|
from openpilot.starpilot.software import fast_update as production
|
|
|
|
from openpilot.common.params import Params as NativeParams
|
|
params = NativeParams(str(Path(self.temp.name) / 'params'))
|
|
params.put_bool('IsOffroad', True, block=True)
|
|
source = type('Physical', (), {'allowed': lambda self: True, 'effective': lambda self: False,
|
|
'update_ready': lambda self, manager_offroad: manager_offroad and self.allowed() and self.effective() is False,
|
|
'close': lambda self: None})()
|
|
updater = updated.Updater.__new__(updated.Updater)
|
|
updater.params = params
|
|
finalized = Path(self.temp.name) / 'finalized'
|
|
finalized.mkdir()
|
|
marker = finalized / '.overlay_consistent'
|
|
marker.touch()
|
|
overlay = self.repo / '.overlay_init'
|
|
overlay.touch()
|
|
with (
|
|
patch.object(updated, 'BASEDIR', str(self.repo)),
|
|
patch.object(updated, 'FINALIZED', str(finalized)),
|
|
patch.object(updated, 'OVERLAY_INIT', overlay),
|
|
patch.object(updated, 'dismount_overlay'),
|
|
patch.object(updated.HARDWARE, 'get_os_version', return_value='19.8.1'),
|
|
patch.object(evidence, 'PhysicalSource', return_value=source),
|
|
patch.object(production.time, 'sleep'),
|
|
):
|
|
result = updater.fast_update('Dom', self.previous)
|
|
self.assertEqual(result, 'reboot-requested')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.target)
|
|
self.assertFalse(marker.exists())
|
|
self.assertFalse(overlay.exists())
|
|
self.assertEqual(params.get('UpdaterFastState')['stage'], 'complete')
|
|
self.assertTrue(params.get_bool('DoReboot'))
|
|
updated.recover_fast_state(params)
|
|
self.assertEqual(params.get('UpdaterFastState')['stage'], 'complete')
|
|
params.put('UpdaterFastState', {'version': 1, 'stage': 'applying', 'detail': 'Applying fetched commit...'}, block=True)
|
|
updated.recover_fast_state(params)
|
|
self.assertEqual(params.get('UpdaterFastState')['stage'], 'error')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.target)
|
|
params.put('UpdaterFastState', {'version': 1, 'stage': 'complete', 'detail': 'Already up to date.'}, block=True)
|
|
updated.recover_fast_state(params)
|
|
self.assertEqual(params.get('UpdaterFastState')['stage'], 'complete')
|
|
|
|
def test_expected_commit_changed_rejected_before_fetch(self):
|
|
commands = []
|
|
|
|
def traced(command, cwd):
|
|
commands.append(command)
|
|
return owner.run(command, cwd)
|
|
|
|
with self.assertRaises(owner.FastUpdateError):
|
|
self.update(expected_commit='1' * 40, run=traced)
|
|
self.assertFalse(any('fetch' in command for command in commands))
|
|
|
|
def test_source_only_ui_update_admitted(self):
|
|
folder = self.remote / 'openpilot/starpilot/ui'
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
(folder / 'runtime_app.py').write_text('value = 1')
|
|
self.git(self.remote, 'add', '.')
|
|
self.git(self.remote, 'commit', '-m', 'UI update')
|
|
self.assertEqual(self.update(), 'reboot-requested')
|
|
|
|
def test_vendored_extension_update_and_rollback_preserve_downloaded_model(self):
|
|
self.git(self.remote, 'reset', '--hard', self.previous)
|
|
manifest_path = self.remote / 'upstream-sync.json'
|
|
manifest = json.loads(manifest_path.read_text())
|
|
manifest['dependencies'] = [entry for entry in manifest['dependencies'] if entry['path'] != 'jetlink_repo']
|
|
manifest.pop('vendored_extensions', None)
|
|
if (self.remote / 'jetlink_repo').exists():
|
|
self.git(self.remote, 'rm', '-r', 'jetlink_repo')
|
|
manifest_path.write_text(json.dumps(manifest))
|
|
self.git(self.remote, 'add', '.')
|
|
self.receipt_commit()
|
|
self.previous = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
self.git(self.repo, 'fetch', 'origin', 'Dom')
|
|
self.git(self.repo, 'reset', '--hard', self.previous)
|
|
model = self.repo / 'models' / 'download'
|
|
model.parent.mkdir()
|
|
model.write_bytes(b'downloaded model')
|
|
|
|
extension = self.remote / 'jetlink_repo'
|
|
extension.mkdir()
|
|
(extension / 'source.py').write_text('API_VERSION = 1\n')
|
|
manifest['vendored_extensions'] = [{'path': 'jetlink_repo', 'commit': '4' * 40, 'tree': '5' * 40,
|
|
'url': 'https://example.invalid/jetlink', 'exclude': []}]
|
|
manifest_path.write_text(json.dumps(manifest))
|
|
self.git(self.remote, 'add', '.')
|
|
self.receipt_commit()
|
|
target = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
|
|
self.assertEqual(self.update(), 'reboot-requested')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), target)
|
|
self.assertEqual((self.repo / 'jetlink_repo/source.py').read_text(), 'API_VERSION = 1\n')
|
|
self.assertEqual(model.read_bytes(), b'downloaded model')
|
|
self.assertEqual(self.update(rollback=True), 'reboot-requested')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse((self.repo / 'jetlink_repo').exists())
|
|
self.assertEqual(model.read_bytes(), b'downloaded model')
|
|
self.assertEqual(len(self.invalidations), 2)
|
|
|
|
def test_native_source_change_rejected_without_artifact_proof(self):
|
|
(self.remote / 'native.cc').write_text('int main() {}')
|
|
self.git(self.remote, 'add', '.')
|
|
self.git(self.remote, 'commit', '-m', 'native update')
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'full validated update'):
|
|
self.update()
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.invalidations)
|
|
|
|
def receipt_commit(self):
|
|
prebuilt.write_receipt(self.remote)
|
|
self.git(self.remote, 'add', 'prebuilt.json')
|
|
self.git(self.remote, 'commit', '-m', 'validated receipt')
|
|
|
|
def test_matching_receipt_admits_native_source_and_artifacts(self):
|
|
(self.remote / 'native.cc').write_text('int source;')
|
|
(self.remote / 'native.so').write_bytes(b'compiled artifact fixture')
|
|
self.git(self.remote, 'add', '.')
|
|
self.receipt_commit()
|
|
self.assertEqual(self.update(), 'reboot-requested')
|
|
|
|
def test_receipt_stale_source_rejected(self):
|
|
(self.remote / 'native.cc').write_text('int source;')
|
|
self.git(self.remote, 'add', '.')
|
|
self.receipt_commit()
|
|
(self.remote / 'native.cc').write_text('int changed;')
|
|
self.git(self.remote, 'commit', '-am', 'stale source')
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'matching build receipt'):
|
|
self.update()
|
|
self.assertFalse(self.invalidations)
|
|
|
|
def test_receipt_stale_artifact_rejected(self):
|
|
(self.remote / 'native.so').write_bytes(b'artifact')
|
|
self.git(self.remote, 'add', '.')
|
|
self.receipt_commit()
|
|
(self.remote / 'native.so').write_bytes(b'changed artifact')
|
|
self.git(self.remote, 'commit', '-am', 'stale artifact')
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'matching build receipt'):
|
|
self.update()
|
|
self.assertFalse(self.invalidations)
|
|
|
|
def test_receipt_working_content_and_modes_match_committed_tree(self):
|
|
(self.remote / 'native.cc').write_text('int unstaged;')
|
|
self.git(self.remote, 'add', '.')
|
|
(self.remote / 'native.cc').write_text('int working;')
|
|
(self.remote / 'native.cc').chmod(0o755)
|
|
(self.remote / 'native-link').symlink_to('native.cc')
|
|
self.git(self.remote, 'add', 'native-link')
|
|
prebuilt.write_receipt(self.remote)
|
|
self.git(self.remote, 'add', '.')
|
|
self.git(self.remote, 'commit', '-m', 'receipt content and modes')
|
|
|
|
def git(*args):
|
|
return subprocess.check_output(['git', '-C', str(self.remote), *args]).decode()
|
|
|
|
self.assertTrue(prebuilt.valid_receipt(git, 'HEAD'))
|
|
|
|
def test_missing_prebuilt_rejected_without_removing_local_marker(self):
|
|
self.git(self.remote, 'rm', 'prebuilt')
|
|
self.git(self.remote, 'commit', '-m', 'nonprebuilt')
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'prebuilt'):
|
|
self.update()
|
|
self.assertTrue((self.repo / 'prebuilt').exists())
|
|
|
|
def test_wrong_os_rejected_before_mutation(self):
|
|
with self.assertRaises(owner.FastUpdateError):
|
|
self.update(current_os='19.8.2')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.invalidations)
|
|
self.assertFalse(self.params.calls)
|
|
|
|
def test_dirty_tracked_source_backed_up_before_update(self):
|
|
path = 'openpilot/starpilot/ui/source.py'
|
|
(self.repo / path).write_text('local repair')
|
|
self.assertEqual(self.update(), 'reboot-requested')
|
|
self.assertEqual((self.repo / path).read_text(), 'new')
|
|
refs = self.git(self.repo, 'for-each-ref', '--format=%(refname)', 'refs/starpilot/local-changes').splitlines()
|
|
self.assertEqual(len(refs), 1)
|
|
self.assertEqual(self.git(self.repo, 'show', refs[0] + ':' + path), 'local repair')
|
|
|
|
def test_staged_and_unstaged_source_restored_on_failure(self):
|
|
path = 'openpilot/starpilot/ui/source.py'
|
|
(self.repo / path).write_text('staged repair')
|
|
self.git(self.repo, 'add', path)
|
|
(self.repo / path).write_text('unstaged repair')
|
|
with patch.object(self.params, 'put_bool', side_effect=RuntimeError('reboot failed')):
|
|
with self.assertRaisesRegex(RuntimeError, 'reboot failed'):
|
|
self.update()
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.git(self.repo, 'show', ':' + path), 'staged repair')
|
|
self.assertEqual((self.repo / path).read_text(), 'unstaged repair')
|
|
|
|
def test_dirty_version_listing_leaves_changes_and_backup_refs_untouched(self):
|
|
path = self.repo / 'openpilot/starpilot/ui/source.py'
|
|
path.write_text('local repair')
|
|
self.assertEqual(self.update(versions_only=True), 'versions-listed')
|
|
self.assertEqual(path.read_text(), 'local repair')
|
|
self.assertEqual(self.git(self.repo, 'for-each-ref', 'refs/starpilot/local-changes'), '')
|
|
|
|
def test_backup_failure_does_not_reset_local_changes(self):
|
|
path = self.repo / 'openpilot/starpilot/ui/source.py'
|
|
path.write_text('local repair')
|
|
def failing(command, cwd):
|
|
if 'stash' in command:
|
|
raise RuntimeError('backup failed')
|
|
return owner.run(command, cwd)
|
|
with self.assertRaisesRegex(RuntimeError, 'backup failed'):
|
|
self.update(run=failing)
|
|
self.assertEqual(path.read_text(), 'local repair')
|
|
self.assertFalse(self.invalidations)
|
|
self.assertFalse(self.params.calls)
|
|
|
|
def test_dirty_current_version_is_reinstalled_with_backup(self):
|
|
self.git(self.remote, 'reset', '--hard', self.previous)
|
|
path = self.repo / 'openpilot/starpilot/ui/source.py'
|
|
path.write_text('local repair')
|
|
self.assertEqual(self.update(), 'reboot-requested')
|
|
self.assertEqual(path.read_text(), 'old')
|
|
self.assertTrue(self.git(self.repo, 'for-each-ref', 'refs/starpilot/local-changes'))
|
|
|
|
def test_backup_ref_failure_does_not_reset_or_invalidate(self):
|
|
path = self.repo / 'openpilot/starpilot/ui/source.py'
|
|
path.write_text('local repair')
|
|
def failing(command, cwd):
|
|
if 'update-ref' in command and any('local-changes/' in item for item in command):
|
|
raise RuntimeError('backup ref failed')
|
|
return owner.run(command, cwd)
|
|
with self.assertRaisesRegex(RuntimeError, 'backup ref failed'):
|
|
self.update(run=failing)
|
|
self.assertEqual(path.read_text(), 'local repair')
|
|
self.assertFalse(self.invalidations)
|
|
self.assertFalse(self.params.calls)
|
|
|
|
def test_reboot_write_failure_rolls_back(self):
|
|
def failing(*args, **kwargs):
|
|
raise RuntimeError('params write failed')
|
|
|
|
with patch.object(self.params, "put_bool", side_effect=failing), self.assertRaisesRegex(RuntimeError, 'params write failed'):
|
|
self.update()
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
|
|
def test_fetch_failure_does_not_mutate(self):
|
|
def failing(command, cwd):
|
|
if 'fetch' in command:
|
|
raise RuntimeError('fetch failed')
|
|
return owner.run(command, cwd)
|
|
|
|
with self.assertRaisesRegex(RuntimeError, 'fetch failed'):
|
|
self.update(run=failing)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.invalidations)
|
|
|
|
def test_apply_failure_rolls_back(self):
|
|
failed = False
|
|
|
|
def failing(command, cwd):
|
|
nonlocal failed
|
|
if 'reset' in command and command[-1] == self.target and not failed:
|
|
failed = True
|
|
owner.run(command, cwd)
|
|
raise RuntimeError('apply failed after reset')
|
|
return owner.run(command, cwd)
|
|
|
|
with self.assertRaisesRegex(RuntimeError, 'apply failed'):
|
|
self.update(run=failing)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual((self.repo / 'openpilot/starpilot/ui/source.py').read_text(), 'old')
|
|
self.assertFalse(self.params.calls)
|
|
|
|
def test_lost_parked_before_reboot_rolls_back(self):
|
|
states = iter([True, True, True, False])
|
|
with self.assertRaises(owner.FastUpdateError):
|
|
self.update(parked=lambda: next(states))
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.params.calls)
|
|
|
|
def test_lost_parked_before_source_mutation(self):
|
|
states = iter([True, False])
|
|
with self.assertRaises(owner.FastUpdateError):
|
|
self.update(parked=lambda: next(states))
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.invalidations)
|
|
|
|
def test_invalid_vendor_target_rejected_before_checkout(self):
|
|
(self.remote / 'upstream-sync.json').write_text('{}')
|
|
self.git(self.remote, 'commit', '-am', 'invalid')
|
|
with self.assertRaises(ValueError):
|
|
self.update()
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.invalidations)
|
|
|
|
def test_selected_branch_applies_and_rollback_restores_exact_branch_and_commit(self):
|
|
self.git(self.remote, 'checkout', '-b', 'Other')
|
|
(self.remote / 'openpilot/starpilot/ui/source.py').write_text('other')
|
|
self.git(self.remote, 'commit', '-am', 'other branch')
|
|
other = self.git(self.remote, 'rev-parse', 'HEAD')
|
|
stages = []
|
|
arguments = {
|
|
'params': self.params,
|
|
'parked': lambda: True,
|
|
'expected_commit': self.previous,
|
|
'current_os': '19.8.1',
|
|
'invalidate': lambda: None,
|
|
'sleep': lambda seconds: stages.append(('sleep', seconds)),
|
|
'progress': lambda stage, detail: stages.append((stage, detail)),
|
|
}
|
|
self.assertEqual(owner.fast_update(self.repo, 'Other', **arguments), 'reboot-requested')
|
|
self.assertEqual(self.git(self.repo, 'symbolic-ref', '--short', 'HEAD'), 'Other')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), other)
|
|
self.assertEqual(self.git(self.repo, 'config', '--get', 'starpilot.previousVersion'), f'{self.previous} Dom')
|
|
self.assertIn(('sleep', 0.25), stages)
|
|
arguments['expected_commit'] = other
|
|
self.assertEqual(owner.fast_update(self.repo, 'Other', rollback=True, **arguments), 'reboot-requested')
|
|
self.assertEqual(self.git(self.repo, 'symbolic-ref', '--short', 'HEAD'), 'Dom')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
|
|
def test_failed_branch_apply_restores_source_and_previous_version_receipt(self):
|
|
self.git(self.remote, 'branch', 'Other')
|
|
self.git(self.repo, 'update-ref', 'refs/starpilot/previous', self.previous)
|
|
self.git(self.repo, 'config', 'starpilot.previousVersion', f'{self.previous} Retained')
|
|
|
|
def failed(command, cwd):
|
|
if 'checkout' in command and 'Other' in command:
|
|
raise subprocess.CalledProcessError(1, command)
|
|
return owner.run(command, cwd)
|
|
|
|
with self.assertRaises(subprocess.CalledProcessError):
|
|
owner.fast_update(
|
|
self.repo,
|
|
'Other',
|
|
params=self.params,
|
|
parked=lambda: True,
|
|
expected_commit=self.previous,
|
|
current_os='19.8.1',
|
|
invalidate=lambda: None,
|
|
run=failed,
|
|
sleep=lambda _: None,
|
|
)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertEqual(self.git(self.repo, 'config', '--get', 'starpilot.previousVersion'), f'{self.previous} Retained')
|
|
self.assertFalse(self.params.calls)
|
|
|
|
def test_incomplete_previous_version_receipt_rejected_without_source_mutation(self):
|
|
self.git(self.repo, 'update-ref', 'refs/starpilot/previous', self.previous)
|
|
self.git(self.repo, 'config', 'starpilot.previousVersion', f"{'1' * 40} Dom")
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'receipt is incomplete'):
|
|
self.update(rollback=True)
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.invalidations)
|
|
self.assertFalse(self.params.calls)
|
|
|
|
def test_restart_notice_rechecks_parked_and_rolls_back(self):
|
|
waits = []
|
|
parked = [True]
|
|
|
|
def notice(seconds):
|
|
waits.append(seconds)
|
|
parked[0] = False
|
|
|
|
with self.assertRaises(owner.FastUpdateError):
|
|
self.update(parked=lambda: parked[0], sleep=notice)
|
|
self.assertEqual(waits, [0.25])
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.params.calls)
|
|
|
|
def test_untracked_collision_rejected(self):
|
|
(self.repo / 'new-model').write_bytes(b'model')
|
|
(self.remote / 'new-model').write_text('tracked update')
|
|
self.git(self.remote, 'add', 'new-model')
|
|
self.receipt_commit()
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'overwrite untracked data'):
|
|
self.update()
|
|
self.assertEqual((self.repo / 'new-model').read_bytes(), b'model')
|
|
self.assertFalse(self.invalidations)
|
|
|
|
def test_ignored_model_cache_is_not_scanned_or_removed(self):
|
|
self.git(self.repo, 'config', 'core.excludesFile', str(self.repo / '.git/local-ignore'))
|
|
(self.repo / '.git/local-ignore').write_text('models/\n')
|
|
cache = self.repo / 'models/nested/download'
|
|
cache.parent.mkdir(parents=True)
|
|
cache.write_bytes(b'keep downloaded model')
|
|
commands = []
|
|
|
|
def traced(command, cwd):
|
|
commands.append(command)
|
|
return owner.run(command, cwd)
|
|
|
|
self.assertEqual(self.update(run=traced), 'reboot-requested')
|
|
self.assertEqual(cache.read_bytes(), b'keep downloaded model')
|
|
self.assertFalse(any('--others' in command for command in commands))
|
|
|
|
def test_ignored_update_collision_is_rejected(self):
|
|
self.git(self.repo, 'config', 'core.excludesFile', str(self.repo / '.git/local-ignore'))
|
|
(self.repo / '.git/local-ignore').write_text('[[]model]\n')
|
|
local = self.repo / '[model]'
|
|
local.write_bytes(b'private model')
|
|
(self.remote / '[model]').write_bytes(b'new tracked model')
|
|
self.git(self.remote, 'add', '[model]')
|
|
self.receipt_commit()
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'overwrite untracked data'):
|
|
self.update()
|
|
self.assertEqual(local.read_bytes(), b'private model')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
|
|
def test_untracked_parent_file_and_directory_contents_are_protected(self):
|
|
for parent_file in (True, False):
|
|
with self.subTest(parent_file=parent_file):
|
|
folder = self.repo / 'new-data'
|
|
target = self.remote / 'new-data'
|
|
if parent_file:
|
|
folder.write_bytes(b'private data')
|
|
target.mkdir()
|
|
(target / 'source').write_text('new tracked source')
|
|
else:
|
|
folder.mkdir()
|
|
(folder / 'download').write_bytes(b'private data')
|
|
target.write_text('new tracked source')
|
|
self.git(self.remote, 'add', 'new-data')
|
|
self.receipt_commit()
|
|
with self.assertRaisesRegex(owner.FastUpdateError, 'overwrite untracked data'):
|
|
self.update()
|
|
self.assertEqual((folder if parent_file else folder / 'download').read_bytes(), b'private data')
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.git(self.remote, 'reset', '--hard', self.target)
|
|
if parent_file:
|
|
folder.unlink()
|
|
else:
|
|
(folder / 'download').unlink()
|
|
folder.rmdir()
|
|
|
|
def test_tracked_directory_can_be_replaced_by_file(self):
|
|
self.git(self.remote, 'rm', '-r', 'openpilot/starpilot/ui')
|
|
(self.remote / 'openpilot/starpilot').mkdir(parents=True, exist_ok=True)
|
|
(self.remote / 'openpilot/starpilot/ui').write_text('replacement')
|
|
self.git(self.remote, 'add', '.')
|
|
self.receipt_commit()
|
|
self.assertEqual(self.update(), 'reboot-requested')
|
|
self.assertEqual((self.repo / 'openpilot/starpilot/ui').read_text(), 'replacement')
|
|
|
|
def test_post_apply_validation_failure_rolls_back(self):
|
|
original = owner.validate_revision
|
|
|
|
def validate(repo, revision):
|
|
if revision == 'HEAD':
|
|
raise ValueError('post-check failed')
|
|
return original(repo, revision)
|
|
|
|
with patch.object(owner, 'validate_revision', validate):
|
|
with self.assertRaisesRegex(ValueError, 'post-check failed'):
|
|
self.update()
|
|
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
|
self.assertFalse(self.params.calls)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|