IQ.Pilot Prebuilt Release @ 27f668a
This commit is contained in:
246
iqpilot/system/updated/casync/casync.py
Executable file
246
iqpilot/system/updated/casync/casync.py
Executable file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
import io
|
||||
import lzma
|
||||
import os
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict, namedtuple
|
||||
from collections.abc import Callable
|
||||
from typing import IO
|
||||
|
||||
import requests
|
||||
from Crypto.Hash import SHA512
|
||||
from iqpilot.system.updated.casync import tar
|
||||
from iqpilot.system.updated.casync.common import create_casync_tar_package
|
||||
|
||||
CA_FORMAT_INDEX = 0x96824d9c7b129ff9
|
||||
CA_FORMAT_TABLE = 0xe75b9e112f17417d
|
||||
CA_FORMAT_TABLE_TAIL_MARKER = 0xe75b9e112f17417
|
||||
FLAGS = 0xb000000000000000
|
||||
|
||||
CA_HEADER_LEN = 48
|
||||
CA_TABLE_HEADER_LEN = 16
|
||||
CA_TABLE_ENTRY_LEN = 40
|
||||
CA_TABLE_MIN_LEN = CA_TABLE_HEADER_LEN + CA_TABLE_ENTRY_LEN
|
||||
|
||||
CHUNK_DOWNLOAD_TIMEOUT = 60
|
||||
CHUNK_DOWNLOAD_RETRIES = 3
|
||||
|
||||
CAIBX_DOWNLOAD_TIMEOUT = 120
|
||||
|
||||
Chunk = namedtuple('Chunk', ['sha', 'offset', 'length'])
|
||||
ChunkDict = dict[bytes, Chunk]
|
||||
|
||||
|
||||
class ChunkReader(ABC):
|
||||
@abstractmethod
|
||||
def read(self, chunk: Chunk) -> bytes:
|
||||
...
|
||||
|
||||
|
||||
class BinaryChunkReader(ChunkReader):
|
||||
"""Reads chunks from a local file"""
|
||||
def __init__(self, file_like: IO[bytes]) -> None:
|
||||
super().__init__()
|
||||
self.f = file_like
|
||||
|
||||
def read(self, chunk: Chunk) -> bytes:
|
||||
self.f.seek(chunk.offset)
|
||||
return self.f.read(chunk.length)
|
||||
|
||||
|
||||
class FileChunkReader(BinaryChunkReader):
|
||||
def __init__(self, path: str) -> None:
|
||||
super().__init__(open(path, 'rb'))
|
||||
|
||||
def __del__(self):
|
||||
self.f.close()
|
||||
|
||||
|
||||
class RemoteChunkReader(ChunkReader):
|
||||
"""Reads lzma compressed chunks from a remote store"""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.session = requests.Session()
|
||||
|
||||
def read(self, chunk: Chunk) -> bytes:
|
||||
sha_hex = chunk.sha.hex()
|
||||
url = os.path.join(self.url, sha_hex[:4], sha_hex + ".cacnk")
|
||||
|
||||
if os.path.isfile(url):
|
||||
with open(url, 'rb') as f:
|
||||
contents = f.read()
|
||||
else:
|
||||
for i in range(CHUNK_DOWNLOAD_RETRIES):
|
||||
try:
|
||||
resp = self.session.get(url, timeout=CHUNK_DOWNLOAD_TIMEOUT)
|
||||
break
|
||||
except Exception:
|
||||
if i == CHUNK_DOWNLOAD_RETRIES - 1:
|
||||
raise
|
||||
time.sleep(CHUNK_DOWNLOAD_TIMEOUT)
|
||||
|
||||
resp.raise_for_status()
|
||||
contents = resp.content
|
||||
|
||||
decompressor = lzma.LZMADecompressor(format=lzma.FORMAT_AUTO)
|
||||
return decompressor.decompress(contents)
|
||||
|
||||
|
||||
class DirectoryTarChunkReader(BinaryChunkReader):
|
||||
"""creates a tar archive of a directory and reads chunks from it"""
|
||||
|
||||
def __init__(self, path: str, cache_file: str) -> None:
|
||||
create_casync_tar_package(pathlib.Path(path), pathlib.Path(cache_file))
|
||||
|
||||
self.f = open(cache_file, "rb")
|
||||
super().__init__(self.f)
|
||||
|
||||
def __del__(self):
|
||||
self.f.close()
|
||||
os.unlink(self.f.name)
|
||||
|
||||
|
||||
def parse_caibx(caibx_path: str) -> list[Chunk]:
|
||||
"""Parses the chunks from a caibx file. Can handle both local and remote files.
|
||||
Returns a list of chunks with hash, offset and length"""
|
||||
caibx: io.BufferedIOBase
|
||||
if os.path.isfile(caibx_path):
|
||||
caibx = open(caibx_path, 'rb')
|
||||
else:
|
||||
resp = requests.get(caibx_path, timeout=CAIBX_DOWNLOAD_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
caibx = io.BytesIO(resp.content)
|
||||
|
||||
caibx.seek(0, os.SEEK_END)
|
||||
caibx_len = caibx.tell()
|
||||
caibx.seek(0, os.SEEK_SET)
|
||||
|
||||
# Parse header
|
||||
length, magic, flags, min_size, _, max_size = struct.unpack("<QQQQQQ", caibx.read(CA_HEADER_LEN))
|
||||
assert flags == flags
|
||||
assert length == CA_HEADER_LEN
|
||||
assert magic == CA_FORMAT_INDEX
|
||||
|
||||
# Parse table header
|
||||
length, magic = struct.unpack("<QQ", caibx.read(CA_TABLE_HEADER_LEN))
|
||||
assert magic == CA_FORMAT_TABLE
|
||||
|
||||
# Parse chunks
|
||||
num_chunks = (caibx_len - CA_HEADER_LEN - CA_TABLE_MIN_LEN) // CA_TABLE_ENTRY_LEN
|
||||
chunks = []
|
||||
|
||||
offset = 0
|
||||
for i in range(num_chunks):
|
||||
new_offset = struct.unpack("<Q", caibx.read(8))[0]
|
||||
|
||||
sha = caibx.read(32)
|
||||
length = new_offset - offset
|
||||
|
||||
assert length <= max_size
|
||||
|
||||
# Last chunk can be smaller
|
||||
if i < num_chunks - 1:
|
||||
assert length >= min_size
|
||||
|
||||
chunks.append(Chunk(sha, offset, length))
|
||||
offset = new_offset
|
||||
|
||||
caibx.close()
|
||||
return chunks
|
||||
|
||||
|
||||
def build_chunk_dict(chunks: list[Chunk]) -> ChunkDict:
|
||||
"""Turn a list of chunks into a dict for faster lookups based on hash.
|
||||
Keep first chunk since it's more likely to be already downloaded."""
|
||||
r = {}
|
||||
for c in chunks:
|
||||
if c.sha not in r:
|
||||
r[c.sha] = c
|
||||
return r
|
||||
|
||||
|
||||
def extract(target: list[Chunk],
|
||||
sources: list[tuple[str, ChunkReader, ChunkDict]],
|
||||
out_path: str,
|
||||
progress: Callable[[int], None] | None = None):
|
||||
stats: dict[str, int] = defaultdict(int)
|
||||
|
||||
mode = 'rb+' if os.path.exists(out_path) else 'wb'
|
||||
with open(out_path, mode) as out:
|
||||
for cur_chunk in target:
|
||||
|
||||
# Find source for desired chunk
|
||||
for name, chunk_reader, store_chunks in sources:
|
||||
if cur_chunk.sha in store_chunks:
|
||||
bts = chunk_reader.read(store_chunks[cur_chunk.sha])
|
||||
|
||||
# Check length
|
||||
if len(bts) != cur_chunk.length:
|
||||
continue
|
||||
|
||||
# Check hash
|
||||
if SHA512.new(bts, truncate="256").digest() != cur_chunk.sha:
|
||||
continue
|
||||
|
||||
# Write to output
|
||||
out.seek(cur_chunk.offset)
|
||||
out.write(bts)
|
||||
|
||||
stats[name] += cur_chunk.length
|
||||
|
||||
if progress is not None:
|
||||
progress(sum(stats.values()))
|
||||
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Desired chunk not found in provided stores")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def extract_directory(target: list[Chunk],
|
||||
sources: list[tuple[str, ChunkReader, ChunkDict]],
|
||||
out_path: str,
|
||||
tmp_file: str,
|
||||
progress: Callable[[int], None] | None = None):
|
||||
"""extract a directory stored as a casync tar archive"""
|
||||
|
||||
stats = extract(target, sources, tmp_file, progress)
|
||||
|
||||
with open(tmp_file, "rb") as f:
|
||||
tar.extract_tar_archive(f, pathlib.Path(out_path))
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def print_stats(stats: dict[str, int]):
|
||||
total_bytes = sum(stats.values())
|
||||
print(f"Total size: {total_bytes / 1024 / 1024:.2f} MB")
|
||||
for name, total in stats.items():
|
||||
print(f" {name}: {total / 1024 / 1024:.2f} MB ({total / total_bytes * 100:.1f}%)")
|
||||
|
||||
|
||||
def extract_simple(caibx_path, out_path, store_path):
|
||||
# (name, callback, chunks)
|
||||
target = parse_caibx(caibx_path)
|
||||
sources = [
|
||||
# (store_path, RemoteChunkReader(store_path), build_chunk_dict(target)),
|
||||
(store_path, FileChunkReader(store_path), build_chunk_dict(target)),
|
||||
]
|
||||
|
||||
return extract(target, sources, out_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
caibx = sys.argv[1]
|
||||
out = sys.argv[2]
|
||||
store = sys.argv[3]
|
||||
|
||||
stats = extract_simple(caibx, out, store)
|
||||
print_stats(stats)
|
||||
61
iqpilot/system/updated/casync/common.py
Normal file
61
iqpilot/system/updated/casync/common.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
from iqpilot.system.version import BUILD_METADATA_FILENAME, BuildMetadata
|
||||
from iqpilot.system.updated.casync import tar
|
||||
|
||||
|
||||
CASYNC_ARGS = ["--with=symlinks", "--with=permissions", "--compression=xz", "--chunk-size=16M"]
|
||||
CASYNC_FILES = [BUILD_METADATA_FILENAME]
|
||||
|
||||
|
||||
def run(cmd):
|
||||
return subprocess.check_output(cmd)
|
||||
|
||||
|
||||
def get_exclude_set(path) -> set[str]:
|
||||
exclude_set = set(CASYNC_FILES)
|
||||
|
||||
for file in path.rglob("*"):
|
||||
if file.is_file() or file.is_symlink():
|
||||
|
||||
while file.resolve() != path.resolve():
|
||||
exclude_set.add(str(file.relative_to(path)))
|
||||
|
||||
file = file.parent
|
||||
|
||||
return exclude_set
|
||||
|
||||
|
||||
def create_build_metadata_file(path: pathlib.Path, build_metadata: BuildMetadata):
|
||||
with open(path / BUILD_METADATA_FILENAME, "w") as f:
|
||||
build_metadata_dict = dataclasses.asdict(build_metadata)
|
||||
build_metadata_dict["openpilot"].pop("is_dirty") # this is determined at runtime
|
||||
build_metadata_dict.pop("channel") # channel is unrelated to the build itself
|
||||
f.write(json.dumps(build_metadata_dict))
|
||||
|
||||
|
||||
def is_not_git(path: pathlib.Path) -> bool:
|
||||
return ".git" not in path.parts
|
||||
|
||||
|
||||
def create_casync_tar_package(target_dir: pathlib.Path, output_path: pathlib.Path):
|
||||
tar.create_tar_archive(output_path, target_dir, is_not_git)
|
||||
|
||||
|
||||
def create_casync_from_file(file: pathlib.Path, output_dir: pathlib.Path, caibx_name: str):
|
||||
caibx_file = output_dir / f"{caibx_name}.caibx"
|
||||
run(["casync", "make", *CASYNC_ARGS, caibx_file, str(file)])
|
||||
|
||||
return caibx_file
|
||||
|
||||
|
||||
def create_casync_release(target_dir: pathlib.Path, output_dir: pathlib.Path, caibx_name: str):
|
||||
tar_file = output_dir / f"{caibx_name}.tar"
|
||||
create_casync_tar_package(target_dir, tar_file)
|
||||
caibx_file = create_casync_from_file(tar_file, output_dir, caibx_name)
|
||||
tar_file.unlink()
|
||||
digest = run(["casync", "digest", *CASYNC_ARGS, target_dir]).decode("utf-8").strip()
|
||||
return digest, caibx_file
|
||||
39
iqpilot/system/updated/casync/tar.py
Normal file
39
iqpilot/system/updated/casync/tar.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import pathlib
|
||||
import tarfile
|
||||
from typing import IO
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
def include_default(_) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def create_tar_archive(filename: pathlib.Path, directory: pathlib.Path, include: Callable[[pathlib.Path], bool] = include_default):
|
||||
"""Creates a tar archive of a directory"""
|
||||
|
||||
with tarfile.open(filename, 'w') as tar:
|
||||
for file in sorted(directory.rglob("*"), key=lambda f: f.stat().st_size if f.is_file() else 0, reverse=True):
|
||||
if not include(file):
|
||||
continue
|
||||
relative_path = str(file.relative_to(directory))
|
||||
if file.is_symlink():
|
||||
info = tarfile.TarInfo(relative_path)
|
||||
info.type = tarfile.SYMTYPE
|
||||
info.linkpath = str(file.readlink())
|
||||
tar.addfile(info)
|
||||
|
||||
elif file.is_file():
|
||||
info = tarfile.TarInfo(relative_path)
|
||||
info.size = file.stat().st_size
|
||||
info.type = tarfile.REGTYPE
|
||||
info.mode = file.stat().st_mode
|
||||
with file.open('rb') as f:
|
||||
tar.addfile(info, f)
|
||||
|
||||
|
||||
def extract_tar_archive(fh: IO[bytes], directory: pathlib.Path):
|
||||
"""Extracts a tar archive to a directory"""
|
||||
|
||||
tar = tarfile.open(fileobj=fh, mode='r')
|
||||
tar.extractall(str(directory), filter=lambda info, path: info)
|
||||
tar.close()
|
||||
228
iqpilot/system/updated/casync/tests/test_casync.py
Normal file
228
iqpilot/system/updated/casync/tests/test_casync.py
Normal file
@@ -0,0 +1,228 @@
|
||||
import pytest
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
from iqpilot.system.updated.casync import casync
|
||||
from iqpilot.system.updated.casync import tar
|
||||
|
||||
# dd if=/dev/zero of=/tmp/img.raw bs=1M count=2
|
||||
# sudo losetup -f /tmp/img.raw
|
||||
# losetup -a | grep img.raw
|
||||
@pytest.mark.linux
|
||||
class TestCasync:
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.tmpdir = tempfile.TemporaryDirectory()
|
||||
|
||||
# Build example contents
|
||||
chunk_a = [i % 256 for i in range(1024)] * 512
|
||||
chunk_b = [(256 - i) % 256 for i in range(1024)] * 512
|
||||
zeroes = [0] * (1024 * 128)
|
||||
contents = chunk_a + chunk_b + zeroes + chunk_a
|
||||
|
||||
cls.contents = bytes(contents)
|
||||
|
||||
# Write to file
|
||||
cls.orig_fn = os.path.join(cls.tmpdir.name, 'orig.bin')
|
||||
with open(cls.orig_fn, 'wb') as f:
|
||||
f.write(cls.contents)
|
||||
|
||||
# Create casync files
|
||||
cls.manifest_fn = os.path.join(cls.tmpdir.name, 'orig.caibx')
|
||||
cls.store_fn = os.path.join(cls.tmpdir.name, 'store')
|
||||
subprocess.check_output(["casync", "make", "--compression=xz", "--store", cls.store_fn, cls.manifest_fn, cls.orig_fn])
|
||||
|
||||
target = casync.parse_caibx(cls.manifest_fn)
|
||||
hashes = [c.sha.hex() for c in target]
|
||||
|
||||
# Ensure we have chunk reuse
|
||||
assert len(hashes) > len(set(hashes))
|
||||
|
||||
def setup_method(self):
|
||||
# Clear target_lo
|
||||
self.target_fn = os.path.join(self.tmpdir.name, next(tempfile._get_candidate_names()))
|
||||
self.seed_fn = os.path.join(self.tmpdir.name, next(tempfile._get_candidate_names()))
|
||||
|
||||
def teardown_method(self):
|
||||
for fn in [self.target_fn, self.seed_fn]:
|
||||
try:
|
||||
os.unlink(fn)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def test_simple_extract(self):
|
||||
target = casync.parse_caibx(self.manifest_fn)
|
||||
|
||||
sources = [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))]
|
||||
stats = casync.extract(target, sources, self.target_fn)
|
||||
|
||||
with open(self.target_fn, 'rb') as target_f:
|
||||
assert target_f.read() == self.contents
|
||||
|
||||
assert stats['remote'] == len(self.contents)
|
||||
|
||||
def test_seed(self):
|
||||
target = casync.parse_caibx(self.manifest_fn)
|
||||
|
||||
# Populate seed with half of the target contents
|
||||
with open(self.seed_fn, 'wb') as seed_f:
|
||||
seed_f.write(self.contents[:len(self.contents) // 2])
|
||||
|
||||
sources = [('seed', casync.FileChunkReader(self.seed_fn), casync.build_chunk_dict(target))]
|
||||
sources += [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))]
|
||||
stats = casync.extract(target, sources, self.target_fn)
|
||||
|
||||
with open(self.target_fn, 'rb') as target_f:
|
||||
assert target_f.read() == self.contents
|
||||
|
||||
assert stats['seed'] > 0
|
||||
assert stats['remote'] < len(self.contents)
|
||||
|
||||
def test_already_done(self):
|
||||
"""Test that an already flashed target doesn't download any chunks"""
|
||||
target = casync.parse_caibx(self.manifest_fn)
|
||||
|
||||
with open(self.target_fn, 'wb') as f:
|
||||
f.write(self.contents)
|
||||
|
||||
sources = [('target', casync.FileChunkReader(self.target_fn), casync.build_chunk_dict(target))]
|
||||
sources += [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))]
|
||||
|
||||
stats = casync.extract(target, sources, self.target_fn)
|
||||
|
||||
with open(self.target_fn, 'rb') as f:
|
||||
assert f.read() == self.contents
|
||||
|
||||
assert stats['target'] == len(self.contents)
|
||||
|
||||
def test_chunk_reuse(self):
|
||||
"""Test that chunks that are reused are only downloaded once"""
|
||||
target = casync.parse_caibx(self.manifest_fn)
|
||||
|
||||
# Ensure target exists
|
||||
with open(self.target_fn, 'wb'):
|
||||
pass
|
||||
|
||||
sources = [('target', casync.FileChunkReader(self.target_fn), casync.build_chunk_dict(target))]
|
||||
sources += [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))]
|
||||
|
||||
stats = casync.extract(target, sources, self.target_fn)
|
||||
|
||||
with open(self.target_fn, 'rb') as f:
|
||||
assert f.read() == self.contents
|
||||
|
||||
assert stats['remote'] < len(self.contents)
|
||||
|
||||
@pytest.mark.linux
|
||||
class TestCasyncDirectory:
|
||||
"""Tests extracting a directory stored as a casync tar archive"""
|
||||
|
||||
NUM_FILES = 16
|
||||
|
||||
@classmethod
|
||||
def setup_cache(cls, directory, files=None):
|
||||
if files is None:
|
||||
files = range(cls.NUM_FILES)
|
||||
|
||||
chunk_a = [i % 256 for i in range(1024)] * 512
|
||||
chunk_b = [(256 - i) % 256 for i in range(1024)] * 512
|
||||
zeroes = [0] * (1024 * 128)
|
||||
cls.contents = chunk_a + chunk_b + zeroes + chunk_a
|
||||
cls.contents = bytes(cls.contents)
|
||||
|
||||
for i in files:
|
||||
with open(os.path.join(directory, f"file_{i}.txt"), "wb") as f:
|
||||
f.write(cls.contents)
|
||||
|
||||
os.symlink(f"file_{i}.txt", os.path.join(directory, f"link_{i}.txt"))
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.tmpdir = tempfile.TemporaryDirectory()
|
||||
|
||||
# Create casync files
|
||||
cls.manifest_fn = os.path.join(cls.tmpdir.name, 'orig.caibx')
|
||||
cls.store_fn = os.path.join(cls.tmpdir.name, 'store')
|
||||
|
||||
cls.directory_to_extract = tempfile.TemporaryDirectory()
|
||||
cls.setup_cache(cls.directory_to_extract.name)
|
||||
|
||||
cls.orig_fn = os.path.join(cls.tmpdir.name, 'orig.tar')
|
||||
tar.create_tar_archive(cls.orig_fn, pathlib.Path(cls.directory_to_extract.name))
|
||||
|
||||
subprocess.check_output(["casync", "make", "--compression=xz", "--store", cls.store_fn, cls.manifest_fn, cls.orig_fn])
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
cls.tmpdir.cleanup()
|
||||
cls.directory_to_extract.cleanup()
|
||||
|
||||
def setup_method(self):
|
||||
self.cache_dir = tempfile.TemporaryDirectory()
|
||||
self.working_dir = tempfile.TemporaryDirectory()
|
||||
self.out_dir = tempfile.TemporaryDirectory()
|
||||
|
||||
def teardown_method(self):
|
||||
self.cache_dir.cleanup()
|
||||
self.working_dir.cleanup()
|
||||
self.out_dir.cleanup()
|
||||
|
||||
def run_test(self):
|
||||
target = casync.parse_caibx(self.manifest_fn)
|
||||
|
||||
cache_filename = os.path.join(self.working_dir.name, "cache.tar")
|
||||
tmp_filename = os.path.join(self.working_dir.name, "tmp.tar")
|
||||
|
||||
sources = [('cache', casync.DirectoryTarChunkReader(self.cache_dir.name, cache_filename), casync.build_chunk_dict(target))]
|
||||
sources += [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))]
|
||||
|
||||
stats = casync.extract_directory(target, sources, pathlib.Path(self.out_dir.name), tmp_filename)
|
||||
|
||||
with open(os.path.join(self.out_dir.name, "file_0.txt"), "rb") as f:
|
||||
assert f.read() == self.contents
|
||||
|
||||
with open(os.path.join(self.out_dir.name, "link_0.txt"), "rb") as f:
|
||||
assert f.read() == self.contents
|
||||
assert os.readlink(os.path.join(self.out_dir.name, "link_0.txt")) == "file_0.txt"
|
||||
|
||||
return stats
|
||||
|
||||
def test_no_cache(self):
|
||||
self.setup_cache(self.cache_dir.name, [])
|
||||
stats = self.run_test()
|
||||
assert stats['remote'] > 0
|
||||
assert stats['cache'] == 0
|
||||
|
||||
def test_full_cache(self):
|
||||
self.setup_cache(self.cache_dir.name, range(self.NUM_FILES))
|
||||
stats = self.run_test()
|
||||
assert stats['remote'] == 0
|
||||
assert stats['cache'] > 0
|
||||
|
||||
def test_one_file_cache(self):
|
||||
self.setup_cache(self.cache_dir.name, range(1))
|
||||
stats = self.run_test()
|
||||
assert stats['remote'] > 0
|
||||
assert stats['cache'] > 0
|
||||
assert stats['cache'] < stats['remote']
|
||||
|
||||
def test_one_file_incorrect_cache(self):
|
||||
self.setup_cache(self.cache_dir.name, range(self.NUM_FILES))
|
||||
with open(os.path.join(self.cache_dir.name, "file_0.txt"), "wb") as f:
|
||||
f.write(b"1234")
|
||||
|
||||
stats = self.run_test()
|
||||
assert stats['remote'] > 0
|
||||
assert stats['cache'] > 0
|
||||
assert stats['cache'] > stats['remote']
|
||||
|
||||
def test_one_file_missing_cache(self):
|
||||
self.setup_cache(self.cache_dir.name, range(self.NUM_FILES))
|
||||
os.unlink(os.path.join(self.cache_dir.name, "file_12.txt"))
|
||||
|
||||
stats = self.run_test()
|
||||
assert stats['remote'] > 0
|
||||
assert stats['cache'] > 0
|
||||
assert stats['cache'] > stats['remote']
|
||||
284
iqpilot/system/updated/tests/test_base.py
Normal file
284
iqpilot/system/updated/tests/test_base.py
Normal file
@@ -0,0 +1,284 @@
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.system.manager.process import ManagerProcess
|
||||
from iqpilot.selfdrive.test.helpers import processes_context
|
||||
|
||||
|
||||
def run(args, **kwargs):
|
||||
return subprocess.check_output(args, **kwargs)
|
||||
|
||||
|
||||
def update_release(directory, name, version, agnos_version, release_notes):
|
||||
(directory / "iqpilot" / "docs").mkdir(parents=True, exist_ok=True)
|
||||
with open(directory / "iqpilot" / "docs" / "CHANGELOG.md", "w") as f:
|
||||
f.write(release_notes)
|
||||
|
||||
(directory / "iqpilot" / "common").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(directory / "iqpilot" / "common" / "version.h", "w") as f:
|
||||
f.write(f'#define COMMA_VERSION "{version}"')
|
||||
|
||||
launch_env = directory / "launch_env.sh"
|
||||
with open(launch_env, "w") as f:
|
||||
f.write(f'export AGNOS_VERSION="{agnos_version}"')
|
||||
|
||||
st = os.stat(launch_env)
|
||||
os.chmod(launch_env, st.st_mode | stat.S_IEXEC)
|
||||
|
||||
test_symlink = directory / "test_symlink"
|
||||
if not os.path.exists(str(test_symlink)):
|
||||
os.symlink("iqpilot/common/version.h", test_symlink)
|
||||
|
||||
|
||||
def get_version(path: str) -> str:
|
||||
with open(os.path.join(path, "iqpilot", "common", "version.h")) as f:
|
||||
return f.read().split('"')[1]
|
||||
|
||||
|
||||
@pytest.mark.linux
|
||||
@pytest.mark.slow
|
||||
class BaseUpdateTest:
|
||||
def setup_method(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.mock_update_path = pathlib.Path(self.tmpdir)
|
||||
|
||||
self.params = Params()
|
||||
|
||||
self.basedir = self.mock_update_path / "openpilot"
|
||||
self.basedir.mkdir()
|
||||
|
||||
self.staging_root = self.mock_update_path / "safe_staging"
|
||||
|
||||
self.remote_dir = self.mock_update_path / "remote"
|
||||
self.remote_dir.mkdir()
|
||||
|
||||
os.environ["UPDATER_STAGING_ROOT"] = str(self.staging_root)
|
||||
os.environ["UPDATER_LOCK_FILE"] = str(self.mock_update_path / "safe_staging_overlay.lock")
|
||||
|
||||
self.MOCK_RELEASES = {
|
||||
"release3": ("0.1.2", "1.2", "0.1.2 release notes"),
|
||||
"master": ("0.1.3", "1.2", "0.1.3 release notes"),
|
||||
}
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_basedir(self, mocker):
|
||||
mocker.patch("iqpilot.common.basedir.BASEDIR", self.basedir)
|
||||
mocker.patch("iqpilot.system.updated.updated.BASEDIR", str(self.basedir))
|
||||
mocker.patch("iqpilot.system.updated.updated.STAGING_ROOT", str(self.staging_root))
|
||||
mocker.patch("iqpilot.system.updated.updated.LOCK_FILE", str(self.mock_update_path / "safe_staging_overlay.lock"))
|
||||
mocker.patch("iqpilot.system.updated.updated.OVERLAY_INIT", self.basedir / ".overlay_init")
|
||||
|
||||
def set_target_branch(self, branch):
|
||||
self.params.put("UpdaterTargetBranch", branch)
|
||||
|
||||
def setup_basedir_release(self, release):
|
||||
self.params = Params()
|
||||
self.set_target_branch(release)
|
||||
|
||||
def update_remote_release(self, release):
|
||||
raise NotImplementedError("")
|
||||
|
||||
def setup_remote_release(self, release):
|
||||
raise NotImplementedError("")
|
||||
|
||||
def additional_context(self):
|
||||
raise NotImplementedError("")
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.tmpdir)
|
||||
|
||||
def wait_for_condition(self, condition, timeout=12):
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
waited = time.monotonic() - start
|
||||
if condition():
|
||||
print(f"waited {waited}s for condition ")
|
||||
return waited
|
||||
|
||||
if waited > timeout:
|
||||
raise TimeoutError("timed out waiting for condition")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
def _test_finalized_update(self, branch, version, agnos_version, release_notes):
|
||||
assert get_version(str(self.basedir)) == version
|
||||
assert os.access(str(self.basedir / "launch_env.sh"), os.X_OK)
|
||||
|
||||
with open(self.basedir / "test_symlink") as f:
|
||||
assert version in f.read()
|
||||
|
||||
class ParamsBaseUpdateTest(BaseUpdateTest):
|
||||
def _test_finalized_update(self, branch, version, agnos_version, release_notes):
|
||||
assert self.params.get("UpdaterNewDescription").startswith(f"{version} / {branch}")
|
||||
assert self.params.get("UpdaterNewReleaseNotes") == f"{release_notes}\n".encode()
|
||||
super()._test_finalized_update(branch, version, agnos_version, release_notes)
|
||||
|
||||
def send_check_for_updates_signal(self, updated: ManagerProcess):
|
||||
updated.signal(signal.SIGUSR1.value)
|
||||
|
||||
def send_download_signal(self, updated: ManagerProcess):
|
||||
updated.signal(signal.SIGHUP.value)
|
||||
|
||||
def _test_params(self, branch, fetch_available, update_available):
|
||||
assert self.params.get("UpdaterTargetBranch") == branch
|
||||
assert self.params.get_bool("UpdaterFetchAvailable") == fetch_available
|
||||
assert self.params.get_bool("UpdateAvailable") == update_available
|
||||
|
||||
def wait_for_idle(self):
|
||||
self.wait_for_condition(lambda: self.params.get("UpdaterState") == "idle")
|
||||
|
||||
def wait_for_failed(self):
|
||||
self.wait_for_condition(lambda: self.params.get("UpdateFailedCount") is not None and \
|
||||
self.params.get("UpdateFailedCount") > 0)
|
||||
|
||||
def wait_for_fetch_available(self):
|
||||
self.wait_for_condition(lambda: self.params.get_bool("UpdaterFetchAvailable"))
|
||||
|
||||
def wait_for_update_available(self):
|
||||
self.wait_for_condition(lambda: self.params.get_bool("UpdateAvailable"))
|
||||
|
||||
def wait_for_reboot_requested(self):
|
||||
self.wait_for_condition(lambda: self.params.get_bool("DoReboot"))
|
||||
|
||||
def test_no_update(self):
|
||||
# Start on release3, ensure we don't fetch any updates
|
||||
self.setup_remote_release("release3")
|
||||
self.setup_basedir_release("release3")
|
||||
|
||||
with self.additional_context(), processes_context(["updated"]) as [updated]:
|
||||
self._test_params("release3", False, False)
|
||||
self.wait_for_idle()
|
||||
self._test_params("release3", False, False)
|
||||
|
||||
self.send_check_for_updates_signal(updated)
|
||||
|
||||
self.wait_for_idle()
|
||||
|
||||
self._test_params("release3", False, False)
|
||||
|
||||
def test_new_release(self):
|
||||
# Start on release3, simulate a release3 commit, ensure we fetch that update properly
|
||||
self.setup_remote_release("release3")
|
||||
self.setup_basedir_release("release3")
|
||||
|
||||
with self.additional_context(), processes_context(["updated"]) as [updated]:
|
||||
self._test_params("release3", False, False)
|
||||
self.wait_for_idle()
|
||||
self._test_params("release3", False, False)
|
||||
|
||||
self.MOCK_RELEASES["release3"] = ("0.1.3", "1.2", "0.1.3 release notes")
|
||||
self.update_remote_release("release3")
|
||||
|
||||
self.send_check_for_updates_signal(updated)
|
||||
|
||||
self.wait_for_fetch_available()
|
||||
|
||||
self._test_params("release3", True, False)
|
||||
|
||||
self.send_download_signal(updated)
|
||||
|
||||
self.wait_for_update_available()
|
||||
|
||||
self._test_params("release3", False, True)
|
||||
self._test_finalized_update("release3", *self.MOCK_RELEASES["release3"])
|
||||
|
||||
def test_switch_branches(self):
|
||||
# Start on release3, request to switch to master manually, ensure we switched
|
||||
self.setup_remote_release("release3")
|
||||
self.setup_remote_release("master")
|
||||
self.setup_basedir_release("release3")
|
||||
|
||||
with self.additional_context(), processes_context(["updated"]) as [updated]:
|
||||
self._test_params("release3", False, False)
|
||||
self.wait_for_idle()
|
||||
self._test_params("release3", False, False)
|
||||
|
||||
self.set_target_branch("master")
|
||||
self.send_check_for_updates_signal(updated)
|
||||
|
||||
self.wait_for_fetch_available()
|
||||
|
||||
self._test_params("master", True, False)
|
||||
|
||||
self.send_download_signal(updated)
|
||||
|
||||
self.wait_for_update_available()
|
||||
|
||||
self._test_params("master", False, True)
|
||||
self._test_finalized_update("master", *self.MOCK_RELEASES["master"])
|
||||
|
||||
def test_download_only_does_not_auto_install(self):
|
||||
self.setup_remote_release("release3")
|
||||
self.setup_basedir_release("release3")
|
||||
self.params.put("UpdaterInstallMode", "download_only")
|
||||
|
||||
with self.additional_context(), processes_context(["updated"]) as [updated]:
|
||||
self.wait_for_idle()
|
||||
|
||||
self.MOCK_RELEASES["release3"] = ("0.1.3", "1.2", "0.1.3 release notes")
|
||||
self.update_remote_release("release3")
|
||||
|
||||
self.send_check_for_updates_signal(updated)
|
||||
self.wait_for_fetch_available()
|
||||
|
||||
self.send_download_signal(updated)
|
||||
self.wait_for_update_available()
|
||||
|
||||
assert not self.params.get_bool("DoReboot")
|
||||
|
||||
def test_download_and_install_auto_installs(self):
|
||||
self.setup_remote_release("release3")
|
||||
self.setup_basedir_release("release3")
|
||||
self.params.put("UpdaterInstallMode", "download_and_install")
|
||||
|
||||
with self.additional_context(), processes_context(["updated"]) as [updated]:
|
||||
self.wait_for_idle()
|
||||
|
||||
self.MOCK_RELEASES["release3"] = ("0.1.3", "1.2", "0.1.3 release notes")
|
||||
self.update_remote_release("release3")
|
||||
|
||||
self.send_check_for_updates_signal(updated)
|
||||
self.wait_for_fetch_available()
|
||||
|
||||
self.send_download_signal(updated)
|
||||
self.wait_for_reboot_requested()
|
||||
|
||||
def test_agnos_update(self, mocker):
|
||||
# Start on release3, push an update with an agnos change
|
||||
self.setup_remote_release("release3")
|
||||
self.setup_basedir_release("release3")
|
||||
|
||||
with self.additional_context(), processes_context(["updated"]) as [updated]:
|
||||
mocker.patch("iqpilot.system.hardware.AGNOS", "True")
|
||||
mocker.patch("iqpilot.system.hardware.tici.hardware.Tici.get_os_version", "1.2")
|
||||
mocker.patch("iqpilot.system.hardware.tici.agnos.get_target_slot_number")
|
||||
mocker.patch("iqpilot.system.hardware.tici.agnos.flash_agnos_update")
|
||||
|
||||
self._test_params("release3", False, False)
|
||||
self.wait_for_idle()
|
||||
self._test_params("release3", False, False)
|
||||
|
||||
self.MOCK_RELEASES["release3"] = ("0.1.3", "1.3", "0.1.3 release notes")
|
||||
self.update_remote_release("release3")
|
||||
|
||||
self.send_check_for_updates_signal(updated)
|
||||
|
||||
self.wait_for_fetch_available()
|
||||
|
||||
self._test_params("release3", True, False)
|
||||
|
||||
self.send_download_signal(updated)
|
||||
|
||||
self.wait_for_update_available()
|
||||
|
||||
self._test_params("release3", False, True)
|
||||
self._test_finalized_update("release3", *self.MOCK_RELEASES["release3"])
|
||||
62
iqpilot/system/updated/tests/test_branch_migrations.py
Normal file
62
iqpilot/system/updated/tests/test_branch_migrations.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.basedir import BASEDIR
|
||||
from iqpilot.system.version import BuildMetadata, OpenpilotMetadata
|
||||
from iqpilot.system.updated.updated import Updater, display_commit_date
|
||||
|
||||
|
||||
def test_display_commit_date():
|
||||
assert display_commit_date("'1786378834 2026-08-10 11:20:34 -0500'") == "2026-08-10 11:20:34"
|
||||
assert display_commit_date("1786378834 2026-08-10 11:20:34 -0500") == "2026-08-10 11:20:34"
|
||||
assert display_commit_date("'1786378834 2026-08-10 11:20:34 -0500'\n") == "2026-08-10 11:20:34"
|
||||
assert display_commit_date("Jul 02") == "Jul 02"
|
||||
|
||||
|
||||
def test_tici_branch_unchanged(mocker):
|
||||
params = Params()
|
||||
params.put("UpdaterTargetBranch", "master-dev")
|
||||
mocker.patch("iqpilot.system.updated.updated.HARDWARE.get_device_type", return_value="tici")
|
||||
|
||||
try:
|
||||
assert Updater().target_branch == "master-dev"
|
||||
finally:
|
||||
params.remove("UpdaterTargetBranch")
|
||||
|
||||
|
||||
def test_non_tici_branch_unchanged(mocker):
|
||||
params = Params()
|
||||
params.put("UpdaterTargetBranch", "master-dev")
|
||||
mocker.patch("iqpilot.system.updated.updated.HARDWARE.get_device_type", return_value="tizi")
|
||||
|
||||
try:
|
||||
assert Updater().target_branch == "master-dev"
|
||||
finally:
|
||||
params.remove("UpdaterTargetBranch")
|
||||
|
||||
|
||||
def test_non_git_baked_deployment_uses_build_metadata(mocker):
|
||||
mocker.patch("iqpilot.system.updated.updated.has_git_repo", return_value=False)
|
||||
mocker.patch("iqpilot.system.updated.updated.HARDWARE.get_device_type", return_value="tici")
|
||||
mocker.patch(
|
||||
"iqpilot.system.updated.updated.get_build_metadata",
|
||||
return_value=BuildMetadata(
|
||||
"release3",
|
||||
OpenpilotMetadata(
|
||||
version="1.2.3",
|
||||
release_notes="notes",
|
||||
git_commit="abcdef1234567890",
|
||||
git_origin="github.com/IQLvbs/openpilot",
|
||||
git_commit_date="Jul 02",
|
||||
build_style="release",
|
||||
is_dirty=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
updater = Updater()
|
||||
|
||||
assert updater.git_mode is False
|
||||
assert updater.get_branch(BASEDIR) == "release3"
|
||||
assert updater.get_commit_hash() == "abcdef1234567890"
|
||||
assert updater.target_branch == "release3"
|
||||
assert updater.update_available is False
|
||||
assert updater.update_ready is False
|
||||
48
iqpilot/system/updated/tests/test_git.py
Normal file
48
iqpilot/system/updated/tests/test_git.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import contextlib
|
||||
import os
|
||||
from iqpilot.system.updated.tests.test_base import ParamsBaseUpdateTest, run, update_release
|
||||
from iqpilot.system.updated.updated import cleanup_stale_prebuilt_marker
|
||||
|
||||
|
||||
class TestUpdateDGitStrategy(ParamsBaseUpdateTest):
|
||||
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", "config", "user.name", "IQPilot Test"], cwd=self.remote_dir)
|
||||
run(["git", "config", "user.email", "test@iqpilot.local"], 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
|
||||
|
||||
def test_cleanup_stale_prebuilt_marker(self):
|
||||
self.setup_remote_release("release3")
|
||||
self.setup_basedir_release("release3")
|
||||
|
||||
prebuilt_path = os.path.join(self.basedir, "prebuilt")
|
||||
with open(prebuilt_path, "w") as f:
|
||||
f.write("")
|
||||
|
||||
cleanup_stale_prebuilt_marker(str(self.basedir), "release3")
|
||||
assert not os.path.exists(prebuilt_path)
|
||||
|
||||
def test_keep_prebuilt_marker_for_prebuilt_branch(self):
|
||||
self.setup_remote_release("release3")
|
||||
self.setup_basedir_release("release3")
|
||||
|
||||
prebuilt_path = os.path.join(self.basedir, "prebuilt")
|
||||
with open(prebuilt_path, "w") as f:
|
||||
f.write("")
|
||||
|
||||
cleanup_stale_prebuilt_marker(str(self.basedir), "release3-prebuilt")
|
||||
assert os.path.exists(prebuilt_path)
|
||||
627
iqpilot/system/updated/updated.py
Executable file
627
iqpilot/system/updated/updated.py
Executable file
@@ -0,0 +1,627 @@
|
||||
#!/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<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')
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user