mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-27 08:33:45 +08:00
Software and AGNOS
Manage software updates and AGNOS retained state through explicit manifests and update preferences. Co-authored-by: Daniel Koepping <elkoled@gmail.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
This commit is contained in:
+33
-2
@@ -5,6 +5,11 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
source "$DIR/launch_env.sh"
|
||||
|
||||
function agnos_init {
|
||||
if [ "${AGNOS_UPDATE_POLICY:-auto}" = "retain" ] && [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then
|
||||
echo "This StarPilot build retains AGNOS $AGNOS_VERSION; installed OS does not match. No OS update was attempted."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# TODO: move this to agnos
|
||||
sudo rm -f /data/etc/NetworkManager/system-connections/*.nmmeta
|
||||
rm -f /data/scons_cache/config.lock
|
||||
@@ -20,7 +25,7 @@ function agnos_init {
|
||||
# Check if AGNOS update is required
|
||||
if [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then
|
||||
AGNOS_PY="$DIR/openpilot/common/hardware/comma/agnos.py"
|
||||
MANIFEST="$DIR/openpilot/system/hardware/comma/agnos.json"
|
||||
MANIFEST="$DIR/openpilot/common/hardware/comma/agnos.json"
|
||||
if "$AGNOS_PY" --verify "$MANIFEST"; then
|
||||
sudo reboot
|
||||
fi
|
||||
@@ -28,6 +33,28 @@ function agnos_init {
|
||||
"$DIR/openpilot/common/hardware/comma/updater" "$AGNOS_PY" "$MANIFEST"
|
||||
done
|
||||
fi
|
||||
|
||||
# Add missing USB rules for C3's internal Panda; C3X/C4 use SPI.
|
||||
if [ "$(tr -d '\0' 2>/dev/null < /sys/firmware/devicetree/base/model)" = "comma tici" ]; then
|
||||
if ! sudo timeout -k 1s 5s sh -ec '
|
||||
mkdir -p /run/udev/rules.d
|
||||
cat > /run/udev/rules.d/99-starpilot-panda.rules <<EOF
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcc", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddee", MODE="0666"
|
||||
EOF
|
||||
# Batch AGNOS GPIO permissions so coldplug does not block Panda USB events.
|
||||
if [ -f /etc/udev/rules.d/99-gpio.rules ] && ! mountpoint -q /etc/udev/rules.d/99-gpio.rules; then
|
||||
sed "s/{} \\\\;/{} +/g" /etc/udev/rules.d/99-gpio.rules > /run/udev/rules.d/99-gpio.rules
|
||||
mount --bind /run/udev/rules.d/99-gpio.rules /etc/udev/rules.d/99-gpio.rules
|
||||
fi
|
||||
udevadm control --reload-rules
|
||||
for product in ddcc ddee; do
|
||||
udevadm trigger --subsystem-match=usb --attr-match=idVendor=3801 --attr-match=idProduct="$product"
|
||||
done
|
||||
'; then
|
||||
echo "Panda USB permission setup failed; continuing startup."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function launch {
|
||||
@@ -81,10 +108,14 @@ function launch {
|
||||
ln -sfn rednose_repo/rednose rednose
|
||||
ln -sfn teleoprtc_repo/teleoprtc teleoprtc
|
||||
ln -sfn tinygrad_repo/tinygrad tinygrad
|
||||
ln -sfn jetlink_repo/jetlink jetlink
|
||||
|
||||
# hardware specific init
|
||||
if [ -f /AGNOS ]; then
|
||||
agnos_init
|
||||
agnos_init || return $?
|
||||
if ! "$DIR/scripts/install_boot_logo.sh"; then
|
||||
echo "StarPilot boot image update failed; continuing startup."
|
||||
fi
|
||||
fi
|
||||
|
||||
# write tmux scrollback to a file
|
||||
|
||||
+8
-1
@@ -6,6 +6,11 @@ export NUMEXPR_NUM_THREADS=1
|
||||
export OPENBLAS_NUM_THREADS=1
|
||||
export VECLIB_MAXIMUM_THREADS=1
|
||||
|
||||
# Use the dependency environment supplied by the retained custom image.
|
||||
if [ -x /usr/local/venv/bin/python3 ]; then
|
||||
export PATH="/usr/local/venv/bin:$PATH"
|
||||
fi
|
||||
|
||||
# models get lower priority than ui
|
||||
# - ui is ~5ms
|
||||
# - modeld is 20ms
|
||||
@@ -16,7 +21,9 @@ export VECLIB_MAXIMUM_THREADS=1
|
||||
export QCOM_PRIORITY=12
|
||||
|
||||
if [ -z "$AGNOS_VERSION" ]; then
|
||||
export AGNOS_VERSION="19.9"
|
||||
export AGNOS_VERSION="19.8.1"
|
||||
fi
|
||||
|
||||
export AGNOS_UPDATE_POLICY="auto"
|
||||
|
||||
export STAGING_ROOT="/data/safe_staging"
|
||||
|
||||
@@ -1,84 +1,85 @@
|
||||
[
|
||||
{
|
||||
"name": "xbl",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl-e8acf2a9cc7f0ce84cb803bfea9477f765c0d7b4daf26048e59651b9e6a7bfbb.img.xz",
|
||||
"hash": "e8acf2a9cc7f0ce84cb803bfea9477f765c0d7b4daf26048e59651b9e6a7bfbb",
|
||||
"hash_raw": "e8acf2a9cc7f0ce84cb803bfea9477f765c0d7b4daf26048e59651b9e6a7bfbb",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl-effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b.img.xz",
|
||||
"hash": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b",
|
||||
"hash_raw": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b",
|
||||
"size": 3282256,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "bea7f1a24428c3ededf672fa4fc78baf180cfbd8aafb77c974655b38517283e3"
|
||||
"ondevice_hash": "ed61a650bea0c56652dd0fc68465d8fc722a4e6489dc8f257630c42c6adcdc89"
|
||||
},
|
||||
{
|
||||
"name": "xbl_config",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl_config-758552ecf92b5569677197783bf0ccb73d7f961685308e45d3276ac9dd974f85.img.xz",
|
||||
"hash": "758552ecf92b5569677197783bf0ccb73d7f961685308e45d3276ac9dd974f85",
|
||||
"hash_raw": "758552ecf92b5569677197783bf0ccb73d7f961685308e45d3276ac9dd974f85",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl_config-63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c.img.xz",
|
||||
"hash": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c",
|
||||
"hash_raw": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c",
|
||||
"size": 98124,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "fb18cde08a98a168961ecd357e92474823046752b94e112f59fe51a6acd7197d"
|
||||
"ondevice_hash": "b12801ffaa81e58e3cef914488d3b447e35483ba549b28c6cd9deb4814c3265f"
|
||||
},
|
||||
{
|
||||
"name": "abl",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/abl-29fd7ed1c012e599420764840f9f11286d34dbff4adaf102a447f06d8c5e0b35.img.xz",
|
||||
"hash": "29fd7ed1c012e599420764840f9f11286d34dbff4adaf102a447f06d8c5e0b35",
|
||||
"hash_raw": "29fd7ed1c012e599420764840f9f11286d34dbff4adaf102a447f06d8c5e0b35",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/abl-32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6.img.xz",
|
||||
"hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6",
|
||||
"hash_raw": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6",
|
||||
"size": 274432,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "29fd7ed1c012e599420764840f9f11286d34dbff4adaf102a447f06d8c5e0b35"
|
||||
"ondevice_hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6"
|
||||
},
|
||||
{
|
||||
"name": "aop",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/aop-78b2287ca219a0811b3004c523fa0f4749e4d1fd92be3aba61699305b7943ad1.img.xz",
|
||||
"hash": "78b2287ca219a0811b3004c523fa0f4749e4d1fd92be3aba61699305b7943ad1",
|
||||
"hash_raw": "78b2287ca219a0811b3004c523fa0f4749e4d1fd92be3aba61699305b7943ad1",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/aop-21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9.img.xz",
|
||||
"hash": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9",
|
||||
"hash_raw": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9",
|
||||
"size": 184364,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "6c9135446bd3fc075fcee59b887a12e49029ab1f98ed8d6d1e32c73569d47de3"
|
||||
"ondevice_hash": "c1be2f4aac5b3af49b904b027faec418d05efd7bd5144eb4fdfcba602bcf2180"
|
||||
},
|
||||
{
|
||||
"name": "devcfg",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/devcfg-f71df3a86958c093ba3969254c4db025187eef9385427f1ade946742939b43cc.img.xz",
|
||||
"hash": "f71df3a86958c093ba3969254c4db025187eef9385427f1ade946742939b43cc",
|
||||
"hash_raw": "f71df3a86958c093ba3969254c4db025187eef9385427f1ade946742939b43cc",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/devcfg-d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620.img.xz",
|
||||
"hash": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620",
|
||||
"hash_raw": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620",
|
||||
"size": 40336,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "2a67971602012c1b43544964709da13c322786b456a8e78568b117e8b1540ce3"
|
||||
"ondevice_hash": "17b229668b20305ff8fa3cd5f94716a3aaa1e5bf9d1c24117eff7f2f81ae719f"
|
||||
},
|
||||
{
|
||||
"name": "boot",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/boot-b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615.img.xz",
|
||||
"hash": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615",
|
||||
"hash_raw": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615",
|
||||
"size": 46962688,
|
||||
"url": "https://files.firestar.link/x/31f0wvnkr4vh/boot31.img.xz",
|
||||
"fallback_urls": [
|
||||
"https://files-east.firestar.link/x/191w2n28bgbb/boot31.img.xz"
|
||||
],
|
||||
"hash": "ac3863c3834a94986647eb61d21796e7550095a18e0119c9102cab06ce390417",
|
||||
"hash_raw": "ac3863c3834a94986647eb61d21796e7550095a18e0119c9102cab06ce390417",
|
||||
"size": 49043456,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "3ef4769bb1cc73bfc31f96f073b46af42dcc0bad4ab2dde32d11929745d0cd28"
|
||||
"ondevice_hash": "b09c28cb609f996fce791c5cf36e00922896d54a54b476a7d9463d4cf817dc9b"
|
||||
},
|
||||
{
|
||||
"name": "system",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img.xz",
|
||||
"hash": "9aebca09b9d142718e71efbfe44b723010eec6e70067c043c63186067291739d",
|
||||
"hash_raw": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7",
|
||||
"url": "https://files.firestar.link/x/ffkgctpyunid/system30.img.xz",
|
||||
"fallback_urls": [
|
||||
"https://files-east.firestar.link/x/9ft817s9f5no/system30.img.xz"
|
||||
],
|
||||
"hash": "9c753bdd90e6079b2cf525659d7523d0ebc0a375bd61e61cca628f1db3832cfd",
|
||||
"hash_raw": "98e15de07c39a19b5fa17c96a67e66d4362f9ebaf7827b640f4127e3b6417da8",
|
||||
"size": 4718592000,
|
||||
"sparse": true,
|
||||
"full_check": false,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "270b76c5c5568828e3883390b83ea445425a104dd42119d68f06093ce6b6fcef",
|
||||
"alt": {
|
||||
"hash": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img",
|
||||
"size": 4718592000
|
||||
}
|
||||
"ondevice_hash": "9625ec32df735b26ea6b0b62834addc8b522108d5f5c4a2aebcb98fc2b94f4e9"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -12,7 +12,7 @@ import requests
|
||||
|
||||
SPARSE_CHUNK_FMT = struct.Struct('H2xI4x')
|
||||
|
||||
AGNOS_MANIFEST_FILE = "openpilot/system/hardware/comma/agnos.json"
|
||||
AGNOS_MANIFEST_FILE = "openpilot/common/hardware/comma/agnos.json"
|
||||
|
||||
|
||||
class StreamingDecompressor:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Bounded local history and plain-text notes from the existing updater."""
|
||||
|
||||
from datetime import datetime
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
|
||||
COMMIT = re.compile(r'[0-9a-f]{40}\Z')
|
||||
MAX_COMMITS = 20
|
||||
MAX_NOTES_BYTES = 32768
|
||||
|
||||
|
||||
def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> list[dict]:
|
||||
if not COMMIT.fullmatch(commit):
|
||||
return []
|
||||
try:
|
||||
output = subprocess.run(
|
||||
['git', '-C', str(path), '--no-pager', 'log', '--no-decorate', '--no-color', '--no-show-signature',
|
||||
*(['--first-parent'] if first_parent else []), f'-{MAX_COMMITS}', '--format=%H%x00%cI%x00%<(240,trunc)%s%x00', commit, '--'],
|
||||
check=True, capture_output=True, text=True, timeout=2).stdout
|
||||
if len(output.encode()) > 32768:
|
||||
return []
|
||||
fields = output.split('\0')
|
||||
rows = []
|
||||
for index in range(0, len(fields) - 2, 3):
|
||||
sha, date, subject = fields[index].strip(), fields[index + 1], fields[index + 2].rstrip()
|
||||
if not COMMIT.fullmatch(sha) or len(subject) > 500:
|
||||
return []
|
||||
parsed = datetime.fromisoformat(date)
|
||||
if parsed.tzinfo is None:
|
||||
return []
|
||||
rows.append({'hash': sha, 'date': parsed.isoformat(), 'subject': ''.join(c for c in subject if c.isprintable())})
|
||||
return rows[:MAX_COMMITS]
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return []
|
||||
|
||||
|
||||
class _NotesText(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts = []
|
||||
self.hidden = 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ('script', 'style'):
|
||||
self.hidden += 1
|
||||
elif not self.hidden and tag in ('br', 'p', 'li', 'h1', 'h2', 'h3'):
|
||||
self.parts.append('\n')
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ('script', 'style'):
|
||||
self.hidden = max(0, self.hidden - 1)
|
||||
elif not self.hidden and tag in ('p', 'li', 'h1', 'h2', 'h3'):
|
||||
self.parts.append('\n')
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.hidden:
|
||||
self.parts.append(data)
|
||||
|
||||
|
||||
def release_notes(params, key: str) -> str | None:
|
||||
raw, readable = read_saved(params, key, MAX_NOTES_BYTES)
|
||||
if not readable or raw is None:
|
||||
return None
|
||||
try:
|
||||
parser = _NotesText()
|
||||
parser.feed(raw.decode('utf-8'))
|
||||
parser.close()
|
||||
return re.sub(r'\n{3,}', '\n\n', ''.join(parser.parts)).strip()
|
||||
except (UnicodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def recent_versions(path: Path, branch: str | None) -> dict:
|
||||
empty = {'branch': branch, 'head': None, 'entries': []}
|
||||
if type(branch) is not str or not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}', branch):
|
||||
return empty
|
||||
ref = f'refs/starpilot/recent/{branch}'
|
||||
try:
|
||||
subprocess.run(['git', '-C', str(path), 'check-ref-format', ref], check=True, capture_output=True, timeout=2)
|
||||
head = subprocess.run(['git', '-C', str(path), 'rev-parse', '--verify', ref + '^{commit}'],
|
||||
check=True, capture_output=True, text=True, timeout=2).stdout.strip()
|
||||
if not COMMIT.fullmatch(head):
|
||||
return empty
|
||||
return {'branch': branch, 'head': head, 'entries': commit_history(path, head, first_parent=True)}
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return empty
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Automatic download preference; manual updater requests remain independent."""
|
||||
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
|
||||
AUTOMATIC_DOWNLOADS = 'UpdaterAutomaticDownloads'
|
||||
|
||||
|
||||
def automatic_downloads(params) -> bool | None:
|
||||
try:
|
||||
raw, readable = read_saved(params, AUTOMATIC_DOWNLOADS, 1)
|
||||
except (OSError, KeyError, ValueError):
|
||||
return None
|
||||
if not readable or raw not in (None, b'0', b'1'):
|
||||
return None
|
||||
return raw != b'0' # Preserve the upstream schedule for existing installations.
|
||||
|
||||
|
||||
def download_permitted(params, *, manual: bool, check_only: bool) -> bool:
|
||||
return not check_only and (manual or automatic_downloads(params) is True)
|
||||
@@ -0,0 +1,41 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
|
||||
from openpilot.starpilot.software.history import commit_history, release_notes, recent_versions
|
||||
|
||||
|
||||
def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path):
|
||||
def git(*args):
|
||||
return subprocess.run(['git', '-C', str(tmp_path), '-c', 'core.hooksPath=/dev/null',
|
||||
'-c', 'user.name=Test', '-c', 'user.email=test@example.com', *args],
|
||||
check=True, capture_output=True, text=True).stdout.strip()
|
||||
git('init', '-q')
|
||||
for index in range(23):
|
||||
git('commit', '--allow-empty', '-qm', f'Change {index:02d}')
|
||||
commit = git('rev-parse', 'HEAD~1')
|
||||
rows = commit_history(tmp_path, commit)
|
||||
assert len(rows) == 20
|
||||
assert rows[0]['hash'] == commit and rows[0]['subject'] == 'Change 21'
|
||||
assert rows[-1]['subject'] == 'Change 02'
|
||||
assert all(row['date'] for row in rows)
|
||||
assert commit_history(tmp_path, '--all') == []
|
||||
assert commit_history(tmp_path, 'f' * 40) == []
|
||||
assert recent_versions(tmp_path, 'Dom')['entries'] == []
|
||||
git('update-ref', 'refs/starpilot/recent/Dom', commit)
|
||||
recent = recent_versions(tmp_path, 'Dom')
|
||||
assert recent['head'] == commit and recent['branch'] == 'Dom' and recent['entries'] == rows
|
||||
assert recent_versions(tmp_path, '../escape')['entries'] == []
|
||||
assert recent_versions(tmp_path, 'Dom')['entries'][0]['hash'] == commit
|
||||
|
||||
|
||||
def test_release_notes_are_bounded_plain_text(tmp_path):
|
||||
params = SimpleNamespace(get_param_path=lambda key: str(tmp_path / key))
|
||||
path = Path(params.get_param_path('UpdaterCurrentReleaseNotes'))
|
||||
assert release_notes(params, path.name) is None
|
||||
path.write_text('<h1>StarPilot</h1><ul><li>First & second</li></ul><script>danger()</script><style>.bad{}</style>')
|
||||
assert release_notes(params, path.name) == 'StarPilot\n\nFirst & second'
|
||||
path.write_bytes(b'x' * 32769)
|
||||
assert release_notes(params, path.name) is None
|
||||
path.write_bytes(b'\xff')
|
||||
assert release_notes(params, path.name) is None
|
||||
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, automatic_downloads, download_permitted
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw, expected', [(None, True), (b'1', True), (b'0', False), (b'', None), (b'yes', None), (b'2', None)])
|
||||
def test_default_saved_and_invalid_values_preserve_manual_requests(tmp_path, raw, expected):
|
||||
params = SimpleNamespace(get_param_path=lambda key: str(tmp_path / key))
|
||||
if raw is not None:
|
||||
Path(params.get_param_path(AUTOMATIC_DOWNLOADS)).write_bytes(raw)
|
||||
assert automatic_downloads(params) is expected
|
||||
assert download_permitted(params, manual=False, check_only=False) is (expected is True)
|
||||
assert download_permitted(params, manual=True, check_only=False)
|
||||
assert not download_permitted(params, manual=False, check_only=True)
|
||||
assert not download_permitted(params, manual=True, check_only=True)
|
||||
|
||||
|
||||
def test_nonregular_and_symlink_preferences_do_not_enable_automatic_downloads(tmp_path):
|
||||
params = SimpleNamespace(get_param_path=lambda key: str(tmp_path / key))
|
||||
path = tmp_path / AUTOMATIC_DOWNLOADS
|
||||
path.mkdir()
|
||||
assert automatic_downloads(params) is None
|
||||
path.rmdir()
|
||||
(tmp_path / 'value').write_bytes(b'1')
|
||||
path.symlink_to(tmp_path / 'value')
|
||||
assert automatic_downloads(params) is None
|
||||
@@ -0,0 +1,87 @@
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from openpilot.starpilot.software import update_control
|
||||
|
||||
|
||||
class UpdaterControlTests(unittest.TestCase):
|
||||
def test_invalid_identity_and_action_fail_closed(self):
|
||||
self.assertFalse(update_control.available(0, 0))
|
||||
with self.assertRaises(update_control.UpdaterControlError):
|
||||
update_control.send(0, 0, 'install')
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux') and hasattr(socket, 'SO_PEERCRED'), 'Linux peer credentials required')
|
||||
def test_same_process_control_and_stale_identity(self):
|
||||
actions = []
|
||||
accepted = threading.Event()
|
||||
def request(action):
|
||||
actions.append(action)
|
||||
accepted.set()
|
||||
server = update_control.UpdaterControlServer(request)
|
||||
server.start()
|
||||
self.addCleanup(server.close)
|
||||
pid = os.getpid()
|
||||
start = update_control.process_start(pid)
|
||||
self.assertTrue(update_control.available(pid, start))
|
||||
self.assertEqual(actions, [])
|
||||
update_control.send(pid, start, 'check')
|
||||
self.assertTrue(accepted.wait(1))
|
||||
accepted.clear()
|
||||
update_control.send(pid, start, 'download')
|
||||
self.assertTrue(accepted.wait(1))
|
||||
self.assertEqual(actions, ['check', 'download'])
|
||||
self.assertFalse(update_control.available(pid, start + 1))
|
||||
with self.assertRaises(update_control.UpdaterControlError):
|
||||
update_control.send(pid, start + 1, 'check')
|
||||
|
||||
real_peer = update_control._peer
|
||||
def wrong_server_pid(connection):
|
||||
if threading.current_thread() is threading.main_thread():
|
||||
return pid + 1, os.geteuid()
|
||||
return real_peer(connection)
|
||||
with mock.patch.object(update_control, '_peer', side_effect=wrong_server_pid):
|
||||
with self.assertRaises(update_control.UpdaterControlError):
|
||||
update_control.send(pid, start, 'check')
|
||||
self.assertEqual(actions, ['check', 'download'])
|
||||
|
||||
with mock.patch.object(update_control, '_peer', return_value=(pid + 1, os.geteuid() + 1)):
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(1)
|
||||
connection.connect(update_control._address(pid, start, os.geteuid()))
|
||||
connection.sendall(b'check\n')
|
||||
self.assertEqual(connection.recv(16), b'error\n')
|
||||
self.assertEqual(actions, ['check', 'download'])
|
||||
server.close()
|
||||
self.assertFalse(update_control.available(pid, start))
|
||||
|
||||
def test_wait_helper_maps_control_requests_without_signal_handlers(self):
|
||||
from openpilot.system.updated import updated
|
||||
|
||||
with mock.patch.object(updated.signal, 'signal') as register_signal, \
|
||||
mock.patch.object(updated, 'UpdaterControlServer') as server_type, \
|
||||
mock.patch.object(updated.atexit, 'register'):
|
||||
helper = updated.WaitTimeHelper()
|
||||
self.assertEqual(register_signal.call_count, 2)
|
||||
server_type.assert_called_once()
|
||||
server_type.return_value.start.assert_called_once()
|
||||
self.assertEqual(helper.user_request, updated.UserRequest.NONE)
|
||||
self.assertFalse(helper.ready_event.is_set())
|
||||
helper._control_request('check')
|
||||
self.assertEqual(helper.user_request, updated.UserRequest.CHECK)
|
||||
self.assertTrue(helper.ready_event.is_set())
|
||||
helper.ready_event.clear()
|
||||
helper._control_request('download')
|
||||
self.assertEqual(helper.user_request, updated.UserRequest.FETCH)
|
||||
self.assertTrue(helper.ready_event.is_set())
|
||||
helper.ready_event.clear()
|
||||
helper._control_request('status')
|
||||
self.assertEqual(helper.user_request, updated.UserRequest.FETCH)
|
||||
self.assertFalse(helper.ready_event.is_set())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Same-user, process-bound local control for the running updater."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TIMEOUT = 0.5
|
||||
MAX_COMMAND = 256
|
||||
_BRANCH = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}\Z")
|
||||
_COMMIT = re.compile(r"[0-9a-f]{40}\Z")
|
||||
_CREDENTIALS = struct.Struct('3i')
|
||||
|
||||
|
||||
class UpdaterControlError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def process_start(pid: int) -> int:
|
||||
try:
|
||||
raw = (Path('/proc') / str(pid) / 'stat').read_text()
|
||||
fields = raw[raw.rindex(')') + 2:].split()
|
||||
start = int(fields[19])
|
||||
if start <= 0:
|
||||
raise ValueError
|
||||
return start
|
||||
except (OSError, ValueError, IndexError):
|
||||
raise UpdaterControlError('Updater process identity is unavailable') from None
|
||||
|
||||
|
||||
def _address(pid: int, start: int, uid: int) -> str:
|
||||
if type(pid) is not int or type(start) is not int or pid <= 1 or start <= 0:
|
||||
raise UpdaterControlError('Invalid updater identity')
|
||||
return f'\0starpilot-updater-v1-{uid}-{pid}-{start}'
|
||||
|
||||
|
||||
def _peer(connection: socket.socket) -> tuple[int, int]:
|
||||
if not hasattr(socket, 'SO_PEERCRED'):
|
||||
raise UpdaterControlError('Peer credentials are unavailable')
|
||||
pid, uid, _ = _CREDENTIALS.unpack(connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, _CREDENTIALS.size))
|
||||
return pid, uid
|
||||
|
||||
|
||||
def _line(connection: socket.socket) -> bytes:
|
||||
data = bytearray()
|
||||
while len(data) < MAX_COMMAND:
|
||||
chunk = connection.recv(1)
|
||||
if not chunk:
|
||||
break
|
||||
data.extend(chunk)
|
||||
if chunk == b'\n':
|
||||
return bytes(data)
|
||||
raise UpdaterControlError('Invalid updater control message')
|
||||
|
||||
|
||||
def _exchange(pid: int, start: int, command: bytes) -> None:
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(TIMEOUT)
|
||||
connection.connect(_address(pid, start, os.geteuid()))
|
||||
if _peer(connection) != (pid, os.geteuid()) or process_start(pid) != start:
|
||||
raise UpdaterControlError('Updater process changed')
|
||||
connection.sendall(command + b'\n')
|
||||
if _line(connection) != b'ok\n':
|
||||
raise UpdaterControlError('Updater declined control request')
|
||||
except (OSError, TimeoutError):
|
||||
raise UpdaterControlError('Updater control is unavailable') from None
|
||||
|
||||
|
||||
def available(pid: int, start: int) -> bool:
|
||||
try:
|
||||
_exchange(pid, start, b'status')
|
||||
return True
|
||||
except UpdaterControlError:
|
||||
return False
|
||||
|
||||
|
||||
def _command(action: str, branch=None, commit=None, selected_commit=None) -> bytes:
|
||||
if action == 'version' and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit):
|
||||
base = _command('fast', branch, commit).decode('ascii').split(' ', 1)[1]
|
||||
return f'version {base} {selected_commit}'.encode('ascii')
|
||||
if selected_commit is not None:
|
||||
raise UpdaterControlError('Invalid updater version target')
|
||||
if action in ('check', 'download') and branch is None and commit is None:
|
||||
return action.encode('ascii')
|
||||
if (action in ('update', 'fast', 'rollback', 'versions') and type(branch) is str and type(commit) is str and
|
||||
_BRANCH.fullmatch(branch) and _COMMIT.fullmatch(commit)):
|
||||
return f'{action} {branch} {commit}'.encode('ascii')
|
||||
raise UpdaterControlError('Invalid updater control action')
|
||||
|
||||
|
||||
def _parse(command: bytes):
|
||||
if command in (b'status\n', b'check\n', b'download\n'):
|
||||
return command[:-1].decode('ascii'), None, None
|
||||
try:
|
||||
parts = command[:-1].decode('ascii').split(' ')
|
||||
if len(parts) == 4 and parts[0] == 'version' and command.endswith(b'\n'):
|
||||
action, branch, commit, selected = parts
|
||||
if _command(action, branch, commit, selected) + b'\n' == command:
|
||||
return action, branch, commit, selected
|
||||
action, branch, commit = parts
|
||||
except (UnicodeError, ValueError):
|
||||
raise UpdaterControlError('Invalid updater control message') from None
|
||||
if not command.endswith(b'\n') or action not in ('update', 'fast', 'rollback', 'versions') or _command(action, branch, commit) + b'\n' != command:
|
||||
raise UpdaterControlError('Invalid updater control message')
|
||||
return action, branch, commit
|
||||
|
||||
|
||||
def send(pid: int, start: int, action: str, *, branch=None, commit=None, selected_commit=None) -> None:
|
||||
_exchange(pid, start, _command(action, branch, commit, selected_commit))
|
||||
|
||||
|
||||
class UpdaterControlServer:
|
||||
def __init__(self, request: Callable[..., None]):
|
||||
self.request = request
|
||||
self.socket: socket.socket | None = None
|
||||
self.thread: threading.Thread | None = None
|
||||
self.stopped = threading.Event()
|
||||
|
||||
def start(self) -> None:
|
||||
if self.socket is not None:
|
||||
raise UpdaterControlError('Updater control already started')
|
||||
pid = os.getpid()
|
||||
address = _address(pid, process_start(pid), os.geteuid())
|
||||
listener = None
|
||||
try:
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
listener.settimeout(TIMEOUT)
|
||||
listener.bind(address)
|
||||
listener.listen(4)
|
||||
except OSError:
|
||||
if listener is not None:
|
||||
listener.close()
|
||||
raise UpdaterControlError('Updater control cannot listen') from None
|
||||
self.socket = listener
|
||||
self.thread = threading.Thread(target=self._run, args=(listener,), name='updater-control', daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _run(self, listener: socket.socket) -> None:
|
||||
while not self.stopped.is_set():
|
||||
try:
|
||||
connection, _ = listener.accept()
|
||||
except TimeoutError:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
with connection:
|
||||
connection.settimeout(TIMEOUT)
|
||||
try:
|
||||
_, uid = _peer(connection)
|
||||
command = _line(connection)
|
||||
if uid != os.geteuid():
|
||||
connection.sendall(b'error\n')
|
||||
else:
|
||||
parsed = _parse(command)
|
||||
action, branch, commit = parsed[:3]
|
||||
if action == 'version':
|
||||
self.request(action, branch=branch, commit=commit, selected_commit=parsed[3])
|
||||
elif action in ('update', 'fast', 'rollback', 'versions'):
|
||||
self.request(action, branch=branch, commit=commit)
|
||||
elif action != 'status':
|
||||
self.request(action)
|
||||
connection.sendall(b'ok\n')
|
||||
except (RuntimeError, ValueError):
|
||||
try:
|
||||
connection.sendall(b'error\n')
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
self.stopped.set()
|
||||
if self.socket is not None:
|
||||
self.socket.close()
|
||||
self.socket = None
|
||||
if self.thread is not None:
|
||||
self.thread.join(timeout=TIMEOUT * 2)
|
||||
self.thread = None
|
||||
Reference in New Issue
Block a user