ap_src_meta_fetcher.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. import redis
  2. import dill
  3. import fnmatch
  4. import time
  5. import logging
  6. import ap_git
  7. import os
  8. class APSourceMetadataFetcher:
  9. """
  10. Class to fetch metadata like available boards, features etc.
  11. from the AP source code
  12. """
  13. __singleton = None
  14. def __init__(self, ap_repo: ap_git.GitRepo,
  15. caching_enabled: bool = False,
  16. redis_host: str = 'localhost',
  17. redis_port: str = '6379') -> None:
  18. """
  19. Initializes the APSourceMetadataFetcher instance
  20. with a given repository path.
  21. Parameters:
  22. ap_repo (GitRepo): ArduPilot local git repository containing
  23. the metadata generation scripts.
  24. caching_enabled (bool): Enable caching metadata for each commit to
  25. avoid checking out git repo each time.
  26. redis_host (str): Hostname of the Redis instance to use for caching
  27. metadata.
  28. redis_port (int): Port of the Redis instance to use for caching
  29. metadata
  30. Raises:
  31. RuntimeError: If an instance of this class already exists,
  32. enforcing a singleton pattern.
  33. """
  34. # Enforce singleton pattern by raising an error if
  35. # an instance already exists.
  36. if APSourceMetadataFetcher.__singleton:
  37. raise RuntimeError(
  38. "APSourceMetadataFetcher must be a singleton."
  39. )
  40. self.logger = logging.getLogger(__name__)
  41. self.repo = ap_repo
  42. self.caching_enabled = caching_enabled
  43. if self.caching_enabled:
  44. self.__redis_client = redis.Redis(
  45. host=redis_host,
  46. port=redis_port,
  47. decode_responses=False,
  48. )
  49. self.logger.info(
  50. f"Redis connection established with {redis_host}:{redis_port}"
  51. )
  52. self.__boards_key_prefix = "boards-"
  53. self.__build_options_key_prefix = "bopts-"
  54. APSourceMetadataFetcher.__singleton = self
  55. def __boards_key(self, commit_id: str) -> str:
  56. """
  57. Generate the Redis key that stores the boards list for a given commit.
  58. Parameters:
  59. commit_id (str): The git sha for the commit.
  60. Returns:
  61. str: The Redis key containing the cached board list.
  62. """
  63. return self.__boards_key_prefix + f"{commit_id}"
  64. def __build_options_key(self, commit_id: str) -> str:
  65. """
  66. Generate the Redis key that stores the build options list for a given
  67. commit.
  68. Parameters:
  69. commit_id (str): The git sha for the commit.
  70. Returns:
  71. str: The Redis key containing the cached build options list.
  72. """
  73. return self.__build_options_key_prefix + f"{commit_id}"
  74. def __cache_boards_at_commit(self,
  75. boards: tuple,
  76. commit_id: str,
  77. ttl_sec: int = 86400) -> None:
  78. """
  79. Cache the given tuple of boards for a particular commit.
  80. Parameters:
  81. boards (tuple): The tuple of boards (both non-periph and periph).
  82. commit_id (str): The git sha for the commit.
  83. ttl_sec (int): Time-to-live (TTL) in seconds after which the
  84. cached list expires.
  85. Raises:
  86. RuntimeError: If the method is called when caching is disabled.
  87. """
  88. if not self.caching_enabled:
  89. raise RuntimeError("Should not be called with caching disabled.")
  90. key = self.__boards_key(commit_id=commit_id)
  91. self.logger.debug(
  92. "Caching boards list "
  93. f"Redis key: {key}, "
  94. f"Boards: {boards}, "
  95. f"TTL: {ttl_sec} sec"
  96. )
  97. self.__redis_client.set(
  98. name=key,
  99. value=dill.dumps(boards),
  100. ex=ttl_sec
  101. )
  102. def __cache_build_options_at_commit(self,
  103. build_options: list,
  104. commit_id: str,
  105. ttl_sec: int = 86400) -> None:
  106. """
  107. Cache the given list of build options for a particular commit.
  108. Parameters:
  109. build_options (list): The list of build options.
  110. commit_id (str): The git sha for the commit.
  111. ttl_sec (int): Time-to-live (TTL) in seconds after which the
  112. cached list expires.
  113. Raises:
  114. RuntimeError: If the method is called when caching is disabled.
  115. """
  116. if not self.caching_enabled:
  117. raise RuntimeError("Should not be called with caching disabled.")
  118. key = self.__build_options_key(commit_id=commit_id)
  119. self.logger.debug(
  120. "Caching build options "
  121. f"Redis key: {key}, "
  122. f"Build Options: {build_options}, "
  123. f"TTL: {ttl_sec} sec"
  124. )
  125. self.__redis_client.set(
  126. name=key,
  127. value=dill.dumps(build_options),
  128. ex=ttl_sec
  129. )
  130. def __get_build_options_at_commit_from_cache(self,
  131. commit_id: str) -> list:
  132. """
  133. Retrieves a list of build options available at a specified commit
  134. from cache if exists, None otherwise.
  135. Parameters:
  136. commit_id (str): The commit id to get build options for.
  137. Returns:
  138. list: A list of build options available at the specified commit.
  139. Raises:
  140. RuntimeError: If the method is called when caching is disabled.
  141. """
  142. if not self.caching_enabled:
  143. raise RuntimeError("Should not be called with caching disabled.")
  144. key = self.__build_options_key(commit_id=commit_id)
  145. self.logger.debug(
  146. f"Getting cached build options for commit id {commit_id}, "
  147. f"Redis Key: {key}"
  148. )
  149. value = self.__redis_client.get(key)
  150. self.logger.debug(f"Got value {value} at key {key}")
  151. return dill.loads(value) if value else None
  152. def __get_boards_at_commit_from_cache(self,
  153. commit_id: str) -> tuple[list, list]:
  154. """
  155. Returns the tuple of boards (for non-periph and periph targets,
  156. in order) for a given commit from cache if exists, None otherwise.
  157. Parameters:
  158. commit_id (str): The commit id to get boards list for.
  159. Returns:
  160. tuple: A tuple of two lists in order:
  161. - A list contains boards for NON-'ap_periph' targets.
  162. - A list contains boards for the 'ap_periph' target.
  163. Raises:
  164. RuntimeError: If the method is called when caching is disabled.
  165. """
  166. if not self.caching_enabled:
  167. raise RuntimeError("Should not be called with caching disabled.")
  168. key = self.__boards_key(commit_id=commit_id)
  169. self.logger.debug(
  170. f"Getting cached boards list for commit id {commit_id}, "
  171. f"Redis Key: {key}"
  172. )
  173. value = self.__redis_client.get(key)
  174. self.logger.debug(f"Got value {value} at key {key}")
  175. boards = dill.loads(value) if value else None
  176. if not boards:
  177. return None
  178. # Ensure the data retrieved from the cache is correct
  179. # We should get a tuple containing two lists
  180. try:
  181. non_periph_boards, periph_boards = boards
  182. except ValueError as e:
  183. self.logger.debug(f"Boards from cache: '{boards}'")
  184. self.logger.exception(e)
  185. return None
  186. return (
  187. non_periph_boards,
  188. periph_boards
  189. )
  190. def __exclude_boards_matching_patterns(self, boards: list, patterns: list):
  191. ret = []
  192. for b in boards:
  193. excluded = False
  194. for p in patterns:
  195. if fnmatch.fnmatch(b.lower(), p.lower()):
  196. excluded = True
  197. break
  198. if not excluded:
  199. ret.append(b)
  200. return ret
  201. def __get_boards_at_commit_from_repo(self, remote: str,
  202. commit_ref: str) -> tuple[list, list]:
  203. """
  204. Returns the tuple of boards (for both non-periph and periph targets,
  205. in order) for a given commit from the git repo.
  206. Parameters:
  207. remote (str): The name of the remote repository.
  208. commit_ref (str): The commit reference to check out.
  209. Returns:
  210. tuple: A tuple of two lists in order:
  211. - A list contains boards for NON-'ap_periph' targets.
  212. - A list contains boards for the 'ap_periph' target.
  213. """
  214. with self.repo.get_checkout_lock():
  215. self.repo.checkout_remote_commit_ref(
  216. remote=remote,
  217. commit_ref=commit_ref,
  218. force=True,
  219. hard_reset=True,
  220. clean_working_tree=True
  221. )
  222. import importlib.util
  223. spec = importlib.util.spec_from_file_location(
  224. name="board_list.py",
  225. location=os.path.join(
  226. self.repo.get_local_path(),
  227. 'Tools', 'scripts',
  228. 'board_list.py')
  229. )
  230. mod = importlib.util.module_from_spec(spec)
  231. spec.loader.exec_module(mod)
  232. non_periph_boards = mod.AUTOBUILD_BOARDS
  233. periph_boards = mod.AP_PERIPH_BOARDS
  234. self.logger.debug(f"non_periph_boards raw: {non_periph_boards}")
  235. self.logger.debug(f"periph_boards raw: {periph_boards}")
  236. non_periph_boards = self.__exclude_boards_matching_patterns(
  237. boards=non_periph_boards,
  238. patterns=['fmuv*', 'SITL*'],
  239. )
  240. self.logger.debug(f"non_periph_boards filtered: {non_periph_boards}")
  241. non_periph_boards_sorted = sorted(non_periph_boards)
  242. periph_boards_sorted = sorted(periph_boards)
  243. self.logger.debug(
  244. f"non_periph_boards sorted: {non_periph_boards_sorted}"
  245. )
  246. self.logger.debug(f"periph_boards sorted: {periph_boards_sorted}")
  247. return (
  248. non_periph_boards_sorted,
  249. periph_boards_sorted,
  250. )
  251. def __get_build_options_at_commit_from_repo(self,
  252. remote: str,
  253. commit_ref: str) -> tuple[
  254. list,
  255. list
  256. ]:
  257. """
  258. Returns the list of build options for a given commit from the git repo.
  259. Parameters:
  260. remote (str): The name of the remote repository.
  261. commit_ref (str): The commit reference to check out.
  262. Returns:
  263. list: A list of build options available at the specified commit.
  264. """
  265. with self.repo.get_checkout_lock():
  266. self.repo.checkout_remote_commit_ref(
  267. remote=remote,
  268. commit_ref=commit_ref,
  269. force=True,
  270. hard_reset=True,
  271. clean_working_tree=True
  272. )
  273. import importlib.util
  274. spec = importlib.util.spec_from_file_location(
  275. name="build_options.py",
  276. location=os.path.join(
  277. self.repo.get_local_path(),
  278. 'Tools',
  279. 'scripts',
  280. 'build_options.py'
  281. )
  282. )
  283. mod = importlib.util.module_from_spec(spec)
  284. spec.loader.exec_module(mod)
  285. build_options = mod.BUILD_OPTIONS
  286. return build_options
  287. def __get_boards_at_commit(self, remote: str,
  288. commit_ref: str) -> tuple[list, list]:
  289. """
  290. Retrieves lists of boards available for building at a
  291. specified commit for both NON-'ap_periph' and ap_periph targets
  292. and returns a tuple containing both lists.
  293. If caching is enabled, this would first look in the cache for
  294. the list. In case of a cache miss, it would retrive the list
  295. by checkout out the git repo and running `board_list.py` and
  296. cache it.
  297. Parameters:
  298. remote (str): The name of the remote repository.
  299. commit_ref (str): The commit reference to check out.
  300. Returns:
  301. tuple: A tuple of two lists in order:
  302. - A list contains boards for NON-'ap_periph' targets.
  303. - A list contains boards for the 'ap_periph' target.
  304. """
  305. tstart = time.time()
  306. if not self.caching_enabled:
  307. boards = self.__get_boards_at_commit_from_repo(
  308. remote=remote,
  309. commit_ref=commit_ref,
  310. )
  311. self.logger.debug(
  312. f"Took {(time.time() - tstart)} seconds to get boards"
  313. )
  314. return boards
  315. commid_id = self.repo.commit_id_for_remote_ref(
  316. remote=remote,
  317. commit_ref=commit_ref,
  318. )
  319. self.logger.debug(f"Fetching boards for commit {commid_id}.")
  320. cached_boards = self.__get_boards_at_commit_from_cache(
  321. commit_id=commid_id
  322. )
  323. if cached_boards:
  324. boards = cached_boards
  325. else:
  326. self.logger.debug(
  327. "Cache miss. Fetching boards from repo for "
  328. f"commit {commid_id}."
  329. )
  330. boards = self.__get_boards_at_commit_from_repo(
  331. remote=remote,
  332. commit_ref=commid_id,
  333. )
  334. self.__cache_boards_at_commit(
  335. boards=boards,
  336. commit_id=commid_id,
  337. )
  338. self.logger.debug(
  339. f"Took {(time.time() - tstart)} seconds to get boards"
  340. )
  341. return boards
  342. def get_boards(self, remote: str, commit_ref: str,
  343. vehicle_id: str) -> list:
  344. """
  345. Returns a list of boards available for building at a
  346. specified commit for given vehicle.
  347. Parameters:
  348. remote (str): The name of the remote repository.
  349. commit_ref (str): The commit reference to check out.
  350. vehicle_id (str): The vehicle ID to get the boards list for.
  351. Returns:
  352. list: A list of boards.
  353. """
  354. non_periph_boards, periph_boards = self.__get_boards_at_commit(
  355. remote=remote,
  356. commit_ref=commit_ref,
  357. )
  358. if vehicle_id == 'ap-periph':
  359. return periph_boards
  360. return non_periph_boards
  361. def get_build_options_at_commit(self, remote: str,
  362. commit_ref: str) -> list:
  363. """
  364. Retrieves a list of build options available at a specified commit.
  365. If caching is enabled, this would first look in the cache for
  366. the list. In case of a cache miss, it would retrive the list
  367. by checkout out the git repo and running `build_options.py` and
  368. cache it.
  369. Parameters:
  370. remote (str): The name of the remote repository.
  371. commit_ref (str): The commit reference to check out.
  372. Returns:
  373. list: A list of build options available at the specified commit.
  374. """
  375. tstart = time.time()
  376. if not self.caching_enabled:
  377. build_options = self.__get_build_options_at_commit_from_repo(
  378. remote=remote,
  379. commit_ref=commit_ref,
  380. )
  381. self.logger.debug(
  382. f"Took {(time.time() - tstart)} seconds to get build options"
  383. )
  384. return build_options
  385. commid_id = self.repo.commit_id_for_remote_ref(
  386. remote=remote,
  387. commit_ref=commit_ref,
  388. )
  389. self.logger.debug(f"Fetching build options for commit {commid_id}.")
  390. cached_build_options = self.__get_build_options_at_commit_from_cache(
  391. commit_id=commid_id
  392. )
  393. if cached_build_options:
  394. build_options = cached_build_options
  395. else:
  396. self.logger.debug(
  397. "Cache miss. Fetching build options from repo for "
  398. f"commit {commid_id}."
  399. )
  400. build_options = self.__get_build_options_at_commit_from_repo(
  401. remote=remote,
  402. commit_ref=commid_id,
  403. )
  404. self.__cache_build_options_at_commit(
  405. build_options=build_options,
  406. commit_id=commid_id,
  407. )
  408. self.logger.debug(
  409. f"Took {(time.time() - tstart)} seconds to get build options"
  410. )
  411. return build_options
  412. def get_board_defaults_from_fw_server(
  413. self,
  414. artifacts_url: str,
  415. board_id: str,
  416. vehicle_id: str = None,
  417. ) -> dict:
  418. """
  419. Fetch board defaults from firmware.ardupilot.org features.txt.
  420. The features.txt file contains lines like:
  421. - FEATURE_NAME (enabled features)
  422. - !FEATURE_NAME (disabled features)
  423. Parameters:
  424. artifacts_url (str): Base URL for build artifacts for a version.
  425. board_id (str): Board identifier
  426. vehicle_id (str): Vehicle identifier
  427. (for special handling like Heli)
  428. Returns:
  429. dict: Dictionary mapping feature define to state
  430. (1 for enabled, 0 for disabled), or None if fetch fails
  431. """
  432. import requests
  433. # Heli builds are stored under a separate folder
  434. artifacts_subdir = board_id
  435. if vehicle_id == "Heli":
  436. artifacts_subdir += "-heli"
  437. features_txt_url = f"{artifacts_url}/{artifacts_subdir}/features.txt"
  438. try:
  439. response = requests.get(features_txt_url, timeout=30)
  440. response.raise_for_status()
  441. feature_states = {}
  442. enabled_count = 0
  443. disabled_count = 0
  444. for line in response.text.splitlines():
  445. line = line.strip()
  446. # Skip empty lines and comments
  447. if not line or line.startswith('#'):
  448. continue
  449. # Check if feature is disabled (prefixed with !)
  450. if line.startswith('!'):
  451. feature_name = line[1:].strip()
  452. if feature_name:
  453. feature_states[feature_name] = 0
  454. disabled_count += 1
  455. else:
  456. # Enabled feature
  457. if line:
  458. feature_states[line] = 1
  459. enabled_count += 1
  460. self.logger.info(
  461. f"Fetched board defaults from firmware server: "
  462. f"{enabled_count} enabled, "
  463. f"{disabled_count} disabled"
  464. )
  465. return feature_states
  466. except requests.RequestException as e:
  467. self.logger.warning(
  468. f"Failed to fetch board defaults from {features_txt_url}: {e}"
  469. )
  470. return None
  471. @staticmethod
  472. def get_singleton():
  473. return APSourceMetadataFetcher.__singleton