versions_fetcher.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. import logging
  2. import os
  3. import ap_git
  4. import json
  5. import jsonschema
  6. import hashlib
  7. from pathlib import Path
  8. from threading import Lock
  9. from utils import TaskRunner
  10. from .vehicles_manager import VehiclesManager as vehm
  11. class VersionInfo:
  12. """
  13. Class to wrap version info properties.
  14. """
  15. def __init__(self,
  16. remote_info: 'RemoteInfo',
  17. commit_ref: str,
  18. release_type: str,
  19. version_number: str,
  20. ap_build_artifacts_url) -> None:
  21. self.remote_info = remote_info
  22. self.commit_ref = commit_ref
  23. self.release_type = release_type
  24. self.version_number = version_number
  25. self.ap_build_artifacts_url = ap_build_artifacts_url
  26. # Generate version_id as remote-sanitized_commit_ref-hash
  27. # Commit ref is sanitized for URL safety by replacing '/' with '-'
  28. # Hash is used to ensure unique ID after sanitization of commit_ref,
  29. # as different commit refs may become identical after replacing '/'
  30. commit_ref_sanitized = commit_ref.replace('/', '-')
  31. commit_ref_hash = hashlib.md5(commit_ref.encode()).hexdigest()[:8]
  32. self.version_id = (
  33. f"{remote_info.name}-{commit_ref_sanitized}-{commit_ref_hash}"
  34. )
  35. class RemoteInfo:
  36. """
  37. Class to wrap remote info properties.
  38. """
  39. def __init__(self,
  40. name: str,
  41. url: str) -> None:
  42. self.name = name
  43. self.url = url
  44. def to_dict(self):
  45. return {
  46. 'name': self.name,
  47. 'url': self.url,
  48. }
  49. class VersionsFetcher:
  50. """
  51. Class to fetch the version-to-build metadata from remotes.json
  52. and provide methods to view the same
  53. """
  54. __singleton = None
  55. def __init__(self, remotes_json_path: str,
  56. ap_repo: ap_git.GitRepo):
  57. """
  58. Initializes the VersionsFetcher instance
  59. with a given remotes.json path.
  60. Parameters:
  61. remotes_json_path (str): Path to the remotes.json file.
  62. ap_repo (GitRepo): ArduPilot local git repository. This local
  63. repository is shared between the VersionsFetcher
  64. and the APSourceMetadataFetcher.
  65. Raises:
  66. RuntimeError: If an instance of this class already exists,
  67. enforcing a singleton pattern.
  68. """
  69. if vehm.get_singleton() is None:
  70. raise RuntimeError("VehiclesManager should be initialised first")
  71. # Enforce singleton pattern by raising an error if
  72. # an instance already exists.
  73. if VersionsFetcher.__singleton:
  74. raise RuntimeError("VersionsFetcher must be a singleton.")
  75. self.logger = logging.getLogger(__name__)
  76. self.__remotes_json_path = remotes_json_path
  77. self.__ensure_remotes_json()
  78. self.__access_lock_versions_metadata = Lock()
  79. self.__versions_metadata = []
  80. tasks = (
  81. (self.fetch_ap_releases, 1200),
  82. (self.fetch_whitelisted_tags, 1200),
  83. )
  84. self.__task__runner = TaskRunner(tasks=tasks)
  85. self.repo = ap_repo
  86. VersionsFetcher.__singleton = self
  87. def start(self) -> None:
  88. """
  89. Start auto-fetch jobs.
  90. """
  91. self.logger.info(
  92. "Starting VersionsFetcher background auto-fetch jobs."
  93. )
  94. self.__task__runner.start()
  95. def stop(self) -> None:
  96. """
  97. Stop auto-fetch jobs.
  98. """
  99. self.logger.info(
  100. "Stopping VersionsFetcher background auto-fetch jobs."
  101. )
  102. self.__task__runner.stop()
  103. def get_all_remotes_info(self) -> list[RemoteInfo]:
  104. """
  105. Return the list of RemoteInfo objects constructed from the
  106. information in the remotes.json file
  107. Returns:
  108. list: RemoteInfo objects for all remotes mentioned in remotes.json
  109. """
  110. return [
  111. RemoteInfo(
  112. name=remote.get('name', None),
  113. url=remote.get('url', None)
  114. )
  115. for remote in self.__get_versions_metadata()
  116. ]
  117. def get_remote_info(self, remote_name: str) -> RemoteInfo:
  118. """
  119. Return the RemoteInfo for the given remote name, None otherwise.
  120. Returns:
  121. RemoteInfo: The remote information object.
  122. """
  123. return next(
  124. (
  125. remote for remote in self.get_all_remotes_info()
  126. if remote.name == remote_name
  127. ),
  128. None
  129. )
  130. def get_versions_for_vehicle(self, vehicle_id: str) -> list[VersionInfo]:
  131. """
  132. Return the list of dictionaries containing the info about the
  133. versions listed to be built for a particular vehicle.
  134. Parameters:
  135. vehicle_id (str): the vehicle ID to fetch versions list for
  136. Returns:
  137. list: VersionInfo objects for all versions allowed to be
  138. built for the said vehicle.
  139. """
  140. if vehicle_id is None:
  141. raise ValueError("Vehicle ID is a required parameter.")
  142. vehicle = vehm.get_singleton().get_vehicle_by_id(vehicle_id)
  143. if vehicle is None:
  144. raise ValueError(f"Invalid vehicle ID '{vehicle_id}'.")
  145. vehicle_name = vehicle.name
  146. versions_list = []
  147. for remote in self.__get_versions_metadata():
  148. remote_info = RemoteInfo(
  149. name=remote.get('name', None),
  150. url=remote.get('url', None)
  151. )
  152. for vehicle in remote['vehicles']:
  153. if vehicle['name'] != vehicle_name:
  154. continue
  155. for release in vehicle['releases']:
  156. versions_list.append(VersionInfo(
  157. remote_info=remote_info,
  158. commit_ref=release.get('commit_reference', None),
  159. release_type=release.get('release_type', None),
  160. version_number=release.get('version_number', None),
  161. ap_build_artifacts_url=release.get(
  162. 'ap_build_artifacts_url',
  163. None
  164. )
  165. ))
  166. return versions_list
  167. def is_version_listed(self, vehicle_id: str, version_id: str) -> bool:
  168. """
  169. Check if a version with given properties mentioned in remotes.json
  170. Parameters:
  171. vehicle_id (str): ID of the vehicle for which version is listed
  172. version_id (str): version ID
  173. Returns:
  174. bool: True if the said version is mentioned in remotes.json,
  175. False otherwise
  176. """
  177. if vehicle_id is None:
  178. raise ValueError("vehicle_id is a required parameter.")
  179. if version_id is None:
  180. raise ValueError("version_id is a required parameter.")
  181. return version_id in [
  182. version_info.version_id
  183. for version_info in
  184. self.get_versions_for_vehicle(vehicle_id=vehicle_id)
  185. ]
  186. def get_version_info(self, vehicle_id: str,
  187. version_id: str) -> VersionInfo:
  188. """
  189. Find first version matching the given properties in remotes.json
  190. Parameters:
  191. vehicle_id (str): ID of the vehicle for which version is listed
  192. version_id (str): version ID
  193. Returns:
  194. VersionInfo: Object for the version matching the properties,
  195. None if not found
  196. """
  197. return next(
  198. (
  199. version
  200. for version in self.get_versions_for_vehicle(
  201. vehicle_id=vehicle_id
  202. )
  203. if version.version_id == version_id
  204. ),
  205. None
  206. )
  207. def reload_remotes_json(self) -> None:
  208. """
  209. Read remotes.json, validate its structure against the schema
  210. and cache it in memory
  211. """
  212. # load file containing vehicles listed to be built for each
  213. # remote along with the branches/tags/commits on which the
  214. # firmware can be built
  215. remotes_json_schema_path = os.path.join(
  216. os.path.dirname(__file__),
  217. 'remotes.schema.json'
  218. )
  219. with open(self.__remotes_json_path, 'r') as f, \
  220. open(remotes_json_schema_path, 'r') as s:
  221. f_content = f.read()
  222. # Early return if file is empty
  223. if not f_content:
  224. return
  225. versions_metadata = json.loads(f_content)
  226. schema = json.loads(s.read())
  227. # validate schema
  228. jsonschema.validate(instance=versions_metadata, schema=schema)
  229. self.__set_versions_metadata(versions_metadata=versions_metadata)
  230. # update git repo with latest remotes list
  231. self.__sync_remotes_with_ap_repo()
  232. def __ensure_remotes_json(self) -> None:
  233. """
  234. Ensures remotes.json exists and is a valid JSON file.
  235. """
  236. p = Path(self.__remotes_json_path)
  237. if not p.exists():
  238. # Ensure parent directory exists
  239. Path.mkdir(p.parent, parents=True, exist_ok=True)
  240. # write empty json list
  241. with open(p, 'w') as f:
  242. f.write('[]')
  243. def __set_versions_metadata(self, versions_metadata: list) -> None:
  244. """
  245. Set versions metadata property with the one passed as parameter
  246. This requires to acquire the access lock to avoid overwriting the
  247. object while it is being read
  248. """
  249. if versions_metadata is None:
  250. raise ValueError("versions_metadata is a required parameter. "
  251. "Cannot be None.")
  252. with self.__access_lock_versions_metadata:
  253. self.__versions_metadata = versions_metadata
  254. def __get_versions_metadata(self) -> list:
  255. """
  256. Read versions metadata property
  257. This requires to acquire the access lock to avoid reading the list
  258. while it is being modified
  259. Returns:
  260. list: the versions metadata list
  261. """
  262. with self.__access_lock_versions_metadata:
  263. return self.__versions_metadata
  264. def __sync_remotes_with_ap_repo(self):
  265. """
  266. Update the remotes in ArduPilot local repository with the latest
  267. remotes list.
  268. """
  269. remotes = tuple(
  270. (remote.name, remote.url)
  271. for remote in self.get_all_remotes_info()
  272. )
  273. self.repo.remote_add_bulk(remotes=remotes, force=True)
  274. def fetch_ap_releases(self) -> None:
  275. """
  276. Execute the fetch_releases.py script to update remotes.json
  277. with Ardupilot's official releases
  278. """
  279. from scripts import fetch_releases
  280. fetch_releases.run(
  281. base_dir=os.path.join(
  282. os.path.dirname(self.__remotes_json_path),
  283. '..',
  284. ),
  285. remote_name="ardupilot",
  286. )
  287. self.reload_remotes_json()
  288. return
  289. def fetch_whitelisted_tags(self) -> None:
  290. """
  291. Execute the fetch_whitelisted_tags.py script to update
  292. remotes.json with tags from whitelisted repos
  293. """
  294. from scripts import fetch_whitelisted_tags
  295. fetch_whitelisted_tags.run(
  296. base_dir=os.path.join(
  297. os.path.dirname(self.__remotes_json_path),
  298. '..',
  299. )
  300. )
  301. self.reload_remotes_json()
  302. return
  303. @staticmethod
  304. def get_singleton():
  305. return VersionsFetcher.__singleton