github_backup.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  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 logging
  6. from datetime import datetime, timedelta, timezone
  7. import httpx
  8. from sqlalchemy import desc, select
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.core.database import async_session
  11. from backend.app.models.archive import PrintArchive
  12. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  13. from backend.app.models.printer import Printer
  14. from backend.app.models.settings import Settings
  15. from backend.app.models.spool import Spool
  16. from backend.app.models.spool_usage_history import SpoolUsageHistory
  17. from backend.app.services.git_providers.factory import get_provider_backend
  18. from backend.app.services.printer_manager import printer_manager
  19. logger = logging.getLogger(__name__)
  20. # Schedule intervals in seconds
  21. SCHEDULE_INTERVALS = {
  22. "hourly": 3600,
  23. "daily": 86400,
  24. "weekly": 604800,
  25. }
  26. _PROVIDER_DISPLAY_NAMES = {
  27. "github": "GitHub",
  28. "gitlab": "GitLab",
  29. "gitea": "Gitea",
  30. "forgejo": "Forgejo",
  31. }
  32. class GitHubBackupService:
  33. """Service for backing up profiles to GitHub."""
  34. def __init__(self):
  35. self._scheduler_task: asyncio.Task | None = None
  36. self._check_interval = 60 # Check every minute for scheduled runs
  37. self._running_backup: bool = False
  38. self._backup_progress: str | None = None
  39. self._http_client: httpx.AsyncClient | None = None
  40. async def _get_client(self) -> httpx.AsyncClient:
  41. """Get or create HTTP client."""
  42. if self._http_client is None or self._http_client.is_closed:
  43. self._http_client = httpx.AsyncClient(timeout=60.0)
  44. return self._http_client
  45. async def start_scheduler(self):
  46. """Start the background scheduler loop."""
  47. if self._scheduler_task is not None:
  48. return
  49. logger.info("Starting GitHub backup scheduler")
  50. self._scheduler_task = asyncio.create_task(self._scheduler_loop())
  51. def stop_scheduler(self):
  52. """Stop the scheduler."""
  53. if self._scheduler_task:
  54. self._scheduler_task.cancel()
  55. self._scheduler_task = None
  56. logger.info("Stopped GitHub backup scheduler")
  57. async def _scheduler_loop(self):
  58. """Main scheduler loop - checks for due backups."""
  59. while True:
  60. try:
  61. await asyncio.sleep(self._check_interval)
  62. await self._check_scheduled_backups()
  63. except asyncio.CancelledError:
  64. break
  65. except Exception:
  66. logger.exception("Error in GitHub backup scheduler")
  67. await asyncio.sleep(60)
  68. async def _check_scheduled_backups(self):
  69. """Check if any scheduled backups are due."""
  70. async with async_session() as db:
  71. result = await db.execute(
  72. select(GitHubBackupConfig).where(
  73. GitHubBackupConfig.enabled == True, # noqa: E712
  74. GitHubBackupConfig.schedule_enabled == True, # noqa: E712
  75. )
  76. )
  77. configs = result.scalars().all()
  78. now = datetime.now(timezone.utc)
  79. for config in configs:
  80. # Handle both naive (from DB) and aware datetimes
  81. next_run = config.next_scheduled_run
  82. if next_run and next_run.tzinfo is None:
  83. next_run = next_run.replace(tzinfo=timezone.utc)
  84. if next_run and next_run <= now:
  85. logger.info("Running scheduled backup for config %s", config.id)
  86. await self.run_backup(config.id, trigger="scheduled")
  87. def calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
  88. """Calculate the next scheduled run time."""
  89. now = from_time or datetime.now(timezone.utc)
  90. interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
  91. return now + timedelta(seconds=interval)
  92. async def test_connection(self, repo_url: str, token: str, provider: str = "github") -> dict:
  93. """Test connection and permissions for the given provider."""
  94. backend = get_provider_backend(provider)
  95. client = await self._get_client()
  96. return await backend.test_connection(repo_url, token, client)
  97. async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
  98. """Run a backup operation.
  99. Args:
  100. config_id: ID of the backup configuration
  101. trigger: "manual" or "scheduled"
  102. Returns:
  103. dict with success, message, log_id, commit_sha, files_changed
  104. """
  105. if self._running_backup:
  106. return {"success": False, "message": "A backup is already running", "log_id": None}
  107. self._running_backup = True
  108. log_id = None
  109. try:
  110. async with async_session() as db:
  111. # Get config
  112. result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
  113. config = result.scalar_one_or_none()
  114. if not config:
  115. return {"success": False, "message": "Configuration not found", "log_id": None}
  116. if not config.enabled:
  117. return {"success": False, "message": "Backup is disabled", "log_id": None}
  118. # Defense in depth: re-verify the repo is private before each
  119. # push. The save endpoint already enforces this on every config
  120. # change, but a user can flip a repo from private to public in
  121. # GitHub's UI between configuration and the next scheduled run.
  122. test_result = await self.test_connection(
  123. config.repository_url, config.access_token, provider=config.provider
  124. )
  125. if not test_result.get("success") or test_result.get("is_private") is not True:
  126. visibility_note = (
  127. "the target repository is no longer private"
  128. if test_result.get("is_private") is False
  129. else "could not confirm the target repository is private"
  130. )
  131. abort_message = (
  132. f"Backup aborted: {visibility_note}. Bambuddy backups carry credentials "
  133. "and are refused for any non-private target. Make the repository private "
  134. "to resume scheduled backups."
  135. )
  136. log = GitHubBackupLog(
  137. config_id=config_id,
  138. status="failed",
  139. trigger=trigger,
  140. completed_at=datetime.now(timezone.utc),
  141. error_message=abort_message,
  142. )
  143. db.add(log)
  144. config.last_backup_at = datetime.now(timezone.utc)
  145. config.last_backup_status = "failed"
  146. config.last_backup_message = abort_message
  147. if config.schedule_enabled:
  148. config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
  149. await db.commit()
  150. await db.refresh(log)
  151. logger.warning(
  152. "Backup aborted for config %s: repo not private (is_private=%r, success=%r)",
  153. config_id,
  154. test_result.get("is_private"),
  155. test_result.get("success"),
  156. )
  157. return {
  158. "success": False,
  159. "message": abort_message,
  160. "log_id": log.id,
  161. }
  162. # Create log entry
  163. log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
  164. db.add(log)
  165. await db.commit()
  166. await db.refresh(log)
  167. log_id = log.id
  168. try:
  169. # Collect backup data
  170. self._backup_progress = "Collecting profiles..."
  171. backup_data = await self._collect_backup_data(db, config)
  172. if not backup_data:
  173. # No data to backup
  174. log.status = "skipped"
  175. log.completed_at = datetime.now(timezone.utc)
  176. log.error_message = "No data to backup"
  177. config.last_backup_at = datetime.now(timezone.utc)
  178. config.last_backup_status = "skipped"
  179. config.last_backup_message = "No data to backup"
  180. if config.schedule_enabled:
  181. config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
  182. await db.commit()
  183. return {
  184. "success": True,
  185. "message": "No data to backup",
  186. "log_id": log_id,
  187. "commit_sha": None,
  188. "files_changed": 0,
  189. }
  190. provider_name = _PROVIDER_DISPLAY_NAMES.get(config.provider, config.provider)
  191. self._backup_progress = f"Pushing to {provider_name}..."
  192. push_result = await self._push_to_provider(config, backup_data)
  193. # Update log and config
  194. log.status = push_result["status"]
  195. log.completed_at = datetime.now(timezone.utc)
  196. log.commit_sha = push_result.get("commit_sha")
  197. log.files_changed = push_result.get("files_changed", 0)
  198. log.error_message = push_result.get("error")
  199. config.last_backup_at = datetime.now(timezone.utc)
  200. config.last_backup_status = push_result["status"]
  201. config.last_backup_message = push_result.get("message", "")
  202. config.last_backup_commit_sha = push_result.get("commit_sha")
  203. if config.schedule_enabled:
  204. config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
  205. await db.commit()
  206. return {
  207. "success": push_result["status"] in ("success", "skipped"),
  208. "message": push_result.get("message", "Backup completed"),
  209. "log_id": log_id,
  210. "commit_sha": push_result.get("commit_sha"),
  211. "files_changed": push_result.get("files_changed", 0),
  212. }
  213. except Exception as e:
  214. logger.exception("Backup failed")
  215. log.status = "failed"
  216. log.completed_at = datetime.now(timezone.utc)
  217. log.error_message = str(e)
  218. config.last_backup_at = datetime.now(timezone.utc)
  219. config.last_backup_status = "failed"
  220. config.last_backup_message = str(e)
  221. if config.schedule_enabled:
  222. config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
  223. await db.commit()
  224. return {
  225. "success": False,
  226. "message": str(e),
  227. "log_id": log_id,
  228. "commit_sha": None,
  229. "files_changed": 0,
  230. }
  231. finally:
  232. self._running_backup = False
  233. self._backup_progress = None
  234. async def _collect_backup_data(self, db: AsyncSession, config: GitHubBackupConfig) -> dict:
  235. """Collect data to backup based on config settings.
  236. Returns dict with structure:
  237. {
  238. "backup_metadata.json": {...},
  239. "kprofiles/{serial}/{nozzle}.json": {...},
  240. "cloud_profiles/filament.json": [...],
  241. "cloud_profiles/printer.json": [...],
  242. "cloud_profiles/process.json": [...],
  243. "settings/app_settings.json": {...},
  244. }
  245. """
  246. files: dict[str, dict | list] = {}
  247. # Metadata file (no timestamps - git tracks file history)
  248. metadata = {
  249. "version": "1.0",
  250. "backup_type": "bambuddy_profiles",
  251. "contents": {
  252. "kprofiles": config.backup_kprofiles,
  253. "cloud_profiles": config.backup_cloud_profiles,
  254. "settings": config.backup_settings,
  255. "spools": config.backup_spools,
  256. "archives": config.backup_archives,
  257. },
  258. }
  259. files["backup_metadata.json"] = metadata
  260. # Collect K-profiles from all connected printers
  261. if config.backup_kprofiles:
  262. self._backup_progress = "Collecting K-profiles from printers..."
  263. await self._collect_kprofiles(db, files)
  264. # Collect cloud profiles
  265. if config.backup_cloud_profiles:
  266. self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
  267. await self._collect_cloud_profiles(db, files)
  268. # Collect app settings
  269. if config.backup_settings:
  270. self._backup_progress = "Collecting app settings..."
  271. await self._collect_settings(db, files)
  272. # Collect spool inventory
  273. if config.backup_spools:
  274. self._backup_progress = "Collecting spool inventory..."
  275. await self._collect_spools(db, files)
  276. # Collect print archives
  277. if config.backup_archives:
  278. self._backup_progress = "Collecting print archives..."
  279. await self._collect_archives(db, files)
  280. return files
  281. async def _collect_kprofiles(self, db: AsyncSession, files: dict):
  282. """Collect K-profiles from all connected printers."""
  283. result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
  284. printers = result.scalars().all()
  285. nozzle_diameters = ["0.2", "0.4", "0.6", "0.8"]
  286. for printer in printers:
  287. client = printer_manager.get_client(printer.id)
  288. if not client or not client.state.connected:
  289. continue
  290. serial = printer.serial_number
  291. printer_profiles = {}
  292. for nozzle in nozzle_diameters:
  293. try:
  294. profiles = await client.get_kprofiles(nozzle_diameter=nozzle)
  295. if profiles:
  296. profile_data = {
  297. "version": "1.0",
  298. "printer_name": printer.name,
  299. "printer_serial": serial,
  300. "nozzle_diameter": nozzle,
  301. "profiles": [
  302. {
  303. "slot_id": p.slot_id,
  304. "name": p.name,
  305. "k_value": p.k_value,
  306. "filament_id": p.filament_id,
  307. "nozzle_id": p.nozzle_id,
  308. "extruder_id": p.extruder_id,
  309. "setting_id": p.setting_id,
  310. "n_coef": p.n_coef,
  311. }
  312. for p in profiles
  313. ],
  314. }
  315. files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
  316. printer_profiles[nozzle] = len(profiles)
  317. except Exception as e:
  318. logger.warning("Failed to get K-profiles for printer %s nozzle %s: %s", serial, nozzle, e)
  319. if printer_profiles:
  320. logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
  321. async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
  322. """Collect Bambu Cloud profiles if authenticated."""
  323. # Backup runs without a user context, so fall back to the auth-disabled
  324. # Settings storage. ``build_authenticated_cloud`` honours the stored
  325. # region so China-region tokens are validated against api.bambulab.cn.
  326. from backend.app.api.routes.cloud import build_authenticated_cloud
  327. cloud = await build_authenticated_cloud(db, user=None)
  328. if cloud is None or not cloud.is_authenticated:
  329. if cloud is not None:
  330. await cloud.close()
  331. logger.info("Cloud not authenticated, skipping cloud profiles")
  332. return
  333. try:
  334. settings = await cloud.get_slicer_settings()
  335. if not settings:
  336. return
  337. # Separate by type
  338. filament_settings = []
  339. printer_settings = []
  340. process_settings = []
  341. for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
  342. setting_type = setting.get("type", "")
  343. if setting_type == "filament":
  344. filament_settings.append(setting)
  345. elif setting_type == "printer":
  346. printer_settings.append(setting)
  347. elif setting_type == "process":
  348. process_settings.append(setting)
  349. if filament_settings:
  350. files["cloud_profiles/filament.json"] = {
  351. "version": "1.0",
  352. "profiles": filament_settings,
  353. }
  354. if printer_settings:
  355. files["cloud_profiles/printer.json"] = {
  356. "version": "1.0",
  357. "profiles": printer_settings,
  358. }
  359. if process_settings:
  360. files["cloud_profiles/process.json"] = {
  361. "version": "1.0",
  362. "profiles": process_settings,
  363. }
  364. logger.info(
  365. "Collected cloud profiles: %d filament, %d printer, %d process",
  366. len(filament_settings),
  367. len(printer_settings),
  368. len(process_settings),
  369. )
  370. except Exception:
  371. logger.warning("Failed to collect cloud profiles", exc_info=True)
  372. finally:
  373. await cloud.close()
  374. async def _collect_settings(self, db: AsyncSession, files: dict):
  375. """Collect app settings."""
  376. result = await db.execute(select(Settings))
  377. settings = result.scalars().all()
  378. # Filter out sensitive settings
  379. sensitive_keys = {"bambu_cloud_token", "auth_secret_key"}
  380. settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
  381. files["settings/app_settings.json"] = {
  382. "version": "1.0",
  383. "settings": settings_data,
  384. }
  385. async def _collect_spools(self, db: AsyncSession, files: dict):
  386. """Collect spool inventory data."""
  387. result = await db.execute(select(Spool))
  388. spools = result.scalars().all()
  389. if not spools:
  390. return
  391. spool_list = []
  392. for s in spools:
  393. spool_data = {
  394. "id": s.id,
  395. "material": s.material,
  396. "subtype": s.subtype,
  397. "color_name": s.color_name,
  398. "rgba": s.rgba,
  399. "brand": s.brand,
  400. "label_weight": s.label_weight,
  401. "core_weight": s.core_weight,
  402. "weight_used": s.weight_used,
  403. "weight_locked": s.weight_locked,
  404. "slicer_filament": s.slicer_filament,
  405. "slicer_filament_name": s.slicer_filament_name,
  406. "nozzle_temp_min": s.nozzle_temp_min,
  407. "nozzle_temp_max": s.nozzle_temp_max,
  408. "note": s.note,
  409. "cost_per_kg": s.cost_per_kg,
  410. "tag_uid": s.tag_uid,
  411. "tray_uuid": s.tray_uuid,
  412. "data_origin": s.data_origin,
  413. "tag_type": s.tag_type,
  414. "archived_at": str(s.archived_at) if s.archived_at else None,
  415. "created_at": str(s.created_at) if s.created_at else None,
  416. }
  417. spool_list.append(spool_data)
  418. files["spools/inventory.json"] = {
  419. "version": "1.0",
  420. "spools": spool_list,
  421. }
  422. # Collect usage history
  423. usage_result = await db.execute(select(SpoolUsageHistory))
  424. usages = usage_result.scalars().all()
  425. if usages:
  426. usage_list = []
  427. for u in usages:
  428. usage_list.append(
  429. {
  430. "id": u.id,
  431. "spool_id": u.spool_id,
  432. "printer_id": u.printer_id,
  433. "print_name": u.print_name,
  434. "archive_id": u.archive_id,
  435. "weight_used": u.weight_used,
  436. "percent_used": u.percent_used,
  437. "status": u.status,
  438. "cost": u.cost,
  439. "created_at": str(u.created_at) if u.created_at else None,
  440. }
  441. )
  442. files["spools/usage_history.json"] = {
  443. "version": "1.0",
  444. "usage_history": usage_list,
  445. }
  446. logger.info("Collected %d spools and %d usage records", len(spool_list), len(usages))
  447. async def _collect_archives(self, db: AsyncSession, files: dict):
  448. """Collect print archive metadata (no binary files)."""
  449. result = await db.execute(select(PrintArchive))
  450. archives = result.scalars().all()
  451. if not archives:
  452. return
  453. archive_list = []
  454. for a in archives:
  455. archive_data = {
  456. "id": a.id,
  457. "printer_id": a.printer_id,
  458. "project_id": a.project_id,
  459. "filename": a.filename,
  460. "file_size": a.file_size,
  461. "content_hash": a.content_hash,
  462. "print_name": a.print_name,
  463. "print_time_seconds": a.print_time_seconds,
  464. "filament_used_grams": a.filament_used_grams,
  465. "filament_type": a.filament_type,
  466. "filament_color": a.filament_color,
  467. "layer_height": a.layer_height,
  468. "total_layers": a.total_layers,
  469. "nozzle_diameter": a.nozzle_diameter,
  470. "bed_temperature": a.bed_temperature,
  471. "nozzle_temperature": a.nozzle_temperature,
  472. "sliced_for_model": a.sliced_for_model,
  473. "status": a.status,
  474. "started_at": str(a.started_at) if a.started_at else None,
  475. "completed_at": str(a.completed_at) if a.completed_at else None,
  476. "makerworld_url": a.makerworld_url,
  477. "designer": a.designer,
  478. "external_url": a.external_url,
  479. "is_favorite": a.is_favorite,
  480. "tags": a.tags,
  481. "notes": a.notes,
  482. "cost": a.cost,
  483. "failure_reason": a.failure_reason,
  484. "quantity": a.quantity,
  485. "energy_kwh": a.energy_kwh,
  486. "energy_cost": a.energy_cost,
  487. "created_at": str(a.created_at) if a.created_at else None,
  488. }
  489. archive_list.append(archive_data)
  490. files["archives/print_history.json"] = {
  491. "version": "1.0",
  492. "archives": archive_list,
  493. }
  494. logger.info("Collected %d print archives", len(archive_list))
  495. async def _push_to_provider(self, config: GitHubBackupConfig, files: dict) -> dict:
  496. """Push files to the configured Git provider."""
  497. backend = get_provider_backend(config.provider)
  498. client = await self._get_client()
  499. return await backend.push_files(
  500. repo_url=config.repository_url,
  501. token=config.access_token,
  502. branch=config.branch,
  503. files=files,
  504. client=client,
  505. )
  506. @property
  507. def is_running(self) -> bool:
  508. """Check if a backup is currently running."""
  509. return self._running_backup
  510. @property
  511. def progress(self) -> str | None:
  512. """Get current backup progress message."""
  513. return self._backup_progress
  514. async def get_logs(self, config_id: int, limit: int = 50, offset: int = 0) -> list[GitHubBackupLog]:
  515. """Get backup logs for a configuration."""
  516. async with async_session() as db:
  517. result = await db.execute(
  518. select(GitHubBackupLog)
  519. .where(GitHubBackupLog.config_id == config_id)
  520. .order_by(desc(GitHubBackupLog.started_at))
  521. .offset(offset)
  522. .limit(limit)
  523. )
  524. return list(result.scalars().all())
  525. # Singleton instance
  526. github_backup_service = GitHubBackupService()