updated: prep for new updater (#31695)
	
		
	
				
					
				
			* wip * wip * wip * wip * wip * wip * wip * proc * release * fix * this should move here * e2e update test * that too * fix * fix * fix running in docker * don't think GHA will work * also test switching branches * it's a test * lets not delete that yet * fix * fix2 * fix * fix * tests too * fix * cleanup / init * test agnos update * test agnos * move this back up * no diffpull/214/head
							parent
							
								
									c30688fe3a
								
							
						
					
					
						commit
						b93f6ce4f6
					
				
				 5 changed files with 419 additions and 284 deletions
			
			
		| @ -0,0 +1,100 @@ | |||||||
|  | import abc | ||||||
|  | import os | ||||||
|  | 
 | ||||||
|  | from pathlib import Path | ||||||
|  | import subprocess | ||||||
|  | from typing import List | ||||||
|  | 
 | ||||||
|  | from markdown_it import MarkdownIt | ||||||
|  | from openpilot.common.params import Params | ||||||
|  | from openpilot.common.swaglog import cloudlog | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | LOCK_FILE = os.getenv("UPDATER_LOCK_FILE", "/tmp/safe_staging_overlay.lock") | ||||||
|  | STAGING_ROOT = os.getenv("UPDATER_STAGING_ROOT", "/data/safe_staging") | ||||||
|  | FINALIZED = os.path.join(STAGING_ROOT, "finalized") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def run(cmd: list[str], cwd: str = None) -> str: | ||||||
|  |   return subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT, encoding='utf8') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class UpdateStrategy(abc.ABC): | ||||||
|  |   def __init__(self): | ||||||
|  |     self.params = Params() | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def init(self) -> None: | ||||||
|  |     pass | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def cleanup(self) -> None: | ||||||
|  |     pass | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def get_available_channels(self) -> List[str]: | ||||||
|  |     """List of available channels to install, (branches, releases, etc)""" | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def current_channel(self) -> str: | ||||||
|  |     """Current channel installed""" | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def fetched_path(self) -> str: | ||||||
|  |     """Path to the fetched update""" | ||||||
|  | 
 | ||||||
|  |   @property | ||||||
|  |   def target_channel(self) -> str: | ||||||
|  |     """Target Channel""" | ||||||
|  |     b: str | None = self.params.get("UpdaterTargetBranch", encoding='utf-8') | ||||||
|  |     if b is None: | ||||||
|  |       b = self.current_channel() | ||||||
|  |     return b | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def update_ready(self) -> bool: | ||||||
|  |     """Check if an update is ready to be installed""" | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def update_available(self) -> bool: | ||||||
|  |     """Check if an update is available for the current channel""" | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def describe_current_channel(self) -> tuple[str, str]: | ||||||
|  |     """Describe the current channel installed, (description, release_notes)""" | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def describe_ready_channel(self) -> tuple[str, str]: | ||||||
|  |     """Describe the channel that is ready to be installed, (description, release_notes)""" | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def fetch_update(self) -> None: | ||||||
|  |     pass | ||||||
|  | 
 | ||||||
|  |   @abc.abstractmethod | ||||||
|  |   def finalize_update(self) -> None: | ||||||
|  |     pass | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def set_consistent_flag(consistent: bool) -> None: | ||||||
|  |   os.sync() | ||||||
|  |   consistent_file = Path(os.path.join(FINALIZED, ".overlay_consistent")) | ||||||
|  |   if consistent: | ||||||
|  |     consistent_file.touch() | ||||||
|  |   elif not consistent: | ||||||
|  |     consistent_file.unlink(missing_ok=True) | ||||||
|  |   os.sync() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def parse_release_notes(releases_md: str) -> str: | ||||||
|  |   try: | ||||||
|  |     r = releases_md.split('\n\n', 1)[0]  # Slice latest release notes | ||||||
|  |     try: | ||||||
|  |       return str(MarkdownIt().render(r)) | ||||||
|  |     except Exception: | ||||||
|  |       return r + "\n" | ||||||
|  |   except FileNotFoundError: | ||||||
|  |     pass | ||||||
|  |   except Exception: | ||||||
|  |     cloudlog.exception("failed to parse release notes") | ||||||
|  |   return "" | ||||||
| @ -0,0 +1,241 @@ | |||||||
|  | import datetime | ||||||
|  | import os | ||||||
|  | import re | ||||||
|  | import shutil | ||||||
|  | import subprocess | ||||||
|  | import time | ||||||
|  | 
 | ||||||
|  | from collections import defaultdict | ||||||
|  | from pathlib import Path | ||||||
|  | from typing import List | ||||||
|  | 
 | ||||||
