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:
firestar5683
2026-09-25 14:53:21 -05:00
parent 14e42178df
commit 0db0d2f589
12 changed files with 529 additions and 39 deletions
+33 -2
View File
@@ -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
View File
@@ -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"
+36 -35
View File
@@ -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"
}
]
]
+1 -1
View File
@@ -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:
+90
View File
@@ -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 &amp; 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