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/31802/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