Files
StarPilot/openpilot/starpilot/software/auto_install.py
T

170 lines
6.8 KiB
Python

"""Opt-in, offroad-only restarts on a numeric five-field cron schedule."""
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
import fcntl
import json
import os
from pathlib import Path
import re
import stat
import uuid
from zoneinfo import ZoneInfo
from openpilot.starpilot.state_migration import _atomic_write, _private_dir
DEFAULT_CRON = '0 0 * * *'
_LIMITS = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
def cron_fields(expression: str) -> list[set[int]]:
if not isinstance(expression, str) or len(expression) > 128 or len(expression.split()) != 5:
raise ValueError('Use five cron fields: minute hour day month weekday')
fields = []
for field, (low, high) in zip(expression.split(), _LIMITS, strict=True):
values = set()
for item in field.split(','):
parts = item.split('/')
if len(parts) > 2:
raise ValueError('Invalid cron step')
step = int(parts[1]) if len(parts) == 2 else 1
base = parts[0]
if base == '*':
start, end = low, high
elif '-' in base:
start, end = map(int, base.split('-'))
else:
start = int(base)
end = high if len(parts) == 2 else start
if not low <= start <= end <= high or not 1 <= step <= high - low + 1:
raise ValueError('Cron field is outside its allowed range')
values.update(range(start, end + 1, step))
fields.append(values)
return fields
def cron_matches(expression: str, now: datetime) -> bool:
minute, hour, day, month, weekday = cron_fields(expression)
dow = (now.weekday() + 1) % 7
day_matches, weekday_matches = now.day in day, dow in weekday or dow == 0 and 7 in weekday
parts = expression.split()
restricted = not parts[2].startswith('*') and not parts[4].startswith('*')
calendar_matches = (day_matches or weekday_matches) if restricted else day_matches and weekday_matches
return now.minute in minute and now.hour in hour and now.month in month and calendar_matches
def next_restart(expression: str, now: datetime) -> datetime | None:
minutes, hours, days, months, weekdays = cron_fields(expression)
restricted = not expression.split()[2].startswith('*') and not expression.split()[4].startswith('*')
# Eight years includes the longest gap between leap days.
for offset in range(366 * 8):
day = now + timedelta(days=offset)
dow = (day.weekday() + 1) % 7
day_matches = day.day in days
weekday_matches = dow in weekdays or dow == 0 and 7 in weekdays
if day.month not in months or not ((day_matches or weekday_matches) if restricted else day_matches and weekday_matches):
continue
for hour in sorted(hours):
for minute in sorted(minutes):
candidate = day.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate > now and candidate.astimezone(UTC).astimezone(now.tzinfo) == candidate:
return candidate
return None
class InstallSettings:
def __init__(self, root: Path | None = None):
self._root = Path(root) if root is not None else None
@property
def root(self) -> Path:
if self._root is None:
from openpilot.starpilot.storage import starpilot_storage_root
self._root = starpilot_storage_root() / 'software' / 'auto-install'
return self._root
@property
def path(self) -> Path:
return self.root / 'settings.json'
def read(self) -> dict | None:
try:
fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb') as source:
if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
return None
raw = source.read(4097)
if len(raw) > 4096:
return None
value = json.loads(raw)
keys = {'version', 'enabled', 'cron', 'revision', 'lastAttempt'}
if (not isinstance(value, dict) or set(value) not in (keys, keys | {'timezone'}) or
type(value['version']) is not int or value['version'] != 1 or type(value['enabled']) is not bool or not isinstance(value['revision'], str) or
not re.fullmatch('[0-9a-f]{32}', value['revision']) or
value['lastAttempt'] is not None and (not isinstance(value['lastAttempt'], str) or len(value['lastAttempt']) > 64)):
return None
ZoneInfo(value.get('timezone', 'UTC'))
cron_fields(value['cron'])
return value
except FileNotFoundError:
return {'version': 1, 'enabled': False, 'cron': DEFAULT_CRON, 'revision': '0' * 32, 'lastAttempt': None}
except (OSError, ValueError, TypeError, KeyError):
return None
@contextmanager
def locked(self):
_private_dir(self.root)
fd = os.open(self.root / '.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def write(self, value: dict) -> None:
_atomic_write(self.path, json.dumps(value, sort_keys=True).encode())
def save(self, enabled: bool, expression: str, expected_revision: str | None, admitted, timezone: str | None = None) -> dict:
if type(enabled) is not bool:
raise ValueError('Choose whether to install updates automatically')
cron_fields(expression)
if timezone is not None:
ZoneInfo(timezone)
with self.locked():
current = self.read()
if (current['revision'] if current else None) != expected_revision:
raise ValueError('Install preferences changed; refresh before saving')
if not admitted():
raise PermissionError('Park the vehicle before changing install preferences')
value = {'version': 1, 'enabled': enabled, 'cron': ' '.join(expression.split()), 'revision': uuid.uuid4().hex,
'lastAttempt': current['lastAttempt'] if current else None}
value['timezone'] = timezone or (current.get('timezone', 'UTC') if current else 'UTC')
self.write(value)
if self.read() != value:
raise OSError('Install preferences could not be verified')
return value
def scheduled_restart(params, settings: InstallSettings, ready, parked, now: datetime) -> bool:
value = settings.read()
if value:
now = now.astimezone(ZoneInfo(value.get('timezone', 'UTC')))
if not value or not value['enabled'] or not cron_matches(value['cron'], now):
return False
occurrence = now.strftime('%Y-%m-%dT%H:%M%z')
def admitted():
return (params.get_bool('IsOffroad') and not params.get_bool('DisableUpdates') and
not params.get_bool('DoReboot') and ready() and parked())
with settings.locked():
current = settings.read()
if current is None or current != value or current['lastAttempt'] == occurrence or not admitted():
return False
# Reserve the occurrence before rebooting so a failed restart cannot loop within the same minute.
settings.write({**current, 'lastAttempt': occurrence})
if not admitted():
return False
params.put_bool('DoReboot', True, block=True)
return True