test_updated.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. #!/usr/bin/env python3
  2. import datetime
  3. import os
  4. import pytest
  5. import time
  6. import tempfile
  7. import unittest
  8. import shutil
  9. import signal
  10. import subprocess
  11. import random
  12. from openpilot.common.basedir import BASEDIR
  13. from openpilot.common.params import Params
  14. @pytest.mark.tici
  15. class TestUpdated(unittest.TestCase):
  16. def setUp(self):
  17. self.updated_proc = None
  18. self.tmp_dir = tempfile.TemporaryDirectory()
  19. org_dir = os.path.join(self.tmp_dir.name, "commaai")
  20. self.basedir = os.path.join(org_dir, "openpilot")
  21. self.git_remote_dir = os.path.join(org_dir, "openpilot_remote")
  22. self.staging_dir = os.path.join(org_dir, "safe_staging")
  23. for d in [org_dir, self.basedir, self.git_remote_dir, self.staging_dir]:
  24. os.mkdir(d)
  25. self.neos_version = os.path.join(org_dir, "neos_version")
  26. self.neosupdate_dir = os.path.join(org_dir, "neosupdate")
  27. with open(self.neos_version, "w") as f:
  28. v = subprocess.check_output(r"bash -c 'source launch_env.sh && echo $REQUIRED_NEOS_VERSION'",
  29. cwd=BASEDIR, shell=True, encoding='utf8').strip()
  30. f.write(v)
  31. self.upper_dir = os.path.join(self.staging_dir, "upper")
  32. self.merged_dir = os.path.join(self.staging_dir, "merged")
  33. self.finalized_dir = os.path.join(self.staging_dir, "finalized")
  34. # setup local submodule remotes
  35. submodules = subprocess.check_output("git submodule --quiet foreach 'echo $name'",
  36. shell=True, cwd=BASEDIR, encoding='utf8').split()
  37. for s in submodules:
  38. sub_path = os.path.join(org_dir, s.split("_repo")[0])
  39. self._run(f"git clone {s} {sub_path}.git", cwd=BASEDIR)
  40. # setup two git repos, a remote and one we'll run updated in
  41. self._run([
  42. f"git clone {BASEDIR} {self.git_remote_dir}",
  43. f"git clone {self.git_remote_dir} {self.basedir}",
  44. f"cd {self.basedir} && git submodule init && git submodule update",
  45. f"cd {self.basedir} && scons -j{os.cpu_count()} cereal/ common/"
  46. ])
  47. self.params = Params(os.path.join(self.basedir, "persist/params"))
  48. self.params.clear_all()
  49. os.sync()
  50. def tearDown(self):
  51. try:
  52. if self.updated_proc is not None:
  53. self.updated_proc.terminate()
  54. self.updated_proc.wait(30)
  55. except Exception as e:
  56. print(e)
  57. self.tmp_dir.cleanup()
  58. # *** test helpers ***
  59. def _run(self, cmd, cwd=None):
  60. if not isinstance(cmd, list):
  61. cmd = (cmd,)
  62. for c in cmd:
  63. subprocess.check_output(c, cwd=cwd, shell=True)
  64. def _get_updated_proc(self):
  65. os.environ["PYTHONPATH"] = self.basedir
  66. os.environ["GIT_AUTHOR_NAME"] = "testy tester"
  67. os.environ["GIT_COMMITTER_NAME"] = "testy tester"
  68. os.environ["GIT_AUTHOR_EMAIL"] = "testy@tester.test"
  69. os.environ["GIT_COMMITTER_EMAIL"] = "testy@tester.test"
  70. os.environ["UPDATER_TEST_IP"] = "localhost"
  71. os.environ["UPDATER_LOCK_FILE"] = os.path.join(self.tmp_dir.name, "updater.lock")
  72. os.environ["UPDATER_STAGING_ROOT"] = self.staging_dir
  73. os.environ["UPDATER_NEOS_VERSION"] = self.neos_version
  74. os.environ["UPDATER_NEOSUPDATE_DIR"] = self.neosupdate_dir
  75. updated_path = os.path.join(self.basedir, "selfdrive/updated.py")
  76. return subprocess.Popen(updated_path, env=os.environ)
  77. def _start_updater(self, offroad=True, nosleep=False):
  78. self.params.put_bool("IsOffroad", offroad)
  79. self.updated_proc = self._get_updated_proc()
  80. if not nosleep:
  81. time.sleep(1)
  82. def _update_now(self):
  83. self.updated_proc.send_signal(signal.SIGHUP)
  84. # TODO: this should be implemented in params
  85. def _read_param(self, key, timeout=1):
  86. ret = None
  87. start_time = time.monotonic()
  88. while ret is None:
  89. ret = self.params.get(key, encoding='utf8')
  90. if time.monotonic() - start_time > timeout:
  91. break
  92. time.sleep(0.01)
  93. return ret
  94. def _wait_for_update(self, timeout=30, clear_param=False):
  95. if clear_param:
  96. self.params.remove("LastUpdateTime")
  97. self._update_now()
  98. t = self._read_param("LastUpdateTime", timeout=timeout)
  99. if t is None:
  100. raise Exception("timed out waiting for update to complete")
  101. def _make_commit(self):
  102. all_dirs, all_files = [], []
  103. for root, dirs, files in os.walk(self.git_remote_dir):
  104. if ".git" in root:
  105. continue
  106. for d in dirs:
  107. all_dirs.append(os.path.join(root, d))
  108. for f in files:
  109. all_files.append(os.path.join(root, f))
  110. # make a new dir and some new files
  111. new_dir = os.path.join(self.git_remote_dir, "this_is_a_new_dir")
  112. os.mkdir(new_dir)
  113. for _ in range(random.randrange(5, 30)):
  114. for d in (new_dir, random.choice(all_dirs)):
  115. with tempfile.NamedTemporaryFile(dir=d, delete=False) as f:
  116. f.write(os.urandom(random.randrange(1, 1000000)))
  117. # modify some files
  118. for f in random.sample(all_files, random.randrange(5, 50)):
  119. with open(f, "w+") as ff:
  120. txt = ff.readlines()
  121. ff.seek(0)
  122. for line in txt:
  123. ff.write(line[::-1])
  124. # remove some files
  125. for f in random.sample(all_files, random.randrange(5, 50)):
  126. os.remove(f)
  127. # remove some dirs
  128. for d in random.sample(all_dirs, random.randrange(1, 10)):
  129. shutil.rmtree(d)
  130. # commit the changes
  131. self._run([
  132. "git add -A",
  133. "git commit -m 'an update'",
  134. ], cwd=self.git_remote_dir)
  135. def _check_update_state(self, update_available):
  136. # make sure LastUpdateTime is recent
  137. t = self._read_param("LastUpdateTime")
  138. last_update_time = datetime.datetime.fromisoformat(t)
  139. td = datetime.datetime.utcnow() - last_update_time
  140. self.assertLess(td.total_seconds(), 10)
  141. self.params.remove("LastUpdateTime")
  142. # wait a bit for the rest of the params to be written
  143. time.sleep(0.1)
  144. # check params
  145. update = self._read_param("UpdateAvailable")
  146. self.assertEqual(update == "1", update_available, f"UpdateAvailable: {repr(update)}")
  147. self.assertEqual(self._read_param("UpdateFailedCount"), "0")
  148. # TODO: check that the finalized update actually matches remote
  149. # check the .overlay_init and .overlay_consistent flags
  150. self.assertTrue(os.path.isfile(os.path.join(self.basedir, ".overlay_init")))
  151. self.assertEqual(os.path.isfile(os.path.join(self.finalized_dir, ".overlay_consistent")), update_available)
  152. # *** test cases ***
  153. # Run updated for 100 cycles with no update
  154. def test_no_update(self):
  155. self._start_updater()
  156. for _ in range(100):
  157. self._wait_for_update(clear_param=True)
  158. self._check_update_state(False)
  159. # Let the updater run with no update for a cycle, then write an update
  160. def test_update(self):
  161. self._start_updater()
  162. # run for a cycle with no update
  163. self._wait_for_update(clear_param=True)
  164. self._check_update_state(False)
  165. # write an update to our remote
  166. self._make_commit()
  167. # run for a cycle to get the update
  168. self._wait_for_update(timeout=60, clear_param=True)
  169. self._check_update_state(True)
  170. # run another cycle with no update
  171. self._wait_for_update(clear_param=True)
  172. self._check_update_state(True)
  173. # Let the updater run for 10 cycles, and write an update every cycle
  174. @unittest.skip("need to make this faster")
  175. def test_update_loop(self):
  176. self._start_updater()
  177. # run for a cycle with no update
  178. self._wait_for_update(clear_param=True)
  179. for _ in range(10):
  180. time.sleep(0.5)
  181. self._make_commit()
  182. self._wait_for_update(timeout=90, clear_param=True)
  183. self._check_update_state(True)
  184. # Test overlay re-creation after tracking a new file in basedir's git
  185. def test_overlay_reinit(self):
  186. self._start_updater()
  187. overlay_init_fn = os.path.join(self.basedir, ".overlay_init")
  188. # run for a cycle with no update
  189. self._wait_for_update(clear_param=True)
  190. self.params.remove("LastUpdateTime")
  191. first_mtime = os.path.getmtime(overlay_init_fn)
  192. # touch a file in the basedir
  193. self._run("touch new_file && git add new_file", cwd=self.basedir)
  194. # run another cycle, should have a new mtime
  195. self._wait_for_update(clear_param=True)
  196. second_mtime = os.path.getmtime(overlay_init_fn)
  197. self.assertTrue(first_mtime != second_mtime)
  198. # run another cycle, mtime should be same as last cycle
  199. self._wait_for_update(clear_param=True)
  200. new_mtime = os.path.getmtime(overlay_init_fn)
  201. self.assertTrue(second_mtime == new_mtime)
  202. # Make sure updated exits if another instance is running
  203. def test_multiple_instances(self):
  204. # start updated and let it run for a cycle
  205. self._start_updater()
  206. time.sleep(1)
  207. self._wait_for_update(clear_param=True)
  208. # start another instance
  209. second_updated = self._get_updated_proc()
  210. ret_code = second_updated.wait(timeout=5)
  211. self.assertTrue(ret_code is not None)
  212. # *** test cases with NEOS updates ***
  213. # Run updated with no update, make sure it clears the old NEOS update
  214. def test_clear_neos_cache(self):
  215. # make the dir and some junk files
  216. os.mkdir(self.neosupdate_dir)
  217. for _ in range(15):
  218. with tempfile.NamedTemporaryFile(dir=self.neosupdate_dir, delete=False) as f:
  219. f.write(os.urandom(random.randrange(1, 1000000)))
  220. self._start_updater()
  221. self._wait_for_update(clear_param=True)
  222. self._check_update_state(False)
  223. self.assertFalse(os.path.isdir(self.neosupdate_dir))
  224. # Let the updater run with no update for a cycle, then write an update
  225. @unittest.skip("TODO: only runs on device")
  226. def test_update_with_neos_update(self):
  227. # bump the NEOS version and commit it
  228. self._run([
  229. "echo 'export REQUIRED_NEOS_VERSION=3' >> launch_env.sh",
  230. "git -c user.name='testy' -c user.email='testy@tester.test' \
  231. commit -am 'a neos update'",
  232. ], cwd=self.git_remote_dir)
  233. # run for a cycle to get the update
  234. self._start_updater()
  235. self._wait_for_update(timeout=60, clear_param=True)
  236. self._check_update_state(True)
  237. # TODO: more comprehensive check
  238. self.assertTrue(os.path.isdir(self.neosupdate_dir))
  239. if __name__ == "__main__":
  240. unittest.main()