github_backup.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883
  1. """GitHub backup service for printer profiles.
  2. Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitHub.
  3. """
  4. import asyncio
  5. import base64
  6. import hashlib
  7. import json
  8. import logging
  9. import re
  10. from datetime import datetime, timedelta, timezone
  11. import httpx
  12. from sqlalchemy import desc, select
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.core.database import async_session
  15. from backend.app.models.archive import PrintArchive
  16. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  17. from backend.app.models.printer import Printer
  18. from backend.app.models.settings import Settings
  19. from backend.app.models.spool import Spool
  20. from backend.app.models.spool_usage_history import SpoolUsageHistory
  21. from backend.app.services.printer_manager import printer_manager
  22. logger = logging.getLogger(__name__)
  23. # Schedule intervals in seconds
  24. SCHEDULE_INTERVALS = {
  25. "hourly": 3600,
  26. "daily": 86400,
  27. "weekly": 604800,
  28. }
  29. class GitHubBackupService:
  30. """Service for backing up profiles to GitHub."""
  31. def __init__(self):
  32. self._scheduler_task: asyncio.Task | None = None
  33. self._check_interval = 60 # Check every minute for scheduled runs
  34. self._running_backup: bool = False
  35. self._backup_progress: str | None = None
  36. self._http_client: httpx.AsyncClient | None = None
  37. async def _get_client(self) -> httpx.AsyncClient:
  38. """Get or create HTTP client."""
  39. if self._http_client is None or self._http_client.is_closed:
  40. self._http_client = httpx.AsyncClient(timeout=60.0)
  41. return self._http_client
  42. async def start_scheduler(self):
  43. """Start the background scheduler loop."""
  44. if self._scheduler_task is not None:
  45. return
  46. logger.info("Starting GitHub backup scheduler")
  47. self._scheduler_task = asyncio.create_task(self._scheduler_loop())
  48. def stop_scheduler(self):
  49. """Stop the scheduler."""
  50. if self._scheduler_task:
  51. self._scheduler_task.cancel()
  52. self._scheduler_task = None
  53. logger.info("Stopped GitHub backup scheduler")
  54. async def _scheduler_loop(self):
  55. """Main scheduler loop - checks for due backups."""
  56. while True:
  57. try:
  58. await asyncio.sleep(self._check_interval)
  59. await self._check_scheduled_backups()
  60. except asyncio.CancelledError:
  61. break
  62. except Exception as e:
  63. logger.error("Error in GitHub backup scheduler: %s", e)
  64. await asyncio.sleep(60)
  65. async def _check_scheduled_backups(self):
  66. """Check if any scheduled backups are due."""
  67. async with async_session() as db:
  68. result = await db.execute(
  69. select(GitHubBackupConfig).where(
  70. GitHubBackupConfig.enabled == True, # noqa: E712
  71. GitHubBackupConfig.schedule_enabled == True, # noqa: E712
  72. )
  73. )
  74. configs = result.scalars().all()
  75. now = datetime.now(timezone.utc)
  76. for config in configs:
  77. # Handle both naive (from DB) and aware datetimes
  78. next_run = config.next_scheduled_run
  79. if next_run and next_run.tzinfo is None:
  80. next_run = next_run.replace(tzinfo=timezone.utc)
  81. if next_run and next_run <= now:
  82. logger.info("Running scheduled backup for config %s", config.id)
  83. await self.run_backup(config.id, trigger="scheduled")
  84. def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
  85. """Calculate the next scheduled run time."""
  86. now = from_time or datetime.now(timezone.utc)
  87. interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
  88. return now + timedelta(seconds=interval)
  89. async def test_connection(self, repo_url: str, token: str) -> dict:
  90. """Test GitHub connection and permissions.
  91. Args:
  92. repo_url: GitHub repository URL
  93. token: Personal Access Token
  94. Returns:
  95. dict with success, message, repo_name, permissions
  96. """
  97. try:
  98. owner, repo = self._parse_repo_url(repo_url)
  99. client = await self._get_client()
  100. # Test API access
  101. response = await client.get(
  102. f"https://api.github.com/repos/{owner}/{repo}",
  103. headers={
  104. "Authorization": f"token {token}",
  105. "Accept": "application/vnd.github.v3+json",
  106. "User-Agent": "Bambuddy-Backup",
  107. },
  108. )
  109. if response.status_code == 401:
  110. return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
  111. if response.status_code == 404:
  112. return {
  113. "success": False,
  114. "message": "Repository not found. Check URL and token permissions.",
  115. "repo_name": None,
  116. "permissions": None,
  117. }
  118. if response.status_code != 200:
  119. return {
  120. "success": False,
  121. "message": f"GitHub API error: {response.status_code}",
  122. "repo_name": None,
  123. "permissions": None,
  124. }
  125. data = response.json()
  126. permissions = data.get("permissions", {})
  127. # Check for push permission
  128. if not permissions.get("push", False):
  129. return {
  130. "success": False,
  131. "message": "Token does not have push permission to this repository",
  132. "repo_name": data.get("full_name"),
  133. "permissions": permissions,
  134. }
  135. return {
  136. "success": True,
  137. "message": "Connection successful",
  138. "repo_name": data.get("full_name"),
  139. "permissions": permissions,
  140. }
  141. except Exception as e:
  142. logger.error("GitHub connection test failed: %s", e)
  143. # Sanitize error - don't expose internal details
  144. error_type = type(e).__name__
  145. return {
  146. "success": False,
  147. "message": f"Connection failed: {error_type}",
  148. "repo_name": None,
  149. "permissions": None,
  150. }
  151. def _parse_repo_url(self, url: str) -> tuple[str, str]:
  152. """Parse owner and repo from GitHub URL."""
  153. # Limit URL length to prevent ReDoS attacks
  154. if not url or len(url) > 500:
  155. raise ValueError("Invalid GitHub URL: URL too long or empty")
  156. # Handle HTTPS URLs - use atomic groups via limited character classes
  157. # GitHub usernames: 1-39 chars, alphanumeric and hyphens
  158. # Repo names: 1-100 chars, alphanumeric, hyphens, underscores, dots
  159. match = re.match(r"https://github\.com/([\w-]{1,39})/([\w.\-]{1,100})(?:\.git)?/?$", url)
  160. if match:
  161. return match.group(1), match.group(2)
  162. # Handle SSH URLs
  163. match = re.match(r"git@github\.com:([\w-]{1,39})/([\w.\-]{1,100})(?:\.git)?$", url)
  164. if match:
  165. return match.group(1), match.group(2)
  166. raise ValueError(f"Invalid GitHub URL: {url}")
  167. async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
  168. """Run a backup operation.
  169. Args:
  170. config_id: ID of the backup configuration
  171. trigger: "manual" or "scheduled"
  172. Returns:
  173. dict with success, message, log_id, commit_sha, files_changed
  174. """
  175. if self._running_backup:
  176. return {"success": False, "message": "A backup is already running", "log_id": None}
  177. self._running_backup = True
  178. log_id = None
  179. try:
  180. async with async_session() as db:
  181. # Get config
  182. result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
  183. config = result.scalar_one_or_none()
  184. if not config:
  185. return {"success": False, "message": "Configuration not found", "log_id": None}
  186. if not config.enabled:
  187. return {"success": False, "message": "Backup is disabled", "log_id": None}
  188. # Create log entry
  189. log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
  190. db.add(log)
  191. await db.commit()
  192. await db.refresh(log)
  193. log_id = log.id
  194. try:
  195. # Collect backup data
  196. self._backup_progress = "Collecting profiles..."
  197. backup_data = await self._collect_backup_data(db, config)
  198. if not backup_data:
  199. # No data to backup
  200. log.status = "skipped"
  201. log.completed_at = datetime.now(timezone.utc)
  202. log.error_message = "No data to backup"
  203. config.last_backup_at = datetime.now(timezone.utc)
  204. config.last_backup_status = "skipped"
  205. config.last_backup_message = "No data to backup"
  206. if config.schedule_enabled:
  207. config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
  208. await db.commit()
  209. return {
  210. "success": True,
  211. "message": "No data to backup",
  212. "log_id": log_id,
  213. "commit_sha": None,
  214. "files_changed": 0,
  215. }
  216. # Push to GitHub
  217. self._backup_progress = "Pushing to GitHub..."
  218. push_result = await self._push_to_github(config, backup_data)
  219. # Update log and config
  220. log.status = push_result["status"]
  221. log.completed_at = datetime.now(timezone.utc)
  222. log.commit_sha = push_result.get("commit_sha")
  223. log.files_changed = push_result.get("files_changed", 0)
  224. log.error_message = push_result.get("error")
  225. config.last_backup_at = datetime.now(timezone.utc)
  226. config.last_backup_status = push_result["status"]
  227. config.last_backup_message = push_result.get("message", "")
  228. config.last_backup_commit_sha = push_result.get("commit_sha")
  229. if config.schedule_enabled:
  230. config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
  231. await db.commit()
  232. return {
  233. "success": push_result["status"] in ("success", "skipped"),
  234. "message": push_result.get("message", "Backup completed"),
  235. "log_id": log_id,
  236. "commit_sha": push_result.get("commit_sha"),
  237. "files_changed": push_result.get("files_changed", 0),
  238. }
  239. except Exception as e:
  240. logger.error("Backup failed: %s", e)
  241. log.status = "failed"
  242. log.completed_at = datetime.now(timezone.utc)
  243. log.error_message = str(e)
  244. config.last_backup_at = datetime.now(timezone.utc)
  245. config.last_backup_status = "failed"
  246. config.last_backup_message = str(e)
  247. if config.schedule_enabled:
  248. config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
  249. await db.commit()
  250. return {
  251. "success": False,
  252. "message": str(e),
  253. "log_id": log_id,
  254. "commit_sha": None,
  255. "files_changed": 0,
  256. }
  257. finally:
  258. self._running_backup = False
  259. self._backup_progress = None
  260. async def _collect_backup_data(self, db: AsyncSession, config: GitHubBackupConfig) -> dict:
  261. """Collect data to backup based on config settings.
  262. Returns dict with structure:
  263. {
  264. "backup_metadata.json": {...},
  265. "kprofiles/{serial}/{nozzle}.json": {...},
  266. "cloud_profiles/filament.json": [...],
  267. "cloud_profiles/printer.json": [...],
  268. "cloud_profiles/process.json": [...],
  269. "settings/app_settings.json": {...},
  270. }
  271. """
  272. files: dict[str, dict | list] = {}
  273. # Metadata file (no timestamps - git tracks file history)
  274. metadata = {
  275. "version": "1.0",
  276. "backup_type": "bambuddy_profiles",
  277. "contents": {
  278. "kprofiles": config.backup_kprofiles,
  279. "cloud_profiles": config.backup_cloud_profiles,
  280. "settings": config.backup_settings,
  281. "spools": config.backup_spools,
  282. "archives": config.backup_archives,
  283. },
  284. }
  285. files["backup_metadata.json"] = metadata
  286. # Collect K-profiles from all connected printers
  287. if config.backup_kprofiles:
  288. self._backup_progress = "Collecting K-profiles from printers..."
  289. await self._collect_kprofiles(db, files)
  290. # Collect cloud profiles
  291. if config.backup_cloud_profiles:
  292. self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
  293. await self._collect_cloud_profiles(db, files)
  294. # Collect app settings
  295. if config.backup_settings:
  296. self._backup_progress = "Collecting app settings..."
  297. await self._collect_settings(db, files)
  298. # Collect spool inventory
  299. if config.backup_spools:
  300. self._backup_progress = "Collecting spool inventory..."
  301. await self._collect_spools(db, files)
  302. # Collect print archives
  303. if config.backup_archives:
  304. self._backup_progress = "Collecting print archives..."
  305. await self._collect_archives(db, files)
  306. return files
  307. async def _collect_kprofiles(self, db: AsyncSession, files: dict):
  308. """Collect K-profiles from all connected printers."""
  309. result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
  310. printers = result.scalars().all()
  311. nozzle_diameters = ["0.2", "0.4", "0.6", "0.8"]
  312. for printer in printers:
  313. client = printer_manager.get_client(printer.id)
  314. if not client or not client.state.connected:
  315. continue
  316. serial = printer.serial_number
  317. printer_profiles = {}
  318. for nozzle in nozzle_diameters:
  319. try:
  320. profiles = await client.get_kprofiles(nozzle_diameter=nozzle)
  321. if profiles:
  322. profile_data = {
  323. "version": "1.0",
  324. "printer_name": printer.name,
  325. "printer_serial": serial,
  326. "nozzle_diameter": nozzle,
  327. "profiles": [
  328. {
  329. "slot_id": p.slot_id,
  330. "name": p.name,
  331. "k_value": p.k_value,
  332. "filament_id": p.filament_id,
  333. "nozzle_id": p.nozzle_id,
  334. "extruder_id": p.extruder_id,
  335. "setting_id": p.setting_id,
  336. "n_coef": p.n_coef,
  337. }
  338. for p in profiles
  339. ],
  340. }
  341. files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
  342. printer_profiles[nozzle] = len(profiles)
  343. except Exception as e:
  344. logger.warning("Failed to get K-profiles for printer %s nozzle %s: %s", serial, nozzle, e)
  345. if printer_profiles:
  346. logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
  347. async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
  348. """Collect Bambu Cloud profiles if authenticated."""
  349. # Backup runs without a user context, so fall back to the auth-disabled
  350. # Settings storage. ``build_authenticated_cloud`` honours the stored
  351. # region so China-region tokens are validated against api.bambulab.cn.
  352. from backend.app.api.routes.cloud import build_authenticated_cloud
  353. cloud = await build_authenticated_cloud(db, user=None)
  354. if cloud is None or not cloud.is_authenticated:
  355. if cloud is not None:
  356. await cloud.close()
  357. logger.info("Cloud not authenticated, skipping cloud profiles")
  358. return
  359. try:
  360. settings = await cloud.get_slicer_settings()
  361. if not settings:
  362. return
  363. # Separate by type
  364. filament_settings = []
  365. printer_settings = []
  366. process_settings = []
  367. for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
  368. setting_type = setting.get("type", "")
  369. if setting_type == "filament":
  370. filament_settings.append(setting)
  371. elif setting_type == "printer":
  372. printer_settings.append(setting)
  373. elif setting_type == "process":
  374. process_settings.append(setting)
  375. if filament_settings:
  376. files["cloud_profiles/filament.json"] = {
  377. "version": "1.0",
  378. "profiles": filament_settings,
  379. }
  380. if printer_settings:
  381. files["cloud_profiles/printer.json"] = {
  382. "version": "1.0",
  383. "profiles": printer_settings,
  384. }
  385. if process_settings:
  386. files["cloud_profiles/process.json"] = {
  387. "version": "1.0",
  388. "profiles": process_settings,
  389. }
  390. logger.info(
  391. f"Collected cloud profiles: {len(filament_settings)} filament, "
  392. f"{len(printer_settings)} printer, {len(process_settings)} process"
  393. )
  394. except Exception as e:
  395. logger.warning("Failed to collect cloud profiles: %s", e)
  396. finally:
  397. await cloud.close()
  398. async def _collect_settings(self, db: AsyncSession, files: dict):
  399. """Collect app settings."""
  400. result = await db.execute(select(Settings))
  401. settings = result.scalars().all()
  402. # Filter out sensitive settings
  403. sensitive_keys = {"bambu_cloud_token", "auth_secret_key"}
  404. settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
  405. files["settings/app_settings.json"] = {
  406. "version": "1.0",
  407. "settings": settings_data,
  408. }
  409. async def _collect_spools(self, db: AsyncSession, files: dict):
  410. """Collect spool inventory data."""
  411. result = await db.execute(select(Spool))
  412. spools = result.scalars().all()
  413. if not spools:
  414. return
  415. spool_list = []
  416. for s in spools:
  417. spool_data = {
  418. "id": s.id,
  419. "material": s.material,
  420. "subtype": s.subtype,
  421. "color_name": s.color_name,
  422. "rgba": s.rgba,
  423. "brand": s.brand,
  424. "label_weight": s.label_weight,
  425. "core_weight": s.core_weight,
  426. "weight_used": s.weight_used,
  427. "weight_locked": s.weight_locked,
  428. "slicer_filament": s.slicer_filament,
  429. "slicer_filament_name": s.slicer_filament_name,
  430. "nozzle_temp_min": s.nozzle_temp_min,
  431. "nozzle_temp_max": s.nozzle_temp_max,
  432. "note": s.note,
  433. "cost_per_kg": s.cost_per_kg,
  434. "tag_uid": s.tag_uid,
  435. "tray_uuid": s.tray_uuid,
  436. "data_origin": s.data_origin,
  437. "tag_type": s.tag_type,
  438. "archived_at": str(s.archived_at) if s.archived_at else None,
  439. "created_at": str(s.created_at) if s.created_at else None,
  440. }
  441. spool_list.append(spool_data)
  442. files["spools/inventory.json"] = {
  443. "version": "1.0",
  444. "spools": spool_list,
  445. }
  446. # Collect usage history
  447. usage_result = await db.execute(select(SpoolUsageHistory))
  448. usages = usage_result.scalars().all()
  449. if usages:
  450. usage_list = []
  451. for u in usages:
  452. usage_list.append(
  453. {
  454. "id": u.id,
  455. "spool_id": u.spool_id,
  456. "printer_id": u.printer_id,
  457. "print_name": u.print_name,
  458. "archive_id": u.archive_id,
  459. "weight_used": u.weight_used,
  460. "percent_used": u.percent_used,
  461. "status": u.status,
  462. "cost": u.cost,
  463. "created_at": str(u.created_at) if u.created_at else None,
  464. }
  465. )
  466. files["spools/usage_history.json"] = {
  467. "version": "1.0",
  468. "usage_history": usage_list,
  469. }
  470. logger.info("Collected %d spools and %d usage records", len(spool_list), len(usages))
  471. async def _collect_archives(self, db: AsyncSession, files: dict):
  472. """Collect print archive metadata (no binary files)."""
  473. result = await db.execute(select(PrintArchive))
  474. archives = result.scalars().all()
  475. if not archives:
  476. return
  477. archive_list = []
  478. for a in archives:
  479. archive_data = {
  480. "id": a.id,
  481. "printer_id": a.printer_id,
  482. "project_id": a.project_id,
  483. "filename": a.filename,
  484. "file_size": a.file_size,
  485. "content_hash": a.content_hash,
  486. "print_name": a.print_name,
  487. "print_time_seconds": a.print_time_seconds,
  488. "filament_used_grams": a.filament_used_grams,
  489. "filament_type": a.filament_type,
  490. "filament_color": a.filament_color,
  491. "layer_height": a.layer_height,
  492. "total_layers": a.total_layers,
  493. "nozzle_diameter": a.nozzle_diameter,
  494. "bed_temperature": a.bed_temperature,
  495. "nozzle_temperature": a.nozzle_temperature,
  496. "sliced_for_model": a.sliced_for_model,
  497. "status": a.status,
  498. "started_at": str(a.started_at) if a.started_at else None,
  499. "completed_at": str(a.completed_at) if a.completed_at else None,
  500. "makerworld_url": a.makerworld_url,
  501. "designer": a.designer,
  502. "external_url": a.external_url,
  503. "is_favorite": a.is_favorite,
  504. "tags": a.tags,
  505. "notes": a.notes,
  506. "cost": a.cost,
  507. "failure_reason": a.failure_reason,
  508. "quantity": a.quantity,
  509. "energy_kwh": a.energy_kwh,
  510. "energy_cost": a.energy_cost,
  511. "created_at": str(a.created_at) if a.created_at else None,
  512. }
  513. archive_list.append(archive_data)
  514. files["archives/print_history.json"] = {
  515. "version": "1.0",
  516. "archives": archive_list,
  517. }
  518. logger.info("Collected %d print archives", len(archive_list))
  519. async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:
  520. """Push files to GitHub using the GitHub API.
  521. Uses the Git Data API to create blobs, tree, and commit.
  522. Returns:
  523. dict with status, message, commit_sha, files_changed
  524. """
  525. try:
  526. owner, repo = self._parse_repo_url(config.repository_url)
  527. branch = config.branch
  528. client = await self._get_client()
  529. headers = {
  530. "Authorization": f"token {config.access_token}",
  531. "Accept": "application/vnd.github.v3+json",
  532. "User-Agent": "Bambuddy-Backup",
  533. }
  534. # Get current branch reference
  535. ref_response = await client.get(
  536. f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers
  537. )
  538. if ref_response.status_code == 404:
  539. # Branch doesn't exist, need to create it from default branch
  540. return await self._create_branch_and_push(client, headers, owner, repo, branch, files)
  541. if ref_response.status_code != 200:
  542. return {
  543. "status": "failed",
  544. "message": f"Failed to get branch ref: {ref_response.status_code}",
  545. "error": ref_response.text,
  546. }
  547. ref_data = ref_response.json()
  548. current_commit_sha = ref_data["object"]["sha"]
  549. # Get the current tree
  550. commit_response = await client.get(
  551. f"https://api.github.com/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
  552. )
  553. if commit_response.status_code != 200:
  554. return {"status": "failed", "message": "Failed to get current commit"}
  555. current_tree_sha = commit_response.json()["tree"]["sha"]
  556. # Get existing files to check for changes
  557. tree_response = await client.get(
  558. f"https://api.github.com/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
  559. )
  560. existing_files = {}
  561. if tree_response.status_code == 200:
  562. for item in tree_response.json().get("tree", []):
  563. if item["type"] == "blob":
  564. existing_files[item["path"]] = item["sha"]
  565. # Create blobs for changed files
  566. tree_items = []
  567. files_changed = 0
  568. for path, content in files.items():
  569. content_str = json.dumps(content, indent=2, default=str)
  570. content_bytes = content_str.encode("utf-8")
  571. content_sha = hashlib.sha1(
  572. f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False
  573. ).hexdigest()
  574. # Skip if file hasn't changed
  575. if path in existing_files and existing_files[path] == content_sha:
  576. continue
  577. # Create blob
  578. blob_response = await client.post(
  579. f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
  580. headers=headers,
  581. json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
  582. )
  583. if blob_response.status_code != 201:
  584. logger.error("Failed to create blob for %s: %s", path, blob_response.text)
  585. continue
  586. blob_sha = blob_response.json()["sha"]
  587. tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
  588. files_changed += 1
  589. if not tree_items:
  590. return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
  591. # Create new tree
  592. tree_response = await client.post(
  593. f"https://api.github.com/repos/{owner}/{repo}/git/trees",
  594. headers=headers,
  595. json={"base_tree": current_tree_sha, "tree": tree_items},
  596. )
  597. if tree_response.status_code != 201:
  598. return {"status": "failed", "message": f"Failed to create tree: {tree_response.text}"}
  599. new_tree_sha = tree_response.json()["sha"]
  600. # Create commit
  601. commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
  602. commit_response = await client.post(
  603. f"https://api.github.com/repos/{owner}/{repo}/git/commits",
  604. headers=headers,
  605. json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
  606. )
  607. if commit_response.status_code != 201:
  608. return {"status": "failed", "message": f"Failed to create commit: {commit_response.text}"}
  609. new_commit_sha = commit_response.json()["sha"]
  610. # Update branch reference
  611. ref_update = await client.patch(
  612. f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}",
  613. headers=headers,
  614. json={"sha": new_commit_sha},
  615. )
  616. if ref_update.status_code != 200:
  617. return {"status": "failed", "message": f"Failed to update branch: {ref_update.text}"}
  618. return {
  619. "status": "success",
  620. "message": f"Backup successful - {files_changed} files updated",
  621. "commit_sha": new_commit_sha,
  622. "files_changed": files_changed,
  623. }
  624. except Exception as e:
  625. logger.error("Push to GitHub failed: %s", e)
  626. return {"status": "failed", "message": str(e), "error": str(e)}
  627. async def _create_branch_and_push(
  628. self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
  629. ) -> dict:
  630. """Create a new branch and push files when branch doesn't exist."""
  631. try:
  632. # Get default branch
  633. repo_response = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
  634. if repo_response.status_code != 200:
  635. return {"status": "failed", "message": "Failed to get repo info"}
  636. default_branch = repo_response.json().get("default_branch", "main")
  637. # Get default branch ref
  638. ref_response = await client.get(
  639. f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
  640. )
  641. if ref_response.status_code != 200:
  642. # Empty repo - create initial commit
  643. return await self._create_initial_commit(client, headers, owner, repo, branch, files)
  644. base_sha = ref_response.json()["object"]["sha"]
  645. # Create new branch
  646. create_ref = await client.post(
  647. f"https://api.github.com/repos/{owner}/{repo}/git/refs",
  648. headers=headers,
  649. json={"ref": f"refs/heads/{branch}", "sha": base_sha},
  650. )
  651. if create_ref.status_code != 201:
  652. return {"status": "failed", "message": f"Failed to create branch: {create_ref.text}"}
  653. # Now push to the new branch (recursive call will find the branch)
  654. return await self._push_to_github(
  655. type(
  656. "Config",
  657. (),
  658. {
  659. "repository_url": f"https://github.com/{owner}/{repo}",
  660. "access_token": headers["Authorization"].replace("token ", ""),
  661. "branch": branch,
  662. },
  663. )(),
  664. files,
  665. )
  666. except Exception as e:
  667. return {"status": "failed", "message": str(e)}
  668. async def _create_initial_commit(
  669. self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
  670. ) -> dict:
  671. """Create initial commit in an empty repository."""
  672. try:
  673. # Create blobs
  674. tree_items = []
  675. for path, content in files.items():
  676. content_str = json.dumps(content, indent=2, default=str)
  677. blob_response = await client.post(
  678. f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
  679. headers=headers,
  680. json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
  681. )
  682. if blob_response.status_code == 201:
  683. tree_items.append(
  684. {"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
  685. )
  686. # Create tree
  687. tree_response = await client.post(
  688. f"https://api.github.com/repos/{owner}/{repo}/git/trees",
  689. headers=headers,
  690. json={"tree": tree_items},
  691. )
  692. if tree_response.status_code != 201:
  693. return {"status": "failed", "message": "Failed to create tree"}
  694. tree_sha = tree_response.json()["sha"]
  695. # Create commit (no parents for initial)
  696. commit_response = await client.post(
  697. f"https://api.github.com/repos/{owner}/{repo}/git/commits",
  698. headers=headers,
  699. json={
  700. "message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
  701. "tree": tree_sha,
  702. },
  703. )
  704. if commit_response.status_code != 201:
  705. return {"status": "failed", "message": "Failed to create commit"}
  706. commit_sha = commit_response.json()["sha"]
  707. # Create branch ref
  708. ref_response = await client.post(
  709. f"https://api.github.com/repos/{owner}/{repo}/git/refs",
  710. headers=headers,
  711. json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
  712. )
  713. if ref_response.status_code != 201:
  714. return {"status": "failed", "message": "Failed to create branch ref"}
  715. return {
  716. "status": "success",
  717. "message": f"Initial backup created - {len(files)} files",
  718. "commit_sha": commit_sha,
  719. "files_changed": len(files),
  720. }
  721. except Exception as e:
  722. return {"status": "failed", "message": str(e)}
  723. @property
  724. def is_running(self) -> bool:
  725. """Check if a backup is currently running."""
  726. return self._running_backup
  727. @property
  728. def progress(self) -> str | None:
  729. """Get current backup progress message."""
  730. return self._backup_progress
  731. async def get_logs(self, config_id: int, limit: int = 50, offset: int = 0) -> list[GitHubBackupLog]:
  732. """Get backup logs for a configuration."""
  733. async with async_session() as db:
  734. result = await db.execute(
  735. select(GitHubBackupLog)
  736. .where(GitHubBackupLog.config_id == config_id)
  737. .order_by(desc(GitHubBackupLog.started_at))
  738. .offset(offset)
  739. .limit(limit)
  740. )
  741. return list(result.scalars().all())
  742. # Singleton instance
  743. github_backup_service = GitHubBackupService()