gitlab.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. """GitLab backend — implements GitProviderBackend using the GitLab REST API v4."""
  2. import base64
  3. import json
  4. import logging
  5. import re
  6. import urllib.parse
  7. from datetime import datetime, timezone
  8. import httpx
  9. from backend.app.services.git_providers.base import GitProviderBackend
  10. logger = logging.getLogger(__name__)
  11. class GitLabBackend(GitProviderBackend):
  12. """Backend for gitlab.com and self-hosted GitLab instances."""
  13. def get_api_base(self, repo_url: str) -> str:
  14. match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
  15. if not match:
  16. raise ValueError(f"Cannot derive API base from URL: {repo_url}")
  17. return f"{match.group(1)}/api/v4"
  18. def get_headers(self, token: str) -> dict:
  19. return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
  20. def parse_repo_url(self, url: str) -> tuple[str, str]:
  21. """Return (namespace, repo) from HTTPS or SSH URL.
  22. namespace may include subgroups, e.g. 'group/subgroup' for
  23. gitlab.com/group/subgroup/project. Callers join them with '/' and
  24. URL-encode the result for /api/v4/projects/{encoded_path}.
  25. """
  26. if not url or len(url) > 500:
  27. raise ValueError("Invalid Git URL: URL too long or empty")
  28. match = re.match(r"https?://[\w.\-]+(:\d+)?/(.+?)(?:\.git)?/?$", url)
  29. if match:
  30. full_path = match.group(2)
  31. if "/" not in full_path:
  32. raise ValueError(f"Cannot parse repository URL: {url}")
  33. namespace, _, repo = full_path.rpartition("/")
  34. return namespace, repo
  35. match = re.match(r"git@[\w.\-]+:(.+?)(?:\.git)?$", url)
  36. if match:
  37. full_path = match.group(1)
  38. if "/" not in full_path:
  39. raise ValueError(f"Cannot parse repository URL: {url}")
  40. namespace, _, repo = full_path.rpartition("/")
  41. return namespace, repo
  42. raise ValueError(f"Cannot parse repository URL: {url}")
  43. async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
  44. try:
  45. owner, repo = self.parse_repo_url(repo_url)
  46. api_base = self.get_api_base(repo_url)
  47. headers = self.get_headers(token)
  48. encoded_path = urllib.parse.quote(f"{owner}/{repo}", safe="")
  49. response = await client.get(f"{api_base}/projects/{encoded_path}", headers=headers)
  50. if response.status_code == 401:
  51. return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
  52. if response.status_code == 404:
  53. return {
  54. "success": False,
  55. "message": "Repository not found. Check URL and token permissions.",
  56. "repo_name": None,
  57. "permissions": None,
  58. }
  59. if response.status_code != 200:
  60. return {
  61. "success": False,
  62. "message": f"API error: {response.status_code}",
  63. "repo_name": None,
  64. "permissions": None,
  65. }
  66. data = response.json()
  67. perms = data.get("permissions") or {}
  68. project_level = (perms.get("project_access") or {}).get("access_level", 0)
  69. group_level = (perms.get("group_access") or {}).get("access_level", 0)
  70. effective = max(project_level, group_level)
  71. # GitLab uses visibility="private" / "internal" / "public". Both
  72. # "internal" (signed-in users) and "public" are non-private for
  73. # the purposes of this safety check.
  74. visibility = (data.get("visibility") or "").lower()
  75. is_private = visibility == "private"
  76. if effective < 30: # Developer = 30, Maintainer = 40, Owner = 50
  77. return {
  78. "success": False,
  79. "message": "Token requires Developer access or higher to push",
  80. "repo_name": data.get("name_with_namespace"),
  81. "permissions": perms,
  82. "is_private": is_private,
  83. }
  84. return {
  85. "success": True,
  86. "message": "Connection successful",
  87. "repo_name": data.get("name_with_namespace"),
  88. "permissions": perms,
  89. "is_private": is_private,
  90. }
  91. except Exception as e:
  92. logger.error("GitLab connection test failed: %s", e)
  93. return {
  94. "success": False,
  95. "message": f"Connection failed: {type(e).__name__}",
  96. "repo_name": None,
  97. "permissions": None,
  98. "is_private": None,
  99. }
  100. def _encoded_project(self, repo_url: str) -> str:
  101. """Return the URL-encoded ``namespace/project`` path for /api/v4/projects/."""
  102. owner, repo = self.parse_repo_url(repo_url)
  103. return urllib.parse.quote(f"{owner}/{repo}", safe="")
  104. async def list_commits(
  105. self,
  106. repo_url: str,
  107. token: str,
  108. branch: str,
  109. client: httpx.AsyncClient,
  110. limit: int = 20,
  111. ) -> dict:
  112. """List recent commits on ``branch`` via /repository/commits."""
  113. try:
  114. api_base = self.get_api_base(repo_url)
  115. headers = self.get_headers(token)
  116. encoded_path = self._encoded_project(repo_url)
  117. response = await client.get(
  118. f"{api_base}/projects/{encoded_path}/repository/commits",
  119. headers=headers,
  120. params={"ref_name": branch, "per_page": limit},
  121. )
  122. if response.status_code == 404:
  123. return {
  124. "success": False,
  125. "message": (
  126. f"Branch '{branch}' not found, or the repository has no commits yet. "
  127. "Run a backup before restoring."
  128. ),
  129. "commits": [],
  130. }
  131. if response.status_code != 200:
  132. msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
  133. logger.warning("list_commits %s: %s", repo_url, msg)
  134. return {"success": False, "message": msg, "commits": []}
  135. try:
  136. data = response.json()
  137. except ValueError:
  138. return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
  139. if not isinstance(data, list):
  140. return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
  141. commits = []
  142. for entry in data[:limit]:
  143. if not isinstance(entry, dict):
  144. continue
  145. sha = entry.get("id")
  146. if not isinstance(sha, str) or not sha:
  147. continue
  148. # GitLab flattens author/date onto the commit itself rather than
  149. # nesting them under "commit" the way GitHub does.
  150. commits.append(
  151. {
  152. "sha": sha,
  153. "message": entry.get("message") or "",
  154. "author": entry.get("author_name") or "",
  155. "date": entry.get("committed_date") or entry.get("created_at") or "",
  156. }
  157. )
  158. return {"success": True, "message": "OK", "commits": commits}
  159. except Exception as e:
  160. logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
  161. return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
  162. async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
  163. """Read one commit's metadata directly, for refs outside the list window."""
  164. try:
  165. api_base = self.get_api_base(repo_url)
  166. headers = self.get_headers(token)
  167. encoded_path = self._encoded_project(repo_url)
  168. response = await client.get(
  169. f"{api_base}/projects/{encoded_path}/repository/commits/{urllib.parse.quote(ref, safe='')}",
  170. headers=headers,
  171. )
  172. if response.status_code == 404:
  173. return {"success": False, "message": f"Commit '{ref}' not found in the repository", "commit": None}
  174. if response.status_code != 200:
  175. msg = f"Failed to read commit (HTTP {response.status_code}): {self._truncated_response_text(response)}"
  176. logger.warning("get_commit %s ref=%s: %s", repo_url, ref, msg)
  177. return {"success": False, "message": msg, "commit": None}
  178. try:
  179. data = response.json()
  180. except ValueError:
  181. return {"success": False, "message": "Non-JSON response reading commit", "commit": None}
  182. sha = data.get("id") if isinstance(data, dict) else None
  183. if not isinstance(sha, str) or not sha:
  184. return {"success": False, "message": "Commit response carried no SHA", "commit": None}
  185. # GitLab flattens author/date onto the commit, as in list_commits.
  186. return {
  187. "success": True,
  188. "message": "OK",
  189. "commit": {
  190. "sha": sha,
  191. "message": data.get("message") or "",
  192. "author": data.get("author_name") or "",
  193. "date": data.get("committed_date") or data.get("created_at") or "",
  194. },
  195. }
  196. except Exception as e:
  197. logger.exception("get_commit failed for %s ref=%s", repo_url, ref)
  198. return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commit": None}
  199. async def list_tree(
  200. self,
  201. repo_url: str,
  202. token: str,
  203. ref: str,
  204. client: httpx.AsyncClient,
  205. ) -> dict:
  206. """List blob paths at ``ref`` via /repository/tree, following pagination."""
  207. try:
  208. api_base = self.get_api_base(repo_url)
  209. headers = self.get_headers(token)
  210. encoded_path = self._encoded_project(repo_url)
  211. paths: list[str] = []
  212. page = 1
  213. complete = False
  214. # GitLab's tree endpoint paginates instead of exposing a "truncated"
  215. # flag, so walk pages until one comes back short. The page cap stops
  216. # a malformed X-Next-Page loop from spinning forever — and reaching
  217. # it is a failure, not a result: see the check after the loop.
  218. while page <= 50:
  219. response = await client.get(
  220. f"{api_base}/projects/{encoded_path}/repository/tree",
  221. headers=headers,
  222. params={"ref": ref, "recursive": "true", "per_page": 100, "page": page},
  223. )
  224. if response.status_code == 404:
  225. return {
  226. "success": False,
  227. "message": f"Commit or tree '{ref}' not found in the repository",
  228. "paths": [],
  229. "blob_shas": {},
  230. }
  231. if response.status_code != 200:
  232. msg = (
  233. f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
  234. )
  235. logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
  236. return {"success": False, "message": msg, "paths": [], "blob_shas": {}}
  237. try:
  238. data = response.json()
  239. except ValueError:
  240. return {"success": False, "message": "Non-JSON response listing tree", "paths": [], "blob_shas": {}}
  241. if not isinstance(data, list):
  242. return {"success": False, "message": "Unexpected shape listing tree", "paths": [], "blob_shas": {}}
  243. for item in data:
  244. if isinstance(item, dict) and item.get("type") == "blob":
  245. path = item.get("path")
  246. if isinstance(path, str) and path:
  247. paths.append(path)
  248. if len(data) < 100:
  249. complete = True
  250. break
  251. page += 1
  252. if not complete:
  253. # Falling out of the loop means the last page was full and there
  254. # are more. Returning success here would hand the restore a
  255. # silently partial path list, and it would then report the
  256. # categories it could not see as "not present in this commit" —
  257. # the same failure GitHub's truncated=true check refuses to allow.
  258. msg = (
  259. "Repository tree exceeds the listing limit (more than 5000 files), so the backup "
  260. "contents cannot be enumerated reliably. Rotate the backup repository."
  261. )
  262. logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
  263. return {"success": False, "message": msg, "paths": [], "blob_shas": {}}
  264. # GitLab reads files by path, so there is no blob-SHA map to share.
  265. return {"success": True, "message": "OK", "paths": sorted(paths), "blob_shas": {}}
  266. except Exception as e:
  267. logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
  268. return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": [], "blob_shas": {}}
  269. async def fetch_files(
  270. self,
  271. repo_url: str,
  272. token: str,
  273. ref: str,
  274. paths: list[str],
  275. client: httpx.AsyncClient,
  276. blob_shas: dict[str, str] | None = None,
  277. ) -> dict:
  278. """Read ``paths`` at ``ref`` via /repository/files/{path}.
  279. ``blob_shas`` is accepted for interface parity and ignored: this backend
  280. addresses files by path, so it never needed the tree listing that makes
  281. the map worth passing.
  282. """
  283. try:
  284. api_base = self.get_api_base(repo_url)
  285. headers = self.get_headers(token)
  286. encoded_path = self._encoded_project(repo_url)
  287. files: dict[str, str] = {}
  288. for path in paths:
  289. encoded_file = urllib.parse.quote(path, safe="")
  290. response = await client.get(
  291. f"{api_base}/projects/{encoded_path}/repository/files/{encoded_file}",
  292. headers=headers,
  293. params={"ref": ref},
  294. )
  295. # A path absent from this commit is expected — which categories a
  296. # backup contains varies by config — so skip rather than fail.
  297. if response.status_code == 404:
  298. continue
  299. if response.status_code != 200:
  300. msg = (
  301. f"Failed to read {path} (HTTP {response.status_code}): "
  302. f"{self._truncated_response_text(response)}"
  303. )
  304. logger.warning("fetch_files %s: %s", repo_url, msg)
  305. return {"success": False, "message": msg, "files": {}}
  306. try:
  307. data = response.json()
  308. except ValueError:
  309. return {"success": False, "message": f"Non-JSON response reading {path}", "files": {}}
  310. if not isinstance(data, dict):
  311. return {"success": False, "message": f"Unexpected shape reading {path}", "files": {}}
  312. content = data.get("content")
  313. if not isinstance(content, str):
  314. return {"success": False, "message": f"Missing content reading {path}", "files": {}}
  315. encoding = data.get("encoding", "base64")
  316. try:
  317. if encoding == "base64":
  318. files[path] = base64.b64decode(content).decode("utf-8")
  319. elif encoding in ("text", "utf-8", "plain"):
  320. files[path] = content
  321. else:
  322. return {
  323. "success": False,
  324. "message": f"Unsupported encoding {encoding!r} reading {path}",
  325. "files": {},
  326. }
  327. except (ValueError, UnicodeDecodeError) as e:
  328. return {"success": False, "message": f"Could not decode {path}: {type(e).__name__}", "files": {}}
  329. return {"success": True, "message": "OK", "files": files}
  330. except Exception as e:
  331. logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
  332. return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
  333. async def push_files(
  334. self,
  335. repo_url: str,
  336. token: str,
  337. branch: str,
  338. files: dict,
  339. client: httpx.AsyncClient,
  340. ) -> dict:
  341. try:
  342. owner, repo = self.parse_repo_url(repo_url)
  343. api_base = self.get_api_base(repo_url)
  344. headers = self.get_headers(token)
  345. encoded_path = urllib.parse.quote(f"{owner}/{repo}", safe="")
  346. encoded_branch = urllib.parse.quote(branch, safe="")
  347. branch_response = await client.get(
  348. f"{api_base}/projects/{encoded_path}/repository/branches/{encoded_branch}",
  349. headers=headers,
  350. )
  351. if branch_response.status_code == 404:
  352. proj_response = await client.get(f"{api_base}/projects/{encoded_path}", headers=headers)
  353. if proj_response.status_code != 200:
  354. return {"status": "failed", "message": "Failed to get project info"}
  355. default_branch = proj_response.json().get("default_branch", "main")
  356. default_encoded = urllib.parse.quote(default_branch, safe="")
  357. default_response = await client.get(
  358. f"{api_base}/projects/{encoded_path}/repository/branches/{default_encoded}",
  359. headers=headers,
  360. )
  361. if default_response.status_code != 200:
  362. return await self._create_initial_commit(client, headers, api_base, encoded_path, branch, files)
  363. create_response = await client.post(
  364. f"{api_base}/projects/{encoded_path}/repository/branches",
  365. headers=headers,
  366. json={"branch": branch, "ref": default_branch},
  367. )
  368. if create_response.status_code not in (200, 201):
  369. return {"status": "failed", "message": f"Failed to create branch: {create_response.status_code}"}
  370. elif branch_response.status_code != 200:
  371. return {"status": "failed", "message": f"Failed to check branch: {branch_response.status_code}"}
  372. existing_blobs: dict[str, str] = {}
  373. page = 1
  374. while True:
  375. tree_response = await client.get(
  376. f"{api_base}/projects/{encoded_path}/repository/tree",
  377. headers=headers,
  378. params={"recursive": "true", "ref": branch, "per_page": 100, "page": page},
  379. )
  380. if tree_response.status_code != 200:
  381. break
  382. items = tree_response.json()
  383. if not items:
  384. break
  385. for item in items:
  386. if item.get("type") == "blob":
  387. existing_blobs[item["path"]] = item["id"]
  388. page += 1
  389. actions = []
  390. for path, content in files.items():
  391. content_str = json.dumps(content, indent=2, default=str)
  392. content_bytes = content_str.encode("utf-8")
  393. content_sha = self._blob_sha(content_bytes)
  394. if path in existing_blobs and existing_blobs[path] == content_sha:
  395. continue
  396. actions.append(
  397. {
  398. "action": "update" if path in existing_blobs else "create",
  399. "file_path": path,
  400. "content": base64.b64encode(content_bytes).decode(),
  401. "encoding": "base64",
  402. }
  403. )
  404. if not actions:
  405. return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
  406. commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
  407. commit_response = await client.post(
  408. f"{api_base}/projects/{encoded_path}/repository/commits",
  409. headers=headers,
  410. json={"branch": branch, "commit_message": commit_message, "actions": actions},
  411. )
  412. if commit_response.status_code not in (200, 201):
  413. return {
  414. "status": "failed",
  415. "message": f"Failed to create commit: {self._truncated_response_text(commit_response)}",
  416. }
  417. return {
  418. "status": "success",
  419. "message": f"Backup successful - {len(actions)} files updated",
  420. "commit_sha": commit_response.json().get("id"),
  421. "files_changed": len(actions),
  422. }
  423. except Exception as e:
  424. logger.error("Push to GitLab failed: %s", e)
  425. return {"status": "failed", "message": str(e), "error": str(e)}
  426. async def _create_initial_commit(
  427. self,
  428. client: httpx.AsyncClient,
  429. headers: dict,
  430. api_base: str,
  431. encoded_path: str,
  432. branch: str,
  433. files: dict,
  434. ) -> dict:
  435. """Create the first commit in an empty repository."""
  436. try:
  437. actions = []
  438. for path, content in files.items():
  439. content_str = json.dumps(content, indent=2, default=str)
  440. actions.append(
  441. {
  442. "action": "create",
  443. "file_path": path,
  444. "content": base64.b64encode(content_str.encode()).decode(),
  445. "encoding": "base64",
  446. }
  447. )
  448. commit_message = f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
  449. commit_response = await client.post(
  450. f"{api_base}/projects/{encoded_path}/repository/commits",
  451. headers=headers,
  452. json={"branch": branch, "commit_message": commit_message, "actions": actions, "start_branch": branch},
  453. )
  454. if commit_response.status_code not in (200, 201):
  455. return {
  456. "status": "failed",
  457. "message": f"Failed to create initial commit: {self._truncated_response_text(commit_response)}",
  458. }
  459. return {
  460. "status": "success",
  461. "message": f"Initial backup created - {len(files)} files",
  462. "commit_sha": commit_response.json().get("id"),
  463. "files_changed": len(files),
  464. }
  465. except Exception as e:
  466. return {"status": "failed", "message": str(e)}