mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
627 lines
34 KiB
Python
627 lines
34 KiB
Python
"""Parked requests to the existing updater and manager."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import stat
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from openpilot.common.basedir import BASEDIR
|
|
from openpilot.common.hardware import COMMA_HARDWARE
|
|
from openpilot.common.params import Params
|
|
from openpilot.common.vendor_manifest import validate_revision
|
|
from openpilot.starpilot.galaxy.software_status import SoftwareStatus, SoftwareUnavailable
|
|
from openpilot.starpilot.saved_source import read_saved
|
|
from openpilot.starpilot.software.update_control import UpdaterControlError
|
|
from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, automatic_downloads
|
|
from openpilot.starpilot.software.auto_install import DEFAULT_CRON, InstallSettings, cron_fields, next_restart
|
|
from openpilot.starpilot.software.history import METADATA_ROOT, commit_history, downloaded_versions, previous_version, release_notes, recent_versions
|
|
|
|
|
|
FINALIZED = Path(os.environ.get("UPDATER_STAGING_ROOT", "/data/safe_staging")) / "finalized"
|
|
BRANCH_PATTERN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}\Z")
|
|
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z")
|
|
MAX_BRANCH_LIST = 32768
|
|
MAX_BRANCHES = 256
|
|
SOURCE_ACTIONS = frozenset(("update", "fast", "rollback", "versions", "version", "download-version"))
|
|
PROCESS_TITLE = b"openpilot.system.updated.updated"
|
|
|
|
|
|
class SoftwareOperationError(Exception):
|
|
def __init__(self, message: str, status: int = 409):
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
class UpdaterProcess:
|
|
"""Resolve the exact manager child before using its updater-owned control."""
|
|
|
|
def __init__(self, messages=None, *, control=None, proc_root: Path = Path("/proc"),
|
|
mono_ns: Callable[[], int] = time.monotonic_ns):
|
|
if control is None:
|
|
from openpilot.starpilot.software import update_control
|
|
control = update_control
|
|
self.messages = messages
|
|
self.control = control
|
|
self.proc_root = proc_root
|
|
self.mono_ns = mono_ns
|
|
self.created_ns = mono_ns()
|
|
self.lock = threading.Lock()
|
|
|
|
def _manager_pid(self) -> int:
|
|
if self.messages is None:
|
|
from openpilot.cereal import messaging
|
|
self.messages = messaging.SubMaster(["managerState"])
|
|
sm = self.messages
|
|
sm.update(0)
|
|
now = self.mono_ns()
|
|
received = int(sm.recv_time["managerState"] * 1e9)
|
|
published = int(sm.logMonoTime["managerState"])
|
|
age = now - received
|
|
if (not sm.seen["managerState"] or not sm.alive["managerState"] or not sm.valid["managerState"] or
|
|
not 0 <= age <= 3_000_000_000 or received <= self.created_ns or
|
|
not 0 <= now - published <= 3_000_000_000 or published <= self.created_ns):
|
|
raise SoftwareOperationError("Updater manager status is unavailable", 503)
|
|
matches = [p for p in sm["managerState"].processes if p.name == "updated"]
|
|
if len(matches) != 1 or not matches[0].running or not matches[0].shouldBeRunning or int(matches[0].pid) <= 1:
|
|
raise SoftwareOperationError("Updater is not running", 503)
|
|
return int(matches[0].pid)
|
|
|
|
def _proc_identity(self, pid: int) -> int:
|
|
base = self.proc_root / str(pid)
|
|
try:
|
|
raw = (base / "stat").read_text()
|
|
right = raw.rfind(")")
|
|
if right < 0:
|
|
raise ValueError
|
|
fields = raw[right + 2:].split()
|
|
start = int(fields[19])
|
|
cmdline = (base / "cmdline").read_bytes().split(b"\0", 1)[0]
|
|
if start <= 0 or cmdline != PROCESS_TITLE:
|
|
raise ValueError
|
|
return start
|
|
except (OSError, ValueError, IndexError):
|
|
raise SoftwareOperationError("Updater process identity is unavailable", 503) from None
|
|
|
|
def current_identity(self) -> tuple[int, int] | None:
|
|
with self.lock:
|
|
try:
|
|
pid = self._manager_pid()
|
|
start = self._proc_identity(pid)
|
|
if self._manager_pid() == pid and self._proc_identity(pid) == start and self.control.available(pid, start):
|
|
return pid, start
|
|
except (SoftwareOperationError, UpdaterControlError, OSError, RuntimeError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
def available(self) -> bool:
|
|
return self.current_identity() is not None
|
|
|
|
def send(self, action: str, *, branch=None, commit=None, selected_commit=None) -> tuple[int, int]:
|
|
if action not in ("check", "download", "update", "fast", "rollback", "versions", "version", "download-version"):
|
|
raise SoftwareOperationError("Invalid updater command", 400)
|
|
with self.lock:
|
|
pid = self._manager_pid()
|
|
start = self._proc_identity(pid)
|
|
if self._manager_pid() != pid or self._proc_identity(pid) != start:
|
|
raise SoftwareOperationError("Updater process changed", 503)
|
|
try:
|
|
if action in ('version', 'download-version'):
|
|
self.control.send(pid, start, action, branch=branch, commit=commit, selected_commit=selected_commit)
|
|
elif action in ("update", "fast", "rollback", "versions"):
|
|
self.control.send(pid, start, action, branch=branch, commit=commit)
|
|
else:
|
|
self.control.send(pid, start, action)
|
|
except (UpdaterControlError, OSError, RuntimeError, ValueError):
|
|
raise SoftwareOperationError("Updater control is unavailable", 503) from None
|
|
return pid, start
|
|
|
|
def close(self) -> None:
|
|
with self.lock:
|
|
if self.messages is not None:
|
|
for sock in getattr(self.messages, "sock", {}).values():
|
|
close = getattr(sock, "close", None)
|
|
if close:
|
|
close()
|
|
self.messages = None
|
|
|
|
|
|
def _git_identity(path: Path) -> tuple[str, str] | None:
|
|
try:
|
|
branch = subprocess.run(["git", "-C", str(path), "symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
check=True, capture_output=True, text=True, timeout=2).stdout.strip()
|
|
commit = subprocess.run(["git", "-C", str(path), "rev-parse", "--verify", "HEAD^{commit}"],
|
|
check=True, capture_output=True, text=True, timeout=2).stdout.strip()
|
|
if not BRANCH_PATTERN.fullmatch(branch) or not COMMIT_PATTERN.fullmatch(commit):
|
|
return None
|
|
return branch, commit
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
|
|
|
|
class SoftwareOperations:
|
|
def __init__(self, params=None, status=None, parked=None, process=None, *, finalized=FINALIZED,
|
|
installed=BASEDIR, git_identity=_git_identity, clock=time.monotonic, history_reader=commit_history, refresh=None,
|
|
metadata=METADATA_ROOT, install_settings=None):
|
|
self.params = params if params is not None else Params()
|
|
self.status = status if status is not None else SoftwareStatus(self.params)
|
|
if parked is None:
|
|
from openpilot.starpilot.drive_state.evidence import PhysicalSource
|
|
self.context = PhysicalSource()
|
|
physical = self.context
|
|
self.parked = lambda: physical.update_ready(self._flag('IsOffroad') is True)
|
|
else:
|
|
self.context = None
|
|
self.parked = parked
|
|
self.process = process if process is not None else UpdaterProcess()
|
|
self.refresh = refresh
|
|
self.finalized = Path(finalized)
|
|
self.staging = self.finalized.parent / 'merged'
|
|
self.metadata = Path(metadata)
|
|
self.install_settings = install_settings if install_settings is not None else InstallSettings()
|
|
self.installed = Path(installed)
|
|
self.git_identity = git_identity
|
|
self.clock = clock
|
|
self.history_reader = history_reader
|
|
self._history_key = None
|
|
self._history_at = float('-inf')
|
|
self._history = None
|
|
self.lock = threading.RLock()
|
|
self.selected_target: str | None = None
|
|
self.request: dict | None = None
|
|
self._baseline: tuple | None = None
|
|
self._started = 0.0
|
|
self._fast_commit = None
|
|
self._request_owner = None
|
|
|
|
def _param(self, key: str, limit: int = 256) -> str | None:
|
|
try:
|
|
value = self.params.get(key)
|
|
if isinstance(value, bytes):
|
|
value = value.decode("utf-8")
|
|
if value is not None and (type(value) is not str or len(value.encode()) > limit or not value.isprintable()):
|
|
raise ValueError
|
|
return value
|
|
except (OSError, ValueError, UnicodeError, TypeError):
|
|
raise SoftwareOperationError("Updater status is unavailable", 503) from None
|
|
|
|
def _branches(self) -> list[str]:
|
|
raw = self._param("UpdaterAvailableBranches", MAX_BRANCH_LIST)
|
|
if raw is None or not raw:
|
|
return []
|
|
entries = raw.split(",")
|
|
if len(entries) > MAX_BRANCHES:
|
|
return []
|
|
return list(dict.fromkeys(branch for branch in entries if BRANCH_PATTERN.fullmatch(branch)))
|
|
|
|
def _flag(self, key: str) -> bool | None:
|
|
try:
|
|
raw, readable = read_saved(self.params, key, 8)
|
|
if not readable:
|
|
return None
|
|
if raw is None:
|
|
return False
|
|
return raw == b"1" if raw in (b"0", b"1") else None
|
|
except (OSError, ValueError, TypeError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def _valid_branch(branch: object) -> bool:
|
|
if type(branch) is not str or not BRANCH_PATTERN.fullmatch(branch):
|
|
return False
|
|
try:
|
|
return subprocess.run(["git", "check-ref-format", "--branch", branch], capture_output=True,
|
|
timeout=2).returncode == 0
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
|
|
def _parked(self) -> bool:
|
|
try:
|
|
return self._flag("IsOffroad") is True and bool(self.parked())
|
|
except (OSError, RuntimeError, ValueError, TypeError):
|
|
return False
|
|
|
|
def _ready(self, status: dict, target: str | None) -> bool:
|
|
try:
|
|
marker = self.finalized.joinpath(".overlay_consistent").lstat()
|
|
regular_marker = stat.S_ISREG(marker.st_mode)
|
|
except OSError:
|
|
regular_marker = False
|
|
if (target is None or status["updater"]["targetBranch"] != target or
|
|
status["updater"]["finalizedUpdateReady"] is not True or
|
|
status["updater"]["state"] != "idle" or
|
|
not regular_marker):
|
|
return False
|
|
final = self.git_identity(self.finalized)
|
|
current = self.git_identity(self.installed)
|
|
if final is None or current is None or final[0] != target or final == current:
|
|
return False
|
|
description = self._param("UpdaterNewDescription")
|
|
parts = description.split(" / ") if description else []
|
|
if len(parts) != 4 or parts[1] != target or not final[1].startswith(parts[2]) or len(parts[2]) != 7:
|
|
return False
|
|
try:
|
|
validate_revision(self.finalized, final[1])
|
|
except (OSError, ValueError, subprocess.SubprocessError):
|
|
return False
|
|
return True
|
|
|
|
def _refresh_request(self, status: dict, current_owner=None, *, parked=False) -> None:
|
|
request = self.request
|
|
if self._baseline is None or request is None or request["state"] != "pending" or request["action"] == "install":
|
|
return
|
|
state = status["updater"]["state"]
|
|
check = (status['updater'].get('fast') or {}).get('check')
|
|
if request['action'] in ('check', 'versions') and check is not None:
|
|
completed = check.get('completedAt')
|
|
if (type(completed) in (int, float) and completed >= self._started and
|
|
(request['target'] is None or check['branch'] == request['target'])):
|
|
if check['state'] == 'complete':
|
|
request.update(state='complete', error=None)
|
|
elif check['state'] == 'error':
|
|
request.update(state='failed', error=check['detail'])
|
|
elif current_owner is not None and self._request_owner is not None and current_owner != self._request_owner:
|
|
request.update(state='failed', error='Metadata check was interrupted when the updater restarted. Retry the check.')
|
|
elif self.clock() - self._started > 90:
|
|
request.update(state='failed', error='Metadata check did not report completion. Retry the check.')
|
|
return
|
|
if (request["action"] not in SOURCE_ACTIONS and request["target"] is not None and
|
|
status["updater"]["targetBranch"] != request["target"]):
|
|
request.update(state="failed", error="Updater target changed")
|
|
elif state == "idle" and status["updater"]["failedCount"] is not None and self._baseline[2] is not None and \
|
|
status["updater"]["failedCount"] > self._baseline[2]:
|
|
error = "Updater reported a failure"
|
|
try:
|
|
error = self._param("LastUpdateException", 4096) or error
|
|
except SoftwareOperationError:
|
|
pass
|
|
request.update(state="failed", error=error)
|
|
elif request['action'] == 'versions' and state == 'idle' and status['updater']['failedCount'] == 0 and \
|
|
status['updater']['lastFetchAt'] is not None and status['updater']['lastFetchAt'] != self._baseline[1]:
|
|
request.update(state='complete', error=None)
|
|
elif state == "idle" and status["updater"]["failedCount"] == 0 and \
|
|
status["updater"]["lastSuccessAt"] is not None and \
|
|
status["updater"]["lastSuccessAt"] != self._baseline[0]:
|
|
if request["action"] in SOURCE_ACTIONS:
|
|
if status["updater"]["lastFetchAt"] != self._baseline[1]:
|
|
installed = self.git_identity(self.installed)
|
|
restarting = self._flag("DoReboot") is True or installed is not None and installed[1] != self._fast_commit
|
|
if request['action'] == 'download-version':
|
|
if self._ready(status, request['target']) and self.git_identity(self.finalized) == (request['target'], request['selectedCommit']):
|
|
request.update(state='complete', error=None)
|
|
else:
|
|
request.update(state='failed', error='Selected version was not prepared; refresh and try again')
|
|
elif request['action'] == 'versions':
|
|
request.update(state='complete', error=None)
|
|
else:
|
|
request.update(state="complete", error=None, outcome="restarting" if restarting else "up_to_date")
|
|
elif request["action"] == "check" or status["updater"]["lastFetchAt"] != self._baseline[1]:
|
|
request.update(state="complete", error=None)
|
|
elif self._ready(status, request["target"]):
|
|
request.update(state="complete", error=None)
|
|
else:
|
|
request.update(state="failed", error="No new download was reported for this branch")
|
|
if request["state"] != "pending":
|
|
return
|
|
if (state == "idle" and self._flag("DoReboot") is False and current_owner is not None and
|
|
((self._request_owner is not None and current_owner != self._request_owner) or
|
|
(self._request_owner is None and parked and
|
|
status["updater"].get("fast") == {"stage": "error", "detail":
|
|
"Update process restarted before completion; check the installed version and retry."}))):
|
|
request.update(state="failed", error="Update was interrupted when the updater stopped. Retry while parked.")
|
|
elif self.clock() - self._started > 900:
|
|
request.update(state="failed", error="Updater did not report completion")
|
|
|
|
def _rollback_available(self):
|
|
try:
|
|
result = subprocess.run(["git", "-C", str(self.installed), "rev-parse", "--verify", "refs/starpilot/previous^{commit}"],
|
|
check=True, capture_output=True, text=True, timeout=2)
|
|
branch = subprocess.run(["git", "-C", str(self.installed), "config", "--local", "--get", "starpilot.previousVersion"],
|
|
check=True, capture_output=True, text=True, timeout=2).stdout.strip()
|
|
saved = branch.split(' ', 1)
|
|
return bool(len(saved) == 2 and saved[0] == result.stdout.strip() and COMMIT_PATTERN.fullmatch(saved[0]) and self._valid_branch(saved[1]))
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
|
|
def snapshot(self, *, include_history: bool = True) -> dict:
|
|
with self.lock:
|
|
try:
|
|
status = self.status.snapshot()
|
|
except SoftwareUnavailable:
|
|
raise SoftwareOperationError("Updater status is unavailable", 503) from None
|
|
identity_reader = getattr(self.process, "current_identity", None)
|
|
current_owner = identity_reader() if callable(identity_reader) else None
|
|
process = current_owner is not None if callable(identity_reader) else self.process.available()
|
|
try:
|
|
fresh = self.refresh is None or self.refresh() is True
|
|
except (OSError, RuntimeError, ValueError, TypeError):
|
|
fresh = False
|
|
if not fresh:
|
|
admission = {'allowed': False, 'reason': 'Current vehicle status refresh failed'}
|
|
elif self.context is not None:
|
|
admission = self.context.update_status(self._flag('IsOffroad') is True)
|
|
else:
|
|
admission = {'allowed': self._parked(),
|
|
'reason': 'Switch the device Offroad and confirm the vehicle is parked before updating'}
|
|
parked = bool(admission['allowed'])
|
|
self._refresh_request(status, current_owner, parked=parked)
|
|
branches = self._branches()
|
|
updater_idle = status["updater"]["state"] == "idle"
|
|
pending = self.request is not None and self.request["state"] == "pending"
|
|
fast = status['updater'].get('fast') or {}
|
|
metadata_busy = fast.get('check', {}).get('state') == 'checking' or pending and self.request['action'] in ('check', 'versions')
|
|
disabled_state = self._flag("DisableUpdates")
|
|
disabled = disabled_state is not False
|
|
reboot = self._flag("DoReboot") is not False
|
|
usable = parked and updater_idle and not disabled and not pending and not reboot and process and not metadata_busy
|
|
can_check = (parked and not disabled and not reboot and process and not metadata_busy and
|
|
status['updater']['state'] in ('idle', 'checking...', 'downloading...', 'finalizing update...') and
|
|
(not pending or self.request['action'] in ('download', 'download-version')))
|
|
target = self.selected_target or status["updater"]["targetBranch"] or status["installed"]["branch"]
|
|
selected = target is not None and target in branches and status["updater"]["targetBranch"] == target
|
|
reason = None
|
|
if not parked:
|
|
reason = admission['reason']
|
|
elif disabled:
|
|
reason = "Updates are disabled"
|
|
elif reboot:
|
|
reason = "Reboot is pending"
|
|
elif not process:
|
|
reason = "Getting the updater ready…" if COMMA_HARDWARE else "Updater status is unavailable"
|
|
elif pending or not updater_idle or metadata_busy:
|
|
reason = "Updater is busy"
|
|
fast = status["updater"].get("fast") or {}
|
|
active = (status['updater']['state'] == 'updating...' or
|
|
status['updater']['state'] in ('downloading...', 'finalizing update...') and
|
|
fast.get('stage') not in ('complete', 'error'))
|
|
stage = fast["stage"] if fast and active else status["updater"]["state"]
|
|
if stage == "idle":
|
|
stage = None
|
|
detail = fast["detail"] if fast and active else ""
|
|
if not process and not updater_idle:
|
|
stage = "unavailable"
|
|
detail = ("Waiting for the device updater service…" if COMMA_HARDWARE else
|
|
"Update checks require the device updater service, which is unavailable when Galaxy runs alone on a development computer.")
|
|
elif self.request is not None and self.request.get("error") == "Updater did not report completion":
|
|
stage, detail = "timed_out", "Request timed out; the updater may still be running"
|
|
install = self.install_settings.read()
|
|
prepared = self.git_identity(self.finalized) if status["updater"]["finalizedUpdateReady"] else None
|
|
restart = next_restart(install["cron"], datetime.now(ZoneInfo(install.get("timezone", "UTC")))) if install and install["enabled"] and prepared else None
|
|
return {
|
|
"repository": self._repository(),
|
|
"updaterAvailable": process, "progress": {"stage": stage, "detail": detail, "percent": fast.get('percent') if active else None,
|
|
**({'background': fast['background']} if active and 'background' in fast else {})},
|
|
"parked": parked, "availableBranches": branches, "selectedTarget": target,
|
|
"canCheck": can_check, "canFastUpdate": usable and self._valid_branch(status["installed"]["branch"]), "canSelect": usable and bool(branches),
|
|
"canRollback": usable and self._rollback_available(),
|
|
"canDownload": usable and selected, "canInstall": usable and selected and self._ready(status, target),
|
|
"reason": reason, "request": dict(self.request) if self.request is not None else None,
|
|
"automaticDownloads": automatic_downloads(self.params), "canConfigure": parked and not reboot,
|
|
"automaticInstalls": install['enabled'] if install else None,
|
|
"restartSchedule": install['cron'] if install else DEFAULT_CRON,
|
|
"installPreferenceRevision": install['revision'] if install else None,
|
|
"restartTimezone": install.get("timezone", "UTC") if install else "UTC",
|
|
"preparedCommit": prepared[1] if prepared else None,
|
|
"nextRestartAt": restart.isoformat() if restart else None,
|
|
**({"history": self._history_snapshot(status)} if include_history else {}),
|
|
}
|
|
|
|
def _repository(self) -> str | None:
|
|
remote = self._param('GitRemote') or ''
|
|
match = re.fullmatch(r'(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?/?', remote)
|
|
return match[1] if match else None
|
|
|
|
def _history_snapshot(self, status: dict) -> dict:
|
|
branch = self.selected_target or status['updater']['targetBranch'] or status['installed']['branch']
|
|
key = (status['installed']['commit'], self._param('UpdaterNewDescription'), branch,
|
|
status['updater']['lastFetchAt'], status['updater'].get('lastCheckedAt'), status['updater']['state'])
|
|
now = self.clock()
|
|
if self._history is not None and self._history_key == key and 0 <= now - self._history_at < 30.:
|
|
return self._history
|
|
recent = recent_versions(self.installed, branch)
|
|
staged_recent = recent_versions(self.staging, branch)
|
|
if staged_recent['head'] is not None:
|
|
recent = staged_recent
|
|
elif recent['head'] is None:
|
|
recent = recent_versions(self.finalized, branch)
|
|
metadata = recent_versions(self.metadata, branch)
|
|
if metadata['head'] is not None:
|
|
recent = metadata
|
|
installed = self.git_identity(self.installed)
|
|
downloaded = self.git_identity(self.finalized)
|
|
current_rows = self.history_reader(self.installed, installed[1], first_parent=True) if installed else []
|
|
previous = previous_version(self.installed)
|
|
downloaded_rows = (self.history_reader(self.finalized, downloaded[1])
|
|
if downloaded and downloaded != installed else [])
|
|
# Readiness and installation still require _ready(); a history entry never
|
|
# grants permission to boot or execute a revision.
|
|
self._history = {
|
|
'installed': current_rows, 'downloaded': downloaded_rows, 'recent': recent, 'previous': previous,
|
|
'downloadedCommits': downloaded_versions((self.staging if self.staging.exists() else self.finalized, self.installed),
|
|
(row['hash'] for row in [*recent['entries'], *current_rows, *([previous] if previous else [])])),
|
|
'currentReleaseNotes': release_notes(self.params, 'UpdaterCurrentReleaseNotes'),
|
|
'downloadedReleaseNotes': release_notes(self.params, 'UpdaterNewReleaseNotes') if downloaded_rows else None,
|
|
}
|
|
self._history_at, self._history_key = now, key
|
|
return self._history
|
|
|
|
def _preferences(self, payload: dict, authorized: Callable[[], bool]) -> dict:
|
|
expected = payload.get('expectedAutomaticDownloads')
|
|
enabled = payload.get('automaticDownloads')
|
|
if (set(payload) != {'action', 'automaticDownloads', 'expectedAutomaticDownloads'} or
|
|
type(enabled) is not bool or expected is not None and type(expected) is not bool):
|
|
raise SoftwareOperationError('Invalid software preferences', 400)
|
|
with self.lock:
|
|
if not authorized():
|
|
raise SoftwareOperationError('Session expired', 403)
|
|
if not self._parked() or self._flag('DoReboot') is not False:
|
|
raise SoftwareOperationError('Park the vehicle to change update preferences', 409)
|
|
if automatic_downloads(self.params) is not expected:
|
|
raise SoftwareOperationError('Update preference changed; refresh before saving', 409)
|
|
if not authorized() or not self._parked() or self._flag('DoReboot') is not False:
|
|
raise SoftwareOperationError('Session or parked state changed', 403)
|
|
if automatic_downloads(self.params) is not expected:
|
|
raise SoftwareOperationError('Update preference changed; refresh before saving', 409)
|
|
self.params.put_bool(AUTOMATIC_DOWNLOADS, enabled, block=True)
|
|
if automatic_downloads(self.params) is not enabled:
|
|
raise SoftwareOperationError('Update preference could not be verified', 503)
|
|
return self.snapshot()
|
|
|
|
def _install_preferences(self, payload: dict, authorized: Callable[[], bool]) -> dict:
|
|
if (set(payload) not in ({'action', 'automaticInstalls', 'restartSchedule', 'expectedRevision'},
|
|
{'action', 'automaticInstalls', 'restartSchedule', 'expectedRevision', 'restartTimezone'}) or
|
|
type(payload['automaticInstalls']) is not bool or type(payload['restartSchedule']) is not str or
|
|
payload['expectedRevision'] is not None and
|
|
(type(payload['expectedRevision']) is not str or not re.fullmatch('[0-9a-f]{32}', payload['expectedRevision']))):
|
|
raise SoftwareOperationError('Invalid install preferences', 400)
|
|
try:
|
|
cron_fields(payload['restartSchedule'])
|
|
if 'restartTimezone' in payload:
|
|
if not isinstance(payload['restartTimezone'], str) or len(payload['restartTimezone']) > 128:
|
|
raise ValueError('Invalid timezone')
|
|
ZoneInfo(payload['restartTimezone'])
|
|
except (ValueError, ZoneInfoNotFoundError) as error:
|
|
raise SoftwareOperationError(str(error), 400) from error
|
|
with self.lock:
|
|
if not authorized():
|
|
raise SoftwareOperationError('Session expired', 403)
|
|
if not self._parked() or self._flag('DoReboot') is not False:
|
|
raise SoftwareOperationError('Park the vehicle to change install preferences', 409)
|
|
def admitted():
|
|
return authorized() and self._parked() and self._flag('DoReboot') is False
|
|
try:
|
|
self.install_settings.save(payload['automaticInstalls'], payload['restartSchedule'], payload['expectedRevision'], admitted,
|
|
timezone=payload.get('restartTimezone'))
|
|
except PermissionError as error:
|
|
raise SoftwareOperationError(str(error), 403) from error
|
|
except ValueError as error:
|
|
raise SoftwareOperationError(str(error), 409) from error
|
|
return self.snapshot()
|
|
|
|
def action(self, action: str, payload: object, *, authorized: Callable[[], bool]) -> dict:
|
|
if (type(payload) is not dict or type(action) is not str or
|
|
action not in ("check", "download", "select", "install", "preferences", "install-preferences", "update", "fast", "rollback",
|
|
"versions", "version", "download-version")):
|
|
raise SoftwareOperationError("Invalid software action", 400)
|
|
assert isinstance(payload, dict)
|
|
payload = dict(payload)
|
|
if payload.get("action") != action or any(type(key) is not str for key in payload):
|
|
raise SoftwareOperationError("Invalid software action", 400)
|
|
if action == 'preferences':
|
|
return self._preferences(payload, authorized)
|
|
if action == 'install-preferences':
|
|
return self._install_preferences(payload, authorized)
|
|
fields = set(payload) - {"action"}
|
|
if action == "check":
|
|
if fields:
|
|
raise SoftwareOperationError("Invalid software action", 400)
|
|
branch = None
|
|
else:
|
|
pinned_install = action == "install" and fields != {"branch"}
|
|
required_fields = {"branch", "expectedCommit", "selectedCommit"} if action in ("version", "download-version") or pinned_install else {"branch"}
|
|
if fields != required_fields or not self._valid_branch(payload.get("branch")):
|
|
raise SoftwareOperationError("Invalid branch", 400)
|
|
if pinned_install and any(type(payload[key]) is not str or not COMMIT_PATTERN.fullmatch(payload[key])
|
|
for key in ('expectedCommit', 'selectedCommit')):
|
|
raise SoftwareOperationError("Invalid selected version", 400)
|
|
branch = payload["branch"]
|
|
assert isinstance(branch, str)
|
|
with self.lock:
|
|
if not authorized():
|
|
raise SoftwareOperationError("Session expired", 403)
|
|
metadata_action = action in ('check', 'versions')
|
|
view = self.snapshot(include_history=action in ('version', 'download-version'))
|
|
if not view["parked"]:
|
|
raise SoftwareOperationError("Vehicle is not safely parked", 409)
|
|
if metadata_action and not view['canCheck'] or not metadata_action and view["reason"] is not None:
|
|
raise SoftwareOperationError(view["reason"], 503 if view["reason"] == "Updater status is unavailable" else 409)
|
|
if action not in SOURCE_ACTIONS and branch is not None and branch not in view["availableBranches"]:
|
|
raise SoftwareOperationError("Branch is not in the current updater list", 409)
|
|
if (action not in SOURCE_ACTIONS and action not in ("check", "select") and
|
|
(branch != view["selectedTarget"] or not view["canDownload"])):
|
|
raise SoftwareOperationError("Selected updater target changed", 409)
|
|
if action in SOURCE_ACTIONS:
|
|
identity = self.git_identity(self.installed)
|
|
if (identity is None or not self._valid_branch(identity[0]) or
|
|
(action in ("update", "fast", "versions", "version", "download-version") and
|
|
branch != identity[0] and branch not in view["availableBranches"]) or
|
|
(action == "rollback" and (branch != identity[0] or not view["canRollback"]))):
|
|
raise SoftwareOperationError("Installed checkout or update target is unavailable", 409)
|
|
if action in ('version', 'download-version'):
|
|
target = payload['selectedCommit']
|
|
recent = view['history']['recent']
|
|
selectable = [*recent['entries'], *(view['history']['installed'] if branch == identity[0] else [])]
|
|
if (payload['expectedCommit'] != identity[1] or type(target) is not str or not COMMIT_PATTERN.fullmatch(target) or
|
|
recent['branch'] != branch or not any(row['hash'] == target for row in selectable)):
|
|
raise SoftwareOperationError('Selected version or installed revision changed; refresh and try again', 409)
|
|
if action == "install" and not view["canInstall"]:
|
|
raise SoftwareOperationError("Finalized update does not match selected branch", 409)
|
|
if not authorized() or not self._parked():
|
|
raise SoftwareOperationError("Session or parked state changed", 403)
|
|
current = self.status.snapshot()
|
|
if (not metadata_action and current["updater"]["state"] != "idle" or self._flag("DisableUpdates") is not False or
|
|
self._flag("DoReboot") is not False or not self.process.available() or
|
|
(action not in SOURCE_ACTIONS and branch is not None and branch not in self._branches())):
|
|
raise SoftwareOperationError("Updater state changed", 409)
|
|
if action == 'versions':
|
|
self.selected_target = branch
|
|
if action == "select":
|
|
self.params.put("UpdaterTargetBranch", branch, block=True)
|
|
self.selected_target = branch
|
|
return self.snapshot()
|
|
accepted_owner = None
|
|
request_started = self.clock()
|
|
if action == "install":
|
|
status = self.status.snapshot()
|
|
if not self._ready(status, branch) or self._flag("DoReboot") is not False:
|
|
raise SoftwareOperationError("Update is no longer ready", 409)
|
|
if not authorized() or not self._parked():
|
|
raise SoftwareOperationError("Session or parked state changed", 403)
|
|
if 'selectedCommit' in payload and (status['installed']['commit'] != payload['expectedCommit'] or
|
|
self.git_identity(self.installed) != (status['installed']['branch'], payload['expectedCommit']) or
|
|
self.git_identity(self.finalized) != (branch, payload['selectedCommit'])):
|
|
raise SoftwareOperationError("Selected version changed; refresh before installing", 409)
|
|
self.params.put_bool("DoReboot", True, block=True)
|
|
else:
|
|
status = self.status.snapshot()
|
|
baseline = (status["updater"]["lastSuccessAt"], status["updater"]["lastFetchAt"],
|
|
status["updater"]["failedCount"])
|
|
if not authorized() or not self._parked():
|
|
raise SoftwareOperationError("Session or parked state changed", 403)
|
|
if action in SOURCE_ACTIONS:
|
|
latest_identity = self.git_identity(self.installed)
|
|
if latest_identity is None or latest_identity != identity:
|
|
raise SoftwareOperationError("Installed checkout changed while preparing the update; retry when it finishes", 409)
|
|
if action in ('version', 'download-version') and identity[1] != payload['expectedCommit']:
|
|
raise SoftwareOperationError('Installed revision changed before version request; refresh and try again', 409)
|
|
self._fast_commit = identity[1]
|
|
if action in SOURCE_ACTIONS:
|
|
if action in ('version', 'download-version'):
|
|
accepted_owner = self.process.send(action, branch=branch, commit=identity[1], selected_commit=payload['selectedCommit'])
|
|
else:
|
|
accepted_owner = self.process.send(action, branch=branch, commit=identity[1])
|
|
else:
|
|
accepted_owner = self.process.send(action)
|
|
self.request = {"id": secrets.token_hex(8), "action": action,
|
|
"target": branch if branch is not None else status["updater"]["targetBranch"],
|
|
"state": "pending", "error": None}
|
|
if action in ('version', 'download-version', 'install') and 'selectedCommit' in payload:
|
|
self.request['selectedCommit'] = payload['selectedCommit']
|
|
self._baseline = baseline if action != "install" else None
|
|
self._request_owner = accepted_owner
|
|
self._started = request_started
|
|
return self.snapshot(include_history=not metadata_action)
|
|
|
|
def close(self) -> None:
|
|
if self.context is not None:
|
|
self.context.close()
|
|
self.process.close()
|