gitlab_api.py 8.9 KB

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