mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-05 20:13:47 +08:00
Safely stage the audited legacy StarPilot return
This commit is contained in:
+37
-4
@@ -57,6 +57,37 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
function update_is_ready {
|
||||
[ -f "$1" ] && [ ! -L "$1" ]
|
||||
}
|
||||
|
||||
# A validated legacy return records the OS it was staged against. Check it on
|
||||
# every boot before swapping trees, including manual reboots.
|
||||
# Keep this in sync with TRUSTED_LEGACY_RELEASES in updated.py.
|
||||
TRUSTED_LEGACY_STARPILOT_COMMIT="4a553da9ac22a14e34513c011aed04c1e93b4619"
|
||||
|
||||
function staged_os_is_compatible {
|
||||
local staged="$1" marker="$1/.git/starpilot-retain-agnos" commit
|
||||
commit="$(git -C "$staged" rev-parse --verify 'HEAD^{commit}')" || return 1
|
||||
if [ "$commit" = "$TRUSTED_LEGACY_STARPILOT_COMMIT" ]; then
|
||||
[ -f "$marker" ] && [ -s "$marker" ] && [ ! -L "$marker" ] && [ -r /VERSION ] && [ "$(cat "$marker")" = "$(< /VERSION)" ]
|
||||
else
|
||||
[ ! -e "$marker" ] && [ ! -L "$marker" ]
|
||||
fi
|
||||
}
|
||||
|
||||
function reset_update_os_environment {
|
||||
unset AGNOS_VERSION AGNOS_ACCEPTED_VERSIONS
|
||||
}
|
||||
|
||||
function maybe_activate_legacy_agnos {
|
||||
local repo="$1" verifier="$2" manifest="$3" commit
|
||||
commit="$(git -C "$repo" rev-parse --verify 'HEAD^{commit}')" || return 1
|
||||
if [ "$commit" != "$TRUSTED_LEGACY_STARPILOT_COMMIT" ] && [ ! -L "$manifest" ]; then
|
||||
"$verifier" --verify "$manifest" && sudo reboot
|
||||
fi
|
||||
}
|
||||
|
||||
function launch {
|
||||
# Remove orphaned git lock if it exists on boot
|
||||
[ -f "$DIR/.git/index.lock" ] && rm -f "$DIR/.git/index.lock"
|
||||
@@ -75,8 +106,10 @@ function launch {
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "${DIR} has been modified, skipping overlay update installation"
|
||||
else
|
||||
if [ -f "${STAGING_ROOT}/finalized/.overlay_consistent" ]; then
|
||||
if [ ! -d /data/safe_staging/old_openpilot ]; then
|
||||
if update_is_ready "${STAGING_ROOT}/finalized/.overlay_consistent"; then
|
||||
if ! staged_os_is_compatible "${STAGING_ROOT}/finalized"; then
|
||||
echo "Staged update requires a different or unverified AGNOS version; skipping installation."
|
||||
elif [ ! -d /data/safe_staging/old_openpilot ]; then
|
||||
echo "Valid overlay update found, installing"
|
||||
LAUNCHER_LOCATION="${BASH_SOURCE[0]}"
|
||||
|
||||
@@ -84,10 +117,10 @@ function launch {
|
||||
mv "${STAGING_ROOT}/finalized" "$DIR"
|
||||
cd "$DIR"
|
||||
# releases before the openpilot/ move can't swap AGNOS on newer AGNOS, use ours
|
||||
[ ! -L system/hardware/tici/agnos.json ] && /data/safe_staging/old_openpilot/openpilot/common/hardware/comma/agnos.py --verify system/hardware/tici/agnos.json && sudo reboot
|
||||
maybe_activate_legacy_agnos "$DIR" /data/safe_staging/old_openpilot/openpilot/common/hardware/comma/agnos.py system/hardware/tici/agnos.json
|
||||
|
||||
echo "Restarting launch script ${LAUNCHER_LOCATION}"
|
||||
unset AGNOS_VERSION
|
||||
reset_update_os_environment
|
||||
exec "${LAUNCHER_LOCATION}"
|
||||
else
|
||||
echo "openpilot backup found, not updating"
|
||||
|
||||
@@ -30,6 +30,15 @@ def required_os_version(launch):
|
||||
return versions[0] if len(versions) == 1 else None
|
||||
|
||||
|
||||
def legacy_os_compatible(launch, current_os):
|
||||
required_os = required_os_version(launch)
|
||||
accepted = re.findall(r'^\s*export\s+AGNOS_ACCEPTED_VERSIONS=["\']([^"\'\n]+)["\']\s*$', launch, re.MULTILINE)
|
||||
accepted_os = {required_os}
|
||||
if len(accepted) == 1:
|
||||
accepted_os.update(accepted[0].split())
|
||||
return bool(required_os and current_os and current_os in accepted_os)
|
||||
|
||||
|
||||
def validate_legacy_return(git, revision, branch):
|
||||
if branch not in ('Dom', 'StarPilot'):
|
||||
raise FastUpdateIncompatible('Legacy return is only available for Dom and StarPilot 6.x releases')
|
||||
@@ -243,12 +252,7 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
|
||||
validate_revision(repo, target)
|
||||
launch = git('show', f'{target}:launch_env.sh')
|
||||
required_os = required_os_version(launch)
|
||||
accepted_os = {required_os}
|
||||
if legacy_return:
|
||||
accepted = re.findall(r'^\s*export\s+AGNOS_ACCEPTED_VERSIONS=["\']([^"\'\n]+)["\']\s*$', launch, re.MULTILINE)
|
||||
if len(accepted) == 1:
|
||||
accepted_os.update(accepted[0].split())
|
||||
if not required_os or not current_os or current_os not in accepted_os:
|
||||
if not (legacy_os_compatible(launch, current_os) if legacy_return else required_os and current_os == required_os):
|
||||
raise FastUpdateIncompatible('Update requires a different or unverified operating system')
|
||||
if target == previous and branch == initial_branch and not git('status', '--porcelain', '--untracked-files=no').strip():
|
||||
notify('complete', 'Already up to date.')
|
||||
|
||||
@@ -77,6 +77,7 @@ class TestFastRequest(unittest.TestCase):
|
||||
module.set_consistent_flag = Mock()
|
||||
module.init_overlay = Mock()
|
||||
module.validate_revision = Mock()
|
||||
module.validate_update_revision = Mock(return_value=False)
|
||||
module.AGNOS = False
|
||||
self.updater.target_branch = "SecretGoodStarPilot"
|
||||
self.updater._checked_branch = "SecretGoodStarPilot"
|
||||
@@ -84,7 +85,7 @@ class TestFastRequest(unittest.TestCase):
|
||||
for failure in (False, True):
|
||||
with self.subTest(failure=failure):
|
||||
module.init_overlay.reset_mock()
|
||||
module.validate_revision.reset_mock()
|
||||
module.validate_update_revision.reset_mock()
|
||||
self.writes.clear()
|
||||
self.values.pop("LastUpdateTime", None)
|
||||
self.helper._control_request("download")
|
||||
@@ -105,9 +106,9 @@ class TestFastRequest(unittest.TestCase):
|
||||
self.assertEqual("LastUpdateTime" in self.values, not failure)
|
||||
module.finalize_update.assert_called_once()
|
||||
module.init_overlay.assert_called_once_with()
|
||||
self.assertEqual(module.validate_revision.call_args_list, [
|
||||
mock.call(module.OVERLAY_MERGED, 'a' * 40),
|
||||
mock.call(module.OVERLAY_MERGED, 'HEAD'),
|
||||
self.assertEqual(module.validate_update_revision.call_args_list, [
|
||||
mock.call(module.OVERLAY_MERGED, 'a' * 40, 'SecretGoodStarPilot'),
|
||||
mock.call(module.OVERLAY_MERGED, 'HEAD', 'SecretGoodStarPilot'),
|
||||
])
|
||||
|
||||
def test_fast_request_skips_staging_and_startup_delay(self):
|
||||
|
||||
@@ -44,6 +44,93 @@ class TestRetainedAgnos(unittest.TestCase):
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_readiness_flag_never_follows_or_replaces_existing_marker(self):
|
||||
updated = load_updater()
|
||||
updated.FINALIZED = str(self.root)
|
||||
marker = self.root / '.overlay_consistent'
|
||||
target = self.root / 'untouched'
|
||||
target.write_text('keep')
|
||||
marker.symlink_to(target)
|
||||
with patch.object(updated.os, 'sync'):
|
||||
with self.assertRaises(FileExistsError):
|
||||
updated.set_consistent_flag(True)
|
||||
self.assertEqual(target.read_text(), 'keep')
|
||||
updated.set_consistent_flag(False)
|
||||
updated.set_consistent_flag(True)
|
||||
self.assertTrue(marker.is_file())
|
||||
self.assertFalse(marker.is_symlink())
|
||||
with self.assertRaises(FileExistsError):
|
||||
updated.set_consistent_flag(True)
|
||||
|
||||
def test_launcher_readiness_rejects_symlink(self):
|
||||
function = shell_function(ROOT / 'launch_chffrplus.sh', 'update_is_ready')
|
||||
target = self.root / 'target'
|
||||
target.touch()
|
||||
marker = self.root / 'marker'
|
||||
marker.symlink_to(target)
|
||||
command = function + f'\nupdate_is_ready {shlex.quote(str(marker))}'
|
||||
self.assertNotEqual(subprocess.run(['bash', '-c', command]).returncode, 0)
|
||||
marker.unlink()
|
||||
marker.touch()
|
||||
self.assertEqual(subprocess.run(['bash', '-c', command]).returncode, 0)
|
||||
|
||||
def test_launch_discards_inherited_os_version_overrides(self):
|
||||
function = shell_function(ROOT / 'launch_chffrplus.sh', 'reset_update_os_environment')
|
||||
env_file = self.root / 'target_launch_env.sh'
|
||||
env_file.write_text('if [ -z "$AGNOS_VERSION" ]; then export AGNOS_VERSION="19.8.1"; fi\n' +
|
||||
'if [ -z "$AGNOS_ACCEPTED_VERSIONS" ]; then export AGNOS_ACCEPTED_VERSIONS="19.8.1 20.2.1"; fi\n')
|
||||
script = function + '\nexport AGNOS_VERSION=wrong AGNOS_ACCEPTED_VERSIONS=wrong\n' + \
|
||||
f'reset_update_os_environment\nsource {shlex.quote(str(env_file))}\nprintf "%s|%s" "$AGNOS_VERSION" "$AGNOS_ACCEPTED_VERSIONS"'
|
||||
result = subprocess.check_output(['bash', '-c', script], text=True)
|
||||
self.assertEqual(result, '19.8.1|19.8.1 20.2.1')
|
||||
|
||||
def test_legacy_launch_checks_exact_commit_and_os_before_swap_or_slot_activation(self):
|
||||
repo = self.root / 'staged'
|
||||
repo.mkdir()
|
||||
subprocess.run(['git', 'init', '-q', str(repo)], check=True)
|
||||
subprocess.run(['git', '-C', str(repo), '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid',
|
||||
'commit', '--allow-empty', '-qm', 'fixture'], check=True)
|
||||
commit = subprocess.check_output(['git', '-C', str(repo), 'rev-parse', 'HEAD'], text=True).strip()
|
||||
version = self.root / 'VERSION'
|
||||
version.write_text('20.2.1\n')
|
||||
marker = repo / '.git/starpilot-retain-agnos'
|
||||
launcher = ROOT / 'launch_chffrplus.sh'
|
||||
text = launcher.read_text()
|
||||
audited = next(iter(load_updater().TRUSTED_LEGACY_RELEASES['StarPilot']))
|
||||
self.assertIn(f'TRUSTED_LEGACY_STARPILOT_COMMIT="{audited}"', text)
|
||||
check = shell_function(launcher, 'staged_os_is_compatible').replace('/VERSION', str(version))
|
||||
activate = shell_function(launcher, 'maybe_activate_legacy_agnos')
|
||||
verifier = self.root / 'verifier'
|
||||
log = self.root / 'calls'
|
||||
verifier.write_text(f'#!/bin/sh\necho verify >> {shlex.quote(str(log))}\n')
|
||||
verifier.chmod(0o755)
|
||||
manifest = self.root / 'agnos.json'
|
||||
manifest.write_text('[]')
|
||||
common = f'TRUSTED_LEGACY_STARPILOT_COMMIT={commit}\n{check}\n{activate}\n'
|
||||
def admission():
|
||||
return subprocess.run(['bash', '-c', common + f'staged_os_is_compatible {shlex.quote(str(repo))}'], check=False).returncode
|
||||
self.assertNotEqual(admission(), 0) # Missing marker is not a modern update.
|
||||
marker.write_text('wrong\n')
|
||||
self.assertNotEqual(admission(), 0)
|
||||
marker.unlink()
|
||||
marker.symlink_to(version)
|
||||
self.assertNotEqual(admission(), 0)
|
||||
marker.unlink()
|
||||
marker.write_text('20.2.1\n')
|
||||
self.assertEqual(admission(), 0)
|
||||
version.write_text('changed\n')
|
||||
self.assertNotEqual(admission(), 0) # Manual reboot must recheck the OS.
|
||||
shell = common + f'sudo() {{ echo reboot >> {shlex.quote(str(log))}; }}\n' + \
|
||||
f'maybe_activate_legacy_agnos {shlex.quote(str(repo))} {shlex.quote(str(verifier))} {shlex.quote(str(manifest))}\n'
|
||||
subprocess.run(['bash', '-c', shell], check=True)
|
||||
self.assertFalse(log.exists()) # A pinned legacy install never activates a slot.
|
||||
marker.unlink()
|
||||
modern = common.replace(f'TRUSTED_LEGACY_STARPILOT_COMMIT={commit}', 'TRUSTED_LEGACY_STARPILOT_COMMIT=other')
|
||||
self.assertEqual(subprocess.run(['bash', '-c', modern + f'staged_os_is_compatible {shlex.quote(str(repo))}']).returncode, 0)
|
||||
subprocess.run(['bash', '-c', shell.replace(f'TRUSTED_LEGACY_STARPILOT_COMMIT={commit}',
|
||||
'TRUSTED_LEGACY_STARPILOT_COMMIT=other')], check=True)
|
||||
self.assertEqual(log.read_text().splitlines(), ['verify', 'reboot'])
|
||||
|
||||
def test_retained_match_never_flashes_or_queries_slot(self):
|
||||
with patch.dict(os.environ, {'AGNOS_VERSION': 'unexpected', 'AGNOS_UPDATE_POLICY': 'auto'}):
|
||||
self.updater.handle_agnos_update()
|
||||
|
||||
@@ -105,6 +105,7 @@ class TestVendoredUpdate(unittest.TestCase):
|
||||
self.git(self.merged, "config", "fetch.recurseSubmodules", "true")
|
||||
|
||||
self.updated = load_updater()
|
||||
self.updated.TRUSTED_LEGACY_RELEASES = {}
|
||||
self.updated.OVERLAY_MERGED = str(self.merged)
|
||||
self.updated.METADATA_ROOT = self.root / 'metadata.git'
|
||||
self.updated.FINALIZED = str(self.finalized)
|
||||
@@ -205,6 +206,137 @@ class TestVendoredUpdate(unittest.TestCase):
|
||||
self.assertEqual(self.git(self.merged, 'rev-parse', 'HEAD'), self.initial_commit)
|
||||
self.assertFalse(self.updater.params.get_bool('DoReboot'))
|
||||
|
||||
def make_legacy_release(self, branch='StarPilot'):
|
||||
self.git(self.source, 'checkout', '-b', branch)
|
||||
(self.source / 'upstream-sync.json').unlink()
|
||||
files = {'prebuilt': '', 'launch_env.sh': 'export AGNOS_VERSION="19.8.1"\nexport AGNOS_ACCEPTED_VERSIONS="19.8.1 20.2.1"\n',
|
||||
'launch_openpilot.sh': '#!/bin/sh\n', 'launch_chffrplus.sh': '#!/bin/sh\n',
|
||||
'selfdrive/ui/lib/starpilot_version.py': 'STARPILOT_DISPLAY_VERSION = "6.7.9"\n',
|
||||
'selfdrive/car/card.py': 'pass\n', 'selfdrive/modeld/modeld.py': 'pass\n'}
|
||||
for name, content in files.items():
|
||||
path = self.source / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
if name in ('launch_openpilot.sh', 'launch_chffrplus.sh'):
|
||||
path.chmod(0o755)
|
||||
target = self.commit_source('Legacy prebuilt release')
|
||||
self.updated.TRUSTED_LEGACY_RELEASES[branch] = frozenset({target})
|
||||
return target
|
||||
|
||||
@patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True)))
|
||||
def test_download_legacy_starpilot_stages_without_reboot_or_flashing(self, _physical):
|
||||
installed = self.root / 'installed'
|
||||
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
|
||||
self.updated.BASEDIR = str(installed)
|
||||
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
|
||||
target = self.make_legacy_release()
|
||||
self.updated.AGNOS = True
|
||||
self.updated.HARDWARE.get_os_version.return_value = '20.2.1'
|
||||
with patch.object(self.updater, 'set_params'):
|
||||
self.assertEqual(self.updater.fast_update('StarPilot', self.initial_commit, target, download_only=True), 'downloaded')
|
||||
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.update_ready)
|
||||
self.assertFalse(self.updater.params.get_bool('DoReboot'))
|
||||
self.updated.handle_agnos_update.assert_not_called()
|
||||
self.updated.HARDWARE.get_os_version.return_value = 'unverified'
|
||||
with self.assertRaises(self.updated.FastUpdateIncompatible):
|
||||
self.updated.validate_update_revision(str(self.finalized), 'HEAD', 'StarPilot')
|
||||
self.updater.params.put('UpdaterState', 'idle')
|
||||
self.updater.params.put_bool('IsOffroad', True)
|
||||
settings = self.updater.install_settings
|
||||
settings.save(True, '* * * * *', settings.read()['revision'], lambda: True)
|
||||
with patch.object(self.updated, 'system_time_valid', return_value=True):
|
||||
self.assertFalse(self.updater.maybe_install())
|
||||
self.assertFalse(self.updater.params.get_bool('DoReboot'))
|
||||
|
||||
def test_legacy_wrong_os_or_incomplete_release_rejected_before_checkout(self):
|
||||
target = self.make_legacy_release()
|
||||
self.updater.params.put('UpdaterTargetBranch', 'StarPilot')
|
||||
self.updated.HARDWARE.get_os_version.return_value = 'wrong'
|
||||
with self.assertRaises(self.updated.FastUpdateIncompatible):
|
||||
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.HARDWARE.get_os_version.return_value = '19.8.1'
|
||||
(self.source / 'prebuilt').unlink()
|
||||
incomplete = self.commit_source('Incomplete legacy release')
|
||||
self.updated.TRUSTED_LEGACY_RELEASES['StarPilot'] = frozenset({incomplete})
|
||||
self.updater._branches_checked = False
|
||||
with self.assertRaises(self.updated.FastUpdateIncompatible):
|
||||
self.updater.fetch_update()
|
||||
self.assertFalse((self.finalized / '.overlay_consistent').exists())
|
||||
self.assertNotEqual(target, self.git(self.source, 'rev-parse', 'HEAD'))
|
||||
|
||||
def test_legacy_exception_does_not_accept_other_branch_or_malformed_manifest(self):
|
||||
target = self.make_legacy_release()
|
||||
self.updated.HARDWARE.get_os_version.return_value = '19.8.1'
|
||||
with self.assertRaises(ValueError):
|
||||
self.updated.validate_update_revision(str(self.source), target, 'other')
|
||||
(self.source / 'upstream-sync.json').write_text('{}')
|
||||
target = self.commit_source('Invalid modern manifest')
|
||||
with self.assertRaises(ValueError):
|
||||
self.updated.validate_update_revision(str(self.source), target, 'StarPilot')
|
||||
|
||||
def test_legacy_return_rejects_gitlinks_and_submodules(self):
|
||||
self.make_legacy_release()
|
||||
self.updated.HARDWARE.get_os_version.return_value = '19.8.1'
|
||||
self.git(self.source, 'update-index', '--add', '--cacheinfo', f'160000,{self.initial_commit},unsafe')
|
||||
self.git(self.source, 'commit', '-m', 'Invalid legacy gitlink')
|
||||
self.updated.TRUSTED_LEGACY_RELEASES['StarPilot'] = frozenset({self.git(self.source, 'rev-parse', 'HEAD')})
|
||||
with self.assertRaises(self.updated.FastUpdateIncompatible):
|
||||
self.updated.validate_update_revision(str(self.source), 'HEAD', 'StarPilot')
|
||||
self.git(self.source, 'rm', '--cached', 'unsafe')
|
||||
(self.source / '.gitmodules').write_text('[submodule "unsafe"]\npath = unsafe\n')
|
||||
self.commit_source('Invalid legacy submodule metadata')
|
||||
self.updated.TRUSTED_LEGACY_RELEASES['StarPilot'] = frozenset({self.git(self.source, 'rev-parse', 'HEAD')})
|
||||
with self.assertRaises(self.updated.FastUpdateIncompatible):
|
||||
self.updated.validate_update_revision(str(self.source), 'HEAD', 'StarPilot')
|
||||
|
||||
def test_untrusted_legacy_commit_is_rejected_even_if_it_looks_complete(self):
|
||||
target = self.make_legacy_release()
|
||||
self.updated.TRUSTED_LEGACY_RELEASES.clear()
|
||||
self.updated.HARDWARE.get_os_version.return_value = '19.8.1'
|
||||
with self.assertRaises(ValueError):
|
||||
self.updated.validate_update_revision(str(self.source), target, 'StarPilot')
|
||||
audited = load_updater().TRUSTED_LEGACY_RELEASES
|
||||
self.assertEqual(audited, {'StarPilot': frozenset({'4a553da9ac22a14e34513c011aed04c1e93b4619'})})
|
||||
|
||||
def test_reserved_readiness_marker_rejected_in_modern_and_legacy_trees(self):
|
||||
for kind in ('modern-file', 'modern-symlink', 'legacy-file', 'legacy-symlink'):
|
||||
with self.subTest(kind=kind):
|
||||
if kind.startswith('legacy') and self.git(self.source, 'branch', '--show-current') != 'StarPilot':
|
||||
self.make_legacy_release()
|
||||
self.updated.HARDWARE.get_os_version.return_value = '19.8.1'
|
||||
marker = self.source / '.overlay_consistent'
|
||||
if kind.endswith('symlink'):
|
||||
marker.symlink_to('prebuilt')
|
||||
else:
|
||||
marker.write_text('premature')
|
||||
target = self.commit_source(kind)
|
||||
branch = 'StarPilot' if kind.startswith('legacy') else BRANCH
|
||||
self.updated.TRUSTED_LEGACY_RELEASES[branch] = frozenset({target})
|
||||
with self.assertRaisesRegex(ValueError, 'reserved update readiness'):
|
||||
self.updated.validate_update_revision(str(self.source), target, branch)
|
||||
marker.unlink()
|
||||
self.commit_source('Remove marker')
|
||||
|
||||
def test_copy_interruption_never_copies_an_untracked_readiness_marker(self):
|
||||
marker = self.merged / '.overlay_consistent'
|
||||
marker.symlink_to('panda/README.md')
|
||||
original_copy = shutil.copy2
|
||||
def fail_during_copy(src, dst, *args, **kwargs):
|
||||
if Path(src).name == 'README.md':
|
||||
raise OSError('interrupted copy')
|
||||
return original_copy(src, dst, *args, **kwargs)
|
||||
with patch.object(self.updated.shutil, 'copy2', side_effect=fail_during_copy):
|
||||
with self.assertRaises(shutil.Error):
|
||||
self.updated.finalize_update()
|
||||
ready = self.finalized / '.overlay_consistent'
|
||||
self.assertFalse(ready.exists())
|
||||
self.assertFalse(ready.is_symlink())
|
||||
|
||||
def test_metadata_refresh_does_not_wake_background_but_check_does(self):
|
||||
(self.source / 'new.txt').write_text('available')
|
||||
self.commit_source('New update')
|
||||
|
||||
@@ -25,7 +25,8 @@ from openpilot.common.vendor_manifest import validate_revision
|
||||
from openpilot.starpilot.software.update_control import UpdaterControlError, UpdaterControlServer
|
||||
from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, download_permitted
|
||||
from openpilot.starpilot.software.fast_update import (
|
||||
FastUpdateError, FastUpdateIncompatible, fetch_files, required_os_version, run as run_git,
|
||||
FastUpdateError, FastUpdateIncompatible, fetch_files, legacy_os_compatible, required_os_version, run as run_git,
|
||||
validate_legacy_return,
|
||||
)
|
||||
from openpilot.starpilot.software.history import MAX_COMMITS, METADATA_ROOT, commit_history
|
||||
from openpilot.starpilot.software.auto_install import InstallSettings, cron_matches, scheduled_restart
|
||||
@@ -161,7 +162,8 @@ def set_consistent_flag(consistent: bool) -> None:
|
||||
os.sync()
|
||||
consistent_file = Path(os.path.join(FINALIZED, ".overlay_consistent"))
|
||||
if consistent:
|
||||
consistent_file.touch()
|
||||
fd = os.open(consistent_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
|
||||
os.close(fd)
|
||||
elif not consistent:
|
||||
consistent_file.unlink(missing_ok=True)
|
||||
os.sync()
|
||||
@@ -261,6 +263,33 @@ def init_overlay() -> None:
|
||||
cloudlog.info(f"git diff output:\n{git_diff}")
|
||||
|
||||
|
||||
# Audited legacy release, pinned by Git commit identity. Other releases must
|
||||
# satisfy the modern source manifest contract.
|
||||
TRUSTED_LEGACY_RELEASES = {'StarPilot': frozenset({'4a553da9ac22a14e34513c011aed04c1e93b4619'})}
|
||||
LEGACY_OS_MARKER = '.git/starpilot-retain-agnos'
|
||||
|
||||
|
||||
def validate_update_revision(repo: str, revision: str, branch: str) -> bool:
|
||||
"""Validate a modern source tree or an explicitly supported legacy return."""
|
||||
def git(*args):
|
||||
return run(['git', *args], repo, download='show' in args)
|
||||
|
||||
if git('ls-tree', revision, '--', '.overlay_consistent').strip():
|
||||
raise ValueError('Source tree contains reserved update readiness marker')
|
||||
manifest_entry = git('ls-tree', revision, '--', 'upstream-sync.json').strip()
|
||||
commit = git('rev-parse', '--verify', f'{revision}^{{commit}}').strip()
|
||||
if not manifest_entry and commit in TRUSTED_LEGACY_RELEASES.get(branch, ()):
|
||||
validate_legacy_return(git, revision, branch)
|
||||
launch = git('show', f'{revision}:launch_env.sh')
|
||||
if not legacy_os_compatible(launch, HARDWARE.get_os_version()):
|
||||
raise FastUpdateIncompatible('Update requires a different or unverified operating system')
|
||||
return True
|
||||
if manifest_entry.split()[:1] == ['100644']:
|
||||
git('show', f'{revision}:upstream-sync.json')
|
||||
validate_revision(repo, revision)
|
||||
return False
|
||||
|
||||
|
||||
def finalize_update(progress=None) -> None:
|
||||
"""Take the current OverlayFS merged view and finalize a copy outside of
|
||||
OverlayFS, ready to be swapped-in at BASEDIR. Copy using shutil.copytree"""
|
||||
@@ -268,7 +297,8 @@ def finalize_update(progress=None) -> None:
|
||||
# Remove the update ready flag and any old updates
|
||||
cloudlog.info("creating finalized version of the overlay")
|
||||
set_consistent_flag(False)
|
||||
validate_revision(OVERLAY_MERGED, "HEAD")
|
||||
branch = run(["git", "symbolic-ref", "--short", "HEAD"], OVERLAY_MERGED).strip()
|
||||
legacy_return = validate_update_revision(OVERLAY_MERGED, "HEAD", branch)
|
||||
|
||||
# Copy the merged overlay view and set the update ready flag
|
||||
if os.path.exists(FINALIZED):
|
||||
@@ -302,13 +332,25 @@ def finalize_update(progress=None) -> None:
|
||||
reported = now
|
||||
return destination
|
||||
started = time.monotonic()
|
||||
shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True, copy_function=copy_file)
|
||||
def ignore_readiness_marker(directory, names):
|
||||
return {'.overlay_consistent'} if Path(directory) == Path(OVERLAY_MERGED) else set()
|
||||
shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True, copy_function=copy_file, ignore=ignore_readiness_marker)
|
||||
cloudlog.event('update copy complete', duration=time.monotonic() - started, files=copied)
|
||||
|
||||
if progress is not None:
|
||||
progress('Verifying the prepared update...', None)
|
||||
run(["git", "reset", "--hard", "--no-recurse-submodules"], FINALIZED)
|
||||
validate_revision(FINALIZED, "HEAD")
|
||||
validate_update_revision(FINALIZED, "HEAD", branch)
|
||||
marker = Path(FINALIZED, LEGACY_OS_MARKER)
|
||||
marker.unlink(missing_ok=True)
|
||||
if legacy_return:
|
||||
# Bind installation to the exact OS validated during staging. The launcher
|
||||
# rechecks this before swapping trees, even for an ordinary manual reboot.
|
||||
os_version = HARDWARE.get_os_version()
|
||||
if not legacy_os_compatible(run(['git', 'show', 'HEAD:launch_env.sh'], FINALIZED), os_version):
|
||||
raise FastUpdateIncompatible('Update requires a different or unverified operating system')
|
||||
with marker.open('x') as f:
|
||||
f.write(os_version + '\n')
|
||||
|
||||
if progress is not None:
|
||||
progress('Syncing the prepared update to storage...', None)
|
||||
@@ -381,11 +423,11 @@ class Updater:
|
||||
if (not idle() or self.params.get('UpdaterState') != 'idle' or
|
||||
self._check_thread is not None or not self.update_ready or Path(FINALIZED, '.overlay_consistent').is_symlink()):
|
||||
return False
|
||||
validate_revision(FINALIZED, 'HEAD')
|
||||
validate_update_revision(FINALIZED, 'HEAD', self.get_branch(FINALIZED))
|
||||
return True
|
||||
try:
|
||||
return scheduled_restart(self.params, self.install_settings, ready, parked, now)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
except (OSError, ValueError, FastUpdateError, subprocess.SubprocessError):
|
||||
cloudlog.exception('Scheduled update restart was not admitted')
|
||||
return False
|
||||
finally:
|
||||
@@ -760,10 +802,9 @@ class Updater:
|
||||
fetched_commit = selected_commit or self.branches[branch]
|
||||
run(['git', 'fetch', '--progress', '--depth=1', '--filter=blob:none', '--no-tags', '--no-recurse-submodules',
|
||||
'origin', f'+{fetched_commit}:refs/remotes/origin/{branch}'], OVERLAY_MERGED, progress=git_progress)
|
||||
# Fetch the manifest under the download guard before read-only validation.
|
||||
if run(["git", "ls-tree", fetched_commit, "--", "upstream-sync.json"], OVERLAY_MERGED).split()[:1] == ['100644']:
|
||||
run(["git", "show", f"{fetched_commit}:upstream-sync.json"], OVERLAY_MERGED, download=True)
|
||||
validate_revision(OVERLAY_MERGED, fetched_commit)
|
||||
# Validate before checkout or executing any target code. Legacy returns are
|
||||
# restricted to known release branches and must already support this OS.
|
||||
legacy_return = validate_update_revision(OVERLAY_MERGED, fetched_commit, branch)
|
||||
def git(*args, stdin=None):
|
||||
return run(['git', *args], OVERLAY_MERGED, progress=git_progress, stdin=stdin)
|
||||
fetch_files(git, fetched_commit)
|
||||
@@ -786,11 +827,11 @@ class Updater:
|
||||
["git", "clean", "-xdff"],
|
||||
]
|
||||
r = [run(cmd, OVERLAY_MERGED, download=cmd[1] in ('checkout', 'reset'), progress=apply_progress) for cmd in cmds]
|
||||
validate_revision(OVERLAY_MERGED, "HEAD")
|
||||
validate_update_revision(OVERLAY_MERGED, "HEAD", branch)
|
||||
cloudlog.info("git reset success: %s", '\n'.join(r))
|
||||
|
||||
# TODO: show agnos download progress
|
||||
if AGNOS:
|
||||
if AGNOS and not legacy_return:
|
||||
handle_agnos_update()
|
||||
|
||||
# Create the finalized, ready-to-swap update
|
||||
|
||||
Reference in New Issue
Block a user