gitlab_api.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. # SPDX-FileCopyrightText: 2022-2023 Espressif Systems (Shanghai) CO LTD
  2. # SPDX-License-Identifier: Apache-2.0
  3. import argparse
  4. import logging
  5. import os
  6. import re
  7. import tarfile
  8. import tempfile
  9. import time
  10. import zipfile
  11. from functools import wraps
  12. from typing import Any, Callable, Dict, List, Optional
  13. import gitlab
  14. TR = Callable[..., Any]
  15. logging.basicConfig(level=logging.INFO)
  16. def retry(func: TR) -> TR:
  17. """
  18. This wrapper will only catch several exception types associated with
  19. "network issues" and retry the whole function.
  20. """
  21. @wraps(func)
  22. def wrapper(self: 'Gitlab', *args: Any, **kwargs: Any) -> Any:
  23. retried = 0
  24. while True:
  25. try:
  26. res = func(self, *args, **kwargs)
  27. except (IOError, EOFError, gitlab.exceptions.GitlabError) as e:
  28. if isinstance(e, gitlab.exceptions.GitlabError):
  29. if e.response_code == 500:
  30. # retry on this error
  31. pass
  32. elif e.response_code == 404 and os.environ.get('LOCAL_GITLAB_HTTPS_HOST', None):
  33. # remove the environment variable "LOCAL_GITLAB_HTTPS_HOST" and retry
  34. os.environ.pop('LOCAL_GITLAB_HTTPS_HOST', None)
  35. else:
  36. # other GitlabErrors aren't retried
  37. raise e
  38. retried += 1
  39. if retried > self.DOWNLOAD_ERROR_MAX_RETRIES:
  40. raise e # get out of the loop
  41. else:
  42. logging.warning(
  43. 'Network failure in {}, retrying ({})'.format(getattr(func, '__name__', '(unknown callable)'),
  44. retried))
  45. time.sleep(2 ** retried) # wait a bit more after each retry
  46. continue
  47. else:
  48. break
  49. return res
  50. return wrapper
  51. class Gitlab(object):
  52. JOB_NAME_PATTERN = re.compile(r'(\w+)(\s+(\d+)/(\d+))?')
  53. DOWNLOAD_ERROR_MAX_RETRIES = 3
  54. def __init__(self, project_id: Optional[int] = None):
  55. config_data_from_env = os.getenv('PYTHON_GITLAB_CONFIG')
  56. if config_data_from_env:
  57. # prefer to load config from env variable
  58. with tempfile.NamedTemporaryFile('w', delete=False) as temp_file:
  59. temp_file.write(config_data_from_env)
  60. config_files = [temp_file.name] # type: Optional[List[str]]
  61. else:
  62. # otherwise try to use config file at local filesystem
  63. config_files = None
  64. self._init_gitlab_inst(project_id, config_files)
  65. @retry
  66. def _init_gitlab_inst(self, project_id: Optional[int], config_files: Optional[List[str]]) -> None:
  67. gitlab_id = os.getenv('LOCAL_GITLAB_HTTPS_HOST') # if None, will use the default gitlab server
  68. self.gitlab_inst = gitlab.Gitlab.from_config(gitlab_id=gitlab_id, config_files=config_files)
  69. self.gitlab_inst.auth()
  70. if project_id:
  71. self.project = self.gitlab_inst.projects.get(project_id, lazy=True)
  72. else:
  73. self.project = None
  74. @retry
  75. def get_project_id(self, name: str, namespace: Optional[str] = None) -> int:
  76. """
  77. search project ID by name
  78. :param name: project name
  79. :param namespace: namespace to match when we have multiple project with same name
  80. :return: project ID
  81. """
  82. projects = self.gitlab_inst.projects.list(search=name)
  83. res = []
  84. for project in projects:
  85. if namespace is None:
  86. if len(projects) == 1:
  87. res.append(project.id)
  88. break
  89. if project.namespace['path'] == namespace:
  90. if project.name == name:
  91. res.insert(0, project.id)
  92. else:
  93. res.append(project.id)
  94. if not res:
  95. raise ValueError("Can't find project")
  96. return int(res[0])
  97. @retry
  98. def download_artifacts(self, job_id: int, destination: str) -> None:
  99. """
  100. download full job artifacts and extract to destination.
  101. :param job_id: Gitlab CI job ID
  102. :param destination: extract artifacts to path.
  103. """
  104. job = self.project.jobs.get(job_id)
  105. with tempfile.NamedTemporaryFile(delete=False) as temp_file:
  106. job.artifacts(streamed=True, action=temp_file.write)
  107. with zipfile.ZipFile(temp_file.name, 'r') as archive_file:
  108. archive_file.extractall(destination)
  109. @retry
  110. def download_artifact(self, job_id: int, artifact_path: str, destination: Optional[str] = None) -> List[bytes]:
  111. """
  112. download specific path of job artifacts and extract to destination.
  113. :param job_id: Gitlab CI job ID
  114. :param artifact_path: list of path in artifacts (relative path to artifact root path)
  115. :param destination: destination of artifact. Do not save to file if destination is None
  116. :return: A list of artifact file raw data.
  117. """
  118. job = self.project.jobs.get(job_id)
  119. raw_data_list = []
  120. for a_path in artifact_path:
  121. try:
  122. data = job.artifact(a_path) # type: bytes
  123. except gitlab.GitlabGetError as e:
  124. logging.error("Failed to download '{}' from job {}".format(a_path, job_id))
  125. raise e
  126. raw_data_list.append(data)
  127. if destination:
  128. file_path = os.path.join(destination, a_path)
  129. try:
  130. os.makedirs(os.path.dirname(file_path))
  131. except OSError:
  132. # already exists
  133. pass
  134. with open(file_path, 'wb') as f:
  135. f.write(data)
  136. return raw_data_list
  137. @retry
  138. def find_job_id(self, job_name: str, pipeline_id: Optional[str] = None, job_status: str = 'success') -> List[Dict]:
  139. """
  140. Get Job ID from job name of specific pipeline
  141. :param job_name: job name
  142. :param pipeline_id: If None, will get pipeline id from CI pre-defined variable.
  143. :param job_status: status of job. One pipeline could have multiple jobs with same name after retry.
  144. job_status is used to filter these jobs.
  145. :return: a list of job IDs (parallel job will generate multiple jobs)
  146. """
  147. job_id_list = []
  148. if pipeline_id is None:
  149. pipeline_id = os.getenv('CI_PIPELINE_ID')
  150. pipeline = self.project.pipelines.get(pipeline_id)
  151. jobs = pipeline.jobs.list(all=True)
  152. for job in jobs:
  153. match = self.JOB_NAME_PATTERN.match(job.name)
  154. if match:
  155. if match.group(1) == job_name and job.status == job_status:
  156. job_id_list.append({'id': job.id, 'parallel_num': match.group(3)})
  157. return job_id_list
  158. @retry
  159. def download_archive(self, ref: str, destination: str, project_id: Optional[int] = None,
  160. cache_dir: Optional[str] = None) -> str:
  161. """
  162. Download archive of certain commit of a repository and extract to destination path
  163. :param ref: commit or branch name
  164. :param destination: destination path of extracted archive file
  165. :param project_id: download project of current instance if project_id is None
  166. :return: root path name of archive file
  167. """
  168. if project_id is None:
  169. project = self.project
  170. else:
  171. project = self.gitlab_inst.projects.get(project_id)
  172. if cache_dir:
  173. local_archive_file = os.path.join(cache_dir, f'{ref}.tar.gz')
  174. os.makedirs(os.path.dirname(local_archive_file), exist_ok=True)
  175. if os.path.isfile(local_archive_file):
  176. logging.info('Use cached archive file. Skipping download...')
  177. else:
  178. with open(local_archive_file, 'wb') as fw:
  179. try:
  180. project.repository_archive(sha=ref, streamed=True, action=fw.write)
  181. except gitlab.GitlabGetError as e:
  182. logging.error('Failed to archive from project {}'.format(project_id))
  183. raise e
  184. logging.info('Downloaded archive size: {:.03f}MB'.format(
  185. float(os.path.getsize(local_archive_file)) / (1024 * 1024)))
  186. return self.decompress_archive(local_archive_file, destination)
  187. # no cache
  188. with tempfile.NamedTemporaryFile(delete=False) as temp_file:
  189. try:
  190. project.repository_archive(sha=ref, streamed=True, action=temp_file.write)
  191. except gitlab.GitlabGetError as e:
  192. logging.error('Failed to archive from project {}'.format(project_id))
  193. raise e
  194. logging.info('Downloaded archive size: {:.03f}MB'.format(float(os.path.getsize(temp_file.name)) / (1024 * 1024)))
  195. return self.decompress_archive(temp_file.name, destination)
  196. @staticmethod
  197. def decompress_archive(path: str, destination: str) -> str:
  198. with tarfile.open(path, 'r') as archive_file:
  199. root_name = archive_file.getnames()[0]
  200. archive_file.extractall(destination)
  201. return os.path.join(os.path.realpath(destination), root_name)
  202. def get_job_tags(self, job_id: int) -> str:
  203. """
  204. Get tags of a job
  205. :param job_id: job id
  206. :return: comma-separated tags of the job
  207. """
  208. job = self.project.jobs.get(job_id)
  209. return ','.join(job.tag_list)
  210. def main() -> None:
  211. parser = argparse.ArgumentParser()
  212. parser.add_argument('action')
  213. parser.add_argument('project_id', type=int)
  214. parser.add_argument('--pipeline_id', '-i', type=int, default=None)
  215. parser.add_argument('--ref', '-r', default='master')
  216. parser.add_argument('--job_id', '-j', type=int, default=None)
  217. parser.add_argument('--job_name', '-n', default=None)
  218. parser.add_argument('--project_name', '-m', default=None)
  219. parser.add_argument('--destination', '-d', default=None)
  220. parser.add_argument('--artifact_path', '-a', nargs='*', default=None)
  221. args = parser.parse_args()
  222. gitlab_inst = Gitlab(args.project_id)
  223. if args.action == 'download_artifacts':
  224. gitlab_inst.download_artifacts(args.job_id, args.destination)
  225. if args.action == 'download_artifact':
  226. gitlab_inst.download_artifact(args.job_id, args.artifact_path, args.destination)
  227. elif args.action == 'find_job_id':
  228. job_ids = gitlab_inst.find_job_id(args.job_name, args.pipeline_id)
  229. print(';'.join([','.join([str(j['id']), j['parallel_num']]) for j in job_ids]))
  230. elif args.action == 'download_archive':
  231. gitlab_inst.download_archive(args.ref, args.destination)
  232. elif args.action == 'get_project_id':
  233. ret = gitlab_inst.get_project_id(args.project_name)
  234. print('project id: {}'.format(ret))
  235. elif args.action == 'get_job_tags':
  236. ret = gitlab_inst.get_job_tags(args.job_id)
  237. print(ret)
  238. if __name__ == '__main__':
  239. main()