manager.py 14 KB

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