manager.py 16 KB

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