|  | from openpilot.common.basedir import BASEDIR | ||||||
|  | from openpilot.common.params import Params | ||||||
|  | from openpilot.common.swaglog import cloudlog | ||||||
|  | from openpilot.selfdrive.updated.common import FINALIZED, STAGING_ROOT, UpdateStrategy, parse_release_notes, set_consistent_flag, run | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | OVERLAY_UPPER = os.path.join(STAGING_ROOT, "upper") | ||||||
|  | OVERLAY_METADATA = os.path.join(STAGING_ROOT, "metadata") | ||||||
|  | OVERLAY_MERGED = os.path.join(STAGING_ROOT, "merged") | ||||||
|  | OVERLAY_INIT = Path(os.path.join(BASEDIR, ".overlay_init")) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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 dismount_overlay() -> None: | ||||||
|  |   if os.path.ismount(OVERLAY_MERGED): | ||||||
|  |     cloudlog.info("unmounting existing overlay") | ||||||
|  |     run(["sudo", "umount", "-l", OVERLAY_MERGED]) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def init_overlay() -> None: | ||||||
|  | 
 | ||||||
|  |   # Re-create the overlay if BASEDIR/.git has changed since we created the overlay | ||||||
|  |   if OVERLAY_INIT.is_file() and os.path.ismount(OVERLAY_MERGED): | ||||||
|  |     git_dir_path = os.path.join(BASEDIR, ".git") | ||||||
|  |     new_files = run(["find", git_dir_path, "-newer", str(OVERLAY_INIT)]) | ||||||
|  |     if not len(new_files.splitlines()): | ||||||
|  |       # A valid overlay already exists | ||||||
|  |       return | ||||||
|  |     else: | ||||||
|  |       cloudlog.info(".git directory changed, recreating overlay") | ||||||
|  | 
 | ||||||
|  |   cloudlog.info("preparing new safe staging area") | ||||||
|  | 
 | ||||||
|  |   params = Params() | ||||||
|  |   params.put_bool("UpdateAvailable", False) | ||||||
|  |   set_consistent_flag(False) | ||||||
|  |   dismount_overlay() | ||||||
|  |   run(["sudo", "rm", "-rf", STAGING_ROOT]) | ||||||
|  |   if os.path.isdir(STAGING_ROOT): | ||||||
|  |     shutil.rmtree(STAGING_ROOT) | ||||||
|  | 
 | ||||||
|  |   for dirname in [STAGING_ROOT, OVERLAY_UPPER, OVERLAY_METADATA, OVERLAY_MERGED]: | ||||||
|  |     os.mkdir(dirname, 0o755) | ||||||
|  | 
 | ||||||
|  |   if os.lstat(BASEDIR).st_dev != os.lstat(OVERLAY_MERGED).st_dev: | ||||||
|  |     raise RuntimeError("base and overlay merge directories are on different filesystems; not valid for overlay FS!") | ||||||
|  | 
 | ||||||
|  |   # Leave a timestamped canary in BASEDIR to check at startup. The device clock | ||||||
|  |   # should be correct by the time we get here. If the init file disappears, or | ||||||
|  |   # critical mtimes in BASEDIR are newer than .overlay_init, continue.sh can | ||||||
|  |   # assume that BASEDIR has used for local development or otherwise modified, | ||||||
|  |   # and skips the update activation attempt. | ||||||
|  |   consistent_file = Path(os.path.join(BASEDIR, ".overlay_consistent")) | ||||||
|  |   if consistent_file.is_file(): | ||||||
|  |     consistent_file.unlink() | ||||||
|  |   OVERLAY_INIT.touch() | ||||||
|  | 
 | ||||||
|  |   os.sync() | ||||||
|  |   overlay_opts = f"lowerdir={BASEDIR},upperdir={OVERLAY_UPPER},workdir={OVERLAY_METADATA}" | ||||||
|  | 
 | ||||||
|  |   mount_cmd = ["mount", "-t", "overlay", "-o", overlay_opts, "none", OVERLAY_MERGED] | ||||||
|  |   run(["sudo"] + mount_cmd) | ||||||
|  |   run(["sudo", "chmod", "755", os.path.join(OVERLAY_METADATA, "work")]) | ||||||
|  | 
 | ||||||
|  |   git_diff = run(["git", "diff"], OVERLAY_MERGED) | ||||||
|  |   params.put("GitDiff", git_diff) | ||||||
|  |   cloudlog.info(f"git diff output:\n{git_diff}") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class GitUpdateStrategy(UpdateStrategy): | ||||||
|  | 
 | ||||||
|  |   def init(self) -> None: | ||||||
|  |     init_overlay() | ||||||
|  | 
 | ||||||
|  |   def cleanup(self) -> None: | ||||||
|  |     OVERLAY_INIT.unlink(missing_ok=True) | ||||||
|  | 
 | ||||||
