Files
StarPilot/openpilot/starpilot/software/tests/test_fast_update.py
T

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()