index.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import logging
  2. import re
  3. from collections import defaultdict
  4. from typing import Optional
  5. from urllib.parse import urlparse
  6. from packaging.version import InvalidVersion, Version
  7. from .models import BoardArtifact, ReleaseRecord
  8. FIRMWARE_SERVER_BASE = "https://firmware.ardupilot.org"
  9. # CBS vehicle id -> firmware.ardupilot.org top-level directory.
  10. FIRMWARE_SERVER_DIR_BY_VEHICLE_ID = {
  11. "copter": "Copter",
  12. "plane": "Plane",
  13. "rover": "Rover",
  14. "sub": "Sub",
  15. "heli": "Copter",
  16. "blimp": "Blimp",
  17. "tracker": "AntennaTracker",
  18. "ap-periph": "AP_Periph",
  19. }
  20. def firmware_server_dir(vehicle_id: str) -> str:
  21. try:
  22. return FIRMWARE_SERVER_DIR_BY_VEHICLE_ID[vehicle_id]
  23. except KeyError as exc:
  24. raise ValueError(f"Unknown vehicle id: {vehicle_id}") from exc
  25. def latest_features_txt_url(vehicle_id: str, board_id: str) -> str:
  26. """Hardcoded firmware-server latest features.txt URL for tag builds."""
  27. board_subdir = board_id + ("-heli" if vehicle_id == "heli" else "")
  28. return (
  29. f"{FIRMWARE_SERVER_BASE}/"
  30. f"{firmware_server_dir(vehicle_id)}/latest/"
  31. f"{board_subdir}/features.txt"
  32. )
  33. # Minimum version for a vehicle to expose from manifest entries.
  34. MIN_VERSION_BY_VEHICLE_ID = {
  35. "copter": "4.3",
  36. "plane": "4.3",
  37. "rover": "4.3",
  38. "sub": "4.3",
  39. "tracker": "4.3",
  40. "blimp": "4.3",
  41. "heli": "4.3",
  42. "ap-periph": "1.8.1",
  43. }
  44. def vehicle_id_for_manifest_entry(entry: dict) -> Optional[str]:
  45. match entry.get("vehicletype", ""):
  46. case "Copter":
  47. return "heli" if entry.get("mav-type") == "HELICOPTER" else "copter"
  48. case "Plane":
  49. return "plane"
  50. case "Rover":
  51. return "rover"
  52. case "Sub":
  53. return "sub"
  54. case "Blimp":
  55. return "blimp"
  56. case "AntennaTracker":
  57. return "tracker"
  58. case "AP_Periph":
  59. return "ap-periph"
  60. case _:
  61. return None
  62. def _should_skip_version(
  63. vehicle_id: str, release_type: str, manifest_version: str
  64. ) -> bool:
  65. if release_type == "latest":
  66. return False
  67. min_version = MIN_VERSION_BY_VEHICLE_ID.get(vehicle_id)
  68. if not min_version:
  69. return False
  70. try:
  71. return Version(manifest_version) < Version(min_version)
  72. except InvalidVersion:
  73. return True
  74. def map_manifest_release_type(mav_firmware_version_type: str) -> Optional[str]:
  75. if not mav_firmware_version_type:
  76. return None
  77. if mav_firmware_version_type.startswith("STABLE-"):
  78. return "stable"
  79. if mav_firmware_version_type == "OFFICIAL":
  80. return "stable"
  81. if mav_firmware_version_type == "BETA":
  82. return "beta"
  83. if mav_firmware_version_type == "DEV":
  84. return "latest"
  85. return mav_firmware_version_type.lower()
  86. def parse_artifacts_base_url(url: str) -> Optional[str]:
  87. match = re.match(
  88. r"(https://firmware\.ardupilot\.org/[^/]+/[^/]+)/",
  89. url or "",
  90. )
  91. if match:
  92. return match.group(1)
  93. return None
  94. def manifest_platform_key(platform: str, vehicle_id: str) -> str:
  95. """Normalize manifest platform field to the board id used for lookups."""
  96. if vehicle_id == "heli" and platform.endswith("-heli"):
  97. return platform[:-5]
  98. return platform
  99. def _artifact_name_from_url(url: str) -> str:
  100. return urlparse(url).path.rstrip("/").split("/")[-1]
  101. def _versioned_release_path_segment(release_type: str, version_number: str) -> str:
  102. if version_number == "NA":
  103. return release_type
  104. return f"{release_type}-{version_number}"
  105. def _artifact_url_specificity(url: str, release_type: str, version_number: str) -> int:
  106. """Prefer versioned firmware-server paths over generic release aliases."""
  107. segment = _versioned_release_path_segment(release_type, version_number)
  108. if f"/{segment}/" in url:
  109. return 2
  110. if f"/{release_type}/" in url:
  111. return 1
  112. return 0
  113. def _dedupe_board_artifacts(
  114. artifacts: list[BoardArtifact],
  115. release_type: str,
  116. version_number: str,
  117. ) -> list[BoardArtifact]:
  118. """Manifest entries may alias the same file under versioned and generic URLs."""
  119. by_name: dict[str, BoardArtifact] = {}
  120. for artifact in artifacts:
  121. existing = by_name.get(artifact.name)
  122. if existing is None or _artifact_url_specificity(
  123. artifact.url, release_type, version_number
  124. ) > _artifact_url_specificity(existing.url, release_type, version_number):
  125. by_name[artifact.name] = artifact
  126. return list(by_name.values())
  127. def _release_fields_from_entry(
  128. entry: dict,
  129. ) -> Optional[tuple[str, str, str, str]]:
  130. vehicle_id = vehicle_id_for_manifest_entry(entry)
  131. if vehicle_id is None:
  132. return None
  133. release_type = map_manifest_release_type(
  134. entry.get("mav-firmware-version-type", "")
  135. )
  136. manifest_version = entry.get("mav-firmware-version")
  137. if not release_type or not manifest_version:
  138. return None
  139. if _should_skip_version(vehicle_id, release_type, manifest_version):
  140. return None
  141. base_url = parse_artifacts_base_url(entry.get("url", ""))
  142. if not base_url:
  143. return None
  144. git_sha = entry.get("git-sha")
  145. if not git_sha:
  146. return None
  147. version_number = "NA" if release_type == "latest" else manifest_version
  148. return vehicle_id, release_type, version_number, git_sha
  149. def _record_release_meta(
  150. release_meta: dict[tuple, dict],
  151. vehicle_id: str,
  152. release_type: str,
  153. version_number: str,
  154. git_sha: str,
  155. logger: logging.Logger,
  156. ) -> None:
  157. key = (vehicle_id, release_type, version_number)
  158. if key not in release_meta:
  159. release_meta[key] = {
  160. "vehicle_id": vehicle_id,
  161. "release_type": release_type,
  162. "version_number": version_number,
  163. "git_sha": git_sha,
  164. }
  165. return
  166. if release_meta[key]["git_sha"] != git_sha:
  167. logger.debug(
  168. "Conflicting git-sha for %s: keeping %s, ignoring %s",
  169. key,
  170. release_meta[key]["git_sha"][:8],
  171. git_sha[:8],
  172. )
  173. def _record_board_artifact(
  174. artifacts_by_release: dict[tuple[str, str, str], dict[str, list[BoardArtifact]]],
  175. entry: dict,
  176. vehicle_id: str,
  177. release_type: str,
  178. version_number: str,
  179. ) -> None:
  180. platform = entry.get("platform")
  181. artifact_url = entry.get("url", "")
  182. if not platform or not artifact_url:
  183. return
  184. platform = manifest_platform_key(platform, vehicle_id)
  185. key = (vehicle_id, release_type, version_number)
  186. artifacts_by_release[key][platform].append(
  187. BoardArtifact(
  188. name=_artifact_name_from_url(artifact_url),
  189. url=artifact_url,
  190. format=entry.get("format", ""),
  191. size=entry.get("image_size"),
  192. )
  193. )
  194. def _releases_from_meta(
  195. release_meta: dict[tuple, dict],
  196. ) -> dict[str, list[ReleaseRecord]]:
  197. releases_by_vehicle: dict[str, list[ReleaseRecord]] = defaultdict(list)
  198. for meta in release_meta.values():
  199. releases_by_vehicle[meta["vehicle_id"]].append(
  200. ReleaseRecord(
  201. vehicle_id=meta["vehicle_id"],
  202. release_type=meta["release_type"],
  203. version_number=meta["version_number"],
  204. commit_reference=meta["git_sha"],
  205. )
  206. )
  207. for vehicle_id in releases_by_vehicle:
  208. releases_by_vehicle[vehicle_id].sort(
  209. key=lambda r: (
  210. 0 if r.release_type == "latest" else 1,
  211. r.release_type,
  212. _version_sort_key(r.version_number),
  213. )
  214. )
  215. return dict(releases_by_vehicle)
  216. class ManifestIndex:
  217. """In-memory index built from a parsed manifest document."""
  218. def __init__(
  219. self,
  220. releases_by_vehicle: dict[str, list[ReleaseRecord]],
  221. artifacts_by_release: dict[
  222. tuple[str, str, str], dict[str, list[BoardArtifact]]
  223. ],
  224. ):
  225. self.releases_by_vehicle = releases_by_vehicle
  226. self.artifacts_by_release = artifacts_by_release
  227. @classmethod
  228. def build(cls, manifest: dict) -> "ManifestIndex":
  229. logger = logging.getLogger(__name__)
  230. release_meta: dict[tuple, dict] = {}
  231. artifacts_by_release: dict[
  232. tuple[str, str, str], dict[str, list[BoardArtifact]]
  233. ] = defaultdict(lambda: defaultdict(list))
  234. for entry in manifest.get("firmware") or []:
  235. fields = _release_fields_from_entry(entry)
  236. if fields is None:
  237. continue
  238. vehicle_id, release_type, version_number, git_sha = fields
  239. _record_release_meta(
  240. release_meta,
  241. vehicle_id,
  242. release_type,
  243. version_number,
  244. git_sha,
  245. logger,
  246. )
  247. _record_board_artifact(
  248. artifacts_by_release,
  249. entry,
  250. vehicle_id,
  251. release_type,
  252. version_number,
  253. )
  254. return cls(
  255. _releases_from_meta(release_meta),
  256. {
  257. release_key: dict(platforms)
  258. for release_key, platforms in artifacts_by_release.items()
  259. },
  260. )
  261. def get_releases(self, vehicle_id: str) -> list[ReleaseRecord]:
  262. return list(self.releases_by_vehicle.get(vehicle_id, []))
  263. def get_board_artifacts(
  264. self,
  265. vehicle_id: str,
  266. release_type: str,
  267. version_number: str,
  268. board_id: str,
  269. ) -> list[BoardArtifact]:
  270. key = (vehicle_id, release_type, version_number)
  271. artifacts = list(self.artifacts_by_release.get(key, {}).get(board_id, []))
  272. return _dedupe_board_artifacts(artifacts, release_type, version_number)
  273. def get_features_txt_url(
  274. self,
  275. vehicle_id: str,
  276. release_type: str,
  277. version_number: str,
  278. board_id: str,
  279. ) -> Optional[str]:
  280. artifacts = self.get_board_artifacts(
  281. vehicle_id, release_type, version_number, board_id
  282. )
  283. if not artifacts:
  284. return None
  285. return artifacts[0].url.rsplit("/", 1)[0] + "/features.txt"
  286. def _version_sort_key(version_number: str) -> tuple:
  287. if version_number == "NA":
  288. return (0,)
  289. try:
  290. parsed = Version(version_number)
  291. return (1, parsed.major, parsed.minor, parsed.micro)
  292. except InvalidVersion:
  293. return (2, version_number)