registry_client.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import base64
  2. import requests
  3. from requests.auth import HTTPBasicAuth
  4. from urllib.parse import urljoin
  5. class RegistryClient:
  6. def __init__(self, url, username='', password='', verify_ssl=True):
  7. self.base_url = url.rstrip('/')
  8. self.username = username
  9. self.password = password
  10. self.verify_ssl = verify_ssl
  11. self.session = requests.Session()
  12. self.session.verify = verify_ssl
  13. self.token = None
  14. self._auth_method = None
  15. if username and password:
  16. self.session.auth = HTTPBasicAuth(username, password)
  17. def _url(self, path):
  18. return f"{self.base_url}{path}"
  19. def _get_headers(self):
  20. headers = {'Accept': 'application/json'}
  21. if self.token:
  22. headers['Authorization'] = f'Bearer {self.token}'
  23. return headers
  24. def _request(self, method, path, **kwargs):
  25. url = self._url(path)
  26. kwargs.setdefault('headers', self._get_headers())
  27. kwargs.setdefault('verify', self.verify_ssl)
  28. response = self.session.request(method, url, **kwargs)
  29. if response.status_code == 401:
  30. self._handle_401(response)
  31. kwargs['headers'] = self._get_headers()
  32. response = self.session.request(method, url, **kwargs)
  33. response.raise_for_status()
  34. return response
  35. def _handle_401(self, response):
  36. www_auth = response.headers.get('WWW-Authenticate', '')
  37. if 'Bearer' in www_auth:
  38. self._extract_token(www_auth)
  39. elif 'Basic' in www_auth and self.username and self.password:
  40. token_resp = self.session.get(
  41. self._url('/v2/'),
  42. auth=HTTPBasicAuth(self.username, self.password),
  43. verify=self.verify_ssl
  44. )
  45. token_resp.raise_for_status()
  46. def _extract_token(self, www_auth):
  47. params = {}
  48. for part in www_auth.split(','):
  49. if '=' in part:
  50. key, value = part.split('=', 1)
  51. key = key.strip()
  52. value = value.strip().strip('"')
  53. if key == 'realm':
  54. params['realm'] = value
  55. else:
  56. params[key] = value
  57. if 'realm' in params:
  58. token_url = params.pop('realm')
  59. token_resp = self.session.get(
  60. token_url,
  61. params=params,
  62. verify=self.verify_ssl
  63. )
  64. token_resp.raise_for_status()
  65. self.token = token_resp.json().get('token')
  66. def check_connection(self):
  67. try:
  68. response = self._request('GET', '/v2/')
  69. return True, 'Conexión exitosa'
  70. except requests.exceptions.ConnectionError as e:
  71. return False, f'Error de conexión: {str(e)}'
  72. except requests.exceptions.HTTPError as e:
  73. return False, f'Error HTTP: {e.response.status_code}'
  74. except Exception as e:
  75. return False, f'Error: {str(e)}'
  76. def list_catalog(self):
  77. response = self._request('GET', '/v2/_catalog')
  78. return response.json().get('repositories', [])
  79. def list_tags(self, repository):
  80. response = self._request('GET', f'/v2/{repository}/tags/list')
  81. data = response.json()
  82. tags = data.get('tags', [])
  83. return sorted(tags) if tags else []
  84. def get_manifest(self, repository, reference):
  85. headers = {
  86. 'Accept': ', '.join([
  87. 'application/vnd.docker.distribution.manifest.v2+json',
  88. 'application/vnd.docker.distribution.manifest.list.v2+json',
  89. 'application/vnd.oci.image.manifest.v1+json',
  90. 'application/vnd.oci.image.index.v1+json',
  91. ])
  92. }
  93. response = self._request('GET', f'/v2/{repository}/manifests/{reference}', headers=headers)
  94. return response.json(), response.headers
  95. def get_blob_summaries(self, repository, manifest):
  96. layers = manifest.get('layers', [])
  97. config = manifest.get('config', {})
  98. result = {
  99. 'layers': [],
  100. 'config_size': config.get('size', 0),
  101. 'config_digest': config.get('digest', ''),
  102. 'total_size': 0,
  103. }
  104. for layer in layers:
  105. digest = layer.get('digest', '')
  106. size = layer.get('size', 0)
  107. result['layers'].append({
  108. 'digest': digest,
  109. 'size': size,
  110. 'size_human': self._human_size(size),
  111. })
  112. result['total_size'] += size
  113. result['total_size_human'] = self._human_size(result['total_size'])
  114. return result
  115. def get_image_detail(self, repository, reference):
  116. manifest, headers = self.get_manifest(repository, reference)
  117. media_type = manifest.get('mediaType', '')
  118. manifest_list = 'manifest.list' in media_type or 'index' in media_type
  119. digest = headers.get('Docker-Content-Digest', headers.get('docker-content-digest', ''))
  120. result = {
  121. 'repository': repository,
  122. 'reference': reference,
  123. 'digest': digest,
  124. 'media_type': media_type,
  125. 'manifest_list': manifest_list,
  126. 'schema_version': manifest.get('schemaVersion'),
  127. 'media_type_display': self._media_type_display(media_type),
  128. }
  129. if manifest_list:
  130. manifests = manifest.get('manifests', [])
  131. result['sub_manifests'] = []
  132. for sub in manifests:
  133. sub_platform = sub.get('platform', {})
  134. result['sub_manifests'].append({
  135. 'digest': sub.get('digest', ''),
  136. 'size': sub.get('size', 0),
  137. 'size_human': self._human_size(sub.get('size', 0)),
  138. 'architecture': sub_platform.get('architecture', 'unknown'),
  139. 'os': sub_platform.get('os', 'unknown'),
  140. })
  141. else:
  142. config_data = self._get_config(repository, config_digest=manifest.get('config', {}).get('digest', ''))
  143. result['architecture'] = config_data.get('architecture', 'unknown')
  144. result['os'] = config_data.get('os', 'unknown')
  145. result['created'] = config_data.get('created', '')
  146. result['author'] = config_data.get('author', '')
  147. history = config_data.get('history', [])
  148. result['history'] = []
  149. for h in history:
  150. created_by = h.get('created_by', '')
  151. if created_by and created_by != '/bin/sh -c #(nop) ADD file:' in created_by:
  152. result['history'].append({
  153. 'created_by': created_by[:200] + ('...' if len(created_by) > 200 else ''),
  154. })
  155. blob_info = self.get_blob_summaries(repository, manifest)
  156. result['layers'] = blob_info['layers']
  157. result['total_size'] = blob_info['total_size']
  158. result['total_size_human'] = blob_info['total_size_human']
  159. return result
  160. def _get_config(self, repository, config_digest):
  161. if not config_digest:
  162. return {}
  163. try:
  164. response = self._request('GET', f'/v2/{repository}/blobs/{config_digest}')
  165. return response.json()
  166. except Exception:
  167. return {}
  168. def delete_manifest(self, repository, reference):
  169. try:
  170. manifest, resp_headers = self.get_manifest(repository, reference)
  171. media_type = manifest.get('mediaType', 'application/vnd.docker.distribution.manifest.v2+json')
  172. digest = resp_headers.get('Docker-Content-Digest', resp_headers.get('docker-content-digest', ''))
  173. if not digest:
  174. return False, 'No se pudo obtener el digest del manifest. Intenta eliminar desde el panel de administración del registry.'
  175. response = self._request(
  176. 'DELETE',
  177. f'/v2/{repository}/manifests/{digest}',
  178. headers={
  179. 'Accept': media_type,
  180. },
  181. )
  182. return True, 'Imagen eliminada correctamente'
  183. except requests.exceptions.HTTPError as e:
  184. status = e.response.status_code
  185. if status == 404:
  186. return False, 'Imagen no encontrada (404). Es posible que el registry requiera eliminar por digest. Prueba obtener el digest desde los detalles de la imagen.'
  187. elif status == 405:
  188. return False, 'El registry no permite eliminación de imágenes (405 Method Not Allowed). Es necesario habilitar el "delete" en la configuración del registry.'
  189. elif status == 401 or status == 403:
  190. return False, f'No tienes permisos para eliminar imágenes ({status}). Verifica las credenciales y que el usuario tenga permisos de escritura.'
  191. else:
  192. body = ''
  193. try:
  194. body = e.response.text[:300]
  195. except Exception:
  196. pass
  197. return False, f'Error al eliminar ({status}): {body}'
  198. except Exception as e:
  199. return False, f'Error: {str(e)}'
  200. def _media_type_display(self, media_type):
  201. types = {
  202. 'application/vnd.docker.distribution.manifest.v2+json': 'Docker Manifest v2',
  203. 'application/vnd.docker.distribution.manifest.list.v2+json': 'Docker Manifest List v2',
  204. 'application/vnd.oci.image.manifest.v1+json': 'OCI Image Manifest v1',
  205. 'application/vnd.oci.image.index.v1+json': 'OCI Image Index v1',
  206. }
  207. return types.get(media_type, media_type or 'Desconocido')
  208. @staticmethod
  209. def _human_size(size_bytes):
  210. for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
  211. if size_bytes < 1024.0:
  212. return f"{size_bytes:.1f} {unit}"
  213. size_bytes /= 1024.0
  214. return f"{size_bytes:.1f} PB"