manager.py 14 KB

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