#!/usr/bin/env python3 import os import re import datetime import subprocess import psutil import signal import fcntl import threading import time from collections import defaultdict from pathlib import Path from iqpilot.common.basedir import BASEDIR from iqpilot.common.params import Params from iqpilot.common.time_helpers import system_time_valid from iqpilot.common.markdown import parse_markdown from iqpilot.common.swaglog import cloudlog from iqpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from iqpilot.system.hardware import AGNOS, HARDWARE from iqpilot.system.version import get_build_metadata, IQ_BRANCH_MIGRATIONS LOCK_FILE = os.getenv("UPDATER_LOCK_FILE", "/tmp/safe_staging_overlay.lock") STAGING_ROOT = os.getenv("UPDATER_STAGING_ROOT", "/data/safe_staging") OVERLAY_INIT = Path(os.path.join(BASEDIR, ".overlay_init")) # do not allow to engage after this many hours onroad and this many routes HOURS_NO_CONNECTIVITY_MAX = 27 ROUTES_NO_CONNECTIVITY_MAX = 84 # send an offroad prompt after this many hours onroad and this many routes HOURS_NO_CONNECTIVITY_PROMPT = 23 ROUTES_NO_CONNECTIVITY_PROMPT = 80 class UserRequest: NONE = 0 CHECK = 1 FETCH = 2 class UpdateInstallMode: DOWNLOAD_ONLY = "download_only" DOWNLOAD_AND_INSTALL = "download_and_install" class WaitTimeHelper: def __init__(self): self.ready_event = threading.Event() self.user_request = UserRequest.NONE signal.signal(signal.SIGHUP, self.update_now) signal.signal(signal.SIGUSR1, self.check_now) def update_now(self, signum: int, frame) -> None: cloudlog.info("caught SIGHUP, attempting to downloading update") self.user_request = UserRequest.FETCH self.ready_event.set() def check_now(self, signum: int, frame) -> None: cloudlog.info("caught SIGUSR1, checking for updates") self.user_request = UserRequest.CHECK self.ready_event.set() def sleep(self, t: float) -> None: self.ready_event.wait(timeout=t) def write_time_to_param(params, param) -> None: t = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) params.put(param, t) def run(cmd: list[str], cwd: str | None = None) -> str: return subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT, encoding='utf8') def has_git_repo(path: str) -> bool: return os.path.isdir(os.path.join(path, ".git")) def display_commit_date(value: str) -> str: value = value.strip() match = re.fullmatch(r"'?\d+ (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) [+-]\d{4}'?", value) return match.group(1) if match else value.strip("'") def cleanup_stale_overlay() -> None: try: merged = os.path.join(STAGING_ROOT, "merged") if os.path.ismount(merged): run(["sudo", "umount", "-l", merged]) if os.path.isdir(STAGING_ROOT): run(["sudo", "rm", "-rf", STAGING_ROOT]) OVERLAY_INIT.unlink(missing_ok=True) except Exception: cloudlog.exception("updater: failed to clean up stale overlay") def parse_release_notes(basedir: str) -> bytes: try: with open(os.path.join(basedir, "iqpilot", "docs", "CHANGELOG.md"), "rb") as f: r = f.read().split(b'\n\n', 1)[0] # Slice latest release notes try: return bytes(parse_markdown(r.decode("utf-8")), encoding="utf-8") except Exception: return r + b"\n" except FileNotFoundError: pass except Exception: cloudlog.exception("failed to parse release notes") return b"" def get_agnos_target_versions(launch_env_dir: str) -> tuple[str, list[str]]: out = run([ "bash", "-c", r'unset AGNOS_VERSION AGNOS_COMPAT_VERSIONS && source launch_env.sh && printf "%s\n%s\n" "${AGNOS_VERSION}" "${AGNOS_COMPAT_VERSIONS}"', ], launch_env_dir) lines = out.splitlines() expected_version = lines[0].strip() if len(lines) > 0 else "" compat_versions = [v.strip() for v in (lines[1] if len(lines) > 1 else "").split(",") if v.strip()] return expected_version, compat_versions def agnos_version_allowed(current_version: str, expected_version: str, compat_versions: list[str]) -> bool: if current_version == expected_version or current_version.startswith(f"{expected_version}-"): return True return current_version in compat_versions def configure_git_auth(cwd: str) -> None: """Install the IQ.Lvbs read-only git credential helper before any remote op. The IQ.Firewall blocks anonymous access to the git server (to stop bandwidth- wasting mirrors), so the fleet must authenticate. The read token + credential helper live in a proprietary, signed, compiled bundle (iqpilot_private.konn3kt.iqlvbs.git_remote); the token never appears in .git/config, argv, or this open-source file. On dev installs without the bundle this is a no-op and ambient git credentials are used.""" try: from iqpilot.system.proprietary_runtime._verified_import import import_verified_module import_verified_module("iqpilot_updater_private", "iqpilot_private.updater.git_remote").configure(cwd) except Exception: cloudlog.info("updater: proprietary git auth unavailable; using ambient git credentials") try: from iqpilot.common.git_creds import configure as configure_user_creds configure_user_creds(cwd) except Exception: cloudlog.exception("updater: user git credentials unavailable") def setup_git_options(cwd: str) -> None: # We sync FS object atimes (which NEOS doesn't use) and mtimes, but ctimes # are outside user control. Make sure Git is set up to ignore system ctimes, # because they change when we make hard links during finalize. Otherwise, # there is a lot of unnecessary churn. This appears to be a common need on # OSX as well: https://www.git-tower.com/blog/make-git-rebase-safe-on-osx/ # We are using copytree to copy the directory, which also changes # inode numbers. Ignore those changes too. # Set protocol to the new version (default after git 2.26) to reduce data # usage on git fetch --dry-run from about 400KB to 18KB. git_cfg = [ ("core.trustctime", "false"), ("core.checkStat", "minimal"), ("protocol.version", "2"), ("gc.auto", "0"), ("gc.autoDetach", "false"), ] for option, value in git_cfg: run(["git", "config", option, value], cwd) def cleanup_stale_prebuilt_marker(cwd: str, branch: str) -> None: prebuilt_path = os.path.join(cwd, "prebuilt") if not os.path.exists(prebuilt_path): return tracked = True try: run(["git", "ls-files", "--error-unmatch", "prebuilt"], cwd) except subprocess.CalledProcessError: tracked = False if not tracked and not branch.endswith("-prebuilt"): os.remove(prebuilt_path) cloudlog.info("removed stale untracked prebuilt marker on non-prebuilt branch %s", branch) def handle_agnos_update() -> None: from iqpilot.system.hardware.tici.agnos import flash_agnos_update, get_target_slot_number cur_version = HARDWARE.get_os_version() device_type = HARDWARE.get_device_type() is_tici_c3 = device_type in ("tici", "three") updated_version, compat_versions = get_agnos_target_versions(BASEDIR) cloudlog.info(f"AGNOS version check: current={cur_version}, target={updated_version}, compat={compat_versions}") if agnos_version_allowed(cur_version, updated_version, compat_versions): return cloudlog.info(f"Beginning background installation for AGNOS {updated_version}") set_offroad_alert("Offroad_NeosUpdate", True) manifest_name = "agnos_tici_15_1.json" if is_tici_c3 else "agnos.json" manifest_path = os.path.join(BASEDIR, "iqpilot/system/hardware/tici", manifest_name) cloudlog.info(f"AGNOS manifest selected: device_type={device_type}, manifest={manifest_name}") target_slot_number = get_target_slot_number() flash_agnos_update(manifest_path, target_slot_number, cloudlog) set_offroad_alert("Offroad_NeosUpdate", False) class Updater: def __init__(self): self.params = Params() self.branches = defaultdict(lambda: None) self._has_internet: bool = False # The commit/branch the running processes booted from. We update BASEDIR in # place, so this is what we diff against to know a reboot is needed. try: self.running_commit = self.get_commit_hash(BASEDIR) self.running_branch = self.get_branch(BASEDIR) self.running_description = self.get_description(BASEDIR) except Exception: cloudlog.exception("updater: failed to capture running version") self.running_commit = "" self.running_branch = "" self.running_description = "" @property def git_mode(self) -> bool: return has_git_repo(BASEDIR) @property def has_internet(self) -> bool: return self._has_internet @property def install_mode(self) -> str: mode = self.params.get("UpdaterInstallMode") if mode not in (UpdateInstallMode.DOWNLOAD_ONLY, UpdateInstallMode.DOWNLOAD_AND_INSTALL): return UpdateInstallMode.DOWNLOAD_AND_INSTALL return mode @property def target_branch(self) -> str: b: str | None = self.params.get("UpdaterTargetBranch") if b is None: b = self.get_branch(BASEDIR) b = IQ_BRANCH_MIGRATIONS.get((HARDWARE.get_device_type(), b), b) return b @property def update_ready(self) -> bool: """True when the code on disk differs from what's running -> reboot to apply. After a reboot this is False again, since running_* is recaptured at start.""" if not self.git_mode: return False on_disk_commit = self.get_commit_hash(BASEDIR) on_disk_branch = self.get_branch(BASEDIR) return (on_disk_commit != self.running_commit) or (on_disk_branch != self.running_branch) @property def update_available(self) -> bool: """True when the code on disk is behind the remote target -> can download.""" if not self.git_mode: return False if len(self.branches) == 0: return False target = self.target_branch on_disk_commit = self.get_commit_hash(BASEDIR) on_disk_branch = self.get_branch(BASEDIR) hash_mismatch = self.branches[target] is not None and on_disk_commit != self.branches[target] branch_mismatch = on_disk_branch != target return hash_mismatch or branch_mismatch def get_branch(self, path: str) -> str: if not has_git_repo(path): try: return get_build_metadata(path).channel except Exception: return "" return run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path).rstrip() def get_commit_hash(self, path: str = BASEDIR) -> str: if not has_git_repo(path): try: return get_build_metadata(path).openpilot.git_commit except Exception: return "" return run(["git", "rev-parse", "HEAD"], path).rstrip() def get_description(self, basedir: str) -> str: if not os.path.exists(basedir): return "" try: if has_git_repo(basedir): with open(os.path.join(basedir, "iqpilot", "common", "version.h")) as version_file: version = version_file.read().split('"')[1] branch = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], basedir).rstrip() commit = run(["git", "rev-parse", "HEAD"], basedir).rstrip()[:7] commit_date = display_commit_date(run(["git", "show", "--no-patch", "--format='%ct %ci'", "HEAD"], basedir)) else: metadata = get_build_metadata(basedir) branch = metadata.channel commit = metadata.openpilot.git_commit[:7] version = metadata.openpilot.version commit_date = display_commit_date(metadata.openpilot.git_commit_date) return f"{version} / {branch} / {commit} / {commit_date}" except Exception: cloudlog.exception("updater.get_description") return "" def set_params(self, update_success: bool, failed_count: int, exception: str | None) -> None: self.params.put("UpdateFailedCount", failed_count) self.params.put("UpdaterTargetBranch", self.target_branch) self.params.put_bool("UpdaterFetchAvailable", self.update_available) if len(self.branches): self.params.put("UpdaterAvailableBranches", ','.join(self.branches.keys())) last_uptime_onroad = self.params.get("UptimeOnroad", return_default=True) last_route_count = self.params.get("RouteCount", return_default=True) if update_success: self.params.put("LastUpdateTime", datetime.datetime.now(datetime.UTC).replace(tzinfo=None)) self.params.put("LastUpdateUptimeOnroad", last_uptime_onroad) self.params.put("LastUpdateRouteCount", last_route_count) if exception is None: self.params.remove("LastUpdateException") else: self.params.put("LastUpdateException", exception) # Current = what's running (captured at boot, stable until reboot). # New = what's on disk now (changes once we fetch in place). self.params.put("UpdaterCurrentDescription", self.running_description) self.params.put("UpdaterCurrentReleaseNotes", parse_release_notes(BASEDIR)) self.params.put("UpdaterNewDescription", self.get_description(BASEDIR)) self.params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR)) self.params.put_bool("UpdateAvailable", self.update_ready) # Handle user prompt for alert in ("Offroad_UpdateFailed", "Offroad_ConnectivityNeeded", "Offroad_ConnectivityNeededPrompt"): set_offroad_alert(alert, False) build_metadata = get_build_metadata() if failed_count > 15 and exception is not None and self.has_internet: if build_metadata.tested_channel: extra_text = "Ensure the software is correctly installed. Uninstall and re-install if this error persists." else: extra_text = exception set_offroad_alert("Offroad_UpdateFailed", True, extra_text=extra_text) def check_for_update(self) -> None: cloudlog.info("checking for updates") if not self.git_mode: cloudlog.info("updater: baked non-git deployment detected; skipping git update check") self._has_internet = False self.branches = defaultdict(lambda: None) return excluded_branches = ('release2', 'release2-staging') # authenticate before the very first remote op (the internet probe below), # since anonymous access is firewalled configure_git_auth(BASEDIR) try: run(["git", "ls-remote", "origin", "HEAD"], BASEDIR) self._has_internet = True except subprocess.CalledProcessError: self._has_internet = False setup_git_options(BASEDIR) output = run(["git", "ls-remote", "--heads"], BASEDIR) self.branches = defaultdict(lambda: None) for line in output.split('\n'): ls_remotes_re = r'(?P\b[0-9a-f]{5,40}\b)(\s+)(refs\/heads\/)(?P.*$)' x = re.fullmatch(ls_remotes_re, line.strip()) if x is not None and x.group('branch_name') not in excluded_branches: self.branches[x.group('branch_name')] = x.group('commit_sha') cur_branch = self.get_branch(BASEDIR) cur_commit = self.get_commit_hash(BASEDIR) new_branch = self.target_branch new_commit = self.branches[new_branch] if (cur_branch, cur_commit) != (new_branch, new_commit): cloudlog.info(f"update available, {cur_branch} ({str(cur_commit)[:7]}) -> {new_branch} ({str(new_commit)[:7]})") else: cloudlog.info(f"up to date on {cur_branch} ({str(cur_commit)[:7]})") def fetch_update(self) -> None: if not self.git_mode: cloudlog.info("updater: baked non-git deployment detected; skipping git fetch") self.params.put("UpdaterState", "idle") return cloudlog.info("attempting git fetch and in-place reset") configure_git_auth(BASEDIR) self.params.put("UpdaterState", "downloading...") self.params.put_bool("UpdateAvailable", False) setup_git_options(BASEDIR) run(["git", "config", "--replace-all", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], BASEDIR) branch = self.target_branch git_fetch_output = run(["git", "fetch", "origin", branch], BASEDIR) cloudlog.info("git fetch success: %s", git_fetch_output) cloudlog.info("git reset in progress") cmds = [ ["git", "checkout", "--force", "-B", branch, "FETCH_HEAD"], ["git", "branch", "--set-upstream-to", f"origin/{branch}"], ["git", "reset", "--hard", "FETCH_HEAD"], ] r = [run(cmd, BASEDIR) for cmd in cmds] cloudlog.info("git reset success: %s", '\n'.join(r)) cleanup_stale_prebuilt_marker(BASEDIR, branch) # TODO: show agnos download progress if AGNOS: handle_agnos_update() cloudlog.info("update applied to disk; reboot to finish") # cap the reboot quiet-window deferral so an idle SSH session can't hold updates forever MAX_SSH_DEFER_S = 2 * 60 * 60 def has_active_ssh_session() -> bool: """True when someone is interactively logged in over SSH. tmux's own panes register in utmp with a tmux(...) host and don't count.""" try: out = subprocess.check_output(["who"], encoding="utf8", timeout=5) except Exception: return False for line in out.splitlines(): fields = line.split() if len(fields) >= 2 and fields[1].startswith("pts/") and "tmux(" not in line: return True return False def prepare_environment() -> bool: """Run the boot-time env sync + build right after an update lands on disk, while the device is offroad and the stack is idle -- so the reboot that follows starts on a warm cache with nothing heavy left to do at boot. launch_chffrplus.sh keeps the identical steps as its fallback path.""" # same env the launch script builds under: repo on PYTHONPATH, venv tools # (uv, cython, ...) on PATH env = os.environ.copy() env["PYTHONPATH"] = BASEDIR + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "") env["PATH"] = os.pathsep.join([os.path.join(BASEDIR, ".venv", "bin"), "/usr/local/venv/bin", env.get("PATH", "")]) def _run(cmd: list[str], cwd: str) -> None: subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT, encoding="utf8", env=env) try: env_sync = os.path.join(BASEDIR, "artifacts", "runtime", "env_sync.sh") if os.path.exists(env_sync): cloudlog.info("update prep: syncing python env") _run(["bash", env_sync], BASEDIR) venv_python = os.path.join(BASEDIR, ".venv", "bin", "python3") build_py = os.path.join(BASEDIR, "iqpilot", "system", "manager", "build.py") if not os.path.exists(os.path.join(BASEDIR, "prebuilt")) and os.path.exists(venv_python) and os.path.exists(build_py): cloudlog.info("update prep: building") _run([venv_python, build_py, "--headless"], os.path.dirname(build_py)) cloudlog.info("update prep: done, next boot starts on a warm cache") return True except subprocess.CalledProcessError as e: output = e.output[-4096:] if e.output else "" cloudlog.event("update prep failed", cmd=e.cmd, output=output, returncode=e.returncode, error=True) return False def publish_running_version(params: Params) -> None: try: updater = Updater() release_notes = parse_release_notes(BASEDIR) params.put("UpdaterState", "idle") params.put("UpdaterTargetBranch", updater.target_branch) params.put("UpdaterCurrentDescription", updater.running_description) params.put("UpdaterCurrentReleaseNotes", release_notes) params.put("UpdaterNewDescription", updater.running_description) params.put("UpdaterNewReleaseNotes", release_notes) params.put_bool("UpdaterFetchAvailable", False) params.put_bool("UpdateAvailable", False) except Exception: cloudlog.exception("updater: failed to publish running version") def main() -> None: params = Params() if params.get_bool("DisableUpdates"): cloudlog.warning("updates are disabled by the DisableUpdates param") publish_running_version(params) exit(0) with open(LOCK_FILE, 'w') as ov_lock_fd: try: fcntl.flock(ov_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError as e: raise RuntimeError("couldn't get overlay lock; is another instance running?") from e # Set low io priority proc = psutil.Process() if psutil.LINUX: proc.ionice(psutil.IOPRIO_CLASS_BE, value=7) cleanup_stale_overlay() if not params.get("InstallDate"): t = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) params.put("InstallDate", t) updater = Updater() update_failed_count = 0 # TODO: Load from param? wait_helper = WaitTimeHelper() params.put("UpdaterState", "idle") params.put_bool("UpdateAvailable", False) # Run the update loop first_run = True # commit hash the apply-time prep (env sync + build) last succeeded for prepared_commit = updater.running_commit # when the pending reboot first became eligible, for the SSH quiet-window cap install_eligible_since = None while True: wait_helper.ready_event.clear() # Attempt an update exception = None install_pending = False try: # ensure we have some params written soon after startup updater.set_params(False, update_failed_count, exception) if not system_time_valid() or first_run: first_run = False wait_helper.sleep(60) continue update_failed_count += 1 # check for update params.put("UpdaterState", "checking...") updater.check_for_update() # download update last_fetch = params.get("UpdaterLastFetchTime") timed_out = last_fetch is None or (datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - last_fetch > datetime.timedelta(days=3)) user_requested_fetch = wait_helper.user_request == UserRequest.FETCH if params.get_bool("IsOnroad") and not user_requested_fetch: # the update download + AGNOS flash hold hundreds of MB; onroad that trips lowMemory, # so defer the heavy work until parked cloudlog.info("skipping fetch, device is onroad") elif params.get_bool("NetworkMetered") and not timed_out and not user_requested_fetch: cloudlog.info("skipping fetch, connection metered") elif wait_helper.user_request == UserRequest.CHECK: cloudlog.info("skipping fetch, only checking") elif updater.update_available or user_requested_fetch: updater.fetch_update() write_time_to_param(params, "UpdaterLastFetchTime") else: cloudlog.info("already up to date, skipping fetch") # Finish a fetched update. update_ready stays true across cycles (on-disk # vs running commit), so failed prep and deferred reboots retry here on # the short sleep instead of being one-shot at fetch time. if updater.update_ready and not params.get_bool("IsOnroad"): on_disk_commit = updater.get_commit_hash(BASEDIR) if prepared_commit != on_disk_commit: if prepare_environment(): prepared_commit = on_disk_commit if prepared_commit != on_disk_commit: # prep failed: hold the reboot and retry next cycle. The boot-time # fallback path still exists, but rebooting into it is exactly the # heavy all-at-once boot that takes devices down. install_pending = True elif updater.install_mode == UpdateInstallMode.DOWNLOAD_AND_INSTALL: if install_eligible_since is None: install_eligible_since = time.monotonic() if has_active_ssh_session() and time.monotonic() - install_eligible_since < MAX_SSH_DEFER_S: cloudlog.info("update ready; deferring reboot, active SSH session") install_pending = True else: cloudlog.info("update ready; auto-install mode enabled, triggering reboot") params.put_bool("DoReboot", True) else: install_eligible_since = None update_failed_count = 0 except subprocess.CalledProcessError as e: cloudlog.event( "update process failed", cmd=e.cmd, output=e.output, returncode=e.returncode ) exception = f"command failed: {e.cmd}\n{e.output}" except Exception as e: cloudlog.exception("uncaught updated exception, shouldn't happen") exception = str(e) try: params.put("UpdaterState", "idle") update_successful = (update_failed_count == 0) updater.set_params(update_successful, update_failed_count, exception) except Exception: cloudlog.exception("uncaught updated exception while setting params, shouldn't happen") # infrequent attempts if we successfully updated recently; short cadence # while an install is pending (failed prep retry or deferred reboot) wait_helper.user_request = UserRequest.NONE wait_helper.sleep(5*60 if (update_failed_count > 0 or install_pending) else 1.5*60*60) if __name__ == "__main__": main()