manager.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import time
  2. import redis
  3. import dill
  4. from enum import Enum
  5. from utils import RateLimiter
  6. import logging
  7. import hashlib
  8. from metadata_manager import RemoteInfo
  9. import os
  10. class BuildState(Enum):
  11. PENDING = 0
  12. RUNNING = 1
  13. SUCCESS = 2
  14. FAILURE = 3
  15. ERROR = 4
  16. TIMED_OUT = 5
  17. class BuildProgress:
  18. def __init__(
  19. self,
  20. state: BuildState,
  21. percent: int
  22. ) -> None:
  23. """
  24. Initialise the progress property for a build,
  25. including its state and completion percentage.
  26. Parameters:
  27. state (BuildState): The current state of the build.
  28. percent (int): The completion percentage of the build (0-100).
  29. """
  30. self.state = state
  31. self.percent = percent
  32. def to_dict(self) -> dict:
  33. return {
  34. 'state': self.state.name,
  35. 'percent': self.percent,
  36. }
  37. class BuildInfo:
  38. def __init__(self,
  39. vehicle_id: str,
  40. remote_info: RemoteInfo,
  41. git_hash: str,
  42. board: str,
  43. selected_features: set) -> None:
  44. """
  45. Initialize build information object including vehicle,
  46. remote, git hash, selected features, and progress of the build.
  47. The progress percentage is initially 0 and the state is PENDING.
  48. Parameters:
  49. vehicle_id (str): The vehicle ID associated with the build.
  50. remote_info (RemoteInfo): The remote repository containing the
  51. source commit to build on.
  52. git_hash (str): The git commit hash to build on.
  53. board (str): Board to build for.
  54. selected_features (set): Set of features selected for the build.
  55. """
  56. self.vehicle_id = vehicle_id
  57. self.remote_info = remote_info
  58. self.git_hash = git_hash
  59. self.board = board
  60. self.selected_features = selected_features
  61. self.progress = BuildProgress(
  62. state=BuildState.PENDING,
  63. percent=0
  64. )
  65. self.time_created = time.time()
  66. self.time_started = None # when build state becomes RUNNING
  67. def to_dict(self) -> dict:
  68. return {
  69. 'vehicle_id': self.vehicle_id,
  70. 'remote_info': self.remote_info.to_dict(),
  71. 'git_hash': self.git_hash,
  72. 'board': self.board,
  73. 'selected_features': list(self.selected_features),
  74. 'progress': self.progress.to_dict(),
  75. 'time_created': self.time_created,
  76. 'time_started': getattr(self, 'time_started', None),
  77. }
  78. class BuildManager:
  79. """
  80. Class to manage the build lifecycle, including build submission,
  81. announcements, progress updates, and retrieval of build-related
  82. information.
  83. """
  84. __singleton = None
  85. def __init__(self,
  86. outdir: str,
  87. redis_host: str = 'localhost',
  88. redis_port: int = 6379,
  89. redis_task_queue_name: str = 'builds-queue') -> None:
  90. """
  91. Initialide the BuildManager instance. This class is responsible
  92. for interacting with Redis to store build metadata and managing
  93. build tasks.
  94. Parameters:
  95. outdir (str): Path to the directory for storing build artifacts.
  96. redis_host (str): Hostname of the Redis instance for storing build
  97. metadata.
  98. redis_port (int): Port of the Redis instance for storing build
  99. metadata.
  100. redis_task_queue_name (str): Redis List name to be used as the
  101. task queue.
  102. Raises:
  103. RuntimeError: If an instance of this class already exists,
  104. enforcing a singleton pattern.
  105. """
  106. if BuildManager.__singleton:
  107. raise RuntimeError("BuildManager must be a singleton")
  108. # Initialide Redis client without decoding responses
  109. # as we use dill for serialization.
  110. self.__redis_client = redis.Redis(
  111. host=redis_host,
  112. port=redis_port,
  113. decode_responses=False
  114. )
  115. self.__task_queue = redis_task_queue_name
  116. self.__outdir = outdir
  117. # Initialide an IP-based rate limiter.
  118. # Allow 10 builds per hour per client
  119. self.__ip_rate_limiter = RateLimiter(
  120. redis_host=redis_host,
  121. redis_port=redis_port,
  122. time_window_sec=3600,
  123. allowed_requests=10
  124. )
  125. self.__build_entry_prefix = "buildmeta-"
  126. self.logger = logging.getLogger(__name__)
  127. self.logger.info(
  128. "Build Manager initialised with configuration: "
  129. f"Redis host: {redis_host}, "
  130. f"Redis port: {redis_port}, "
  131. f"Redis task queue: {self.__task_queue}, "
  132. f"Build output directory: {self.__outdir}, "
  133. f"Build entry prefix: {self.__build_entry_prefix}"
  134. )
  135. BuildManager.__singleton = self
  136. def __del__(self) -> None:
  137. """
  138. Gracefully close the Redis connection when the BuildManager instance
  139. is deleted.
  140. """
  141. if self.__redis_client:
  142. self.logger.debug("Closing Redis connection")
  143. self.__redis_client.close()
  144. def __key_from_build_id(self, build_id: str) -> str:
  145. """
  146. Generate the Redis key that stores the build information for the given
  147. build ID.
  148. Parameters:
  149. build_id (str): The unique ID for the build.
  150. Returns:
  151. str: The Redis key containing the build information.
  152. """
  153. return self.__build_entry_prefix + build_id
  154. def __build_id_from_key(self, key: str) -> str:
  155. """
  156. Extract the build ID from the given Redis key.
  157. Parameters:
  158. key (str): The Redis key storing build information.
  159. Returns:
  160. str: The build ID corresponding to the given Redis key.
  161. """
  162. return key[len(self.__build_entry_prefix):]
  163. def get_outdir(self) -> str:
  164. """
  165. Return the directory where build artifacts are stored.
  166. Returns:
  167. str: Path to the output directory containing build artifacts.
  168. """
  169. return self.__outdir
  170. def __generate_build_id(self, build_info: BuildInfo) -> str:
  171. """
  172. Generate a unique build ID based on the build information and
  173. current timestamp. The build information is hashed and combined
  174. with the time to generate the ID.
  175. Parameters:
  176. build_info (BuildInfo): The build information object.
  177. Returns:
  178. str: The generated build ID (64 characters).
  179. """
  180. h = hashlib.md5(
  181. f"{build_info}-{time.time_ns()}".encode()
  182. ).hexdigest()
  183. bid = f"{build_info.vehicle_id}-{build_info.board}-{h}"
  184. return bid
  185. def submit_build(self,
  186. build_info: BuildInfo,
  187. client_ip: str) -> str:
  188. """
  189. Submit a new build request, generate a build ID, and queue the
  190. build for processing.
  191. Parameters:
  192. build_info (BuildInfo): The build information.
  193. client_ip (str): The IP address of the client submitting the
  194. build request.
  195. Returns:
  196. str: The generated build ID for the submitted build.
  197. """
  198. self.__ip_rate_limiter.count(client_ip)
  199. build_id = self.__generate_build_id(build_info)
  200. self.__insert_build_info(build_id=build_id, build_info=build_info)
  201. self.__queue_build(build_id=build_id)
  202. return build_id
  203. def __queue_build(self,
  204. build_id: str) -> None:
  205. """
  206. Add the build ID to the Redis task queue for processing.
  207. Parameters:
  208. build_id (str): The ID of the build to be queued.
  209. """
  210. self.__redis_client.rpush(
  211. self.__task_queue,
  212. build_id.encode()
  213. )
  214. def get_next_build_id(self, timeout: int = 0) -> str:
  215. """
  216. Block until the next build ID is available in the task queue,
  217. then return it. If timeout is specified and no build is available
  218. within that time, returns None.
  219. Parameters:
  220. timeout (int): Maximum time to wait in seconds. 0 means wait
  221. indefinitely.
  222. Returns:
  223. str: The ID of the next build to be processed, or None if timeout.
  224. """
  225. result = self.__redis_client.blpop(self.__task_queue, timeout=timeout)
  226. if result is None:
  227. # Timeout occurred
  228. return None
  229. _, build_id_encoded = result
  230. build_id = build_id_encoded.decode()
  231. self.logger.debug(f"Next build id: {build_id}")
  232. return build_id
  233. def build_exists(self,
  234. build_id: str) -> bool:
  235. """
  236. Check if a build with the given ID exists in the datastore.
  237. Parameters:
  238. build_id (str): The ID of the build to check.
  239. Returns:
  240. bool: True if the build exists, False otherwise.
  241. """
  242. return self.__redis_client.exists(
  243. self.__key_from_build_id(build_id=build_id)
  244. )
  245. def __insert_build_info(self,
  246. build_id: str,
  247. build_info: BuildInfo,
  248. ttl_sec: int = 86400) -> None:
  249. """
  250. Insert the build information into the datastore.
  251. Parameters:
  252. build_id (str): The ID of the build.
  253. build_info (BuildInfo): The build information to store.
  254. ttl_sec (int): Time-to-live (TTL) in seconds after which the
  255. build expires.
  256. """
  257. if self.build_exists(build_id=build_id):
  258. raise ValueError(f"Build with id {build_id} already exists")
  259. key = self.__key_from_build_id(build_id)
  260. self.logger.debug(
  261. "Adding build info, "
  262. f"Redis key: {key}, "
  263. f"Build Info: {build_info}, "
  264. f"TTL: {ttl_sec} sec"
  265. )
  266. self.__redis_client.set(
  267. name=key,
  268. value=dill.dumps(build_info),
  269. ex=ttl_sec
  270. )
  271. def get_build_info(self,
  272. build_id: str) -> BuildInfo:
  273. """
  274. Retrieve the build information for the given build ID.
  275. Parameters:
  276. build_id (str): The ID of the build to retrieve.
  277. Returns:
  278. BuildInfo: The build information for the given build ID.
  279. """
  280. key = self.__key_from_build_id(build_id=build_id)
  281. self.logger.debug(
  282. f"Getting build info for build id {build_id}, Redis Key: {key}"
  283. )
  284. value = self.__redis_client.get(key)
  285. self.logger.debug(f"Got value {value} at key {key}")
  286. return dill.loads(value) if value else None
  287. def __update_build_info(self,
  288. build_id: str,
  289. build_info: BuildInfo) -> None:
  290. """
  291. Update the build information for an existing build in datastore.
  292. Parameters:
  293. build_id (str): The ID of the build to update.
  294. build_info (BuildInfo): The new build information to replace
  295. the existing one.
  296. """
  297. key = self.__key_from_build_id(build_id=build_id)
  298. self.logger.debug(
  299. "Updating build info, "
  300. f"Redis key: {key}, "
  301. f"Build Info: {build_info}, "
  302. f"TTL: Keeping Same"
  303. )
  304. self.__redis_client.set(
  305. name=key,
  306. value=dill.dumps(build_info),
  307. keepttl=True
  308. )
  309. def update_build_time_started(self,
  310. build_id: str,
  311. time_started: float) -> None:
  312. """
  313. Update the build's time_started timestamp.
  314. Parameters:
  315. build_id (str): The ID of the build to update.
  316. time_started (float): The timestamp when the build started running.
  317. """
  318. build_info = self.get_build_info(build_id=build_id)
  319. if build_info is None:
  320. raise ValueError(f"Build with id {build_id} not found.")
  321. build_info.time_started = time_started
  322. self.__update_build_info(
  323. build_id=build_id,
  324. build_info=build_info
  325. )
  326. def update_build_progress_percent(self,
  327. build_id: str,
  328. percent: int) -> None:
  329. """
  330. Update the build's completion percentage.
  331. Parameters:
  332. build_id (str): The ID of the build to update.
  333. percent (int): The new completion percentage (0-100).
  334. """
  335. build_info = self.get_build_info(build_id=build_id)
  336. if build_info is None:
  337. raise ValueError(f"Build with id {build_id} not found.")
  338. build_info.progress.percent = percent
  339. self.__update_build_info(
  340. build_id=build_id,
  341. build_info=build_info
  342. )
  343. def update_build_progress_state(self,
  344. build_id: str,
  345. new_state: BuildState) -> None:
  346. """
  347. Update the build's state (e.g., PENDING, RUNNING, SUCCESS, FAILURE).
  348. Parameters:
  349. build_id (str): The ID of the build to update.
  350. new_state (BuildState): The new state to set for the build.
  351. """
  352. build_info = self.get_build_info(build_id=build_id)
  353. if build_info is None:
  354. raise ValueError(f"Build with id {build_id} not found.")
  355. build_info.progress.state = new_state
  356. self.__update_build_info(
  357. build_id=build_id,
  358. build_info=build_info
  359. )
  360. def get_all_build_ids(self) -> list:
  361. """
  362. Retrieve the IDs of all builds currently stored in the datastore.
  363. Returns:
  364. list: A list of all build IDs.
  365. """
  366. keys_encoded = self.__redis_client.keys(
  367. f"{self.__build_entry_prefix}*"
  368. )
  369. keys = [key.decode() for key in keys_encoded]
  370. self.logger.debug(
  371. f"Keys with prefix {self.__build_entry_prefix}"
  372. f": {keys}"
  373. )
  374. return [
  375. self.__build_id_from_key(key)
  376. for key in keys
  377. ]
  378. def get_build_artifacts_dir_path(self, build_id: str) -> str:
  379. """
  380. Return the directory at which the build artifacts are stored.
  381. Parameters:
  382. build_id (str): The ID of the build.
  383. Returns:
  384. str: The build artifacts path.
  385. """
  386. return os.path.join(
  387. self.get_outdir(),
  388. build_id,
  389. )
  390. def get_build_log_path(self, build_id: str) -> str:
  391. """
  392. Return the path at which the log for a build is written.
  393. Parameters:
  394. build_id (str): The ID of the build.
  395. Returns:
  396. str: The path at which the build log is written.
  397. """
  398. return os.path.join(
  399. self.get_build_artifacts_dir_path(build_id),
  400. 'build.log'
  401. )
  402. def get_build_archive_path(self, build_id: str) -> str:
  403. """
  404. Return the path to the build archive.
  405. Parameters:
  406. build_id (str): The ID of the build.
  407. Returns:
  408. str: The path to the build archive.
  409. """
  410. return os.path.join(
  411. self.get_build_artifacts_dir_path(build_id),
  412. f"{build_id}.tar.gz"
  413. )
  414. @staticmethod
  415. def get_singleton() -> "BuildManager":
  416. """
  417. Return the singleton instance of the BuildManager class.
  418. Returns:
  419. BuildManager: The singleton instance of the BuildManager.
  420. """
  421. return BuildManager.__singleton