github_backup.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. """API routes for GitHub profile backup."""
  2. import logging
  3. from fastapi import APIRouter, Depends, HTTPException, Query
  4. from sqlalchemy import delete, desc, select
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  7. from backend.app.core.database import get_db
  8. from backend.app.core.permissions import Permission
  9. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  10. from backend.app.models.user import User
  11. from backend.app.schemas.github_backup import (
  12. GitHubBackupConfigCreate,
  13. GitHubBackupConfigResponse,
  14. GitHubBackupConfigUpdate,
  15. GitHubBackupLogResponse,
  16. GitHubBackupStatus,
  17. GitHubBackupTriggerResponse,
  18. GitHubTestConnectionResponse,
  19. )
  20. from backend.app.services.github_backup import github_backup_service
  21. logger = logging.getLogger(__name__)
  22. router = APIRouter(prefix="/github-backup", tags=["github-backup"])
  23. def _config_to_response(config: GitHubBackupConfig) -> dict:
  24. """Convert config model to response dict."""
  25. return {
  26. "id": config.id,
  27. "repository_url": config.repository_url,
  28. "has_token": bool(config.access_token),
  29. "branch": config.branch,
  30. "schedule_enabled": config.schedule_enabled,
  31. "schedule_type": config.schedule_type,
  32. "backup_kprofiles": config.backup_kprofiles,
  33. "backup_cloud_profiles": config.backup_cloud_profiles,
  34. "backup_settings": config.backup_settings,
  35. "backup_spools": config.backup_spools,
  36. "backup_archives": config.backup_archives,
  37. "enabled": config.enabled,
  38. "last_backup_at": config.last_backup_at,
  39. "last_backup_status": config.last_backup_status,
  40. "last_backup_message": config.last_backup_message,
  41. "last_backup_commit_sha": config.last_backup_commit_sha,
  42. "next_scheduled_run": config.next_scheduled_run,
  43. "created_at": config.created_at,
  44. "updated_at": config.updated_at,
  45. }
  46. @router.get("/config", response_model=GitHubBackupConfigResponse | None)
  47. async def get_config(
  48. db: AsyncSession = Depends(get_db),
  49. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  50. ):
  51. """Get the current GitHub backup configuration."""
  52. result = await db.execute(select(GitHubBackupConfig).limit(1))
  53. config = result.scalar_one_or_none()
  54. if not config:
  55. return None
  56. return _config_to_response(config)
  57. @router.post("/config", response_model=GitHubBackupConfigResponse)
  58. async def save_config(
  59. config_data: GitHubBackupConfigCreate,
  60. db: AsyncSession = Depends(get_db),
  61. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  62. ):
  63. """Create or update GitHub backup configuration.
  64. Only one configuration is supported. If one exists, it will be updated.
  65. """
  66. # Check for existing config
  67. result = await db.execute(select(GitHubBackupConfig).limit(1))
  68. config = result.scalar_one_or_none()
  69. if config:
  70. # Update existing
  71. config.repository_url = config_data.repository_url
  72. config.access_token = config_data.access_token
  73. config.branch = config_data.branch
  74. config.schedule_enabled = config_data.schedule_enabled
  75. config.schedule_type = config_data.schedule_type.value
  76. config.backup_kprofiles = config_data.backup_kprofiles
  77. config.backup_cloud_profiles = config_data.backup_cloud_profiles
  78. config.backup_settings = config_data.backup_settings
  79. config.backup_spools = config_data.backup_spools
  80. config.backup_archives = config_data.backup_archives
  81. config.enabled = config_data.enabled
  82. # Calculate next scheduled run if enabled
  83. if config.schedule_enabled:
  84. config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
  85. else:
  86. config.next_scheduled_run = None
  87. logger.info("Updated GitHub backup config: %s", config.repository_url)
  88. else:
  89. # Create new
  90. config = GitHubBackupConfig(
  91. repository_url=config_data.repository_url,
  92. access_token=config_data.access_token,
  93. branch=config_data.branch,
  94. schedule_enabled=config_data.schedule_enabled,
  95. schedule_type=config_data.schedule_type.value,
  96. backup_kprofiles=config_data.backup_kprofiles,
  97. backup_cloud_profiles=config_data.backup_cloud_profiles,
  98. backup_settings=config_data.backup_settings,
  99. backup_spools=config_data.backup_spools,
  100. backup_archives=config_data.backup_archives,
  101. enabled=config_data.enabled,
  102. )
  103. if config.schedule_enabled:
  104. config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
  105. db.add(config)
  106. logger.info("Created GitHub backup config: %s", config.repository_url)
  107. await db.commit()
  108. await db.refresh(config)
  109. return _config_to_response(config)
  110. @router.patch("/config", response_model=GitHubBackupConfigResponse)
  111. async def update_config(
  112. update_data: GitHubBackupConfigUpdate,
  113. db: AsyncSession = Depends(get_db),
  114. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  115. ):
  116. """Partially update GitHub backup configuration."""
  117. result = await db.execute(select(GitHubBackupConfig).limit(1))
  118. config = result.scalar_one_or_none()
  119. if not config:
  120. raise HTTPException(status_code=404, detail="No configuration found")
  121. update_dict = update_data.model_dump(exclude_unset=True)
  122. for key, value in update_dict.items():
  123. if key == "schedule_type" and value is not None:
  124. setattr(config, key, value.value)
  125. else:
  126. setattr(config, key, value)
  127. # Recalculate next scheduled run if schedule settings changed
  128. if "schedule_enabled" in update_dict or "schedule_type" in update_dict:
  129. if config.schedule_enabled:
  130. config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
  131. else:
  132. config.next_scheduled_run = None
  133. await db.commit()
  134. await db.refresh(config)
  135. logger.info("Updated GitHub backup config: %s", config.repository_url)
  136. return _config_to_response(config)
  137. @router.delete("/config")
  138. async def delete_config(
  139. db: AsyncSession = Depends(get_db),
  140. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  141. ):
  142. """Delete the GitHub backup configuration and all logs."""
  143. result = await db.execute(select(GitHubBackupConfig).limit(1))
  144. config = result.scalar_one_or_none()
  145. if not config:
  146. raise HTTPException(status_code=404, detail="No configuration found")
  147. await db.delete(config)
  148. await db.commit()
  149. logger.info("Deleted GitHub backup config")
  150. return {"message": "Configuration deleted"}
  151. @router.post("/test", response_model=GitHubTestConnectionResponse)
  152. async def test_connection(
  153. repo_url: str = Query(..., description="GitHub repository URL"),
  154. token: str = Query(..., description="Personal Access Token"),
  155. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  156. ):
  157. """Test GitHub connection with provided credentials."""
  158. result = await github_backup_service.test_connection(repo_url, token)
  159. return GitHubTestConnectionResponse(**result)
  160. @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
  161. async def test_stored_connection(
  162. db: AsyncSession = Depends(get_db),
  163. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  164. ):
  165. """Test GitHub connection using stored configuration."""
  166. result = await db.execute(select(GitHubBackupConfig).limit(1))
  167. config = result.scalar_one_or_none()
  168. if not config:
  169. raise HTTPException(status_code=404, detail="No configuration found")
  170. if not config.access_token:
  171. raise HTTPException(status_code=400, detail="No access token configured")
  172. test_result = await github_backup_service.test_connection(config.repository_url, config.access_token)
  173. return GitHubTestConnectionResponse(**test_result)
  174. @router.post("/run", response_model=GitHubBackupTriggerResponse)
  175. async def trigger_backup(
  176. db: AsyncSession = Depends(get_db),
  177. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  178. ):
  179. """Manually trigger a backup."""
  180. result = await db.execute(select(GitHubBackupConfig).limit(1))
  181. config = result.scalar_one_or_none()
  182. if not config:
  183. raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
  184. if not config.enabled:
  185. raise HTTPException(status_code=400, detail="Backup is disabled")
  186. backup_result = await github_backup_service.run_backup(config.id, trigger="manual")
  187. return GitHubBackupTriggerResponse(**backup_result)
  188. @router.get("/status", response_model=GitHubBackupStatus)
  189. async def get_status(
  190. db: AsyncSession = Depends(get_db),
  191. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  192. ):
  193. """Get current backup status."""
  194. result = await db.execute(select(GitHubBackupConfig).limit(1))
  195. config = result.scalar_one_or_none()
  196. if not config:
  197. return GitHubBackupStatus(
  198. configured=False,
  199. enabled=False,
  200. is_running=False,
  201. progress=None,
  202. last_backup_at=None,
  203. last_backup_status=None,
  204. next_scheduled_run=None,
  205. )
  206. return GitHubBackupStatus(
  207. configured=True,
  208. enabled=config.enabled,
  209. is_running=github_backup_service.is_running,
  210. progress=github_backup_service.progress,
  211. last_backup_at=config.last_backup_at,
  212. last_backup_status=config.last_backup_status,
  213. next_scheduled_run=config.next_scheduled_run,
  214. )
  215. @router.get("/logs", response_model=list[GitHubBackupLogResponse])
  216. async def get_logs(
  217. limit: int = Query(default=50, ge=1, le=200),
  218. offset: int = Query(default=0, ge=0),
  219. db: AsyncSession = Depends(get_db),
  220. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  221. ):
  222. """Get backup logs."""
  223. result = await db.execute(select(GitHubBackupConfig).limit(1))
  224. config = result.scalar_one_or_none()
  225. if not config:
  226. return []
  227. logs_result = await db.execute(
  228. select(GitHubBackupLog)
  229. .where(GitHubBackupLog.config_id == config.id)
  230. .order_by(desc(GitHubBackupLog.started_at))
  231. .offset(offset)
  232. .limit(limit)
  233. )
  234. logs = logs_result.scalars().all()
  235. return [
  236. GitHubBackupLogResponse(
  237. id=log.id,
  238. config_id=log.config_id,
  239. started_at=log.started_at,
  240. completed_at=log.completed_at,
  241. status=log.status,
  242. trigger=log.trigger,
  243. commit_sha=log.commit_sha,
  244. files_changed=log.files_changed,
  245. error_message=log.error_message,
  246. )
  247. for log in logs
  248. ]
  249. @router.delete("/logs")
  250. async def clear_logs(
  251. keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
  252. db: AsyncSession = Depends(get_db),
  253. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  254. ):
  255. """Clear backup logs, optionally keeping the most recent entries."""
  256. result = await db.execute(select(GitHubBackupConfig).limit(1))
  257. config = result.scalar_one_or_none()
  258. if not config:
  259. return {"deleted": 0, "message": "No configuration found"}
  260. if keep_last > 0:
  261. # Get IDs to keep
  262. keep_result = await db.execute(
  263. select(GitHubBackupLog.id)
  264. .where(GitHubBackupLog.config_id == config.id)
  265. .order_by(desc(GitHubBackupLog.started_at))
  266. .limit(keep_last)
  267. )
  268. keep_ids = [row[0] for row in keep_result.fetchall()]
  269. if keep_ids:
  270. delete_result = await db.execute(
  271. delete(GitHubBackupLog).where(
  272. GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)
  273. )
  274. )
  275. else:
  276. delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
  277. else:
  278. delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
  279. await db.commit()
  280. deleted_count = delete_result.rowcount
  281. logger.info("Deleted %s GitHub backup logs (kept %s)", deleted_count, keep_last)
  282. return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}