gitlab_api.py 8.8 KB

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