|  |   def sync_branches(self): | ||||||
|  |     excluded_branches = ('release2', 'release2-staging') | ||||||
|  | 
 | ||||||
|  |     output = run(["git", "ls-remote", "--heads"], OVERLAY_MERGED) | ||||||
|  | 
 | ||||||
|  |     self.branches = defaultdict(lambda: None) | ||||||
|  |     for line in output.split('\n'): | ||||||
|  |       ls_remotes_re = r'(?P<commit_sha>\b[0-9a-f]{5,40}\b)(\s+)(refs\/heads\/)(?P<branch_name>.*$)' | ||||||
|  |       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') | ||||||
|  | 
 | ||||||
|  |     return self.branches | ||||||
|  | 
 | ||||||
|  |   def get_available_channels(self) -> List[str]: | ||||||
|  |     self.sync_branches() | ||||||
|  |     return list(self.branches.keys()) | ||||||
|  | 
 | ||||||
|  |   def update_ready(self) -> bool: | ||||||
|  |     consistent_file = Path(os.path.join(FINALIZED, ".overlay_consistent")) | ||||||
|  |     if consistent_file.is_file(): | ||||||
|  |       hash_mismatch = self.get_commit_hash(BASEDIR) != self.branches[self.target_channel] | ||||||
|  |       branch_mismatch = self.get_branch(BASEDIR) != self.target_channel | ||||||
|  |       on_target_channel = self.get_branch(FINALIZED) == self.target_channel | ||||||
|  |       return ((hash_mismatch or branch_mismatch) and on_target_channel) | ||||||
|  |     return False | ||||||
|  | 
 | ||||||
|  |   def update_available(self) -> bool: | ||||||
|  |     if os.path.isdir(OVERLAY_MERGED) and len(self.get_available_channels()) > 0: | ||||||
|  |       hash_mismatch = self.get_commit_hash(OVERLAY_MERGED) != self.branches[self.target_channel] | ||||||
|  |       branch_mismatch = self.get_branch(OVERLAY_MERGED) != self.target_channel | ||||||
|  |       return hash_mismatch or branch_mismatch | ||||||
|  |     return False | ||||||
|  | 
 | ||||||
|  |   def get_branch(self, path: str) -> str: | ||||||
|  |     return run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path).rstrip() | ||||||
|  | 
 | ||||||
|  |   def get_commit_hash(self, path) -> str: | ||||||
|  |     return run(["git", "rev-parse", "HEAD"], path).rstrip() | ||||||
|  | 
 | ||||||
|  |   def get_current_channel(self) -> str: | ||||||
|  |     return self.get_branch(BASEDIR) | ||||||
|  | 
 | ||||||
|  |   def current_channel(self) -> str: | ||||||
|  |     return self.get_branch(BASEDIR) | ||||||
|  | 
 | ||||||
|  |   def describe_branch(self, basedir) -> str: | ||||||
|  |     if not os.path.exists(basedir): | ||||||
|  |       return "" | ||||||
|  | 
 | ||||||
|  |     version = "" | ||||||
|  |     branch = "" | ||||||
|  |     commit = "" | ||||||
|  |     commit_date = "" | ||||||
|  |     try: | ||||||
|  |       branch = self.get_branch(basedir) | ||||||
|  |       commit = self.get_commit_hash(basedir)[:7] | ||||||
|  |       with open(os.path.join(basedir, "common", "version.h")) as f: | ||||||
|  |         version = f.read().split('"')[1] | ||||||
|  | 
 | ||||||
|  |       commit_unix_ts = run(["git", "show", "-s", "--format=%ct", "HEAD"], basedir).rstrip() | ||||||
|  |       dt = datetime.datetime.fromtimestamp(int(commit_unix_ts)) | ||||||
|  |       commit_date = dt.strftime("%b %d") | ||||||
|  |     except Exception: | ||||||
|  |       cloudlog.exception("updater.get_description") | ||||||
|  |     return f"{version} / {branch} / {commit} / {commit_date}" | ||||||
|  | 
 | ||||||
|  |   def release_notes_branch(self, basedir) -> str: | ||||||
|  |     with open(os.path.join(basedir, "RELEASES.md"), "r") as f: | ||||||
|  |       return parse_release_notes(f.read()) | ||||||
|  | 
 | ||||||
|  |   def describe_current_channel(self) -> tuple[str, str]: | ||||||
|  |     return self.describe_branch(BASEDIR), self.release_notes_branch(BASEDIR) | ||||||
|  | 
 | ||||||
|  |   def describe_ready_channel(self) -> tuple[str, str]: | ||||||
|  |     if self.update_ready(): | ||||||
|  |       return self.describe_branch(FINALIZED), self.release_notes_branch(FINALIZED) | ||||||
|  | 
 | ||||||
|  |     return "", "" | ||||||
|  | 
 | ||||||
|  |   def fetch_update(self): | ||||||
|  |     cloudlog.info("attempting git fetch inside staging overlay") | ||||||
|  | 
 | ||||||
|  |     setup_git_options(OVERLAY_MERGED) | ||||||
|  | 
 | ||||||
|  |     branch = self.target_channel | ||||||
|  |     git_fetch_output = run(["git", "fetch", "origin", branch], OVERLAY_MERGED) | ||||||
|  |     cloudlog.info("git fetch success: %s", git_fetch_output) | ||||||
|  | 
 | ||||||
|  |     cloudlog.info("git reset in progress") | ||||||
|  |     cmds = [ | ||||||
|  |       ["git", "checkout", "--force", "--no-recurse-submodules", "-B", branch, "FETCH_HEAD"], | ||||||
|  |       ["git", "reset", "--hard"], | ||||||
|  |       ["git", "clean", "-xdff"], | ||||||
|  |       ["git", "submodule", "sync"], | ||||||
|  |       ["git", "submodule", "update", "--init", "--recursive"], | ||||||
|  |       ["git", "submodule", "foreach", "--recursive", "git", "reset", "--hard"], | ||||||
|  |     ] | ||||||
|  |     r = [run(cmd, OVERLAY_MERGED) for cmd in cmds] | ||||||
|  |     cloudlog.info("git reset success: %s", '\n'.join(r)) | ||||||
|  | 
 | ||||||
|  |   def fetched_path(self): | ||||||
|  |     return str(OVERLAY_MERGED) | ||||||
|  | 
 | ||||||
|  |   def finalize_update(self) -> None: | ||||||
|  |     """Take the current OverlayFS merged view and finalize a copy outside of | ||||||
|  |     OverlayFS, ready to be swapped-in at BASEDIR. Copy using shutil.copytree""" | ||||||
|  | 
 | ||||||
|  |     # Remove the update ready flag and any old updates | ||||||
|  |     cloudlog.info("creating finalized version of the overlay") | ||||||
|  |     set_consistent_flag(False) | ||||||
|  | 
 | ||||||
|  |     # Copy the merged overlay view and set the update ready flag | ||||||
|  |     if os.path.exists(FINALIZED): | ||||||
|  |       shutil.rmtree(FINALIZED) | ||||||
|  |     shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True) | ||||||
|  | 
 | ||||||
|  |     run(["git", "reset", "--hard"], FINALIZED) | ||||||
|  |     run(["git", "submodule", "foreach", "--recursive", "git", "reset", "--hard"], FINALIZED) | ||||||
|  | 
 | ||||||
|  |     cloudlog.info("Starting git cleanup in finalized update") | ||||||
|  |     t = time.monotonic() | ||||||
|  |     try: | ||||||
|  |       run(["git", "gc"], FINALIZED) | ||||||
|  |       run(["git", "lfs", "prune"], FINALIZED) | ||||||
|  |       cloudlog.event("Done git cleanup", duration=time.monotonic() - t) | ||||||
|  |     except subprocess.CalledProcessError: | ||||||
|  |       cloudlog.exception(f"Failed git cleanup, took {time.monotonic() - t:.3f} s") | ||||||
|  | 
 | ||||||
|  |     set_consistent_flag(True) | ||||||
|  |     cloudlog.info("done finalizing overlay") | ||||||
| @ -0,0 +1,22 @@ | |||||||
|  | import contextlib | ||||||
|  | from openpilot.selfdrive.updated.tests.test_base import BaseUpdateTest, run, update_release | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TestUpdateDGitStrategy(BaseUpdateTest): | ||||||
|  |   def update_remote_release(self, release): | ||||||
|  |     update_release(self.remote_dir, release, *self.MOCK_RELEASES[release]) | ||||||
|  |     run(["git", "add", "."], cwd=self.remote_dir) | ||||||
|  |     run(["git", "commit", "-m", f"openpilot release {release}"], cwd=self.remote_dir) | ||||||
|  | 
 | ||||||
|  |   def setup_remote_release(self, release): | ||||||
|  |     run(["git", "init"], cwd=self.remote_dir) | ||||||
|  |     run(["git", "checkout", "-b", release], cwd=self.remote_dir) | ||||||
|  |     self.update_remote_release(release) | ||||||
|  | 
 | ||||||
|  |   def setup_basedir_release(self, release): | ||||||
|  |     super().setup_basedir_release(release) | ||||||
|  |     run(["git", "clone", "-b", release, self.remote_dir, self.basedir]) | ||||||
|  | 
 | ||||||
|  |   @contextlib.contextmanager | ||||||
|  |   def additional_context(self): | ||||||
|  |     yield | ||||||
					Loading…
					
					
				
		Reference in new issue